Session 状态同步
busy/idle/waiting 的确定与散播——CC 把活体状态广播到磁盘(PID 文件),Codex 把 turn 生命周期写进 rollout、活体状态只走连接推送
Session 状态同步
结论
两个 runtime 的内部状态机高度对称(连 waiting 子状态都对得上:CC waiting + waitingFor ≈ Codex Active { active_flags: [WaitingOnApproval, WaitingOnUserInput] }),但同步哲学完全相反:
- Claude Code:活体状态广播到磁盘——每次状态翻转写
~/.claude/sessions/<pid>.json,任何进程 fswatch 即可观测(实测翻转延迟 <2s);但 turn 生命周期不进 transcript,early-ESC 中断在磁盘上是「负空间」。 - Codex:turn 生命周期事件(
TurnStarted/TurnComplete/TurnAborted)逐条 flush 进 rollout JSONL——磁盘即事件总线,中断显式落盘、离线可判;但活体状态(ThreadStatus)只走 JSON-RPC 推给已连接客户端,磁盘上没有任何 PID/busy 文件。
早期「基于 transcript JSONL 推断状态」的路线对 CC 确实不可靠(僵尸 working),正解是 PID 文件 + transcript 尾部交叉判定;而同样的需求在 Codex 上一个 rollout 文件就够——tail -f 见 TurnStarted 即 busy、TurnComplete/TurnAborted 即 idle 且带中断原因。
研究问题
- 会话级对外状态用什么类型表示?谁、怎么推导?
- 状态翻转怎么传出去——文件还是连接?push 还是 poll?
- 哪些状态事件落盘?interrupt 在磁盘上可见吗?
- 进程不在 / 重启后,listing 与 resume 怎么从磁盘重建状态?
各 Agent 设计与实现
Claude Code
状态模型 [一手源码] utils/concurrentSessions.ts:19 + screens/REPL.tsx:1155:
export type SessionStatus = 'busy' | 'idle' | 'waiting'
// REPL.tsx — 由 UI 渲染态派生:
const sessionStatus = isWaitingForApproval || isShowingLocalJSXCommand ? 'waiting'
: isLoading ? 'busy' : 'idle';waiting 附带上下文 waitingFor: "approve Bash" | "input needed" | ...。状态从 React 渲染态派生(隐式但等价)。
同步通道:磁盘广播 [一手源码] concurrentSessions.ts:150-161:每次翻转 fire-and-forget 写 ~/.claude/sessions/<pid>.json(源码注释自认:丢一次写只影响 claude ps 一帧)。消费者:claude ps、任意 fswatch 第三方、CCR bridge(bridgeSessionId 去重)。注册边界:interactive / -p headless / bg-daemon 都注册(kind 字段区分);subagent 不注册(getAgentId() != null 直接 return,:54)。
另有一条进程内 listener 链(sessionState.ts 的 idle/running/requires_action 三态 → CCR/IDE/SDK opt-in),与 PID 文件零耦合——同一份「忙不忙」事实有两套并行散播机制,监控 interactive 看 PID 文件,监控 SDK 嵌入用 CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS。
磁盘持久层:状态是负空间 [一手源码] types/logs.ts:297-317:transcript 条目全集约 20 种(消息、summary、ai-title/tag、file-history-snapshot 等),没有 turn-started/completed/aborted 条目。写入还是 100ms 批量 drain(sessionStorage.ts:567 FLUSH_INTERVAL_MS = 100)。所以:强杀/断电/early-ESC 在 transcript 上没有任何标记(streaming 中断才有 [Request interrupted by user] user 消息)——这正是「JSONL 推断会卡僵尸 working」的根因。
离线重建:listing 走 head 64KB + tail 64KB lite read + mtime 排序(sessionStoragePortable.ts:135-282),无索引;活体判定靠 PID 文件 + isProcessRunning 探活、stale 惰性清扫;resume 全量读 JSONL → leafUuids → buildConversationChain 回溯。transcript 默认 30 天清理(docs sessions.md cleanupPeriodDays)。
Codex CLI
状态模型 [一手源码] app-server-protocol/src/protocol/v2/thread.rs:1132-1150:
pub enum ThreadStatus {
NotLoaded, Idle, SystemError,
Active { active_flags: Vec<ThreadActiveFlag> }, // WaitingOnApproval | WaitingOnUserInput
}推导是教科书级「计数器事实 + 纯函数」(app-server/src/thread_status.rs:421-451):RuntimeFacts { is_loaded, running, pending_permission_requests, pending_user_input_requests, has_system_error } → 纯函数映射。waiting 用 RAII guard 管理:note_permission_requested 递增计数并返回 guard,Drop 自动递减(:48-58)——任何取消/panic 路径都不会把状态卡死在 waiting。还有乱序补丁:turn 事件先于 watch 状态到达时强制改报 Active(:285-299)。
同步通道:连接推送 [一手源码] thread_status.rs:221-244:三个出口全部进程内/连接内——per-thread watch::Receiver<ThreadStatus>、全局 running_turn_count watch、JSON-RPC ThreadStatusChanged 通知(同状态去重 :405-417)。磁盘上没有任何 PID/状态文件(全量检索 CODEX_HOME 写入面:只有 rollout、history.jsonl、session_index.jsonl、SQLite、log)。新版 TUI 自己也是 app-server client(tui/Cargo.toml:30-31),ThreadStatus 是全线统一对外状态——但只对握着连接的人可见。
磁盘持久层:rollout 即事件总线 [一手源码] rollout/src/policy.rs:88-92:TurnStarted/TurnComplete/TurnAborted 三件套白名单持久化,且逐条 flush(recorder.rs:1686-1693 每条 write+flush)。用户 Ctrl+C → TurnAborted { reason: Interrupted } 落盘(thread_manager.rs:1595-1600)——中断是磁盘上的一等公民,离线也能判「上次是不是被打断的」。
离线重建:元数据索引在 SQLite state_5.sqlite(ThreadMetadata:rollout_path、时间、cwd、git 信息;启动 backfill 扫描回填)+ append-only session_index.jsonl;resume 全量重放 RolloutItem;NotLoaded 把「磁盘有、内存无」做成协议一等态。
差异矩阵
| 维度 | Claude Code | Codex CLI |
|---|---|---|
| 会话级状态类型 | busy | idle | waiting + waitingFor 文本 | NotLoaded | Idle | SystemError | Active{active_flags} |
| 状态推导 | UI 渲染态派生 | RuntimeFacts 计数器 + 纯函数;waiting RAII guard 防泄漏 |
| 活体同步通道 | 磁盘广播 ~/.claude/sessions/<pid>.json(fire-and-forget,任何进程可 fswatch) | 连接推送 JSON-RPC ThreadStatusChanged(去重)+ watch channel;磁盘无状态文件 |
| turn 生命周期落盘 | ❌ 无条目,中断是负空间 | ✅ TurnStarted/TurnComplete/TurnAborted 白名单落盘 |
| transcript/rollout flush | 100ms 批量(remote 10ms) | 逐条 flush,tail -f 实时 |
| 进程注册表 | ✅ PID 文件(kind/entrypoint/bridgeSessionId) | ❌ 无;进程外无法枚举活体 |
| 离线判中断 | ❌ 只能靠负空间推断 | ✅ rollout 尾部 TurnAborted{Interrupted} |
| 离线索引 | 无索引(mtime + head/tail 64KB lite read) | SQLite state_5.sqlite + session_index.jsonl |
| 文档覆盖 | PID 状态文件零文档 | rollout/状态机制零文档 |
最小复现
# CC:watch 状态文件,跑一个长任务观察 busy→idle 翻转(实测 <2s)
fswatch ~/.claude/sessions/ | while read f; do jq -r '.status' "$f" 2>/dev/null; done
# Codex:tail 当日 rollout,turn 生命周期实时可见
tail -f ~/.codex/sessions/$(date +%Y/%m/%d)/rollout-*.jsonl | grep -o '"type":"turn_[a-z]*"'
# Ctrl+C 中断 codex 后,验证中断落盘:
grep '"TurnAborted"' ~/.codex/sessions/$(date +%Y/%m/%d)/rollout-*.jsonl | tail -1Harness 接入建议(Yoda 实践)
- CC:
fswatch ~/.claude/sessions/(状态翻转)+ transcript 尾部交叉判定 turn 结局。PID 文件是 fire-and-forget,watcher 必须容忍丢翻转、以updatedAt去抖;勿缓存 sessionId↔pid 映射(/resume 会原地改写)。启动时对照存活 PID 清洗历史状态(防僵尸 working)——Yodaclaude-run-state-source.ts已落地此方案。 - Codex:直接
tail -frollout——TurnStarted→busy、TurnComplete→正常结束、TurnAborted{reason}→中断带原因,一个文件解决 CC 需要两个信号源的问题。需要 waiting 粒度(审批/征询输入)必须以 app-server client 身份连接收ThreadStatusChanged。Yodacodex-run-state-source.ts验证中。 - 崩溃签名差异:CC 崩溃 = stale PID 文件(显式负信号);Codex 崩溃 = rollout 尾部既无 TurnComplete 也无 TurnAborted(负空间,但窗口小)。
- 自研 runtime:最稳是两者都做——turn 生命周期进持久日志(离线可重放)+ 活体状态进可 watch 的文件(实时可旁观);waiting 态管理抄 Codex 的 RAII guard(计数器 + 析构自减)。
失效条件
- CC
status字段曾在feature('BG_SESSIONS')门控下(2.1.169 实测已开)——更老版本无 PID 状态字段; - CC 若新增 turn 生命周期 transcript 条目(源码快照 2026-03-31,可能演进),「负空间」结论过期;
- Codex SQLite 库名已迭代到
state_5,schema 变更需回归;rollout 持久化是白名单(Error/ExecCommandEnd不落盘),勿假设事件齐全; - Codex 若增加磁盘状态文件 / CC 若移除 PID 文件机制,本章核心对比翻转。
参考资料
- 深度报告:
agent-research/output/手工川-session-state-sync-cc-vs-codex-2026-06-11-v0.1.md(全量证据与行号) - 前序:
手工川-agent-run-state-2026-06-09-v0.2.md(内部状态机层)、手工川-claude-code-esc-behavior-2026-06-11-v0.2.md(ESC/中断与 PID 文件实测) - CC docs:
sessions.md(transcript 路径、30 天清理);Codex source:app-server/src/thread_status.rs、rollout/src/policy.rs、rollout/src/recorder.rs - Yoda 实现:
src/main/core/conversations/claude-run-state-source.ts、codex-run-state-source.ts