From c13c79ebacc32064ec7ed52b0af456699c0c0662 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 14:55:37 +0000 Subject: [PATCH] Propose the N3 implementation plan: durability and observability Why: N2 is closed; per the completion doc's dependency order N3 is next and needs a plan grounded in current code (the crash window between persist and intent commit is unprotected, the product conversation path has zero durability, span names/attributes do not follow the GenAI convention, the memory port does not exist). Evidence: five-area survey with file:line citations at the pinned baselines (agent-native 74aa60b, backend 7980a8a, core 0b5c774); audit N1/N21 verified fixed, N6/N20 verified still open. Impact: documentation only; work starts after this plan merges. Refs: 2026-10-02-agent-stack-completion.md N3 section --- .../2026-10-07-agent-stack-n3-plan.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/proposals/2026-10-07-agent-stack-n3-plan.md diff --git a/docs/proposals/2026-10-07-agent-stack-n3-plan.md b/docs/proposals/2026-10-07-agent-stack-n3-plan.md new file mode 100644 index 0000000..70db872 --- /dev/null +++ b/docs/proposals/2026-10-07-agent-stack-n3-plan.md @@ -0,0 +1,170 @@ +# 方案:N3 持久与可观测——现状核验与增量实施 + +日期:2026-10-07。上游:[2026-10-02-agent-stack-completion.md](2026-10-02-agent-stack-completion.md)「N3 持久与可观测」节(范围与三条验收的唯一权威)、[2026-10-02-agent-stack-audit.md](../records/2026-10-02-agent-stack-audit.md)(审计项逐条对当前代码复核后引用)。 + +基线 pin:agent-native `74aa60b`、backend `7980a8a`、agent-squad `6306451`、core `0b5c774`(均为已合入 main)。本文所有 file:line 以这些 SHA 为准;实施时先复核再引用。 + +依赖状态:N1/Q1/I1/I2/N2 全部闭卡(任务卡归档)。总提案依赖序「N3 在 N2 之后」满足,N3 开工。 + +## 一、现状核验(五路侦察 + 关键处亲验) + +### 1. RunState 可恢复(验收①) + +已就位(库层、native 适配路径): + +- **意图日志**:`runOne` 先 `commitToolIntent`(记 CallID/参数/Replay 声明)再执行(runtime/loop.go:649-654,738-749;session/intent.go:9-25);执行失败于写意图即产 IsError 配对。 +- **恢复配对**:`recoverPending`(loop.go:707-736)对未配对意图——存储声明与注册表现行声明**双 safe 才重放**(completion doc row 75 的不变式),否则补「副作用未知,未重试」IsError 配对。任一情形配对不变式都成立。 +- **预算停配对**:`appendSkippedToolResults`(loop.go:452-469,N1 已修,回归 loop_budget_test.go:78)。 +- **毒化轮转**:`rotateIfPoisoned` + `.native-last-failure` 落盘(adapters/native/backend.go:529-593,N21 已修,跨进程回归 r2_regression_test.go:19)。 +- 回归测试:durable_test.go 四组(safe 重放一次/unsafe 不重放/存储 safe+现行 never 不重放/follow-up 持久序)。 + +仍开放: + +- **崩溃窗口无保护**:`persist(assistant)`(loop.go:265)→ 首个 `commitToolIntent`(loop.go:650)之间被杀,转录留下带 tool_calls 的 assistant 且零意图;`recoverPending` 无计可施,新 user 消息追加在孤立尾巴后,下一个请求被提供方拒绝。**无任何与意图无关的尾部配对兜底**。 +- **产品会话路径零持久**:`llm_direct.go:172-193` 调 `nruntime.Run` 不传 `Session`——意图日志/恢复配对在产品会话 run 上全部不生效;中断后部分 parts 只存内存,seal 即丢。**这是验收① 对产品的真缺口。** +- 无 mid-turn RunState 类型(turn 序号/在途调用/累计用量);`runtime/checkpoint` 是文件回滚日志,不是循环状态(checkpoint.go:13-22)。 +- 无「杀进程中途」测试:现有 durable 测试手喂意图,没有覆盖 persist→intent 窗口。 +- **N6 残余**:`Stop.Replacement` 仍把内存 assistant 改为替换文(loop.go:339-347),磁盘上 assistant 是原文——内存/转录分叉跨重启可见(追加的 user 替换消息只缓解了「模型能看到」)。 +- **子代理优雅停机不续跑**:`InterruptRunning` 对所有子 run 写 `done.json{status:"interrupted"}`(runmgr.go:1658),而 `ResumeInterruptedChildren` 遇 done.json 即跳过(subagent_transcript.go:119)——WP5 续跑只对 SIGKILL 式死亡生效,优雅停机语义上反而不续。 + +### 2. 文件检查点可回滚(验收②) + +已就位: + +- 库 checkpoint 包:append-only JSONL(Record{Seq,Anchor,Root,Path,Existed,Before,After})、`Plan/Rewind`、内容漂移跳过(stateRestore/Already/Skip,checkpoint.go:194-229)、包注释与结构双重保证「shell/python 不追踪」。 +- write/edit/multi_edit 三工具经 `WorkspaceToolsCheckpointed` 挂 mark(registry.go:370-394;builtin.go:111-117,153-168,333-353)。 +- backend rewind:每会话 `.agentx/checkpoints.jsonl`(rewind.go:12-17)、anchor=用户消息 ID、HTTP 两路由(server.go:169-170)、regenerate 复用(borrow.go:218)、忙时 409。 +- ZCode 边界全符合:只动文件不回滚历史(追加 system 消息而非删除)、不透明续跑(regenerate 显式起新 run)。 +- 测试:checkpoint_test.go 两组、checkpoint_hook_test.go、rewind_test.go(backend 端到端)。 + +仍开放(收口项,无根缺): + +- 回滚资格**隐式**(struct 有 mark 字段即入集):无导出断言/注册表查询,未来工具可静默入集或出集。 +- `restore` 用 `os.WriteFile` 跟随符号链接(checkpoint.go:241)——fs_write_file 有符号链接防护(fs.go:142-146),回滚路径没有。 +- `Rewind` 顺序恢复、首错即返(checkpoint.go:108):中途失败留下部分恢复,且部分态不进 Result。 +- 无保留策略:`Record` 每次全读算 Seq(checkpoint.go:57-62),日志无界增长。 +- HTTP 层无测试;root 移动、空 `ordered` 边界(selected() checkpoint.go:146)未测。 +- backend 自家工具面只有 `fs_write_file` 挂检查点(tools/fs.go:158-162),无 fs_edit/fs_multi_edit(产品面不暴露编辑工具)——验收② 的 write/edit/multi_edit 语义在库与适配层成立,验收测试按层分配(见 WP3)。 + +### 3. Memory 端口 + +已就位(全是产品侧资产):`memory_entries` 表(migrate v13)+ `PutMemory/ListMemory`(platform.go:137,157,仅 INSERT、无 scope 过滤无语义排序);HTTP 手工面(server.go:245-246);被动 recall(runmgr.go:409-426 全量过期过滤 + ctxasm.InjectMemory ≤20 条注入系统消息);`session_note`、plans/todos;预置 prompts(aux-session-memory.md、agent-memory-extractor.md,**无消费者**);`AgentSpec.MemoryScope` 已解析(agentspec.go:26,152-153)**零消费**。 + +仍开放:库内零 memory 面(端口不存在);无写回路径(模型不可自服务,回合后抽取未接线);recall 无 scope 过滤;MemoryScope 无消费。 + +### 4. OTel GenAI span(验收③) + +已就位:telemetry 包(Span/StartFunc/手写 OTLP HTTP JSON 导出,telemetry.go:18-106);**`publicAttrs` 拒绝名单丢 prompt/messages/content 且被测试钉住**(telemetry.go:53-62,89;telemetry_test.go:35-41)——验收③「默认不含提示词正文」已满足且有机器证据;Trace 钩子三处 span(loop.go:174 invoke_agent、:407 chat、:655 execute_tool);backend 全线注入(app.go:87,258,284-285 → llm.go/sub_loop)。 + +仍开放: + +- **span 名无后缀**:约定是 `chat `/`execute_tool `/`invoke_agent `,现名裸词。**这是验收③ 的主缺口。** +- 属性未 `gen_ai.*`:缺 `gen_ai.system`、`gen_ai.request.model`、`gen_ai.tool.name`、`gen_ai.usage.input_tokens/output_tokens`(token 在循环有,从未进 span)。 +- 无 trace 关联:每 span 随机新 TraceID(telemetry.go:33),无 parentSpanId,Span 无 Parent/Status/Kind 字段。 +- 配置面只有环境变量 `OTEL_EXPORTER_OTLP_ENDPOINT`(telemetry.go:30),无 settings 项。 +- resource 无 `service.name`;导出错误丢弃(:46);属性值全 string(token 应 intValue)。 + +### 5. 成本「上一请求/累计」 + +已就位:`Usage.Last*` 三字段(types.go:90-99)+ 循环覆盖(loop.go:262-264);预算检查用 `LastPromptTokens`(loop.go:872-873,正确——prompt 成本按整段历史计);`message_end` 事件带单请求值(loop.go:269)。 + +仍开放:cache tokens 从不累计(loop.go:259-264 只动 Prompt/Completion/Total);`"usage"` 事件只带累计(loop.go:853-855,且 budgetStop/错误路径不发);`core.Usage` 无 Last*/cache 字段(modules/core model.go:205-213);**子代理 run 零 token 记录**(runmgr.go:1399 只记 DurationMs;sub_loop 不调 AddUsage);前端只显示 total(AgentTracker.tsx:79-83);无逐请求台账。completion 预算用累计(loop.go:875)经复核为**正确语义**(run 级输出预算),不动。 + +### 6. 模型回退链(基本已修,剩收口) + +已就位:库 `WithFallback`(llm/fallback.go:7-47——5xx 且未出增量才切换,测试 fallback_test.go 三组);backend `ChatFallbacks` 解析(model.go:65-81)+ 消费(llm_direct.go:103、sub_loop.go:61 chat 档子代理)+ `noteModelSwitch` 通知与 servedModel 落账(llm_direct.go:210-240)。回退条目共享主端点(记录为有意)。 + +仍开放(收口项):verifier 与 lightweight 档未接;切换无可观测事件类型(仅 process part 文案);agent-native CLI 未用 WithFallback。 + +### 7. 流式错误默认重试 + +已就位:`WithRetry`(providers.go:447-524——Retryable 分类 408/429/529/≥500 与容量文案、Retry-After 上限 30s、指数退避加抖动、**只在未出增量时重试**、上下文溢出不重试;providers_test.go 五组钉住);backend `runtime.llmRetries` 默认 2 接直连(llm.go:208-209;settings.go:43,94);agent-native CLI 接 2 次(main.go:121)。 + +仍开放:**库循环无默认**——重试是宿主 opt-in,子代理/verifier/squad 路径未接(`sub_loop.go:52-59` 无 WithRetry);`onEvent` 为 nil,重试不可观测;run 记录无重试次数。 + +## 二、工作包划分 + +| WP | 内容 | 主要仓 | 对应验收 | +|---|---|---|---| +| WP1 | RunState 收口:崩溃窗口尾部配对兜底 + 杀窗口测试 + N6 残余 + 子代理优雅停机续跑语义 | agent-native + backend | ①(库层) | +| WP2 | 产品会话路径持久化:llm_direct 接 Session(transcript.jsonl),产品会话 run 获得意图日志与恢复配对 | backend | ①(产品层) | +| WP3 | 检查点收口:资格断言、符号链接防护、部分恢复进 Result、保留策略、HTTP 与边界测试 | agent-native + backend | ② | +| WP4 | OTel GenAI 对齐:span 名后缀、gen_ai.* 属性、trace 关联与 parent、resource、settings 面 | agent-native + backend | ③ | +| WP5 | Memory 端口:runtime/memory 包 + 循环 recall 消费点 + MemoryScope + backend scope 实现 + memory_write 工具 | agent-native + backend | — | +| WP6 | 计费拆分与回退/重试收口:cache 累计、usage 事件与 run 记录带 Last*/cache、子代理 token、库默认重试、可观测事件 | agent-native + core + backend + frontend | — | + +依赖:WP1 先行(验收① 库层核心,WP2 依赖其兜底语义);WP2 随后(产品落点);WP3/WP4/WP5 独立可并行;WP6 收尾(含 core 字段级联与重试语义变化,WP6 后做总装与 A/B)。每 WP 走既有链式(库 PR → 级联 → backend PR → Dev gitlink PR),逐 WP 合入。 + +### WP1 RunState 收口(验收① 库层) + +1. **尾部配对兜底**:`recoverPending` 之后加 `repairOrphanTail`——Load 后若活动分支尾部是带 tool_calls 的 assistant 且其 ToolCalls 存在无结果项,对每个无结果调用补一条 `tool` 消息(`Content: "进程中断,该调用未执行"`, IsError: true)并 persist。与意图路径正交:意图已配对的调用不受影响(按 CallID 判重)。这样无论死在哪个窗口,下次 Run 前转录配对恒成立。**这是验收① 的机器核心。** +2. **杀窗口测试**:循环测试钩子(在 persist(assistant) 之后、首个 intent 之前让测试能停住循环,或直接构造该转录形态——两态都测:窗口内死亡(无意图)与执行中死亡(有意图未配对)),断言:恢复后 assertToolPairs 双向成立 + 兜底结果文案正确 + 已配对项零重复。 +3. **N6 残余**:`Stop.Replacement` 不再改内存 assistant(只追加 user 替换消息)——内存与转录一致;`loop_harness_test.go:17-63` 同步改断言(替换文案不再出现在 res.Messages 的 assistant 上,而是新 user 消息)。行为变化入卡。 +4. **子代理优雅停机续跑语义**:done.json 按 status 分流——`completed/failed/cancelled` 挡续跑;`interrupted` 不挡(优雅停机=可续跑现场)。`ResumeInterruptedChildren` 改判据并补回归(模拟 InterruptRunning 路径后仍被续跑一次且仅一次)。 + +### WP2 产品会话路径持久化(验收① 产品层) + +1. `llm_direct.go` 的 `directRun` 打开会话工作区 transcript:`///.agentx/transcript.jsonl`,经 `runtime.Session` 传入 `nruntime.Run`——产品会话 run 获得意图日志、恢复配对与(WP1-1 的)尾部兜底。 +2. 历史来源对齐:现 `History` 来自 store 重放;Session 模式以 transcript 为准(库语义:Session 非空时 transcript 是权威,History 仅在 transcript 为空时作种子——llm.History 的既有语义 loop.go:76-78)。迁移语义:首次启用时 transcript 为空 → 用 store 历史种子;此后 transcript 权威。种子与续接的一致性测试(不丢不重)。 +3. **不做透明续跑**(ZCode 边界,入卡):`ReconcileInterrupted` 对会话 run 仍 seal interrupted;配对修复在下一条用户消息触发的新 run 前完成(WP1-1 兜住),用户看到的是「中断」标记 + 可继续对话,不是自动重跑。 +4. 端到端测试:产品路径杀窗口形态 → 新 run 前配对成立 + 用户可继续发消息(种子不丢历史)。 + +### WP3 检查点收口(验收②) + +1. **资格断言**:导出 `Registry.Checkpointed(name) bool`(struct mark 非空即真)或同语义测试辅助;测试钉住入集集合恰为 {write_file, edit_file, multi_edit}(库)与 {fs_write_file}(backend 自家面),防静默漂移。 +2. **符号链接防护**:`restore` 落盘前 `Lstat`——目标为符号链接时跳过并进 Skipped(与 fs_write_file 的防护同语义)+ 测试。 +3. **部分恢复进 Result**:`Rewind` 单文件失败不中止,记录 `Failed []string` 续跑其余;Result 增字段(additive);测试(一个失败一个不失败)。 +4. **保留策略**:按 anchor 裁剪——`Log.Compact(keepAnchors int)` 只保留最近 N 个 anchor 的记录(重写日志文件);backend 在 RewindFiles 成功后以 keepAnchors=200 调用;`Record` 的 Seq 改由文件尾行号维护(去全读)。 +5. **测试补齐**:HTTP happy + busy 409(server 层);root 移动场景(restore 按记录内 Root 恢复,不窜根);空 `ordered` 边界行为钉住;「外部同字节改写」分类为 Already 的语义钉住。 + +### WP4 OTel GenAI 对齐(验收③) + +1. **span 名带后缀**:`invoke_agent `、`chat `、`execute_tool `(loop.go 三处改名;适配器/宿主传 agent 名——invoke_agent 的 agent 名用 Config.ExecutionID 或新增 Config.AgentName,默认 "agent-native")。 +2. **`gen_ai.*` 属性**:`gen_ai.system`(openai/anthropic,由客户端协议名上抛——Stream 事件或 Config 显式字段,取后者简单:`Config.ProviderName string`,宿主可设,空=openai);`gen_ai.request.model`(chat span);`gen_ai.tool.name`(execute_tool span);`gen_ai.usage.input_tokens/output_tokens`(chat span 结束时由该轮 Usage 填入)。 +3. **trace 关联**:一次 Run 共享一个 TraceID,`invoke_agent` 为根、`chat`/`execute_tool` 为其子(parentSpanId)。Trace 钩子签名扩展:`Trace func(name string, attrs map[string]string) func()` 保持,Span 增 ParentSpanID/TraceID 注入位(telemetry.StartFunc 返回的闭包按 name 层级维护栈——invoke_agent 入栈为根,出栈弹出;简单确定性强)。 +4. **resource 与导出**:resource 增 `service.name=agent-native`(宿主可覆盖:telemetry.StartFunc 的选项参数);token 属性 intValue;导出失败记日志(不静默吞)。 +5. **settings 面**:`telemetry.otlpEndpoint`(默认 "" = 不导出,保持现状语义;环境变量继续有效,settings 覆盖环境);`telemetry.serviceName`(默认 agent-native)。两处 default 的行为不变证明测试。 +6. **测试(验收③ 机器证据)**:span 名后缀与属性键枚举断言(钉住约定);一次 Run 的 span 共享 TraceID 且 parent 链正确;token 进 chat span 且为 intValue;拒绝名单保持(prompt/content 永不序列化——既有测试原样通过)。 + +### WP5 Memory 端口 + +1. **runtime/memory 包**:`Entry{ID,Scope,ProjectID,Type,Content,Source,Confidence,CreatedAt,ExpiresAt}` + `RecallQuery{Scope,ProjectID,Limit}` + `Store interface{ Recall(ctx,q) ([]Entry,error); Append(ctx,e Entry) error }`。端口形态对齐 squad/runstore.go 的先例(库定义+宿主实现)。 +2. **循环消费点**:`Config.Memory memory.Store`(nil=不启用)+ `Config.MemoryScope string`(对应 AgentSpec.MemoryScope 的消费);`prepareRequest` 时在系统前缀后追加 recall 注入块(替换文案形态与 ctxasm.InjectMemory 对齐:`[记忆]\n- (typ) content`,上限 20 条,确定性排序)——行为对齐测试(端口注入的条目出现在请求里、nil 时无变化)。 +3. **backend 适配**:`storeMemory{st, owner}` 实现端口(Recall=ListMemory scope/过期过滤+Limit;Append=PutMemory);`llm.go` 装配把现 `Memories()` 断言路径(llm.go:231-233 + runmgr.go:409-426)迁到端口(语义等价:同条目同排序同文案——迁移前后注入文本一致的对照测试)。`AgentSpec.MemoryScope` 经 squad/native 适配传入 Config.MemoryScope(plugin agents 的画像驱动 scope)。 +4. **memory_write 工具**:库内工具(模型自服务写回;参数 {scope,type,content,expires_in_days?},经 `Config.Memory.Append` 落库;未配置不注册);scope 校验(user|project)与类型校验(fact|preference|decision|episode|procedure)fail-closed。 +5. 后续项(入卡剩余项,不在本 WP):回合后抽取接线(agent-memory-extractor 子代理 + SessionEnd 事件驱动)、AllowStore 持久化形态(N1 deferred)。 + +### WP6 计费拆分与回退/重试收口 + +1. **cache tokens 累计**:loop.go:259-264 增 CacheRead/Write 累计与 Last* 对应位(Usage 增 LastCacheReadTokens/LastCacheWriteTokens?——决策:Last* 只对 Prompt/Completion/Total 已有三字段,cache 只入累计位,避免字段膨胀;记录决策依据)。 +2. **usage 事件与 run 记录**:`emitUsage` 带 Last*+cache;settle 之外的路径(budgetStop/错误)也发一次终态 usage(口径=当时累计,标注终止原因在既有错误面);`core.Usage` 增 `LastPromptTokens/LastCompletionTokens/CacheReadTokens/CacheWriteTokens`(additive JSON,core 仓 PR+级联);backend AddUsage 链路上抛新字段;**子代理 token 记账**(sub_loop AddUsage 接线,finishChild 写入 child.Usage 的 token 字段)。 +3. **前端**:contracts.ts Usage 增可选字段;UsageBadge 显示「上一请求 / 累计」(如 `+12.3k · 累计 98.1k`)。 +4. **库默认重试**:`Config.RetryAttempts`(默认 2,0=关)——`Run` 内包装 `cfg.Stream = llm.WithRetry(cfg.Stream, attempts, onEvent)`;backend 直连的显式包装退役(防双重),子代理/verifier/squad 经库默认获得(行为变化:这些路径从 0 次变 2 次重试——入卡;onEvent 接 runCtx 事件面,`auto_retry_start/end` 成为可见事件)。 +5. **可观测**:`auto_retry_start/end` 与 `model_fallback_switch` 事件类型入事件面(库 Event 类型 + backend 映射 + 前端展示沿用现有 process part 形态)。 +6. **回退链接 verifier/lightweight**:verifier 的流包 WithFallback(lightweight 档回落主档的既有语义不动);CLI 记录边界(不接)。 + +## 三、验收总表 + +| 总提案验收 | 机器证据 | 所在 WP | 现状 | +|---|---|---|---| +| ① 回合中途杀进程,恢复后工具配对仍成立 | 库:`TestResumePairsOrphanTailAfterCrashWindow`(无意图窗口)+ 既有 durable 四组(原样保持);产品:`TestDirectRunPairsAfterKillSeed`(种子不丢+配对成立) | WP1+WP2 | 库有意图路径覆盖、窗口无保护;产品零持久 | +| ② 回滚只撤销 write / edit / multi_edit | 资格集合钉住测试(库 {write_file,edit_file,multi_edit}、backend {fs_write_file})+ 既有 rewind/checkpoint 测试原样通过 + shell 文件存活断言(既有) | WP3 | 结构性成立、断言隐式 | +| ③ span 名与约定一致,默认不含提示词正文 | `TestSpanNamesFollowGenAIConvention`(名称后缀+gen_ai.* 键枚举+intValue token+TraceID 共享+parent 链)+ 既有 publicAttrs 拒绝名单测试原样通过 | WP4 | 正文拒绝已有;名称/属性/关联未对齐 | + +## 四、边界与非目标 + +- **不做透明续跑**(ZCode 边界):配对修复后等新用户消息;会话 run 仍 seal interrupted。WP5 子代理续跑(重派新 run)是既有有意设计,不扩展到会话 run。 +- **不回滚对话历史**(ZCode 边界):rewind 只动文件,WP3 不碰消息面。 +- **不做** memory 回合后抽取接线、AllowStore 持久化、RAG 语义检索 memory_entries(WP5 是端口与接线,召回排序升级留后续)。 +- **不做** OTel SDK 引入/gRPC/batch/采样(保留手写 HTTP JSON 导出;WP4 只做语义对齐与可靠化小修)。 +- **不做** completion 预算口径改动(loop.go:875 经复核为正确语义)、prompt 预算口径改动。 +- **不做** 回退条目独立端点(共享主端点记录为有意)、CLI 的 fallback 接线(记录边界)。 +- 审计 N1/N21 已修只引用;N6 随 WP1 关闭;N20 随 WP6 关闭(预算语义不动,展示/记录口径拆分)。 + +## 五、风险与回退 + +- **产品路径接 Session(WP2)**:transcript 成为权威是会话语义变化——种子迁移必须保「不丢不重」(WP2-2 的对照测试为放行门槛);出问题回退 = 不传 Session(单点开关,代码内常量而非设置项,回退 PR 即可)。 +- **N6 行为变化(WP1-3)**:Stop.Replacement 的展示形态变化(assistant 不再显示替换文,替换文以 user 消息出现)——guardrails 消费方(verify 回路)逐一回归。 +- **WP6 重试默认化**:子代理/verifier/squad 从 0 变 2 次重试——超时/配额消耗上升;`RetryAttempts=0` 可全关(测试钉住 0=不重试)。 +- **core.Usage 加字段**:additive JSON 兼容旧记录;级联链 core PR → backend PR → frontend PR → Dev。 +- **done.json 语义分流(WP1-4)**:interrupted 从「挡续跑」变「可续跑」——优雅停机后重启会多一次重派;幂等由 resumed.json 保证,测试钉住「一次且仅一次」。