OpenAI Codex · Multi-agent graph

A Multi-agent Graph Must Be Persisted

What you dispatch is a node; the edge is born Open. Mail enqueues first; followup is what wakes it. What you close is the edge; history stays.

Course goalAfter reading, you can name three things: a sub-agent is a node on the graph, and an edge is only Open or Closed; send_message only enqueues, followup_task is what wakes; shutdown unloads the runtime, closing the edge is what drops it from the graph. Whether you can keep talking tomorrow — look at the edge first.
Try it first · Spawn an explorer
Dispatch Hypatia from /root; watch how mail travels and how a result flows back
Wrap-up
Shutdown only unloads the runtime; the edge stays Open. Close-edge is what writes Closed. Flip it and play again — the post-restart roster is different.
Root session /root Idle; can dispatch a child
Not dispatched yet Waiting to spawn This node is not on the graph yet
SQLite edge card
No thread_spawn_edges row yet.
Mailbox and registry
MESSAGE FOLLOWUP RESULT
The registry is empty. Restore only walks Open edges and sticks identities back.
Logic trail · which source span each animation step maps to
  1. Compute the next depth, write ThreadSpawnregistry.rs L87
  2. Build the absolute path /root/explore_auth from task_namemulti_agents_common.rs L117
  3. Draw the nickname Hypatia from the scientist listcontrol/spawn.rs L32
  4. A non-ephemeral session upserts an Open edge at oncecontrol.rs L776
  5. send_message packs QueueOnly and only enqueuesmessage_tool.rs L103
  6. The mailbox is a session-level queue; enqueue emits a Mailbox activityinput_queue.rs L127
  7. followup_task sets trigger_turn; that is the wakehandlers.rs L98
  8. The child turn ends, sends Result to the parent, does not wakesession/mod.rs L1977
  9. Shutdown does not change the edge; close-edge only marks the target’s own inbound edge Closedlegacy.rs L6
  10. Restart only loads Open descendants back into the registrycontrol/spawn.rs L158
Hit Play to see an explorer grow onto the graph, how mail travels, and how a result flows back.
The graph precedes the runtimeA node is born with a path, a nickname, and an Open edge. The runtime can be unloaded; the edge stays, and history stays in the rollout.
Mail and wake are two thingsMESSAGE only enqueues. FOLLOWUP is what starts work. RESULT flies back to the parent mailbox and, by default, does not steal the current turn.
The wrap-up decides whether tomorrow still has itFlip the wrap-up above and play again — see whether the registry still knows this child after restart.
Teaching sketch: nickname is fixed as Hypatia, path as /root/explore_auth, to show the graph, mailbox, and edge state. Line numbers on the logic trail match openai/codex commit 4f39251a01.
Idea 1 · Make parent–child a stateful edge
What problem it solves

You ask the root session to dispatch an explorer into the auth module. The model calls spawn_agent; the tool returns a task name /root/explore_auth and the nickname Hypatia. A few minutes later you hit wait_agent, and a FINAL_ANSWER is lying in the mailbox.

The next day you open the same thread. The child session’s runtime is already unloaded. If the system only remembers that a call happened yesterday, this path is gone. Send send_message again and the control plane reports live agent path not found.

What the idea is

Codex makes parent–child a directed edge. An edge has two values. Open means it can still be restored as an open spawned agent. Closed means, from the graph’s view, it is already shut. Serialization is open and closed.

Source:codex-rs/agent-graph-store/src/types.rs lines 4–12

The graph lives in SQLite’s thread_spawn_edges table. child_thread_id is the primary key; one child cannot hang on two parents. Spawn the same child again and both parent and status are overwritten. Session body does not enter this table. A sub-agent’s model context still walks its own rollout. The graph only answers who spawned whom, and whether this edge is open or closed now.

Source:codex-rs/state/migrations/0021_thread_spawn_edges.sql lines 1–8

A non-ephemeral session upserts an Open edge the moment the thread is built. A write failure only warns. The child thread is already running; the graph can catch up later. The backfill uses ON CONFLICT DO NOTHING, and will not flip a Closed edge back.

Source:codex-rs/core/src/agent/control.rs lines 767–780

When listing descendants, the filter applies to every edge you walk. Some(Open) only follows Open. A subtree whose parent edge is already Closed will not be listed, even if a grandchild edge is still Open.

Source:codex-rs/agent-graph-store/src/store.rs lines 49–54

The path is the addressing key; the nickname is for humans. The root is /root. A relative name is appended to the current path. .. and . are rejected, so you cannot crawl to a sibling on a relative path. A role can shrink capability; it cannot replace the parent session’s authority. Built-in live roles are default, explorer, worker. explorer.toml is an empty file.

Source:codex-rs/core/src/agent/role.rs lines 1–4

spawn_agent New child thread Path + nickname upsert an Open edge SQLite child rollout JSONL Who spawned whom; open or closed Session body does not enter the edge table
After one spawn: identity enters the registry, the edge enters SQLite, the body enters the rollout.
Why it lasts

After a restart you must be able to ask: does this child still count as alive? Remembering one spawn event cannot answer that. Only Open enters the restore list. Closed disappears from subtree walks. child_id as primary key keeps the graph a tree; walks can BFS by depth. Rewrite it in another language and the minimum shape is still a three-column table: parent, child, status.

Idea 2 · Keep communication and wake apart
What problem it solves

The sub-agent finishes and must send a FINAL_ANSWER home. If that letter wakes by itself, a parent mid-sentence on a user-visible final answer is forced into a new turn. The user sees a half sentence, then a completion notice shoved in.

What the idea is

There are four communication tags: Spawn, Message, Followup, Result. Those are OTEL labels. On the protocol side there is one InterAgentCommunication; trigger_turn decides whether to wake.

send_message is QueueOnly — enqueue only. followup_task is TriggerTurn — that is the wake. Empty messages are rejected outright. followup_task cannot hit the root node.

Source:codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs lines 11–24

The handler enqueues first, then decides whether to start work. When trigger_turn is false, the letter stays put. Only if it is true, or the session still has an unfinished durable sleep, does work start. V2’s completion notice is Result, and trigger_turn is false. If the parent is talking, the letter queues in mailbox phase.

Source:codex-rs/core/src/session/handlers.rs lines 89–99

The mailbox is a session-level queue. User barge-in goes to pending_input; child mail goes to mailbox_pending_mails — two slots. Siblings can talk if they use an absolute path such as /root/worker_b. The relative name worker_b is appended to self and becomes your own child.

Source:codex-rs/core/src/session/input_queue.rs lines 76–80

send_message QueueOnly followup_task TriggerTurn Child turn ends Result Parent or child mailbox Enqueue first, then look at trigger_turn Lie there; wait for the next turn MESSAGE / RESULT Start work, maybe_start_turn Only FOLLOWUP walks here
All three letters enter the mailbox. Only followup sets trigger_turn; a completion notice does not steal the current turn.
Why it lasts

The right to wake is scarce. Whoever can open a turn must not open one casually. Split delivery from start-of-work: a completion notice does not wake by default; wake is reserved for followup_task and the user. Another message bus can use the same boolean: wake, or enqueue only.

Idea 3 · Closing an edge and shutting down are two things
What problem it solves

When V2’s resident slots fill, it unloads a child by LRU. If unloading the runtime also marks the edge Closed, that child vanishes from the Open subtree. The next restore cannot find it. The user never closed it — the system struck the name itself.

What the idea is

shutdown_live_agent shuts a live agent, flushes the rollout, sends Shutdown, and plucks the thread from the manager. The edge is still Open. The next restore still treats it as a live subtree member.

Source:codex-rs/core/src/agent/control/legacy.rs lines 6–8

close_agent first marks the target’s own inbound edge Closed, then shuts down. Descendant edges are not marked Closed here. A parent turn that ends normally walks TurnComplete and does not call close_agent. The child keeps running; the edge stays Open.

Source:codex-rs/core/src/agent/control/legacy.rs lines 48–58

V2 restore is two steps. First, Open descendants’ identities go back into the registry — the runtime is not reopened. Only when someone actually send_messages or followup_tasks is the thread hung back from the rollout.

Source:codex-rs/core/src/agent/control/spawn.rs lines 144–162

The edge is Open Shutdown Close edge Edge still Open; runtime unloaded The edge becomes Closed On restore, identity returns to the registry On restore, this edge is filtered out
Unloading the runtime is not dropping the name from the graph. Only close changes status.
What you close is the edge; history stays.
Why it lasts

A live runtime and a name dropped from the graph are two independent facts. Resident LRU, a process restart, a user closing a window — any of them can unload a runtime. Only an orchestrator’s explicit close writes Closed. Restore walks Open edges. This split does not depend on Rust.

Side-by-side · What should a sub-agent be abstracted as

DSH: seam first; the graph is an enumeration result

DSH makes a sub-agent a swappable provider seam. SubagentProvider carries name, capabilities, inheritsParentContext, start. In-process fork, Claude Code, Codex, ACP — different impls on one interface.

It can also list children and descendants, a read-only enumeration from the live session store and optional persistence. There is no Open / Closed edge table like thread_spawn_edges. Topology is the session header’s origin: subagent plus what you fold out after the fact. Swapping impls is cheap; restore-by-edge has to be built separately.

Verified against source · 2026-08-22 · packages/subagent/subagent/src/types.ts lines 285–295 · DSH · Subagent is a seam

Claude Code: a tool call plus a transcript side chain

The tool the model sees is now named Agent. The old line is still Task, kept for permission rules, hooks, and sessions mid-restore. Explore / Plan are one-shot; the parent will not continue them.

The runtime hands each child an agentId. There is no separate spawn-edge table. Restore reads the transcript for that id. A parent listing live children has to scan the side chain; there is no filter-by-edge. Codex pays an extra table and two statuses so it can still talk by graph after a restart.

Verified against source · 2026-08-22 · restored-src/src/tools/AgentTool/constants.ts lines 1–4
Classroom Exercise
01

Is an Open grandchild still there under a Closed parent edge

Draw a three-level tree: root→A is Closed, A→B is Open. List descendants once with Some(Open) and once with None. Does B appear?

The filter applies to every edge you walk. Then read the comment at codex-rs/agent-graph-store/src/store.rs lines 49–54 and write down both results.

Takeaway:A sub-agent is a node on the graph. An edge is only Open or Closed; session body walks the rollout. send only enqueues; followup is what wakes; a completion notice does not steal the current turn. Shutdown unloads the runtime; closing the edge is what drops the name from the graph. Tomorrow, look at the edge first, then decide whether to hang the runtime back.