青雲的博客
拆开 Codex 第六部:Agent runtime 怎样被承载与验证 第 32 章

一个子 Agent 为什么需要独立 Thread

从 MultiAgentV2 的 spawn_agent 参数、AgentPath 与 SessionSource,追到独立 CodexThread、parent_thread_id/forked_from_id 双谱系、容量注册和 best-effort AgentGraphStore 持久边,说明一个子 Agent 的身份为什么不能只用名字或进程解释。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 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_idforked_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 的工具参数至少有 messagetask_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 的默认值是 allnone 选择普通 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_agentAgentControlThreadSpawn 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_sourcespawn_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_materializedflush_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_idparent_thread_id,并把它们写入 SessionConfiguredSessionMeta。对普通 New child,通常只有 parent_thread_id;对 FullHistory fork,两个字段都可以指向同一个父 Thread,但语义仍不同:

字段记录的问题典型用途
parent_thread_id谁在 runtime 拓扑上 spawn 了这个 child?live tree、子 Agent 关系、Graph edge
forked_from_idchild 的初始 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 也只是 DisabledV1V2 的协议字段。它决定某些 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 只有 OpenClosed;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_agentsend_messagefollowup_taskwait_agentinterrupt_agentlist_agents,其中没有 close_agent。把“进程/loop 已停”写成“graph edge 已关闭”,或把旧版关闭工具外推到 V2,都会误导后续 resume。

本节源码依据(5 处)

一张身份表比一句“子 Agent”更准确

nodeAgentPathThreadIdsourcehistory modepersisted edge
root/rootProot sourceown historynone
fresh child/root/researchCThreadSpawn(parent=P)NewOpen(upsert 成功时)
fork child/root/forkFThreadSpawn(parent=P)FullHistoryOpen(upsert 成功时)
ephemeral child/root/tmpElive ThreadSpawnNew/live onlynone(按配置跳过写入)

表里 CFE 都是独立 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_idforked_from_id 的分工。