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
22 KiB
方案:N3 持久与可观测——现状核验与增量实施
日期:2026-10-07。上游:2026-10-02-agent-stack-completion.md「N3 持久与可观测」节(范围与三条验收的唯一权威)、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 <model>/execute_tool <tool>/invoke_agent <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 收口(验收① 库层)
- 尾部配对兜底:
recoverPending之后加repairOrphanTail——Load 后若活动分支尾部是带 tool_calls 的 assistant 且其 ToolCalls 存在无结果项,对每个无结果调用补一条tool消息(Content: "进程中断,该调用未执行", IsError: true)并 persist。与意图路径正交:意图已配对的调用不受影响(按 CallID 判重)。这样无论死在哪个窗口,下次 Run 前转录配对恒成立。这是验收① 的机器核心。 - 杀窗口测试:循环测试钩子(在 persist(assistant) 之后、首个 intent 之前让测试能停住循环,或直接构造该转录形态——两态都测:窗口内死亡(无意图)与执行中死亡(有意图未配对)),断言:恢复后 assertToolPairs 双向成立 + 兜底结果文案正确 + 已配对项零重复。
- N6 残余:
Stop.Replacement不再改内存 assistant(只追加 user 替换消息)——内存与转录一致;loop_harness_test.go:17-63同步改断言(替换文案不再出现在 res.Messages 的 assistant 上,而是新 user 消息)。行为变化入卡。 - 子代理优雅停机续跑语义:done.json 按 status 分流——
completed/failed/cancelled挡续跑;interrupted不挡(优雅停机=可续跑现场)。ResumeInterruptedChildren改判据并补回归(模拟 InterruptRunning 路径后仍被续跑一次且仅一次)。
WP2 产品会话路径持久化(验收① 产品层)
llm_direct.go的directRun打开会话工作区 transcript:<workspaceRoot>/<owner>/<conv>/.agentx/transcript.jsonl,经runtime.Session传入nruntime.Run——产品会话 run 获得意图日志、恢复配对与(WP1-1 的)尾部兜底。- 历史来源对齐:现
History来自 store 重放;Session 模式以 transcript 为准(库语义:Session 非空时 transcript 是权威,History 仅在 transcript 为空时作种子——llm.History 的既有语义 loop.go:76-78)。迁移语义:首次启用时 transcript 为空 → 用 store 历史种子;此后 transcript 权威。种子与续接的一致性测试(不丢不重)。 - 不做透明续跑(ZCode 边界,入卡):
ReconcileInterrupted对会话 run 仍 seal interrupted;配对修复在下一条用户消息触发的新 run 前完成(WP1-1 兜住),用户看到的是「中断」标记 + 可继续对话,不是自动重跑。 - 端到端测试:产品路径杀窗口形态 → 新 run 前配对成立 + 用户可继续发消息(种子不丢历史)。
WP3 检查点收口(验收②)
- 资格断言:导出
Registry.Checkpointed(name) bool(struct mark 非空即真)或同语义测试辅助;测试钉住入集集合恰为 {write_file, edit_file, multi_edit}(库)与 {fs_write_file}(backend 自家面),防静默漂移。 - 符号链接防护:
restore落盘前Lstat——目标为符号链接时跳过并进 Skipped(与 fs_write_file 的防护同语义)+ 测试。 - 部分恢复进 Result:
Rewind单文件失败不中止,记录Failed []string续跑其余;Result 增字段(additive);测试(一个失败一个不失败)。 - 保留策略:按 anchor 裁剪——
Log.Compact(keepAnchors int)只保留最近 N 个 anchor 的记录(重写日志文件);backend 在 RewindFiles 成功后以 keepAnchors=200 调用;Record的 Seq 改由文件尾行号维护(去全读)。 - 测试补齐:HTTP happy + busy 409(server 层);root 移动场景(restore 按记录内 Root 恢复,不窜根);空
ordered边界行为钉住;「外部同字节改写」分类为 Already 的语义钉住。
WP4 OTel GenAI 对齐(验收③)
- span 名带后缀:
invoke_agent <agent|agent-native>、chat <model>、execute_tool <tool>(loop.go 三处改名;适配器/宿主传 agent 名——invoke_agent 的 agent 名用 Config.ExecutionID 或新增 Config.AgentName,默认 "agent-native")。 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 填入)。- 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 入栈为根,出栈弹出;简单确定性强)。 - resource 与导出:resource 增
service.name=agent-native(宿主可覆盖:telemetry.StartFunc 的选项参数);token 属性 intValue;导出失败记日志(不静默吞)。 - settings 面:
telemetry.otlpEndpoint(默认 "" = 不导出,保持现状语义;环境变量继续有效,settings 覆盖环境);telemetry.serviceName(默认 agent-native)。两处 default 的行为不变证明测试。 - 测试(验收③ 机器证据):span 名后缀与属性键枚举断言(钉住约定);一次 Run 的 span 共享 TraceID 且 parent 链正确;token 进 chat span 且为 intValue;拒绝名单保持(prompt/content 永不序列化——既有测试原样通过)。
WP5 Memory 端口
- 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 的先例(库定义+宿主实现)。 - 循环消费点:
Config.Memory memory.Store(nil=不启用)+Config.MemoryScope string(对应 AgentSpec.MemoryScope 的消费);prepareRequest时在系统前缀后追加 recall 注入块(替换文案形态与 ctxasm.InjectMemory 对齐:[记忆]\n- (typ) content,上限 20 条,确定性排序)——行为对齐测试(端口注入的条目出现在请求里、nil 时无变化)。 - 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)。 - memory_write 工具:库内工具(模型自服务写回;参数 {scope,type,content,expires_in_days?},经
Config.Memory.Append落库;未配置不注册);scope 校验(user|project)与类型校验(fact|preference|decision|episode|procedure)fail-closed。 - 后续项(入卡剩余项,不在本 WP):回合后抽取接线(agent-memory-extractor 子代理 + SessionEnd 事件驱动)、AllowStore 持久化形态(N1 deferred)。
WP6 计费拆分与回退/重试收口
- cache tokens 累计:loop.go:259-264 增 CacheRead/Write 累计与 Last* 对应位(Usage 增 LastCacheReadTokens/LastCacheWriteTokens?——决策:Last* 只对 Prompt/Completion/Total 已有三字段,cache 只入累计位,避免字段膨胀;记录决策依据)。
- 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 字段)。 - 前端:contracts.ts Usage 增可选字段;UsageBadge 显示「上一请求 / 累计」(如
+12.3k · 累计 98.1k)。 - 库默认重试:
Config.RetryAttempts(默认 2,0=关)——Run内包装cfg.Stream = llm.WithRetry(cfg.Stream, attempts, onEvent);backend 直连的显式包装退役(防双重),子代理/verifier/squad 经库默认获得(行为变化:这些路径从 0 次变 2 次重试——入卡;onEvent 接 runCtx 事件面,auto_retry_start/end成为可见事件)。 - 可观测:
auto_retry_start/end与model_fallback_switch事件类型入事件面(库 Event 类型 + backend 映射 + 前端展示沿用现有 process part 形态)。 - 回退链接 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 保证,测试钉住「一次且仅一次」。