Skip to content

子代理结果投递即落盘,修复异常中断丢失结果 - #152

Merged
phantom5099 merged 5 commits into
mainfrom
subagent
Nov 6, 2026
Merged

phantom5099 merged 5 commits into
mainfrom
subagent

Conversation

@phantom5099

Copy link
Copy Markdown
Owner

变更说明

子代理终态结果此前只进内存 Mailbox,由父回合在自己的 drain 点才写盘。这留下两个丢失窗口:

  1. 父回合提前结束(模型没 wait_agent 就收尾)→ 无人 drain,结果留在内存队列里;
  2. 进程退出 → MailboxLayer 是内存 Map,一起清空。

本 PR 把「投递」的语义从「入内存队列」改成「先写父转录、再入队」,使结果在投递点即持久化:异常中断后重建上下文能读到,不中断时也不影响内存中已有上下文。

主要改动

  • subagent/registry.ts:drainRun 组装带 uuid 的 subagent_result 事件,先 session.appendEvent(parentTranscriptPath) 再 mailbox.offer(...)。落盘失败仍投递(Effect.ignore),退化为旧行为。
  • subagent/registry.ts:新增 parentTranscriptOf()。parentSessionId 本身不携带位置信息(同一 id 可能是顶层 sessions/<id>.jsonl,也可能是 sessions/<root>/subagents/<id>.jsonl),且全仓没有 id→路径反查,所以路径必须在 spawn 时定好跟着 run 走。SpawnOptions.parentChain 改为必填,链首位即父,其余是父的祖先。
  • agent/agent.ts:drain 不再经 session 落盘,直接 context.absorb(sessionRef, items),保证「落盘一次、吸收一次」严格 1:1。
  • session/session.ts / port.ts:删除 recordSubagentResult / recordSubagentMessage,它们只在父回合 drain 点写盘,正是要拆掉的路径;已无调用者。
  • context/context.ts:absorb 按事件 uuid 判重。反例是真实存在的:结果在 turn 5 落盘、父回合未 drain 就结束,turn 6 重建 buffer 时已从盘上读到它,step 1 再 drain 到陈旧条目就会重复注入。
  • tools/domains/subagent/message.ts:send_message 走同一契约(来信是同一个洞)。收件人路径按祖先/直接子代两种情况解析,且只追加到已存在的转录,避免为已删除的祖先建幽灵文件。
  • docs/subagent.md:更新结果回注一节,写明顺序不可颠倒的理由与跨回合去重契约。

预期收益

  • 子代理结果不再依赖「父回合是否及时 drain」和「进程是否存活」,只依赖父转录是否落盘。
  • Mailbox 的定位收窄为「本回合唤醒通道」,其易失性不再等于事实丢失。

测试

pnpm typecheck、pnpm lint、全量 1026 个用例通过。新增/调整:

  • registry.test.ts:断言投递点就写盘、盘上那条与队列里那条是同一个对象、嵌套父的路径落在 root-1/subagents/parent-1.jsonl。
  • memory-buffer.test.ts:跨回合 drain 到已读入的事件不重复注入。
  • lifecycle-tools.test.ts:send_message 给不在线的子会话投递后,事件已在收件人转录末行。
  • 删除 subagent-lifecycle-meta.test.ts 中 recordSubagentMessage 的用例(锁的是被删函数返回字面量,属元测试)。

已知遗留(未在本次处理)

  • subagent_result 仍无 turnId,因此不会被 rollback 隐藏、不进压缩区间。落盘更早会让这个既有语义问题更容易被撞上,但改变它属于回滚语义变更。
  • appendLine 不是 fsync。窗口从「整段」缩到「一行」,掉电仍可能丢最后一帧。

- drainRun 先 appendEvent 写父转录再 mailbox.offer:盘为事实源,队列只负责本回合唤醒
- SpawnOptions.parentChain 改为必填,并按链首位推出父会话自己的转录位置(parentTranscriptOf)
- 删除 recordSubagentResult / recordSubagentMessage,父回合 drain 只做 context.absorb,避免双写
- subagent_result / subagent_message 增加 uuid,absorb 按 uuid 判重,防跨回合重复注入
- send_message 走同一契约:先写收件人转录再投递,且只追加到已存在的转录
祖先不再凭链上身份免校验:没有转录就收不下可持久化的消息,一律 INVALID_INPUT。
用例改为真实建盘后再投递,同时锁住祖先路径推导(子代理平级挂顶层会话的 subagents/)。
parentTranscriptOf 注释与文档原文都写成「嵌套父」,易被读成目录逐层嵌套;
实际 owner 取链末位,子代理一律与父平级挂在顶层会话的 subagents/ 下。
@phantom5099
phantom5099 merged commit e2b05fd into main Nov 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant