Yoda
参考Agent 设计指南可观测

Transcript 会话日志即观测面

CC transcript JSONL(20 种条目、100ms 批量 flush、turn 生命周期不落盘)vs Codex rollout JSONL(白名单持久化、逐条 flush、TurnStarted/Aborted 一等公民)——外部解析器的地基

Transcript 会话日志即观测面

证据标签:[一手源码] [一手文档] [推断]。CC 源码为重建快照(src_2026-03-31);Codex codex-rs @ b89ce9a (2026-06-06)。本章大量复用已逐行复核的前置调研《session-state-sync v0.1》《compact-summary-sync v0.1》(2026-06-11)。

结论

会话日志是两个 runtime 最重要、也最不对称的观测面。Codex 把 rollout JSONL 当事件总线用:每行 {timestamp, type, payload} 立即 flush(tail -f 实时可读),持久化走显式白名单,turn 生命周期三件套 TurnStarted/TurnComplete/TurnAborted 全部落盘——一个文件即可离线重建完整状态机,连「上次是被 Ctrl+C 打断的」都能从文件尾判出。CC 把 transcript 当内容存档用:20 种条目类型全是消息与元数据,没有任何 turn 生命周期条目,中断是「负空间」;写入按 100ms 批量 drain,链式结构靠 uuid/parentUuid 单链编码,subagent 写独立 sidechain 文件。外部解析器(log-reader)的核心姿势由此分化:读 Codex 按「事件流重放」写,读 CC 按「消息链重建 + 旁路信号补全」写——CC 的活体状态要去 ~/.claude/sessions/<pid>.json 拿,turn 结局要靠链尾形状推断。两边格式都无版本号、随版本漂移,harness 必须做防腐层:宽容解析、按行跳错、白名单取字段。

研究问题

  • 两边各自把什么写进会话日志?条目类型全集是什么?
  • flush 策略与实时性:能不能 tail -f
  • turn 生命周期(开始/完成/中断)在磁盘上可见吗?
  • 外部解析器要遵守哪些不变量(去重、链重建、压缩边界)?

各 Agent 设计与实现

Claude Code

路径与信封~/.claude/projects/<cwd-slug>/<session-id>.jsonl,append-only [一手文档] sessions.md。每条消息的信封字段 [一手源码] types/logs.ts:8-17SerializedMessage = Message & { cwd, userType, entrypoint, sessionId, timestamp, version, gitBranch?, slug? }——注意 version 是 CC 版本号而非格式版本号。

条目类型全集 20 种 [一手源码] types/logs.ts:298-318Entry union):TranscriptMessage(user/assistant 消息本体)、Summary、CustomTitle、AiTitle、LastPrompt、TaskSummary(每 min(5 步, 2min) fork 主线程生成的「正在做什么」摘要,给 claude ps 用,logs.ts:90-95 注释)、Tag、AgentName/AgentColor/AgentSetting、PRLink、FileHistorySnapshot、AttributionSnapshot、QueueOperation、SpeculationAccept、Mode、WorktreeState、ContentReplacement、ContextCollapseCommit/Snapshot(marble-origami 上下文折叠)。全集中没有 turn-started/completed/aborted、没有 task-status——turn 边界只能从消息链形状推断;early ESC 中断在 transcript 上是负空间,仅 streaming 中断会留一条 [Request interrupted by user] user 消息(前篇实测)。

链式编码与 sidechainuuid/parentUuid 单链 + isSidechain/agentId 标记;subagent 写独立文件 <sessionId>/subagents/agent-<agentId>.jsonl [一手源码] sessionStorage.ts:257。压缩不换文件:append 一条 subtype:'compact_boundary'parentUuid:null 的 system 消息截断旧链,摘要作普通 user 消息跟在后面(sessionStorage.ts:1040 附近;细节见 compact 章前置报告)。

flush 策略:批量[一手源码] sessionStorage.ts:567 FLUSH_INTERVAL_MS = 100(每文件写队列定时 drain;CCR remote 模式 10ms,:530)。tail -f 能跟但有 ≤100ms 批延迟,且一个 turn 的多条消息可能同批落盘。

离线索引与清理。无索引:resume picker 对每个 JSONL 只读 head 64KB(首条 prompt)+ tail 64KB(title/tag/lastPrompt),按 mtime 排序 [一手源码] sessionStoragePortable.ts:135-282。resume 全量读 → 取最新 leaf → 沿 parentUuid 回溯重链(含环检测)sessionStorage.ts:2069-2392默认 30 天自动清理cleanupPeriodDays[一手文档] sessions.md:114——离线审计有保质期。

Codex CLI

路径与行格式~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl。每行 [一手源码] protocol/src/protocol.rs:2976-2980

pub struct RolloutLine {
    pub timestamp: String,
    #[serde(flatten)]
    pub item: RolloutItem,   // 序列化为 {"type": "...", "payload": {...}}
}

RolloutItem 五变体 [一手源码] protocol.rs:2827-2833SessionMeta(首行,含 cwd/git 信息)、ResponseItem(模型对话原始项)、Compacted(压缩快照,内嵌 replacement_history 全量替换历史)、TurnContext(当前 model/approval 策略等)、EventMsg(运行时事件)。

持久化是显式白名单[一手源码] rollout/src/policy.rs:74-95 should_persist_event_msg:放行 UserMessageAgentMessageAgentReasoningTokenCountTurnStarted / TurnComplete / TurnAbortedContextCompactedMcpToolCallEndThreadRolledBack 等 16 类;过滤 ErrorWarningExecCommandBegin/End、各类 delta 与审批请求。用户 Ctrl+C → TurnAborted { reason: Interrupted } 落盘(core/src/thread_manager.rs:1595-1600)——中断是磁盘上的一等公民事件

flush 策略:逐条[一手源码] rollout/src/recorder.rs:1686-1693

self.file.write_all(json.as_bytes()).await?;
self.file.flush().await?;   // 每行立即 flush,tail -f 实时可见

离线索引。SQLite $CODEX_HOME/state_5.sqlite 存 ThreadMetadata(thread_id ↔ rollout_path、cwd、git branch/sha),启动时 backfill 扫描回填;另有 append-only session_index.jsonl(改名记录,newest-wins)[一手源码] rollout/src/state_db.rssession_index.rs:17-65。归档走 .jsonl.zst 压缩,append 前先 materialize 回明文(compression.rs:66-74)。无自动清理——与 CC 的 30 天形成对照。

差异矩阵

维度Claude CodeCodex CLI
文件~/.claude/projects/<slug>/<sid>.jsonl~/.codex/sessions/Y/M/D/rollout-<ts>-<uuid>.jsonl
行结构消息信封(SerializedMessage)或元数据条目,靠 type 区分 20 种{timestamp, type, payload},5 类 RolloutItem
链/序编码uuid/parentUuid 单链(需重建)纯 append 顺序即语义顺序
turn 生命周期落盘❌ 负空间(无 started/completed/aborted 条目)✅ 三件套白名单持久化(policy.rs:88-92),中断带 reason
flush100ms 批量(remote 10ms),sessionStorage.ts:567逐条 flush(recorder.rs:1692
token 用量落盘assistant 行内嵌 message.usage(API 原样)TokenCount 事件(累计值)白名单落盘
subagent独立 sidechain 文件 <sid>/subagents/agent-<id>.jsonl同走 rollout 体系(子线程独立 rollout)
压缩编码compact_boundary 指针手术 + 摘要明文 user 消息Compacted 全量快照(replacement_history
离线索引无(mtime 排序 + head/tail 64KB lite read)SQLite state_5.sqlite + session_index.jsonl
自动清理30 天(cleanupPeriodDays,可配)无(手动 archive + zstd)
活体状态不在 transcript:旁路 ~/.claude/sessions/<pid>.json不在磁盘:仅 app-server 连接推送 ThreadStatus
格式文档路径/清理有文档;条目类型全集仅源码全部仅源码(TS/JsonSchema derive 是半公开契约)

最小复现

# Codex:单文件重放状态机(TurnStarted→busy,TurnComplete/TurnAborted→idle+原因)
tail -f ~/.codex/sessions/$(date +%Y/%m/%d)/rollout-*.jsonl \
  | grep -E '"turn_started"|"turn_complete"|"turn_aborted"|"token_count"'

# CC:transcript 只有内容,活体状态走旁路 PID 文件
fswatch ~/.claude/sessions/   # busy/idle/waiting 翻转 <2s(前篇实测)
tail -f ~/.claude/projects/<slug>/<sid>.jsonl | grep -E '"type":"(user|assistant|summary)"'

Harness 接入建议(Yoda 实践)

Yoda 把两种格式都解析成统一会话视图,全部代码在 yoda/src/main/core/conversations/

  • CC 解析claude-transcript.ts:25-50):逐行 JSON.parse、解析失败静默跳行;过滤规则isSidechain===trueisMeta===truesubtype==='stop_hook_summary' 一律跳过;只取 type==='user'/'assistant'message.role 匹配的行。工具输出截断 16KB。
  • Codex 解析codex-rollout-terminal-history.ts):经 getCodexSessionContext 从 state DB 解析 thread→rollout 路径,再整文件格式化为 user/assistant/tool/status 四角色条目;性能教训写在 codex-usage-reader.ts:23-26 注释里——rollout 路径解析「deliberately uses ONLY the state DB」,因为兜底的全目录扫描要 parse ~/.codex/sessions 下数百个文件,批量迭代时不可接受。
  • 运行状态双源claude-run-state-source.ts(PID 文件 + transcript 尾交叉)与 codex-run-state-source.ts(rollout turn 事件尾),正是上文「负空间 vs 一等公民」差异的产品化落点;CC 侧另有 claude-interrupt-sniffer.ts 专门补中断信号。
  • 防腐层原则(两边通用):① 每行独立 try/parse,坏行跳过不报错;② 只白名单取字段,未知 type 忽略(CC 03-31 → 今已可能新增条目类型);③ 不要复刻 parentUuid 重链逻辑除非必须(压缩后的指针手术极易做错,见 compact 报告反例);④ 用文件 mtime 做解析缓存键(Yoda usage-cache.ts)。

反例 / 边界

  • rollout ≠ 全量事件流:白名单过滤掉 Error/ExecCommandEnd/审批请求等(policy.rs:101+),离线分析别假设事件齐全;waiting(审批中)粒度只在 app-server 连接上有。
  • CC 崩溃签名是 stale PID 文件,Codex 崩溃签名是 rollout 尾部既无 TurnComplete 也无 TurnAborted——两边都是负空间,只是窗口大小不同。
  • CC transcript 的 version 字段是 CLI 版本而非格式版本,不能当解析分支依据;同一文件内可能混有多个 CC 版本写入的行(resume 跨升级)。
  • /resume 会让同一 transcript 被新进程接管——外部 watcher 不要缓存 sessionId↔pid 映射(前篇实测结论)。

失效条件

  • CC Entry union 在 03-31 快照为 20 种,live 版本可能已扩——新条目应被宽容解析器自动忽略,但依赖条目语义的功能需回归。
  • Codex 白名单 policy.rs 每版可增删(库名已迭代到 state_5.sqlite,说明 schema 在快速演进)——升级后 diff should_persist_event_msg
  • CC 30 天清理、100ms flush 均可配/可变;CCR remote 模式行为不同。
  • 两边均无格式版本号——任何字段重命名都是静默 breaking change,harness 需要 fixture 测试(Yoda 的 *.test.ts 即此用途)。

参考资料

  • 前置报告(已逐行复核行号):output/手工川-session-state-sync-cc-vs-codex-2026-06-11-v0.1.mdoutput/手工川-compact-summary-sync-cc-vs-codex-2026-06-11-v0.1.md
  • [一手源码] CC:types/logs.tssessionStorage.tssessionStoragePortable.ts
  • [一手源码] Codex:protocol/src/protocol.rs:2827-2980rollout/src/{policy,recorder,state_db,session_index,compression}.rs
  • [一手文档] claude-code-docs/docs/sessions.md(路径 + 30 天清理);Codex 侧 rollout 公开文档零覆盖
  • Yoda:src/main/core/conversations/(claude-transcript.ts、codex-rollout-terminal-history.ts、*-run-state-source.ts)

On this page