# Codex App Server 原生协议集成设计

Date: 2026-07-16
Status: current
Scope: `desktop/internal/agent/driver_codex*.go` 与 `AgentProcess` 对 Codex app-server 原生 JSON-RPC thread/turn 生命周期、消息 ownership、恢复和 transport 故障语义
Related canonical specs: [`specs/daemon/daemon.md`](../specs/daemon/daemon.md), [`specs/message/delivery-runtime.md`](../specs/message/delivery-runtime.md), [`specs/daemon/codex-instructions-prompt-split.md`](../specs/daemon/codex-instructions-prompt-split.md), [`docs/tech_design.md`](./tech_design.md)
Non-goals: 改写 AgentTeam 的 daemon-facing contract；把 stdio transport 立即切成 WebSocket；改变 rewind 的产品语义
Implementation note: Codex uses the app-server JSON-RPC protocol with handshake hardening, thread start/resume, state-aware `turn/start` / `turn/steer`, correlated `clientUserMessageId` ownership and reconciliation, deprecated-compatible rewind recovery, runtime-native compaction observability, terminal-overflow emergency compaction, explicit `turn/started` / terminal lifecycle fences, structured streaming notifications, prompt split, restart-safe session persistence, and permission-mode-dependent sandbox configuration. `full_access` maps to `danger-full-access` / `dangerFullAccess`; `agent_workspace_write` maps to `workspace-write` / `workspaceWrite` with the agent workdir as the only writable root. Mutating request outcomes that become ambiguous after dispatch are never blindly retried; the daemon restores/reconciles durable ownership before replaying an exact body.

Current delivery contract: AgentTeam resolves each delivery from the authoritative app-server state: regular active turn with a known id uses `turn/steer`; idle/system-error state uses `turn/start`; `notLoaded` is resumed once and re-evaluated; incomplete/unknown snapshots get one bounded `thread/read(includeTurns=true)` reconciliation. Both start and steer carry the daemon delivery id as `clientUserMessageId`. Transport success only means the input may be runtime-owned, not that the model saw it; the runtime-visible freshness surface is the later `userMessage.clientId` echo. Durable daemon ownership phases (`queued` / `pending` / `uncertain` / `surfaced` / `failed`) survive Process and daemon restart. A daemon-local continuation inbox is retained only for automatic ordered fallback delivery and never becomes an agent-facing pull tool. Server-side agent `/receive` is not part of the current contract.

本文是 supporting design，不替代 canonical product/spec 文档。产品与 daemon 边界仍以 `specs/**/*.md` 为准。当前公开 runtime ID 是 `codex`；本文中的 `codex-app-server` 是历史设计阶段名称、内部 app-server 协议路径或 `driver_codex.go` 文件名语境，不表示独立的公开 runtime ID。

## 1. 背景

Codex app-server 是一个长驻的 JSON-RPC 服务，不是“一次请求、一次退出”的 CLI。本文关注的是把 `codex` runtime 的 app-server 实现从早期“启动进程、写一行请求、按行读 stdout、自己猜运行状态”的半包装状态，收敛到原生 JSON-RPC thread/turn 协议。

当前实现入口与关键代码位于：

- `desktop/internal/agent/driver_codex.go`
- `desktop/internal/agent/process.go`

设计立项时，代码已经有一层 request/response 关联和基础通知翻译，但协议使用仍然不完整：

- 只发 `initialize`，没有补 `initialized`
- `thread/start` / `thread/resume` 还在发 schema 中不存在的 `persistExtendedHistory`
- 所有 stdin delivery 都编码成 `turn/start`
- transport 读取仍使用 `bufio.Scanner`，单行上限 1 MiB
- recovery 直接 `thread/resume` + `thread/rollback`，没有优先走轻量状态查询，也没有对 active turn 做显式中断
- runtime 状态仍靠 AgentTeam 本地路径和零散通知共同推断，没有形成一套明确的协议派生状态机

## 2. 已验证的协议面

本节基于本机 `codex-cli 0.144.5` 的 schema、OpenAI Codex `main@cbc83d961e8132bfff4d340ab8342d181b79e95e` 的 app-server/TUI 源码，而不是基于猜测或旧笔记。验证命令：

```bash
codex --version
codex app-server generate-json-schema --out <tmpdir>
```

### 2.1 transport 与握手

app-server 支持 `stdio://` 和 `ws://` listen endpoint。AgentTeam 当前只需要 stdio，但 transport 设计不应把自己锁死在“CLI 文本流”心智模型里。

握手必须是两步：

1. `initialize` request
2. `initialized` notification

`initialized` 是客户端 notification，不是可选优化。

### 2.2 本设计依赖的 request 方法

最小必要方法集如下：

- `thread/start`
- `thread/resume`
- `thread/read`
- `thread/rollback`（当前仅作为 deprecated compatibility path；见 4.8）
- `thread/fork`（`thread/rollback` 的长期替代协议，但会产生新 thread id，不能在旧 recovery contract 中原地替换）
- `thread/compact/start`
- `turn/start`
- `turn/steer`（app-server schema capability；AgentTeam uses it for ack-aware active-turn message-body delivery with `clientUserMessageId`）
- `turn/interrupt`

关键 shape 约束，以当前 schema 为准：

- `thread/start` 发送 AgentTeam 当前需要的 thread-level 上下文字段：`cwd`、`sandbox`、`approvalPolicy`、`model`（可选）、`baseInstructions`（可选）、`developerInstructions`（可选）
- `thread/resume` 需要 `threadId`；AgentTeam 显式发送 `sandbox` / `approvalPolicy`，`cwd`、`model`、`baseInstructions`、`developerInstructions` 可选
- `thread/read` 需要 `threadId`，`includeTurns` 默认为 `false`
- `turn/start` 需要 `threadId` + `input`；AgentTeam 显式发送 `sandboxPolicy`
- `turn/steer` 需要 `threadId` + `expectedTurnId` + `input`
- `turn/interrupt` 需要 `threadId` + `turnId`

补充约束：

- AgentTeam 现在把长期必须 pin 住的 prompt 内容放进 `baseInstructions` / `developerInstructions`
- 只适合被 turn history 摘要化的参考手册和每次不同的 wake/user input，通过 visible turn input 或刷新工具 surface 给 runtime
- `thread/resume` 虽然 schema 接受 instruction 字段，但不应被当作可靠的 hot-reload 机制；是否继续复用旧 thread，应由 daemon 自己基于 persisted instruction fingerprint 决定。真正 resume 时必须重放同一 launch policy snapshot；空的 optional instruction 不发送，避免把“无 override”变成 `Some("")`
- `sandbox` / `sandboxPolicy` 是当前显式 sandbox override；不要再与 `permissionProfile` 混用
- 当前 sandbox 映射由 agent `permissionMode` 决定：`full_access` 使用 thread `sandbox: "danger-full-access"`、turn `sandboxPolicy: { type: "dangerFullAccess" }`，并允许 Codex request-permission approval；`agent_workspace_write` 使用 thread `sandbox: "workspace-write"`、turn `sandboxPolicy: { type: "workspaceWrite", writableRoots: [workDir] }`，并关闭 request-permission approval。

这两个字段很重要：

- `turn/steer.expectedTurnId`
- `turn/interrupt.turnId`

因此 daemon 不能只知道“线程是否 active”，还必须尽量维护当前 active turn 的 ID。

### 2.3 本设计依赖的 notification 方法

与 AgentTeam 直接相关的通知至少包括：

- `error`
- `thread/started`
- `thread/status/changed`
- `thread/tokenUsage/updated`
- `turn/started`
- `turn/completed`
- `item/started`
- `item/completed`
- `item/agentMessage/delta`
- `item/reasoning/textDelta`
- `PlanDeltaNotification`（schema 独立建模的 plan 流式增量；thinking 流不能只看 reasoning）
- `item/commandExecution/outputDelta`
- `item/fileChange/outputDelta`

兼容性要求：

- 未识别 method 只记日志，不中断连接
- `thread/compacted` 已被 schema 标为 deprecated；新实现不应把它当作唯一 compaction 信号

### 2.4 thread / turn 状态事实

当前 schema 中，`Thread` 自带 `status`，`Turn` 自带 `status`：

- `thread.status` 可区分 `idle`、`active`、`notLoaded`、`systemError`；其中 `active` 变体还带 `activeFlags[]`，当前值包括 `waitingOnApproval`、`waitingOnUserInput`
- `turn.status` 可区分 `inProgress`、`completed`、`interrupted`、`failed`
- `thread/read(includeTurns=true)`、`thread/resume`、`thread/fork` 与 legacy `thread/rollback` 都能返回 `thread.turns`

这意味着：

- “线程有没有 active turn”不该只靠本地布尔值
- 泛化的 `thread.status=active` 不等于“当前 turn 可以安全吸收新的 AgentTeam message body”；message freshness 只把可见 turn input 或刷新工具结果视为 observed surface
- 当本地丢失 active turn id 时，可以通过 `thread.read(includeTurns=true)` 或 `thread/resume` 重新定位 `status=inProgress` 的 turn

## 3. 设计立项时的主要偏差

以下记录设计立项时的代码差距，用于解释第 4 节约束；它们不是当前待办，当前验收基线见第 5 节。

### 3.1 协议合规

- 缺少 `initialized` notification
- 请求体仍带 `persistExtendedHistory`

这两项都说明实现对的是旧心智模型，而不是当前 schema。

### 3.2 状态模型不完整

立项时 driver 只维护：

- `threadID`
- `completedTurnCount`
- `completedTurnIDs`

但没有显式维护：

- `thread.status`
- `activeTurnID`
- “本地状态是来自通知、来自 resume/read，还是未知”

这会直接阻碍：

- 正确维护 active turn identity，用于 interrupt、recovery 和 direct input 状态纠偏
- 正确使用 `turn/interrupt`
- 在 active turn / idle 切换边界上做可靠恢复

### 3.3 transport 曾是大行脆弱实现

设计立项时，`driver_codex.go` 和 `process.go` 都还在用 `bufio.Scanner`。`thread/resume` / `thread/read(includeTurns=true)` 可以返回多 MiB payload，当前 1 MiB 限制已经不满足生产场景。

### 3.4 live delivery 与 freshness surface

早期实现已经区分 idle `turn/start` 与 active `turn/steer`，并为 steer 使用 `clientUserMessageId` / `userMessage.clientId` surfaced ack；但 pending ownership 仍主要停留在 live Process 内存里，direct start/interrupt 也没有统一 correlation。Transport failure、success response decode failure、Process crash 和 daemon restart 之间缺少一个能回答“正文究竟归谁”的 durable phase，因此简单回落 continuation inbox 仍可能重复 input，等待新 wake 又可能永久搁置 input。新设计必须把 freshness ack、durable ownership、ambiguous reconciliation 和 HOL retry 合成一个状态机。

同一个缺口也存在于 MCP freshness result：把 q1 从 inbox 取出、在格式化前就记成 surfaced，会让后续 historical-surfaced filter 把本次 q1 再过滤掉，最终给模型返回 `No new messages.`；反过来，如果只在格式化时保留 q1，却在最终 response write 之前清掉 owner，encode/short-write/transport replacement 仍会静默丢失。Codex 的 MCP callback 还有两跳：AgentTeam MCP HTTP response 只是 app-server driver 拿到 tool result，真正进入 Codex 的边界是 driver 把完整 JSON-RPC response 写回对应 generation 的 app-server child。实现必须把这两跳建模成 receipt transaction，而不能把第一次 HTTP 200 当作 model-visible。

### 3.5 recovery 路径过重

立项时 `RestoreRecoveryPoint(...)`：

1. `thread/resume`
2. 读取 `len(thread.turns)`
3. `thread/rollback`

问题在于：

- 所有检查都用重 payload 路径
- 不区分“只读状态检查”和“真正需要加载 thread”
- active turn 存在时没有先走 interrupt
- 没有显式处理“active turn id 已知 / 未知”两种情况

## 4. 当前设计

### 4.1 transport：保留 stdio，但按 JSON-RPC client 实现

`codex-app-server` driver 应该被视为一个长期连接的 JSON-RPC client。stdio 只是当前 transport，不是架构抽象本身。

transport 层最少需要提供三件事：

1. 大行安全读取
2. request/response pending table
3. notification 分发

当前实现约束：

- 用 `bufio.Reader.ReadSlice('\n')` 分片读取并显式累加，替代 `bufio.Scanner`；不能调用会在返回前先为整个未终止 token 分配内存的 convenience helper，否则上限检查发生得太晚
- 对累积的完整原始 JSON-RPC 行（包括行尾换行）执行硬 `32 MiB` 上限，而不是按单个 reader fragment、解码后的字段或近似字符数计量；`bufio.ErrBufferFull` 只表示继续读取下一片，不放宽累计上限
- app-server JSON-RPC reader 与 Process translated-event reader 复用同一 framing helper；超限、malformed frame、读取失败或 EOF 都按下述 transport-fatal 规则收口，不能 OOM、静默截断或让两层 parser 漂移
- 所有 JSON-RPC request 都通过统一 `request(...)` 发送
- 客户端 notification 使用统一 `notify(...)` 发送，至少支持 `initialized`

app-server notification 转成 Process event 时还要满足 generation-scoped、lossless output 约束：

- 每个 launch generation 拥有独立 translated-output queue；每一行只按**最终 JSON 编码后的 wire bytes**（包括 JSON escaping 与终止换行）计费，当前最多 128 个 entry，累计 byte budget 与单行 parser 上限同为 `32 MiB`
- producer 遇到 entry/byte budget 饱和必须等待可取消的 backpressure，不能丢 transcript、terminal 或 tool event；`json.Encoder.SetEscapeHTML(false)` 避免 `<`、`>`、`&` 的无意义扩张，但 quotes 等真实 escaping 仍按最终编码计费
- replacement 在等待 generation write fence 前只 abort 旧 queue 的 producer side，唤醒 blocked enqueue 和等待 idle 的 closer；拿到 fence 后才由 retired transport cancel flusher 并关闭旧 writer，避免旧 generation 越过 fence 关闭新 transport。显式 Stop/cleanup 或 translated-output 自身失败可以立即 cancel/close writer 来解除无 consumer 的 blocked write；source EOF/malformed 则不能 abort 已接纳输出，而是走下述 seal + drain
- 编码失败、最终单行超过 `32 MiB`、short write 或非预期 writer failure 都是 transport-fatal，不能退化为 best-effort drop；intentional abort/cleanup 则幂等收口，不能反过来污染 replacement generation
- source stdout EOF / malformed-frame failure 只停止 producer 并 seal queue，已经接纳的 transcript、usage 与 terminal 必须继续按序 drain；Process-facing exit 必须等待 generation reader、translated flusher 以及最后一行后的 EOF consumption fence，防止 `cmd.Wait` / `onExit` 越过尚未处理的 `turn/completed`。显式 cleanup 可以发 shutdown signal 放弃缺失 consumer，但不能弱化 unexpected-exit 的顺序

当前 transport 还必须实现失败语义，而不只是读写 framing：

- request 注册、lifecycle dispatch guard 与向当前 child stdin 写入共享同一个线性化边界，旧 generation 的 reader/close 不能拆掉新 launch；
- app-server 是双向 JSON-RPC，server request 使用独立 id namespace，数值上可以与 AgentTeam client pending request 相同；reader 必须先按非空 `method` 把 frame 路由为 server request，再把无 `method` 的 frame 按 `id` 匹配 client response，不能用 id 命中顺序猜方向；
- malformed JSON-RPC frame、既无 `id` 也无 `method` 的 frame、stdout EOF/读取失败都属于 transport-fatal，必须一次性退休当前 transport、取消 server request、关闭 stream，并让 child 退出；继续读会使 request ownership 和 turn lifecycle 变得不可证明；
- server request 的 success/error JSON-RPC response 都必须完整写入当前 generation；任一 short/error write 都退休 transport，不能让 app-server 等待一段被截断的 response 后继续复用失去帧边界的连接；
- 当前 pending client requests 在 transport-fatal 时统一收到带 `kind=transport_ambiguous` 的结构化错误；它表示 request 可能已被 app-server 接受，不能等价为 rejected；
- mutating request dispatch 后等待 response 超时/取消、stdin write failure，以及 `turn/start` / `turn/steer` success response 无法 decode，同样归类为 ambiguous outcome；
- app-server 明确返回的 JSON-RPC error 是 known rejection，可以按协议状态进行 bounded reroute；ambiguous outcome 只能交给 `clientUserMessageId` reconciliation，禁止直接重复发送正文；
- graceful shutdown 会先标记 closing，避免正常关闭被误报成 runtime transport failure。

这里的关键不是“所有错误都重启”，而是把 transport liveness 与 message ownership 分开：transport-fatal 可以结束当前 Process；已 dispatch 的具体输入仍由 daemon durable ownership journal 负责恢复、查询和去重。

### 4.2 握手

连接建立后必须执行：

1. `initialize`
2. `initialized`

如果 `initialize` 成功但 `initialized` 发送失败，整个 driver 启动应视为失败，因为后续线程生命周期已经不再可信。

### 4.3 driver 需要维护的协议派生状态

driver 内部最少维护：

- `threadID`
- `threadStatus`（保留 `activeFlags`，不要压扁成单个布尔值）
- `activeTurnID`
- `completedTurnCount`
- `completedTurnIDs`
- non-steerable turn kind / compaction fence
- 已由 terminal `error` 合成 TurnEnd 的 turn 集合

状态来源优先级：

1. 最新 notification
2. 最近一次 `thread/read` / `thread/resume` / visible turn input 响应
3. 本地默认值

设计原则：

- 允许“状态未知”，不要强行用布尔值装作自己知道
- `activeTurnID` 为空时，不表示一定 idle；只表示“当前未确认 active turn id”
- request response 只能补足“通知还没到”的空窗，不能把已经被更新 notification 推进过的状态再倒回去

这条 ordering 约束在实现里尤其重要，因为 JSON-RPC response 和 notification 共享同一条连接，但 server 可以先发 `turn/started` / `turn/completed` 或 `thread/status/changed`，再回对应 request。driver 必须把 notification 当成权威状态，避免“晚到的 `turn/start` response 把已 completed 的 turn 重新标成 active”或“`thread/compact/start` response 把已完成的 compaction 重新标成进行中”。

同样地，`thread/read` / `thread/resume` 的 detailed thread snapshot 也必须被视为足够强的纠偏信号: 如果快照已经显示 thread 回到 `idle`，或者已经出现真实的 `inProgress` turn，就要清掉本地粘滞的 compaction 进行中标记，避免后续无关的 `thread/status/changed -> idle` 被误判成“刚完成一次 compact”。

`turn/started` 的 ordering 还需要遵守 Codex TUI 的语义：同一 completed turn 的重复 started notification 必须忽略；但一个更新的、不同 turn id 的 authoritative started notification 可以替换本地残留的旧 `activeTurnID`。不能因为旧缓存仍显示 active 就把新 turn 当成 stale。相反，所有可能改变 root activity 的 item/delta/error 必须同时通过 primary thread、current turn 和 `completedTurnIDs` fence。

App-server notification read agentteam 本身有顺序，但 MCP/dynamic tool callback 可能从异步 goroutine 完成。driver 因此需要一个 turn-event serialization fence，把 root notification、async tool output 与 terminal event 放进同一顺序：一个 activity 要么在 terminal TurnEnd 前发布，要么看到 completed fence 后被丢弃，不允许出现 TurnEnd 后又追加 root tool/text/thinking 的状态回跳。

### 4.4 启动与恢复 thread

#### 新 thread

使用 `thread/start`，发送 AgentTeam 当前真正需要的字段：

- `cwd`
- 来自 agent `permissionMode` 的 `sandbox`：`agent_workspace_write` 使用 `"workspace-write"`，`full_access` 使用 `"danger-full-access"`
- 来自 agent `permissionMode` 的 `approvalPolicy`：`request_permissions` 只在 `full_access` 下为 `true`
- `model`（可选）
- `baseInstructions`（平台级核心不变量）
- `developerInstructions`（agent-specific 指令，可为空）

不要继续发送不存在的 `persistExtendedHistory`。

fresh thread 的首个 turn 只携带：

- turn-history reference（例如 skill reference / tool catalog）
- dynamic user input（例如首次消息、wake prompt、deferred continuation prompt）

因此 driver 必须同时准备好 fresh-turn input 与 resume-turn input，并在实际 thread 路径确定后再选择；任何 fallback 到 fresh thread 的路径都必须重新使用 fresh-turn input，避免新 thread 漏掉 reference 文本。

#### 恢复已有 thread

使用 `thread/resume`：

- 从 launch-generation snapshot 显式重放由 agent `permissionMode` 推导出的 `sandbox` / `approvalPolicy`、`cwd`、非空 instruction overrides 与显式 model policy；snapshot 与运行中 `effectiveModel` 分离，不能被 child-thread usage hydration 或旧 thread read 污染
- 显式 `model` 必须和 `config.model_reasoning_effort` 成对发送；reasoning summary 通过 `config.model_reasoning_summary` 重放。没有显式 model 时不单独发送 effort，让 app-server 使用该 thread 已持久化的 model/effort
- 普通 resume 和 recovery resume 先用 `excludeTurns=true` 建立订阅/拿到 model，再用一次 `thread/read(includeTurns=true)` 获取权威 history，避免在 response 与历史 usage replay 之间重复构造大 payload
- 记录返回的 `thread.id`
- 用返回的 `thread.turns` 初始化 `completedTurnCount`
- 如果 `thread.status=active`，从 `turns` 中找 `status=inProgress` 的 turn 作为 `activeTurnID`
- best-effort 传递当前 `baseInstructions` / `developerInstructions`，但不要把它当成可靠的 instruction refresh 语义

恢复策略补充：

- 如果本地 session 元数据里没有 instruction fingerprint，说明这是 pre-split legacy thread，直接放弃 resume 并创建 fresh thread
- 如果当前 instructions fingerprint 与上次启动记录不一致，同样直接 cut over 到 fresh thread
- 只有 fingerprint 仍然匹配的 post-migration thread，才尝试真正的 `thread/resume`
- daemon 在构造 `Process`、调用 driver `Spawn` 或任何路径可能发送首个 turn **之前**，先持久化本次 start 的 `SessionResumeSafe=false` baseline；这不是“在 attach resume-safety callback 前”就足够的弱约束。`SessionInit`、authoritative `turn/started`、新 runtime handoff 和 compaction 都维持/重新置为 unsafe；任何 live ownership 的同一事务 upsert 也必须把 safe 置回 false。只有 authoritative terminal boundary 且当前没有更老 delivery 要先处理时才能尝试写回 safe；safe=true 的持久化事务会再次检查 journal，并在存在 `queued|pending|uncertain` owner 时拒绝 promotion
- resume-safety callback 晚于 runtime output parser 安装，所以 Process 会保存带 version 的最新快照，并在 callback 安装时 replay；daemon 持久化层再以每次 Process launch 递增的 epoch 和 epoch 内单调 version 做 CAS，旧 Process callback 或旧 transition/replay 都不能覆盖更新的 unsafe/terminal boundary
- no-prompt launch 的 idle `SessionInit` 在完成 callback delivery、ownership restore/drain 判定后，如果 inbox、pending runtime input 与 reserved handoff 均为空，会直接建立 safe boundary（因为不会再有 TurnEnd）；任何随后恢复出的 live ownership 会以更高 version 重新置为 unsafe
- daemon restart 时，如果本地 state 指向同一 Codex thread 但 `SessionResumeSafe=false`，必须 fail closed：不 resume 该 thread，而是创建 fresh thread。历史 ownership record 仍可带旧 thread id 做只读 `thread/read` reconciliation，但不能借 reconciliation 重新 attach 到 outcome 未知的执行

恢复 delivery ownership 与恢复 app-server session 是两个独立问题。所有 live owner 都先用 **no-prompt launch** 建立并发布 Process；`queued` 随后通过保留的 `DeliverForHandoff` 走“durable queued → durable pending → correlated start/steer”的正常两阶段路径，不能把正文直接塞进 Spawn prompt，否则 runtime mutation 会早于 ownership/store 安装。`pending` / `uncertain` 则 suppress idle start turn，并先对旧 `clientUserMessageId` 做 reconciliation。这个 no-prompt restore 是防重边界：在证明 `absent` 前，不得把正文同时作为 fresh thread 首条消息再次发送。

两阶段边界不取决于 server 是否要求 delivery ACK，也不取决于 session 是否在 UI 上标为 ephemeral。凡是带 concrete `MessageID` 的 default dispatch、`agent:start` wake、execution completion/timeout/interruption signal，以及 hidden workflow delivery，都必须先 durable `queued`，再在 Process 配置并原子发布后执行 error-returning handoff；只有 handoff 成功才算本地 admission。Hidden workflow 可继续隐藏 checkpoint/runtime UI，但要使用独立的 hashed session workdir，不能与普通 agent 的 `.daemon-state.json` 共用 journal。

### 4.5 消息投递

投递逻辑以两个边界为准：app-server 当前 state 决定 request shape；daemon durable ownership 决定正文是否允许重放。不能用一个本地 `isIdle` 布尔值同时替代这两个事实。

#### 4.5.1 state-aware start / steer / resume router

当前 router 对同一 concrete input 只做 bounded state reconciliation：

1. `thread.status=active`、存在 regular steerable `activeTurnID`：使用 `turn/steer(expectedTurnId=activeTurnID)`。
2. `thread.status=idle|systemError`：使用 `turn/start`。
3. `thread.status=notLoaded`：最多执行一次 `thread/resume`；resume 的正常 hydration 会做 detailed read，然后从新 snapshot 重新选择 start/steer。
4. snapshot unknown，或 active 但缺少 turn id：最多做一次 `thread/read(includeTurns=true)`。若 detailed snapshot 仍为 active 且没有 turn id，返回 ordered runtime-boundary error，绝不发 `turn/start`。Codex app-server 会异步路由 `turn/start`；active race 下它可能把 input 注入旧 turn，而 response 又为新 submission id，不能把它当成同步“active turn”探针。
5. review、manual compaction、waiting boundary 等 non-steerable/compaction fence：直接返回 boundary error，让 daemon 保持 queued/HOL；不能用一次 read 把本地 fence “读没了”后强行 start。

Ack-aware busy delivery 的调用方把 Process 视为 busy，所以缓存的 `idle|systemError` 也可能过期；除非缓存已经给出 concrete active + turn id，否则先做 authoritative read。普通 direct delivery 同样只允许一次 boundary refresh + 一次 retry，不构造无界 `thread/read` agentteam。

Router 是有上限的迭代状态机，而不是 start/steer 互相递归：同一 concrete input 最多一次 authoritative refresh、最多两次 mutating request，并始终复用同一个 `clientUserMessageId`。Known `turn/steer` missing-turn rejection 可改走一次 `turn/start`；known start/steer state mismatch 只有在 read 得到 concrete regular active turn id 后才可改走一次 steer。Timeout、EOF、response decode failure 或 transport disconnect 都是 ambiguous outcome，第一次 mutation 后立即停下并交给 reconciliation，绝不能 blind retry。`turn/interrupt` 若明确返回 `expected active turn id X but found Y`，只允许用解析出的 Y 重试一次；第二次 mismatch 停止，不能追着不断变化的 turn id 循环。

#### 4.5.2 `clientUserMessageId` ownership

无论选择 `turn/start` 还是 `turn/steer`，routed AgentTeam message 都带稳定的 daemon delivery id 作为 `clientUserMessageId`。这也包括 transient recovery 使用的 `DeliverInterruptingRuntimeInput`：interrupt 只改变 turn boundary，不能丢掉原 `RuntimeInput.ID` correlation。具体 ownership 流程为：

1. 在 mutating request dispatch 前，原始 `InboxMessage`、rendered `RuntimeInput`、thread id 和 wake token 以 `pending` phase 原子写入 `.daemon-state.json`；持久化失败则不 dispatch，也不能向 server ACK accepted。
2. Known rejection 可把原始正文转回 `queued`；non-steerable boundary 保留严格顺序，等待后续 input boundary。
3. Request dispatch 后的 timeout、write/transport failure、fatal frame 或 success response decode failure 转为 `uncertain`。不重复 start/steer。
4. Codex 后续发送 `item.type=userMessage` 且 `item.clientId` 匹配时，driver 无论 terminal fence 是否已经关闭都必须发 `runtime_input_surfaced`。Process 把 ownership 置为 `surfaced`，追加轻量 ack activity，并推进 target observed frontier。
5. `surfaced` ack 若属于已经 observed 的 target，只做 cleanup；若 session mismatch，则不能把它当作当前 session surface，原消息在仍非 stale 时回到 ordered fallback。

Transport accepted 与 successful JSON-RPC response 都不等于模型已看到正文。只有 correlated `userMessage.clientId`、daemon 后续 fallback 的真实 surface，或显式 read surface 可以推进 freshness。Accepted pending input 不使用短固定 ACK timeout；长 command 期间保持 runtime-owned pending。为避免 restored/uncertain head 在 runtime 永久异常时无限 HOL，Process 仍有 15 分钟的 duplicate-safe reconciliation safety ceiling：耗尽后只写 durable `failed` 并报告 wake failure，不重放正文。

#### 4.5.3 read-only reconciliation

Process/daemon restart 或 ambiguous transport 后，Codex driver 实现 `ReconcileRuntimeInput(inputID, sessionID)`：每次 attempt 只调用一次 `thread/read(includeTurns=true)`，校验返回 thread id，并搜索所有 turn item 的 `userMessage.clientId`。

- 找到 client id：`surfaced`，禁止 replay。
- item view 为 `notLoaded|summary`：history 不完整；active 返回 `pending`，其它状态返回 `unknown`。
- full history 未找到、thread 仍 active：返回 `pending`，因为 accepted input 可能尚未 materialize。
- full history 未找到、thread 为 `idle|systemError|notLoaded`：返回 authoritative `absent`，此时才允许把 exact body 恢复为 `queued`。
- 未识别 status/items view 或 read error：`unknown`/error，不猜测 absent。

Reconcile 本身不 resume、不轮询、不修改 primary-thread snapshot；backoff 与上限由 Process 统一拥有。每次 read deadline 为 10 秒，delay 从 100 ms 起步并封顶 5 秒。`unknown` / read error 最多六次；authoritative `pending` 不按六次的短预算失败，而是持续到 15 分钟 elapsed safety ceiling（新 accepted success 另有 1 秒 materialization grace）。恢复时只调度最老 live owner 的 reconciliation；head terminal 后才推进下一条。持续无法证明结果或无 reconciler时进入 durable `failed`，上报 wake failure；安全策略是停止防重，而不是无限重放。

Pi RPC 遵循同一 ownership contract，但其 accepted/surface 两个边界来自不同事实：`prompt`/`steer` response 只表示命令被 Pi 接收，只有带 AgentTeam marker 的 user message（包括 plain-string content 和 content blocks）进入 Pi session JSONL 后，或等价的 `runtime_input_surfaced` 事件，才是 surfaced。首条 concrete delivery 必须使用 no-prompt launch 后的 `DeliverForHandoff`，不能在 `Spawn` 前把正文交给 Pi；这样 `.daemon-state.json` 的 `queued → pending` 写入、Process/store attach 和 session publication 先于 runtime mutation。Marker 可能在 `agent_settled` 之后迟到，仍须完成同一 input 的 terminal ownership，不能因为 root 已 idle 就丢弃或把 accepted 当 surfaced。Pi JSONL 是 appendable stream；reconciliation 只读匹配 session JSONL 的 user content：marker 缺失、文件不存在但 transport 仍可能 materialize、header/旧历史/assistant-only EOF、JSONL 损坏或 transport 结果不明时保持 `pending|unknown`。可读 EOF 或未找到 marker 不是 input-specific negative fence，尤其 no-prompt replacement 已经 live 但 quiescent 时绝不能把这些快照判为 authoritative absent 或 exact replay；只有同一当前 driver 明确观测到该 input 的 pre-mutation rejection（或其它等价 input-specific rejection fence）时才允许 `absent`。Process 对 `pending|uncertain` 使用有界 retry/reconcile safety ceiling，耗尽写 durable `failed` 并报告 wake failure，绝不 blind replay。恢复时先 no-prompt restore，再按 FIFO 只处理最老 live owner；后续 inbox message 不能借 root idle 窗口越过它。

每个 reconciliation attempt 还要捕获 record 的 mutation generation/token。Live `runtime_input_surfaced`、queued transfer 或其它 phase mutation 会递增 generation；异步 read 返回后只有 store attachment epoch 与 record generation 都仍匹配时才允许写 `absent|pending|surfaced|failed`。这样陈旧的“未找到”结果不能覆盖稍后到达的 correlated surface，也不能把已经转移给新 Process 的 owner 重新排队。

#### 4.5.4 bounded HOL continuation

Daemon journal phase 为 `queued`、`pending`、`uncertain` 时都属于 live ownership；为兼容未来 schema 并对损坏 state fail closed，只有显式 `surfaced|failed` 可视为 terminal，未知或空 phase 也必须阻止 restore/recovery，且不得按 queued speculative replay。新消息必须排在最老 live record 后面；delivery gate 把 render、ownership transition、start/steer 和 dequeue 串行化。最老 uncertain record reconciliation 完成前，后续消息不得借 runtime 短暂 steerable 的窗口越过它。

Queued head 由单一 retry worker 处理，当前 exponential backoff 为 100 ms 到 5 秒，普通 transient 最多八次且最多一分钟；review/approval/compaction/active-no-turn 等 runtime boundary 不消耗短 attempt budget，但最多持有 15 分钟。Permanent failure 立即进入 `failed`；head durable surfaced/failed 后才 release 下一条。Freshness 已证明 stale 的 live owner也必须先持久化为 `surfaced`，不能只从内存队列删除后留下 ghost journal。Unexpected Process exit 会在同一 session-key start reservation 下自动从最老 live record 续跑；context-window synthetic recovery 必须排在 live owner 之后；显式 Stop/Sleep 只保存既有 ownership、不立即 respawn，并拒绝在 stopping/stopped 窗口接受新的 handoff。

同一 session key 的 start reservation 覆盖“launch → Process 原子 publish → reserved owner 的 post-publication durable handoff”整个 generation，不能在 publish 后、handoff 前提前释放。普通 wake、exit continuation、runtime-policy restart 与 synthetic recovery 都通过同一 `BeginStart`/publish fence 竞争；只有 current generation 可以发布，superseded launch 必须 Stop。SessionManager `Close`/`Delete` 会原子 invalidate reservation、退休已发布 session，并停止 publication 恰好先赢下 fence 的未跟踪 Process。Continuation retry 在 scheduling 与 terminal commit 两侧重检 generation/terminal fence，避免一个失败 generation 与并发成功 start 各自续跑 queue head。

Journal 上限按**真实序列化 JSON 字节**（包括 escaping 和数组分隔符）计量：最多 100 条 live owner、live 子集最多 1 MiB；总计最多 228 条/2 MiB，其中 `surfaced` 与 `failed` 各保留最近 64 条。Runtime `LastError` 先转为合法 UTF-8 并截断到 16 KiB，不能让未受控 provider diagnostics 永久阻塞 FIFO head。

同 target 存在 pending refresh hint 时，`send_message` 可以在 daemon 本地返回 held-like result；held 可 best-effort 附带 daemon-local fallback inbox 中已有的 target messages，并按本地 inbox / pending runtime input 覆盖情况要求 runtime 读取已附带新消息、等待下一个 input boundary，或调用 exact-target `read_history`。

#### 4.5.5 Process admission、retirement 与 durability boundary

一个 concrete delivery admission 不只是“拿到 `deliveryGate`”这一瞬间。它拥有一个绑定 Process lifetime 的 cancellation context，覆盖等待 gate、prompt/attachment render、ownership fsync、runtime RPC 或 raw stdin write，以及 mutation 后的 terminal persistence。`Stop` / `Sleep` 必须先把 Process 标为 non-accepting 并取消这个 lifetime，使使用 `context.Background()` 发起的 cooperative RPC 也能退出，然后再等待已经 admission 的工作排空。

Public retirement 是 strong admission completion boundary：第一个普通 `Stop` / `Sleep` caller 负责 terminate、drain reentrant lifecycle mutations 与 `deliveryGate`；并发 caller 必须等待同一个 `stopComplete`，再确认 gate 已排空，不能因为看到 `stopping=true` 就提前返回。Recovery finalizer 若已经持有 `deliveryGate`，则必须走显式的 gate-held、non-reentrant retirement path；它完成 state/termination/lifecycle drain，但绝不递归获取或等待自己持有的 gate。Unexpected child exit 同样先把 Process 变为 non-accepting，排空 lifecycle mutation 与 delivery admission，之后才触发 `onExit`/replacement。

Process 有两个不同的完成 fence：`WaitForExit` 只表示 child/runtime 已退出；`WaitForExitCallback` 才表示 dispatcher/user `onExit` 已返回。`waitProcess` 会先 signal child exit，再执行可能持久化 job failure、清 session 或启动 continuation 的 callback。`Stop` / `Sleep` 自身故意不 join callback，避免未 publish launch 的 callback 与 launch owner 相互等待；需要“返回后无 callback side effect”的全局 owner 必须显式同时 join child 与 callback。

从 runtime MCP/server callback 触发的 inbox freshness/reconcile 不能获取 non-reentrant `deliveryGate`，因为外层 app-server request 可能正持有该 gate 等待 callback。它们改用 reentrant-safe lifecycle mutation lease。Freshness tool result 还携带绑定 claim-time Process 的 exact owner capability，不能在 final write 时重新按 agent/session 查找 mutable current Process。该 lease 从 queued→uncertain fsync 和 inbox removal 一直覆盖 final HTTP/child write、terminal fsync、stopped check，以及 wake/onEvent/frontier/resume/retry 的全部同步 post-commit side effect；Stop/onExit/replacement 只能在 callback 整体返回后继续。所有 ownership writes 还按 message 串行，并用 mutation generation + terminal-monotonic CAS 防回退：旧 `queued|pending` write、旧 MCP Reconcile 或旧 Process callback 都不能覆盖较新的 generation，也不能把 terminal 写回 live phase。`surfaced` 是最高优先级的 authoritative success：`failed` 可以被稍后证明的 surfaced evidence 升级，`surfaced` 不能降为 `failed`；stale rollback/delete 只能删除仍为 live 的 owner，绝不能擦掉 terminal tombstone。

Receipt owner 还有不同的 ambiguous-write 收口。Direct HTTP short/error 不得转入 absent reconciliation，因为客户端可能已经拿到完整 JSON；它保留 exact lease，到 bounded deadline 先 fsync `failed` 再发 WakeFailed，禁止重放。Codex 的首个 HTTP hop 无论 write 返回完整还是 short/error 都只 transfer 为 `deferred`，因为 app-server child 可能已经解析并稍后完成第二跳；只有 generation-scoped child response 完整写入才 `surfaced`，显式 child uncertainty 才允许 read-only reconcile。Child/output transport 经 `Wait` 证明退休后，只清理 runtime-transferred deferred lease，绝不能扫仍由独立 HTTP writer 持有的 direct lease。所有 renewal/terminal/retirement 都显式取消旧 timer，避免 callback 已收口后仍保留六分钟 goroutine。

Raw stdin 没有 correlated query，因此采用更保守的 write boundary：

1. physical `Write` 前先把完整 replay body 持久化为 `pending`；失败则一个字节也不写。
2. 明确写入 `0` bytes 时，只有 durable `queued` 写成功后才允许 exact retry；若该 fsync 失败，原 durable `pending` 继续 fail closed。
3. positive partial、invalid count 或其它 ambiguous write 先记 `uncertain`；因为损坏的 byte stream 不能继续 append、且 runtime 无 reconciler，owner 随即以 `failed` terminal evidence 收口并退休 stdin/child。它作为 unexpected exit 允许 replacement 继续后续 owner，但绝不重放这条正文。

Durability 不止是临时文件本身的 `fsync`。首次创建 session workdir 时，daemon 在任何 ownership `Put` 或 runtime `Spawn` 前把从 leaf 到 AgentTeam config root 的完整目录链逐层 `fsync`；`.daemon-state.json` transaction 则执行 temp-file sync、atomic rename 和 parent-directory sync。任一目录 sync 失败都必须作为 admission failure 传播，不能发送 accepted ACK。

Dispatcher 的 `Close` 与普通 `Stop` 是全局 strong join：它们 invalidate
session start reservations，关闭新的 Process callback 与 Exec credential
admission，取消 runtime ownership retry workers，并无界等待这些 owner 以及所有
Process exit callbacks 全部结束，因此返回后不允许 late fsync、publication 或 wake
emit。真实 Process 在构造前登记 dispatcher-level callback owner，直到完整 `onExit`
wrapper 返回才释放；shutdown 不依赖 Process 此时仍出现在 session index，因而不会漏掉
“先摘索引、后写 store/checkpoint”的 callback。只有显式
`StopForShutdown(ctx)` 是 bounded/cancellable variant；同一个 caller deadline
（没有则默认 5 秒）从入口开始同时覆盖 server-connection Stop、interactive Process
Stop/child-exit/callback、Memory watcher owners、credential revocation 与
reservation/retry joins。各 surface 并行发起，不能出现无界 `WaitGroup.Wait` 或
N×per-process timeout；deadline 后 API 必须返回，未响应 cancellation 的 cleanup
只允许在后台 best-effort 收尾，但 closed session/connector generation 仍永久拒绝
late publication。

Server connection manager 是最外层 ingress fence。终态开始时它先关闭 admission、原子摘下 published runtime snapshot，并取消所有已 admit start 与 manager-owned runtime lifetime。每个 server ID 使用独立 mutation generation：newer Enable/Disable/Register/Reconcile 取消 older start；输掉 publish fence 的 runtime 必须同步 compensating Stop；Reconcile 失败只能在 failed generation 仍为 current 时恢复 previous runtime。成功 publication 后 runtime context 与 startup caller 解耦，归 Manager 持有。Bounded Stop 的 caller 可以超时，但 immutable drain owner 会继续 exact idempotent Stop，后续 strong Close/Stop 仍 join 同一个完成边界，不能把第一次短 deadline 固化成 false completion。

Wire Protocol 的每条已建立 Session Link 还有精确 connection-run owner group。Pumps、所有异步 handler、user-token/upgrade prefetch、session reporter、sync ticker、skills/KB/memory refresh、Elastic Link dial/runner/reader 与 remote assignment 都必须在 goroutine 启动前进入同一 run admission。Carrier pumps 结束后先关闭 admission、cancel 并 join stable owner snapshot、关闭 Elastic Links，再做一次 post-owner `sendCh` drain；只有随后才能 clear generation、发布 disconnect 或开始 reconnect。旧 handler 即使在 cancellation unwind 时产生最后一帧，也不会被新 connection generation 消费。Protocol bounded Stop 只停止 caller 等待，不关闭 exact run `done`；later strong Stop 继续等待原 run。

Dashboard/profile 与 runtime transition 共同使用 durable revision CAS。`AgentTeamServer.Revision` 每次 Update、field-local persistence、rollback、SetEnabled 或补偿都推进；Update 返回同一事务真正 commit 的 snapshot，不能通过随后一次 Get 猜本请求写入了什么。Wire 持久化 workspace/status 遇 conflict 时 reload durable file，基于 winner 只合并目标字段并 bounded retry。Runtime Reconcile rollback 同时受 per-ID generation 保护；delete 在 exact revision 上 suspend/runtime-delete，revision conflict 时不调用 stale compensation。这样 A→B→C→B 的 ABA 也不能让最早的 B 请求恢复 A 并覆盖 newer winner。

Trajectory GC 使用 daemon-owned single-epoch owner：Start 与 Close 原子竞争，
Close/Stop 先终止 admission、cancel，再 strong-join；`StopForShutdown` 只使用同一个
overall deadline 做 bounded wait。任何持久化 mutation 前都再次检查 owner context，
防止 callback 或 periodic tick 在 shutdown 返回后写盘或发布状态。

#### 4.5.6 resolved target obligations and frontier settlement

Codex TUI 的 `pending_steers: VecDeque` 仍是 runtime 自己的 input queue：提交后，只有
committed user-message history echo 才能证明 steer 已 acked。AgentTeam 不把这组 runtime
事实转换成按消息覆盖的 started/injected 标志或全局 `pendingWake` scalars。daemon journal
与 Process projection 为每个 concrete delivery 保留 FIFO owner、`MessageID`、canonical
`TargetKey`、target-local `TargetSeq`、delivery class 与 terminal status；这些字段用于
ownership/recovery，不是模型可见的 reply identity。

Admission capacity 是 runtime mutation 前的 durable ownership 边界。当前 512 条 live
owner / 512 KiB 单条上限由 queued、pending、uncertain 等真实 owner 共同计算；已进入
runtime 的 owner 即使稍后 surfaced，也不能因为 admission 容量变化而丢失。最老 live
owner 在 durable terminal（acked 或 failed）前保持 FIFO head，后续输入只能排队或
返回 held/backpressure，不能越过它。

Visible reply settlement 是 server-frontier based：

- 普通 `send_message` 只有服务端成功返回 `sent` 或 `already_sent`，并同时给出
   resolved `TargetKey` 与 `SeenUpToSeq` 时，才可以结算该 target 下已经 acked 且
  `TargetSeq <= SeenUpToSeq` 的 obligations；本地 wake token、provider turn、message
  id 或 `AcceptedSeq` 都不是 settlement frontier。
- `held`、失败、响应缺少 target/frontier，以及只记录本地 AcceptedSeq 的路径，都不
  结算 obligation。不同 target 完全独立；旧 frontier 不能结算同 target 的更高 seq，
  更高 seq 会在下一次 eligible boundary 触发新的 final-visible-reply check。
- reaction/signal delivery 只保留其 delivery class 与自身 message identity；它们不
  参加普通 visible-reply settlement，也不触发普通 final check。

Turn terminal boundary 前，final-visible-reply check 按有普通回复资格的 pending
obligations 聚合 resolved targets，每个 target 只生成一条 bounded prompt，且不持久化
“started/injected”标志。模型成功 send 后由服务端 frontier 批量结算；若最终仍没有
send，当前 acked obligations 一次性写入 `completed_without_visible_reply`（或
`failed`）并释放状态，不能循环注入或遗留 q2 等待下一次 boundary。
### 4.6 notification 处理

新实现至少应做到以下映射。

#### 状态类通知

- `thread/started`
  - 记录 thread 已载入
- `thread/status/changed`
  - 更新 `threadStatus`
  - 普通 turn 的非 active 状态不能单独替代 authoritative terminal；仍有 active turn 时先保持 turn fence，避免把晚到 activity 重新归入一个已被本地提前清空的 turn
  - compaction 的上游顺序允许先收到 `idle`、后收到匹配的 `turn/completed`；此时只 latch terminal status，不清 `activeTurnID`、不发 `RuntimeIdle`
- `turn/started`
  - 更新 `threadStatus=active`
  - 记录 `activeTurnID=turn.id`
  - 向 Process 发送带 authoritative-start metadata 的 lifecycle event；这是 Codex root busy 和 resume-unsafe 的唯一 started 边界，普通 item activity 不替代它
- `turn/completed`
  - 更新 turn 完成计数
  - 若 `turn.status` 为 `completed` / `interrupted` / `failed`，清空 `activeTurnID`
  - 更新 `threadStatus` 为 idle，除非随后又收到新的 active 状态通知
  - 只为当前 primary turn 产生一次 TurnEnd；重复 completed 或已经由 terminal error 合成的 completed 只做 cleanup
  - 对 compaction，只有与 `compactionTurnID` 精确匹配的 completion 才能消费先前 latch 的 upstream idle，并原子产生一次 `RuntimeIdle`；错误 turn、旧 generation 与重复 completion 都不能消费 latch
- `error(willRetry=false)`
  - 先保留/上报 runtime error evidence
  - 把该 primary turn 原子标为 failed-completed，并立即合成一次 TurnEnd
  - 后到的正常 `turn/completed` 被 terminal-error fence consume，不再产生第二个 TurnEnd

Codex driver 声明 explicit turn lifecycle。Process 对 Text/Thinking/ToolCall/ToolOutput/Error 只记录 trajectory；这些 event 不得单独把 idle 改回 busy。Authoritative `turn/started` 才标 busy/unsafe；TurnEnd 才进入 drain-next-or-idle，并在没有 active runtime error/queued input 时标 resume-safe。Terminal error 合成的 TurnEnd 会清除阻塞 idle 的临时 `runtimeErrorDetail`，但不会抹掉已发出的错误 activity、wake failure 或 runtime event。

#### 用户可见内容

- `PlanDeltaNotification`
  - 映射到 thinking 增量；但它是 experimental，completed 的 plan item 仍是 authoritative
- `item/reasoning/summaryTextDelta`
  - 映射到默认可见 thinking summary 增量
- `item/reasoning/textDelta`
  - raw reasoning；默认不进入 AgentTeam activity，只在显式 raw-reasoning debug 开关下映射
- `item/agentMessage/delta`
  - 映射到逐步文本输出；不要只等 `item/completed`
- `item/started` / `item/completed`
  - 用 item 类型驱动 tool/file activity、最终文本补全和落账

所有 root content notification 先经过 current-primary-turn fence：thread 必须是当前 primary thread，turn id 必须匹配当前 active turn，而且不能出现在 `completedTurnIDs`。Terminal notification 与 async MCP/dynamic callback 共享 serialization mutex，因此 TurnEnd 之后不会再出现来自该 turn 的 reasoning/text/plan/command/file/MCP/web/collab root activity。

以下事件例外保留，因为它们不改变 root liveness：

- `userMessage.clientId` correlated surfaced ack，即使它晚于 TurnEnd 也必须完成 ownership；
- usage、settings/model hydration 等 observability/state metadata；
- canonical `subAgentActivity` control-plane projection；
- late collab completion 中对子 agent model/lifecycle 的内部更新，但不再输出 root ToolOutput。

#### 可观测性

- `thread/tokenUsage/updated`
  - 用于后续 compact 策略与调试
- `error`
  - 统一上报 runtime 错误
- 未知通知
  - 记日志，不中断

### 4.7 item 映射原则

对 AgentTeam 当前最重要的 item 类型，建议如下：

- `agentMessage`
  - delta 用于流式文本，completed 用于收尾
- `plan`
  - 与 `PlanDeltaNotification` 一起映射到 thinking；delta 是 experimental，completed item 才是 authoritative
- `reasoning`
  - 与 `item/reasoning/textDelta` 一起转译为 thinking
- `commandExecution`
  - `item/started` 产生 shell/tool activity
  - `item/commandExecution/outputDelta` 映射到 `tool_output(shell)`，用于更细粒度运行输出
- `fileChange`
  - `item/started` 产生 file-change activity
  - `item/fileChange/outputDelta` 映射到 `tool_output(file_change)`，用于显示变更细节流
- `mcpToolCall`
  - 映射到 AgentTeam 现有 MCP tool activity 命名
- `dynamicToolCall`
  - 映射到动态 tool activity
- `collabAgentToolCall`
  - 映射到协作 agent activity
- `webSearch`
  - 映射到 web search activity
- `contextCompaction`
  - 内部状态事件，不必默认对外显示

### 4.8 rewind / recovery

OpenAI Codex 当前 schema 和 app-server README 已明确把 `thread/rollback` 标成 **deprecated and will be removed soon**；推荐的长期历史分支能力是 `thread/fork(lastTurnId|beforeTurnId)`。两者不能机械替换：fork 会生成新的 `thread.id` / session tree root，而 AgentTeam 当前 recovery interface 与 dispatcher session persistence 假定 restore 原地保留 session id。当前实现因此只把 rollback 保留为显式 compatibility path，并在协议请求失败时 fail closed；后续迁移必须把“fork 后的新 thread id”作为 Restore 返回值，原子更新 Process、dispatcher `.daemon-state.json`、session projection 与后续 delivery ownership，再删除 legacy rollback。不能只把 method 名换掉后继续把旧 id 当成当前 session。

#### live runtime recovery

在线 recovery 应优先分层处理：

1. `thread/read(includeTurns=false)` 获取当前 `thread.status`
2. 对 loaded thread，这个轻量读取只用来确认 coarse `thread.status`；在决定 no-op 或计算 `thread/rollback.numTurns` 之前，仍要用 `thread/read(includeTurns=true)` 或等价的详细快照重新核对 turn 历史
3. `completedTurnCount` 只能作为启发式缓存，不能在 recovery 上单独当作权威 turn 数量来源
4. 如果 thread active 且已知 `activeTurnID`，先 `turn/interrupt(threadId, turnId)`，失败按 best-effort 处理
5. 在兼容版本上再走 deprecated `thread/rollback`；不支持时明确失败，不猜测成功

`turn/interrupt` 成功与 interrupted turn 写入 `thread/read(includeTurns=true)` 历史不是同一个原子边界。对 recent concrete-turn interrupt，Restore 即使立刻读到 `currentTurnCount == targetTurnCount` 也不能判 no-op；必须在 caller context 与 30 秒 snapshot lease 的共同上限内轮询，直到同一 concrete turn 已出现且非 `inProgress`、thread 也已 inactive，再用包含该 terminal turn 的权威数量计算 rollback diff。读失败或 caller cancel 保留 snapshot 供重试；snapshot 自身过期明确失败并清理；一旦 exact terminal turn 已物化，应在发出 rollback 前 compare-and-clear snapshot，避免 rollback 实际成功但 response ambiguous 时，重试反而等待已经被 rollback 删除的 turn。Startup cancellation 的 `turnId` 为空且没有 turn event，不强制 `+1`，只要求 inactive 且历史不少于 interrupt 前的 completed boundary；若仍小于 recovery target 则照常 fail closed。

Restore admission 与普通 delivery/handoff 使用同一个 `deliveryGate`。在进入 rollback 前必须同时证明本地 inbox、pending runtime input、reserved handoff、transient retry state 以及 durable journal 都没有 `queued|pending|uncertain` owner；任一 live owner 存在都返回 busy，不能一边 rewind 一边把新正文交给旧 turn。Live restore 的 finalizer/retirement 保持 gate 到 cleanup 完成，detached recovery 也通过同一 owner proof 才允许 `SleepForRecovery`/retire。

Recovery cleanup 只允许用共享事务 update 清 session/resume/recovery 字段并递增 safety epoch；它必须保留 delivery ownership、deferred deliveries、active task、runtime policy 与其它非 session 启动配置，不能通过删除整个 `.daemon-state.json` 制造 accepted body 丢失。

关键点：

- `turn/interrupt` 需要 turn id，所以 live runtime 必须尽量维护它
- 不能再假设“只要知道 threadId 就能中断”

#### detached recovery

detached compatibility recovery 维持“启动临时 app-server 连接后 `thread/resume`、authoritative read 再 rollback”的路径：

1. 启动临时 app-server 进程
2. `initialize` + `initialized`
3. `thread/resume(threadId, excludeTurns=true)`，把 persisted thread 装入 live `ThreadManager`
4. `thread/read(includeTurns=true)` 获取 authoritative turns/status
5. 必要时中断 in-progress turn，并再次 detailed read 核对 terminal snapshot
6. 在兼容版本上调用 deprecated `thread/rollback`
7. 关闭临时进程

这样做的理由不是“协议强制只能 resume 后 rollback”，而是：

- detached runtime 没有现成的本地状态缓存
- `thread/read(includeTurns=true)` 可以读取 persisted 但 `notLoaded` 的历史，却不会把 thread 装入 live `ThreadManager`；deprecated `thread/rollback` 的 `load_thread` 只接受已经加载的 thread，因此 fresh/detached driver 不能把“read 得到历史”等同于 loaded
- `thread/resume` 会把 persisted thread 装入 live manager；AgentTeam 固定使用 `excludeTurns=true`，因此不能把 resume response 当作完整历史，必须紧接 `thread/read(includeTurns=true)`
- 可在一个路径里同时解决“当前 turn 数量”和“active turn id 是否已知”

### 4.9 compaction

Codex 自己根据 active history、model limit 和当前 turn 状态选择
auto-compaction。AgentTeam 也在 ordinary turn 的 quiescent idle boundary 使用共享的
absolute active-context threshold 做 proactive due check；两条路径都可调用
`thread/compact/start`，但 native、proactive maintenance 与 terminal-overflow
emergency 保持独立 source 和 failure/continuation 语义。Compaction 不能被当成解决
大 payload 的手段。

明确约束：

- compaction 解决的是上下文窗口，不是历史 payload 上限
- transport 必须无条件支持大 `thread/resume` / `thread/read(includeTurns=true)` 响应

当前实现采用 runtime-native decision + AgentTeam proactive maintenance + emergency
fallback：

- `thread/tokenUsage/updated.last.totalTokens` 是 Codex adapter 的 primary-thread
  active-context signal。普通 turn 到达权威 idle、没有 queued/pending input 且该值
  达到共享 `AGENTTEAM_PROACTIVE_COMPACTION_TOKENS` 阈值时，Process 才允许 driver
  对同一 thread 发起 proactive compact；未达到阈值只做 cheap no-op
- canonical boundary 是同一 root thread 上 exact
  `threadId + turnId + contextCompaction item.id` 配对的 `item/started` /
  `item/completed`。`thread/compacted` 是 deprecated compatibility signal，不参与
  boundary 决策
- completed item 若匹配 AgentTeam 当前 `compactionTurnID`，source 由 mutation purpose
  精确区分为 `agentteam_proactive` 或 `agentteam_emergency`；若匹配当前 ordinary root turn且
  没有 AgentTeam fence，source 为 `runtime_native`；缺失、冲突或无法 exact 证明的
  identity 记为 `unknown`，不按时间邻近猜测
- driver 在 started 时保存最近一次 root usage，在 item active 期间保存最后一条
  exact-turn root usage。completed 时记录 before/after；若 active 期间没有 usage，
  after 保持 unknown，并由 completed 后第一条 root usage 补齐
- completed boundary 只给后续第一条当前 root-thread usage 设置
  `ContextCompacted=true`。foreign subagent、其他 thread、旧 launch 或缺失 exact
  root identity 的 usage 不能消费 latch；即使第一条 usage 因 model hydration 暂存，
  标记也随同该 bounded pending usage 保留
- 如果 ordinary work 的 `error` notification 带
  `codexErrorInfo=contextWindowExceeded` 且 `willRetry=false`，driver 会把 emergency
  compact 标记为 pending；proactive compaction 自身的 matching terminal failure
  只标记 maintenance failure，不得重新 queue emergency
- 真正的 `thread/compact/start` 不在 driver 的 `turn/completed` notification 回调里
  直接触发，而是在 `AgentProcess` 处理 `turn_end`、优先完成 visible-reply
  final-check、overflow recovery 和 inbox/pending delivery 后，再按 emergency-first、
  proactive-second 的顺序由 capability driver 发起
- app-server 的实际 compaction terminal 顺序可以是 `thread/status/changed -> idle` **先于**匹配的 `turn/completed`。Driver 收到前者时只在当前 generation、当前 `compactionTurnID` 上 latch terminal status；直到后者精确匹配才原子清除 compaction fence、消费 latch，并依次发出一次 `RuntimeCompacted` completion status 与一次 daemon 可消费的 `RuntimeIdle`。Activity 因而明确记录成功后再进入 idle；若 exact item boundary 已有 before/after active-context usage，completion detail 同时带这两个 token count。Wrong-turn、duplicate 或 old-generation completion 都不能消费 latch；普通 turn 不使用这条捷径，仍以 TurnEnd 为唯一正常 terminal
- `thread/compact/start` 一旦可能已经写入 transport，timeout、EOF、write/response decode failure 都是 ambiguous mutation：driver 不得回滚 compaction fence，也不得重新排队第二次 compact；Process 保持 non-idle / resume-unsafe，直到 authoritative `RuntimeIdle`/thread idle 边界证明 compact 已结束。只有 app-server 的 known rejection 才能同步解除 fence
- proactive success 保持同一 thread/session，`RuntimeIdle` 后只恢复普通 delivery，
  不注入 continuation，也不递归触发另一次 due check；matching terminal failure
  只发 bounded maintenance diagnostic 后回到 authoritative idle
- structured diagnostics 记录 native/proactive/emergency/unknown boundary、
  before/after usage、terminal overflow、manual request/result 和原 wake/work terminal outcome。
  correlation 使用 launch generation、thread/turn/item、overflow cause turn、
  message/wake identity；不记录 transcript、summary、prompt 或 user body
- 这样可以保证 `specs/message/delivery-runtime.md` 里的 busy-turn continuation 优先级高于 runtime maintenance，不会让 compact 抢掉本应立刻继续处理的下一条 routed message

## 5. 当前验收基线

本设计最初列出的 handshake、large-frame、thread state、interrupt、streaming 和 unknown-notification 工作已进入当前实现。后续修改至少保持以下回归基线：

- lifecycle：只有 authoritative `turn/started` / terminal boundary 改变 root busy/idle；terminal error 只合成一个 TurnEnd；completed turn 的 late root activity 被 fence，ACK/usage/settings 等非 liveness 事实仍可消费；
- router：idle/systemError start、regular active steer、notLoaded 单次 resume、unknown/active-no-turn 单次 detailed read；read 后仍 active-no-turn 必须保持 boundary、不得 start；同一 input 最多一次 refresh/两次 mutation，interrupt mismatch 最多一次 actual-id retry；non-steerable/compaction 不被 refresh 绕过；
- ownership：runtime request 前 durable `pending`，ambiguous 后 `uncertain`，只有 correlated ack 进入 `surfaced`，只有 authoritative `absent` 允许 exact replay；Process/daemon restart 保持同一 ownership；
- ordering：delivery gate + 单一 queue-head retry/reconciler + session-key start reservation，后到消息不越过 queued/uncertain head，unexpected-exit continuation 不与普通 BeginStart 双发 Process，retry/reconcile 耗尽后产生 bounded terminal failure evidence；
- admission：所有 concrete MessageID（包括 default dispatch、agent:start 与 hidden workflow）都先 durable queued，再 no-prompt publish + error-returning handoff；ephemeral UI mode 不削弱 ownership，Session Close/Delete 会 invalidate 并发 launch generation；
- transport/output：`ReadSlice` fragments 按完整 line 累计硬 `32 MiB`；malformed frame/EOF 等 transport-fatal 会退休 child 和所有 pending；translated output 按 generation 与最终 encoded wire bytes 做 lossless bounded backpressure，abort 先于 generation fence，oversize/encode/short-write failure 一律 fatal；mutating outcome ambiguity 不被当成 reject；旧 launch generation 不能关闭或输出到新 transport；
- wake/delivery：同一 turn 的 correlated inputs 由 durable FIFO ownership 保证顺序；当前 pending delivery rows 按 resolved `TargetKey` 聚合普通 visible-reply obligations，保留每条 `MessageID`/`TargetSeq` 终态。只有 `sent`/`already_sent` 成功响应携带的服务端 `TargetKey` + `SeenUpToSeq` 才能批量结算 acked rows；held、失败、本地 AcceptedSeq 与旧 frontier 都不结算，更高 seq 在后续边界重新 final-check；signal-only rows 不参加普通 final-check。
- process boundary：admission lifetime cancellation 覆盖 render/fsync/RPC/write；public concurrent Stop/Sleep 等待同一 admission strong completion，gate-held recovery 使用 non-reentrant retirement；child exit 与 `onExit` callback done 是两个独立 fence；MCP freshness/reconcile 受 lifecycle barrier 与完整 terminal-monotonic ownership CAS（含 acked 优先及 stale-delete tombstone）保护；raw stdin 先 pending，zero-byte durable queued，partial/ambiguous terminal failed 后退休 byte stream；
- durability/shutdown：workdir directory chain、state rename parent 都必须 fsync；Dispatcher Close/Stop strong-join session reservations、ownership retry、Memory watcher owners、Exec credential cleanup、server connections 和 Process callbacks，只有 `StopForShutdown(ctx)` bounded；closed session/connector generation 禁止 deadline 后 late runtime publication；
- connector generation：Manager 终态 admission/per-ID mutation generation/manager-owned runtime context 阻止 stale start 与 rollback；Session Link exact connection-run 必须 join pumps、handler、reporter、ticker、prefetch、refresh、elastic 与 remote request 后再 reconnect，并在 join 后 drain 旧 send queue；profile Update/rollback/delete 使用 advancing durable revision，包含 ABA 防护与 field-local conflict merge；
- restart：unsafe Codex session fail closed，不 resume outcome 未知的 active thread；pending/uncertain ownership 使用 no-prompt restore，并可只读查询旧 thread；
- recovery/compaction：legacy live/detached rollback 仍使用 authoritative turn snapshot并显式记录 deprecation/migration boundary；restore 与 delivery 共用 admission gate，cleanup 保留 durable journal；proactive/emergency compaction 与 synthetic context recovery 不抢占已排队 delivery，upstream idle 只由 matching compaction completion 消费，ambiguous compact 保持 resume-unsafe 直到 authoritative idle，proactive success 不注入 continuation。

WebSocket transport 仍只是可扩展方向，不是当前 stdio contract 的前置条件。

## 6. 实施边界

当前实现会在 Codex driver、通用 `AgentProcess` delivery ownership、dispatcher local state 三层协作，但不要求改变下列产品/外部边界：

- 改写 public/internal API
- 为 Codex 增加 server-side receive ledger 或 agent pull inbox
- 改变 Claude/OpenCode 已有 lifecycle 语义；explicit turn lifecycle 是 driver capability
- 改变 AgentTeam 的 rewind 产品承诺
- 改变公开 runtime ID 或对外产品语义；当前公开 runtime ID 已由 consolidation 收敛为 `codex`

设计立项时曾将 `codex` 与 `codex-app-server` 作为两个 implementation 分开讨论：

- `codex`: CLI 路径
- `codex-app-server`: 原生 thread/turn 协议路径

该分离现在只用于理解历史迁移和内部 driver/protocol 边界；对外 runtime ID 不再暴露 `codex-app-server`。
