01

单轮:打通执行引擎

控制台发问后,谁真正调用了模型?从 stdio 到 turn/completed,逐层拆解一次请求的完整生命周期。

全景:一次问答背后的五层结构

打出命令到模型返回结果,中间经过五个清晰可辨的层次。先看整体,再逐层钻入。

点击上方组件查看职责说明
📌

关键认知

Node.js 不直接调用模型 HTTP 接口。它是编排客户端,Codex 才是真正与远端模型通信的执行引擎。两者通过 stdio 连接。

邮局比喻:initialize → initialized

把这套通信想象成一个邮局系统:Node 是寄件人,Codex 是邮局,模型服务是收件目的地。寄信前必须先"在柜台登记开户"——这就是协议握手。

1
spawn 启动子进程
Node 调用 child_process.spawn,把 Codex App Server 作为子进程拉起。此刻"邮局开门",但还没有任何账户。
2
发送 initialize 请求
Node 通过 JSON-RPC 2.0 发送 initialize 方法。Codex 返回初始化响应,客户端等待这一步成功。
3
发送 initialized 通知
Node 再发一条单向通知 initialized(无需回复),告知 Codex"我已收到你的初始化响应,正式开始"。握手完成。
为什么要两步握手?
initialize 返回初始化结果;initialized 是客户端的确认回执。两步保证双方同步进入"就绪"状态,避免客户端在服务端还未准备好时就发业务请求。
为什么本地不需要 HTTP 端口
Node 与本机 Codex 通过进程管道通信;握手完成后,thread/turn 请求沿用同一连接。远端模型调用仍然需要网络。

Thread 与 Turn:往来档案与一次寄送

Thread 是一个往来档案,Turn 是其中一次寄送。单轮 Demo 只新建一个档案、完成一次寄送,但架构已经为多轮做好了准备。

A
thread/start → 拿到 threadId,注入基础指令
↓
B
先注册事件监听(暂存本 Thread 的通知),再发 turn/start
↓
C
RPC 返回 turnId,把缓存里匹配该 turnId 的通知重放
↓
D
等待 turn/completed,提取回答文本
↓
E
finally 块负责释放连接与子进程,失败也要清理
⚠️

事件先到缓存——关键时序细节

turn/start 触发的事件通知可能在 RPC 响应(含 turnId)返回之前就到达 stdio。代码先建立监听缓冲区,收到 turnId 后再回放已缓存的匹配事件,防止漏掉早到的通知。

turnId 不是递增数字
Turn 标识符由 Codex 服务端生成,是唯一字符串而非 1、2、3 这样的计数器。匹配时必须用完整 ID 对比,不能用序号推算。
finally 的责任
无论成功还是异常,finally 块都必须关闭子进程。否则 Codex 进程会在后台残留,占用资源并可能干扰下次启动。

真实代码:runTurn 的核心逻辑

以下是 s01 源码中 runTurn 函数的实际片段,展示先分配本轮状态、再发送请求的顺序。

源码 · 05-app-server.mjs · 第 155–164 行
    if (!threadId) await startThread();
    const turn = { threadId, id: null, answers: new Map(), buffer: [], completed: Promise.withResolvers() };
    turn.completed.promise.catch(() => {});
    activeTurn = turn;
    const timer = setTimeout(() => fail(new Error("App Server 本轮调用超时;已停止连接,实际执行状态需检查日志,不自动重发。")), launch.timeoutMs);
    console.log("正在等待模型回复……");
    try {
      const result = await request("turn/start", {
        threadId, input: [{ type: "text", text: prompt, text_elements: [] }]
      });
逐行解读

turn 对象:在内存中为本轮预分配状态槽——answers 存模型回答,buffer 缓存早到的通知,completed 是一个手动控制的 Promise。

activeTurn = turn:把当前轮次注册为"活跃轮次",后续收到的通知事件都会先检查是否属于这里。

setTimeout + fail:超时保护。如果模型长时间不回复,主动断开并报错,不无限等待。

request("turn/start", ...):通过 JSON-RPC 发起本轮任务,prompt 是用户输入文本。

📂

路径说明

完整文件:stages/s01-single-turn/05-app-server.mjs,第 145–173 行。runtime.mjs 负责准备 .runs 运行目录,05-app-server.mjs 管理通信生命周期。

组件群聊:一次请求的接力棒

以下对话模拟各组件之间的协作过程,属于教学动画,非真实系统通信。

0 / 10

版本边界、实践命令与知识测验

🔖 版本变化说明
s01 是演化序列起点,建立了"主进程编排 + Codex 子进程执行"的解耦结构。
s01 → s02:增加 FIFO 输入缓冲与 /resume 会话恢复,Thread 可跨启动复用。
s03+:引入 HTTP 服务层与配置驱动 Agent,不再是纯 CLI 模式。
本系列模型调用要求 Responses API 规范,基础 chat/completions 接口无法直接适配。Responses API 是执行引擎对远端服务的要求。
⌨️ 实践命令(s01 CLI)
cd stages/s01-single-turn && npm ci
test -f .env || cp .env.example .env(填入 API Key/URL/Model)
npm run check(仅校验本地配置,不发网络请求)
npm start(默认提示词)
npm start -- "你的问题"(自定义输入)
npm test(离线模拟测试)
→ 打开 s01 命令与教学模拟
🚧 边界说明
s01/s02 无独立 HTTP 服务,不提供网页连接入口,实践即 CLI 命令。
回答采用整段聚合输出,不支持逐 token 流式打印。
features.memories=false:关闭跨会话记忆,但本次会话日志仍可持久化。
工作目录仅做应用层路径过滤,非操作系统级沙箱。

你删除了请求前的缓冲逻辑,偶尔明明有完成通知却一直等待。哪项解释能定位问题?

配置校验通过,但远端模型连接失败。沿着实际调用链,你应检查哪一层发出的请求?

你重写了连接启动流程,想在发出首条用户任务前确认通信已就绪。应采用哪种顺序?

02

多轮:让会话延续

同一本笔记本写下多页——理解 Thread 与 Turn 的层级、FIFO 缓冲的真实含义,以及 /resume 与长期记忆的本质区别。

全景:从单轮到多轮的三个关键变化

s02 在 s01 基础上做了三处结构性扩展,先看差异再逐步拆解。

变化 1:Thread 跨 Turn 复用
s01:一次执行一个 Thread,结束即关闭。s02:同一 Thread 可以持续接收新 Turn,直到用户退出或执行 /resume 切换。
变化 2:FIFO 输入缓冲
用户可以快速连续输入多条消息,但系统只在上一 Turn 执行完毕后才取下一条。这是输入节奏控制,不是重写模型的推理循环。
变化 3:/resume 会话恢复
通过关键词检索历史 Thread 元数据(名称、开场预览),选定后调用 thread/resume 恢复上下文。恢复的是会话状态,不是"长期记忆注入"。
📌

谁保存历史?

多轮上下文由本机 Codex App Server 原生持久化。Node 端不维护历史消息数组,也不向模型重发历史,这些工作完全由 Codex 内部管理。

数据流:用户输入到模型回答的 7 步旅程

以下动画模拟多轮模式下一条用户输入的完整流转路径,属于教学模拟,非真实推理过程。

👤
用户终端
📋
FIFO 队列
⚙️
Codex 子进程
💬
回答输出
点击"下一步"开始模拟
0 / 7

FIFO 是排队,不是推理

FIFO 输入缓冲是用户侧的节奏控制机制,不负责模型内部的推理循环。

容易混淆的两个概念
外
FIFO 缓冲 · Node 侧

用户消息入队、出队;决定何时发出下一个 turn/start,不决定模型如何推理。

内
ReAct / 内部 Loop · Codex 侧

处理一个 Turn 内的模型推理与工具调用往返;不管理下一条用户输入的排队。

关键区分

FIFO 决定的是"你的第三条消息要等第二条处理完才能发出",而不是模型"思考"的步骤数。

从执行机制看,一个 Turn 可包含多次推理与工具往返,由 Codex 的内部循环控制。本阶段的工具能力受限,不能把这种机制理解为已开放外部工具。

结论:FIFO 是外部排队,ReAct 是内部循环,两者分工不同。

为什么需要 FIFO?
如果同时推进多个用户输入,客户端必须处理更复杂的会话状态。本阶段按顺序等待每个 Turn 完成,使输入与结果的对应关系清晰。
超时后恢复原会话
s02 超时后停止连接,提示重启并使用 /resume。任务状态持久化、unconfirmed 与 /continue 队列恢复属于 s03 的服务能力。

resume 恢复会话 vs 记忆注入:两件不同的事

这是最容易被混淆的概念对。用笔记本比喻:resume 是"重新翻开一本旧笔记本继续写",记忆是"把多本笔记的精华摘录写到新本子的扉页"。

/resume 做了什么
1. 按关键词检索本地会话元数据(名称 / 开场预览)
2. 显示编号列表,用户输入数字选择
3. 发送 thread/resume RPC,Codex 恢复该 Thread 的原生历史
4. 后续 Turn 在此 Thread 上继续,上下文连贯
记忆(Memory)是什么
长期记忆可将有用信息跨会话保留并在后续会话召回;它与重新打开某一个历史会话是不同机制。
s02 不开启跨会话记忆(features.memories=false),/resume 恢复的是历史上下文,不是记忆注入。
源码 · stages/s02-multi-turn/05-app-server.mjs · 第 239–248 行
      const selected = /^\d+$/.test(value) ? threads[Number(value) - 1] : undefined;
      if (!selected) { console.log("请输入列表中有效的会话编号。"); continue; }
      if (selected.id === threadId) { console.log("这就是当前会话,可以继续聊天。"); return false; }
      await ensureThreadDirectory(root, selected.cwd);
      const result = await request("thread/resume", {
        ...threadOptions, threadId: selected.id, cwd: selected.cwd, excludeTurns: true
      });
      selectThread(result);
      console.log("已恢复会话:", safeTitle(selected.name || selected.preview));
      console.log("后续消息沿用原会话历史,使用上面显示的当前配置模型。");
逐行解读

数字校验:用正则确认用户输入是纯数字,再从 threads 数组取对应项,防止无效输入。

相同会话检测:如果选择的就是当前 Thread,直接提示继续,不重复 resume。

thread/resume + excludeTurns:通知 Codex 恢复该 Thread,excludeTurns: true 表示不在响应中回传完整历史 Turn 列表,减少传输量。

模型说明:恢复后使用的是"当前配置的模型",不保证与历史 Turn 使用的模型版本完全一致。

📂

路径说明

完整文件:stages/s02-multi-turn/05-app-server.mjs,第 239–252 行。会话元数据索引由本地存储维护,Codex 原生历史由 Codex 自身持久化,两者分工明确。

源码对照:s02 的 runTurn 与 s01 的相似与差异

s02 的 runTurn 结构与 s01 高度相似,关键在于 Thread 复用逻辑和超时错误提示的变化。

源码 · stages/s02-multi-turn/05-app-server.mjs · 第 192–201 行
  async function runTurn(prompt) {
    if (!threadId) await startThread();
    const turn = { threadId, id: null, answers: new Map(), buffer: [], completed: Promise.withResolvers() };
    turn.completed.promise.catch(() => {});
    activeTurn = turn;
    const timer = setTimeout(() => fail(new Error("App Server 本轮调用超时;已停止连接,可重启后 /resume。")), launch.timeoutMs);
    console.log("正在等待模型回复……");
    try {
      const result = await request("turn/start", {
        threadId, input: [{ type: "text", text: prompt, text_elements: [] }]
与 s01 的关键差异

threadId 复用:if (!threadId) await startThread()——若已有 Thread 则跳过创建,直接在同一 Thread 内发起新 Turn,这是多轮连续对话的核心。

超时提示变化:s02 的超时错误信息增加了"可重启后 /resume"——提示用户会话可恢复,不必从头开始。

buffer 机制相同:早到事件的缓冲与回放逻辑与 s01 完全一致,两版复用了同一解决方案。

原生历史归 Codex,不归 Node
Node 端通过 threadId 引用原生会话,完整对话历史由 Codex 内部的 SQLite/JSONL 维护。即使 Node 进程重启,原生历史与对应目录仍存在时,可通过 /resume 恢复。
检索范围限制
/resume 仅基于会话名称、开场预览和 ID 做基础匹配,不支持历史全文检索。无法通过消息内容关键词精确定位某条对话。

版本边界、实践命令与知识测验

🔖 版本变化说明
s01 → s02:新增 FIFO 输入缓冲、Thread 跨 Turn 复用、/resume 会话恢复、会话元数据本地索引。
s02 → s03:增加 HTTP 服务层、配置驱动 Agent、MCP 工具协议、沿用 FIFO,并增加持久化任务状态与重启暂停。
s02 仍无独立 HTTP 服务,不提供网页连接入口。
⌨️ 实践命令(s02 CLI)
cd stages/s02-multi-turn && npm ci
test -f .env || cp .env.example .env(填入 API Key/URL/Model)
npm run check(本地校验,不连网)
npm start(进入交互模式)
验证多轮:输入“记住代号是蓝鲸”→回答后再问“代号是什么”;核对同一 Thread ID、不同 Turn ID,检查回答而非预设成功
验证恢复:/exit 退出后重启,输入 /resume
npm test(离线模拟测试)
→ 打开 s02 命令与教学模拟
🚧 边界说明
无跨会话原生记忆沉淀,/resume 恢复的是会话上下文而非长期记忆。
回答仍为整段聚合输出,不支持流式打印。
未接入 MCP 协议,不支持外部工具调用(s03 起引入)。
单机单 owner 单 Agent,不支持多账户或多 Agent 编排。

你准备给会话增加“继续上次聊天”,却在 Node 的元数据里找不到正文。该去哪里恢复?

用户连续输入三条问题,第二条等了很久;他希望调整 FIFO 来减少单轮工具调用。哪种判断正确?

用户 /resume 后接着聊旧话题成功,却发现新会话不记得旧代号。该如何解释?

03

配置:装配一个 Agent

如何让同一套服务代码支持不同角色、工具和权限——答案是配置驱动。从 JSON 装配文件到工具白名单,逐层拆解 Agent 的组装逻辑。

全景:装配工作台的六个插槽

想象一张装配工作台:服务代码是台架,配置文件是装配清单,六个插槽决定 Agent "是谁、能做什么、能碰什么"。

点击插槽查看各字段职责
📌

唯一装配入口

JSON 文件 agents/writing-agent.json 是配置的唯一可编辑入口,遵循 schemaVersion: "1.0"。未知字段直接拒绝,缺少必填字段也会报错,保证运行状态与配置严格一致。

四个核心概念:装配台的底层语言

读懂配置文件前,先理解这四个词各自指的是什么,以及它们之间的边界。

Schema(模式)
writing-agent.json 的 schemaVersion: "1.0" 声明了当前配置遵循的 Schema 版本。加载器按 Schema 校验所有字段:多余字段拒绝,缺失必填字段报错。Schema 是配置的"格式合同"。
Registry(注册表)
ToolManager 的 build() 方法生成一个带版本号的 registry,其中包含当前版本的工具规格。list 和 call 都受工具白名单约束,Registry 是工具的"电话簿"。
MCP(模型上下文协议)
s03 的工具通过 mcp-server.mjs 提供一个本地 HTTP MCP 服务端,Codex 通过 MCP 协议发现并调用工具。一个 MCP 服务端承载本项目的工具,不是每个工具单独一个服务。
Skills(原生技能)
Skills 是方法指导文本,指导模型"如何做"某类任务(如写作流程)。工具权限由 tools.allow 单独控制——Skill 里提到某工具,不代表该工具自动被授权。两者必须分开配置。
🔑

方法 vs 授权:最重要的边界

Skills 告诉模型怎么做,tools.allow 决定模型能做什么。即使 Skill 里描述了"调用 write_file 保存结果",如果 allow 列表里没有 write_file,模型调用时仍会被拒绝。

解剖真实配置文件

以下是 writing-agent.json 的实际片段,展示装配工作台中的角色、工具、资料与执行配置。

源码 · stages/s03-agent-core/agents/writing-agent.json · 第 1–18 行
{
  "schemaVersion": "1.0",
  "agent": {
    "id": "writing-assistant",
    "version": "1.0.0",
    "instructions": "roles/writing-assistant.md",
    "skills": [],
    "tools": { "allow": ["list_files", "read_file", "write_file", "search_web", "read_webpage"] },
    "dataBindings": [
同一文件 · 连续下段
      { "sourceRef": "originals", "actions": ["read"], "methods": ["files"], "required": true },
      { "sourceRef": "research", "actions": ["read", "create"], "methods": ["files"], "required": true },
      { "sourceRef": "drafts", "actions": ["read", "create"], "methods": ["files"], "required": true },
      { "sourceRef": "articles", "actions": ["read"], "methods": ["files"], "required": true }
    ],
    "executionRef": "restricted-local",
    "memory": { "use": false, "contribute": false },
    "outputs": { "directory": "outputs", "formats": ["md", "txt"], "writeMode": "create-only", "requiredChecks": ["file-exists", "format"] }
  },
字段解读

schemaVersion:版本声明,加载器据此选择校验规则,未知版本直接拒绝。

instructions:角色正文文件路径,不是内联文本——角色内容与配置结构分离,易于独立维护。

tools.allow:5 个工具名组成白名单,list 和 call 都受此约束,模型不可自行扩权。

dataBindings:四个资料源,originals 和 articles 仅 read,research 和 drafts 可 read+create。articles 指向 outputs 目录,是来源绑定别名,不是新目录。

executionRef:引用 bindings.executions 中的策略条目,当前为 restricted-local(只读沙箱,禁止原生 shell 和文件写)。

memory.use/contribute:两者均 false,s03 不读取也不贡献跨会话记忆。

工具注册与 MCP 服务:ToolManager 的工作方式

ToolManager 是工具层的核心,负责把配置里的 allow 列表变成 Codex 可调用的 MCP 接口。

源码 · stages/s03-agent-core/server/modules/tools/index.mjs · 第 15–31 行
export class ToolManager {
  constructor({ config, runtime, log, redact, files, agent, boundary = new ExecutionBoundary() }) {
    Object.assign(this, { config, runtime, log, redact, files, agent, boundary });
    this.configFile = codexPaths(config.root).configFile;
    this.token = randomBytes(32).toString("hex");
    this.status = "loading"; this.error = null;
    Object.assign(boundary, { pending: () => Boolean(this.pending), unavailable: () => this.closed || this.broken,
      refresh: async () => { try { this.scan(); } catch (error) { this.pending = null; this.report(error); } }, apply: () => this.apply() });
  }
同一文件 · 连续下段
  async open() {
    this.registry = await this.build(readToolSources(this.config.root));
    this.server = await createMcpToolServer({ token: this.token, redact: this.redact });
    this.server.registries.set(this.registry.version, this.registry);
    this.config.launch.env = { ...this.config.launch.env, PROJECT_MCP_TOKEN: this.token };
    this.status = "ready";
    return this;
  }
关键设计解读

随机 token:每次启动生成 32 字节随机 token,作为 MCP Bearer 令牌写入 Codex 环境变量,防止未授权调用。

build() → registry:扫描工具源,构建带版本号的 Registry 快照,存入 server.registries Map。

createMcpToolServer:启动本地 HTTP MCP 服务端,一个服务承载本项目的工具,不是一工具一服务。

ExecutionBoundary:边界锁模块注入 ToolManager,保证工具刷新不在任务中途发生,维护状态一致性。

工具刷新的限制
工具刷新受边界锁协调,不能在任务中途切换。Agent 配置、角色或 Skill 文件变更后,会拒绝后续请求并要求重启;新增工具还需实现、加入 allow 并受控重启。
list 与 call 双重过滤
模型请求 list_tools 时,只返回 allow 列表内的工具;call_tool 时再次校验 allowlist,两道过滤缺一不可。模型看不到、也调不到白名单外的工具。

描述符版本机制与 Workspace 边界

配置变更如何被检测?文件操作的边界在哪里?这两个问题是排障时最常碰到的。

1
启动时生成描述符
每次服务启动,系统生成 Agent 描述符,写入 .data/agent-descriptors/,包含角色哈希、Skill 版本、工具版本。
2
Thread 绑定描述符版本
新建 Thread 时,其描述符版本写入 .data/thread-agents.json。恢复旧 Thread 时,先比对当前描述符哈希与记录值是否一致。
3
不匹配则提示 /new
配置、角色或 Skill 文件变更后先重启服务;如果新描述符与旧会话绑定不同,系统拒绝恢复并提示用户执行 /new 新建会话,不自动迁移旧 Thread。
4
文件权限边界
文件服务(files.mjs)自身执行权限边界,不依赖 Codex cwd 约束。工具快照工厂注入文件服务,无跨快照引用,防止历史快照影响当前操作。
⚠️

业务状态存 JSON,不存数据库(s03 专属)

s03 不依赖 PostgreSQL 或 Redis。业务任务状态(messages.json)使用原子替换写入:先写临时文件,再 rename 到目标路径,防止写一半损坏。数据库层能力从 s04 引入。

源码 · stages/s03-agent-core/agents/writing-agent.json · 第 19–29 行
  "bindings": {
    "workspaceDir": "../workspace",
    "skills": {},
    "sources": {
      "originals": { "type": "files", "root": "inputs" },
      "research": { "type": "files", "root": "research" },
同一文件 · 连续下段
      "drafts": { "type": "files", "root": "drafts" },
      "articles": { "type": "files", "root": "outputs" }
    },
    "executions": { "restricted-local": { "sandbox": "read-only", "approvalPolicy": "never", "nativeShell": false, "nativeFileWrite": false } }
  }
关键说明

workspaceDir:Agent 的工作根目录,Thread 的 cwd 设为此路径,跨重启保留。

articles → outputs:articles 是 outputs 目录的来源绑定别名,不是独立目录。list_files 只返回已授权路径的文件,无法通过 articles 访问 outputs 以外内容。

restricted-local:沙箱策略条目,禁止原生 shell 和文件写,approvalPolicy: never 表示不通过弹出审批来申请额外执行权限;工具仍受白名单与文件服务边界约束。

版本边界、实践命令与知识测验

🔖 版本变化说明
s02 → s03:新增配置驱动 Agent、HTTP 服务层、MCP 工具协议、描述符版本机制、文件服务权限边界。本阶段业务状态使用本地 JSON,数据层升级独立安排在 s04。
s03 → s04:数据层升级,引入 PG/Redis,业务状态从本地 JSON 迁移到数据库。
s03 是独立源码快照,不共享 app/ 的运行状态,监听 127.0.0.1:4313,本机开发,无多用户公网授权。
⌨️ 实践命令(s03 HTTP 服务)
cd stages/s03-agent-core && npm ci
test -f .env || cp .env.example .env(填入 API Key/URL/Model,端口默认 4313)
npm run check(本地配置校验)
npm run server(启动服务,s03 无需数据库迁移)
另开终端:npm run chat
验证工具:在 chat 中输入 /tools
npm test && npm run test:codex-tools
→ 进入 s03 练习
🚧 边界说明
单机单用户,每实例一个 Agent 多 Thread,不支持多账户或多 Agent 编排。
Skill 仅验证原生文本指令型;带脚本/附件的工作流不保证运行。
记忆链路读/提炼限制已测;合并摘要当前 blocked,完整链路未最终验收。
新增工具需同时配置 allow 并受控重启,不支持热增不重启。
文件服务限制路径、扩展名、大小和操作:默认 .md/.txt、256 KiB,只创建不覆盖,不跟随软链接或硬链接;不能仅凭 cwd 判断访问权限。

你给 Skill 新增“用 write_file 保存文章”,模型却仍被拒绝。首先应检查哪一项授权?

你修改 Agent 角色并重启了服务。现在 /resume 旧 Thread,描述符版本不同,应如何处理?

你新增第六个已授权工具,担心还要开第六个端口。按本项目结构,应该怎样接入?

04

数据:状态有据可查

从单文件 JSON 到关系型事务——理解 PG 账本、Redis 提醒铃、不可变 blob 与原生历史各司其职,读完能向 AI 程序员准确定位状态与权限问题。

四层架构:谁存什么、谁唤醒谁

s03 用单文件 JSON 管理业务状态,Codex 原生历史独立维护;已具备 FIFO 排队与重启时暂停不确定执行的能力。s04 在此基础上新增关系事务与内容版本追踪。 s04 把存储拆成四块各自负责,就像一家公司把财务账本、门卫铃、文件柜、原始档案交给四个专职部门。

s04 系统架构总图:四层存储职责划分
图示说明:四层是逻辑职责,PG、Redis 为独立服务;右侧 Codex 原生运行时是独立进程,通过 stdio 通信;红色标注为相比 s03 新增或变化的部分。图描述的是职责划分,不是四个独立进程。
01 交互入口

HTTP / CLI 收消息,校验本地凭据;返回任务 ID 与事件。

→
02 装配与调度

读取 Agent 配置,写 tasks + outbox;领取任务并驱动 Codex。

→
03 工具与资源

接 MCP 工具调用,执行读写;输出正文 blob 与资源版本记录。

→
04 数据与文件

PG 管业务事实,Redis 管唤醒,blob 管正文;原生历史另由 Codex 管理。

🗄️
PostgreSQL 账本
所有业务状态(任务、会话、资源版本)的权威来源。写操作在 PG 事务内完成,保证原子性。
🔔
Redis 提醒铃
只存 Thread ID Set,用于低延迟唤醒调度器。它不存任务数据,失联时 outbox 兜底,不丢任务。
📦
Blob 文件柜
以 sha256 命名的不可变文件,存在 .data/files/objects/。内容写入后不覆盖,版本变了就是新文件。
🧠
Codex 原生历史
完整模型回复与工具调用历史,由 Codex 自身管理(SQLite/JSONL),主进程不读写,PG 不重复存储。

PG 账本:任务与调度出口同事务写入

想象银行转账:从 A 账户扣钱和向 B 账户加钱必须同时成功或同时失败,不能只做一半。 s04 把「写任务」和「写调度出口」绑进同一个 PG 事务——服务崩溃时,账本要么两条都有,要么两条都没有,不会出现任务存在但调度出口丢失的情况。

源码:stages/s04-data-layer/server/modules/data/jobs.mjs:75–81

真实源码 · 7 行
      await this.wake(job.threadId, client);
      await this.query(
        `UPDATE agent_data.conversations SET name=CASE WHEN name='' THEN $4 ELSE name END,updated_at=now()
        WHERE instance_id=$1 AND owner_id=$2 AND id=$3`,
        [job.threadId, job.text.slice(0, 100)],
        client,
      );
中文对照

第 1 行:wake 用同一事务 client 写 outbox;前面的任务写入也共享这个事务。

第 2–4 行:更新当前 owner 的指定会话;已有名称保留,空名称才采用任务原文。

第 5–7 行:只取原文前 100 字符作为候选会话名,并把同一事务连接传给 query。

⚡ 关键约束:tasks.text 只存用户任务原文(最多前 100 字符同步到会话名)。完整模型回复与工具历史不在 PG,由 Codex 原生存储管理。向 AI 程序员描述问题时,说"任务原文在 PG tasks 表,模型历史在 Codex 原生存储"是准确的表达。
表 存什么 不存什么
tasks 任务原文、状态、模式、Thread 归属 完整模型回复、工具调用历史
outbox 以 Thread 为键的调度出口、generation 号 任务 ID 列表(不是队列)
resource_versions sha256、文件路径、版本元数据 文件正文(正文在 blob)
conversations 会话、绑定的 agent 版本 Codex 原生对话消息

Redis 提醒铃:Set 唤醒,不是任务队列

Redis 在这里扮演的是"门铃",而不是"信箱"。有人按铃(sAdd),调度器就去 PG 账本里按顺序领取;铃声本身不携带任务详情,也不保证顺序。 任务的权威顺序和状态,永远在 PG。

源码:stages/s04-data-layer/server/modules/data/queue.mjs:58–66

真实源码 · 9 行
    const rows = await this.jobs.deliveries(); // Database failure stops execution, Redis failure only delays dispatch.
    for (const row of rows) {
      try {
        await this.commands.sAdd(this.key, row.thread_id);
      } catch {
        this.healthy = false;
        return;
      }
      await this.jobs.delivered(row);
中文对照

第 1–2 行:从 PG 取待投递记录,并逐条处理;数据库失败会中断,Redis 失败只延迟派发。

第 3–4 行:sAdd 只放入 Thread ID;Set 去重仍留在集合中的 ID,不代表任务只执行一次。

第 5–8 行:Redis 失败就标记不健康并退出;尚未确认的 outbox 记录可在后续补投。

第 9 行:Redis 写入成功后,才确认本条调度出口已投递。

✅
Redis 负责
存 Thread ID Set;低延迟唤醒(200 ms 轮询);sAdd / sPop 操作。
🚫
Redis 不负责
不存任务 ID 队列;不存任务详情;不保证顺序;不替代 PG 做业务状态存储;不存向量数据。
🛡️
Redis 失联时
outbox 记录留存在 PG,调度器健康标记置 false,下次恢复后可补投。任务不丢,只是延迟。
🔑 常见误区:Redis Set 不是任务队列(任务队列存的是任务详情、有顺序保证)。这里 Set 只是"有哪些 Thread 需要被调度器看一眼"的信号,PG 才是决定"领哪条任务"的权威。

追踪一次完整任务闭环

以下用角色对话模拟一条任务从用户提交到完成的完整路径,帮你在脑海里建立各组件的协作顺序。 动画仅为教学模拟,不执行真实推理或数据库操作。

📋 任务闭环追踪 · 教学模拟,不执行真实推理或数据库操作
0 / 6
状态排查速查:任务卡住?先查 PG tasks.status——queued 说明还没被领取(同时检查会话是否暂停、是否有前序任务,以及 Redis 健康);unconfirmed 说明执行中重启了,需人工决策;completed 说明正常完成,按任务的实际输出路径检查产物。

不可变 Blob:内容寻址与来源追踪

单机单 owner、单 Agent 配置,支持多 Thread;并非多 Agent 编排。s04 使用数据库 agent_s04(schema v1),s05 使用 agent_s05(schema v2),PG 与 Redis 均必需。两套 Compose 绑定相同的 5432/6379 端口,不可同时启动;可共享一组依赖并使用不同数据库。

sha256 是内容指纹,正文按指纹命名保存。内容改动会生成新 blob,已登记版本不被覆盖;这不等于承诺永久保留。 这让系统能追踪“摘要声明引用的是 report.md 哪个版本”;读取或校验发现原文变化后,依赖链会传播失效。

🔒
create-only,不覆盖
write_file 只创建新路径,已有路径会被拒绝,即使内容相同。底层 blob 可按内容指纹复用;这是不同层次的规则。
📂
路径格式
.data/files/objects/<sha256>.txt——无子目录,所有版本平铺在同一目录。.data/codex/ 是 Codex 原生存储,由 Codex 管理,主进程不读写。
🔗
来源声明 vs 语义证明
dependencies 表记录版本依赖,source_versions 声明来源。这是声明,不证明模型语义引用完全——AI 可能引用了未声明的内容。
⚠️
跨存储非原子
blob 文件写入与 PG 写 resource_versions 之间没有跨存储事务保证。PG 内部操作是原子的;跨文件系统与数据库则不是。
🛠️ 命令实践入口:以下命令用于验证 PG/Redis 状态和查询任务记录。在 stages/s04-data-layer 中完成依赖安装、环境配置、PG/Redis 启动、npm run data:migrate 与 npm run server 后执行,练习场景入口见 互动练习。
终端命令 · 状态验证
npm run data:check

docker compose exec postgres psql \
  -U agent -d agent_s04 \
  -c "SELECT id, status, substring(text,1,60) \
  FROM agent_data.tasks \
  ORDER BY created_at DESC LIMIT 5;"
命令说明

npm run data:check:运行内置检查脚本,验证 PG 连接是否正常、schema 表结构是否完整、Redis 是否可达。

docker compose exec postgres psql:进入 Docker 容器内的 PostgreSQL 命令行,以 agent 用户连接 agent_s04 数据库。

SELECT id, status, substring(text,1,60) FROM agent_data.tasks:查询最新 5 条任务记录,显示 ID、状态、任务原文前 60 字符。用于排查任务是否入队、是否卡住、是否变为 unconfirmed。

应用场景测验

以下三道题考察你能否在真实场景中定位问题、做出判断。每题只有一个最准确的答案。

场景一:一条新任务已提交 PG 事务,尚未领取,Redis 唤醒投递却失败了。你该如何判断这条任务是否丢失?

场景二:一位 AI 程序员告诉你某条任务的 status 是 unconfirmed,问你该怎么处理。你会如何解释这个状态,以及下一步该做什么?

场景三:用户反映"AI 回复了一些内容,但我不知道它具体说了什么,PG 里查不到"。你如何向 AI 程序员解释这是正常行为,并指出正确的查询位置?

05

知识:可用且有边界

从散落文件到结构化 Wiki,从各自判断到统一门禁——Agent 如何安全地"知道"正确的事。

架构总图:s05 新增了什么

s04 已经能可靠存储业务数据并调度任务。s05 解决两个新问题:让 Agent 在本机维护结构化知识库,并以统一门禁拦截所有工具路径。图左五层承载整体逻辑,图右 Codex 原生运行时通过 dynamicTools 与 03 层通信,而非直接暴露 MCP。

s05 系统架构总图——五层职责划分与 Codex 原生运行时,红色标注为新增/变化项
图为职责划分,不是每层起一个独立进程。红色标注为相比 s04 的新增/变化项。右侧 Codex 原生运行时是独立进程,通过 stdio 与 02 层通信。
01
交互入口
HTTP POST /messages;knowledge CLI 命令;SSE 流响应。新增 knowledge 子命令。
02
Agent 装配与调度
读取 agents/*.json;schemaVersion: 1.1 新增 knowledge[],声明 kbId、mode、actions。路径绑定指定 sourceRoot 与 wikiRoot;写 task + outbox 同事务;dynamicTools 回调将工具调用路由至 03 层门禁。
03
通用门禁 ★新增
fail-closed 原则;统一校验工具白名单、资源所有权、KB 挂载权限与任务范围。
04
知识与资源服务 ★新增
Wiki 乐观锁;可选 RAG 索引构建;资源 blob 版本管理。
05
数据与索引
PostgreSQL schema v2(顺序迁移);Redis 两个独立唤醒路径;BM25 文件;可选 Chroma。

03 层:通用门禁——谁能调哪个工具

门禁是同进程模块,不是网络服务。Codex 每次工具调用都经由 RPC envelope 进入此处,身份来自服务端签发的任务上下文,不依赖模型自称。权限判定按以下四关依次过滤,任意一关不过即返回 FORBIDDEN。

关卡 检查内容 来源
① 上下文有效 任务仍运行中、上下文未被 abort 服务端 AbortController
② 工具白名单 工具名在 Agent 配置声明的允许列表中 agents/*.json 声明
③ 资源所有权 目标资源属于当前任务的 data.scope 服务端签发,非模型自称
④ KB 挂载 + action knowledge[] 中挂载且 actions 包含当前操作;maintain 任务才能写 Wiki Agent 定义 + 任务上下文

来自 server/modules/permissions/index.mjs 的真实代码展示 mount() 如何检查挂载、操作授权与任务维护范围;上下文在任务开始时签发,结束后撤销。

源码:stages/s05-knowledge-layer/server/modules/permissions/index.mjs:51–57

真实源码 · 7 行
  mount(kbId, action = 'read') {
    const context = this.current();
    const mount = (this.agent.definition.agent.knowledge || []).find(m => m.kbId === kbId);
    if (!mount || !mount.actions.includes(action)) denied();
    if (action !== 'read' && context.kind === 'task' && context.maintenanceKb !== kbId) denied();
    return mount;
  }
中文对照

第 1–2 行:取得服务端签发且仍有效的当前上下文。

第 3–4 行:在 Agent 挂载配置中找这个 KB;缺挂载或缺 action 都拒绝。

第 5 行:task 的非 read 操作,还要 maintenanceKb 与目标库一致。普通对话没有维护范围。

第 6–7 行:校验通过才返回挂载配置;maintain 也仍需对应 action 授权。

门禁模式禁用 shell、apply_patch、subagents;Agent 配置变化时按 fail-closed 拒绝。MCP 仅保留兼容测试实现,主流程不启动 MCP listener;Skill 只指导操作方法,不授予权限。
普通对话 task 的 maintenanceKb 为 null,因此 kb_submit_revision(写 Wiki)永远被拒绝。research/<kbId>/wiki 路径上的 read_file/write_file 同样被门禁拒绝,必须用 kb_* 工具经 04 层操作。

04 层:三类任务——Chat、Maintain、Index 各做什么

理解这三类任务是排查知识问题的关键。普通 chat 与 maintain 任务通过 Codex 执行,任务记录存于 tasks 表;index 任务由 KnowledgeWorker 执行,任务记录存于 knowledge_jobs 表,并通过独立的 Redis key 唤醒。

💬
普通对话 task
输入:用户消息
上下文:kind: 'task',maintenanceKb: null
可用 KB 工具:kb_list、kb_read、kb_search(read action)
禁止:kb_submit_revision(写 Wiki)
存储:写 tasks + outbox 同事务,Redis 唤醒
🔧
maintain(仍为普通 task)
输入:npm run knowledge -- maintain <kbId> <提示>
上下文:kind: 'task',maintenanceKb: <kbId>
额外权限:kb_submit_revision(需 update action)
输出:写 wiki_pages / wiki_versions,current_version 原子切换
注意:存于 tasks 表,不是 knowledge_jobs
🗂️
index(knowledge_jobs)
输入:npm run knowledge -- index <kbId>
执行者:KnowledgeWorker(不是 Codex)
输出:staging Chroma collection + BM25 JSON;两边就绪后 PG 切 index_revision
存储:knowledge_jobs 表,Redis 独立 key 唤醒
注意:这是独立的 knowledge_jobs,不在 tasks 表

Wiki 页面提交流程来自 server/modules/knowledge/index.mjs:

源码:stages/s05-knowledge-layer/server/modules/knowledge/index.mjs:153–160

真实源码 · 8 行
      await this.sourceVersions(kb, source_versions, client);
      await this.query(`INSERT INTO agent_data.wiki_pages(instance_id,kb_id,slug) SELECT $1,$3,$4 WHERE $2::uuid IS NOT NULL ON CONFLICT DO NOTHING`, [kbId, slug], client);
      const page = (await this.query('SELECT * FROM agent_data.wiki_pages WHERE instance_id=$1 AND kb_id=$3 AND slug=$4 AND $2::uuid IS NOT NULL FOR UPDATE', [kbId, slug], client)).rows[0];
      if (page.current_version !== expectedVersion) fail('Wiki 已被其他更新修改,请读取最新版本。', 'VERSION_CONFLICT');
      await this.query(`INSERT INTO agent_data.wiki_versions(instance_id,id,kb_id,slug,revision,blob,sources)
        SELECT $1,$3,$4,$5,$6,$7,$8 WHERE $2::uuid IS NOT NULL`, [id, kbId, slug, body.revision, body.blob, JSON.stringify(source_versions)], client);
      this.gate.check('update', { kbId });
      await this.query('UPDATE agent_data.wiki_pages SET current_version=$5 WHERE instance_id=$1 AND kb_id=$3 AND slug=$4 AND $2::uuid IS NOT NULL', [kbId, slug, id], client);
中文对照

第 1–3 行:校验来源、确保页面行存在,再锁定该行读取当前版本。已有页面不会被重复插入。

第 4 行:expectedVersion 必须匹配;冲突就拒绝,防止覆盖另一份更新。

第 5–6 行:登记新 Wiki 版本和来源,正文 blob 在进入这段 PG 事务前已写入。

第 7–8 行:再检查权限,然后切 current_version;这些 PG 操作一起提交。

Wiki 无后台自动工作进程。knowledge:init 只建目录、部署 SKILL.md,不生成任何 Wiki 内容。必须执行 maintain 命令发任务,Codex 读原文后主动调用 kb_submit_revision 才写入 Wiki。

一次 maintain 调用如何流经 02 → 03 → 04 层

以下动画追踪一次 maintain general "整理产品手册" 命令从发出到 Wiki 写入的完整路径。四个角色代表四个子系统,消息展示主要业务顺序,省略了轮询和异常分支;这是教学模拟。

维护任务全链路追踪 教学模拟,不执行真实推理或数据库操作
0 / 5

RAG 索引:从 Wiki 到向量检索的切换路径

RAG 是可选层,Chroma 是可选依赖;未启用 RAG 时可使用 wiki 模式。切换到 RAG 必须走完以下路径——不能热切换,不能在运行时自动生效。

①
配置并重启
在 .env 添加 CHROMA_URL、EMBEDDING_* 变量,docker compose --profile rag up -d chroma,重启服务。配置变化后必须重启,运行中不热读。
②
发 index 任务
npm run knowledge -- index general 写入 knowledge_jobs 表,Redis 独立 key 唤醒 KnowledgeWorker(不是 Codex)。Worker 构建 staging Chroma collection + BM25 JSON 文件。
③
两边就绪后切指针
Chroma staging collection 与 BM25 文件均核验来源后,PG 更新 index_revision。RRF 60 融合双路结果,无 rerank,无查询失败 fallback。
④
Agent 配置改 rag + 重启
将 agents/writing-agent.json 的 mode 改为 "rag",停止并重启服务,新建 Thread 才生效。旧 Thread 历史不可撤回,旧 Thread 中读到的知识无法移除。
已生效的 RAG 不兼容时(如 embedding 维度变化)服务拒绝启动,需重建索引。崩溃中的 building/running 状态重启后恢复为 failed,清理 staging collection 与 BM25 文件,失败时可能留孤儿,需运维核对;旧 ready 索引保留,不会无限自动重试。
首次请求 rag 但索引未 ready 时,挂载显示 pending、effectiveMode=wiki。已生效 rag 遇到运行错误不会 fallback 到 wiki;RAG 索引读取原始资料,Wiki 模式搜索已生效的 Wiki 正文(最多 200 页),不是两种搜索混用。

命令实践入口

bash · 服务终端 + 客户端终端
npm run knowledge -- maintain general \
  "阅读 inputs/general 下全部原始资料,整理为 Wiki"
npm run knowledge -- list
npm run knowledge -- read general 实际创建的slug
npm run knowledge -- search general 关键词
npm run knowledge -- index general
npm run knowledge -- job 返回的JOB_ID
npm test && npm run test:rag
每行含义

发 maintain task:maintenanceKb = general,Codex 读原文后调 kb_submit_revision 写 Wiki。返回 taskId + threadId。

列已挂载 KB:返回模式(wiki/rag)、actions、索引版本。不列页面内容。

读取指定 Wiki 页面:返回正文 + versionId(乐观锁更新时需要)。slug 来自 maintain 任务产物,不能凭空猜测。

搜索 Wiki 正文(wiki 模式有界文本,≤200 页;rag 模式 Chroma+BM25 RRF60,limit 1–20)。

发 index 任务:写 knowledge_jobs 表,KnowledgeWorker 异步构建 RAG 索引,与 Codex 无关。

查询指定 knowledge_job 状态:queued → running → completed 或 failed。

运行验证套件;test:rag 需 Chroma 在 PATH 中或已设 CHROMA_TEST_BIN。embedding 使用固定模拟向量,不证明语义质量。

以下练习在 stages/s05-knowledge-layer 目录进行。准备知识库时先设置 AGENT_MEMORY_ENABLED=false,执行 npm run knowledge:init,把原文放到 workspace/inputs/general/;维护结束后再读取实际页面。实际运行还需先完成 npm ci、本地环境配置、docker compose up -d postgres redis、npm run data:migrate、npm run server。完整步骤见 s05 实践环境。

本轮新增 Web 外部客户端,s05 与当前项目接入受控上传,s01–s04 保留各自的能力边界。受控原文导入采用 operator 身份,仅支持 md/txt、单文件 ≤256 KiB,且只创建、不覆盖。Wiki 页面列表通过 GET /knowledge/pages 获取,读取时仍校验来源;教学页默认不连接服务,也不运行模型。

单机单 owner、单 Agent 配置,支持多 Thread;并非多 Agent 编排。s04 使用数据库 agent_s04(schema v1),s05 使用 agent_s05(schema v2),PG 与 Redis 均必需。两套 Compose 绑定相同的 5432/6379 端口,不可同时启动;可共享一组依赖并使用不同数据库。

限制项说明
单机单 owner无多账户、无共享 KB、无多主机
原生记忆挂 KB 须 AGENT_MEMORY_ENABLED=false;原生记忆关闭后仍保留原生会话历史。收窄权限后需新建 Thread,旧历史不可撤回
RAG 不热切换改配置须重启;embedding 维度变化时拒绝启动
wiki 不引用 wiki来源只能是当前有效原文版本,1–50 条
跨存储非原子blob 写文件 + PG 切指针非原子;PG 内操作原子
无 rerankBM25 + Chroma RRF 融合(常数 60),无后处理重排

应用情景测验

三道场景题,测试你能否用所学定位真实问题、指导 AI 程序员。每道题都有针对各错误选项的解释。

场景一:你让 Agent 在普通对话中整理一篇 Wiki 页面,但服务端返回 FORBIDDEN,模型说"无权限"。你应该怎么告诉 AI 程序员这是为什么?

场景二:你已经成功运行了 npm run knowledge -- index general,job 状态显示 state: completed。你让当前 Thread 继续调用 kb_search,结果看起来还是 wiki 模式的文本匹配,没有向量排名。最可能的原因是什么?

场景三:Codex 调用 kb_submit_revision 时返回 VERSION_CONFLICT。你应该向 AI 程序员解释,这个错误意味着什么,以及正确的处理方式是什么?