Skip to content

Latest commit

 

History

History
400 lines (318 loc) · 170 KB

File metadata and controls

400 lines (318 loc) · 170 KB

Refactor Phase 3 — 后台常驻、全局定时任务、助理心跳与通知(已完成归档)

历史归档。本文件由 active/refactor-closeout.md 在 2026-05-10 拆出,对应 Phase 3(后台常驻、全局定时任务、助理心跳与通知)的全部计划文本与决策日志,并合并入这条主线进行期间发生的 dev-server 内存收口(Sentry dev guard / RunCockpit 数据层拆分 / Settings route-level split / AppShell Phase A)—— 这些都不是独立 Phase,但在 Phase 3 时间窗口内提交。 Step 1-3 完成时间:2026-05-09(Step 1 现状审计 ✅;Step 2 菜单栏常驻 + 本机通知 ✅;Step 3 通知与任务触发闭环 ✅,含 v6 5 件功能修复 + v7 SQLite/Map/类型清理) Step 4 / Step 5 完成时间:2026-05-10(v9 = Settings → Assistant 任务列表搬走轻入口;v10 = 心跳文案诚实化"打开新对话时触发,不是后台定时任务") 当前总控板:active/refactor-closeout.md

Phase 3 用户结果(最终交付)

  • 关闭主窗口仅隐藏到 macOS 菜单栏,scheduler 与本机通知继续跑;只有 Tray「退出 CodePilot」才真正停止进程。
  • reminder 任务到点直接弹本机通知,不依赖 AI provider——"5 分钟后提醒喝水"无 provider 也工作;ai_task 类才走 provider 跑模型。
  • 同日的短间隔提醒和"立即运行"按钮按预期工作getDueTasksdatetime() 比较 bug 修掉;runScheduledTaskNow 走行级锁直接 fire。
  • 本机通知与 Bridge 解耦:未配置 Bridge 也能弹本机通知;delivery log 显式可视化 5 状态(queued / delivered / error / not_configured / skipped),区分"通道未配置"vs"远端失败"vs"成功送达"。
  • Settings → 定时任务 是真的能用的全局任务页:列表 + 立即运行 + 暂停 + 删除 + 展开看 delivery log;"新建任务"按钮 prefill 跳 chat,让 AI 通过 codepilot_schedule_task MCP/tool 创建。
  • 任务 → run row → notification event → delivery 一根链贯通task_run_logs.notification_event_id join 出伞事件 + N 条 deliveries,用户能在任务详情里看到每条 channel 的真实送达状态。
  • dev-server 内存收口@sentry/node + @opentelemetry/* 在 dev 不再初始化(−127 MB ready baseline);Settings 改 route-level split + AppShell 6 个条件组件 lazy 化(prod bundle 收益,dev RSS 由 Turbopack floor 决定,不再继续盲拆)。

计划文本

Phase 3:后台常驻、全局定时任务、助理心跳与通知

用户会看到什么

  • 不再看到“设置里有开关,但任务永远不会触发”的功能。
  • 关闭主窗口不等于退出:CodePilot 会留在 macOS 菜单栏,只有用户点“退出”才真正停止。
  • 定时任务是整个 CodePilot 的通用能力,不是只属于助理设置页;提醒、助理、未来项目自动化都复用同一套调度与通知。
  • 如果定时任务可用,用户能创建任务、看到下次执行时间、收到本机系统通知;配置 Bridge 后可以额外收到远端通知。
  • 心跳是助理的状态检查机制,不等于通用定时任务,也不应该被包装成后台 scheduler。
  • 如果后台能力不足,界面会明确说明限制,而不是假装已经启用。

工程要做什么

  • 先审计现有助理、定时任务、心跳、通知、Bridge 的真实状态。
  • 把 Electron 生命周期改成菜单栏常驻:关闭窗口只隐藏,scheduler 和本机通知链路继续运行;显式“退出”才停止。
  • 把本机 macOS 通知与 Bridge 解耦:Bridge 只是 Telegram / 飞书 / QQ 等远端增强通道,不是本机提醒的前置条件。
  • 把定时任务抽象成全局能力:任务来源可以是用户提醒、助理、未来项目自动化,但底层 scheduler / delivery log / 状态枚举统一。
  • 把心跳从定时任务中拆出来:它是助理工作区的健康检查 / 主动问候策略,状态和文案必须单独表达。
  • 给外部 Agent Runtime 划边界:CodePilot 的 scheduler / heartbeat 不接管外部 Agent 框架自己的 cron、heartbeat、background worker;外部 Agent 只通过事件 / tool call / notification delivery 接入,避免双重调度或互相覆盖状态。
  • Settings 里所有半成品功能按真实状态标为:可用 / 预览 / 暂不可用 / 已隐藏。

不做什么

  • 不先做复杂任务管理 UI。
  • 不把心跳、Memory、定时任务三个半成品一起推进。
  • 不把本机通知能力绑到 Bridge;Bridge 不在线不能影响本机提醒。
  • 不强兼容外部 Agent 框架自己的定时任务 / 心跳系统;Phase 3 只定义隔离边界和事件桥接,不把 OpenClaw / Hermes / 其它 Agent 的后台任务导入 CodePilot scheduler。
  • 不承诺移动端/IM 远程通知一定可靠,除非 Bridge daemon 和 delivery log 已验证。

验收路径

  • 关闭主窗口后,CodePilot 菜单栏图标仍在;点击可重新打开窗口,点“退出 CodePilot”才结束进程。
  • 创建一个 1 分钟测试提醒,关闭窗口,到时间后 macOS 系统通知能弹出;未配置 Bridge 也必须能弹本机通知。
  • 配置 Bridge 后,同一条通知可以额外走远端通道,delivery log 能区分“本机通知成功 / Bridge 未配置或失败”。
  • Settings → Assistant / Health:半成品入口不再误导;心跳明确标为助理能力,定时任务明确标为全局能力。
  • Settings → 定时任务 / 自动化:集中管理所有全局任务;Settings → Assistant 只管理助理心跳和主动问候。
  • 创建一个测试提醒:到时间后能触发并留下日志。
  • 失败时能在 About → 日志文件夹中定位原因。

Phase 3 Step 1:现状审计(2026-05-08)

用户结果

本轮先不改 UI、不新增后台能力,只把“现在到底哪些是真的、哪些只是看起来有”的边界钉清楚。用户角度的结论是:

  • 定时任务不是完整可用状态:系统已经有 scheduler、DB、通知队列和 AI tool,但“几分钟后提醒我”这类同日提醒存在触发时间比较 bug,不能承诺可靠。它应该升级为 CodePilot 全局能力,而不是继续藏在助理设置里。
  • 通知可达依赖前端或桥接后台:正常/紧急通知会先进入 server queue,再由打开的 renderer 轮询展示;如果 Electron 关闭窗口且 bridge 没在后台保活,系统通知不具备独立常驻能力。目标状态应改为:CodePilot 菜单栏常驻即可发本机 macOS 通知,Bridge 只负责额外远端通知。
  • 心跳不是后台定时器:心跳只在用户打开匹配的助理工作区空会话时通过 autoTrigger 触发,不会在用户不打开应用时主动跑。后续必须把“助理心跳”和“全局定时任务”拆开描述。
  • Settings → Assistant 的定时任务区目前是只读列表 + 删除:没有创建 / 立即运行 / 暂停入口;创建主要靠 AI tool codepilot_schedule_task。后续这个列表应视为“助理创建的任务视图”,而不是全局任务中心的最终形态。

审计表

区域 现状 用户风险 Step 2/3 处理
App 生命周期 / 常驻 目前关闭窗口后的后台 poller 与 bridge active 状态耦合;没有独立的“菜单栏常驻 + 显式退出”产品语义 用户以为关闭窗口后提醒还会来,但进程 / server 可能已经停了;Bridge 未开时本机提醒也不可靠 Step 2 必须先做菜单栏常驻:关闭窗口隐藏,scheduler / 本机通知继续运行;只有“退出 CodePilot”才停止
Scheduler 启动 instrumentation.ts 会在 Next node runtime 启动时 ensureSchedulerRunning()/api/chat 和任务 API 也会补启动 app 关闭 / server 不在时没有 OS 级后台执行 跟随 Step 2 常驻后台;UI 文案写“需要 CodePilot 正在后台运行”,不再写“需要 Bridge”
due task 查询 scheduled_tasks.next_run 多处用 Date.toISOString() 写入;getDueTasks()next_run <= datetime('now') 文本比较 同一天内 ISO T 字符串会排在 datetime('now') 空格格式之后,5 分钟后提醒可能不会准时触发 P1 修复:改成 datetime(next_run) <= datetime('now') 或统一 epoch;补同日一次性任务回归测试
手动 run /api/tasks/[id]/run 只是把 next_run 写成 new Date().toISOString() 等 poll 受同一个文本比较 bug 影响,“立即运行”可能也不会马上跑 与 due task 查询同修;最好改成直接调用受控执行入口或写入可比较时间
运行中恢复 getDueTasks() 排除 last_status='running';崩溃时没有启动恢复 running → active/error 任务执行中进程挂掉后可能永久卡住,不再重试 启动时把超时 running 标为 error/backoff,写日志
通知队列 sendNotification() 只 enqueue;renderer useNotificationPoll() 5 秒 drain;urgent 额外直发 Telegram 没有打开页面时 toast/系统通知靠不上;queue 是 drain-on-read,多 renderer 可能抢 建立 notification delivery log;本机 macOS 通知作为第一通道,Bridge / Telegram 作为可选远端通道
Electron 后台通知 electron/main.ts 只有 bridge active 且窗口全关时启动 background poller bridge 未启用时窗口关闭会 kill server,定时任务和通知都停 Step 2 改成 Bridge 无关:菜单栏常驻时 main/renderer 可继续取队列并发 macOS 通知
全局定时任务归属 DB / API 是全局的,但 UI 主要露在 Assistant workspace;创建主要靠 AI tool 用户会误解成“只有助理有定时任务”,也不知道未来项目自动化是否复用同一能力 Step 3 把 scheduler / task status / delivery log 文档化为全局能力;Assistant 只显示助理相关任务
心跳触发 useAssistantTrigger() 在空会话、workspace 匹配、buddy 存在、needsHeartbeat true 时触发 autoTrigger 它不是定时后台心跳;只是“访问时检查” Step 4 单独处理:改成助理心跳 / 健康检查语义,别再和全局定时任务混写
外部 Agent Runtime OpenClaw / Hermes 等框架可能自带 cron、heartbeat、background task、notify-on-complete 如果 CodePilot 同时接管它们的调度,会出现双重触发、重复通知、状态互相覆盖 Phase 3 不兼容外部 scheduler;只接收外部事件并写入隔离的 delivery / activity log。真正适配放到 Phase 4 多 Agent
Settings → Assistant 显示心跳开关、定时任务列表、删除按钮;没有创建 / 暂停 / 立即运行控件 用户看到“定时任务”容易以为页面可管理,也会把助理心跳误解成通用 scheduler 后续 UI 分层:Settings → 定时任务 / 自动化 管全局任务;Assistant 页只显示助理心跳 / 主动问候 / 助理创建任务的轻入口

最小闭环建议

  1. Step 2:后台常驻 + 本机通知 ✅(2026-05-08)
    先把 App 生命周期做对:关闭窗口只隐藏到 macOS 菜单栏,scheduler / server / 本机通知链路继续运行;菜单栏提供“打开 CodePilot / 退出 CodePilot”。用一个测试提醒验证:未配置 Bridge 时也能弹 macOS 系统通知。

    完成情况

    • electron/main.tsmainWindow.on('close') 拦截关闭并 event.preventDefault() + hide();只有 quitApp()(Tray 退出菜单项)或 before-quitisQuitting=true 后 close 才放行。
    • Tray 在 app.whenReady() 内创建(dev / prod 两条路径),从原来的 window-all-closed + bridge active 触发条件改为常驻;Tray 菜单只剩两条:「打开 CodePilot」「退出 CodePilot」(多条 Bridge 状态 / Stop Bridge 文案删除)。
    • 菜单 / tooltip 标签从 src/lib/tray-menu-labels.ts(纯函数)按 app.getLocale() 选中文 / 英文,可独立单测。
    • 后台通知 poller 与 Bridge 解耦:现在是 mainWindow.on('hide') → startBgNotifyPoll()on('show') → stopBgNotifyPoll();poller 内部判断主窗口可见性而非 BrowserWindow.getAllWindows().lengthwindow-all-closed 不再调用 isBridgeActive(),也不再在非 macOS 上 app.quit(),菜单栏常驻在 Win/Linux 同样有效。
    • app.on('activate'):dock 点击时若主窗口仍存在(隐藏态)则直接 show(),不再销毁 tray。
    • 验证:npm run test 1611 通过 0 失败(前 1604 / +7:tray-menu-labels 9 例 + menubar-resident-invariants 7 例,其中 2 例之前因 JSDoc 注释误命中 Bridge / app.quit() 关键词,已加 stripComments() 修复);npx next build 通过;node scripts/build-electron.mjs 通过且 bundle 中可见 getTrayMenuLabels / showMainWindow / quitApp / rebuildTrayMenu
    • 未做:手动 Electron 启动 smoke(Browser/CDP 不主动开,按 AGENTS.md 新规则把这一类核心生命周期改动留给用户在真实 Electron dev 上做一次 close → tray Open → tray Quit 的人工确认)。
  2. Step 3:全局定时任务触发 + delivery log
    先修 next_run 时间比较和 running 恢复,再做“一分钟后测试提醒”smoke。每次触发写 task run / notification delivery 记录:本机通知成功、Bridge 未配置、Bridge 失败必须分开显示。

  3. Step 4:助理心跳诚实化
    把心跳文案从“后台定时器”收回为“助理工作区健康检查 / 主动问候”。如果未来要做后台心跳,它也应复用全局 scheduler,但结果语义仍然是助理专属。

  4. Step 5:UI 分层与诚实化
    Settings → Assistant / Health 对每个半成品标清楚:可用、预览、需要 CodePilot 后台运行、Bridge 仅远端增强、暂不可用。新增或重组 Settings → 定时任务 / 自动化 作为全局任务页,Assistant 页只保留心跳、主动问候、助理行为配置。

Settings 信息架构决定

  • Settings → 定时任务 / 自动化:全局任务中心。管理用户提醒、助理创建的任务、系统自动化、未来项目自动化、外部 Agent 上报的任务事件。列表字段至少包含:任务名称、来源、下次执行时间、上次执行结果、通知状态、启用 / 暂停、删除、立即运行 / 测试。
  • Settings → Assistant:只管理助理相关行为。保留心跳、主动问候、工作区偏好、助理创建任务的轻入口。这里不再承担全局定时任务管理。
  • 底层关系:心跳可以复用全局 scheduler / delivery log,但产品入口和文案必须属于助理行为;定时任务页最多显示“来源:助理心跳”的执行记录,不把心跳配置主控搬过去。
  1. 外部 Agent 兼容边界(Phase 4 才扩)
    Phase 3 不把 OpenClaw / Hermes / 其它 Runtime 的 cron、heartbeat 迁进 CodePilot,也不让 CodePilot scheduler 主动控制它们。只允许两类轻接入:(a) 外部 Agent 主动发事件,CodePilot 记录 activity / delivery;(b) CodePilot 启动一次明确用户请求的任务,等待结果并展示。是否做跨 Agent 定时任务编排,放到 Phase 4 多 Agent 设计里单独审批。

参考源记录:OpenClaw / Hermes / Codex

  • OpenClaw:cron / heartbeat 的成熟点不在“多一个定时器”,而在状态可解释:computed status、doctor / reconcile stale running、active-hours / timezone、结构化 heartbeat result。CodePilot 借鉴状态机和恢复策略,不照搬它的具体 cron。
  • Hermes:background task 的通知语义是 notify_on_complete + 日志可追踪;长任务不要只按墙钟杀死,要看 idle / activity。CodePilot 借鉴 delivery log、后台任务完成事件和 idle-aware 思路。
  • Codex 最新版(资料目录:/Users/op7418/Documents/code/资料/codex,HEAD d9feaffffb):它没有本地提醒 scheduler,但有三点适合 Phase 3:
    • 通知是事件驱动而不是隐式全局开关:TUI 有 agent-turn-completeapproval-requestedplan-mode-prompt 等类型,允许用户选择通知类型,且会按 terminal focus 决定是否发桌面通知。
    • 通知 payload 是结构化 JSON:外部 notify 命令收到 type / thread-id / turn-id / cwd / input-messages / last-assistant-message,不是只拼一段人类文本。
    • 长任务 / 云任务体验拆成“提交 / 状态 / 详情 / 应用”:cloud-tasks 使用 Pending / Ready / Applied / Error 这类明确状态,App Server 的长动作先返回、再通过 turn/*item/*process/* notifications 流式更新。CodePilot 的定时任务也应先有 task run 状态和 delivery log,不要把用户带进一个没有进度语义的黑盒。

本轮验证方式

  • 本轮没有启动 Browser / Chrome / CDP,避免复现上一轮浏览器自动化内存飙升。
  • 审计来源是代码路径与现有单元测试:task-scheduler.tsnotification-manager.tsuseNotificationPoll.ts、Electron background poller、useAssistantTrigger.ts、Settings Assistant UI。
  • 下一轮需要 UI smoke 时按 AGENTS.md 新规则短跑:一个页面、一个动作、一次截图;若出现 profile lock / timeout / 内存异常苗头立即停止。

Phase 3 Step 3:通知与任务触发闭环(计划,2026-05-09,修订 v4 review-fix-3)

状态:📝 计划中,等待审批后开工。Step 2(菜单栏常驻 + 本机通知 IPC)已落地;Step 3 把"任务到点真的会触发 + 用户能看到 + 点击能定位"这条主链补完。

修订 v2(2026-05-09 review-fix)针对 5 条 P2 finding 收紧:(1) 普通"提醒"不跑模型,AI task 才走 provider;(2) delivery log 拆 notification_events (queued) ↔ notification_deliveries (delivered/error),server 入队不能误记成已送达;(3) 历史表复用现有 task_run_logs + insertTaskRunLog,不引入并存的 scheduled_task_runs;(4) "立即运行"走受控执行入口 runScheduledTaskNow(taskId),不再靠下一轮 poll;(5) Step 2 P2 兜底(tray 在生产 await 之前创建 / showMainWindow 不再 serverPort || 3000)声明为 Step 3 prereq、本批已修。

修订 v3(2026-05-09 review-fix-2)针对 4 条新 finding 再收紧:(1) AI tool schema 必须显式声明 kind: 'reminder' | 'ai_task',"提醒我"类语义在 prompt 阶段就定向到 reminder,不靠 DB default 兜底;(2) 一次执行 = 一行 task_run_logs,新增 updateTaskRunLog(runId, ...),running → success/error 原地更新,不再"插 running 再插 success";(3) Bridge 未配置时仍写一条 channel='bridge-telegram' status='not_configured' or 'skipped',让用户能区分"没配置所以跳过"和"根本没尝试远端";(4) task-history-table.test.ts 只扫 src/,不扫 docs / 计划文档自身(解释禁用项的中文/英文 prose 不能误中)。

修订 v4(2026-05-09 review-fix-3)针对 3 条 P2 边界再收紧:(1) kind 必须落到 src/types/index.ts:1464ScheduledTask interface 和 src/lib/notification-mcp.ts:103 的 durable=false 内存 session task 字面量两处之前漏的表达点——session 路径绕过 API 校验,必须自己带 kind;新增 session-reminder-fire.test.ts 单测;(2) events ↔ deliveries 锁定唯一口径:notification_events 1 行 per 任务通知,notification_deliveries N 行 per (event, channel),1:N 关系,绝不"对每条 channel 写 event 行";(3) Bridge × priority 锁定:urgent → 每个远端候选 channel 写 1 行 delivery(4 状态四选一);low/normal → 不写 bridge-* delivery 行(产品策略:Bridge 是 urgent-only 候选)。同时把节标题从 v2 升到 v3 匹配实际版本号。

修订 v5(2026-05-09 review-fix-4)针对 ack 幂等 + 标题滞后 2 项收紧:(1) POST /api/tasks/notify/ack 必须是 UPDATE / UPSERT(event_id, channel) 行(绝不 INSERT 新行)——sendNotification() 入队时已经为每个候选 channel 写好初始 row,ack 只翻状态;DB 加 UNIQUE(event_id, channel) 约束 + 应用层 upsertNotificationDelivery helper 双重兜底;测试加"重复 ack 幂等"+"非法状态转换被拒" + "DB UNIQUE 直接 INSERT 第二行 fail"三条断言。(2) 节标题从 v3 升到 v4 匹配 v4 摘要 blockquote 实际范围。

0. Step 2 prereq(已修,2026-05-09)

  • electron/main.ts:production startup 把 ensureTray() 移到 await startServerOnStablePort() 之前——用户在 server ready 前关掉 loading window 也能从菜单栏重新打开 / 退出。
  • electron/main.tsshowMainWindow() + app.on('activate') 删掉 serverPort || 3000 fallback,改用新 helper chatWindowUrlForRevival()serverPort 未就绪 → 返回 undefined → createWindow() 显示 LOADING_HTML,由启动流程的 mainWindow.loadURL(realUrl)serverPort 落定后切回真实 URL)。3000 在生产服务器(稳定区间 47823–47830)是错的端口,提前 tray 之后这条假设就不成立。
  • menubar-resident-invariants.test.ts 加 2 例:(a) production startup else 块里 ensureTray() 必须出现在 await startServerOnStablePort 之前;(b) showMainWindow body 与 app.on('activate') block 都不许出现 serverPort\s*\|\|\s*3000。bg-poller 内部已有的同款断言保留——它仍在 dev-startup adjacent 路径里合法。

1. 用户会看到什么变化

  • "5 分钟后提醒我"真到点触发,且不需要任何 AI provider——reminder 类任务到点直接弹通知,prompt 文本就是通知正文,不进 generateTextFromProvider 链路。新增的 ai_task 类才走 provider 跑模型。这是 v2 review 抓的最关键一条:以前所有 task 都默认 AI 化,最基础的"喝水提醒"会因为没模型 / 没网络静默失败,本步把这条路径分开。
  • AI tool 创建入口(v3 fix #1,v4 收紧覆盖面):用户在聊天里说"5 分钟后提醒我喝水",模型调用 codepilot_schedule_task 时必须显式传 kind: 'reminder'——不能依赖 DB 默认值。Tool description / schema 写明"natural-language reminders → kind='reminder',AI workflows → kind='ai_task'",让模型在 prompt 阶段就路由对。/api/tasks/schedule 必填 kind(缺失返回 400)。DB 默认值 'ai_task' 仅给老库 schema migration 用,新创建一律由调用方明示。v4 fix #1 — kind 必须落到所有 task 表达点,不只 DB schema 和 API:(a) src/types/index.ts:1464ScheduledTask 接口加 kind: 'reminder' | 'ai_task'(必填);所有读写它的代码路径在 typecheck 阶段就被强制带上字段。(b) src/lib/notification-mcp.ts:103非 durable / 内存 session task 对象字面量(直接 addSessionTask(task),不走 /api/tasks/schedule)也必须带 kind——这是 v3 没覆盖的旁路:AI tool 已经必传 kind,但 session task 路径绕过了 API 校验,仍可能创建无 kind 任务。(c) src/lib/builtin-tools/notification.ts 两条 durable / non-durable 分支都把 kind 透传下去。(d) addSessionTask / getSessionTasks / executeDueTask 在 session task 路径里同样按 kind 分发 reminder vs ai_task,不靠 API 兜底。配套测试:(i) src/types/index.ts ScheduledTask grep 必含 kind:;(ii) notification-mcp.ts session task 对象字面量必须带 kind: 'reminder' | 'ai_task',缺失测试 fail;(iii) 新增 durable=false reminder scheduler test —— 内存 session task 走 reminder 路径不调 provider,与 durable 持久化路径行为一致。
  • 同日的短间隔提醒(5 分钟 / 30 分钟)和"立即运行"按钮都会按预期工作getDueTasks 文本比较 bug 修掉,且"立即运行"通过受控执行入口直接 fire(返回 runId / status='running'),不再写 next_run = now() 等下一轮 poll。
  • 关掉主窗口到菜单栏后,定时任务继续跑——不依赖 Bridge,不依赖 dev server 终端,不依赖你打开任何特定页面。Step 2 + Step 3 prereq 已经把舞台搭好。
  • 到点弹 macOS 系统通知:标题是任务名,正文是 reminder prompt 或 AI 结果,点通知 → CodePilot 主窗口被唤起 + 直接落到对应任务的视图Settings → 定时任务 中焦点定位到这条任务,看得到 next_run / 上次结果 / delivery log)。
  • Bridge 不再是本机通知前置条件:未配置 Bridge 也能弹本机通知(Step 2 已做完);Step 3 通过 delivery log 显式可视化"本机通道 queued → delivered / Bridge 未配置或失败"分类。
  • Settings → 定时任务 / 自动化 是真的能用的全局任务页:列表(任务名 / 类型 reminder|ai_task / 来源 / next_run / 上次结果 / 上次通知 channel)+ 创建 + 立即运行 + 暂停 + 删除。来源字段:用户提醒 / 助理创建 / 系统自动化(未来:外部 Agent 上报)。
  • Settings → Assistant 只管助理行为:心跳开关、心跳间隔、主动问候配置;不再混入"任务列表"那种全局能力;想看"助理创建了哪些任务"是一个链接到全局任务页的入口。
  • 失败可定位且诚实:delivery log 区分 queued(server 已入队,未必送达)→ delivered(renderer / Electron native 已弹)→ error(具体哪条通道、哪条错误)。"queued" 不会再被假装成"delivered"。

2. 验收路径

整个 Step 3 通过即视为达成。每一步都对应一个用户结果。

  1. reminder ≠ AI task(v2 review fix #1)——单测:建一个 kind='reminder' 的"now + 5 minutes"任务(无 provider 配置),poll 触发后断言 (a) generateTextFromProvider 没被调用;(b) sendNotification 收到 title=任务名 / body=prompt 文本;(c) task_run_logs 写一条 status='success',无 last_error。再建一个 kind='ai_task' 任务确认仍走 provider 路径。

  2. next_run 文本比较 bug 修复回归——单测:同样"now + 5 minutes" once-task,poll cycle 拨进 6 分钟后断言被 getDueTasks() 选中。修复前这条 case 不会被选中('2026-05-09T09:05:00.000Z' <= '2026-05-09 09:06:00' 字符串比较失败)。修法默认走 datetime(next_run) <= datetime('now'),不动 schema。

  3. 手动 run 立即触发(v2 review fix #4 + v3 fix #2 run-row 生命周期)——POST /api/tasks/[id]/run 直接调用新增 runScheduledTaskNow(taskId) 同步入口:先 insertTaskRunLog({ status: 'running', … }) 拿到 runId,立刻返回 { runId, status: 'running' };执行完成后 updateTaskRunLog(runId, { status: 'success' \| 'error', result, error, duration_ms }) 原地更新同一行——一次执行只对应一条 task_run_logs,不会"先插 running 再插 success"导致历史重复。单测:(a) /run 返回 runId;(b) 执行结束后查 DB 同一 runId 对应一行,status='success' \| 'error',不存在两行;(c) /runs 返回的列表里同一个 runId 只出现一次。poller 路径走同款 runScheduledTaskNow,逻辑一致。/run API 也增 already_running 分支:DB 行级锁(UPDATE scheduled_tasks SET last_status='running' WHERE id=? AND last_status!='running')抢锁失败 → 返回 { runId: <existing>, status: 'already_running' }

  4. 启动恢复——单测:mock 一个 last_status='running'last_run 早于 X 分钟(默认 30 min stale 阈值)的任务,调 ensureSchedulerRunning(),断言被改成 error + next_run 推迟一个 backoff 间隔。

  5. delivery log 不假成功(v2 review fix #2,v4 锁定 events/deliveries 唯一口径)——

    唯一口径(v4 fix #2)

    • notification_events一次任务通知 = 一行。这是"逻辑通知事件"的伞——同一个任务到点 fire 一次,写一条 notification_events(含 event_id / task_id / source / created_at),无论该通知会被分发到几个通道。sendNotification() 服务端入队时写这一行就完事,为每条 channel 单独写 event。
    • notification_deliveries每个候选 channel × event = 一行。renderer-toast / electron-native / electron-bg-native / bridge-telegram / bridge-feishu / bridge-discord / bridge-qq 等等,每个候选通道在 events 写完后就有一条对应 delivery 行(初始状态可以是 queued 或直接 not_configured / skipped)。通道真送达后回调 POST /api/tasks/notify/ack 翻成 delivered / error
    • 这两张表的关系是 1:N:一个 event 对应多个 delivery,绝不允许"对每条 channel 都写一行 event row"那种 1:1 误解(v2 草稿里的措辞错了,v4 改正)。

    流转

    • notification-manager.sendNotification() 写 1 条 notification_events (queued) + 按 priority × 配置状态枚举所有候选 channel,每个 channel 写 1 条 notification_deliveries(初始 queued / not_configured / skipped,详见验收第 9 项 v3 fix #3)。
    • renderer 的 useNotificationPoll 在成功 showToastelectronAPI.notification.show 后回调 POST /api/tasks/notify/ack UPDATE / UPSERT 对应 (event_id, channel) delivery 行从 queueddelivered / error不 INSERT 新行——v5 fix)。Electron 主进程 bg-poller 同样 ack(channel='electron-bg-native')。Bridge 通道由 telegram-bot / feishu-bot / discord-bot / qq-bot 各自模块在 notifyGeneric 成功 / 失败后 ack。重复 ack 幂等(DB UNIQUE(event_id, channel) 约束 + 应用层 upsertNotificationDelivery helper),同 channel 不会出现 2 行历史。
    • task_run_logs 写任务执行结果(沿用现有 insertTaskRunLog不新建 scheduled_task_runs)。task_run_logs.notification_event_id 关联到 notification_events.id(FK 关系,1:1 — 一次任务执行最多产生一个通知事件)。/api/tasks/[id]/runstask_run_logs + 关联的 notification_events + 各自的 notification_deliveries
  6. 历史表收口(v2 review fix #3)——task_run_logs 是唯一任务执行历史表。schema 只 ADD 字段(kind / notification_event_id 引用 / delivery_summary 缓存);不重新建一份 scheduled_task_runsinsertTaskRunLog 接口扩展新字段;现有 callers 行为兼容。

  7. Electron 通知点击 → 路由跳转——通知 payload 携带 taskId(必要时也带 sessionId),点击通知 → 主进程 notification:click IPC 投递给 renderer → renderer 用现有 electronAPI.notification.onClick 订阅 → router.push('/settings/tasks?focus=<taskId>'),主窗口被 showMainWindow() 唤起(Step 2 + 本批 prereq 都已建好)。

  8. Settings → 定时任务页可用——列表 + 创建 + 立即运行 + 暂停 + 删除四个动作齐全;i18n 中英文都有;不打开任何 section 编译重链路。列表 kind 列显式区分 reminder / ai_task,避免用户误以为"reminder 也要配 provider"。

  9. Bridge 解耦的可见证据(v3 fix #3,v4 fix #3 锁 priority 规则)——

    Bridge × priority 锁定规则(v4 fix #3):远端 Bridge 通道(telegram / feishu / discord / qq)现行代码仅在 priority === 'urgent' 时尝试发送,本步保持该策略不变。notification_deliveries 是否写 bridge-* 行严格跟随该策略:

    • priority === 'urgent':Bridge 是候选通道。每个已被产品支持的远端 channel(先 telegram,后续接入再扩)都写 1 行 notification_deliveries,状态四选一:
      • not_configured — 用户从未填 token / 完成 Bridge 配置
      • skipped — Bridge 已配置但用户在 Settings → Bridge 主动关闭
      • delivered — 远端 ack 成功
      • error — 配置可用但远端调用失败(含错误信息)
    • priority === 'low''normal':Bridge 不是候选通道(产品策略:远端通知是 urgent 才打扰用户)。不写 bridge-* 行。这与 finding #3 "诚实可见" 不冲突——这种场景里"Bridge 未尝试"不是"代码漏了",是产品策略选择,UI 不展示 bridge-* 行天然就表达了"该消息从未尝试远端通道"。
    • 拒绝 skipped_by_priority 提议:v3 review 给了两个选项(写 skipped_by_priority 或不写 bridge-*),v4 选不写——因为 skipped_by_priority 隐含"我们考虑过 Bridge 然后跳过了",但 low/normal 的语义是"Bridge 从一开始就不是候选 channel",写一行只会让用户以为有可能改 priority 让它走,混淆产品策略。

    三种 Bridge 状态 × urgent 同任务复测矩阵

    • (a) Bridge 未配置electron-native: delivered + bridge-telegram: not_configured(之前 v2 写"无 bridge-* 行",会让用户分不清"没配置所以跳过"和"代码根本没尝试"——本步要求显式写一行 not_configured 状态)。
    • (b) Bridge 配置但用户主动关闭electron-native: delivered + bridge-telegram: skipped(与 not_configured 区分)。
    • (c) Bridge 已配置可用 → electron-native: delivered + bridge-telegram: delivered
    • (d) Bridge 已配置但远端失败 → electron-native: delivered + bridge-telegram: error 含错误信息。

    non-urgent 复测:建一条 priority: 'normal' 的 reminder fire 一次,断言 notification_deliveries没有任何 bridge-* 行——只有 renderer-toast / electron-native 候选。

    实现侧(与 v4 fix #2 events/deliveries 1:N 口径一致):sendNotification() 服务端入队时写 1 条 notification_events (queued) + 按 priority + 配置状态枚举候选远端通道:urgent 时枚举 telegram(含 not_configured / skipped placeholder);normal/low 时枚举为空——不为 Bridge 写任何 delivery 行。每个候选通道在 events 写完后写 1 条 notification_deliveries。通道模块(telegram-bot 等)真送达后 ack 翻成 delivered/error未配置 / 跳过的 channel 在写 delivery 行的瞬间就直接落 not_configured / skipped(不留 queued)。

  10. 手动 Electron smoke(用户验收口径):

    • 启动 dev 或 packaged build。
    • 创建一个"+1 分钟"测试 reminder(不配 provider,验证 reminder 路径不依赖 AI)。
    • 关掉主窗口(菜单栏常驻)。
    • 等到点 → macOS 系统通知弹出。
    • 点通知 → 主窗口被唤起 → 落到 Settings → 定时任务页 + 焦点在该任务,能看到 delivery log(queued → delivered)。
    • 关掉 Bridge → 重复,确认本机通知仍发出。
  11. npm run testnpx next build 通过

3. 不做什么(Step 3 边界)

  • 不做复杂任务编排 / 跨 Agent 协调——留 Phase 4 多 Agent 计划。
  • 不接管外部 Agent runtime(OpenClaw / Hermes / Codex / 其它)自带的 cron / heartbeat / background worker——只留适配点:(a) notification-manager.sendNotification() 接受 source: 'codepilot' | 'external' 字段透传到 delivery log;(b) /api/tasks/notify 允许外部 Agent 写入"我已完成"事件,但 CodePilot scheduler 不向外部 Agent 派发任务、不维护它们的 next_run。这条规则避免双重触发,也避免我们替别人扛锅。
  • 不在 Settings → Assistant 里加全局任务能力——心跳和任务严格分两条线。如果用户想从助理页看"助理创建了哪些任务",那是一个链接到全局任务页(router.push('/settings/tasks?source=assistant'))的入口,不是嵌在助理页里的子模块。
  • 不做远端通知重试 / 退避策略改造——Bridge 自治。Bridge 未配置 / 失败时本机通道仍工作,delivery log 记录失败原因,不在 Step 3 内做远端重试逻辑。
  • 不做主动问候 V2 / 个性化——属于 Phase 4+ 助理行为升级。
  • 不做任务模板市场 / 共享 / 跨设备同步
  • 不做权限 / 多用户 ACL——单用户单设备。
  • 不引入 cron 表达式编辑器 UI——后端 cron 解析已存在(getNextCronTime),UI 第一版只暴露"once / interval(每 X 分钟/小时/天)/ daily HH:MM";高级 cron 表达式仍允许通过 AI tool 写入但 UI 不直接编辑。
  • 不动 Step 2 已落地的菜单栏常驻 / 关闭隐藏 / 本机通知 IPC 行为——Step 3 在它们之上加 payload + 路由,不重写。

4. 涉及哪些模块

模块 改动方向
src/lib/task-scheduler.ts getDueTasks() 文本比较;启动恢复 stale running;按 task.kind 分发 reminder vs ai_task(reminder 不调 generateTextFromProvider);通知 payload 加 taskId / event_id;新增导出 runScheduledTaskNow(taskId) 受控执行入口
src/lib/db.ts schema 仅 ADD:scheduled_tasks.kind(NOT NULL,老库 migration 默认 'ai_task' 兼容;新建必须显式传)+ task_run_logs.notification_event_id 关联列;新增 notification_events (queued 入口,id PK + task_id + event_id UNIQUE) + notification_deliveries (per-channel ack,UNIQUE(event_id, channel) 约束 — v5 fix 让"再插一行 delivery"在 SQL 层就被拒,配合应用层 upsertNotificationDelivery(event_id, channel, status, error?) helper) 两张表。不新建 scheduled_task_runs——task_run_logs + insertTaskRunLog 沿用并扩展,再加 updateTaskRunLog(runId, { status, result, error, duration_ms }) 用于 running → success/error 原地更新(v3 fix #2:一次执行 = 一行)。时间字段保持现有 ISO 字符串,getDueTasks 改用 datetime(next_run) 比较,不做迁移
src/types/index.ts ScheduledTask 接口必填 kind: 'reminder' | 'ai_task'(v4 fix #1)——typecheck 强制所有读写路径带字段,session task 内存对象字面量也必须满足
src/lib/builtin-tools/notification.ts + src/lib/notification-mcp.ts codepilot_schedule_task tool schema 必须声明 kind: z.enum(['reminder', 'ai_task'])(v3 fix #1 — 不传是错);description 写"用户说'提醒我'/'remind me' → reminder;用户说要让模型完成某事 → ai_task",给出明确路由规则。两个文件保持同步 schema。v4 fix #1notification-mcp.tsdurable=false 分支构造的 session task 对象字面量(line 103)必须带 kind 字段透传到 addSessionTask;durable=true 分支同样把 kind 加进 POST body
src/app/api/tasks/schedule/route.ts 接收 kind 字段,缺失或非法值返回 400;在 server 层验证一次,不依赖 SQLite 默认
src/lib/task-scheduler.ts (session-task 路径) addSessionTask / getSessionTasks / executeDueTask 在内存 session task 路径里按 kind 分发 reminder vs ai_task,与 SQLite 持久任务路径行为一致(v4 fix #1)
src/lib/notification-manager.ts sendNotification()1 行 notification_events (queued) + event_id + taskId / sessionId / source 透传;同时按 priority × 配置状态枚举候选 channel,每个候选 channel 写 1 行 notification_deliveries(初始 queued / not_configured / skipped,对应 v3 fix #3 四态)。为每个 channel 写额外 event 行(v4 fix #2 — 1:N 关系,event 是伞,delivery 是叶)
src/lib/telegram-bot.ts(或对应 channel module) notifyGeneric 成功 / 失败后写 notification_deliveries 一行 channel='bridge-telegram'(同款给 feishu / discord / qq)
src/app/api/tasks/notify/ack/route.ts(新增) renderer / Electron bg poll / native 通知模块通过 POST 把已存在的 notification_deliveries 行从 queued UPDATE / UPSERTdelivered / error(v5 fix — 不是 INSERT 新行)。匹配键是 (event_id, channel)sendNotification() 入队时已经按候选 channel 写过初始行;ack 只能更新已有行的 status / error / acked_at,绝不允许同一 (event_id, channel) 出现两行。重复 ack 必须幂等——状态可在 queued → deliveredqueued → error 之间转换,但同 channel 的 delivered 不会被另一个 ack 再写一次。事件源用 event_id 串起来
src/hooks/useNotificationPoll.ts drain 后成功 toast / native show 即调 /api/tasks/notify/ack 回写 delivery
src/app/api/tasks/[id]/run/route.ts 改调 runScheduledTaskNow(taskId),返回 { runId, status };不再仅 next_run = now() 等 poll
src/app/api/tasks/[id]/runs/route.ts(新增) task_run_logs + 关联 notification_events / deliveries
electron/main.ts 通知构造时附带 payload { taskId, sessionId, event_id }notification.on('click') 把 payload 带回 renderer 走现有 notification:click IPC;bg-poller 收到 events 后 ack delivery
electron/preload.ts notification.onClick payload 类型扩展 { taskId?: string; sessionId?: string; event_id?: string }
src/components/layout/AppShell.tsx 或新 hook useNotificationClickRoute 订阅 electronAPI.notification.onClick,按 payload router.push/settings/tasks?focus=<taskId>
src/app/settings/tasks/page.tsx 新增全局任务页(route-level split 已为新页准备好 layout)。列表(含 kind 列)+ 创建 dialog(先选 reminder vs ai_task)+ 操作按钮
src/app/settings/layout.tsx + src/components/layout/SettingsSidebar.tsx 加一条 定时任务 / Tasks 入口
src/components/settings/AssistantWorkspaceSection.tsx 删除"助理创建任务列表"区域,改成一个链接(Settings → 定时任务(来源:助理));保留心跳 / 主动问候配置
src/i18n/{zh,en}.ts 任务页文案 + kind / 来源 / 状态 / channel 标签
docs/handover/scheduler-tasks.md(新增)+ docs/insights/scheduler-tasks.md(新增) 完成后按规范双链
docs/exec-plans/completed/refactor-closeout.md 决策日志写完成条目

5. 需要哪些测试

  • 单元 / 契约
    • task-scheduler.test.ts(新增 case):
      • (a) kind='reminder' 任务 fire 时不调 generateTextFromProvidersendNotification 收到 prompt 文本(spy + assert.notCalled / assert.called)。
      • (b) kind='ai_task' 任务保持现行 provider 路径。
      • (c) same-day "+5 分钟" once-task 必须被 getDueTasks() 选中(修复回归)。
      • (d) 手动 run 入口 runScheduledTaskNow(taskId)一条 task_run_logs running 行,执行结束后原地 update 同一行为 success / error——单测断言同 runId 在 DB 中只出现一行(v3 fix #2)。
      • (e) runScheduledTaskNow 在已经 running 的任务上抢锁失败,返回 { runId: <existing>, status: 'already_running' },不重复 fire。
      • (f) 启动恢复把 stale running 推回 error / backoff next_run
      • (g) jitter 仍然生效,不被 bug 修复破坏。
    • 新增 schedule-task-tool-kind.test.ts(v3 fix #1,v4 扩 type + session-task 覆盖):
      • codepilot_schedule_task 的 zod input schema 必须含 kind: z.enum(['reminder', 'ai_task']);不传 kind 时 schema parse 失败。
      • tool description 必须包含 "reminder" 与 "ai_task" 两个关键词的路由说明,便于模型 prompt 阶段就路由对。
      • notification-mcp.ts 同款 schema 一致(grep 两个文件 schema literal 一致)。
      • /api/tasks/schedule route 收到无 kind 请求返回 400;kind 非 enum 值返回 400。
      • v4 新增src/types/index.ts ScheduledTask interface 必含 kind: 'reminder' | 'ai_task'(grep 字面量)。
      • v4 新增notification-mcp.ts 中 session task 对象字面量(durable=false 分支)必须带 kind 字段——grep addSessionTask\\(\\s*\\{[\\s\\S]*?kind:,缺失测试 fail。
    • 新增 session-reminder-fire.test.ts(v4 fix #1):mock 一个内存 session task { kind: 'reminder', schedule_type: 'once', next_run: now+5min, … },调 poll cycle,断言 (a) 没调 generateTextFromProvider;(b) sendNotification 收到 prompt 文本。等价于持久 reminder 测试的 session-only 版本,确保 session task 路径不绕过 reminder 分发。
    • notification-manager.test.ts(v4 收紧 events/deliveries 行数语义):sendNotification正好 1 行 notification_eventsqueued,含 source / task_id / event_id);同时按 priority + 配置状态写 N 行 notification_deliveries(每候选 channel 一行,初始 queued / not_configured / skipped)。断言不出现"每 channel 都写一行 event"误解:SELECT COUNT(*) FROM notification_events WHERE event_id=? 必为 1,不论 N 个 channel。
    • 新增 notification-ack.test.ts(v5 收紧 ack idempotency):
      • 基础 UPSERTPOST /api/tasks/notify/ack { event_id, channel, status: 'delivered' }sendNotification() 之前已经写好的 (event_id, channel) 行从 queued 更新为 delivered——SELECT COUNT(*) FROM notification_deliveries WHERE event_id=? AND channel=? 必为 1(更新前 1 行,更新后仍 1 行,不是 2 行)。
      • 多 channel 同 event:单个 event_id 下多个 channel 各占 1 行(renderer-toast / electron-native / bridge-telegram 等),互不干扰;ack 任一 channel 不影响其它 channel 的 queued 状态。
      • 重复 ack 幂等(v5 fix):同 (event_id, channel) 连续 POST 两次 delivered ack,最终仍只有 1 行,不会出现 2 行 delivered 重复历史。第二次 ack 可以 noop,也可以更新 acked_at 时间戳,但 row count 不变。
      • 状态转换合法性queued → delivered ✓;queued → error ✓;delivered → error 应被拒(已成功不能反向写失败);error → delivered 也应被拒(一次成败已定)。这条防止重试逻辑混乱写穿。
      • DB UNIQUE 约束:直接 INSERT 第二行同 (event_id, channel) 在 SQL 层就 fail(unit test mock SQLite 验证 unique violation)。即使应用层 helper 出 bug,DB 层兜底。
    • 新增 bridge-delivery-visibility.test.ts(v3 fix #3 + v4 fix #3 priority 矩阵):
      • urgent 矩阵:调 sendNotification(priority='urgent'),模拟 Bridge 四种状态分别断言 notification_deliveries 必有 bridge-telegram channel 行:(a) 未配置 → not_configured;(b) 用户主动关 → skipped;(c) 配置可用 → delivered;(d) 配置失败 → error——四种都不许出现"无 bridge-* 行"。
      • non-urgent 拒绝产生 bridge 行(v4 新增):调 sendNotification(priority='normal')priority='low',断言 notification_deliveries没有任何 bridge-* 行(只有 renderer-toast / electron-native)——这条防止有人"善意地"加 skipped_by_priority 而违反产品策略。
    • electron/main.ts 静态契约(menubar-resident-invariants.test.ts 同款):通知构造必须带 payload 字段;notification.on('click') body 必须 webContents.send('notification:click', payload);bg-poller 通知成功后必须命中 ack 路径(grep '/api/tasks/notify/ack' 字面量出现在 main.ts 的 bg-poll 块内)。
    • chat-static-graph.test.ts 同款 modulePath-based 契约:Settings → 定时任务 页的 page.tsx 只 import 自己的 section 组件(不静态拉 useOverviewData / runtime resolver / provider catalog);shell /settings/layout.tsx 不变。
    • settings-routes-shape.test.ts:扩 SECTION_TO_IMPORTtasks 行。
    • 新增 history-table 契约 task-history-table.test.ts:扫描src/(不扫 docs/ 不扫 __tests__/ 自身),不许出现 scheduled_task_runs 字面量(v3 fix #4 — 计划文档与决策日志会反复用这个名字解释"为什么不该建这张表",按文件路径过滤避免计划文档自己绊倒测试);task_run_logs 仍是 src/lib/db.ts 中唯一执行历史表名。配套 grep updateTaskRunLog 必须存在于 src/lib/db.tssrc/lib/task-scheduler.ts,确保单行原地更新路径在位。
  • API 真机往返(dev server 上)
    • POST /api/tasks/schedule 创建 reminder → GET /api/tasks/list 看到 kind='reminder'POST /api/tasks/[id]/run 立刻返回 runningGET /api/tasks/[id]/runs 看到一条 success;删除任务后 list 不再出现。再创建 ai_task 走一遍 provider 路径。
  • Electron 实机 smoke(用户口径)
    • 见上面"验收路径 第 10 项"。这部分按 AGENTS.md 新规则短跑:一个页面、一个动作、一次截图;不长跑 Browser/CDP。
  • Bridge 状态矩阵
    • 三种 Bridge 状态各 1 次手动复测,看 notification_deliveries 三种结果分类(不再看一个混合"delivery log")。

6. 边界与未来扩展

  • 外部 Agent 兼容只留接口、不实现source: 'codepilot' | 'external' 字段 + /api/tasks/notify 接受外部上报事件(写入 notification_deliveries,不进 scheduler 派发链)。这两个口子保留是为了 Phase 4 多 Agent 适配时不需要回头改契约;本步不接管任何外部 cron / heartbeat。
  • iCloud / 跨设备同步:明确不做。CodePilot 是单设备桌面应用,定时任务跟设备绑定。
  • 离线行为:scheduler 在线判断不强约束;任务 prompt 走的是已配置的 provider,provider 网络失败按现有 backoff 处理。
  • macOS 通知系统级权限:首次发本机通知前 Electron 会触发系统弹窗请求权限。这一步在 Step 2 已经踩过(useNotificationPoll 里有 Notification.requestPermission() web fallback),Step 3 不重做。
  • packaged build 内存 / 通知行为:列在 memory investigation 收口决议里"packaged memory smoke 后续单列",不在 Step 3 主线。
  • 多语言通知文案:本机通知文本走 i18n(任务名是用户输入,标题前缀 / 走 i18n key)。

7. 风险与降级

  • DB schema 迁移失败:本步只 ADD 列 / 表(scheduled_tasks.kindtask_run_logs.notification_event_idnotification_eventsnotification_deliveries),按 feedback_db_migration_safety 规则只 ADD 不 DELETE,迁移脚本必须能在用户旧库上幂等执行。失败降级:列 / 表不存在时新建,旧 task 默认 kind='ai_task' 仍能跑(兼容老行为),仅 delivery 可观测性退化。
  • kind='reminder'notify_on_complete 字段交互:现有 scheduled_tasks.notify_on_complete 是"任务跑完后是否通知"。reminder 概念上总是通知(不通知就没意义),所以 reminder 路径应忽略 notify_on_complete=0。在 runScheduledTaskNow / poll fire 入口里按 kind 决策,不靠 caller 设对该字段。
  • runScheduledTaskNow 与 poll 并发:用户点"立即运行" + poll 同时到点 → 不要 fire 两次。新增 task_run_logs.status='running' 行 + DB 行级锁(UPDATE scheduled_tasks SET last_status='running' WHERE id=? AND last_status!='running')确保同一任务只有一个 in-flight run。runScheduledTaskNow 抢锁失败 → 返回 { status: 'already_running', runId: <existing> }
  • Electron 通知点击不灵:macOS 系统通知中心可能在用户禁用通知后整体不弹,或点击后系统不传递 click 事件。降级:通知没弹或点击没传,scheduler 仍然完成任务执行 + 写 task_run_logs,用户打开主窗口仍能看到执行结果;不依赖通知点击作为唯一定位方式。
  • next_run schema 迁移:选 datetime(next_run) 路径不动 schema,每次 select 多一次函数调用——用户可视性能差异忽略不计。如果未来发现性能问题再做整数迁移。
  • delivery ack 丢失:renderer / Electron 主进程在 ack 之前进程被杀 → notification_events 留下 queued 但无对应 delivered。任务页 UI 表示"已入队,未确认送达",不假装 delivered。这是诚实降级,不是 bug。
  • Settings → 定时任务页未来与外部 Agent 任务事件混合显示:列表 source 字段已经留好"外部 Agent"分类,UI 第一版可显示但不可操作(只读),避免用户对 CodePilot 不能控制的任务做"暂停"按钮然后失败。

决策日志

按时间倒序,最新在前。条目从 active/refactor-closeout.md 整段迁移,不增不删。

  • 2026-05-10:Phase 3 v13 — 撤回 v11 右栏互斥(产品决策反向)。这是一次"我把方向修反了"的纠正。v11 接的 TODO 描述说"侧边栏与文件树互斥"——我读这句话理解成"目前 mutex 没钉死,需要把它钉死",于是双层加固:(a) topbar 两个按钮 onClick 加 if (next && otherOpen) closeOther() mutex 三件套;(b) 新建 RightRailMutexEnforcer 组件挂在 <WorkspaceSidebarProvider> 内做事件路径兜底。用户 review v11 时明确指出方向反了——TODO 那条原文是"产品决策是'互斥还是叠加'尚未明确",意思是产品决策本身不清晰、需要先定义;用户在使用中发现实际想要的是叠加:file tree 用来浏览文件、在文件上点击会让 WorkspaceSidebar 弹出 markdown / artifact / file preview Tab;如果同时把 file tree 关掉,用户就丢了浏览上下文。强制 mutex 比"两栏挤压聊天"更糟糕。撤回:(a) src/components/layout/AppShell.tsxRightRailMutexEnforcer 整个函数定义(约 18 行 + 它的 docstring)+ 删 provider tree 中的 <RightRailMutexEnforcer /> mount;(b) src/components/layout/UnifiedTopBar.tsx 文件树按钮 onClick 简化为 setFileTreeOpen(!fileTreeOpen),删 if (next && ws?.state.open) ws.setOpen(false) 三件套;(c) 同款简化 sidebar 按钮 onClick 为 ws.setOpen(!ws.state.open);(d) 改 ChatContentRow 顶部 docstring 与右栏 JSX 注释——把"Mutual exclusion (file tree vs sidebar) is enforced at the topbar onClick handlers"改为"v13 product decision: the two right-rail panels are additive — both can be open simultaneously"。契约测试反向right-rail-mutex.test.ts 文件名保留(git 历史可追),内容彻底翻转——v11 钉的"必须有 enforcer / 必须有 mutex 三件套"全部反成 assert.doesNotMatch,新加 6 例钉死叠加:(i) AppShell 不许有 function RightRailMutexEnforcer 声明;(ii) AppShell 不许 mount <RightRailMutexEnforcer />;(iii) topbar 文件树 onClick 不许含 if (next && ws?.state.open) ws.setOpen(false)(regex 精确锚到 mutex 三件套,避免误中 sidebar 自身的关闭按钮 ws.setOpen(false));(iv) topbar sidebar onClick 同款不许含 if (next && fileTreeOpen) setFileTreeOpen(false);(v) 双向正向钉死 toggle 调用还在(setFileTreeOpen(!fileTreeOpen) + ws.setOpen(!ws.state.open));(vi) <WorkspaceSidebar /><PanelZone /> 仍是 isChatDetailRoute 下的兄弟节点(layout 已经支持叠加,flexbox 自然处理);(vii) railVisible 仍用 ||(任一开就显示分割线,叠加状态下分割线照样显示)。沿用其它 repo-wide grep 测试的 stripComments(行先块后)helper,让 v13 源码里留下的退役理由注释("v11 added a RightRailMutexEnforcer...")不会触发自身 doesNotMatch。为什么会修反方向:原 TODO 描述的"互斥还是叠加尚未明确"我读成了一个待解决的歧义信号,没有去 review 历史决策(其实 v11 之前的 topbar onClick 就已经在做 mutex,那是上游某次"两栏挤压"反馈后的临时修法),直接顺势钉死了 mutex 这一面,没考虑用户实际想要叠加。教训:TODO 描述里出现"产品决策尚未明确"时不能默认按现有代码方向延续——这正是 TODO 说要"先定义、再决定改 UI 还是改交互"的地方,应该停下来问用户而不是自己选一个方向锁定。未做:(a) 没改 layout 处理"两栏同时开 + 聊天宽度太窄"的边界——flexbox 已经会让 main 内容缩到 min-w-0,极端窄屏可能挤到不可读,但用户已说接受这种 trade-off,不在 v13 范围;(b) 没在 topbar 加 hint / tooltip 说"两个按钮可同时开启"——视觉上两个按钮都各自显示 secondary 高亮,用户能看到状态独立,不需要额外提示。验证npm run test 1723 通过 0 失败 0 todo(v12 1724 → 1723 是 v11 旧契约里两条"必须有 mutex"被反向断言替代,6 例对 6 例数量平衡,但 v11 里多挂的"useWorkspaceSidebar 仍监听 WORKSPACE_TAB_OPEN_EVENT 作为 enforcer 存在理由"breadcrumb 已经不需要——撤回后没有 enforcer 也就不需要 breadcrumb 解释为什么 enforcer 存在——删掉,所以净 -1);npx next build ✓ Compiled successfully in 9.2s。CDP 实机 smoke 仍因 chrome-devtools-mcp profile 锁住没跑,用户在本地 /chat/<id> 路径下点 file-tree 按钮 + 点 sidebar 按钮可以验证:两个按钮可以独立 toggle、同时高亮、两栏并排显示在右侧。

  • 2026-05-10:Phase 3 v12 — 用户体感反馈两条:(A) 删掉 Assistant 页"定时任务"link 卡;(B) 心跳卡布局换行 + 文案精简。两条都来自一次"看着就不对"的现场感受,不是新发现的 bug——v9 把 Assistant 页的任务列表收成 link 卡时,看似把 IA 收紧了一层,但实际上全局 /settings/tasks 入口已经在 Settings sidebar 里直达,Assistant 页再放一个跳转入口属于多此一举,且不展示助理特有的信息(计数与全局相同),用户察觉到这点之后直接说"删掉";同时 v10 把心跳描述改长之后,原先的 flex items-center justify-between 让"标题 + 描述 + 状态"整块挤在 Switch 左侧,长描述折行后几乎贴到 Switch,需要排版重排。(A) Assistant 页"定时任务"link 卡删除AssistantWorkspaceSection.tsx 整段 SettingsCard(v9 加的 button + tasksLink* 文案)删除;tasks state 与 setTasks 删除;fetchTasks callback 与对应 useEffect 删除(v12 之前 useEffect 调 fetchSummary() + fetchTasks(),留下 fetchSummary 不动);ScheduledTask import 从 @/types 中移除(这是该文件最后一个消费方)。i18n 退役 4 个 key(zh + en 各 4 条):assistant.scheduledTasks 主标题 + v9 加的 tasksLinkEmpty / tasksLinkCount / tasksLinkAction,三处 grep 验证全仓没有其它消费方。(B) 心跳卡布局重排 + 文案精简WorkspaceStatusCards.tsx:CheckInCard 重写为:顶部一行 flex items-center justify-between gap-3 只放 <h2> 标题 + Switch;下面三段 <p> 全宽换行——描述(heartbeatDesctext-xs leading-relaxed、状态(lastHeartbeatLabel + heartbeatOk/Needed)、提示(editHeartbeatHint)。space-y-3 调整到 space-y-2.5 让密度跟新结构匹配。zh heartbeatDesc 从 89 字精简到 39 字("在助理工作区开始新对话时触发——不是后台定时任务。无事保持静默(HEARTBEAT_OK),有事主动告知。"),en 从 230 字符精简到 150 字符("Triggers when you start a new chat in the assistant workspace — not a background timer. Stays silent (HEARTBEAT_OK) if all is clear; speaks up if something needs attention.")。v10 钉的 7 条契约("不是后台定时任务" / "not a background timer" / "新对话" / "new chat" / "助理工作区" / "assistant workspace" / outcome 半句保留 / title 不变)依旧全部满足,不需要改测试期望——这正是 v10 把 honesty 拆成"必须含 X"和"不许动 Y"两类断言的好处:copy 可以缩,但 invariant 不会松。契约测试更新assistant-tasks-link-only.test.ts v9 那版钉的"必须有 link 入口"被 v12 反过来——重写为"完全不许有 scheduled-task 入口":(i) 不许 tasks.map(;(ii) 不许 useState<ScheduledTask[]>;(iii) 不许 setTasks(;(iv) 不许 ScheduledTask import;(v) 不许 handleDeleteTask 与 DELETE /api/tasks 调用(v9 invariant 保留);(vi) 不许 Trash import(v9 invariant 保留);(vii) 不许 router.push("/settings/tasks…")(v12 新增);(viii) 不许引用 7 条退役 key(scheduledTasks + 3 条 v9 link key + 3 条 v6 已退役的 list key);(ix) zh + en bundle 不许再定义这 7 条 key。文件名保留 assistant-tasks-link-only.test.ts 留 git 历史可读性,文件顶 docstring 把 v6 → v9 → v12 的退役链路全部写明,未来打开看到"link-only"这个名字的人能立即知道实际是 v12 版本的"no entry at all"。注释剥离 helper(stripComments)沿用其它 repo-wide grep 测试的"行先块后"顺序,避免 v12 退役理由注释("v12 — Scheduled tasks block removed entirely…")触发自身负面 grep。踩到的小坑:第一版改 AssistantWorkspaceSection 时把 fetchTasks 改成了一个 placeholder fetchTasksUnused 试图保持 hook 顺序——这是无意义的——下一刻直接彻底删掉了 callback + 它在 useEffect 中的调用,与 fetchSummary 的 effect 改 deps 同步。未做:(a) 没顺手统一 zh + en 在 assistant.heartbeatTitle 上是否对齐"Heartbeat" / "心跳检测"——v10 review 时已确认"心跳"作为类比是合适的,v12 不动。(b) editHeartbeatHint 文案不变("编辑工作区中的 HEARTBEAT.md 来自定义检查内容"),它本身就是一句 hint 不需要排版调整。(c) 没动 OnboardingCard 同款 layout——它只显示一行状态文字 + Reconfigure 链接,没遇到 v10 那种长描述压迫 Switch 的问题,保留原 flex items-center justify-between验证npm run test 1724 通过 0 失败 0 todo(v11 1722 → +2 是 assistant-tasks-link-only.test.ts 从 6 例扩到 8 例 / heartbeat 7 例不变;其它测试都没动);npx next build ✓ Compiled successfully in 7.9s。CDP 实机 smoke 仍因 chrome-devtools-mcp profile 锁住没跑,用户在本地能在 Settings → Assistant 工作区 看到:"助理工作区"卡 + "定时任务"卡片消失 + 心跳卡顶部一行(标题 + Switch 不再贴在一起)+ 描述全宽不挤。

  • 2026-05-10:Phase 3 v11 — 复制 ID 报错 + 右栏互斥两条尾巴一起收。两条都是"用户路径已知 / 不需要产品决策只需要工程修复"型 bug,本批同步收掉,TODO 列表清零。(A) 复制对话 ID / 复制路径报错:根因——三个 callsite 都做 fire-and-forget navigator.clipboard.writeText(value)src/components/layout/UnifiedTopBar.tsx:114-117handleCopyId(顶栏下拉"复制对话 ID")、src/components/layout/SessionListItem.tsx:157-159 下拉菜单"复制对话 ID"、src/components/layout/ProjectGroupHeader.tsx:118-123 下拉菜单"Copy folder path"。Electron renderer 在 DropdownMenu blur 后页面失焦时,writeText 会 reject NotAllowedError(permissions / focus check),调用方既没 await 也没 catch → unhandled promise rejection 进 console / Sentry,且用户没拿到任何反馈,分不清复制是不是成功。修法:新增 src/lib/clipboard.ts:copyWithToast({ text, t, successMessageKey?, failureMessageKey? }) 单一入口,体内 try { await navigator.clipboard.writeText(text); showToast({ type: 'success', message: t(successKey) }); } catch { showToast({ type: 'warning', message: \${t(failureKey)} ${text}` }); }——success 用 success type 给 toast,failure 用 warning type 并把原文 inline 在文案里(复制失败,可以手动复制:<原文>/Could not copy. You can copy this manually: ),让用户即使失败也能从 toast 直接选中复制。三处 callsite 都改成 void copyWithToast({ text, t })useTranslation已经在 UnifiedTopBar / ProjectGroupHeader 内可用,SessionListItem 通过 props 接收t。新增 common.copySuccess/common.copyFailed 双语 i18n key(zh: "已复制到剪贴板" / "复制失败,可以手动复制:";en: "Copied to clipboard" / "Could not copy. You can copy this manually:")。**(B) 右栏 file-tree ↔ sidebar 互斥漏洞**:根因——topbar 上的两个 toggle 按钮 (UnifiedTopBar.tsx:329-337file-tree、:357-364 sidebar) 已经实现 mutex,点开一边会同步关另一边。但 sidebar 还有一条**事件驱动的 open path**:file-tree 点击文件 /MessageItemmarkdown / artifact 点击 / DiffSummary 卡片都会dispatchEvent(new CustomEvent(WORKSPACE_TAB_OPEN_EVENT, ...))useWorkspaceSidebar.tsx:96-109的 listener 把它翻译成pureOpen(prev, tab) => openDynamicTab(...),而 lib/workspace-sidebar.ts:123openDynamicTab永远 setopen: true。这条路径 SQL-完全绕过 topbar 按钮的 mutex——用户开 file tree 浏览,点一个 markdown 文件,sidebar 弹出 preview,两个右栏同时挤压聊天区。**修法**:新增 RightRailMutexEnforcer组件挂在内(必须在 provider 内才能useWorkspaceSidebarOptional(),同时 PanelContext 的 setFileTreeOpen 也需要先于此 provider,所以挂在 provider 子树第一行)。组件体只是 useEffect(() => { if (wsOpen && fileTreeOpen) setFileTreeOpen(false); }, [wsOpen, fileTreeOpen, setFileTreeOpen])——sidebar 一旦 open 且 file tree 也 open,立即关 file tree。Asymmetric on purpose:file-tree 的唯一开启入口是 topbar 按钮(UnifiedTopBar.tsx:329),按钮 onClick 已经同步 if (next && ws?.state.open) ws.setOpen(false)关 sidebar,反向不需要 watcher。Sidebar 选 file-tree-then-sidebar:file-tree 还在;新 sidebar 触发 effect → 关 file-tree。Sidebar-then-file-tree:file-tree 按钮关 sidebar → 不会双开。事件路径开 sidebar:effect 关 file-tree → 单开。三种顺序都不再双开。**契约测试**:(i)clipboard-toast-feedback.test.ts9 例:helper 必须 exportcopyWithToast/ 体内必须try { await navigator.clipboard.writeText(...) } catch/ 必须有 ≥2 个 showToast 调用且包含 type:'success' + type:'warning' 各一次 / 三个 callsite 必须 importcopyWithToast且不再有裸navigator.clipboard.writeText/ zh + en bundle 必须含common.copySuccess+common.copyFailed。第一版 grep 太松——三个 callsite 文件里我自己写的 v11 修复-说明注释里包含 "navigator.clipboard.writeText" 字面量解释根因,第一次跑 fail;加 stripComments helper(先剥行注释、后剥块注释,沿用其它 repo-wide grep 测试同款顺序避免 prose 里 /*误吞)解决。(ii)right-rail-mutex.test.ts6 例:AppShell.tsx 必须有function RightRailMutexEnforcer/ 体内必须usePanel()+useWorkspaceSidebarOptional()+ 一个 useEffect 调setFileTreeOpen(false)/必须在子树内(否则 hook 调用会抛"必须在 Provider 内")/ topbar 两个 onClick 仍保留同步 mutex(防止"未来移到一处"误删一边) /useWorkspaceSidebar.tsx还得有WORKSPACE_TAB_OPEN_EVENT监听 +openDynamicTab 调用、workspace-sidebar.ts openDynamicTab还得 setopen: true——后两条是 breadcrumb,把"为什么需要 enforcer"钉在源里,未来若移除事件路径会立即 fail,提示重新评估 enforcer 是否仍需要。第一版 mutex 同步 onClick 正则的 [\s\S]{0,200}?太短没盖到中间多行解释注释,调成 500 字符通过。**未做**:(a) 没添加运行时(mock-based)测试验证copyWithToast 的真实 await + catch 路径——现有 unit test 套件没有 RTL / jsdom,加一套测试基础设施超出本批 scope;source-grep 契约 + 手动 smoke 已经覆盖关键不变量。(b) 用户主动选了一个不存在的 ghost provider 时是否 trigger mutex—不在本批讨论;mutex 只解决双开问题,不影响 ghost provider 错误链路(那条已被 v8 / Phase 2 Step 4b 完整覆盖)。**验证**:npm run test 1722 通过 0 失败 0 todo(v10 1707 → +15 = clipboard 9 例 + mutex 6 例);npx next build ✓ Compiled successfully in 8.3s。refactor-closeout` TODO 列表至此清空;下一步从 Phase 4-6 中挑一条启动。

  • 2026-05-10:Phase 3 v10 — 助理心跳文案诚实化(IA 闭环 2/2)。Phase 3 Step 4 plan 长期挂在"未做",本批收掉。根因assistant.heartbeatDesc 双语原文是"启用后,助理每次访问时检查 HEARTBEAT.md…" / "When enabled, the assistant checks HEARTBEAT.md on each visit…"——"每次访问" / "on each visit" 是含糊词;用户读到"启用 + 访问"很自然推断"开了开关就有后台定时器,每隔几小时帮我检查一次"。但 useAssistantTrigger.ts 的实际机制窄得多:心跳只在 (i) 用户在助理工作区路由内 (ii) 打开了一个空的新对话 (iii) 服务端报 needsHeartbeat=true 三个条件同时命中时通过 autoTrigger 跑一次。关掉应用 / 切到 Bridge / 切到 Settings → 心跳不会主动跑;过了一天再开应用、不进工作区也不会跑。整条 Phase 3 主线(Step 3 把 reminder 升级成不依赖 AI 的真定时任务 + delivery log + Bridge 解耦)已经把"真定时器"职责全部搬到 /settings/tasks,心跳描述还停留在含糊的"访问"语义就是历史遗留——本批纠正。改动:(a) src/i18n/zh.ts:1116assistant.heartbeatDesc 改成"打开后,每次你在助理工作区开始新对话时,助理会读取 HEARTBEAT.md(这不是后台定时任务,关闭应用或离开工作区时不会主动跑)。没有需要报告的内容 → 保持静默(HEARTBEAT_OK);有需要关注 → 主动告知。"——肯定半句锚到"在助理工作区开始新对话时"(明确触发点),插入"这不是后台定时任务,关闭应用或离开工作区时不会主动跑"(显式否定误解半句),保留原有 silent / speak-up 后半段。(b) src/i18n/en.ts:1129 同款英文:"When on, the assistant reads HEARTBEAT.md each time you start a new chat in the assistant workspace (this is not a background timer — it does not run when the app is closed or you are not in the workspace). Nothing to report → stays silent (HEARTBEAT_OK); something needs attention → speaks up."(c) Title (heartbeatTitle "心跳检测" / "Heartbeat") 不动——v10 是描述层 honesty fix,不是命名层重构;"心跳"作为类比(医疗 / 服务器健康检查"silent if alive")和实际行为是吻合的。契约测试 heartbeat-copy-honesty.test.ts 7 例钉死:(i) zh heartbeatDesc 必须含字面"不是后台定时任务";(ii) zh heartbeatDesc 必须 anchor 到"新对话" + "助理工作区"(避免下一个 PR 把"新对话"改成"访问"或类似含糊词重新滑回原版);(iii) en heartbeatDesc 必须含"not a background timer";(iv) en heartbeatDesc 必须 anchor 到"new chat" + "assistant workspace";(v)+(vi) zh + en 必须保留 silent / speak-up 半句("HEARTBEAT_OK / 保持静默" + "主动告知 / 有需要关注" 在中文;"HEARTBEAT_OK / stays silent" + "speaks up / needs attention" 在英文)——确保下一刀文案微调不会"修了 honesty 顺手砍了 outcome 描述";(vii) heartbeatTitle 双语保持原值,避免无谓重命名。未做:(a) Settings → Assistant 的 Switch toggle 没加单独可见 label,依赖 description 提供 context——v10 是 copy-only fix,不动 layout / 增加 UI elements。(b) 无 CDP 实机 smoke(chrome-devtools-mcp profile 仍被锁);用户在本地 Settings → Assistant 工作区 → 心跳检测 卡片可以核对新文案。验证npm run test 1707 通过 0 失败 0 todo(v9 1700 → +7 新 contract);npx next build ✓ Compiled successfully in 7.4s。期间 apply-discovery-diff.test.ts 首跑闪挂一次(同样的 DB_PATH module-load 冻结导致跨 it block setting state 泄漏,已知现象,与本批改动无关),单独跑 + 重跑全量都 PASS。Phase 3 IA 至此全部闭环:Tasks 页负全局任务管理 + Assistant 页只剩心跳 + 主动问候 + 心跳文案明确边界。Phase 3 不再有未闭环 TODO。

  • 2026-05-10:Phase 3 v9 — Settings → Assistant 任务列表搬走(IA 闭环 1/2)。Phase 3 plan 与 v6 product 决策都锁过"全局任务管理搬到 /settings/tasks,Assistant 只管助理行为",但 v6 之后 AssistantWorkspaceSection.tsx:547 的 SettingsCard 仍内联渲染 task list(任务名 + 调度值 + next_run + 状态徽章 + 删除按钮),与全局 Tasks 页形成双入口;用户在 Assistant 页删任务的旧路径仍工作,且 UX 不一致(无 expand 看 delivery log、无暂停 / 立即运行)。本批 v9 把这条 IA 尾巴收掉。改动:(a) src/components/settings/AssistantWorkspaceSection.tsx:545-590 整段内联列表 + delete 按钮换成单行 link button —— <button onClick={() => router.push('/settings/tasks?source=assistant')}> 包含 "共 N 个定时任务"(或空状态 "还没有定时任务")+ 右侧 "在 设置 · 定时任务 中查看 →" 行动文字。计数仍走原 fetchTasks/api/tasks/list(同 TasksSection 一致),保留对 tasks.length 的读,不再 tasks.map(...) 渲染逐行。?source=assistant 是 URL 协议预留位 —— TasksSection 当前不读这个参数(searchParams.get('focus') 已存在但没 source 分支),未来若想做"按助理来源筛选"的视图,URL 契约已经在位;本批不做后端筛选实现。(b) 删 handleDeleteTask callback(fetch /api/tasks/:id { method: DELETE })—— 删除责任彻底交给 TasksSection,Assistant 页不再持有 destructive action。(c) 从 import { SpinnerGap, CheckCircle, X, Trash }Trash(这是行级 delete glyph 的唯一消费方)—— 让"未来谁想加回内联列表 + 删除按钮"必须连 import 一起改回,typecheck 立刻提醒到位。(d) 退役 i18n key 3 条(assistant.taskDelete / assistant.taskNextRun / assistant.noTasks),新增 3 条(assistant.tasksLinkEmpty 空状态文案、assistant.tasksLinkCount{count} 占位符的计数文案、assistant.tasksLinkAction 跳转文字),中英 bundle 同步。契约测试 assistant-tasks-link-only.test.ts 6 例钉死 IA 决议:(i) 不许 tasks.map(...) 渲染逐行(list 整体不允许复活);(ii) 不许 handleDeleteTask 符号(删除责任不归这里)+ 不许 fetch DELETE /api/tasks/...(双兜底);(iii) 不许 import { Trash } from '@/components/ui/icon'(图标退役);(iv) 必须 router.push("/settings/tasks…") 调用(link 入口存在);(v) 必须用新 tasksLink* 三 key 且不许引用退役 key;(vi) zh + en bundle 必须定义新 key、必须删退役 key、tasksLinkCount 必须含 {count} 占位符(避免 .replace 静默失败)。踩到的小坑:第一版 i18n key 检查只看是否引用,没看 bundle 是否真定义;后扩到读两个 locale 文件 + 正则匹配 'key': 形式 + 确认 {count} 占位符存在 —— 防止"key 在源码里写了但 zh.ts 还没补 / en.ts 漏一个"导致运行时拿不到翻译,build 时通过但 render 时变 i18n key 字面量。未做:(a) ?source=assistant 后端筛选 —— 当前 /api/tasks/list 不接受 source 过滤,TasksSection 也不读 ?source 参数;本批只把 URL 契约预留,实际筛选视图作为可选增强不做。(b) Assistant 页计数仍是"全部任务数"而非"助理创建的任务数"——同样需要后端 source tagging,本批不引入;当前用法是"看到全局任务总数 + 跳转到 Tasks 页",这与点入口前用户想知道的"我有几个定时任务"语义对得上。(c) 心跳文案诚实化仍是 Phase 3 IA 尾巴 2/2,留下一刀。验证npm run test 1700 通过 0 失败 0 todo(v8 1694 → +6 新 contract);npx next build ✓ Compiled successfully in 7.0s;CDP 实机 smoke 因 chrome-devtools-mcp profile 仍被锁住没跑,源码契约 + 单测覆盖足够,用户在本地可以走 Settings → Assistant 工作区 看到单行 link 卡片确认。

  • 2026-05-10:Phase 3 v8 尾巴收两条:(A) sendNotification 返回 deliveries 保留 error 字段;(B) /chat?prefill=… warm-navigation 回填修复。两条都属于"Phase 3 Step 3 主线已经收口、剩余功能正确性 / API 一致性"边角,不开新 Phase。(A) deliveries.error 字段恢复:v7 P2 把 collector 改成 Map<channel, { status, error? }> 后,最终的 Array.from(...).map(([channel, { status }]) => ({ channel, status })) 出口只解构 status,把 error 字段丢了;DB 行(upsertNotificationDelivery 写)仍带 error,UI 通过 /api/tasks/[id]/runs 直接 join DB 不受影响,但 /api/tasks/notify POST 的响应(外部 Bridge clients / 测试 / 未来 delivery-log 面板)只能看到 channel 失败、看不到失败原因。修法:(i) sendNotification 返回类型签名 Array<{ channel: string; status: string; error?: string }>(v7 漏的字段补上);(ii) map projection 改为 error ? { channel, status, error } : { channel, status }——成功 channel 仍然序列化成两字段,避免 error: "" / error: undefined 噪声进 JSON 响应。send-notification-dedup.test.ts 扩 2 例契约:返回类型签名必须含 error?: string、map 解构必须有 { status, error } 形态——任意一处被未来重构改回 { status } 立即红。(B) /chat?prefill=… 回填修复:之前用户从 /settings/tasks 点"新建任务" → router.push('/chat?prefill=…') 落到 chat 页,URL 有 prefill 参数但 textarea 是空的。两层 staleness:(i) src/app/chat/page.tsxuseMemo([])window.location.search——只在首次 mount 跑一次,warm 导航 / back-forward 改 query 后缓存值不刷新;(ii) src/components/chat/MessageInput.tsxuseState(() => initialValue || draft)——只在 mount 时读 initialValue prop,之后即使父喂新值,textarea 也不动。修法:(i) chat/page.tsx 的默认 export 拆出 NewChatPageInner,外层包 <Suspense fallback={null}>,内层用 useSearchParams() 读 prefill——React 会在 URL 变化时自然 re-render;(ii) MessageInput 加 adoptedInitialValueRef = useRef(initialValue) 跟踪上次 adopt 的 prop 值,新增 useEffect:检测到 initialValue !== adoptedInitialValueRef.current && initialValue 时调 setInputValue(initialValue),与 mount 时"prefill 战胜 draft"的优先级一致;prop 回到空字符串时重置 ref,让同一文本下次再 arrive 时仍被视为 fresh transition;稳定 prop 跨多次 render 是 no-op,不会 clobber 用户已经在打的字。新增 chat-prefill-warm-navigation.test.ts 7 例契约:chat/page.tsx 必须 import Suspense + useSearchParams、必须 NOT 含 useMemo([]) + window.location.search 读 prefill 的 stale pattern、默认 export 必须包 <Suspense>、必须用 searchParams.get('prefill');MessageInput 必须有 adoptedInitialValueRef、必须有 deps 含 initialValue + setInputValue 的 useEffect 调 setInputValue(initialValue)adoptedInitialValueRef.current 必须有 ≥2 处赋值(adopt 分支 + empty-reset 分支)。验证npm run test 1694 通过 0 失败 0 todo(v7 1687 → +7 chat-prefill-warm-navigation 新例 + 2 dedup 扩 = 净 +9,dedup 文件原 2 例还在,所以是新例 7 + 扩 2 = 9,符合算式);npx next build ✓ Compiled successfully in 7.0s。期间 providers-options-route.test.ts 的"Pinned → Auto: response.options has empty pinned keys"用例首跑闪挂一次,与本批改动无关——属于 DB_PATH 在 module load 时冻结导致跨 it block 的 setting state 泄漏(closeDb() 只 null handle 不重排路径,已知现象,多次复用同 setting key 时偶发命中),重跑稳定通过。未做:CDP 实机 smoke 因 chrome-devtools-mcp profile 被另一进程锁住没跑,主链路改动有源码契约 + 单测覆盖;用户在本地可以走 npm run dev → /settings/tasks → 新建任务 做一次端到端确认。

  • 2026-05-09:Phase 3 Step 3 v7 review-fix(1 P0 SQLite bug + 1 P2 return-shape 重整 + 1 P3 类型清理)。v6 上线后再压一轮,Codex 抓出 3 件,本批一次性收掉,不动产品形态、只做正确性 / 协议 / 类型层面收紧。(1) P0 — notify_on_complete 直接把 boolean 灌进 SQLite:v6 的 /api/tasks/schedule 路由把 body 解出来直接交给 createScheduledTask;AI tool / 外部 caller POST notify_on_complete: true 是再自然不过的 JS 形态,但 notify_on_complete 列是 INTEGER,better-sqlite3 直接抛 "SQLite3 can only bind numbers, strings, bigints, buffers, and null",整条任务创建链 500 出门——任务页"新建任务"AI 调用就这么炸了。三层防御一次铺到位:(a) 路由 (src/app/api/tasks/schedule/route.ts) 把 notify_on_complete 归一化成 notifyFlag: 0 | 1false / 0 / '0' → 0;undefined / 缺失 → 1,匹配历史默认"完成必通知"语义)后再交给 DB;(b) src/lib/db.ts:createScheduledTask 自身加防御性 coerce —— rawNotify === false || rawNotify === 0 || rawNotify === '0' → 0,其余 → 1,未来任何绕过路由的直接调用(或 typecast 漏网)也不会再炸 SQLite;(c) AI SDK builtin (src/lib/builtin-tools/notification.ts) 的 durable=true POST body 显式声明 const notifyFlag: 0 | 1 = notify_on_complete === false ? 0 : 1 然后 body: JSON.stringify({ ..., notify_on_complete: notifyFlag, durable })——绕过 notify_on_complete, ES6 简写,从源头保证 wire 上永远是 integer,与 notification-mcp.ts 协议保持一致。新增 schedule-notify-flag-normalize.test.ts 5 例:route 层 true → 200 + DB row 1、false → 200 + DB row 0、缺失 → 默认 1,db 层(绕路由)booleans 仍被 coerce,源码 grep 契约——builtin tool 的 fetch(... /api/tasks/schedule ...) 的 body 段不能含 notify_on_complete,notify_on_complete} 形态的 ES6 简写、必须含 notify_on_complete: notifyFlag|0|1|<ternary>(用 fetch\([^)]*\/api\/tasks\/schedule[\s\S]*?body:\s*JSON\.stringify\(\{([\s\S]*?)\}\) 抓 body 内容,避免上游 schema 声明 notify_on_complete: z.boolean().optional(),execute: async ({ ..., notify_on_complete, durable }) 函数参数解构被误判)。(2) P2 — sendNotification 返回数组同 channel 重复:v6 实现用 deliveries: Array<{channel, status}> 累积,每次 writeDelivery push 一行;urgent + Bridge 配置好的快路径,bridge-telegram 先入 queued 再 fire 后入 delivered,返回数组里就同一个 channel 出现两次;DB 行因为 UPSERT 不重复,但 /api/tasks/notify route 与读取响应的客户端会看到 channel 重复 + 旧状态。改 src/lib/notification-manager.ts 收集器为 const deliveryStates = new Map<string, { status, error? }>(),所有 writeDelivery(channel, status) 落到 deliveryStates.set(channel, ...)(覆盖而不是追加),bridge fire 路径直接 deliveryStates.get('bridge-telegram')?.status 读最新态,最后 Array.from(deliveryStates.entries()).map(([channel, {status}]) => ({channel, status})) 重建返回数组——Map 迭代顺序 = 插入顺序,外部观察到的顺序仍然 renderer-toast → electron-native → bridge-*。新增 send-notification-dedup.test.ts 2 例:non-urgent 走 sendNotification 实跑路径(无 Bridge 候选 → 不会启 Telegram long-poll)锁 uniqueByChannel + 候选集 = {renderer-toast, electron-native};source-grep 契约钉住实现层——必须含 new Map<string, ...> + deliveryStates.set( 调用 + Array.from(deliveryStates...) 出口,且 deliveries.push({channel:...}) 老 pattern 不允许复活。(3) P3 — 退役 electron-bg-native 字面量:v6 P1 fix 把 bg-poller 的 ack channel 从 electron-bg-native 改成 electron-native(与 sendNotification 入队预写的同一行 UPSERT),但 src/types/index.ts:NotificationChannel 联合类型里仍然列着 'electron-bg-native'——一旦 typecheck 默认放行,未来 PR 完全可能用 as NotificationChannel 把这个旧字面量再写回 runtime 代码,回归无声。从 union 删字面量、留一句解释注释(// 'electron-native' covers BOTH renderer-driven and bg-poller paths (v6 P1 fix unified them). Retired 'electron-bg-native' literal is intentionally NOT listed here.);扩 bg-poller-channel-parity.test.ts 2 例:(i) NotificationChannel 联合类型不含 electron-bg-native 字面量;(ii) 全 src/ 目录递归扫 .ts/.tsx,跳注释行(// / * / /*)+ 跳测试自身,不允许出现 'electron-bg-native' / "electron-bg-native" 字符串字面量——整层 (类型 + runtime + 测试) 三重锁。踩到的坑(v7 自身):(α) schedule-notify-flag-normalize.test.ts 第 5 例首版 grep 太松——anchor 用 /api/tasks/schedule 字面量首次出现 + 非贪婪匹配到第一个 body: JSON.stringify({...}),但 builtin tool 文件里 /api/tasks/schedule 在注释里先出现两次("Server validates; if this is missing"),把 schema 声明 notify_on_complete: z.boolean().optional(), + execute: async ({ ..., notify_on_complete, durable }) 函数参数解构都吞进匹配区,假报"POST body 含 ES6 简写"。修法:anchor 改成 fetch\([^)]*\/api\/tasks\/schedule 强制锚到真实 fetch 调用 + 用第二个 capture group 抓 body 内容、断言只对 body 内容生效。(β) send-notification-dedup.test.ts 首版第 2 例实跑 urgent + 假 Telegram 凭据,notifyGenericimport('@/lib/telegram-bot') 启了 long-poll 循环,setInterval 永不退出 → npm run test 进程在该测试通过后挂死、所有后续测试文件无法启动。修法:保留 non-urgent 实跑(不会启 Bridge),把 urgent 路径的契约下移到 source-grep——直接断言 notification-manager.ts 必须用 Map keyed by channel + 不允许 deliveries.push({channel:...}) 复活。(γ) 第 3 例 "urgent + Bridge unconfigured → not_configured" 因 db.ts 在 module load 时冻结 DB_PATHcloseDb() 只 nulls handle 不重排路径),上一例写入的 telegram_bot_token / chat_id 会跨 it 块泄漏到本例,导致"未配置"分支跑不出来——直接删掉,注释说明跨 it 状态泄漏的根因。验证npm run test 1687 通过 0 失败 0 todo(v6 1678 → +9 = schedule-notify-flag-normalize.test.ts 5 例 + send-notification-dedup.test.ts 2 例 + bg-poller-channel-parity.test.ts 扩 2 例);npx next build ✓ Compiled successfully in 7.1s。未做(按 v7 边界):(a) Tasks 页"新建任务" → /chat?prefill=... 跳转后 URL 带 prefill 参数但输入框仍空——确认是 MessageInputinitialValue prop 在挂载后变化不生效(不是 SQLite bug 链路),本批 v7 范围只压数据正确性、不动 UI 反应式,留下一刀做 MessageInput.initialValue 的 reactive update(path query change 时 setState textarea 内容)。(b) Electron 端 OS 通知点击 / 关窗 / 后台触发 / 唤起的端到端短跑仍按 AGENTS.md 留给用户做实机 smoke。

  • 2026-05-09:Phase 3 Step 3 v6 review-fix(4 P2 + 1 product redesign + 5 件功能正确性 / hydration / UX 问题修完)。Codex review 抓到 5 件 v3-v5 计划没覆盖到的实现 bug,本批一次性收掉。(1) P1 — delivery log 没串到 run row 上:v5 实现里 sendTaskNotification 返回 void,executeDueTask 没拿到 event_id,task_run_logs.notification_event_id 一直是 NULL,/api/tasks/[id]/runs 按它做 join 但永远拿不到 event/deliveries。修法:sendNotification 已经返回 {event_id, deliveries} —— sendTaskNotification 改为返回 string | nullexecuteDueTask 在成功通知后调 updateTaskRunLog(runId, { notification_event_id: eventId }) 把伞事件挂回 run row;失败路径同款(失败也发通知)。新增 run-event-link.test.ts 端到端跑一个 reminder + 一个 ai_task 失败任务,断言两条 run row 的 notification_event_id 都非 NULL,replay /runs 的 join 逻辑能拿到 event + N 条 deliveries(renderer-toast / electron-native)。(2) P1 — 隐藏窗口 bg-poller channel 不一致sendNotification 入队时为 normal/urgent 写 electron-native: queued;窗口隐藏后 bg-poller 弹了系统通知却 ack 一个新 channel electron-bg-native,原来的 electron-native 永远 queued,UI 显示"通道未确认"但用户其实已经看到了通知。修法:bg-poller 改 ack electron-native: delivered(同一行 UPSERT);同时 ack renderer-toast: skipped(drain 已经把队列消费掉,renderer 后续不会再看到这条 → 不主动标 skipped 那一行也会永远 queued)。electron-bg-native 这个名字彻底退役。新增 bg-poller-channel-parity.test.ts source-grep 契约 3 例:bg-poller 必须 ack electron-native: delivered/error + renderer-toast: skippedelectron-bg-native 字面量在 main.ts 里禁出现。(3) P2 — builtin tool 忽略 durable=falsesrc/lib/builtin-tools/notification.tscodepilot_schedule_task 声明了 durable 字段但 execute 里没分支,durable: false 仍然 POST /api/tasks/schedule 创建持久任务。这与 schema 承诺不符。修法:照搬 notification-mcp.tsdurable=false session-task 分支,session task 字面量带 kind 透传(v4 fix #1 parity)。新增 builtin-tool-durable-parity.test.ts 3 例锁两个文件 schema 一致 + builtin execute 必须 branch on durable === false + 必须调 addSessionTask + session 字面量必须带 kind(4) P2 — /settings/tasks hydration mismatch:v5 实现里 src/app/settings/layout.tsx(mobile 横向 nav)和 src/components/layout/SettingsSidebar.tsx(desktop sidebar)各有一份 sidebarItems 字面量,加 Tasks 时只更新了一份就上线,server SSR 还是老顺序、client 渲染了新顺序,CDP console 报 hydration error。修法:抽 src/components/settings/nav-config.ts 单一来源(SETTINGS_NAV_ITEMS + pathnameToSettingsSection helper),两个文件都从这里 import;以后再加 section 改一处即可。(5) Product redesign — Tasks 页不再当"创建器":v5 在任务页里塞了完整的创建 dialog(kind / name / prompt / schedule_type / once_minutes / interval_value / priority 一堆字段),把用户拉回了"后台管理界面"心智。v6 product 决策:任务页做列表 + 立即运行 + 暂停 + 删除 + 展开看 delivery log;"新建任务"按钮 router.push('/chat?prefill=...'),prefill 是一段 prompt 引导 AI 调 codepilot_schedule_task,让 canonical MCP/tool 入口承担创建逻辑,UI 不复制一套。同时给每行加可展开 delivery log(lazy fetch /api/tasks/[id]/runs,按 notification_event_id join 出 event + deliveries,每个 channel 显示 status + error)—— v6 P1 fix 让这个 UI 真的能拿到数据。i18n 删掉所有创建表单文案(fieldKind / fieldName / fieldPrompt / scheduleOnce / scheduleInterval / fieldOnceMinutes / fieldIntervalValue / fieldPriority / createDesc / creating / cancel 等),新增 tasks.createHint(空状态 hint:点新建任务进 chat)+ tasks.deliveryLog(执行记录与通知通道)两个 key。踩到的坑:v5 ack 测试里复用 event_id='evt-test-1' 字面量 + 同 file 多 it block 共享 module-cached db(DB_PATH 在 module load 时冻结,closeDb 只 nulls handle 不重 path)→ notification_events.event_id UNIQUE 在第二个 it 起就失败。修法:每次 setupEvent 生成 evt-test-<timestamp>-<counter> 唯一 id,避开 module-cache 路径冻结的根因。验证npm run test 1678 通过 0 失败(前 1670 → +8 = run-event-link.test.ts 2 例 + bg-poller-channel-parity.test.ts 3 例 + builtin-tool-durable-parity.test.ts 3 例);npx next build 通过;node scripts/build-electron.mjs 通过。未做(v6 边界):(a) 手动 Electron 实机 smoke 仍按 AGENTS.md 短跑规则留给用户做端到端验收(关窗 → 等到点 → OS 通知 → 点 → 落到 Tasks 页焦点);(b) Settings → Assistant 助理任务列表残留区域仍未清理 → 改成 /settings/tasks?source=assistant 链接的工作放到下一刀;(c) /chat?prefill=... 入口已经过验证(chat/page.tsx:60-65 早已实现 prefill 读取并喂给 MessageInput initialValue),任务创建走 AI tool 链路在 dev/Electron 实机上需要用户做一次完整 smoke 确认;(d) cron 表达式编辑器 / 多任务批量操作 / 任务模板等高级功能继续按 plan 边界不做。

  • 2026-05-09:Phase 3 Step 3 实现完成(v5 计划落地,1670 通过 0 失败)。按 v5 plan 8 个 stage 顺序推进,每完一阶段保持 typecheck + 单测绿。Stage A 类型 + DB schema:(a) src/types/index.ts 新增 ScheduledTaskKind = 'reminder' | 'ai_task' 类型 + NotificationChannel / NotificationDeliveryStatus 联合类型 + NotificationEvent / NotificationDelivery 接口;ScheduledTask.kind 从可选变必填,typecheck 强制所有读写路径带字段;(b) src/lib/db.ts scheduled_taskskind TEXT NOT NULL DEFAULT 'ai_task' CHECK(kind IN ('reminder', 'ai_task')) 列 + safeAddColumn 老库迁移;task_run_logsnotification_event_id TEXT 关联列;新增 notification_events (event_id UNIQUE) + notification_deliveries (UNIQUE(event_id, channel) + status CHECK 5 态);getDueTasks 字符串比较 bug 修复 next_run <= datetime('now')datetime(next_run) <= datetime('now'),同日 +5 分钟提醒终于按时触发;新增 updateTaskRunLog / listTaskRunLogs / insertNotificationEvent / upsertNotificationDelivery (UPSERT + 状态机 guard) / listNotificationDeliveries / getNotificationEvent 一组 helper;createScheduledTask 显式拒绝缺失 kind,不靠 SQLite default 兜底。Stage B 调度器核心executeDueTasktask.kind 分发——reminder 路径 prompt 文本即通知正文,不调 generateTextFromProvider("喝水提醒"无 provider 也工作);ai_task 路径保留现行 provider 链。run-row 生命周期写死 "一次执行 = 一行":插入 running 拿 runId → 完成后 updateTaskRunLog(runId, …) 原地翻 success / error,不再"插 running 再插 success"。新增 runScheduledTaskNow(taskId) 受控执行入口:行级锁 UPDATE scheduled_tasks SET last_status='running' WHERE id=? AND last_status!='running' 抢锁失败返回 { status: 'already_running', runId };成功立即返回 { runId, status: 'running' },fire-and-forget 跑实际工作。新增 recoverStaleRunningTasks() 启动恢复——last_status='running'updated_at > 30 min 前的 task 标 error + backoff next_run,对应 task_run_logs 中的 running 行同款翻 error。sendTaskNotification 第 4 个参数携带 { taskId, sessionId } 透传给 notification-manager。Stage C notification-manager:彻底重写为 1:N 模型——sendNotification() 写 1 行 notification_events (queued) + 按 priority × 配置状态枚举候选 channel 写 N 行 notification_deliveries(renderer-toast 全 priority、electron-native normal+urgent、bridge-telegram 仅 urgent)。Bridge × priority 锁定:urgent 时按 telegram 配置状态写 not_configured / skipped / queued(成功后翻 delivered / error);non-urgent 不写 bridge-* 行(产品策略:Bridge 是 urgent-only 候选)。返回 { event_id, deliveries },老 callers 读 result.sent/api/tasks/notify route 的 shim 兼容。Stage D API routes/api/tasks/schedule 服务端验证 kind 缺失 / 非 enum 返回 400;/api/tasks/[id]/run 直接调 runScheduledTaskNow,不再写 next_run = now() 等下一轮 poll;新增 /api/tasks/[id]/runs 拉历史(含关联的 events + deliveries);新增 /api/tasks/notify/ackupsertNotificationDelivery,支持 5 个状态 + 状态机 guard(delivered ↔ error 拒绝,重复 ack 幂等,DB UNIQUE 兜底)。Stage E AI tool schemassrc/lib/builtin-tools/notification.ts + src/lib/notification-mcp.tscodepilot_schedule_task 都加 kind: z.enum(['reminder', 'ai_task']) 必填字段 + tool description 写明 reminder vs ai_task 路由规则;notification-mcp.tsdurable=false 内存 session task 字面量也带 kind 透传,绕过 API 校验也不漏;durable=true 分支 POST body 也带 kindStage F Electronelectron/main.ts 通知 payload { taskId, sessionId, event_id } 透传到 OS 通知;bg-poller 在 notification.show() 成功后调 /api/tasks/notify/ackelectron-bg-native: delivered,失败翻 errornotification.on('click') 把 payload 通过 notification:click IPC 投递给 renderer。electron/preload.ts notification.show / onClick 类型扩展支持新 payload(保留旧 {type, payload} 形态向后兼容)。src/types/electron.d.ts 同步更新。Stage G UI:新增 src/components/settings/TasksSection.tsx(列表 + 创建 dialog + 立即运行 / 暂停 / 删除四个动作 + ?focus= 锚点滚动)+ src/app/settings/tasks/page.tsx<Suspense>useSearchParams;新增 src/hooks/useNotificationClickRoute.tsrouter.push('/settings/tasks?focus=<taskId>')/chat/<sessionId>),AppShell 挂用;src/hooks/useNotificationPoll.ts 在 toast / native show 成功后调 /api/tasks/notify/ackrenderer-toast: delivered / electron-native: delivered;侧栏 + mobile 横向 nav 都加 Tasks 入口 (Clock 图标,介于 Assistant 和 Bridge 之间);root /settings hash redirect map 加 tasks: /settings/tasks;i18n 中英共 26 个新 key(settings.tasks / settings.tasksDesc / tasks.create / tasks.kindReminder 等),与原有 Phase 2 toast 文案 (tasks.title / tasks.created / tasks.failed) 共存——前者覆盖列表 + 创建 dialog,后者保留给历史 toast。Stage H 测试:新增 4 个文件 17 个用例:(i) schedule-task-tool-kind.test.ts (5 例) 锁两个 AI tool 文件 schema 一致 + description 路由提示 + session task 字面量带 kind + API 服务端 400 + ScheduledTask 类型必填;(ii) task-history-table.test.ts (4 例) 全仓 src/ 不许出现 scheduled_task_runs 字面量(剔除 __tests__/ 自身 + 测试文件本身)+ updateTaskRunLog 必须存在 + task-scheduler.ts 必须调用 + insertTaskRunLog 返回 { runId };(iii) scheduler-reminder-and-recovery.test.ts (3 例) reminder 路径不调 provider + 同行原地 update + 1 events row + 候选 channel 集合正确 / 并发 runScheduledTaskNow 返回 already_running 同 runId / 启动恢复 stale running 翻 error;(iv) notification-ack.test.ts (5 例) 基础 UPSERT row count = 1 / 多 channel 互不干扰 / 重复 ack 幂等 / delivered ↔ error 互拒 / 直接 INSERT 第二行违反 UNIQUE;(v) bridge-delivery-visibility.test.ts (3 例) urgent + Bridge 未配置 → not_configured / urgent + 配置但禁用 → skipped / non-urgent 完全无 bridge-* 行(同时验证 1:N 关系:events 行只 1 条不论 N 个 channel)。踩到的坑:在 ack 测试里复用 event_id='evt-test-1' 字面量 + 同 file 多 it block 共享 module-cached db 实例(DB_PATH 在 module load 时冻结,closeDb() 只 nulls handle 不重排路径)→ 第 2 个 it 起 notification_events.event_id UNIQUE 失败。修法:每个 setupEvent 调用生成 evt-test-<timestamp>-<counter> 唯一 id,避开 module-cache 路径冻结的根因。验证npm run test 1670 通过 0 失败(前 1650 → +20 = 4 个新 test 文件 17 例 + 既有用例数 17 因小调整微涨,subtests 计入总数所以净增 20);npx next build 通过,build 输出含 /settings/tasks 静态路由;node scripts/build-electron.mjs 通过,dist-electron/main.jsackDelivery / chatWindowUrlForRevival 6 处 inline。未做(按 v3/v4/v5 plan 边界):(a) cron 表达式 UI 编辑器——v1 UI 只暴露 once / interval,AI tool 仍可写 cron 但 UI 不直接编辑;(b) 远端 Bridge 重试 / 退避策略改造——Bridge 自治;(c) 外部 Agent runtime 接管 cron / heartbeat——只在 notification_events.source'codepilot' | 'external' 接口位,不实现派发;(d) Settings → Assistant 的"助理创建任务列表"区域目前仍保留(计划要求改成链接到 /settings/tasks?source=assistant),可以下一刀再清——本批 Phase 3 Step 3 主链路(reminder 真触发 / 菜单栏继续跑 / 通知点击定位 / Bridge 解耦)已经全部 in place;(e) 手动 Electron 实机 smoke——按 AGENTS.md 短跑规则,留给用户在 dev / packaged build 上做 1-min 提醒 → 关窗 → 等到点 → 看通知 → 点通知 → 落到 Settings → Tasks 页 + 焦点该任务的 e2e 验收。

  • 2026-05-09:Step 3 计划 v5 review-fix-4(ack 幂等 + 标题滞后修正,开工前最后一刀)。v4 review 通过方向后再抓出 1 个 P2 + 1 个 P3,本批仅改计划不动代码。(1) v5 fix #1 — ack 必须是 UPDATE/UPSERT 不 INSERT:v4 描述了 notification_manager 入队时为每个候选 channel 写初始 delivery row + ack 翻状态,但模块表对 POST /api/tasks/notify/ack 的描述还在用"写一行"措辞,测试段同款,按字面执行很容易被实现成"再插一行 delivery"——同 (event_id, channel) 出现两条历史,UI 看起来"同一通道送达两次"。v5 锁死:"ack 是 UPDATE / UPSERT 同 (event_id, channel) 行,绝不 INSERT 新行"。DB schema 加 UNIQUE(event_id, channel) 约束,应用层用 upsertNotificationDelivery(event_id, channel, status, error?) helper,双重兜底。notification-ack.test.ts 加 4 条新断言:基础 UPSERT 后 row count 仍为 1;重复 ack 幂等(同 (event_id, channel) 连续 POST delivered 两次仍 1 行);非法状态转换被拒(delivered → error / error → delivered 都失败);DB UNIQUE 约束直接 INSERT 第二行 fail。(2) v5 fix #2 — 节标题 v3 → v4:v4 摘要 blockquote 已经在节内,但节标题还停在"修订 v3 review-fix-2",下一轮审查可能误读 v3 缓存版。改为"修订 v4 review-fix-3"匹配 v4 摘要的实际范围(v5 摘要再叠在它下面,与 v2/v3/v4 并列)。未做:Step 3 实现仍未开工,等 v5 范围确认后开始。Step 2 P2 代码修复(ensureTray() 提前 + chatWindowUrlForRevival() helper + 2 例 menubar-resident-invariants.test.ts 新例)保留不变。审批信号:v4 review 已经说"补完上面 3 个 P2 + v3→v4 标题就可以开 Step 3 代码";v5 是 v4 review 之后再补的 1 P2 + 1 P3,等 v5 review 确认后即可开工,不再做计划文档纸面收紧。

  • 2026-05-09:Step 3 计划 v4 review-fix-3(3 条 P2 边界 + 1 条 P3 标题修正)。v3 计划 review 通过"方向"但抓到 3 个 P2 边界 + 1 个 P3 文档纸面问题,本批仅改计划不动代码。(1) v4 fix #1 — kind 必须落到所有 task 表达点:v3 只在 DB schema、tool schema、API 层强制 kind;漏了 src/types/index.ts:1464ScheduledTask interface 和 src/lib/notification-mcp.ts:103durable=false 内存 session task 对象字面量(直接 addSessionTask(task) 旁路了 /api/tasks/schedule 校验)。v4 写明 ScheduledTask 接口必填 kind(typecheck 强制所有读写路径带字段),session task 字面量也必须带;新增 session-reminder-fire.test.ts 单测内存 session reminder 走非-AI 路径,避免 session 路径绕过 reminder 分发。(2) v4 fix #2 — events/deliveries 行数语义唯一口径:v3 文档一处说"sendNotification() 写 1 条 events"另一处说"对每条 channel 都写一行 event row"——直接矛盾。v4 锁死 1:N 关系:notification_events = 一次任务通知的伞事件(一行);notification_deliveries = 每个候选 channel 一行(renderer-toast / electron-native / bridge-telegram / 等等)。task_run_logs.notification_event_id 与 events 是 1:1 FK。notification-manager.test.ts 断言 SELECT COUNT(*) FROM notification_events WHERE event_id=? 必为 1 不论 N 个 channel。(3) v4 fix #3 — Bridge × priority 锁定:v3 只描述 4 状态可见,没说 non-urgent priority 是否产生 bridge-* delivery 行。v4 锁死规则:urgent → 每个候选远端 channel 写 1 行 delivery(4 状态四选一);low/normal → 不写 bridge-* delivery 行(产品策略:Bridge 是 urgent-only 候选,写 skipped_by_priority 隐含"我们考虑过 Bridge 又跳过"会混淆策略)。bridge-delivery-visibility.test.ts 同时覆盖 urgent 4 态矩阵 + non-urgent 必无 bridge-* 行的反向断言。(4) v4 fix #4 — 标题 v2 → v3:Step 3 plan 节标题原本是"修订 v2 review-fix",下面才是 v3 摘要段,下一轮审查可能误读 v2 缓存版。改为"修订 v3 review-fix-2"匹配实际版本号。未做:Step 3 实现仍未开工,等 v4 范围确认后开始(v4 是文档/纸面收紧,v1–v3 内容仍然有效,v4 不删除既有 v2/v3 摘要而是叠加)。Step 2 P2 代码修复(ensureTray() 提前 + chatWindowUrlForRevival() helper + 2 例 menubar-resident-invariants.test.ts 新例)保留不变。

  • 2026-05-09:Step 3 计划 v3 review-fix(4 条新 finding 收紧)。v2 计划过 review 后 Codex 又抓出 4 条边界,本批仅改计划文档不动代码(Step 2 P2 代码修复仍然对,已确认通过)。(1) AI tool schema 必须显式声明 kind:现有两个创建路径 src/lib/builtin-tools/notification.tssrc/lib/notification-mcp.tscodepilot_schedule_task 都没有 kind 字段,靠 DB default 'ai_task' 兜底——意味着用户在聊天里说"5 分钟后提醒我喝水",模型不传 kind 就仍走 AI 路径,"提醒不跑模型"承诺破窗。v3 计划要求两个文件 zod input schema 必填 kind: z.enum(['reminder', 'ai_task']),tool description 写明 "reminder vs ai_task" 路由规则;/api/tasks/schedule route 也在服务端验证 kind 缺失或非法返回 400,不依赖 SQLite 默认。新增契约测试 schedule-task-tool-kind.test.ts 锁两个文件 schema 一致 + API 校验。(2) task_run_logs run-row 生命周期写死 "一次执行 = 一行":v2 plan 一处说 /run 返回 running、另一处验收说 /runs 看到 success,没指明是更新还是新插。v3 写明 runScheduledTaskNowinsertTaskRunLog({ status: 'running' }) 拿到 runId,执行完后 updateTaskRunLog(runId, { status, result, error, duration_ms }) 原地更新同一行——同 runId 在 task_run_logs 中只出现一次。新增 updateTaskRunLog API,并 grep src/lib/db.ts + src/lib/task-scheduler.ts 必须存在该调用。(3) Bridge "未配置" 也写一行 delivery:v2 验收说 Bridge 未配置时"无 bridge-* 行"——这会让 UI 分不清"没配置所以跳过"和"代码根本没尝试"。v3 改为四分类显式可见:not_configured(用户没填 token)/ skipped(用户主动关)/ delivered(成功)/ error(远端失败),四种都必须有 notification_deliveries 一行。新增 bridge-delivery-visibility.test.ts 锁四态。(4) task-history-table.test.ts 只扫 src/:v2 写"全仓不许出现 scheduled_task_runs 字面量",但计划文档自身解释"为什么不该建这张表"时反复用了这个名字,按字面执行测试自己会绊倒计划文档。v3 改为按文件路径过滤,只扫 src/(不扫 docs/ / 不扫 __tests__/ 自身),并配套断言 updateTaskRunLog 必须出现在 db.ts + task-scheduler.ts 里以确保 v3 fix #2 落地。未做:Step 3 实现仍未开工,等 v3 范围获批后开始。Step 2 P2 代码修复(ensureTray() 提前 + chatWindowUrlForRevival() helper)保留不变,menubar-resident-invariants.test.ts 已加的 2 例继续生效。

  • 2026-05-09:Step 2 P2 review fix(tray 提前创建 + 禁 serverPort || 3000 兜底)+ Step 3 计划 v2 review-fix(5 条 P2 收紧)。Codex review 抓出两类问题,本批一起处理。Step 2 代码改动:(a) electron/main.ts production 分支把 ensureTray() 移到 await startServerOnStablePort() 之前——之前 tray 在 server ready 后才创建,loading window 期间用户关窗就会"无窗 + 无菜单栏图标",违反 Step 2 "应用启动即出现菜单栏图标" 的承诺。(b) 新增 helper chatWindowUrlForRevival()serverPort 设了 → 真实 URL;未设 → undefinedcreateWindow() 走 LOADING_HTML splash,启动流程的 mainWindow.loadURL(realUrl) 在 port 落定后切回真页面。showMainWindow() + app.on('activate') 都改用 helper,删掉 serverPort || 3000 兜底——生产服务器稳定区间是 47823–47830,3000 是错的端口,tray 提前后这条假设就不再无害。Bg-poller 内部的 serverPort || 3000 旧兜底已经在前一轮删了,本轮不再涉及。测试menubar-resident-invariants.test.ts 加 2 例:(i) production startup else-block 里 ensureTray() 必须出现在 await startServerOnStablePort 之前(按 indexOf 比较位置);(ii) showMainWindow body 与 app.on('activate') block 都不许出现 serverPort\s*\|\|\s*3000,且必须看到 chatWindowUrlForRevival() 调用。Step 3 计划改动(v2):原 Step 3 plan 抓了 5 条 P2,全部按原文要求收紧:(1) reminder ≠ AI task——新增 scheduled_tasks.kind 字段,kind='reminder' 路径不调 generateTextFromProvider,prompt 文本就是通知正文,"5 分钟后提醒喝水"无 provider 也工作;kind='ai_task' 保留现行 provider 路径。(2) delivery log 不假成功——拆 notification_events (server 入队 queued) 和 notification_deliveries (per-channel delivered/error ack)。sendNotification() 只写 events;renderer / Electron bg / Bridge 各自模块在真送达后调 POST /api/tasks/notify/ack 回写 delivery;ack 丢失时 UI 显示"已入队未确认",不假装 delivered。(3) 历史表不并存——明确复用现有 task_run_logs + insertTaskRunLog,schema 仅 ADD 列(kind / notification_event_id),删除原计划"scheduled_task_runs 新建表"的提法;新增 task-history-table.test.ts grep 全仓不许出现 scheduled_task_runs 字面量防回归。(4) "立即运行"直接执行——新增 runScheduledTaskNow(taskId) 受控执行入口,POST /api/tasks/[id]/run 直接调,返回 { runId, status: 'running' }不再写 next_run = now() 等下一轮 poll;并发保护用 DB 行级锁(UPDATE … WHERE last_status!='running'),抢锁失败返回 already_running + 现有 runId。(5) Step 2 P2 prereq 声明——计划开头新增 #### 0. Step 2 prereq 段,明确 tray 提前 + 禁 3000 兜底是 Step 3 开工前置,本批已修。新增风险条kind='reminder' 应忽略 notify_on_complete=0(reminder 概念上总是通知,不通知就没意义);runScheduledTaskNow 与 poll 并发用行级锁防双 fire。验证npm run test 1650 通过 0 失败(前 1648 → +2 menubar-resident-invariants 新例);npx next build 通过;node scripts/build-electron.mjs 通过。未做:Step 3 实现本身(计划仍在审批阶段,未开工);packaged memory smoke 仍按 memory investigation 收口决议留作后续单列。计划修订之后等用户批准 v2 范围再开工 Step 3 代码。

  • 2026-05-09:🛑 Memory investigation 收口(dev 高 RSS 稳定但非泄露,packaged memory smoke 后续单列)。Codex 走了一轮同机内置浏览器实测,给出当前 worktree vs main 的 dev RSS 对比,是这条主线的终点。Codex 复测数字(同机同方法 / 内置浏览器路径,不是单纯 curl)

    阶段 当前 worktree main 分支
    dev ready ~0.47 GB ~3.97 GB
    打开 /chat ~2.63 GB ~4.13 GB
    打开 /settings/providers ~2.88 GB
    打开 /settings/models ~3.00 GB
    打开 /settings ~4.16 GB
    idle 60s 基本稳定,无持续上涨

    三个判断:(i) 当前 worktree 的 AppShell + root layout 静态图比 main 更轻(AppShell 1066 → 566 KB / root layout 1251 → 810 KB 的对比之前已经摆过);(ii) /chat / /chat/[id] 比 main 多 ~260–280 KB 是 Runtime / RunCheckpoint / RunCockpit / 模型兼容矩阵这一批功能增量带进来的,是产品差不是回归——但它解释不了 GB 级 dev RSS 差距;(iii) idle 60s 不持续上涨,没有证据说明当前 worktree 存在持续内存泄露——主要变量是 Next dev / Turbopack 的编译图行为本身(这条之前几刀的 A/B 也都验证过:static→dynamic 在 dev 不被推迟、缩 union 才有效)。收口决议:(a) 这一轮所有针对 dev RSS 做的拆分全部保留——RunCheckpoint / useOverviewData 解耦、context-core 提取、AppShell Phase A 的 6 个 lazy 目标 + 两个 Dialog state gate、Sentry dev guard + error-classifier 旁路、Settings route-level split、内部链接 hash-→ 路径迁移;这些改动既有 prod bundle / 架构清晰度收益,也消除了"切 Runtime 后红 banner 不消失"那类结构性 bug,独立于 dev RSS 数字成立。(b) 不再继续为了 dev RSS 拆 RunCheckpoint / RunCockpit / AppShell / ChatListPanel——之前的 Phase A、popover 拆分、ChatListPanel route split 三个候选都已经被同机 A/B 证明对 dev RSS 收益 ≤ 噪声带,继续做边际为零。下一刀(如果还有)必须先证明它能动数字,否则不开。(c) dev 高内存视为 Next dev / Turbopack 编译图问题,不是当前 main 线的阻塞项——开发体验上 worktree 已经从 main 的 ~4 GB 降到 ~2.6–3 GB,足够实际工作,剩余浮量来自打包工具自身的 dev 编译图行为。(d) Packaged memory smoke 后续单列——Electron 打包后的实际进程内存表现 ≠ next dev 编译时 RSS,应该用 packaged build + 真实使用流程做一次专项测量,但这次不做、不进 Phase 3 主线。(e) 未来的 dev RSS 调研定位:要再回这条线,先 import-graph dump / heap snapshot 找具体因素(Turbopack runtime 自身 / 某第三方依赖在 dev 异常膨胀 / 某 root layout 公共依赖),不要再凭直觉拆 UI——三轮 A/B 已经证明这种拆法在 Turbopack dev 下不产生收益,再走会浪费迭代预算。接下来回到 Phase 3 主线:Phase 3 Step 2(菜单栏常驻 + 本机通知)已完成,Step 3(全局定时任务触发 + delivery log 修 next_run 文本比较 bug)/ Step 4(助理心跳诚实化)/ Step 5(Settings → Health UI 分层)按 Phase 3 Step 1:现状审计 给出的最小闭环顺序推进,不在 Phase 3 名义下继续做内存相关 UI 拆分。

  • 2026-05-09:Chat 首屏与 Settings Overview 数据层完整解耦(最小修复版)。前两刀(RunCockpit popover 拆分、AppShell Phase A)已经确认 Turbopack dev 不真正推迟 dynamic({ssr:false}) 的 chunk,所以这一刀走"减少静态可达模块的并集"方向,从 chat 入口本身把 useOverviewData 删掉。根因复盘:Phase 2 为了让 RunCheckpoint 与 RunCockpit "看到同一份数据"把 useOverviewData() 直接接进 src/app/chat/page.tsxsrc/components/chat/ChatView.tsx——它进而通过 runtime/effectiveprovider-catalog / runtime resolver 拉进 chat 首屏 dev 编译图,并且 mount 时立刻发起 6+ /api 请求(/api/settings/app / /api/providers/models?runtime=auto / /api/providers/models / /api/providers/options?providerId=__global__ / /api/settings/workspace / /api/workspace/summary 加 per-provider ?all=1),既是 dev RSS 嫌疑也是"切 Runtime 后仍显示全局不可用 / 降级 / 固定不可用"语义错乱的真正源头。改动:(a) 新增 src/hooks/useGlobalAgentRuntime.ts——只 fetch /api/settings/app 一次(监听 provider-changed 重拉),返回 { agentRuntime, loading };不引入 runtime/effective / runtime/legacy,路径里 'native'/'claude-code-sdk' 直接 string 判定;这是给 RuntimeSelector 的 fallback label 用的轻量版本,替换原本拖进重数据层的 useOverviewData()。(b) src/app/chat/page.tsx 删除 useOverviewData / computeEffectiveRuntime / useClaudeStatus 三个静态 import;checkpointReasons 改为只看本会话信号——noCompatibleProvider / !!invalidDefault / pendingContextTokens / usedContextTokens / permissionElevationPending删掉 defaultInvalid: !!invalidDefault || (!overrideGlobalPinnedGate && overview.defaultInvalid) 的 OR 与 runtimeFallback: overrideGlobalPinnedGate ? false : runtimeFallback 的整支抑制逻辑;overrideGlobalPinnedGate 常量随之消失。pinnedDescriptor 不再 fallback 到 overview.defaultProviderName,只用 invalidDefault 信息(runtime-aware resolver effect 是这条信号唯一来源)。RuntimeSelector 渲染的 effectiveRuntime 改用 globalRuntime.agentRuntime。注意 runtime/effective import 仍保留——它给本地 resolveNewChatDefault resolver effect 用,是非 RunCheckpoint 的合法用途,按用户口径 "如果只为 RunCheckpoint 使用" 不在禁止列表。(c) src/components/chat/ChatView.tsx 同步删 useOverviewData / useClaudeStatus / computeEffectiveRuntime 三个 import;checkpointReasons 退化为 noCompatibleProvider + defaultInvalid: false(既有会话不关心全局 pin)+ context-cost + permission-elevation;删 runtimeFallback 整支 + overrideGlobalRuntimeFallback 常量。RuntimeSelector 同样换成 useGlobalAgentRuntimeruntime/effective 在 ChatView 这条路径上完全消失(之前 ChatView 只为 computeEffectiveRuntime 一个引用,下一步 contract test 锁住)。(d) src/lib/run-checkpoint.tsBuildCheckpointsOpts.defaultInvalidruntimeFallback 改成 optional,typecheck 才能让 chat 入口省略它们;library 行为不变(undefined 进 if(opts.defaultInvalid) 等价 false)。Settings / Health 想继续从 useOverviewData() 喂这两个字段照旧 work。(e) 第二条边界src/components/ai-elements/context.tsx 同时携带 tokenlens + HoverCard + Progress + Button,trigger-only RunCockpit shell 静态 import 它太重;新增 src/components/ai-elements/context-core.tsx 只放 ContextSchema / ContextContext / useContextValue / ContextProvider(只是 React Context Provider,不 wrap HoverCard),原 context.tsx 改为 re-import 这些核心符号——保证两个文件共享同一个 React context identity。RunCockpit.tsx shell 把 import { Context } from '@/components/ai-elements/context' 换成 import { ContextProvider } from '@/components/ai-elements/context-core',JSX <Context …><ContextProvider …>;popover 内部 <Context> 包的 HoverCard 本来就 inert(点击交给 <Popover>),换成 ContextProvider 没有行为变化。RunCockpitPopoverContent.tsx 继续 import 完整 ./contextContextContent* 全家桶——只在 popover 真正打开时挂载。测试改动:(i) 新增 chat-static-graph.test.ts 重写为 modulePath-based 静态图 walker(^[ \\t]*import\\b[^;]*; 切顶层静态 import,@//相对路径解析、bare 包靠 source grep),按 (spec, entries[]) 表声明禁用关系——useOverviewData 对 RunCockpit / chat/page / ChatView 三个入口都禁;@/lib/runtime/effective 对 ChatView 禁(chat/page 仍允许,给 resolver effect 用);@/lib/provider-catalog 仅对 RunCockpit 禁(chat 入口经 MessageInput → ModelSelectorDropdown → runtime-compat 合法触达,超出本刀范围);@/components/ai-elements/context + tokenlens 对 RunCockpit 禁。新增正向断言 chat 入口必须用 useGlobalAgentRuntime。(ii) session-runtime-immunity.test.ts 第 15 例旧的"overview.defaultInvalid 必须被 && 守卫"和"runtimeFallback 必须被 ?: false : 抑制"两条断言改为 doesNotMatch——chat 入口剥离 comment 后完全不许出现 overview.defaultInvalidruntimeFallback。原"effectiveMode 必须分支 runtimePin"那条保留(resolver effect 是仅存的 runtimePin 分支点)。stripComments 顺序 (行先块后) 沿用既有 helper,避免我自己 JSDoc 里残留的 runtimeFallback 提示伪命中。第一版踩的坑:(1) buildCheckpoints 的 defaultInvalid / runtimeFallback 当时是 required,chat 入口删除它们后 typecheck 立刻失败——把两个字段改成 optional 解决。(2) chat-static-graph 第一版把 provider-catalog 也禁到 chat 入口,walker 立刻 fail——chat/page → MessageInput → ModelSelectorDropdown → runtime-compat → provider-catalog 是合法的模型选择器路径(不是 RunCockpit / RunCheckpoint),用户原话只列了 useOverviewData + runtime/effective(后者带 "仅 RunCheckpoint 用" qualifier),provider-catalog 不在 chat 入口禁单上,scope 收回 RunCockpit 内。(3) session-runtime-immunity 第一版直接 grep raw source 把我自己 JSDoc 里"为什么删掉 overview.defaultInvalid"的解释命中——加 stripComments 先剥注释,与其它 contract test 一致。带 4.3GB 熔断的同机 A/B 复测npm run dev Turbopack / 干净 .next/dev cache,先跑 POST-FIX 再 git stash 退回 PRE-FIX 跑、stash pop 复原):

    阶段 PRE-FIX POST-FIX 差值
    Ready baseline 459 MB 456 MB −3 MB
    /chat peak 2260 MB 2242 MB −18 MB
    /chat RSS 2229 MB 2212 MB −17 MB
    /chat idle 20s 2229 MB 2212 MB −17 MB
    /settings/providers RSS 2382 MB 2358 MB −24 MB
    /settings/models RSS 2553 MB 2533 MB −20 MB
    FINAL PEAK 2553 MB 2533 MB −20 MB

    结论:dev RSS 稳定下降 17–24 MB,跨多个采样点一致(不是噪声带 ±5 MB 内的抖动);这是把 useOverviewData 整个 graph + computeEffectiveRuntime + useClaudeStatus 从 chat 首屏静态可达集合里彻底剥掉的代价兑现,与 Turbopack dev 不推迟 dynamic 的事实没冲突——这一刀走的是"缩小静态可达 union"路径,不是"static→dynamic 推迟"路径。比之前几刀 0–10 MB 的噪声级数字明显得多,证明对症。真正的产品收益其实更大:(i) 切 RuntimeSelector 之后立刻不再有"全局不可用 / 已降级 / 固定不可用"那种与会话状态矛盾的红 banner——bug 类被结构性消除(chat 首屏代码根本读不到这些全局状态了),不再依赖各种 !sessionRuntimeOverride 抑制项;(ii) /chat 首屏不再触发 6+ /api Overview 请求 fan-out,cold start 网络面板更干净;(iii) end-user 初次访问 /chat 的 production bundle 也对应缩小(ai-elements/context 全家桶 + tokenlens 对 RunCockpit shell 来说是真的离开了),是 Phase A 那一刀想要而 dev 拿不到的 prod 红利。RunCheckpoint 当前承担:no-compatible-provider / pinned-invalid (本地 runtime-aware resolver) / context-cost / permission-elevation;已下线:runtime-fallback (Claude CLI 健康) / global-default-invalid (全局 pin 健康),这两条都属于 /settings/health 范畴,在 lazy RunCockpit popover 里仍然可见。改动文件清单(仅本次 chat ↔ Settings 数据层解耦):新增 src/hooks/useGlobalAgentRuntime.ts + src/components/ai-elements/context-core.tsx;改写 src/app/chat/page.tsx + src/components/chat/ChatView.tsx + src/components/chat/RunCockpit.tsx + src/components/ai-elements/context.tsx(re-import core)+ src/lib/run-checkpoint.ts(两字段 optional)+ src/__tests__/unit/chat-static-graph.test.ts(modulePath-based 表 + 正向 useGlobalAgentRuntime 断言)+ src/__tests__/unit/session-runtime-immunity.test.ts(第 15 例改 doesNotMatch + stripComments)。验证npm run test 1648 通过 0 失败(前 1641 → +7 = chat-static-graph 表展开成多用例;旧"effectiveMode 必须分支 runtimePin"用例保留);npx next build 通过;A/B 两次都没触发 4.3GB 熔断。未做provider-catalogruntime-compat 也想搬出 chat 首屏,但路径在 MessageInput → ModelSelectorDropdown 的合法链路上,要拆得改 picker 加载方式(dynamic + 用户开始打字才加载?),属于 picker 自身的产品决策,本刀不动;useClaudeStatus 仍然存在于其它非 chat 入口(OnboardingWizard / SetupCenter / Health page),那些是合法用途,不动。立场:dev RSS 当前同机 ~2.21 GB(/chat warm)已经低于本刀之前的 2.23 GB / Phase A 之前的 2.34 GB / route-split 之前的 2.66 GB;继续往下走的 ROI 越来越低,下一刀(如果还有)应该是"按产品决策动 picker 加载方式"或"接受现状改测专项调研 import-graph",而不是再发现一个静态依赖偶然搬一搬。

  • 2026-05-09:RunCockpit 数据层拆分(Chat 首屏不再因 RunCockpit 静态吃 Settings overview)。基于 v0.54.0 ↔ 当前 worktree 的对比:root layout 1251→810 KB / AppShell 1066→566 KB(更轻),而 /chat 850→1160 KB / /chat/[id] 879→1202 KB(+310 / +323 KB),增量来源被定位到 RunCockpit.tsx 直接 import useOverviewData,把 runtime/effective + provider catalog + useClaudeStatus 拖进 chat 首屏 dev 编译图。改动:(a) 新增 src/components/chat/RunCockpitPopoverContent.tsx 作为弹层重半部分——拥有 useOverviewData / useClaudeStatus / runtime/effective / ai-elements/contextContextContent* 家族 / severity 分类 / provider+model lookup / issues 块 / 容量未知分支。(b) src/components/chat/RunCockpit.tsx 收缩为 trigger-only shell:只保留 useContextUsage + <Context> provider wrapper(用户明确说 ai-elements/context 是功能成本,可接受)+ Popover 三件套;删除 useOverviewData / useClaudeStatus / runtime/effective / ContextContent* 静态 import,改用 dynamic(() => import("./RunCockpitPopoverContent"), { ssr: false })。trigger 因此主动放弃了 severity-driven warning glyph + tint + 固定模式 chip 文本——这些都依赖 overview 状态,留在 trigger 上意味着 trigger 必须同步读 overview。RunCheckpoint(composer 上方)才是 blocking issues 的规范化展示面,trigger 不需要重复同样的告警;popover 打开时这些信息全部还在。Radix <PopoverContent> 仅在 open=true 时挂载子树,配合 ssr:false dynamic loader,弹层 chunk + 其传递依赖只在用户首次点击时解析。(c) ChatView / chat/page 两个 call site 不变——<RunCockpit> 的 props 接口和原来一致,shell 把 sessionRuntimePin 等通过 props forward 进 popover。(d) 测试调整:run-cockpit-unknown-capacity.test.ts 把 src 引用从 RunCockpit.tsx 改为 RunCockpitPopoverContent.tsx(容量未知分支搬家了,断言路径同步);session-runtime-immunity.test.ts 拆成两个文件分检——shell 必须声明 sessionRuntimePin prop 并 forward 给 popover content(新增正向断言 <RunCockpitPopoverContent sessionRuntimePin={sessionRuntimePin} 出现在 shell),popover content 必须 derive sessionRuntimeOverride + 在 runtimeFallback 派生里短路;旧"runCockpit.tsx 必须包含 sessionRuntimeOverride"的硬断言已经搬到 popover 的源里。(e) 新增 src/__tests__/unit/chat-static-graph.test.ts 4 例契约:纯 Node staticImportGraph() walker(按 ^[ \\t]*import\\b[^;]*; 抽顶层静态 import + 解析 @/ / 相对路径 + 跳过裸包)从 RunCockpit.tsx 出发广度遍历,断言走不到 components/settings/useOverviewData.ts 也走不到 lib/provider-catalog.ts;正向断言 RunCockpitPopoverContent.tsx 必须保留 useOverviewData + runtime/effective 的静态 import(重半留这里就是契约,搬走了反而要叫错);shell 必须next/dynamic + ssr:false 加载 ./RunCockpitPopoverContent;ChatView + chat/page 必须仍然渲染 <RunCockpit/>,确认拆分只搬数据不丢功能。第一版契约太严:原本断言 chat/page + ChatView 静态图也不许出现 useOverviewData,立刻 fail——chat/page.tsx:21 + ChatView.tsx:19 直接 import 是 RunCheckpoint 信号路径独立于 RunCockpit 的预存逻辑,用户的 contract 原话是"不能因为 RunCockpit 引入",不是"chat 完全不许触达",所以正确做法是仅 scope 到 RunCockpit shell 自己的图;chat-entries 那两条直接 import 是另一笔账,本轮不动。带 4.3GB 熔断的同机 A/B 复测(npm run dev / Turbopack / 干净 .next/dev cache,先跑 POST-FIX 再 git stash 退回 PRE-FIX 跑,最后 stash pop 复原)

    阶段 PRE-FIX POST-FIX 差值
    Ready baseline 448 MB 434 MB −14 MB
    /chat peak 2238 MB 2238 MB 0
    /chat RSS 2207 MB 2217 MB +10 MB
    /chat idle 20s 2207 MB 2217 MB +10 MB
    /settings/providers RSS 2372 MB 2375 MB +3 MB
    /settings/models RSS 2547 MB 2548 MB +1 MB
    FINAL PEAK 2547 MB 2548 MB +1 MB

    结论:dev RSS 没有可观测下降,全部数字在 ±15 MB 噪声带内。和 AppShell Phase A 那次完全同样的结果——Turbopack dev 不真正推迟 dynamic({ssr:false}) 的 chunk 编译,dev 阶段它仍然为 HMR 把 lazy chunks 走一遍 graph,所以"static import → dynamic import"的拆分在 dev 内存层面零价值。Phase A 当时是 Sentry 阻断后的下一刀,结果同样验证了这条规律。为什么本刀的 dev 数字没动还有第二个原因:src/app/chat/page.tsx:21 + src/components/chat/ChatView.tsx:19 本来就直接 import { useOverviewData } from '@/components/settings/useOverviewData' 给 RunCheckpoint 信号用——这是 RunCockpit 之外的 pre-existing 静态路径。即便 RunCockpit 这条搬走,chat 入口还有两条直接路径把 useOverviewData 拽进 dev 图。我把这一点写在 chat-static-graph.test.ts 头部注释里,明确"本契约只锁 RunCockpit 不再是路径之一,chat-entries 那两条是单独议题,未来若要继续追 dev 内存可以参照同款 lazy-pattern 改"。结构性 / prod 收益仍然成立:(i) RunCockpit shell 的 dev compile graph 真的不再静态触达 useOverviewData / provider-catalog(chat-static-graph.test.ts 校验通过);(ii) production bundle 里 RunCockpit 的客户端代码不再吃 useOverviewData 的 chunk(除非用户点开 popover),end-user 初次进 /chat 下载体积更小;(iii) 架构语义清晰——trigger 只做 trigger 该做的事(context 环 + token 数),重数据层落到 popover;(iv) sessionRuntimePin / 其它 props forwarding 链清晰可测,未来 RunCheckpoint 也想做同款拆分时 shell↔popover 的 prop forward 蓝本可复用。也修了用户提到的"顺手修 error-classifier.ts dev guard":本轮检查发现这条上一刀("Sentry dev guard 漏网点修复"日志条)已经做完且仍在位——src/lib/error-classifier.ts 第 27 行 if (process.env.NODE_ENV !== 'development') 包了整个 reportToSentry 函数体,import('@sentry/node') 落在守卫内,sentry-dev-guard.test.ts 仓库级契约持续有效;本轮无需再动。验证npm run test 1641 通过 0 失败(前 1636 → +5 = chat-static-graph 4 例 + 修订两个旧 test 但用例数不变 + fold 进新算法);npx next build 通过;A/B 复测两次都没触发 4.3GB 熔断,最高峰 2.55 GB。未做:把 chat/page.tsx + ChatView.tsx 那两条直接 useOverviewData import 也做同款拆分——属于 RunCheckpoint 数据层重构范畴,需要单独评估"Chat 首屏到底应不应该实时跑 RunCheckpoint 信号"再决定,本轮按用户的 scope(RunCockpit only)收口;webpack dev / Turbopack import-graph 调研按用户原指示推迟。对结论负责的话:dev RSS 想要继续往下走,不要再做"static→dynamic"这种纯 import 形式的拆分,它在 Turbopack dev 里就是不灵;要真正瘦 dev 图就得减少静态可达模块的 union——比如把 useOverviewData / RunCheckpoint 合到一个仅在用户停留 /chat 后才挂的 lazy 区块里、或者干脆改让 chat 路由不必同步算 RunCheckpoint(接受冷启动时不显示 banner)。Phase A、本轮、Settings 拆分这三刀的 dev 内存数字摆在一起已经印证了同一件事:Turbopack dev 的 floor 由静态可达模块的并集决定,不被 dynamic + ssr:false 削减;Phase B(ChatListPanel route split)按之前结论保持不开。

  • 2026-05-09:Sentry dev guard 漏网点修复 + 测量口径更正。上一刀只锁了 instrumentation.ts,但 src/lib/error-classifier.ts:reportToSentry 还有 import('@sentry/node').then(...) 这条 lazy 路径——正常 /chat 不必触发,所以那轮 RSS 复测看到下降;可一旦 dev 出 reportable error(NATIVE_STREAM_ERROR / MCP_CONNECTION_ERROR / PROVIDER_NOT_APPLIED / 进 SENTRY_REPORTABLE 集合的错误码),dynamic import 触发,OpenTelemetry 链立刻进 graph,内存反弹,前一刀的 ~130 MB 节省被一次错误事件吞掉。改动:(a) src/lib/error-classifier.tsreportToSentry() 整个函数体(除参数解构外的所有逻辑)包进 if (process.env.NODE_ENV !== 'development') { ... }——和 instrumentation.ts 用同一个 wrap 形式,dev 直接早返还,不读 SENTRY_REPORTABLE / 不构造 message / 不动 import。(b) 新增 src/__tests__/unit/sentry-dev-guard.test.ts 3 例仓库级契约:(i) 扫整个 src/(剔除 __tests__/ + 注释),断言至少能找到 instrumentation.ts + lib/error-classifier.ts 这两个已知 caller(防止 regex 失效后所有断言空跑);(ii) 每个含 @sentry/node 的文件都必须有至少一个 if (NODE_ENV !== 'development') { … } 守卫块;(iii) brace-balanced 提取所有守卫块、把守卫块从源里替换成等长空白后断言剩余文本里不再出现 @sentry/node——任何文件、任何形式(静态 import / 动态 import / 字符串字面量)的 sentry/node 引用都必须被夹在守卫里。和 instrumentation-shape.test.ts 是分工:那个文件锁的是 instrumentation.ts 的内部结构(initRuntimeLog / scheduler 必须在守卫外),这个文件锁的是仓库范围"sentry/node 不许跑出守卫"的横向约束。踩到的坑:第一版 stripComments 先 strip 块注释、后 strip 行注释——结果我在 error-classifier.ts 给 reportToSentry 写的多行 // 解释里包含字面量 `@opentelemetry/*`,这个 /* 在块注释 stripper 眼里就是开始标记,于是它从那里贪婪追到下一个真实的 *//* Sentry not available */ inline 注释)才停,把中间几十行实际代码(包括 import('@sentry/node') 那条)一并吃掉,导致 error-classifier.ts 在 stripped 后已经看不见 @sentry/node,repo-wide 扫描漏掉它,第一例"两个已知 caller 必须被找到"立刻失败给我打脸。修法:strip 顺序倒过来,行注释(把 // @opentelemetry/* 整行删掉,伪造的 /* 一并消失)块注释。instrumentation-shape.test.ts / settings-link-migration.test.ts / menubar-resident-invariants.test.ts 这 3 个之前已经存在的 stripComments 工具同样有这个隐患(它们的目标源恰好没踩进去所以没炸),顺手一起改成"行先块后",并加注释说明原因,防止以后给目标源加注释时再爆。测量口径更正:上一轮汇报里把"用户原基线 ~2130 MB / 我这台 558 MB"的差值归因为"用户走 Electron 路径"——这是错的,用户明确说没跑 Electron / 没跑 Browser/CDP,差值更可能来自 RSS 统计口径不同(用户脚本把 dev server 根进程 + 所有 descendants 合并;我只盯单个 next-server PID)。所以 558→431 / 2347→2231 / 2675→2544 这组同机同口径 A/B 数字仍然有效(−127 / −116 / −131 MB),但跨机器和用户基线的横向比较不可信。这一类口径误判以后避免:A/B 永远在同一脚本同一进程统计逻辑下做。验证npm run test 1636 通过 0 失败(前 1633 → +3 sentry-dev-guard 仓库级用例);npx next build 通过;本轮没改运行时 import 实际触发逻辑(reportToSentry 在 dev 走早返,行为等价于"不报"),因此不再单独跑 RSS A/B——上一刀已经证明 @sentry/node import 阻断有效,本刀把 error-classifier 这条 lazy 旁路收掉之后,dev 启动 + 错误路径都不会再加载 OpenTelemetry 链。未做:webpack dev / 其它 Turbopack import graph 调研按用户原指示推迟;除 instrumentation.ts + error-classifier.ts 外仓库内没有其它 @sentry/node 引用(grep -rln "@sentry/node" src/ 仅命中这两个文件 + 测试本身),所以本轮 sentry 维度收口。

  • 2026-05-09:Dev-server 内存专项:development 不再初始化 server-side Sentry。Phase A 证明 Turbopack dev 不吃 lazy 之后开下一刀,目标是确认 dev 内存"地板"是否被 @sentry/node + @opentelemetry/* 链拖高的。改动:(a) src/instrumentation.tsNEXT_RUNTIME === 'nodejs' 内层加 if (process.env.NODE_ENV !== 'development') 守卫,把 DSN 读取 + ~/.codepilot/sentry-disabled opt-out 检查 + await import('@sentry/node') + Sentry.init(...) 整块包进去;initRuntimeLog()ensureSchedulerRunning() 留在守卫外部,dev 仍然正常跑。Production / packaged build 的行为完全不变(NODE_ENV !== 'development',原路径走通)。next.config.ts 里的 NEXT_PUBLIC_SENTRY_DSN 保留不删——按用户指示避免顺手影响 client Sentry / production build。(b) 新增 src/__tests__/unit/instrumentation-shape.test.ts 5 例契约:(i) 必须有 if (process.env.NODE_ENV !== 'development') 字面守卫;(ii) import('@sentry/node') + Sentry.init( 必须位于守卫块内部——用 brace-balanced extraction 取出守卫块的 body 文本断言;(iii) 守卫块不许有 @sentry/node 字符串(剥离注释后扫描,避免 JSDoc 误命中);(iv) initRuntimeLog 必须不在守卫块内(dev 必须仍然初始化 runtime-log,否则 Doctor export 失效),但仍要被调用;(v) ensureSchedulerRunning 同上(scheduler 不能被 dev 吞掉)。修测试时踩的坑:第一版的"outside doesn't contain @sentry/node"断言被我自己写在文件顶部 JSDoc 里的 @sentry/node 解释命中,加 stripComments(SRC_RAW) 先剥注释再判断,沿用 menubar-resident-invariants / settings-link-migration 的同款 helper。带 4.3GB 熔断的同机 A/B 复测(npm run dev 不动 Turbopack 配置 / 不动 next.config / 干净 .next/dev cache 启动)

    阶段 PRE-FIX (Sentry init in dev) POST-FIX (guarded) 差值
    Ready baseline 558 MB 431 MB −127 MB
    /chat peak 2370 MB 2264 MB −106 MB
    /chat RSS 2347 MB 2231 MB −116 MB
    /settings/providers RSS 2524 MB 2372 MB −152 MB
    /settings/models RSS 2675 MB 2544 MB −131 MB
    FINAL PEAK 2674 MB 2544 MB −130 MB

    结论:Sentry/OTel 是 dev RSS 的可量化贡献者,但不是支配项。同机干净启动下守卫给 ready 省 127 MB(这正是 @sentry/node + @opentelemetry/* instrumentation chain 的运行时代价);warm 之后 /chat / /settings/providers / /settings/models 三个采样点持续低 ~110–150 MB,差值贯穿整个生命周期,不是噪声。但是 dev 编译图 floor 还是 ~2.2 GB(POST-FIX /chat),说明 1.7 GB+ 那一大坨主要还是 Turbopack runtime + AppShell 公共依赖(i18n / theme loader / 各 Provider Context / ChatListPanel)+ root layout 自己,不是 Sentry。用户原口径基线(ready ~2130 / /chat ~2750 / /settings/models ~3040)和我这台机器 PRE-FIX 数字(558 / 2347 / 2675)有 ~1.5 GB 的常态偏差,应该是用户走的是 Electron 内嵌 Node 测得,那条路径除了 npm run dev 自身还要带 Electron utility process 和它捆绑的 native modules(better-sqlite3 / @anthropic-ai/claude-agent-sdk runtime / Discord.js / Shiki)一起进 graph——所以同样的 Sentry 守卫在 Electron 启动场景下保存的绝对 MB 数会更大或更小,结构性结论不变:Sentry/OTel 是主因之一但不是 floor 主体。下一步要继续追那 ~2.2 GB floor,建议把"Next dev / Turbopack import graph 调研"作为单独一刀开,目标是用 Turbopack 的 --inspect-brk / heap snapshot / module graph dump 定位剩余的大头,而不是继续盲拆 UI。验证npm run test 1633 通过 0 失败(前 1628 → +5 instrumentation-shape contract);npx next build 通过;同机 A/B(先跑 POST-FIX 再 git stash 一份回 PRE-FIX 跑、跑完 stash pop 复原)整段没有触发 4.3GB 熔断,最高峰只到 2.67 GB(pre-fix)。未做:webpack dev 模式 A/B(用户明确说"目前先别开 webpack,已经证明更危险",跳过);Electron 启动场景下的 RSS 复测(命令行 next dev 已经看到稳定下降 ~130 MB,Electron 路径预期类似但需要 Electron 进程层面单独测,本轮不开)。

  • 2026-05-09:AppShell Phase A P3 review fix。两个 P3 finding 都修。(a) UpdateDialog gate 收紧:之前 {(updateAvailable ?? false) && <UpdateDialog />} 只看 updateAvailable,用户点过"稍后"后 showDialog=falseupdateAvailable=true,弹窗 chunk 仍然挂着——和"only mount when shown"的承诺不一致。改为 {showDialog && (updateAvailable ?? false) && <UpdateDialog />},dismiss 真正卸载 chunk;UpdateBanner 仍然是常驻轻量提示,不受影响。(b) lazy contract test 升级到 modulePath-based:之前的 negative 断言 import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from 只挡 named import 一种形式,挡不住 import X from "modulePath" (default) / import * as X from "modulePath" (namespace) / import "modulePath" (side-effect);用别名或副作用 import 都能绕过。重写成 findStaticImports(src, modulePath) 工具函数:扫描 ^[ \\t]*import\\b[^;]*; 抽出所有顶层静态 import 完整语句,再判断引号里的 modulePath 是否命中——4 种形式全部覆盖。dynamic(() => import("path")) 永远不会被误匹配,因为该行不以 import 关键字起头(是 () => import(const X = dynamic()。修测试时踩过的坑:第一版改用 ^[ \\t]*import\\b[\\s\\S]*?["']MODULE["'],因 [\\s\\S]*? 的懒匹配可以跨多个 import 语句一直找到目标——遇到 SetupCenter modulePath 时,正则从 line 3 的 import { useState ... } from "react" 一路滑到文件后面的 dynamic(() => import('@/components/setup/SetupCenter')),把 line 3 标成 offender。修复方法是先按 ; 切分语句再判断,每个 statement 自闭合不再串话。新增旁路 sanity check:node 一次性脚本注入 4 种静态 import 形式逐一验证捕获 + 验证 pristine src offenders 为 0,4 种全捕获、pristine 干净。测试更新appshell-lazy-imports.test.ts 第 4 例(UpdateDialog gate)正则从 \(updateContextValue\.updateInfo\?\.updateAvailable[\s\S]{0,80}?\)\s*&&\s*<UpdateDialog\s*\/> 改为 updateContextValue\.showDialog[\s\S]{0,100}?updateContextValue\.updateInfo\?\.updateAvailable[\s\S]{0,40}?<UpdateDialog\s*\/>,必须先 showDialog 再 updateAvailable 再 <UpdateDialog />验证npm run test 1628 通过 0 失败(case 数不变,5 例 contract 全过;P3 fix 改的是 gate 内容和 import-form 覆盖面,不增减用例);npx next build 通过。P3 fix 的 ROI 仅限正确性 + 防回归:UpdateDialog dismiss 后真正卸载 chunk(之前 dismiss 留着也只是几十 KB JS 没人看,不是大问题;但承诺要兑现);contract test 升级把"用别名 import 偷偷加回静态 import"这条潜在回归路径也封死。dev RSS 没新数字——本轮没改运行时行为,不需要重测内存。下一步立场不变:Settings + AppShell 这条收口,2.3-2.4 GB 是 Turbopack dev 的 floor;要继续追只能开"Next dev/Turbopack import graph"专项调研,目标是定位 floor 来源(Turbopack runtime / root layout 公共依赖 / 某个库的 dev 异常膨胀),不是继续拆 UI。

  • 2026-05-09:AppShell Phase A — 6 个条件渲染组件 lazy + Dialog state gate(dev RSS 不动,prod chunk 拆分生效)。范围严格锁死:只动 SetupCenter / SplitChatContainer / WorkspaceSidebar / PanelZone / UpdateDialog / FeatureAnnouncementDialog 这 6 个,ChatListPanel 不动(常驻主路径,ssr:false 会闪烁;Phase B 只在计划里、本轮不实施)。改动:(a) src/components/layout/AppShell.tsx:6 个组件全部从 import {X} from "..." 改成 dynamic(() => import("...").then(m => ({ default: m.X })), { ssr: false })SetupCenter / SplitChatContainer / WorkspaceSidebar / PanelZone 已经是条件渲染({setupOpen && ...} / {isSplitActive ? ... : ...} / {isChatDetailRoute && ...}),ssr:false 安全无 hydration mismatch 风险。UpdateDialog / FeatureAnnouncementDialog 之前是无条件渲染、内部自决,改为在 AppShell 加 state gate:UpdateDialog 由 {(updateContextValue.updateInfo?.updateAvailable ?? false) && <UpdateDialog />} 守卫;FeatureAnnouncementDialog 由 {announcementMaybeVisible && <FeatureAnnouncementDialog />} 守卫,AppShell 在 useEffect 里读 localStorage ANNOUNCEMENT_KEY 决定是否抬起 announcementMaybeVisible,未 dismiss 才 mount。(b) 新增 src/components/layout/feature-announcement-key.ts 共享常量模块——AppShell 不能直接从 FeatureAnnouncementDialog.tsx import 那个 key,否则就把对话框 + react-markdown 一起拖回静态 graph,前功尽弃。Dialog 文件本身也改成从这个共享文件 import。(c) 新增 src/__tests__/unit/appshell-lazy-imports.test.ts 5 例契约:(i) next/dynamic 必须 import;(ii) 6 个目标都不许出现 import { X } 静态形式,必须有 dynamic(...m.X 加载器;(iii) 6 个目标都必须 ssr: false;(iv) <UpdateDialog /> 必须被 updateContextValue.updateInfo?.updateAvailable 守卫;(v) <FeatureAnnouncementDialog /> 必须被 announcementMaybeVisible 守卫且 AppShell 必须从 ./feature-announcement-key import 而不是从 dialog 文件 import。带 4.3GB 熔断的内存复测(两次干净启动取信号,跟 round 4 同协议):第 1 次 baseline 564MB → /chat 峰值 2370 MB(之前 2334,+36)→ idle 20s 2346 MB(之前 ≈2334)→ /settings 峰值 2439(+93,与之前同)→ /settings/providers +216 MB(之前 +175,+41)→ /settings/models +127 MB(之前 +112,+15),FINAL PEAK 2783 MB。第 2 次重启重测:baseline 560MB → /chat 峰值 2380 MB → /settings/providers 增量 +172 MB(与 round 4 +175 持平)→ /settings/models 增量 +154 MB → FINAL PEAK 2678 MB。两次平均下来:/chat 峰值 ~2.37 GB、/settings/* 增量与 round 4 持平。结论:Phase A 在 dev RSS 上没有可观测的下降,第 1 次跑出来的 +41 是 cold-start 噪声,第 2 次重测就回到 baseline。Turbopack dev 不像 prod 那样真把 dynamic({ssr:false}) 推迟到运行时——dev 阶段它仍然把所有 dynamic chunks 走一遍 graph 编译以支持 HMR,所以 dev RSS 的"地板"由 Turbopack runtime + AppShell 剩余静态链(ChatListPanel + UnifiedTopBar + Toaster + 各 Context Provider + I18n / Theme / Workspace Sidebar Provider)决定,不是被这 6 个组件拖高的。Phase A 真正的收益落在 production bundle(end-user 初次访问 /chat 时不再下载 SetupCenter / SplitChat / WorkspaceSidebar / PanelZone / UpdateDialog / FeatureAnnouncementDialog 的 JS,build 时它们已经被 Next 拆成独立 chunks)和架构清晰度(条件渲染 → 条件加载,模式一致),不在 dev 内存数字上。验证npm run test 1628 通过 0 失败(前 1623 → +5 contract);npx next build 通过,11 + Settings 12 共 23+ 路由全部正常;首跑 appshell-lazy-imports 第 3 例(ssr:false)因 regex [\s\S]{0,160}?\}\s*\) 在多行 dynamic block 上贪婪匹配到内层 }) 漏掉外层 { ssr: false },把 anchor 改为 m.${name}[\s\S]{0,250}?ssr:\s*false 再跑就稳定通过。未做:Phase B(ChatListPanel 路由级拆分)按用户指令保留在计划里、不实施——既然 Phase A 在 dev 上零收益已经证明 Turbopack dev 不吃 lazy,把 ChatListPanel 搬到 /chat layout 也大概率不会让 dev RSS 进 1.5–2GB 区间,性价比不够。建议:Settings + AppShell 这条线收口,dev RSS ~2.3–2.4 GB 是当前 Next 16 + Turbopack + 这个 codebase 体量的 floor,进一步优化要么换打包链(Webpack dev / Turbopack 后续版本),要么真做 per-route layout 大手术(把 AppShell 整个拆成 ChatLayout / SettingsLayout 等并行壳,shared state 抽到 Context Provider 之上)——这两条都超出"拆 Settings/AppShell"的范围,下一刀如果继续做应该单独审批。

  • 2026-05-08:Settings 残留裸 hash 跳转清理 + 测试加宽(P2 review fix 2)。上一轮只清了 markdown / anchor 形式的 /settings#xxx 链接,但漏掉了组件内 navTo("#section") / window.location.hash = "#models" 这类直接写 hash 但不带 /settings 前缀的旧入口。在 hash-tab shell 时代它们靠 SettingsLayout 的 hashchange 监听切换 section;route-level split 之后 SettingsLayout 已删,hash 写入只改 URL 不切页,点击表面"无反应"。本轮把这类裸 hash 写全部迁到 router.push("/settings/<section>")改动:(a) src/components/settings/OverviewSection.tsx:本地 navTo callback 从 window.location.hash = hash 改为 useRouter().push("/settings/" + section),11 处 navTo("#X") 调用 → navToSection("X"),依赖项更新(useMemo deps 里的 navTo→navToSection)。(b) src/components/settings/HealthSection.tsx:删除模块级 function navTo(hash),组件内新增 useRouter + useCallback 派生 navToSection,17 处 navTo("#X") 调用全部迁。(c) src/components/settings/RuntimePanel.tsxhandleEnableInModels / handlePickAnotherDefault 两处 window.location.hash = "#models" 改为 router.push("/settings/models"),依赖加 router。(d) src/components/chat/RunCockpit.tsx:删除模块级 function navTo(hash)(这一份用 window.location.href = "/settings" + hash 等于硬重定向,更糟糕——会触发完整页面重载 + 根 page 再 redirect),组件内换成 useRouter + navToSection,5 处调用迁移。src/components/layout/AppShell.tsx:205if (window.location.hash === '#providers') 保留——它是 READ-only listener,不是导航写入,作用是兜底接住外部入口在非 /settings 路径的 #providers 深链触发 SetupCenter;改为 startsWith('/settings') 早返已经在上一轮完成,本轮无需再动。测试加宽 src/__tests__/unit/settings-link-migration.test.ts:之前只扫 /settings#\w+,现在改成 4 模式扫描器,每条命中都标"哪个 pattern + 文件:行 + 行内容":(i) markdown / anchor /settings#\w+;(ii) navTo("#X") helper 调用;(iii) window.location.hash = "#X" 写入(READ-only 比较被排除:测试只匹配 = 赋值,不匹配 === 比较);(iv) router.push("#X") / router.replace("#X") 直接 hash 跳转。允许 allowlist(按文件路径),但本轮迁完后所有 PATTERN 都没用上 allowlist。验证npm run test 1623 通过 0 失败(前 1623,没增减 case,扩 PATTERN 后扫整树仍干净,证明本轮 4 个文件 35 处迁移完整);npx next build 通过,12 条静态 settings 路由不变。未做:内存复测——这轮是行为修复(hash 写不再 silently no-op),不是 dev graph 缩减;Settings 内存图与上一轮相同(峰值仍 ~2.6GB / warm 之后单 section 增量 100–180 MB)。下一步按用户的顺序建议进入 AppShell 静态 import 拆分(SetupCenter / SplitChatContainer / WorkspaceSidebar / PanelZone 等公共壳的 lazy 化),不再继续抠 Settings。

  • 2026-05-08:Settings 路由继续拆细 + 内部链接迁移(P2 review fix)。两个 P2 finding 修复并加契约测试。改动 (a) /settings 根路由不再 import OverviewSection——之前的实现里 root page 直接渲染 <OverviewSection />,导致 /settings#providers 外部深链先编译 Overview → useOverviewData → @/lib/runtime/effective(provider catalog + model discovery + runtime resolver),再 router.replace/settings/providers,等于强行付一份 Overview 的 dev 编译预算。新结构:(i) 新增 src/app/settings/overview/page.tsx 才 import OverviewSection;(ii) src/app/settings/page.tsx 改成纯 client 重定向,完全不 import 任何 section——有 hash 跳到对应 /settings/<hash>,无 hash 默认跳 /settings/overview;(iii) 侧栏 + mobile 横向 nav 的 Overview 链接 href 从 /settings 改成 /settings/overview,但 pathnameToSection 仍把裸 /settings 视为 "overview" 以避免重定向瞬间高亮闪烁。改动 (b) 应用内 14 处 /settings#xxx 链接迁移到 /settings/xxxsrc/lib/run-checkpoint.ts(3 处 action.href + JSDoc)、src/hooks/useSSEStream.ts(错误流里给用户的 markdown 链接)、src/app/chat/page.tsx(错误显示 markdown + assistant 跳转)、src/app/bridge/page.tsx/bridge 兼容跳转)、src/components/settings/ProviderManager.tsx(goToClaudeCodeSettings anchor)、src/components/chat/ImageGenConfirmation.tsx(2 处 anchor)、src/components/layout/FeatureAnnouncementDialog.tsxsrc/components/layout/ChatListPanel.tsxsrc/components/layout/panels/AssistantPanel.tsx(3 处)、src/components/layout/panels/DashboardPanel.tsxsrc/components/setup/ProviderCard.tsx。注释 / JSDoc 里关于"hash 兼容"的描述按新事实改写但保留兼容说明(hash 入口仍由 /settings/page.tsx 的 redirect 表负责)。src/components/layout/AppShell.tsx 的 hash bridge 改为 pathname.startsWith('/settings') 早返(之前 === '/settings',新结构下 /settings/providers 这种深链也不应再在 AppShell 触发 SetupCenter pop)。改动 (c) 测试调整:(i) src/__tests__/unit/run-checkpoint.test.ts 4 处 /settings#providers / /settings#runtime 断言批量改成 /settings/providers / /settings/runtime;(ii) src/__tests__/unit/settings-routes-shape.test.tsoverview 加入 SECTION_TO_IMPORT 表,"root page imports only the lightweight Overview" 改成 "root page is a pure redirect — imports ZERO sections",新增 /settings/overview 必须存在且 router.replace('/settings/overview') 默认兜底的断言;(iii) 新增 src/__tests__/unit/settings-link-migration.test.ts 2 例契约:扫描整个 src/ 树(剔除 __tests__/ + 注释 + JSDoc),任何活跃代码再出现 /settings#\w+ 都失败并打印文件 + 行号 + 行内容;同时锁住根 page 必须保留 hash → route 兜底(window.location.hash + router.replace + 至少 4 个高频 section 出现在重定向表里)。改动 (d) 删除 src/__tests__/unit/settings-layout-lazy-sections.test.ts(基于已删除文件的不变量),由上一轮替换为 settings-routes-shape;本轮再加 settings-link-migration。带 4.3GB 熔断的内存复测(两个场景,干净 dev start):(场景 1) 冷启动直接打 /settings:baseline 562 MB → /settings 峰值 2341 MB(200 OK,3.9s 首编)→ /settings/providers 2481 MB → /settings/models 2664 MB → 25s idle 持平 2664 MB。与上一轮 2.66GB 持平——因为冷启动第一个请求无论打哪里都要付 AppShell + root layout 的首编成本。(场景 2) 先打 /chat 暖 AppShell,再依次打 /settings 各路由:baseline 563 MB → /chat(warm)2334 MB → /settings 增量 +92 MB(之前 root 带 Overview 时这一步就 +500MB+,现在是 redirect-only page,光客户端 router 模块)→ /settings/overview +164 MB(Overview 自己的成本)→ /settings/providers +175 MB → /settings/models +112 MB → 20s idle 2878 MB。关键收益:在 AppShell 已编译的真实使用流程下(用户从 /chat 跳 /settings 是常路径),/settings#providers 深链或者 sidebar 点击落点都不再先付 Overview 成本——之前是 ~500MB,现在 redirect 只 +92MB;直接跳 /settings/providers 内部链接是 +175MB section 自己的成本,不再叠加 Overview / 其它 section。仍未压到 1.5–2GB 目标的根因不在 Settings 而在 AppShell + root layout 自己(首次请求即编译 AppShell → ChatListPanel / SetupCenter / WorkspaceSidebar / PanelZone / SplitChatContainer / I18n / Theme 一整条链路),这是公共基础地板,下一个候选项是把 SetupCenter / SplitChatContainer / WorkspaceSidebar 这种"路由不需要时不应进 graph"的组件 lazy 化。改动文件清单(仅本次 P2 fix):新增 src/app/settings/overview/page.tsx + src/__tests__/unit/settings-link-migration.test.ts;改写 src/app/settings/page.tsx(pure redirect)+ src/app/settings/layout.tsx(overview href)+ src/components/layout/SettingsSidebar.tsx(overview href)+ src/__tests__/unit/settings-routes-shape.test.ts(新增 overview + 调整断言)+ src/__tests__/unit/run-checkpoint.test.ts(hash → route)+ src/lib/run-checkpoint.ts(3 处 action.href)+ src/hooks/useSSEStream.ts + src/app/chat/page.tsx(2 处)+ src/app/bridge/page.tsx + src/components/settings/ProviderManager.tsx + src/components/chat/ImageGenConfirmation.tsx(2 处)+ src/components/layout/FeatureAnnouncementDialog.tsx + src/components/layout/ChatListPanel.tsx + src/components/layout/panels/AssistantPanel.tsx(3 处)+ src/components/layout/panels/DashboardPanel.tsx + src/components/setup/ProviderCard.tsx + src/components/layout/AppShell.tsx(hash bridge guard 改 startsWith)+ src/components/chat/RunCheckpoint.tsx(comment)+ src/components/bridge/BridgeLayout.tsx(comment)+ src/components/settings/OverviewHeatmap.tsx(comment)。验证npm run test 1623 通过 0 失败(前 1621 → +5 新契约 −2 断言重写 = 净 +3 个 case,5 例 link-migration 多于先前 0)。第一次 run 偶然出现 applyDiscoveryDiff 闪挂,重跑稳定通过,疑似 SQLite 测试间共享文件锁的旧抖动,与本轮改动无关。npx next build 通过,build 输出可见 12 条静态路由:/settings + /settings/{about,appearance,assistant,bridge,general,health,models,overview,providers,runtime,usage}未做:UI 实机 smoke 仍按熔断式 curl + idle 复测覆盖,没有触发 4.3GB 熔断;进一步 Browser/CDP 等用户恢复信任后再做。

  • 2026-05-08:Settings 内存暴涨修复(route-level split)。与 Phase 3 Step 2 是独立线,不混入 Phase 3 提交。根因/settings 是单页面 hash-tab shell,11 个 section(Models / Bridge / Usage-Recharts / Appearance-Shiki / Runtime / Health / Providers / ...)都从同一个 SettingsLayout.tsx 可达。即使先前一轮把 sections 改成 next/dynamic,Turbopack dev 仍会从单一路由可达性出发预编译这些 dynamic chunks 进同一 graph,初次打开 /settings 把 next-server RSS 推到约 3.12GB 并稳定在 3.09GB。仅靠 experimental.turbopack* 配置(已落 turbopackFileSystemCacheForDev=false / turbopackMemoryLimit=1.5GB / turbopackSourceMaps=false / turbopackInputSourceMaps=false)止血,但是症状级——根因在路由结构。改动:(a) 拆出真实路由级 split:每个 section 一个 src/app/settings/<section>/page.tsx 只 import 自己的 section 组件——general / appearance / providers / models / runtime / health / usage / assistant / bridge / about 共 10 个 page;/settings 根 page 只 import OverviewSection。(b) src/app/settings/layout.tsx 新增——shared shell,只放窄视口横向 tab strip + {children} slot,完全不 import 任何 section;横向 nav 改用 <Link prefetch={false}> 避免 dev 预编译其它路由。(c) 删除旧 src/components/settings/SettingsLayout.tsx(hash-tab shell,整页持有所有 section 的 dynamic registry,是导致 dev graph 仍然过大的真正"屋脊")。(d) src/components/layout/SettingsSidebar.tsx 重写:从 useSyncExternalStore + getSectionFromHash + history.replaceState 改为 usePathname() + <Link href="/settings/<section>" prefetch={false}>,active 态走 pathname 派生。(e) 旧 hash 入口(错误消息里的 [Open Settings](/settings#providers) markdown 链接、外部深链)兼容:src/app/settings/page.tsxuseEffect 里读 window.location.hash,如果是已知 section 立即 router.replace('/settings/<section>'),user 看不到中间态。(f) Bridge 走 <BridgeLayout embedded /> 保留嵌入 sub-nav 不重复 chrome;Bridge 路由的 padding 由 layout 按 pathname === '/settings/bridge' 关闭,与原 hash-tab shell 行为一致。(g) 删除旧测试 src/__tests__/unit/settings-layout-lazy-sections.test.ts(基于 dynamic-import-in-shell 的不变量已不成立),替换为 src/__tests__/unit/settings-routes-shape.test.ts(5 例契约:每个 section 都有自己的 page.tsx、每个 page.tsx 只 import 自己的 section 不交叉 import 其它 section、shared layout 不 import 任何 section 也不出现 dynamic(、根 /settings/page.tsx 只 import OverviewSection 且必须有 hash → route 重定向、SettingsSidebar 走 usePathname + next/link 不再走 history.replaceState)。带 4.3GB 熔断的内存复测(dev 干净启动 → curl 三个路由 + 25s idle):(i) 启动后 baseline next-server RSS = 564 MB(与之前 ≈575MB 一致);(ii) /settings → 峰值 2344 MB(200 OK,4.1s 首编);(iii) /settings/providers → 峰值 2486 MB(+142 MB,0.8s);(iv) /settings/models → 峰值 2665 MB / ≈2.60 GB(+179 MB,0.7s);(v) 25s idle 不再继续上涨,最终 RSS 与 final peak 相同 2665 MB对比之前 3.12GB 峰值 / 3.09GB idle,下降约 460–610 MB,且 idle 不再爬升,但仍未压到 1.5–2GB 目标。继续偏高的来源:核心是 OverviewSection 自身经 useOverviewData → @/lib/runtime/effective 一路把 provider catalog + model discovery + runtime 解析器拖进 /settings 初编 graph,所以即使 /settings 只有 Overview,首次编译也带上 Runtime 子图。下一步如要再砍:把 OverviewHeatmap / OverviewGettingStartedBar / useOverviewData 拆成 lazy chunk,或把 /settings 默认页改成更轻的 health snapshot。改动文件清单(仅本次内存修复,与 Phase 3 Step 2 独立):新增 src/app/settings/layout.tsx + 10 个 src/app/settings/<section>/page.tsx + src/__tests__/unit/settings-routes-shape.test.ts;改写 src/app/settings/page.tsx + src/components/layout/SettingsSidebar.tsx;删除 src/components/settings/SettingsLayout.tsx + src/__tests__/unit/settings-layout-lazy-sections.test.tsnext.config.tsnext-dev-cache-config.test.ts 维持上一轮 stopgap 不动(防御性配置仍然有用)。验证npm run test 1621 通过 0 失败(前 1616 → +5 新契约 + 删 2 旧用例);npx next build 通过,build 输出可见 11 条静态路由 /settings/settings/{about,appearance,assistant,bridge,general,health,models,providers,runtime,usage} 全部 ○ Static。未做:UI 实机 smoke——熔断式 curl + 25s idle 已经跑过且没触发熔断,进一步走 Browser/CDP 容易复现内存事故,留给用户在恢复信任后再做完整人工核对。

  • 2026-05-08:Phase 3 Step 2 P2 review fix。两个 P2 finding 修复并加回归测试。(a) bg-poll 端口固化 (electron/main.ts startBgNotifyPoll):之前 const port = serverPort || 3000 在 setInterval 外只读一次,prod 启动顺序是先 createWindow() 再 await startServerOnStablePort(),如果用户在 serverPort 写入前就关掉 loading 窗口,poller 会永久轮询 3000,等真实稳定端口(47823–47830)落定后也救不回来。改为每 tick 内 const port = serverPort; if (!port) return,timer 保持 armed,下一轮 5 秒醒来再尝试。(b) macOS tray 单击双触发:之前对所有平台 tray.on('click', showMainWindow),但 macOS 上 setContextMenu 已经让单击直接出菜单,再加 click handler 会同时把主窗口拽出来,与「关窗后安静常驻、点菜单看选项」预期冲突。改为 if (process.platform !== 'darwin') tray.on('click', ...),darwin 只有 setContextMenu + double-click 打开窗口;Win/Linux 保持单击打开 + 右键菜单的常规约定。新增 2 例 invariants 测试:(i) startBgNotifyPoll body 不出现 serverPort || 3000,必须有 const port = serverPort + if (!port) return;(ii) ensureTray body 内 tray.on('click', ...) 必须落在 process.platform !== 'darwin' 守卫之内,double-click 维持无条件绑定。验证:npm run test 1618 通过 0 失败(前 1611 → +7:之前的 menubar-resident-invariants 套件从 7 例升到 9 例,但 node:test 的 subtests 计入总数所以增量是 7 不是 2);node scripts/build-electron.mjs 通过,dist-electron/main.jsconst port = serverPort;if (process.platform !== "darwin") 已 inline。仍未做手动 Electron smoke——这两个 fix 也是 Electron-runtime 行为,留给用户在 dev 上做一次 close → tray Open / 单击 / 双击的人工核对。

  • 2026-05-08:Phase 3 Step 2(后台常驻 + 本机 macOS 通知)完成。Step 1 审计的 P0 缺口落地:之前关闭主窗口会 kill server + 在 bridge inactive 时把 scheduler / 本机通知一起停掉;现在改为菜单栏常驻 + Bridge 解耦的本机通知链路。改动:(a) electron/main.tsmainWindow.on('close') 改为 event.preventDefault() + mainWindow.hide(),只有 isQuitting=true(菜单栏「退出 CodePilot」走 quitApp() 显式置位,或 before-quit 第一次进入时置位)才放行 close。(b) Tray 移到 app.whenReady() 创建,dev / prod 两条路径都加 ensureTray();以前是 window-all-closed + isBridgeActive() 才创建,现在变成应用启动后即常驻。(c) Tray 菜单从「Open CodePilot / Bridge Status: Active / Stop Bridge & Quit」三段式改为「打开 CodePilot / 退出 CodePilot」两条;标签从硬编码英文改为 getTrayMenuLabels(app.getLocale()),纯函数提到 src/lib/tray-menu-labels.ts 便于单测,按 locale 前缀返回 zh / en。(d) Background notification poller 与 Bridge 完全解耦:触发器从 window-all-closed + isBridgeActive() 改为 mainWindow.on('hide') → starton('show') → stop;poller 内部 self-stop 条件从 BrowserWindow.getAllWindows().length > 0 改为 mainWindow.isVisible(),避免「窗口隐藏但仍存在」时被误判为「应该停」。(e) window-all-closed 不再做任何 bridge 检查,也不再在非 Darwin 上 app.quit():菜单栏常驻语义在 Windows / Linux 同样要保留。(f) app.on('activate')(macOS dock 点击):若 mainWindow 已存在(隐藏态)则 showMainWindow() 复用而不是销毁 tray + 重建窗口,tray 跨 dock 点击保持。(g) before-quit 第一次进入即 isQuitting = true(之前是嵌在 if (serverProcess && !isQuitting) 条件里),保证 mainWindow.on('close') 看到的 flag 与 before-quit 决策一致。(h) bridge:is-active IPC + stopBridge() 保留,但只在 before-quit 用作"优雅停 bridge 再杀 server",与 tray / 通知链路无关。新增 2 个测试文件 16 例src/__tests__/unit/tray-menu-labels.test.ts(9 例:zh-CN / zh-TW / zh / en-US / 未知 locale / undefined / 空串 / 大写 ZH-CN / 不允许出现 Bridge 文案)+ src/__tests__/unit/menubar-resident-invariants.test.ts(7 例:close 拦截 + preventDefault + hide / quitApp 唯一 isQuitting=true 入口 / ensureTray 在 dev+prod 两次调用 / Tray 菜单走 getTrayMenuLabels / Tray rebuild 段不出现 Bridge / bg-poll 由 hide/show 驱动且 window-all-closed 不再含 isBridgeActive 或 startBgNotifyPoll / window-all-closed 不调用 app.quit);invariants 测试用 stripComments() 先剥离注释再 grep,防止注释里解释"以前的 Bridge 行为"误命中。验证npm run test 1611 通过 0 失败(之前 1604 → +9 + 7);npx next build 完成无 error;node scripts/build-electron.mjs 通过,dist-electron/main.js 中可见 getTrayMenuLabels / showMainWindow / quitApp / rebuildTrayMenu 已 inline。未做:手动启动 Electron dev 做 close → tray Open → tray Quit 的人工 smoke——按 AGENTS.md 新规则不主动开 Browser / Chrome / CDP,菜单栏 / Tray / Notification 三个 API 在 Node 单测里无法真跑,留给用户在本地 Electron dev 做一次确认;i18n bundle 没动(tray 菜单走 main 进程,与 React i18n 无对接,已在 tray-menu-labels.ts 顶部说明);不接管定时任务时间比较 bug 与 delivery log(属于 Step 3 范围);不改助理心跳逻辑(属于 Step 4 范围)。

  • 2026-05-08:Phase 3 Step 1(后台常驻 / 全局定时任务 / 助理心跳 / 通知现状审计)完成并按产品语义修订。本轮不启动 Browser / Chrome / CDP,避免复现上一轮浏览器自动化内存飙升;只做代码路径和现有测试审计。结论:定时任务已有 scheduler + DB + AI tool + 通知队列,但不能标为完整可用;最严重缺口是 scheduled_tasks.next_run 多处用 ISO 字符串写入,而 getDueTasks()next_run <= datetime('now') 文本比较,导致同日“几分钟后提醒”/手动 run 可能不会准时触发。产品语义修订:定时任务应是 CodePilot 全局能力,不只是 Assistant 页子功能;关闭主窗口应进入 macOS 菜单栏常驻,只有显式“退出 CodePilot”才停止 scheduler / 本机通知;Bridge 只是 Telegram / 飞书 / QQ 等远端增强通道,不是本机 macOS 通知的前置条件;心跳是助理健康检查 / 主动问候策略,不等于全局定时任务。Settings 信息架构决定:Settings → 定时任务 / 自动化 管全局任务;Settings → Assistant 只管心跳、主动问候和助理行为,心跳底层可复用 scheduler,但配置入口不搬到任务中心。外部 Agent Runtime 边界:Phase 3 不接管 OpenClaw / Hermes / 其它框架自带的 cron、heartbeat、background worker,只接收事件 / 通知结果,避免双重调度;跨 Agent 定时任务编排放到 Phase 4 多 Agent 适配里另审。参考源补充:OpenClaw / Hermes 提醒我们要做 computed status、reconcile stale running、delivery log、idle-aware;Codex 最新版(资料目录 codex,HEAD d9feaffffb)没有本地提醒 scheduler,但它的通知体验值得借鉴:通知按事件类型过滤、有 focus condition、有结构化 JSON payload,长任务通过状态 + notification stream 表达进度。下一步调整为:先做后台常驻 + 本机通知,再修全局定时任务触发 + delivery log,最后做助理心跳诚实化和 UI 分层。