一个子 Agent 为什么需要独立 Thread
从 MultiAgentV2 的 spawn_agent 参数、AgentPath 与 SessionSource,追到独立 CodexThread、parent_thread_id/forked_from_id 双谱系、容量注册和 best-effort AgentGraphStore 持久边,说明一个子 Agent 的身份为什么不能只用名字或进程解释。
第 31 章已经证明 TUI 必须按 ThreadId 保存独立的界面状态。下一步容易出现一个反向误解:既然界面有多个 Thread,子 Agent 是否只是父 Agent 的一个视图,或者每个 Agent 都对应一个操作系统进程?固定版本给出的答案都是否定的。
一个 spawned Agent 的独立性来自一组可追踪的 runtime 对象:新的 SessionSource::SubAgent(SubAgentSource::ThreadSpawn)、新的 canonical AgentPath、新的 ThreadId、新的 Codex/CodexThread,以及一条 best-effort 持久 spawn edge。parent_thread_id 与 forked_from_id 还必须分开读:前者回答“谁 spawn 了它”,后者回答“它从哪份 history 复制而来”。edge 写入失败只会告警,不会撤销已经创建的 live child。
flowchart TB
accTitle: 子 Agent 从工具调用到独立 Thread 的身份链
accDescr: spawn_agent 解析 task_name 与 fork_turns,生成 ThreadSpawn source 和 AgentPath;AgentControl 先做容量、nickname 与 path reservation,再选择 New 或 Forked history,ThreadManager 调用 Codex::spawn 创建新的 CodexThread/ThreadId;非 ephemeral child 随后 best effort 地把 parent-child edge 写入 AgentGraphStore,失败只告警,不撤销 live child。
CALL["spawn_agent tool"] --> ARGS["SpawnAgentArgs"]
ARGS --> SOURCE["ThreadSpawn source"]
SOURCE --> PATH["AgentPath /root/task"]
SOURCE --> CONTROL["AgentControl"]
CONTROL --> RESERVE["capacity + path reservation"]
RESERVE --> NEW["InitialHistory::New"]
RESERVE --> FORK["InitialHistory::Forked"]
NEW --> MANAGER["ThreadManager"]
FORK --> MANAGER
MANAGER --> CHILD["new Codex + CodexThread + ThreadId"]
CHILD --> REGISTRY["live AgentRegistry"]
CHILD --> EDGE["best-effort AgentGraphStore Open edge"]
spawn_agent 先决定身份和 history 模式
task_name 是路径片段,不是 Thread identity
MultiAgentV2 的工具参数至少有 message、task_name、可选 role/model/reasoning/service tier,以及 fork_turns。handler 先解析 arguments、计算 child depth、继承 parent instructions,再调用 thread_spawn_source。它把 parent source 的 AgentPath 与 task_name join 成 child path,并把 parent ThreadId 放进 ThreadSpawn source。
task_name 经过 AgentPath 校验,只能是小写 ASCII 字母、数字和下划线,不能是 root、.、.. 或带 / 的路径。它用于树上的稳定寻址和 UI label;真正的运行时 identity 仍是之后由 Core 生成的 ThreadId。
本节源码依据(3 处)
fork_turns 选择复制多少 history
fork_turns 的默认值是 all;none 选择普通 New child;all 选择 FullHistory;正整数选择 LastNTurns(n)。fork_context 在 MultiAgentV2 中被明确拒绝,未知或零值也会变成 model-facing error。
这不是第 27 章的 app-server thread/fork 换了一个参数名。两者都会形成 forked history,但入口和 owner 不同:thread/fork 由 app-server lifecycle handler 创建普通新 Thread;这里由 spawn_agent、AgentControl 和 ThreadSpawn source 建立 child,并额外拥有 AgentPath、agent registry 状态,以及一次 best-effort parent-edge 写入。
因此“子 Agent 看见父上下文”不是一个布尔属性。New child 没有父 rollout history;FullHistory child 复制一份经过过滤的父 history;LastNTurns child 只保留指定窗口。FullHistory 还禁止 model/reasoning overrides,因为这些配置会改变复制上下文的解释方式。
本节源码依据(1 处)
AgentControl 先占资源,再创建 child
AgentControl::spawn_agent_internal 先根据 source、history mode、parent 和 config 决定有效 MultiAgentVersion,检查执行容量,预留 residency slot、线程计数、AgentPath 与 nickname。reservation 只有在 child 成功创建后才 commit;中途失败会释放 path 和计数。
这一步把两个常见失败分开:容量不足是 session 级资源问题,路径冲突是拓扑寻址问题。它们都发生在 Codex::spawn 之前,因此不能等到 Thread 已经运行后再补救。
本节源码依据(3 处)
New child 与 fork child 都有自己的 CodexThread
ThreadManager 不把 child 挂成 parent 的 loop
AgentControl 根据 fork mode 选择 spawn_new_thread_with_source 或 spawn_forked_thread,但两条路径最终都进入 ThreadManager。spawn_thread_with_source 调用 Codex::spawn,传入 child 的 session_source、parent/fork lineage、history、extensions、inherited environment 和 execution policy;随后 finalize_thread_spawn 消费第一条 SessionConfigured event,构造并登记新的 CodexThread。
如果相同 ThreadId 已经运行,resume path 可以返回现有对象;对 New/Forked child,ThreadManager 会拒绝重复 identity。这里没有“同一 loop 加一个 agent handle”的隐式共享:每个成功 child 都有独立 Codex、事件队列和 ThreadManager registry entry。
本节源码依据(2 处)
fork 前必须先 materialize parent rollout
FullHistory 或 LastNTurns fork 读取的是持久化 rollout 的 history,不是 parent 内存里“看起来已经写完”的 event list。spawn_forked_thread 在 snapshot 前调用 ensure_rollout_materialized 和 flush_rollout,再读取 ReadThreadParams { include_history: true }。随后它筛选 history item、截断 turn window、移除 MultiAgentV2 usage hint,并以 InitialHistory::Forked 创建 child。
这条 flush 是一个实际的 ordering contract。若只依赖异步 record_conversation_items,fork child 可能拿到缺少 parent 最新 turn 的旧 history;测试因此专门验证 flush-before-load,而不是只验证 child 最终存在。
本节源码依据(2 处)
两个 lineage 字段回答两个问题
Session 初始化时分别解析 forked_from_thread_id 与 parent_thread_id,并把它们写入 SessionConfigured 与 SessionMeta。对普通 New child,通常只有 parent_thread_id;对 FullHistory fork,两个字段都可以指向同一个父 Thread,但语义仍不同:
| 字段 | 记录的问题 | 典型用途 |
|---|---|---|
parent_thread_id | 谁在 runtime 拓扑上 spawn 了这个 child? | live tree、子 Agent 关系、Graph edge |
forked_from_id | child 的初始 history 从哪个 Thread 复制? | history lineage、审计与 fork 解释 |
thread_id | 当前独立执行实体是谁? | event routing、ThreadManager lookup、TUI store |
agent_path | 人类和工具怎样寻址这棵 Agent 树? | /root/research/parser 等稳定路径 |
把前两个字段压成一个 parent 会丢掉 fork 的来源;把 AgentPath 当作 ThreadId 则无法处理重启、resume 或同一路径的新 identity。
本节源码依据(3 处)
source 与 path 是可序列化身份,不是执行器
SubAgentSource::ThreadSpawn 保存 parent id、depth、optional AgentPath、nickname 和 role;SessionSource::parent_thread_id 只从这一层 source 读取 parent relation。ThreadSource::Subagent 是更窄的 analytics classification,不能替代完整 SessionSource。
MultiAgentVersion 也只是 Disabled、V1、V2 的协议字段。它决定某些 residency、watcher 和 capacity policy,却不把 V2 变成一个新的 Thread kind。所有版本的独立执行实体仍由 ThreadId/CodexThread 表示。
本节源码依据(2 处)
live registry 与持久 graph 是两层状态
AgentRegistry 保护当前 session 的资源
AgentRegistry 是 session-shared 的 in-memory registry,维护 active agent tree、nickname、spawn count 和 configured max depth/capacity。它用于判断一个 path 是否已被占用、一个 spawn 是否还能 reservation,以及 child 创建失败时如何 rollback。它不是跨进程数据库,也不负责重放 rollout。
AgentControl 还可以从 registry/live ThreadManager 合并出当前 open children,并按 AgentPath 与 ThreadId 排序。这个 live view 在 child 尚未持久化、或 state DB 不可用时仍有意义。
本节源码依据(3 处)
durable edge 只表示 spawn topology
AgentGraphStore 是 storage-neutral 的 parent/child edge API。edge 一旦写入,每条 child 只有一个 persisted parent,status 只有 Open 与 Closed;store 可以列 direct children,也可以按 status 做 breadth-first descendants。SQLite 的 local implementation 把它映射到 thread_spawn_edges(parent_thread_id, child_thread_id, status),child id 是 primary key。
AgentControl 在 source 有 parent relation 且 child 非 ephemeral 时尝试写入 Open edge。这个 upsert 是 best effort:失败只记一条 warn!,spawn 继续,已经创建的 child 仍保留 live ThreadId、AgentPath、registry metadata 和通信能力。config.ephemeral 为真时则直接跳过 persistence。于是“当前能看到 child”与“重启后 graph 里能找到 child”是两个独立事实;即使 child 不是 ephemeral,edge 写入失败后,重启时的 descendant 查询也可能找不到它。
本节源码依据(4 处)
source-derived insertion 让 resume 不依赖 live registry
State runtime 在写入 thread metadata 时,还能从 serialized source 解析 ThreadSpawn parent,并在 edge 缺失时插入 Open edge。这给缺失 edge 留下了一次后续补写机会,但不是 spawn 时持久化成功的保证;只有 metadata 写入和插入都成功,graph 才能用于重启后的 descendant 查询。它恢复的也只是 topology,不会恢复 in-memory callback、channel receiver 或 Agent loop。
本节源码依据(2 处)
resume 与 shutdown 的边界
resume_agent_from_rollout 先恢复请求指定的 Thread,然后再决定是否恢复它的 descendants。这个门禁同时读取两份版本事实:当前配置的 multi_agent_version_from_features(),以及恢复历史后算出的 resumed_multi_agent_version。任意一份是 V2,函数都会提前返回,只保留刚恢复的这一条 Thread,不继续扫描 graph store 的 Open descendants。后面的 breadth-first search(BFS,广度优先搜索)只属于非 V2 路径:它沿 Open edge 找 child,用 parent/depth 重建 ThreadSpawn source,再逐个从 rollout 恢复;Closed edge 下的 descendants 不会进入这条旧版恢复队列。
恢复单条 child 时还有一道相同的版本分界。resume_single_agent_from_rollout 会重新登记 live Thread,但只有非 V2 才安装 detached completion watcher。V2 的完成通知由 child session 的 terminal-event 路径负责,第 33 章再追这条链。
普通 shutdown_live_agent 会停止 live agent、等待终止并从 in-memory state 释放资源,但注释明确说它不会把 persisted edge 标成 Closed。legacy / V1 的 close_agent 才会先写 Closed,再沿 live tree shutdown;V2 的 model-facing surface 暴露 spawn_agent、send_message、followup_task、wait_agent、interrupt_agent 与 list_agents,其中没有 close_agent。把“进程/loop 已停”写成“graph edge 已关闭”,或把旧版关闭工具外推到 V2,都会误导后续 resume。
本节源码依据(5 处)
一张身份表比一句“子 Agent”更准确
| node | AgentPath | ThreadId | source | history mode | persisted edge |
|---|---|---|---|---|---|
| root | /root | P | root source | own history | none |
| fresh child | /root/research | C | ThreadSpawn(parent=P) | New | Open(upsert 成功时) |
| fork child | /root/fork | F | ThreadSpawn(parent=P) | FullHistory | Open(upsert 成功时) |
| ephemeral child | /root/tmp | E | live ThreadSpawn | New/live only | none(按配置跳过写入) |
表里 C、F、E 都是独立 ThreadId;只有 fork child 的 history 来源额外指向 P。/root/research 与 /root/fork 可以在同一 parent 下并行运行,TUI、app-server listener 和 ThreadManager registry 都据 ThreadId 隔离。graph store 只有在 edge upsert 成功后才拥有这层关系;写入失败不影响本次 live 通信,却会削弱重启后的拓扑发现。
实验:验证创建、fork flush 与 ephemeral edge 的事实
固定源码上的定向测试:
: "${ARCHIVE_CODEX_RS:?先执行第六部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core spawn_agent_creates_thread_and_sends_prompt
just test --locked -p codex-core spawn_agent_fork_flushes_parent_rollout_before_loading_history
just test --locked -p codex-core ephemeral_spawn_does_not_persist_agent_graph_edge
三条都通过;第一条验证新 child Thread 与初始 prompt,第二条验证 fork 前 flush,第三条验证 ephemeral 按配置跳过 graph edge。第三条不能反向证明 non-ephemeral edge 必然写入成功,spawn 路径仍把 upsert failure 降级成 warning。后两条用裸 cargo test 的默认线程栈曾出现 stack overflow,改走项目 just test recipe 后由 recipe 设置 RUST_MIN_STACK=8388608 并通过;真正的校准点是测试入口与其 8 MiB 栈,不是命令前再写一个会被 recipe 同值覆盖的环境变量。这属于 harness 条件,不能隐瞒成产品语义。
测试也没有证明 shutdown、mailbox、followup、steer、wait 或 interrupt 的顺序。shutdown 及其 live subtree 结果、本章之外的通信动作,都集中在“多个 Agent 怎样通信、等待与中断”的第 33 章。
本节源码依据(2 处)
交给第 33 章的边界
本章已经证明:一个子 Agent 需要独立 Thread,是因为它有独立 Core runtime、ThreadId、事件来源和资源 reservation;持久 graph edge 是后续的 best-effort 拓扑记录,不是 live child 成立的前提。它的独立性不来自 UI 画出的新卡片,也不来自一个新的 OS process。
第 33 章:多个 Agent 怎样通信、等待与中断再处理 live child 之间怎样通过 mailbox/steer/send/followup 通信,怎样 wait、timeout、interrupt,以及父 Agent 如何判断一个 subtree 可以结束。那些动作会消费本章建立的 Thread identity 与 parent topology,但不会改变 parent_thread_id 和 forked_from_id 的分工。