上一篇分析了无交互模式与两套 SDK。

从外部看,Codex 的一次对话像是一段连续的交互:

用户输入
  -> 模型推理
  -> 工具调用
  -> 事件流返回

但只要进入恢复、列表、归档、删除、多 Agent、长期记忆和目标追踪,就会立刻遇到另一个问题:

这段交互结束后,哪些东西必须永久保存?
哪些东西必须快速查询?
哪些东西可以从历史重建?
哪些东西不能只靠重放 JSONL 解决?

Codex 当前采用的是双层持久化模型:

Rollout JSONL
  -> 可重放的事实日志
  -> 保存 canonical history
  -> 支撑恢复和审计

SQLite
  -> 可查询的状态投影
  -> 保存线程索引、日志、目标、记忆任务和 Agent 作业状态
  -> 支撑列表、搜索、关系过滤、后台任务和快速修复

这不是重复保存同一份数据。

更准确地说:

JSONL 是事实来源。
SQLite 是索引、派生状态和任务控制面。

本篇会从源码出发,把这套设计拆开。

本篇目标

阅读完成后,你应该能够:

  1. 解释 Codex 为什么同时使用 Rollout JSONL 和 SQLite。
  2. 画出新建、恢复、追加、flush 和 shutdown 的持久化调用链。
  3. 说明 Rollout 文件为什么支持延迟物化、后台写入和压缩。
  4. 解释 SQLite 的四库拆分:state.sqlitelogs.sqlitegoals.sqlitememories.sqlite
  5. 说明 Thread Store 如何把 Core 和本地存储细节隔离开。
  6. 解释 session restore 如何从 Rollout 重建历史,并通过 read-repair 修复 SQLite。
  7. 解释 startup backfill 如何把历史 Rollout 补入 SQLite。
  8. 说明归档、反归档和删除为什么必须同时处理文件和状态库。
  9. 说明 memories 的 Stage 1、Phase 2、文件产物和工具读取路径。
  10. 说明 goal、logs、agent jobs 和 spawn edges 分别保存在哪里。
  11. 识别这套持久化系统中的并发边界、锁、lease、retry 和故障恢复策略。

1. 本篇源码地图

Rollout JSONL

文件 职责
rollout/src/lib.rs Rollout crate 对外导出、sessions 目录常量
rollout/src/recorder.rs RolloutRecorder、后台 JSONL writer、恢复读取、线程列表
rollout/src/compression.rs .jsonl.zst 压缩、透明读取、追加前物化
rollout/src/metadata.rs 从 Rollout 提取 ThreadMetadata、startup backfill
rollout/src/state_db.rs Rollout crate 到 codex_state::StateRuntime 的桥接

Thread Store

文件 职责
thread-store/README.md ThreadStoreLiveThreadLocalThreadStore 设计说明
thread-store/src/store.rs ThreadStore trait
thread-store/src/live_thread.rs 活跃线程的持久化生命周期
thread-store/src/thread_metadata_sync.rs 从追加历史观察并生成 metadata patch
thread-store/src/local/mod.rs 本地 Thread Store 总入口
thread-store/src/local/live_writer.rs 新建、恢复、追加、persist、flush、shutdown
thread-store/src/local/read_thread.rs 读取线程、SQLite 优先、Rollout fallback
thread-store/src/local/list_threads.rs 线程列表、关系过滤、state-only 和 fallback
thread-store/src/local/archive_thread.rs 归档文件移动和 SQLite 更新
thread-store/src/local/unarchive_thread.rs 反归档文件移动和 SQLite 更新
thread-store/src/local/delete_thread.rs 删除 Rollout 文件和 legacy name index
thread-store/src/local/update_thread_metadata.rs 显式元数据更新和兼容性 SessionMeta 追加

SQLite 状态层

文件 职责
state/src/runtime.rs 打开四个 SQLite DB、迁移、WAL、pool、启动维护
state/src/runtime/threads.rs 线程元数据、spawn graph、删除级联
state/src/runtime/backfill.rs backfill singleton 状态和 lease
state/src/runtime/logs.rs 日志插入、查询、容量裁剪和启动维护
state/src/runtime/goals.rs 目标状态、预算、用量核算
state/src/runtime/memories.rs 记忆 Stage 1 / Phase 2 任务状态和产物索引
state/src/runtime/agent_jobs.rs Agent 批处理作业和 item 状态
state/src/runtime/recovery.rs SQLite 损坏识别和备份恢复
state/src/migrations.rs 四组 migrator

关键迁移

文件 职责
state/migrations/0001_threads.sql 主线程表初始结构
state/migrations/0014_agent_jobs.sql agent_jobsagent_job_items
state/migrations/0021_thread_spawn_edges.sql 父子线程 spawn 边
state/migrations/0040_threads_history_mode.sql history_mode
state/logs_migrations/0001_logs.sql 独立 logs DB
state/goals_migrations/0001_thread_goals.sql 独立 goals DB
state/memory_migrations/0001_memories.sql 独立 memories DB

Memory 和 Goal 扩展

文件 职责
memories/write/src/start.rs 启动异步 memories pipeline
memories/write/src/phase1.rs 从 Rollout 抽取单线程 raw memory
memories/write/src/phase2.rs 全局记忆合并 Agent
memories/write/src/storage.rs raw_memories.md 和 rollout summaries 产物
memories/write/src/runtime.rs Stage 1 模型请求和 consolidation agent 启动
memories/read/src/lib.rs memory read crate 根路径
ext/memories/src/extension.rs Memory 工具和 developer instructions 注入
ext/memories/src/local.rs 本地 memories backend 和路径隔离
ext/memories/src/tools/mod.rs list/read/search/ad-hoc tools
ext/goal/src/extension.rs Goal lifecycle、tools、usage accounting
ext/goal/src/runtime.rs Goal runtime 状态机和自动继续
ext/goal/src/api.rs 外部 GoalService
ext/goal/src/tool.rs get_goalcreate_goalupdate_goal

2. 一张图看懂持久化分层

先看整体结构:

App Server / Core Session
        |
        v
LiveThread
        |
        v
ThreadStore trait
        |
        v
LocalThreadStore
        |
        +--> RolloutRecorder
        |       |
        |       +--> ~/.codex/sessions/**/*.jsonl
        |       +--> ~/.codex/archived_sessions/**/*.jsonl
        |       +--> cold file: .jsonl.zst
        |
        +--> StateRuntime
                |
                +--> state.sqlite
                +--> logs.sqlite
                +--> goals.sqlite
                +--> memories.sqlite

这个图里最关键的边界是 ThreadStore trait

Core 的 Session 不需要知道本地存储是:

JSONL 文件
SQLite 表
压缩文件
远端服务

它只面对 LiveThreadThreadStore

源码中的 trait 是:

pub trait ThreadStore: Any + Send + Sync {
    fn create_thread(&self, params: CreateThreadParams) -> ThreadStoreFuture<'_, ()>;
    fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreFuture<'_, ()>;
    fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreFuture<'_, ()>;
    fn persist_thread(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()>;
    fn flush_thread(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()>;
    fn shutdown_thread(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()>;
    fn load_history(&self, params: LoadThreadHistoryParams) -> ThreadStoreFuture<'_, StoredThreadHistory>;
    fn update_thread_metadata(&self, params: UpdateThreadMetadataParams) -> ThreadStoreFuture<'_, StoredThread>;
}

完整定义在:

codex-rs/thread-store/src/store.rs

这让 Codex 可以把“会话怎么运行”和“会话怎么存储”拆开。

3. 为什么不能只有 JSONL

Rollout JSONL 很适合保存事实。

例如:

SessionMeta
TurnContext
UserMessage
ResponseItem
Tool Call Event
TokenCount
ThreadGoalUpdated
Compacted
InterAgentCommunication

这些都是顺序事件。

如果只考虑恢复会话,JSONL 足够:

打开文件
逐行解析
重建 InitialHistory::Resumed
继续追加

但一旦出现下列场景,JSONL 就不够了:

快速列出最近线程
按 cwd / source / model provider 过滤
按 title / preview 搜索
列出某个父线程的所有子 Agent
查询目标当前状态
统计日志和反馈日志
维护 memory stage1 / phase2 lease
维护 agent job item 分配和完成状态

这些都是索引和状态问题。

如果每次都扫描所有 JSONL:

性能不可控
过滤逻辑复杂
并发任务难以 claim
后台任务无法可靠 retry
日志容量无法按 partition 控制

所以需要 SQLite。

4. 为什么不能只有 SQLite

反过来,SQLite 也不适合作为唯一事实来源。

原因很直接:

模型对话历史是顺序日志,不是单行状态。
恢复需要完整 canonical history,而不是最新 summary。
审计需要看到原始事件流。
不同版本之间需要容忍未知或旧格式。
SQLite 索引损坏时应该能从历史重建。

Codex 的设计选择是:

Rollout JSONL 负责事实。
SQLite 负责可查询投影和后台任务状态。
投影可以通过 backfill / read-repair 从事实日志修复。

这也是本篇的主线。

5. 本地文件布局

Rollout crate 导出的目录常量在:

codex-rs/rollout/src/lib.rs

关键常量是:

pub const SESSIONS_SUBDIR: &str = "sessions";
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";

典型文件布局:

~/.codex/
  sessions/
    2026/
      08/
        02/
          rollout-2026-08-02T09-00-00-<uuid>.jsonl
  archived_sessions/
    ...
  state.sqlite
  logs.sqlite
  goals.sqlite
  memories.sqlite
  memories/
    raw_memories.md
    rollout_summaries/
      ...
    memory_summary.md
    extensions/
      ad_hoc/
        notes/

注意这里有两类状态:

会话历史:
  sessions / archived_sessions 下的 Rollout JSONL

可查询状态和后台任务:
  四个 SQLite 文件
  memories 文件夹中的长期记忆产物

6. 新建线程的持久化调用链

新建线程时,调用链可以简化为:

App Server thread/start
  -> ThreadManager
  -> LiveThread::create
  -> ThreadStore::create_thread
  -> LocalThreadStore::create_thread
  -> RolloutRecorder::new(Create)
  -> 后台 writer task

其中 LiveThread::create 在:

codex-rs/thread-store/src/live_thread.rs

核心结构是:

pub struct LiveThread {
    thread_id: ThreadId,
    history_mode: ThreadHistoryMode,
    thread_store: Arc<dyn ThreadStore>,
    metadata_sync: Arc<Mutex<ThreadMetadataSync>>,
    persistence_telemetry: RolloutPersistenceTelemetry,
}

它做两件事:

1. 把 raw RolloutItem 交给 ThreadStore 写历史。
2. 用 ThreadMetadataSync 观察已持久化 item,生成 metadata patch。

这意味着 metadata 派生逻辑不藏在本地文件实现里,而是在 live 层统一处理。

7. RolloutRecorder::new 的两个模式

RolloutRecorderParams 有两个分支:

pub enum RolloutRecorderParams {
    Create { /* 新会话参数 */ },
    Resume { path: PathBuf },
}

源码位置:

codex-rs/rollout/src/recorder.rs

两个分支行为不同:

模式 行为
Create 预计算 Rollout path 和 SessionMeta,但不立刻创建文件
Resume 先把压缩 Rollout 物化为 .jsonl,再打开 append handle

新建会话的延迟物化很重要。

如果 session 初始化中途失败,就不应该留下空文件或半成品历史。

所以新会话先保留:

rollout_path
deferred_log_file_info
SessionMeta
pending_items

只有真正 persist()flush() 时才打开文件并写入。

8. Rollout 写入不是调用方同步写文件

RolloutRecorder 内部不是每次 append 都直接在调用方线程写文件。

它创建一个 bounded channel:

let (tx, rx) = mpsc::channel::<RolloutCmd>(256);

然后启动后台 writer task:

record_canonical_items()
  -> send RolloutCmd::AddItems
  -> writer task append pending_items
  -> materialized 时异步 flush

命令类型是:

enum RolloutCmd {
    AddItems(Vec<RolloutItem>),
    Persist { ack: oneshot::Sender<std::io::Result<()>> },
    Flush { ack: oneshot::Sender<std::io::Result<()>> },
    Shutdown { ack: oneshot::Sender<std::io::Result<()>> },
}

这里有两个并发边界:

bounded mpsc
  -> 防止无限内存增长

oneshot ack
  -> 给 persist / flush / shutdown 提供持久化屏障

9. writer task 的状态机

后台 writer 拥有:

struct RolloutWriterState {
    writer: Option<JsonlWriter>,
    deferred_log_file_info: Option<LogFileInfo>,
    pending_items: Vec<RolloutItem>,
    meta: Option<SessionMeta>,
    cwd: PathBuf,
    rollout_path: PathBuf,
    last_logged_error: Option<String>,
}

这个结构对应一台小状态机:

Deferred
  writer = None
  deferred_log_file_info = Some
  meta = Some
        |
        | persist / flush / shutdown with pending items
        v
Materialized
  writer = Some(JsonlWriter)
  deferred_log_file_info = None
  meta 已写入或待写入
        |
        | I/O failure
        v
Recovery
  writer = None
  pending_items 保留未写后缀
        |
        | 下一个 barrier retry
        v
Materialized

关键点:

pending_items 只有成功写入后才 drain。
I/O 失败会丢弃 writer handle,但不会丢未写 item。
persist / flush / shutdown 会尝试 reopen 并 retry 一次。

这使得 Rollout 写入对临时 I/O 错误具有可重试性。

10. 为什么 LiveThread 追加后还要 flush

LocalThreadStore::append_items 在:

codex-rs/thread-store/src/local/live_writer.rs

关键逻辑:

let persisted_items =
    persisted_rollout_items(params.items.as_slice(), ThreadHistoryMode::Legacy);

recorder.record_canonical_items(persisted_items.as_slice()).await?;
recorder.flush().await?;

注释说明了目的:

LiveThread applies metadata immediately after append_items returns.
Wait for the local writer so SQLite never gets ahead of JSONL.

也就是说:

JSONL 先落地。
SQLite metadata 再更新。

这是本地存储的一条关键一致性约束。

11. Rollout 记录哪些 item

写入前会经过持久化策略过滤:

codex-rs/rollout/src/policy.rs

本篇不展开每个 item 的过滤规则,但要记住一件事:

LiveThread 看到 raw_items。
Rollout 文件只保存 persisted_rollout_items。
ThreadMetadataSync 也基于已持久化 item 观察 metadata。

这避免了 SQLite 根据“不进入历史的 transient item”更新可恢复状态。

12. SessionMeta 是 Rollout 的根元数据

新建会话时,RolloutRecorder::new(Create) 会构造 SessionMeta

里面包含:

session_id
thread id
forked_from_id
parent_thread_id
timestamp
cwd
originator
cli_version
agent_nickname / agent_role / agent_path
source
thread_source
model_provider
base_instructions
dynamic_tools
selected_capability_roots
memory_mode
history_mode
context_window

恢复或 backfill 时,metadata.rs 会优先从第一条 SessionMeta 构建 ThreadMetadataBuilder

pub fn builder_from_items(
    items: &[RolloutItem],
    rollout_path: &Path,
) -> Option<ThreadMetadataBuilder>

如果没有 SessionMeta,才回退到文件名里的 timestamp 和 UUID。

这保证旧 Rollout 仍可被索引。

13. 恢复会话时如何重建历史

恢复路径的核心函数是:

pub async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory>

内部调用:

pub async fn load_rollout_items(
    path: &Path,
) -> std::io::Result<(Vec<RolloutItem>, Option<ThreadId>, usize)>

恢复流程:

open_rollout_line_reader(path)
  -> 逐行读取 JSONL
  -> serde_json 解析 RolloutLine
  -> 保留合法 RolloutItem
  -> 记录第一条 SessionMeta 中的 thread_id
  -> 跳过 legacy ghost_snapshot
  -> 返回 InitialHistory::Resumed

如果某一行解析失败:

parse_errors += 1
继续读取后续行

这说明恢复路径对局部坏行有容错,但空文件会报错:

empty session file

14. 压缩 Rollout 的读写规则

冷 Rollout 可以压缩为:

rollout-....jsonl.zst

相关源码:

codex-rs/rollout/src/compression.rs

读取路径使用:

pub async fn open_rollout_line_reader(path: &Path) -> io::Result<RolloutLineReader>

它能透明打开:

.jsonl
.jsonl.zst

如果读取时刚好遇到文件在 plain / compressed 表示之间切换,会短暂重试。

追加路径则不同。

追加必须先物化回 plain .jsonl

pub(crate) async fn materialize_rollout_for_append(path: &Path) -> io::Result<PathBuf>

原因很简单:

JSONL 需要 append。
zstd 压缩文件不能直接以 JSONL 追加语义写入。

15. 压缩 worker 的安全边界

压缩 worker 由:

pub fn spawn_rollout_compression_worker(codex_home: PathBuf)

启动。

ThreadManager 在创建本地 Thread Store 时会触发它。

worker 的特点:

best-effort
fire-and-forget
失败只记录日志
不阻塞启动
使用 run marker 避免重叠或过于频繁执行
限制最大运行时间
限制并发压缩数

源码里可以看到:

RUN_MARKER_FILE_NAME = "rollout-compression.lock"
MAX_CONCURRENT_COMPRESSION_JOBS = 2
MIN_ROLLOUT_AGE = 7 days

所以压缩是空间优化,不是恢复正确性的前置条件。

16. SQLite 运行时打开四个数据库

StateRuntime 在:

codex-rs/state/src/runtime.rs

结构如下:

pub struct StateRuntime {
    codex_home: PathBuf,
    default_provider: String,
    pool: Arc<sqlx::SqlitePool>,
    logs_pool: Arc<sqlx::SqlitePool>,
    thread_goals: GoalStore,
    memories: MemoryStore,
    thread_updated_at_millis: Arc<AtomicI64>,
    thread_recency_at_millis: Arc<AtomicI64>,
}

它打开四个 DB:

state.sqlite
logs.sqlite
goals.sqlite
memories.sqlite

源码中对应四个 spec:

STATE_DB
LOGS_DB
GOALS_DB
MEMORIES_DB

17. SQLite 的打开参数

所有 runtime DB 共用基础参数:

SqliteConnectOptions::new()
    .filename(path)
    .create_if_missing(true)
    .journal_mode(SqliteJournalMode::Wal)
    .synchronous(SqliteSynchronous::Normal)
    .busy_timeout(Duration::from_secs(5))
    .log_statements(LevelFilter::Off)

pool 参数:

SqlitePoolOptions::new()
    .max_connections(5)
    .connect_with(options)

这几个选择对应的工程含义:

参数 意义
WAL 减少读写互相阻塞
synchronous Normal 在本地状态库上平衡性能和可靠性
busy timeout 5s 避免短暂 writer 竞争直接失败
max connections 5 支撑并发查询,但控制 SQLite 连接数量
incremental auto-vacuum 新 DB 支持渐进回收,避免启动时 full vacuum

18. 四组迁移为什么 ignore_missing = true

迁移集合在:

codex-rs/state/src/migrations.rs

四组 migrator:

pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations");
pub(crate) static LOGS_MIGRATOR: Migrator = sqlx::migrate!("./logs_migrations");
pub(crate) static GOALS_MIGRATOR: Migrator = sqlx::migrate!("./goals_migrations");
pub(crate) static MEMORIES_MIGRATOR: Migrator = sqlx::migrate!("./memory_migrations");

runtime migrator 会设置:

ignore_missing = true

这很重要。

如果新版本 Codex 已经迁移过数据库,旧版本二进制再打开时可能“不认识”较新的 migration。

ignore_missing = true 让旧二进制可以继续打开数据库,而不是因为未知迁移直接失败。

这是 CLI 工具常见的版本回滚兼容问题。

19. state.sqlite 保存什么

主状态库保存线程元数据和跨线程关系。

核心表是 threads

早期迁移的初始列包括:

CREATE TABLE threads (
    id TEXT PRIMARY KEY,
    rollout_path TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    source TEXT NOT NULL,
    model_provider TEXT NOT NULL,
    cwd TEXT NOT NULL,
    title TEXT NOT NULL,
    sandbox_policy TEXT NOT NULL,
    approval_mode TEXT NOT NULL,
    tokens_used INTEGER NOT NULL DEFAULT 0,
    has_user_event INTEGER NOT NULL DEFAULT 0,
    archived INTEGER NOT NULL DEFAULT 0,
    archived_at INTEGER,
    git_sha TEXT,
    git_branch TEXT,
    git_origin_url TEXT
);

后续迁移不断补充:

cli_version
first_user_message
agent_nickname / agent_role / agent_path
model / reasoning_effort
preview
recency_at
history_mode
thread_source
memory_mode
毫秒级 created_at_ms / updated_at_ms / recency_at_ms

当前运行时代码读取的是最新模型:

pub struct ThreadMetadata {
    pub id: ThreadId,
    pub rollout_path: PathBuf,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub recency_at: DateTime<Utc>,
    pub source: String,
    pub history_mode: ThreadHistoryMode,
    pub thread_source: Option<ThreadSource>,
    pub model_provider: String,
    pub model: Option<String>,
    pub cwd: PathBuf,
    pub title: String,
    pub preview: Option<String>,
    pub tokens_used: i64,
}

完整结构在:

codex-rs/state/src/model/thread_metadata.rs

20. logs.sqlite 保存什么

日志库表来自:

codex-rs/state/logs_migrations/0001_logs.sql

核心列:

CREATE TABLE logs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts INTEGER NOT NULL,
    ts_nanos INTEGER NOT NULL,
    level TEXT NOT NULL,
    target TEXT NOT NULL,
    message TEXT,
    module_path TEXT,
    file TEXT,
    line INTEGER,
    thread_id TEXT,
    process_uuid TEXT,
    estimated_bytes INTEGER NOT NULL DEFAULT 0
);

后续迁移增加 feedback_log_body

日志库独立出来的目的:

降低日志写入和线程状态写入之间的锁竞争。

StateRuntime::insert_logs 在同一个事务中:

插入日志
计算 estimated_bytes
按 partition 裁剪
commit

21. 日志容量不是全局一个上限

日志容量控制在:

codex-rs/state/src/runtime/logs.rs

代码注释把 partition 定义得很清楚:

Thread logs:
  thread_id IS NOT NULL
  每个 thread_id 一个 partition

Threadless process logs:
  thread_id IS NULL
  每个 process_uuid 一个 partition

Threadless null process logs:
  thread_id IS NULL AND process_uuid IS NULL
  单独一个 partition

每个 partition 保留:

约 10 MiB
最多 1000 行

启动维护还会删除 10 天前日志,并执行:

PRAGMA wal_checkpoint(PASSIVE)

这里选择 PASSIVE 是为了不阻塞前台读写。

22. goals.sqlite 保存什么

目标库表在:

codex-rs/state/goals_migrations/0001_thread_goals.sql

表结构:

CREATE TABLE thread_goals (
    thread_id TEXT PRIMARY KEY NOT NULL,
    goal_id TEXT NOT NULL,
    objective TEXT NOT NULL,
    status TEXT NOT NULL CHECK(status IN (
        'active',
        'paused',
        'blocked',
        'usage_limited',
        'budget_limited',
        'complete'
    )),
    token_budget INTEGER,
    tokens_used INTEGER NOT NULL DEFAULT 0,
    time_used_seconds INTEGER NOT NULL DEFAULT 0,
    created_at_ms INTEGER NOT NULL,
    updated_at_ms INTEGER NOT NULL
);

一个 thread 当前最多一个 goal。

状态枚举是:

active
paused
blocked
usage_limited
budget_limited
complete

23. Goal 状态机在哪里实现

状态库层在:

codex-rs/state/src/runtime/goals.rs

主要 API:

pub async fn get_thread_goal(&self, thread_id: ThreadId)
pub async fn replace_thread_goal(...)
pub async fn insert_thread_goal(...)
pub async fn update_thread_goal(...)
pub async fn account_thread_goal_usage(...)
pub async fn delete_thread_goal(...)

状态机要点:

create:
  没有未完成 goal 时创建 active

update:
  可更新 objective / status / token_budget
  支持 expected_goal_id 防止更新错旧 goal

account:
  增加 tokens_used 和 time_used_seconds
  active 且 tokens_used >= token_budget 时转 budget_limited

delete:
  清除当前 thread goal

insert_thread_goal 还有一个约束:

如果已有未完成 goal,则不能创建新 goal。
只有旧 goal complete 时才允许替换。

24. Goal 扩展如何接入运行时

Goal 扩展在:

codex-rs/ext/goal/src/extension.rs

它注册了多类 contributor:

ThreadLifecycleContributor
ConfigContributor
TurnLifecycleContributor
TokenUsageContributor
ToolLifecycleContributor
ToolContributor

这说明 goal 不是一个单独工具那么简单。

它参与:

thread start / resume / idle / stop
turn start / stop / abort / error
token usage accounting
tool finish accounting
goal tools 暴露

25. GoalRuntimeHandle 的并发控制

GoalRuntimeHandle 在:

codex-rs/ext/goal/src/runtime.rs

内部有:

goal_state_lock: Semaphore,

初始化为:

goal_state_lock: Semaphore::new(1)

它保护这些竞争窗口:

外部 create/update/clear goal
turn stop 时核算 active goal
idle continuation 自动启动下一轮
usage limit / turn error 终止 goal

比如外部目标变更前会先:

prepare_external_goal_mutation()

这会先把当前 active goal 进度核算掉,再进行状态写入。

否则可能出现:

旧 goal 的 token/time 被算到新 goal 上

26. Goal 工具只允许模型做有限状态变更

Goal 工具在:

codex-rs/ext/goal/src/tool.rs

三个工具:

get_goal
create_goal
update_goal

update_goal 对模型有限制:

只能把现有 goal 标记为 complete 或 blocked。
pause / resume / budget_limited / usage_limited 由用户或系统控制。

这是安全边界:

模型不能自行伪造 usage limit。
模型不能自行恢复用户暂停的目标。
模型不能绕过系统预算状态。

27. memories.sqlite 保存什么

记忆库表在:

codex-rs/state/memory_migrations/0001_memories.sql

两张核心表:

CREATE TABLE stage1_outputs (
    thread_id TEXT PRIMARY KEY,
    source_updated_at INTEGER NOT NULL,
    raw_memory TEXT NOT NULL,
    rollout_summary TEXT NOT NULL,
    rollout_slug TEXT,
    generated_at INTEGER NOT NULL,
    usage_count INTEGER,
    last_usage INTEGER,
    selected_for_phase2 INTEGER NOT NULL DEFAULT 0,
    selected_for_phase2_source_updated_at INTEGER
);
CREATE TABLE jobs (
    kind TEXT NOT NULL,
    job_key TEXT NOT NULL,
    status TEXT NOT NULL,
    worker_id TEXT,
    ownership_token TEXT,
    started_at INTEGER,
    finished_at INTEGER,
    lease_until INTEGER,
    retry_at INTEGER,
    retry_remaining INTEGER NOT NULL,
    last_error TEXT,
    input_watermark INTEGER,
    last_success_watermark INTEGER,
    PRIMARY KEY (kind, job_key)
);

这里有一个设计重点:

stage1_outputs 保存已抽取的单线程记忆产物。
jobs 同时保存 Stage 1 和 Phase 2 的任务状态。

28. MemoryStore 跨两个数据库工作

MemoryStore 在:

codex-rs/state/src/runtime/memories.rs

结构:

pub struct MemoryStore {
    pool: Arc<SqlitePool>,
    state_pool: Arc<SqlitePool>,
}

它同时拿到:

memories.sqlite pool
state.sqlite pool

原因是:

memory job 状态在 memories.sqlite。
线程元数据、cwd、rollout_path、memory_mode、history_mode 在 state.sqlite。

例如 Stage 1 startup job 选择会从 threads 表找候选线程,再到 stage1_outputs / jobs 判断是否需要更新。

29. Memory pipeline 什么时候启动

启动入口在:

codex-rs/memories/write/src/start.rs

函数:

pub fn start_memories_startup_task(
    thread_manager: Arc<ThreadManager>,
    auth_manager: Arc<AuthManager>,
    thread_id: ThreadId,
    thread: Arc<CodexThread>,
    config: Arc<Config>,
    source: &SessionSource,
)

它会跳过:

ephemeral session
MemoryTool feature disabled
subagent session
state db unavailable

符合条件后:

tokio::spawn
  -> 创建 ~/.codex/memories
  -> seed extension instructions
  -> prune old stage1 outputs
  -> rate limit check
  -> phase1::run
  -> phase2::run

所以 memory 写入 pipeline 是后台任务,不阻塞当前用户 Turn。

30. Stage 1 做什么

Stage 1 在:

codex-rs/memories/write/src/phase1.rs

严格步骤写在注释里:

1. claim eligible rollout jobs
2. build one stage-1 request context
3. run stage-1 extraction jobs in parallel
4. emit metrics and logs

它先调用:

claim_stage1_jobs_for_startup(...)

候选线程约束包括:

active threads
allowed sources
memory_mode = enabled
history_mode = legacy
不等于当前 thread
updated_at 在年龄窗口内
rollout 足够 idle
stage1 output 或成功 watermark 已过期

这说明当前 Stage 1 只处理 legacy 完整 Rollout。

31. Stage 1 的并发和 lease

认领逻辑在:

codex-rs/state/src/runtime/memories.rs

函数:

pub async fn try_claim_stage1_job(
    &self,
    thread_id: ThreadId,
    worker_id: ThreadId,
    source_updated_at: i64,
    lease_seconds: i64,
    max_running_jobs: usize,
) -> anyhow::Result<Stage1JobClaimOutcome>

它使用:

BEGIN IMMEDIATE

并检查:

全局 running job 数量未超过 max_running_jobs
现有 running lease 是否过期
retry_at 是否到期
retry_remaining 是否大于 0
source_updated_at 是否推进

成功时写入:

status = running
worker_id
ownership_token
lease_until
input_watermark

ownership token 是后续成功或失败提交的凭证。

32. Stage 1 模型输入如何构造

Stage 1 实际抽取时会:

RolloutRecorder::load_rollout_items(rollout_path)
  -> serialize_filtered_rollout_response_items
  -> build_stage_one_input_message
  -> stream_stage_one_prompt
  -> 解析 JSON schema 输出

输出结构:

struct StageOneOutput {
    pub(crate) raw_memory: String,
    pub(crate) rollout_summary: String,
    pub(crate) rollout_slug: Option<String>,
}

写入前会:

redact_secrets(raw_memory)
redact_secrets(rollout_summary)
redact_secrets(rollout_slug)

过滤规则也很重要:

developer message 不进入 memory extraction
用户消息里的 AGENTS.md instructions 片段不进入
用户消息里的 skill 片段不进入
InterAgentCommunication 会转成模型输入 item

这样避免把系统指令或技能正文错误固化成长期记忆。

33. Stage 1 成功、空输出和失败

Stage 1 成功后调用:

mark_stage1_job_succeeded(...)

它在一个事务中:

检查 ownership_token
jobs.status = done
last_success_watermark = input_watermark
upsert stage1_outputs
enqueue global consolidation

如果模型返回空记忆:

mark_stage1_job_succeeded_no_output(...)

它会:

jobs.status = done
删除旧 stage1_outputs
如果旧输出曾进入 Phase 2 baseline,则触发 global consolidation

失败时:

mark_stage1_job_failed(...)

它会:

status = error
lease_until = NULL
retry_remaining -= 1
retry_at = now + retry_delay
last_error = reason

34. Phase 2 做什么

Phase 2 在:

codex-rs/memories/write/src/phase2.rs

它是全局合并阶段。

严格步骤:

1. claim global Phase 2 lock
2. prepare memory workspace
3. build locked-down consolidation agent config
4. load DB-backed Phase 2 inputs
5. sync inputs into memory workspace
6. use git diff to detect changes
7. write workspace diff
8. spawn consolidation agent
9. heartbeat and completion handling
10. emit metrics

这里的“全局”很重要。

Stage 1 是多个线程各自抽取。

Phase 2 是把多个 Stage 1 输出合并成全局长期记忆。

35. Phase 2 的 singleton job

Phase 2 使用 jobs 表中的 singleton:

kind = memory_consolidate_global
job_key = global

认领函数:

pub async fn try_claim_global_phase2_job(
    &self,
    worker_id: ThreadId,
    lease_seconds: i64,
) -> anyhow::Result<Phase2JobClaimOutcome>

它会返回:

Claimed
SkippedRetryUnavailable
SkippedRunning
SkippedCooldown

成功后同样写入:

ownership_token
lease_until
started_at
input_watermark

运行期间通过:

heartbeat_global_phase2_job(...)

延长 lease。

如果 heartbeat 失败或 ownership 丢失,合并 Agent 会被视为失败。

36. Phase 2 的安全沙箱

Phase 2 会启动一个内部 consolidation agent。

它的 config 被强制改写:

cwd = ~/.codex/memories
ephemeral = true
generate_memories = false
use_memories = false
include_apps_instructions = false
mcp_servers = empty
approval_policy = Never
disable SpawnCsv / Collab / MemoryTool / Apps / Plugins
network_access = false
writable_roots = [memory root]

这条边界非常关键。

它防止:

记忆合并 Agent 递归生成记忆
记忆合并 Agent 再 spawn 其他 Agent
记忆合并 Agent 访问网络
记忆合并 Agent 写出 memories root

长期记忆系统必须比普通会话更保守。

37. memory 文件产物

文件写入在:

codex-rs/memories/write/src/storage.rs

主要产物:

~/.codex/memories/raw_memories.md
~/.codex/memories/rollout_summaries/*.md

raw_memories.md 内容来自 stage1_outputs.raw_memory

# Raw Memories

Merged stage-1 raw memories ...

## Thread `<thread_id>`
updated_at: ...
cwd: ...
rollout_path: ...
rollout_summary_file: ...

<raw memory>

每个 rollout summary 文件包含:

thread_id
updated_at
rollout_path
cwd
git_branch

<rollout_summary>

文件名由:

pub fn rollout_summary_file_stem(memory: &Stage1Output) -> String

生成,包含时间片段、短 hash 和可选 slug。

38. memory read path 与 write pipeline 是分离的

读路径 crate:

codex-rs/memories/read/src/lib.rs

只定义:

pub fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf {
    codex_home.join("memories")
}

并拥有 citation、usage、metrics 等读相关模块。

工具扩展在:

codex-rs/ext/memories/src/extension.rs

它会在配置启用时:

注入 memory tool developer instructions
按 dedicated_tools 开关暴露 dedicated memory tools

工具包括:

add_ad_hoc_note
list
read
search

39. LocalMemoriesBackend 的路径安全

本地 memory backend 在:

codex-rs/ext/memories/src/local.rs

核心函数:

async fn resolve_scoped_path(
    &self,
    relative_path: Option<&str>,
) -> Result<PathBuf, MemoriesBackendError>

它拒绝:

ParentDir: ..
RootDir: /
Windows Prefix
hidden component
symlink traversal
非目录路径上的继续下钻

所以模型通过 memory tools 读取的是:

memories root 内的非隐藏普通文件/目录

而不是任意本地路径。

40. ad-hoc memory note 的写入约束

ad-hoc note 在:

codex-rs/ext/memories/src/local/ad_hoc_note.rs

目标目录:

extensions/ad_hoc/notes

文件名必须:

YYYY-MM-DDTHH-MM-SS-<slug>.md

约束包括:

最多 128 bytes
必须以 .md 结尾
slug 1 到 80 bytes
slug 只能是小写 ASCII、数字、hyphen
create_new(true),不覆盖已有文件

这也是长期记忆的一条安全边界:

追加人工记忆可以写文件,但不能覆盖任意已有记忆。

41. agent jobs 保存在哪里

Agent job 表仍在主 state.sqlite

迁移在:

codex-rs/state/migrations/0014_agent_jobs.sql

agent_jobs 保存作业级状态:

CREATE TABLE agent_jobs (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    status TEXT NOT NULL,
    instruction TEXT NOT NULL,
    output_schema_json TEXT,
    input_headers_json TEXT NOT NULL,
    input_csv_path TEXT NOT NULL,
    output_csv_path TEXT NOT NULL,
    auto_export INTEGER NOT NULL DEFAULT 1,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    started_at INTEGER,
    completed_at INTEGER,
    last_error TEXT
);

agent_job_items 保存每一行输入的状态:

CREATE TABLE agent_job_items (
    job_id TEXT NOT NULL,
    item_id TEXT NOT NULL,
    row_index INTEGER NOT NULL,
    source_id TEXT,
    row_json TEXT NOT NULL,
    status TEXT NOT NULL,
    assigned_thread_id TEXT,
    attempt_count INTEGER NOT NULL DEFAULT 0,
    result_json TEXT,
    last_error TEXT,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    completed_at INTEGER,
    reported_at INTEGER,
    PRIMARY KEY (job_id, item_id)
);

运行时代码在:

codex-rs/state/src/runtime/agent_jobs.rs

42. agent job item 状态机

主要状态变更:

job:
  pending -> running -> completed
  pending/running -> failed
  pending/running -> cancelled

item:
  pending -> running
  running -> pending
  running -> completed
  running -> failed

重要约束:

mark_agent_job_item_running_with_thread
  只有 pending item 可转 running
  写入 assigned_thread_id

report_agent_job_item_result
  要求 status = running
  要求 assigned_thread_id = reporting_thread_id
  成功后 status = completed
  result_json 写入
  assigned_thread_id 清空

这样可以拒绝 late report:

如果 item 已经 failed 或 assigned_thread_id 不匹配,报告不会被接受。

43. spawn edges 保存在哪里

父子 Agent 关系保存在:

codex-rs/state/migrations/0021_thread_spawn_edges.sql

表:

CREATE TABLE thread_spawn_edges (
    parent_thread_id TEXT NOT NULL,
    child_thread_id TEXT NOT NULL PRIMARY KEY,
    status TEXT NOT NULL
);

索引:

CREATE INDEX idx_thread_spawn_edges_parent_status
    ON thread_spawn_edges(parent_thread_id, status);

运行时代码在:

codex-rs/state/src/runtime/threads.rs

相关 API:

pub async fn upsert_thread_spawn_edge(...)
pub async fn set_thread_spawn_edge_status(...)
pub async fn list_thread_spawn_children(...)
pub async fn list_thread_spawn_descendants(...)
pub async fn find_thread_spawn_child_by_path(...)
pub async fn find_thread_spawn_descendant_by_path(...)

descendants 查询用 SQLite recursive CTE。

这就是多 Agent 树能被快速列出的原因。

44. metadata 从哪些 Rollout item 派生

派生逻辑主要在:

codex-rs/state/src/extract.rs
codex-rs/thread-store/src/thread_metadata_sync.rs

会影响 metadata 的 item 包括:

SessionMeta
TurnContext
EventMsg::UserMessage
EventMsg::ItemCompleted(UserMessage)
EventMsg::TokenCount
EventMsg::ThreadGoalUpdated
EventMsg::TurnStarted

影响内容:

item 更新内容
SessionMeta created_at、source、agent 信息、cwd、git、memory_mode
TurnContext cwd、model、reasoning_effort、approval、sandbox
UserMessage title、preview、first_user_message
TokenCount tokens_used
ThreadGoalUpdated preview 可回填为 goal objective
TurnStarted recency_at

ThreadMetadataSync 还会把纯 touch 更新做节流:

THREAD_UPDATED_AT_TOUCH_INTERVAL = 5 seconds

避免高频无意义 SQLite 写入。

45. startup backfill 如何修复历史索引

backfill 入口在:

codex-rs/rollout/src/metadata.rs

函数:

pub(crate) async fn backfill_sessions(
    runtime: &codex_state::StateRuntime,
    codex_home: &Path,
    default_provider: &str,
)

流程:

读取 backfill_state
如果 complete,直接返回
try_claim_backfill
扫描 sessions 和 archived_sessions
收集 .jsonl / .jsonl.zst
按 watermark 排序
跳过 last_watermark 之前的文件
分批 extract_metadata_from_rollout
upsert_thread
checkpoint_backfill
mark_backfill_complete

backfill_state 是 singleton row。

状态:

pending
running
complete

claim 使用 lease,避免多个进程重复做全量扫描。

46. extract_metadata_from_rollout 做什么

函数:

pub async fn extract_metadata_from_rollout(
    rollout_path: &Path,
    default_provider: &str,
) -> anyhow::Result<ExtractionOutcome>

流程:

RolloutRecorder::load_rollout_items
  -> builder_from_items
  -> builder.build(default_provider)
  -> apply_rollout_item for each item
  -> 用文件 mtime 修正 updated_at / recency_at
  -> 提取最后一次 SessionMeta.memory_mode

backfill 还会处理 archived:

如果文件来自 archived_sessions 且 metadata.archived_at 为空,
用文件 mtime 或 updated_at 作为 archived_at。

这让旧文件即使缺少完整 metadata,也能进入当前 SQLite 索引。

47. read-repair 如何修复单条索引

除了 startup backfill,读取和列表也会触发 read-repair。

桥接函数在:

codex-rs/rollout/src/state_db.rs

常见函数:

pub async fn reconcile_rollout(...)
pub async fn read_repair_rollout_path(...)
pub async fn apply_rollout_items(...)
pub async fn find_rollout_path_by_id(...)

典型场景:

SQLite 中没有 row
SQLite rollout_path 不存在
SQLite row 的 archived 状态不一致
SQLite row 的 filter metadata 过期

此时读取路径会:

回退到文件系统查找 Rollout
从 Rollout 读取 metadata
回写 SQLite

48. list_threads 的 fallback 策略

线程列表路径跨越:

thread-store/src/local/list_threads.rs
rollout/src/recorder.rs
rollout/src/state_db.rs
state/src/runtime/threads.rs

简化流程:

LocalThreadStore::list_threads
  -> list_rollout_threads
  -> RolloutRecorder::list_threads
  -> list_threads_with_db_fallback

核心策略:

如果 use_state_db_only:
  只查 SQLite

如果普通列表:
  先文件系统扫描一页
  对扫描结果做 read-repair / reconcile
  再查 SQLite
  SQLite 成功则优先返回 SQLite page
  SQLite 失败则 fallback 到文件系统 page

对于带 metadata filter 的列表:

source / model_provider / cwd / search

会更保守地验证和修复。

关系过滤:

DirectChildrenOf
DescendantsOf

要求 state DB 可用,因为关系来自 thread_spawn_edges

49. read_thread 的 SQLite 优先和 Rollout fallback

读取单个线程在:

codex-rs/thread-store/src/local/read_thread.rs

流程:

read_sqlite_metadata
  -> 如果 include_history,则验证 SQLite rollout_path 可加载同一 thread
  -> stored_thread_from_sqlite_metadata
  -> attach_history_if_requested

如果 SQLite 不可用、metadata 缺失、path 不可信:

resolve_rollout_path
  -> live recorder path
  -> state_db find path
  -> filesystem find_thread_path_by_id_str
  -> archived fallback

然后:

read_thread_from_rollout_path
  -> read_thread_item_from_rollout
  -> read SessionMeta
  -> 补 forked_from_id / parent_thread_id / history_mode
  -> 如果请求 history,再 load_rollout_items

这就是“SQLite 可以坏,但 Rollout 仍能救”的具体实现。

50. 归档不是只改一个字段

归档源码:

codex-rs/thread-store/src/local/archive_thread.rs

行为:

找到当前 active Rollout
移动到 archived_sessions
更新 SQLite mark_archived

反归档源码:

codex-rs/thread-store/src/local/unarchive_thread.rs

行为:

找到 archived Rollout
移动回 sessions/YYYY/MM/DD
更新 SQLite mark_unarchived

所以 archived 状态有两个体现:

文件物理位置
SQLite archived / archived_at / rollout_path

读路径会同时考虑这两者,防止单边状态过期。

51. 删除为什么先删文件

本地删除在:

codex-rs/thread-store/src/local/delete_thread.rs

它会先删除:

active Rollout
archived Rollout
legacy thread name index

更深层的状态清理在:

codex-rs/state/src/runtime/threads.rs

函数:

pub async fn delete_threads_strict(&self, thread_ids: &[ThreadId]) -> anyhow::Result<u64>

顺序:

1. 删除 logs.sqlite 中 thread logs
2. 删除 memories.sqlite 中 thread memory 和 stage1 job
3. 删除 goals.sqlite 中 thread goal
4. 主 state.sqlite 事务中处理 agent jobs / dynamic tools
5. 删除 thread_spawn_edges
6. 最后删除 threads rows

源码注释说明:

Spawn edges and thread rows are deleted last so a failed delete can be retried.

这是典型的可重试删除设计。

52. 删除时如何处理 agent job

删除线程时,如果某个 agent job item 正在被该线程处理:

assigned_thread_id = deleted thread
status = running

系统会:

把 item 重新置为 pending
清空 assigned_thread_id
写入 last_error = "assigned thread was deleted"

如果 job runner thread 和 worker thread 都在本次删除集合中:

取消整个 job
last_error = "agent job runner thread was deleted"

这避免出现:

running job 没有 runner 消费
worker thread 已删除但 item 仍显示 running

53. SQLite 损坏恢复

恢复逻辑在:

codex-rs/state/src/runtime/recovery.rs
codex-rs/app-server/src/lib.rs

App Server 初始化会调用:

init_sqlite_state_db_with_fresh_start_on_corruption(...)

行为:

try_init
  -> 如果 SQLite corruption 或 sqlite_home 是阻塞文件
  -> 备份损坏 DB
  -> fresh start retry

备份函数:

pub async fn backup_runtime_db_for_fresh_start(
    db_path: &Path,
) -> std::io::Result<Vec<RuntimeDbBackup>>

只移动损坏的 DB 及 sidecars:

<db>.sqlite
<db>.sqlite-wal
<db>.sqlite-shm

到:

db-backups/

这避免因为一个 DB 损坏就丢掉全部状态。

而主 state.sqlite 的线程索引还能通过 Rollout backfill 重建。

54. ThreadMetadataSync 为什么不直接写 SQLite

ThreadMetadataSync 只观察并生成 patch。

它不持有 SQLite,也不写文件。

调用链是:

LiveThread::append_items(raw_items)
  -> ThreadStore::append_items(raw_items)
  -> LocalThreadStore 写 JSONL 并 flush
  -> ThreadMetadataSync::observe_appended_items(persisted_items)
  -> ThreadStore::update_thread_metadata(patch)

这样有三个好处:

1. store 实现只负责自己的存储能力。
2. metadata 派生逻辑在 live 层统一。
3. JSONL 先落地,SQLite 后更新的顺序更清晰。

55. update_thread_metadata 的双写兼容

本地 metadata 更新在:

codex-rs/thread-store/src/local/update_thread_metadata.rs

它主要写 SQLite。

但某些更新还会追加兼容性 SessionMeta 到 Rollout:

memory_mode 更新
部分兼容旧读取路径需要的信息

因此不能把它理解为“只改 SQLite”。

更准确地说:

显式 metadata patch 是控制面写入。
SQLite 是主写入目标。
必要时 Rollout 会追加兼容事实。

56. recency_atupdated_at 为什么都存在

updated_at 表示线程元数据或历史内容更新时间。

recency_at 用于“最近活跃”排序。

ThreadMetadataSync 在看到:

EventMsg::TurnStarted

时推进 recency_at

StateRuntime 里还有两个进程内 high-water mark:

thread_updated_at_millis: Arc<AtomicI64>,
thread_recency_at_millis: Arc<AtomicI64>,

它们用于在热写入时分配单调毫秒时间戳。

源码注释说明:

同一秒内的热写入会 bump 1ms,保证 cursor ordering。
历史 backfill / repair 的旧时间戳不会被强行推进。

这是列表分页正确性的细节。

57. Memory mode 如何在两层中流动

SessionMeta 中有:

memory_mode

新会话创建时:

memory_mode: (!config.generate_memories()).then_some("disabled".to_string())

如果生成 memories 被禁用,会在 Rollout 里记录 disabled

SQLite threads.memory_mode 默认是:

enabled

apply_rollout_items 和 backfill 会从 SessionMeta 中提取最后一次 memory mode 并更新 SQLite。

memory pipeline 又只选择:

threads.memory_mode = 'enabled'

的线程。

所以 memory mode 是:

Rollout 中可重放的事实
SQLite 中可查询的过滤条件

58. 当前源码中的数据库拆分演进

迁移列表中可以看到一个历史演进:

0002_logs.sql
0006_memories.sql
0029_thread_goals.sql

早期 logs、memories、goals 曾进入主 state DB。

后续又有:

0023_drop_logs.sql
0034_drop_thread_goals.sql
0035_drop_memory_tables.sql

当前运行时代码则明确打开独立:

logs.sqlite
goals.sqlite
memories.sqlite

教程分析时要以当前 runtime 为准,而不是被历史迁移中间态误导。

59. 为什么日志、目标、记忆要拆库

拆库的实际收益:

logs.sqlite:
  高频写入、容量裁剪、反馈日志查询

goals.sqlite:
  小而强一致的目标状态机

memories.sqlite:
  后台任务、lease、retry、stage1 output

state.sqlite:
  线程索引、spawn graph、agent jobs、dynamic tools

如果都放在一个 SQLite 文件里:

日志写入和线程列表会争写锁。
memory pipeline 的 BEGIN IMMEDIATE 会影响普通线程状态。
目标状态机的短事务会和批量日志裁剪竞争。

拆库不是为了概念整洁,而是为了减少不同写入模式之间的锁冲突。

60. 从 thread_start 到 memory startup 的关系

新线程启动后,如果满足条件,memory startup task 会启动。

但注意:

memory startup 不参与当前 Thread 的 Rollout 写入正确性。
memory startup 读取的是历史线程的 Rollout 和 SQLite metadata。

它的输入是:

state.sqlite 中可查询的 threads
sessions / archived_sessions 中可读取的 Rollout
memories.sqlite 中的 stage1_outputs / jobs

它的输出是:

memories.sqlite 的 stage1_outputs / jobs
~/.codex/memories 下的 markdown 产物

这说明 memory 是持久化系统的消费者和扩展,而不是主会话恢复的必需组件。

61. 状态机总表

把本篇涉及的状态机汇总:

子系统 状态 存储位置
Rollout writer deferred / materialized / recovery / shutdown 内存状态 + JSONL
Backfill pending / running / complete state.sqlite.backfill_state
Stage 1 memory job running / done / error / retry backoff memories.sqlite.jobs
Phase 2 memory job pending / running / done / error / cooldown memories.sqlite.jobs
Goal active / paused / blocked / usage_limited / budget_limited / complete goals.sqlite.thread_goals
Agent job pending / running / completed / failed / cancelled state.sqlite.agent_jobs
Agent job item pending / running / completed / failed state.sqlite.agent_job_items
Spawn edge open / status variants state.sqlite.thread_spawn_edges
Archive active / archived Rollout path + threads.archived

一个好的排障方法是先问:

这到底是事实日志问题,还是投影/任务状态问题?

如果是事实日志:

检查 Rollout JSONL。

如果是查询、列表、任务或目标:

检查对应 SQLite DB。

62. 典型恢复路径排障

codex resume 找不到会话时,可以按这个顺序排查:

1. Rollout 文件是否还在 sessions 或 archived_sessions。
2. 如果只有 .jsonl.zst,open_rollout_line_reader 是否能读。
3. SQLite threads.rollout_path 是否指向已不存在的路径。
4. find_thread_path_by_id_str 是否能从文件系统找到真实路径。
5. read_repair_rollout_path 是否被触发。
6. Rollout 第一条 SessionMeta 是否能解析 thread_id。
7. history_mode 是否是当前恢复路径支持的 legacy。

如果文件存在而列表不显示:

1. 检查 backfill_state 是否 complete。
2. 检查 threads.preview 是否为空。
3. 检查 archived 过滤是否匹配。
4. 检查 allowed_sources / model_providers / cwd_filters。
5. 对该 Rollout 触发 reconcile。

63. 典型 memory 排障路径

当长期记忆没有更新时:

1. 当前 session 是否 ephemeral。
2. MemoryTool feature 是否启用。
3. source 是否 root session,而不是 subagent。
4. state_db 是否可用。
5. threads.memory_mode 是否 enabled。
6. history_mode 是否 legacy。
7. Rollout updated_at 是否满足 age / idle 窗口。
8. memories.jobs 是否已有 running lease。
9. retry_at 是否还没到。
10. retry_remaining 是否耗尽。
11. Stage 1 是否写入 stage1_outputs。
12. Phase 2 global job 是否处于 cooldown / running。
13. ~/.codex/memories workspace 是否有 git diff。

如果 memory tool 读不到文件:

1. 路径是否在 memories root 内。
2. 是否包含隐藏 component。
3. 是否经过 symlink。
4. 文件是否 UTF-8。
5. line_offset 是否从 1 开始。
6. max_lines 是否为正数。

64. 典型 goal 排障路径

当 goal 状态看起来不对时:

1. goals feature 是否启用。
2. thread 是否有 persistent_thread_state_available。
3. review subagent 是否被禁止暴露工具。
4. goals.sqlite.thread_goals 当前 row 是什么。
5. current turn 是否已记录 token usage。
6. accounting_state 是否有当前 active goal。
7. expected_goal_id 是否阻止了过期更新。
8. token_budget 是否触发 budget_limited。
9. TurnError 是否把 active goal 置为 blocked。
10. UsageLimitExceeded 是否把 active goal 置为 usage_limited。

一个容易误解的点:

update_goal 不能让模型任意切换状态。
模型只能 complete 或 blocked。

用户和系统级状态不由模型工具直接控制。

65. 典型 logs 排障路径

当反馈日志缺失时:

1. 日志是否写入 logs.sqlite 而不是 state.sqlite。
2. thread_id 是否正确。
3. process_uuid 是否能关联 threadless logs。
4. feedback_log_body 是否为空。
5. partition 是否超过 10 MiB 或 1000 行导致裁剪。
6. 启动维护是否删除了 10 天前日志。
7. query_feedback_logs_for_threads 是否只返回最新进程关联日志。

日志系统不是无限审计库。

它是:

容量受控的本地诊断和反馈数据源。

66. 典型 agent job 排障路径

当 CSV spawn job 卡住时:

1. agent_jobs.status 是否 pending/running。
2. agent_job_items 是否还有 pending。
3. running item 的 assigned_thread_id 是否仍存在。
4. assigned thread 是否被删除后已 requeue。
5. job runner thread 是否也被删除导致 job cancelled。
6. late report 的 reporting_thread_id 是否匹配 assigned_thread_id。
7. item 是否已经 failed,导致 report 被拒绝。

这类问题主要看:

state.sqlite.agent_jobs
state.sqlite.agent_job_items
state.sqlite.thread_spawn_edges

67. 本篇最重要的调用链

写入链

Core Session
  -> LiveThread::append_items
  -> ThreadStore::append_items
  -> LocalThreadStore::append_items
  -> RolloutRecorder::record_canonical_items
  -> RolloutRecorder::flush
  -> ThreadMetadataSync::observe_appended_items
  -> ThreadStore::update_thread_metadata
  -> StateRuntime::apply_rollout_items / upsert_thread

恢复链

thread/resume
  -> LocalThreadStore::resume_thread
  -> resolve rollout_path
  -> RolloutRecorder::new(Resume)
  -> materialize_rollout_for_append
  -> RolloutRecorder::get_rollout_history
  -> load_rollout_items
  -> InitialHistory::Resumed

列表链

thread/list
  -> LocalThreadStore::list_threads
  -> RolloutRecorder::list_threads
  -> filesystem scan
  -> read_repair / reconcile
  -> StateRuntime::list_threads
  -> fallback if SQLite unavailable

Memory 链

start_memories_startup_task
  -> phase1::run
  -> claim_stage1_jobs_for_startup
  -> RolloutRecorder::load_rollout_items
  -> stage-one model extraction
  -> mark_stage1_job_succeeded
  -> enqueue_global_consolidation
  -> phase2::run
  -> get_phase2_input_selection
  -> sync files under ~/.codex/memories
  -> spawn consolidation agent
  -> mark_global_phase2_job_succeeded

Goal 链

GoalToolExecutor / GoalService
  -> GoalStore
  -> goals.sqlite.thread_goals
  -> GoalRuntimeHandle runtime effects
  -> GoalEventEmitter
  -> EventMsg::ThreadGoalUpdated
  -> Rollout / metadata preview

68. 这套设计的工程取舍

Codex 没有把所有东西塞进一个 SQLite,也没有只依赖 JSONL。

它把持久化分成三类:

事实日志:
  Rollout JSONL

可查询投影:
  threads / spawn edges / agent jobs

后台任务和运行状态:
  logs / goals / memories / jobs

收益:

恢复可以依赖可重放历史。
列表可以依赖索引。
索引损坏可以 backfill / read-repair。
日志、目标、记忆任务不会互相抢同一个 SQLite 文件锁。
后台任务有 lease / retry / ownership token。

代价:

需要双写顺序约束。
需要处理 JSONL 和 SQLite 不一致。
需要 backfill 和 read-repair。
需要在删除、归档、反归档时同时处理文件和状态。
需要明确哪些状态是事实,哪些只是投影。

这也是大型 CLI 项目从“保存一个历史文件”演进到“本地状态平台”的典型路径。

69. 实践任务一:跟踪一次新线程写入

从这些文件开始:

codex-rs/thread-store/src/live_thread.rs
codex-rs/thread-store/src/local/live_writer.rs
codex-rs/rollout/src/recorder.rs
codex-rs/thread-store/src/thread_metadata_sync.rs
codex-rs/state/src/runtime/threads.rs

任务:

1. 找到 LiveThread::append_items。
2. 找到 LocalThreadStore::append_items。
3. 确认 JSONL flush 发生在 metadata update 之前。
4. 找到 ThreadMetadataSync 观察 UserMessage 的逻辑。
5. 找到 StateRuntime 写 title / preview 的路径。

完成后,你应该能回答:

为什么 SQLite 不会领先于已接受的本地 JSONL append?

70. 实践任务二:手工重建线程索引思路

不要修改代码,只阅读:

codex-rs/rollout/src/metadata.rs
codex-rs/state/src/runtime/backfill.rs
codex-rs/state/src/runtime/threads.rs

任务:

1. 找到 backfill_sessions。
2. 找到 collect_rollout_paths。
3. 找到 extract_metadata_from_rollout。
4. 找到 apply_rollout_item。
5. 找到 upsert_thread。
6. 找到 checkpoint_backfill。

用自己的话写出:

如果 state.sqlite 被删除,哪些信息可以从 Rollout 恢复?
哪些信息无法完全恢复?

提示:

git 信息会尽量保留已有 SQLite 值。
显式 title 和某些运行态任务不一定能从 Rollout 完整重建。

71. 实践任务三:分析一次 memory Stage 1

阅读:

codex-rs/memories/write/src/start.rs
codex-rs/memories/write/src/phase1.rs
codex-rs/state/src/runtime/memories.rs
codex-rs/memories/write/src/storage.rs

任务:

1. 列出 startup task 的跳过条件。
2. 列出 claim_stage1_jobs_for_startup 的候选过滤条件。
3. 找到 try_claim_stage1_job 的 lease 和 retry 判断。
4. 找到 serialize_filtered_rollout_response_items。
5. 说明为什么 developer message 和 skill 片段不进入记忆抽取。
6. 找到 mark_stage1_job_succeeded 写入哪些表。

完成后,你应该能解释:

长期记忆为什么不是每个 Turn 结束后同步生成?

72. 实践任务四:验证 Goal 预算状态机

阅读:

codex-rs/state/src/runtime/goals.rs
codex-rs/ext/goal/src/extension.rs
codex-rs/ext/goal/src/runtime.rs
codex-rs/ext/goal/src/tool.rs

任务:

1. 找到 account_thread_goal_usage。
2. 找到 token_budget 触发 budget_limited 的 SQL CASE。
3. 找到 update_goal 只允许 complete / blocked 的校验。
4. 找到 TurnError 和 UsageLimitExceeded 的处理。
5. 找到 goal_state_lock 的使用场景。

完成后,你应该能说明:

为什么 goal 状态机既在 state DB 层实现,也需要 runtime 层的并发保护?

73. 实践任务五:追一次删除级联

阅读:

codex-rs/thread-store/src/local/delete_thread.rs
codex-rs/state/src/runtime/threads.rs
codex-rs/state/src/runtime/memories.rs
codex-rs/state/src/runtime/goals.rs
codex-rs/state/src/runtime/agent_jobs.rs

任务:

1. 找到本地 Rollout 文件删除。
2. 找到 delete_threads_strict。
3. 按顺序列出 logs、memories、goals、agent jobs、spawn edges、threads 的清理。
4. 解释为什么 thread_spawn_edges 和 threads rows 最后删除。
5. 分析 assigned_thread_id 被删除时 agent_job_items 如何处理。

完成后,你应该能回答:

为什么删除不是简单的 DELETE FROM threads?

74. 本篇小结

Codex 的持久化系统可以浓缩成一句话:

Rollout JSONL 保存可重放事实,SQLite 保存可查询投影和后台任务状态。

围绕这句话,源码形成了几条关键边界:

LiveThread / ThreadStore 隔离 Core 和存储实现。
RolloutRecorder 用后台 writer、bounded queue 和 barrier 保证有序持久化。
LocalThreadStore 在 JSONL flush 后再推进 SQLite metadata。
StateRuntime 拆成 state / logs / goals / memories 四个 SQLite 文件。
Backfill 和 read-repair 让 SQLite 投影可以从 Rollout 修复。
Archive / unarchive 同时移动文件和更新 metadata。
Delete 先清关联状态,最后删 spawn edges 和 threads,保证可重试。
Memory pipeline 用 lease、ownership token、Phase 2 global lock 和沙箱 Agent 生成长期记忆。
Goal runtime 用 SQLite 状态机和 Semaphore 防止目标变更与进度核算交错。
Logs DB 用 partition cap 控制本地诊断数据规模。
Agent jobs 和 spawn edges 把多 Agent 批处理和父子关系变成可查询状态。

这套系统的核心不是“多存几份数据”,而是把不同性质的数据放在合适的位置:

顺序事实放 JSONL。
查询索引放 SQLite。
后台任务放 SQLite lease 表。
长期记忆产物放 memories workspace。

理解这套持久化层之后,Codex 的恢复、列表、归档、多 Agent、目标追踪和长期记忆就能串成同一个工程模型。

下一篇将分析测试系统与调试方法,解释 Codex 如何用 Rust、TypeScript、Python 测试覆盖 CLI、App Server、SDK 和核心会话行为。

更多推荐