统一 agent 平台 · 缺口模块、模块间输入输出与组合方案
版本:v1(评审稿) 公开版
本文是内部设计稿的脱敏公开版:保留模块划分、交接契约与存储规范,
移除内网地址、账号路径、模型与网关名称、仓库路径与规模数字。
本文回答四件事:
- 在已有算力平台之上,还缺哪些模块;
- 这些模块之间传什么(输入输出)、怎么连;
- 文件/存储怎么规范化(布局、命名、权限、生命周期、可执行审计);
- 怎么把它们组合成一个系统,以及分几阶段落地、怎么验收。
这是设计评审稿,不是已实现功能的宣称。 已落地为可执行代码的只有两处,其余模块均未编码:
- 第 5.6 节的
scripts/storage_audit.py:存储审计(tests/test_storage_audit.py23 项通过); - §3.9 的
scripts/incident_verify.py:判定故障是否真的修好(tests/test_incident_verify.py55 项通过)。
它把"定位/修复可以交给模型、验证不能"这条原则变成可测函数:只有当初的检测规则重跑通过才算修好,
agent 自述只记录不生效,观测不到一律判失败。
第 2 节每一条现状都标注了本机可复现的核验方式;第 7.2 节列出明确未核实、不能当作能力的部分。凡本文写"已实现"的,指仓库现有代码或本机服务;凡写"设计"的,均未编码。
1. 一页结论
平台不是缺一个"总控 agent",而是缺两条账本之间的连接件:
- 已有 工作/资源账本(
compute-scheduler):谁占了几张卡、任务跑到哪、租约与配额、失败与抢占。它有租约、fencing token、审计,但没有"会话"概念。 - 已有 会话账本(各 agent 自己的 home:Codex rollout、Claude project jsonl、Cursor chat 目录、DSH session):对话内容与历史,能各自
resume。但它们彼此不知道对方的 id,也没有独占写者保护。 agent-fleet把五个 agent 的技能/记忆/历史检索对齐了,dsh-superagent把触发→评估→工单→派工→回报跑通了,但两者都只覆盖了问题的一半:fleet 不理解"任务",DSH 只调度 DSH 自己的 worker。
所以缺的不是再造一层编排,而是 6 个连接件 + 5 个补强:
| 编号 | 缺口模块 | 一句话职责 |
|---|---|---|
| M1 | Session Registry(会话注册表) | 给每个可恢复会话一个平台内稳定 id,记录它属于哪个 runtime、哪个 home、哪个 provider/model、哪个工作目录 |
| M2 | Runtime Adapter(运行时适配器) | 每个 agent 一套:定位/读取/导出/落位/生成 resume 命令,能力用能力位声明 |
| M3 | Handoff Engine(交接/落位引擎) | 判定"原生 resume / 落位后 resume / 转换后重建",执行落位并写回执 |
| M4 | Session Lease(会话独占写者锁) | 把会话绑到 csched 的租约与 fencing token 上,拒绝第二个写者 |
| M5 | Work Link(工作与会话的关联) | job ↔ session ↔ ticket ↔ artifact 四元关联,让"任务"和"对话"互相找得到 |
| M6 | Unified Gateway(统一入口) | 一个 CLI/HTTP/MCP:提交、查询、交接、恢复,背后是同一份状态 |
| 补强 M7 | Agent 注册与能力广播 | csched 已有 agents 表但为空;需要每个 agent 上报 runtime/provider/model/工具/权限 |
| 补强 M8 | 事件与通知总线 | DSH 有 outbox+回执,csched 有 append-only events,fleet 没有;需统一为"状态可补读、通知去重" |
| 补强 M9 | 凭据与模型路由 | 每个 runtime 的 provider 定义与模型名不同,交接必须带上正确的路由,否则"能 resume 但跑不起来" |
| 补强 M10 | 存储治理(布局 / 保留 / 回收) | 统一目录布局、命名、权限、生命周期与可执行校验,让"文件怎么存、存哪、谁能删、留多久"成为规则而不是习惯(见第 5 节) |
| 补强 M11 | 故障自愈(检测→定位→修复→验证→升级) | 让故障触发 agent 定位并解决,且由确定性复检判定是否真的修好(见 §3.9) |
关键判断(本机实测支撑,见第 2.3 节):
- 会话可移植性是文件系统级的,不是协议级的。 同一 runtime 换 home/换 cwd 只要把会话产物放到它查找的位置就能 resume——codex 家族、Cursor、Claude 三者实测成立(R1/R7/R10/R12);DSH 只验证了"拒绝跨目录采用"(R14),未验证落位后可 resume。
- provider/model 是会话的固有属性,不能静默替换。 Codex 会话记录了
model_provider,换 provider 直接拒绝;显式-c model_provider=... -m ...才能跨路由继续。交接必须携带路由,不能只带 session id。 - 双写会真出事。 Codex 第二个写者报
thread-store conflict: ... already has an active writer;Cursor 直接报Persistent chat ownership is currently changing;而 Cursor 在错误 cwd 下 resume 不报错、只是给了个空上下文——静默失败比报错更危险,必须由平台侧校验上下文回读,不能信退出码。 - 跨 runtime 只有单向导入。 Codex 已经在自动导入 Claude/Cursor 的会话(80 条记录),但没有任何导出通道;跨 runtime 的"完整 resume"只能是"结构化重建",不能假装是同一线程。
2. 现状盘点(事实与证据)
2.1 已有资产
| 资产 | 位置 | 事实 | 核验方式(2026-11-01) | |||
|---|---|---|---|---|---|---|
| 算力控制面 | compute_scheduler/(本机 systemd user unit) | 资源镜像、jobs、leases(含 fencing_token)、attempts、配额三层、抢占、静态池 SSH 部署、健康检查与故障转移、看板、审计 events、notes 黑板、maintenance 队列 + inbox | csched quota / csched jobs / GET /api/v1/{resources,jobs,quota,metrics/summary,events,notes};本机单元测试通过(含存储审计新增用例) | |||
| 控制面接口面 | compute_scheduler/server.py _dispatch | /api/v1 全量:resources、resources/scan、agents(POST/GET)、jobs(+cancel/logs/retry)、maintenance(+claim/close)、caretaker/run、quota(+events)、queues/{pause,resume}、leases/heartbeat、metrics/summary、notes、events、snapshot;token 三档:human/agent/read | 读代码 + curl 行为(无 token → 缺少令牌时的错误) | |||
| 控制面没有的东西 | — | 无会话/线程概念(全库 grep 无 session/thread 语义);无 MCP 封装(csched --help 无 mcp 子命令,compute-scheduler-implementation.md §7 亦承认为未实现);agents 表为空(csched agents 无输出) | csched agents、csched --help、grep | |||
| Agent 机群工具 | ops/agent-fleet/fleet.py(fleet) | 五个 runtime:codex(官方登录)、codex-api(自有网关,共享 home)、codex-ds(自有网关,隔离 home)、agent(Cursor)、claude;status/health/skills --apply/memory --apply/`history index\ | search/exec`;技能并集软链到各 home;共享记忆摘要按出处登记进各 agent 指令文件;FTS5 历史索引跨四类 session store | fleet status、fleet memory、fleet history search、ops/agent-fleet/README.md | ||
| 工单编排 | dsh-superagent(本机 <工单编排器状态目录>/orchestrator.sqlite) | Trigger→Thread→Eval→Decision→Ticket→Mandate→Assignment→Subscription→Channel;outbox 带回执与去重;Assignment 中断可恢复;Executor 是 DSH Session(worker-<ticket id>) | orchestrator.sqlite 表结构、编排器内部的领域与执行文档、orchestrator.sqlite 中的 ticket 记录 | |||
| 工单编排的边界 | 编排器的 Executor 实现 | Executor 只实现 DSH 一种;Codex/Cursor/Claude 无法作为 Ticket 的 Executor | 读 编排器的 executor 目录 只有 dsh.ts、background.ts | |||
| 资源台账技能 | docs/design/skill/(compute-resource-agents) | resources.json 唯一权威;agent_client.py 走 app-server WebSocket(thread/start、thread/resume、turn/start、turn/interrupt、thread/read、model/list),token 文件 600,断线不重发启动命令 | 读 agent_client.py;该脚本的 --rpc 支持 `thread/read\ | thread/list\ | turn/interrupt\ | model/list` |
| 同类调研 | docs/research/multi-agent-harness-2026.md(在 origin/agent/compute-scheduler-proxy-fix-* 分支) | 20+ 对象对照;结论是"任务控制面 + agent 执行器 + 持久执行底座"三层组合,无单一万能框架 | git show <branch>:docs/research/... | |||
| 汇报体系 | 两个独立的静态汇报服务 | 结果汇报页与设计评审页各自独立服务 | systemctl --user list-units |
2.2 五套 launcher、四类会话格式的真实形态
下面按 launcher 列(本机装了五个),但会话格式只有四类:codex 家族共用一种,Cursor / Claude / DSH 各自一种——这决定了 §3.2 只需要四个 adapter,而不是五个。
| 维度 | codex | codex-api | codex-ds | agent (Cursor) | claude |
|---|---|---|---|---|---|
| home | ~/.codex | ~/.codex(共享) | ~/.codex-ds(隔离) | ~/.cursor | ~/.claude |
| 会话产物 | sessions/<Y>/<M>/<D>/rollout-<ts>-<uuid>.jsonl(较旧为同名 .jsonl.zst 压缩档) | 同上 | 同上(独立目录树) | chats/<md5(cwd)>/<chatId>/{store.db,meta.json} | projects/<slug(cwd)>/<sessionId>.jsonl |
| 列表/索引 | session_index.jsonl(id + thread_name + updated_at);thread_history_1.sqlite(thread_items/thread_turns);app-server thread/list | 同左 | 同左(独立库) | 目录枚举 + ~/.cursor/projects/<slug>/agent-transcripts/<chatId>/*.jsonl | 目录枚举 + history.jsonl(display/sessionId/project) |
| 非交互执行 | codex exec / exec --json | 同左(--profile codex-api) | 同左 | agent -p --output-format json | claude -p --output-format json |
| 原生 resume | codex exec resume <uuid>、codex resume、codex fork | 同左 | 同左 | agent -p --resume <chatId> | claude --resume <id>、--continue、--session-id、--fork-session |
| 会话 id 回读 | exec --json 首行 {"type":"thread.started","thread_id":...} | 同左 | 同左 | JSON 结果里的 session_id | JSON 结果里的 session_id |
| 双写保护 | 有:thread-writer-locks/<uuid>.lock,冲突即报错 | 同左 | 同左 | 有:Persistent chat ownership is currently changing | 未实测 |
| 投递/排队到运行中会话 | codex queue --thread <t> --message <m> [--resume](文档存在,未实测) | 同左 | 同左 | 无 | 无 |
| 规模化现状(本机) | 数千 文件 / 数 GB;索引仅 441 条 | 与 codex 同库 | 93 文件 / 83 MB | 49 chat / 606 MB | 18 jsonl / 1.3 MB + 829 条 history |
DSH 不在这五个 launcher 里:它是本机dsh-superagent的宿主,会话在~/.dsh/sessions/<slug(cwd)>/<session-id>/(session.v4.jsonl.zstd+session.lock)。它作为第四类会话格式出现在 §3.2 的 adapter 表里;落位后可 resume 未验证(R14 只验证了它会显式拒绝跨目录采用)。
codex agents只能起 TUI(无 TTY 时报TERM is set to "dumb"或stdout is not a terminal),不可脚本化;脚本化入口是 app-server 的thread/list/thread/read,以及codex exec --json。
2.3 resume 行为的实测结论
以下均为本机当日真实执行(均在临时目录中进行,探针会话事后已清理):
| # | 场景 | 命令要点 | 结果 |
|---|---|---|---|
| R1 | 同 runtime、同 home、换 cwd resume | codex exec resume <uuid>,从一个临时目录续另一个临时目录里的会话 | 成功,正确回读上一轮 token |
| R2 | 同 home、换 provider resume | codex-api exec resume <codex 会话> | 拒绝:This chat uses openai, but your current provider is vendor-gateway. Switch to openai to continue.(-32600, modelProviderMismatch) |
| R3 | 换 provider 但显式覆盖路由 | codex-api exec resume <uuid> -c model_provider='"openai"' -m <official-model> | 成功,回读正确 |
| R4 | 用未定义该 provider 的客户端覆盖 provider 名 | codex exec resume <vendor-gateway 会话> -c model_provider='"vendor-gateway"' -m <vendor-model> | 会话打开成功(thread.started 同 id),但模型被拒:Unsupported model —— provider 名不够,还要有该 profile 的 provider 定义与请求头 |
| R5 | 用 ChatGPT 登录跑 DeepSeek 模型 | codex ... -m <vendor-model> | 拒绝:The '<vendor-model>' model is not supported when using Codex with a ChatGPT account. |
| R6 | 换 home resume(不落位) | codex-ds exec resume <~/.codex 的 uuid> | 失败:no rollout found for thread id <uuid> |
| R7 | 换 home resume(落位后) | 把 rollout jsonl 复制进 ~/.codex-ds/sessions/<Y>/<M>/<D>/,再 codex-ds exec resume <uuid> | 成功,回读正确 → 跨 home 交接 = 复制会话产物 |
| R8 | 双写 | 同一 live thread 再起一个 codex-api exec resume | 报错:thread-store conflict: thread <uuid> already has an active writer |
| R9 | Claude 换 cwd resume(不落位) | claude --resume <id> 在另一目录 | 失败:No conversation found with session ID |
| R10 | Claude 落位后跨 cwd resume | 把 jsonl 复制进 ~/.claude/projects/<slug(新cwd)>/ | 成功,回读正确 |
| R11 | Cursor 换 cwd resume(不落位) | agent -p --resume <chatId> 在另一目录 | 不报错、上下文为空:回答"I didn't print a token",即"续上了空白会话"。静默失败 |
| R12 | Cursor 落位后跨 cwd resume | 把 chats/<md5(cwd)>/<chatId>/ 复制进 <md5(新cwd)>/ | 成功,回读正确 |
| R13 | Cursor 并发 | 同一 chat 两进程 | 报错:Persistent chat ownership is currently changing |
| R14 | DSH 换 cwd 采用会话 | dsh --profile headless --session-id <id> 从另一目录 | 拒绝:session "<id>" was recorded in "<a>", not "<b>"(显式校验,优于 Cursor 的静默) |
| R15 | 跨 runtime:Codex 跑 Cursor 导入线程 | codex exec resume <imported_thread_id>(来自 external_agent_session_imports.json) | 成功。该文件现有 80 条(Claude 38 / Cursor 42),配置项 [desktop] external-agent-import-sync-enabled = true、...-item-types = "all" |
由实测直接推出的三条设计约束:
- C1 交接 = 落位 + 路由 + 独占。三者缺一,resume 要么失败、要么静默丢上下文、要么双跑。
- C2 不能只看退出码。Cursor 在错误 cwd 下"成功"返回。平台必须做上下文回读校验(续接后问一个只有原会话才知道的事实),把通过/不通过写进回执。
但这条校验本身要诚实:它只能证明"这条会话还记得某件事",不能证明"历史无损"。
而且校验要发一个 turn,本身就会写进会话、可能触发工具调用。所以:
- 校验必须禁用工具、只读(
--sandbox read-only或等价),并且校验那一轮要标记为校验轮,不计入任务历史; - 回执要分开记四件事,不能合成一个
passed:identity(是同一个会话 id)、recall(记得旧事实)、
route(provider/model 真的是目标)、work(工作目录与产物还在)。四者都可能单独失败;
- 无法证明的部分(附件、工具状态、被压缩掉的历史)要写进回执的
unproven字段,
而不是让 passed: true 暗示全都验证过了。
- C3 跨 runtime 只能重建、不能伪装。只有 Codex 具备(单向)导入;其余方向必须显式标记为
converted(摘要 + 关键产物 + 新会话),不能报成"同一会话已恢复"。
3. 缺口模块:输入、输出、依赖
约定:所有模块读写三个既有事实源,不自造第二份真相——
csched(工作/资源)、各 runtime home(会话)、orchestrator.sqlite(工单)。
新增的只有关联与索引,它们的每一行都必须可回溯到上面三者之一。
3.1 数据模型(新增两张表,放在 csched 库里)
-- 会话注册表:平台内稳定身份,指向某个 runtime home 里的真实会话
CREATE TABLE IF NOT EXISTS sessions (
session_uid TEXT PRIMARY KEY, -- 平台 id:<runtime>:<native_id>(**不含 home**,见 §3.1.1)
runtime TEXT NOT NULL, -- codex | codex-api | codex-ds | cursor | claude | dsh
native_id TEXT NOT NULL, -- 各 runtime 自己的 id(codex uuid / cursor chatId / claude session uuid / dsh session-id)
home TEXT NOT NULL, -- 最近一次已知的 home;权威位置以 session_copies 为准
cwd TEXT NOT NULL, -- 记录时的工作目录(Cursor/Claude/DSH 的可达性键)
provider TEXT, -- 会话记录的 model_provider;空表示 runtime 无此概念
model TEXT, -- 会话记录的模型
store_path TEXT NOT NULL, -- 会话产物在磁盘上的路径(不复制内容进库)
title TEXT,
writer_state TEXT NOT NULL DEFAULT 'free', -- free | leased | contended(见 §3.5)
parent_uid TEXT, -- fork/导入来源
origin TEXT NOT NULL, -- native | imported | relocated | converted
size_bytes INTEGER, turn_count INTEGER,
last_active_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS sessions_native ON sessions(runtime, native_id);
CREATE INDEX IF NOT EXISTS sessions_cwd ON sessions(runtime, cwd, last_active_at DESC);
-- 物理副本:一次落位一行。逻辑会话(sessions)与其副本(copies)分表,
-- 否则"同一个对话在 x 处与 y 处"无法表达,锁也就锁不住两个写者。
CREATE TABLE IF NOT EXISTS session_copies (
copy_id TEXT PRIMARY KEY, -- <session_uid>@<home_sha8>@<cwd_sha8>
-- 必须含 cwd:Cursor/Claude 的可达性键是 cwd,
-- 同一 home 下换 cwd 落位是合法操作
session_uid TEXT NOT NULL, -- 指向 sessions;一个会话可有多个副本
home TEXT NOT NULL,
cwd TEXT NOT NULL,
store_path TEXT NOT NULL,
content_hash TEXT, -- 该副本最后一次已知内容哈希(GC 与交接校验都依赖它)
authority TEXT NOT NULL CHECK (authority IN ('primary','stale','archived')),
-- primary:runtime 正在写的位置
-- stale:确实过时、可回收
-- archived:人工留档,不自动回收
last_seen_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS one_primary_copy
ON session_copies(session_uid) WHERE authority='primary';
-- 关联:一个 job / 一个 ticket 可以先后用过多个会话(故障转移、交接、重建)
CREATE TABLE IF NOT EXISTS session_links (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at REAL NOT NULL,
session_uid TEXT NOT NULL,
job_id TEXT, -- csched jobs.job_id
ticket_id TEXT, -- orchestrator ticket id(仅作外键语义,不跨库约束)
role TEXT NOT NULL, -- primary | continuation | replacement | imported
receipt TEXT, -- JSON:交接回执(见 3.4)
actor TEXT NOT NULL
);
-- 交接事件:append-only,只追加不改写。描述"发生了什么"。
CREATE TABLE IF NOT EXISTS handoff_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at REAL NOT NULL,
handoff_id TEXT NOT NULL, -- 关联到 handoff_state,事件本身不更新
from_uid TEXT, to_runtime TEXT NOT NULL, to_home TEXT NOT NULL, to_cwd TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('native','relocated','converted')),
lease_id TEXT, fencing_token INTEGER,
kind TEXT NOT NULL, -- planned|materialized|verified|failed|abandoned|authority_switched
detail TEXT, -- JSON:该步的证据(哈希、校验结果、错误)
actor TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS handoff_events_by_id ON handoff_events(handoff_id, seq);
-- 交接当前状态:由事件推导出的投影,可以被更新(这就是它与事件表分开的原因)。
CREATE TABLE IF NOT EXISTS handoff_state (
handoff_id TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK (state IN ('planned','materialized','verified','failed','abandoned')),
baseline_hash TEXT, -- 交接那一刻的不可变基线(GC 只认它,不认"最后已知")
verify TEXT, -- JSON:C2 的上下文回读结果
error TEXT,
updated_at REAL NOT NULL
);
3.1.1 为什么逻辑会话与物理副本必须分表
第一版把 home 编进 session_uid,于是同一个对话在两个 home 里就是两个 uid。
落位之后源与目标各持一个 uid,两个写者可以各自合法地拿到"这个会话"的锁,
而这正是 M4 要防的事。评审把这条列为阻塞项是对的。
修正后的模型:
sessions:逻辑会话,id 只由runtime + native_id决定。锁、租约、session_links、
工单关联全部挂在它上面——一个对话永远只有一个锁。
session_copies:物理副本,一个逻辑会话在几个 home 里存在就有几行;
authority='primary' 的那一行是当前权威位置,由部分唯一索引保证至多一个。
- 落位的语义因此变成"把 primary 从 A 副本切到 B 副本",而不是"产生一个新会话"。
切换是一次带条件的更新:旧 primary 与新 primary 在同一事务内改,
失败则整体回滚,不会出现两个 primary。
authority 的三种取值是必要的:stale 表示"这份确实过时了但还留着",
archived 表示"人工决定留档"。只有 stale 且哈希与 primary 一致时才可回收。
设计取舍:
- 不存对话内容。会话正文留在各自 home,库里只存路径、哈希、标题与统计。理由:体积(本机 Codex 会话 数 GB、数千 个文件)、隐私边界、以及"会话格式属于各 runtime"这一事实。
session_uid不带 home。第一版把 home 编进 id,于是同一对话在两个 home 里就是两个会话、
两把锁——落位后两个写者各自"合法"。现在 id 只由 runtime + native_id 决定,home 属于副本
(session_copies)。代价是必须显式规定 runtime 归一规则:codex/codex-api 是**同一个
runtime 的不同路由**(共享 home 与会话库),codex-ds 虽有独立 home 但会话格式相同,
归一为 runtime=codex, route=<official|vendor> 加不同 home;否则同一个对话仍会被登记两次。
cwd是一等字段。Cursor/Claude/DSH 的可达性由 cwd 决定(R9/R11/R14),提前存下就能在交接前判断"能否 native"。
3.2 M2 Runtime Adapter:统一能力位
一个 adapter 只做五件事,且必须先声明能力再做:
capabilities(): {
can_list, can_read_transcript, can_resume_native, can_relocate,
portable_unit: "file" | "dir" | "none", # 复制什么
cwd_bound: bool, # 是否要求原 cwd
provider_bound: bool, # 是否把 provider 写进会话
needs_route: bool, # resume 命令是否必须带 provider/model
exclusive: "lockfile" | "ownership" | "none",
id_from: "stdout_json" | "index" | "fs_scan"
}
list(opts) -> [SessionMeta]
read(uid, tail=N) -> TranscriptPage
# 落位分三步,缺一步就会"复制成功但 runtime 找不到":
stage(uid, target{home,cwd}) -> StagedCopy # 1. 复制到平台暂存区 sessions/<uid>/payload/
install(staged, target) -> PlacementReceipt # 2. 安装到目标 runtime 真正查找的位置
# 必须处理:目标已存在同名会话(冲突)、
# 安装中途失败(回滚到安装前)、目标已在被写(拒绝)
resume_argv(uid, target, prompt, route) -> [argv] # 纯函数,可单测
verify(uid, target) -> VerifyReceipt # C2 的上下文回读
| runtime | portable_unit | cwd_bound | provider_bound | needs_route | exclusive | id_from |
|---|---|---|---|---|---|---|
codex / codex-api / codex-ds | file(rollout .jsonl 或 .jsonl.zst,保持原名) | 否(R1) | 是(R2/R3) | 是(跨 provider 时) | lockfile | stdout_json |
cursor | dir(chats/<md5(cwd)>/<chatId>/) | 是(R11/R12) | 否 | 否 | ownership | stdout_json |
claude | file(projects/<slug(cwd)>/<id>.jsonl) | 是(R9/R10) | 否 | 否 | 未实测 | stdout_json |
dsh | dir(sessions/<slug(cwd)>/<id>/) | 是(R14,且显式拒绝) | 否 | 否 | lockfile(session.lock) | stdout_json(headless 首行 {"type":"session","sessionId":...}) |
adapter 的三个反模式(本机已踩到,必须写进实现约束):
- 不要把
codex与codex-api当两个 runtime。它们共享 home、共享会话库,只差 provider 路由;当成两个 runtime 会重复注册同一会话(sessions_native唯一索引会挡住,但语义仍错)。正确做法:runtime=codex,route=openai|vendor-gateway。 - 不要用
codex agents。它要求 TTY,脚本里不可用(实测两种失败)。 - 不要相信 Claude/Cursor 的
--resume退出码(R11)。必须verify。
3.3 M1 Session Registry:同步与索引
输入:list() 的结果(周期扫描 + 事件驱动补扫)。
输出:sessions 表的 upsert;给 M5 提供查询。
关键行为:
- 增量扫描:以
store_path的(mtime, size)为指纹,未变则跳过(fleet history已用同样策略,可直接复用)。 - 索引缺口要报出来:
~/.codex/session_index.jsonl441 条 < 数千个会话文件(其中 数千 个是.jsonl.zst),说明"仅靠索引答不出全部会话"。Registry 以文件扫描为准、索引为补充(.jsonl与.jsonl.zst都认),并把差异计数写进健康检查(不静默)。 - 不搬内容:
store_path是唯一定位;跨 home 落位由 M3 复制,Registry 只记录"现在有几份"。
3.4 M3 Handoff Engine:三种模式与回执
handoff(session_uid, target{runtime?, home, cwd}, route?, prompt?, lease_id?) -> HandoffReceipt
判定顺序(先便宜后昂贵,先无损后有损):
| 顺序 | 条件 | 模式 | 动作 | 后果 |
|---|---|---|---|---|
| 1 | 同 runtime、同 home、同 provider,且 cwd 可达(或 adapter 不绑定 cwd) | native | 不复制,直接 resume_argv | 无损 |
| 2 | 同 runtime、不同 home 或不同 cwd,can_relocate 且 portable_unit != none | relocated | 复制产物到目标查找位置 → resume_argv | 无损(源保留,可回滚) |
| 3 | 不同 runtime,且目标 runtime 支持导入该来源(当前仅 Codex ← Claude/Cursor) | relocated(标记 imported) | 走目标 runtime 的导入通道(Codex 已有 external_agent_session_imports.json 机制) | 近似无损,但是目标 runtime 的副本,不回写 |
| 4 | 其余 | converted | 导出摘要 + 关键产物引用 + 未完成事项 → 在目标 runtime 新建会话 | 有损,回执里必须写 lossy: ["对话逐字记录", "思考过程", ...] |
回执(写入 handoff_events.detail,同时作为 job/ ticket 的附件):
{
"handoff_id": "hf_...",
"uid": "codex:01a197b9-...",
"from": {"copy": "codex:01a197b9-...@3f9a12c8@aa17b0e4", "home": "~/.codex",
"provider": "openai", "model": "<official-model>", "cwd": "<探针目录>"},
"to": {"copy": "codex:01a197b9-...@b47d90c1@aa17b0e4", "home": "~/.codex-ds",
"provider": "vendor-gateway", "model": "<vendor-model>"},
"runtime": "codex",
"mode": "relocated",
"materialized": {"copied": ["sessions/2026/11/01/rollout-...jsonl"], "sha256_match": true},
"route": {"provider_override": "vendor-gateway", "model": "<vendor-model>"},
"verify": {"method": "context_recall", "question": "上一轮输出的 token 是什么",
"expected_hash": "...", "observed_hash": "...", "passed": true},
"lossy": [],
"lease": {"lease_id": "l_...", "fencing_token": 7}
}
回执是这套平台的核心产品物:它把"我交接成功了"从一句话变成可复核的证据(复制了什么、哈希是否一致、回读是否通过、路由是什么、占的是哪个租约)。没有回执的交接一律记为 failed。
3.5 M4 Session Lease:独占写者
输入:handoff_state 的 planned 状态 + csched 的 lease_id/fencing_token。
输出:允许/拒绝 resume;sessions.writer_state 上的"当前写者"视图(free | leased | contended)。
与 csched 现有语义的一处必要偏离:csched 的租约过期即释放,因为远端进程本身就是它的任务; 会话锁不能这样。租约过期只说明心跳没到,不说明写者没了。所以 M4 在 csched 租约之上 多加一层"写者是否确认退出"的判断,两者都满足才放锁。这不是重复实现,是刻意更严格。
规则:
- 一个
session_uid同时只允许一个写者(state in ('leased','running')的 lease)。 - 写者身份写进 resume 命令的环境变量(如
PLATFORM_SESSION_TOKEN=<uid>:<fencing_token>),并在每次 resume 前校验 token 仍是当前值——旧 worker 晚到不得写回(与 csched 现有的 fencing 语义一致)。 - runtime 自带的锁只能当第二道:Codex 的
thread-writer-locks、Cursor 的 ownership、DSH 的session.lock都是本机进程级,跨机器/跨用户都不可靠,且 Cursor 在错误 cwd 下根本不触发(R11)。平台必须自己持锁。 - 锁的清理,按原因分两档,不能一律释放:
| 原因 | 是否释放 | 说明 |
|---|---|---|
| 进程已确认退出(进程层面证据) | 释放 | 唯一可以自动释放的情况 |
| handoff_state.state='failed',且确认无存活写者 | 释放 | 只有"进程从没起来"或"进程已确认退出"才算;failed 本身只表示交接失败,不表示没有写者。resume 已经起了进程再失败(如 verify 不过)就是反例 |
| 租约超时 | 不自动释放 | 超时只说明"我没收到心跳",不说明进程没了;转 contended |
| 主机失联 / SSH 不可达 | 不自动释放 | 见下表,这是最容易误判的一种 |
卡住的锁要有 TTL 与"强制接管"审计(接管必须是 human,且记录谁、为什么)。
TTL 到期只产生"可以接管"的提示,不自动放锁——自动放锁等于自动制造双写者。
这条规则有一个本文尚未解决的缺口,必须写明而不是留给实现时发现:
fencing token 只能保护经过平台的写入。会话产物是 runtime 自己写磁盘的:
一个被判定失联、租约已回收的旧进程,如果还活着,它的写入不经过平台校验,
-platform 无法拒收。因此「旧 worker 不得写回」在会话场景下不是靠 token 保证的,只能靠先杀进程再放锁。
据此 M4 的实际语义分两种,且不能混称:
| 场景 | 能保证什么 | 怎么做 |
|---|---|---|
| 进程已确认退出 | 真正独占 | 证据必须是进程层面的:本机 pid + 启动时间 与 /proc/<pid> 不存在,或远端 kill -0 明确返回"无此进程" |
| 进程状态未知(含主机 SSH 不可达、只读探测超时) | 什么都不能保证,也不能当作已退出 | 标 contended;接管需 human 权限;接管后先尝试终止旧进程,仍失败就保持 contended 并拒绝新写者 |
一条必须写死的纠错:SSH 不可达 不是"进程已退出"的证据,而是最危险的反例。
网络分区、隧道断开、跳板机故障、目标机负载过高都会让 SSH 失败,而这些场景里旧进程
往往正在正常写会话文件。把"连不上"当成"没在跑",等于在最需要互斥的时候放开第二个写者。
可达性只能证明"我现在控制不了它",不能证明"它没在写"。
contended 是一个必须存在的状态:它诚实地表示"我们知道可能有两个写者,且我们没有解决"。
把它和 ready 混在一起,就是本文反复强调的那种"看起来成功"的失败。
3.6 M5 Work Link:两条账本的连接
| 方向 | 输入 | 输出 | 谁写 |
|---|---|---|---|
| job → session | csched submit 的参数 | 提交时写入 session_links(role=primary) | Gateway |
| session → job | exec --json 首行的 thread_id / JSON 的 session_id | 回填 sessions.native_id + store_path | Adapter |
| ticket → job | DSH 的 Assignment | 派工时写 session_links(ticket_id=...);回报时带 job_id | 双方(单向写各自库,不跨库事务) |
| ticket → session | Assignment 的 Executor | 现有 worker-<ticket id> 概念扩展为"任意 runtime 的会话" | DSH |
跨库不做分布式事务。 DSH 与 csched 各自提交,用幂等键 + 补偿扫描收敛:DSH 写 link 失败则在下一次 tick 重试;csched 找不到对应 ticket 时保留孤儿 link 并在看板"异常"里挂着,不静默删除。
3.7 M6 Unified Gateway:一个入口
输入:CLI / HTTP /api/v1/... / MCP tool。
输出:与直接调 csched 完全相同的对象(同一份状态)。
platform submit --runtime codex-ds --gpus 0 --command "..." --session-policy reuse|new|any
platform sessions [--runtime --cwd --job --ticket] [--stale]
platform resume <session_uid> --target codex-ds --cwd <dir> [--prompt-file f] [--lease ...]
platform handoff <session_uid> --to cursor --cwd <dir> [--convert]
platform link --job <id> --ticket <id>
platform verify <session_uid> # 上下文回读校验(C2)
platform health # adapters 能力位 + 索引缺口 + 锁占用
MCP 工具(csched 目前没有,属于补强):
platform.list_sessions(filters) platform.resume(session_uid, target)
platform.submit_work(spec) platform.work_status(id)
platform.handoff(session_uid, target) platform.quota_status()
权限边界(沿用 csched 现有三档 token,不新造):
- agent token:可读会话、提交自己的工作、恢复自己持有租约的会话;
- human token:可强制接管锁、可交接他人会话、可设置
converted策略; - read token:看板只读。
3.8 四个补强(M7–M10)
M7 Agent 注册:csched agents 现在是空的。每个 agent 启动时上报
{agent_id, kind, runtime, home, provider, model, host, cwd, capabilities{tools, sandbox, max_concurrency}},
心跳 60 秒。用途:交接前判断"目标 runtime 是否在线、能否用这个模型"(否则就是 R4/R5 的失败)。
M8 事件与通知:把三类事件统一成 {at, actor, kind, object_id, payload, seq}:
csched 的 events(已有、append-only)、DSH 的 outbox 回执(已有)、fleet 的扫描事件(无,需补)。
对外只提供可补读的游标(since=seq)与去重键(kind+object_id+seq),不承诺"必达"。
M9 凭据与路由:每个 runtime 的 provider 定义不同(R4 的教训)。
路由描述必须包含 launcher + profile + provider + model + credential_file,
交接时整块搬运,只搬配置位置、不搬密钥内容(沿用 registry.py 的凭据检测与 agent-fleet/maas-key 单文件模式)。
3.9 M11 故障自愈:自动修到好,并由确定性复检确认
用户要求:出现故障后能自动触发 agent 定位问题并解决。这一节先说清已经有什么,再说缺什么, 否则会重复造已经存在的部件。
3.9.1 已有的那一半(本机实测,2026-11-02)
| 环节 | 已有实现 | 位置 |
|---|---|---|
| 发现 | 每轮 tick:不可达机器 → repair-<id>;环境类失败任务 → task-<id>;应用/认证错误不派单 | caretaker.dispatch_failures |
| 交接 | 工单写入 inbox/(一单文件 + 索引),供 agent 读取 | caretaker.export_inbox |
| 认领 | csched maintain claim,防两 agent 抢同一单 | store.claim_maintenance |
| 执行 | dsh headless 跑一次,带超时、私有工作目录、护栏提示词 | maintainer.run_job |
| 记录 | 结论/耗时/退出码写回工单,日志落 maintenance-logs/ | 同上 |
| 关闭 | 有结论 → done;超时/非零/只有 banner → dropped(保持可见) | 同上 |
这条链路是能用的:已有若干工单闭环,其中一单真的找出了控制面的缺陷(见 3.9.8)。
3.9.2 缺的那一半(同一时刻的实测数字)
| 缺口 | 实测证据 | 后果 |
|---|---|---|
| 修完不验证 | maintenance_jobs 没有"修复后复检"字段;done 只表示 agent 说完了 | 无法回答"这个故障真的没了吗",只能回到 open 重新派单 |
| 没有升级路径 | state 只有 open/claimed/done/dropped | 同一条修不好的工单会永远在 open 里循环 |
| 积压远大于吞吐 | 同一时刻 八十余单 open(其中一部分的对象已被取消),而定时器 每 30 分钟只跑 1 单 | 新故障排在 40 小时之后才可能被看到 |
| 工单不会因状态变化而关闭 | 有一部分 task-* 的对象任务已是 cancelled,工单仍 open | 队列里混入"问题已不存在"的噪声,稀释真正的告警 |
| 默认只读 | compute-scheduler-maintainer.service 的 ExecStart 无 --allow-remote-changes | agent 只能诊断不能修;"定位问题"做到了,"解决问题"没有 |
| 证据不足以定位 | 绝大多数 failed_task 单的 error_class 是 deploy,payload 的 last_detail 只是 <本机路径>/.compute-tasks/<id> 一个路径 | agent 拿到的是"失败了",不是"为什么失败" |
| 同类故障不合并 | 大量 deploy 失败其实分属少数几条命令的重复提交 | 逐单派工等于把同一个根因反复诊断 |
3.9.3 M11 的五个阶段与契约
detect ──► locate ──► repair ──► verify ──┐
▲ ▲ │ 未通过
│ └────── 换一种诊断 ◄────────┤(同类失败达阈值)
│ │
└────────── 退避后继续 ◄────────────────┘
│ 只有下列情况才停
▼
告知人(停手原因)+ 保持 open
| 阶段 | 职责 | 输入 | 输出 | 谁来判定 |
|---|---|---|---|---|
| detect | 从事件流识别"值得处理"的故障 | resources/jobs 状态、health 探测、metric 阈值 | Incident | 确定性规则(不靠模型) |
| locate | 定位根因,产出可执行的判断 | Incident + 证据包 | Diagnosis{root_cause, confidence, evidence[], proposed_action, blast_radius} | agent |
| repair | 执行修复 | Diagnosis.proposed_action | RepairReceipt{commands, before, after, rollback} | agent(受策略约束) |
| verify | 证明故障消失,不是"agent 说修好了" | Incident 的原始检测条件 | verify=PASSED/FAILED | 确定性复检 |
| notify | 让人知道有漏洞、修没修好 | 状态变化 + 复检结果 | 一条可行动的消息 | 确定性规则(见 3.9.9) |
核心原则:locate 和 repair 可以交给模型,verify 绝不能。
验证必须回到当初检测到故障的那条判据上重跑一次(同一探测、同一阈值、同一对象)。
模型说"已修好"只作为 Diagnosis 的一部分记录,不作为关闭工单的依据——这正是本文反复强调的
"不要用 agent 的自述替代证据"。
3.9.3.1 修到好为止:重试没有次数上限
第一版这里写的是"连续失败 3 次即升级、不再自动重试"。那是错的,它和"故障要被持续修好"直接冲突:
一个还在坏着的故障,因为"试了三次"就退出队列,和修好了在表面上没有任何区别——
队列变干净了,系统还是坏的。
正确的边界不是次数,而是原因。自动重试只在以下三种情况下停:
| 停手原因 | 含义 | 谁处理 |
|---|---|---|
needs a human decision | 下一步需要人授权(影响面超出自动范围) | 人 |
fault is outside what this system may repair | 不是我们能修的东西(他人的进程、共享机器状态) | 责任人 |
next attempt would cost more than the fault | 继续修的成本高于故障本身 | 人决定是否接受 |
三条都不是"我们试过但没成功"。失败本身永远不构成放弃的理由。
配套的三条机制:
- 退避到上限而不是退避到停止。第 N 次失败后等待
min(60s × 2^(N-1), 6h),
一个断掉的隧道会每几小时重试一次,永远——而不是试三次之后被遗忘。
- 同类失败要换诊断,不是加大力气重试。同一
signature的修复连续失败 3 次,
说明诊断错了而不是运气差;此时回到 locate 拿新假设,而不是把同样的动作再做一遍。
这是"持续修"和"死循环"的分界线:修的是方法,不是重试次数。
- 只有人能把故障判为不修。
wontfix必须由人写入并带理由;系统自己无权把一个
open 的故障标成"就这样了"。
核心原则:locate 和 repair 可以交给模型,verify 绝不能。
验证必须回到当初检测到故障的那条判据上重跑一次(同一探测、同一阈值、同一对象)。
模型说"已修好"只作为 Diagnosis 的一部分记录,不作为关闭工单的依据——这正是本文反复强调的
"不要用 agent 的自述替代证据"。
3.9.4 数据模型增量
-- 故障事件:一次故障一行,与维护工单是 1:N(同一故障可能被处理多次)
CREATE TABLE IF NOT EXISTS incidents (
incident_id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- unreachable | deploy_failure | lease_stuck | quota_exhausted | ...
subject TEXT NOT NULL, -- resource_id 或 job_id
signature TEXT NOT NULL, -- 根因指纹:kind+subject+归一化错误串的哈希,用于合并同类
detected_at REAL NOT NULL,
detect_rule TEXT NOT NULL, -- 当初触发的那条判据,verify 必须复用它
detect_args TEXT NOT NULL, -- JSON:阈值、探测目标、期望值
state TEXT NOT NULL CHECK (state IN
('open','diagnosing','repairing','verifying','resolved','wontfix')),
-- 没有 escalated:系统无权放弃一个仍 open 的故障
severity INTEGER NOT NULL DEFAULT 10,
verify_attempts INTEGER NOT NULL DEFAULT 0,
repair_attempts INTEGER NOT NULL DEFAULT 0,
updated_at REAL NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS one_open_incident_per_signature
ON incidents(signature) WHERE state IN ('open','diagnosing','repairing','verifying');
三条设计约束:
signature去重。同一kind+subject+归一化错误只允许一个活跃 incident。
重复的 deploy 失败应合并成少数几个 incident,而不是几十张单。
detect_rule+detect_args必须落库。没有它们,verify 就退化成"再跑一次 agent 看看",
而那正是当前失败模式。
- 维护工单变成 incident 的执行记录,不再是独立实体:
maintenance_jobs加incident_id,
一单对应一次 repair 尝试。这样"同一故障试了 3 次"是查得出来的。
3.9.5 修复的授权分级:L1/L2 默认开启
默认只读让"解决问题"永远做不到——agent 只能诊断,故障永远留在原地。
用户已明确要求尽可能自动修复、减少人的参与,因此 L1 与 L2 默认开启,L3 永不开。
| 级别 | 允许的动作 | 默认 | 冷却 | 例子 |
|---|---|---|---|---|
| L0 只读 | 探测、读日志、读台账 | 始终允许 | — | 任何故障的第一步 |
| L1 本机可再生 | 重启本机 unit、重连隧道、清本任务 scratch | 开 | 60 秒 | 隧道断了重连 |
| L2 本系统所有 | 重跑失败任务、重建租约、换机重试 | 开 | 300 秒 | deploy 失败后换机 |
| L3 共享/他人 | 改远端系统配置、动别人进程、删他人数据 | 永不开 | — | 修共享机器 |
判据只有一条:坏了能不能自己长回来。隧道、服务、租约"重连即可",可以自动;
共享机器上的进程状态不由我们定义,必须人确认。这条线写死,不交给模型判断。
四条实现约束(已落地为 scripts/incident_verify.py):
- 故障族 → 级别是显式映射,不是推断。新故障类型默认 L0(只读),
必须有人给它的影响面定级后才会自动修。默认值取安全的那个方向。
- L3 打开会直接抛异常,不是静默忽略——避免某天有人"顺手"把共享机器加进自动列表。
- 每个自动动作必须先有回滚方案。
restart_unit/reconnect_tunnel/retry_job/rebuild_lease
都有对应的 undo;没有 undo 的动作一律要求人工,而不是"先做了再说"。
- 冷却防修复风暴。一秒钟重启八次本身就是一次故障;L1 冷 60 秒、L2 冷 300 秒。
每次自动动作写一条审计:{级别, 动作, 回滚方案, 第几次尝试, 检测规则, 复检结果, 执行者}。
审计里必须能回答"这个动作为什么被允许",而不只是"做了什么"。
3.9.5.1 修完之后怎么测:每类故障的判定规则
"再检查一下"不是规则。规则必须写成 判据 + 期望值 + 数据来源,并且只能用当初触发故障的那条判据。
下表每一行都已落地为 scripts/incident_verify.py 中的同名函数,并有对应测试。
| 故障族 | 复检规则 | 判据(数据来源) | 通过条件 | 为什么不能更简单 |
|---|---|---|---|---|
tunnel_down | tunnel_listening | 本地端口 listening + 一次端到端请求 | 两者都成立 | 只听端口是最典型的假阳性:监听在、对端没了,请求全挂 |
local_service_down | health_field | systemd ActiveState + 一次真实请求 | active 且请求成功 | 进程起得来不等于服务可用 |
unreachable | health_error_cleared | 探测返回的 error 串(health.REPAIRABLE_ERRORS / inventory.REPAIR_HINTS) | error 为空且 reachable,或 error 不再命中该故障当初的分类标记 | 端口通了不等于机器可用(sshd 刚起、磁盘满、无 nvidia-smi 都可能) |
deploy_failure | deploy_healthcheck | 新 attempt 的 state + 该 attempt 的 healthcheck 结论 | state ∈ {running, succeeded} 且 healthcheck 通过 | 现有 75 个 deploy 失败里,detail 只有路径或 healthcheck failed——"running 但 healthcheck 失败"必须判失败 |
lease_stuck | lease_alive | lease state + heartbeat_age_s | 已释放,或心跳年龄 ≤ 阈值(默认 900s) | 只看 state=running 会把僵尸租约当成活的 |
quota_exhausted | quota_available | 配额池 available vs 需求卡数 | available ≥ need | 无上限的池 available=None,不能猜成够用 |
disk_pressure | disk_headroom | 类目录 used_bytes / budget_bytes | 比值 ≤ 阈值 | 删除动作之后要确认空间真的回来了,而不是命令返回 0 |
三条硬规则:
- 故障族与复检规则必须匹配。
deploy_failure配上unreachable的规则,会去问一个
与故障无关的问题——可能故障还在却判通过,也可能真修好了却永远判失败。两者都比"拒绝验证"更坏,
所以不匹配时直接拒绝验证并标失败。
- 指标缺失 = 失败,不是跳过。
quota available=None、heartbeat_age读不到,都判不通过。
"没测到"和"没问题"必须分开。
- 只读复检。复检本身不得写会话、不得触发工具调用、不得改状态;它发的是一个只读探测。
否则"验证"会制造新的副作用,甚至自己把故障修好了再宣布通过。
还有一个必须承认的边界:复检查的是"故障现象是否消失",不是"根因是否消除"。
隧道重连后通过,不代表导致断开的那个原因(比如证书快过期、跳板机内存泄漏)解决了;
所以同一 signature 复发会计入 regressed 并立刻汇报,而不是当成新故障。
3.9.6 并发与积压(当前最痛的一条)
现状是每 30 分钟只处理 1 单,而积压远大于此。改为:
- 按 severity 与 age 调度,不是 FIFO:
P0(服务本身不可用)立即跑,P3(历史失败任务)批量处理; - 同类合并:同一
signature的 N 张单只跑一次诊断,其余引用同一次结果; - 有界并发:
max_concurrent_repairs(默认 2),因为同时改两处更容易把状态搞乱; - 失败退避:按 §3.9.3.1 的指数退避继续重试,不因次数停止;只有三种“原因”会停手并告知人。
3.9.7 验证:故障注入,不是"跑一遍看看"
| 要验的事 | 怎么验 | 通过标准 |
|---|---|---|
| 检测漏报 | 人为停一个隧道、制造一次 deploy 失败 | 各自产生 incident,signature 正确 |
| 同类合并 | 连续制造 10 次同一失败 | 只有 1 个活跃 incident,计数 10 |
| verify 不认自述 | 让 agent 报告"已修复"但不真修 | verify=FAILED,incident 不关闭 |
| verify 不误报 | 真修好后 | verify=PASSED,incident resolved,并记录复检证据 |
| 不停手 | 造一个持续修不好的故障 | 反复重试且间隔递增,始终 open;不会因为次数退出队列 |
| 换诊断 | 让修复连续以同一方式失败 | 达到阈值后回到 locate 换假设,而不是重复同一动作 |
| 只有人能放弃 | 无人工介入 | incident 不会自己变成 wontfix |
| 陈旧清理 | 取消一个失败任务后等一轮 | 对应 incident 自动出队(对象不存在),不再占队列 |
| 汇报不刷屏 | 让一个故障连续失败 20 次 | 只收到 1 条首次汇报 + 按退避的少量提醒,不是 20 条 |
| 汇报不撒谎 | 让 agent 报告"已修复"但不真修 | 不发出"已修复"消息 |
| 汇报不吞掉 | 一个真实故障从发生到修好 | 至少收到"发现"与"已修复"两条 |
| 顺带发现成单 | 诊断中报告一个无关缺陷 | 生成新的 incident,并合并进同一汇报 |
| 无人介入自动修好(L1) | 人为断开一条本机隧道 | 不通知任何人即修好;审计留级别/动作/回滚/复检;随后收到"已修复"汇报 |
| 无人介入自动修好(L2) | 人为制造一次 deploy 失败 | 自动换机重试并复检通过;配额与冷却生效 |
| 冷却生效 | 让隧道反复断开 | 修复间隔不小于冷却时间,不出现重启风暴 |
| L3 不被自动碰 | 造一个共享机器故障 | 停在人工,且审计写明"L3 不允许无人值守" |
| 无回滚不动手 | 构造一个没有 undo 的动作 | 拒绝执行并转人工,而不是"先做了再说" |
| 规则不匹配拒验 | 给 deploy 故障配 unreachable 规则 | 直接判 FAILED,不去问一个无关问题 |
3.9.8 已确认的现实缺陷(由现有维护 agent 自己发现)
repair-manaenv-editor-slot0-129-140 的结论指出:控制面 probe_all() 的 _has_endpoint()
只认 kind ∈ {ssh, local},而该资源是 kind=http,于是每个 tick 都被 skipped,health 永远为空。
已复核为真:endpoint 为 http 的资源共 2 个,_has_endpoint 均返回 False,health 均为空。
这说明自愈链路本身能产出真实缺陷——但也暴露闭环缺失:这个发现写在工单的 summary 文本里,
没有任何机制把它变成一个带负责人的待修项。M11 的 Diagnosis 应当能产出这种"顺带发现的缺陷",
并允许它转成 incident 而不是埋在一段中文里。
3.9.9 汇报:让人知道有漏洞,而不是刷屏
要求是"有漏洞要汇报给我"。这里有两个失败模式,必须同时避开:
- 不报:故障坏了几天没人知道——这正是当前
summary里那句缺陷发现的处境; - 全报:每次重试都发一条,人很快把它静音,之后真故障也看不到。
所以按事件类型决定报不报,而不是按"有没有新消息":
| 事件 | 报不报 | 为什么 |
|---|---|---|
| 首次发现故障 | 立刻报 | 你说"让我知道有漏洞了",这是最该报的一条 |
| 修好且复检通过 | 报 | 有始有终,人才知道可以放心 |
| 修复后复发 | 报 | 复发比首次更严重,说明上次的修复不牢 |
| 停在三种"原因"上 | 报 | 需要人动作,不报就卡住了 |
| 仍在修、还没修好 | 限频报 | 首次之后按 10 分钟起、逐次翻倍、上限 6 小时提醒一次 |
| 每次重试 | 不报 | 刷屏的源头;细节留在 incident 记录里可查 |
消息只写三件事:哪里坏了、现在什么状态、下一步是什么。
不写尝试了几次、跑了什么命令——那些属于记录,不属于通知。
一次重试失败不是新闻,一个还在坏的故障才是。
消息形态(真实存在的失败例):
发现故障:<对象> 出现<故障类型>,已开始自动定位与修复。
<对象> 仍在:复检未通过(<具体判据>),会继续修。
<对象> 的<故障类型>已修复,复检通过。
<对象> 修不动了:<停手原因>。已尝试 N 次,需要你决定下一步。
"已修复"只能由复检结果触发,不能由 agent 的话触发。 否则汇报本身就成了
"把没修好的说成修好了"的通道——那是比不汇报更坏的结果。
3.9.10 顺带发现:自愈过程本身就是缺陷来源
维护 agent 在自己的结论里报告了 _has_endpoint() 不认 kind=http 的缺陷(见 3.9.8)。
这类"修 A 的时候发现 B 坏了"是自愈系统的高价值副产品,但当前它只存在于一段中文
summary 里,没有任何机制把它变成待办。
M11 要求 Diagnosis 带一个 side_findings[] 字段:诊断过程中发现的、与本工单无关但确实存在的缺陷。
它们不改变当前 incident 的状态,但会各自生成新的 incident(走同一套 detect→verify 流程),
并按 §3.9.9 的规则合并进同一条汇报,而不是再发一条消息。
这条的价值在于:系统的缺陷发现能力不再取决于有人恰好读了某条日志。
4. 模块间连接:数据流
4.1 组件与连接(谁调谁)
┌────────────────────────────────────────────────────┐
人 / agent / CI ────► │ M6 Unified Gateway (CLI · HTTP /api/v1 · MCP) │
└───┬───────────────┬────────────────┬───────────────┘
│ │ │
┌─────────────────▼──┐ ┌────────▼─────────┐ ┌───▼──────────────┐
│ M5 Work Link │ │ M3 Handoff Engine │ │ M7 Agent Registry│
│ job↔session↔ticket │ │ native/relocated/ │ │ 谁在线、什么模型 │
│ ↔artifact │ │ converted + 回执 │ │ 什么权限 │
└───┬────────────┬───┘ └───┬──────────┬────┘ └───┬──────────────┘
│ │ │ │ │
┌────────▼──────┐ ┌───▼───────────▼──┐ ┌────▼───────────▼──────────────┐
│ csched 控制面 │ │ M1 Session │ │ M4 Session Lease │
│ jobs/leases/ │ │ Registry │ │ 独占写者 + fencing token │
│ quota/events │ │ sessions/links │ │ (复用 csched 的租约语义) │
└────────┬──────┘ │ handoffs │ └────┬──────────────────────────┘
│ └───┬───────────────┘ │
│ │ │
│ ┌──────▼───────────────────────▼──────┐
│ │ M2 Runtime Adapters (每个 runtime 一套)│
│ │ codex · cursor · claude · dsh │
│ └──┬────────┬─────────┬─────────┬──────┘
│ │ │ │ │
│ ┌────▼───┐ ┌──▼────┐ ┌──▼─────┐ ┌─▼──────┐
│ │~/.codex│ │~/.cursor│ │~/.claude│ │~/.dsh │
│ │+ -.codex-ds│ │ │ │sessions│
│ └────────┘ └───────┘ └────────┘ └────────┘
│
┌────────▼─────────────┐ ┌──────────────────────────────┐
│ dsh-superagent │◄───────┤ 通知/回报(M8 事件游标+去重) │
│ ticket/assignment │ └──────────────────────────────┘
└──────────────────────┘
4.2 一次"派工 → 交接 → 恢复"的时序
调用方 Gateway Work Link Handoff Lease Adapter/Runtime
│ │ │ │ │ │
│ submit(work, │ │ │ │ │
│ session_policy= │ │ │ │ │
│ reuse) │ │ │ │ │
├─────────────────►│ │ │ │ │
│ ├── jobs(POST) ──────────────────────────────────────────► csched
│ │ │ │ │ │
│ ├── 选会话 ─────►│ 查 sessions │ │ │
│ │ │ (runtime, │ │ │
│ │ │ cwd, 最近) │ │ │
│ │ │ │ │ │
│ ├── handoff(uid,target) ─────►│ │ │
│ │ │ ├── 申请租约 ─►│ │
│ │ │ │◄─ lease+token │
│ │ │ ├── materialize ────────────►│ 复制产物
│ │ │ ├── resume_argv ────────────►│ 起进程
│ │ │ ├── verify(上下文回读) ─────►│
│ │ │ │◄── passed/failed │
│ │ │◄── link(job,session) ────┤ │
│◄─ 202 job_id ────┤ │ │ │ │
│ │ │ │ │ │
│ ... 进程崩溃 / 机器失联 ... │ │ │
│ │ │ │ │ │
│ ├── 检测 attempt failed ──────►│ │ │
│ │ │ │ │ │
│ ┌──────────────┴───────────┐ │ │ │ │
│ │ 进程已确认退出? │ │ │ │ │
│ └──────┬──────────────┬────┘ │ │ │ │
│ 是 │ │ 否/未知(含机器失联) │ │ │
│ ▼ ▼ │ │ │ │
│ 释放旧租约 标 contended │ │ │ │
│ 新 attempt 不释放、不重启 │ │ │ │
│ relocated/ 等人工接管 │ │ │ │
│ converted 或旧进程确认退出 │ │ │ │
│ │ │ │ │ │ │
│ └──────┬───────┘ │ │ │ │
│ ▼ │ │ │ │
│ 新回执(含 lossy 与 contended 原因) │ │ │
4.3 关键接口契约(跨模块)
| 契约 | 提供方 | 消费方 | 必须保证 |
|---|---|---|---|
SessionMeta | M2 list/read | M1 | 幂等键是 (runtime, native_id, home, cwd)——必须含 cwd,否则同一 home 下不同 cwd 的两份副本会被合并成一行;store_path 可 stat |
StagedCopy | M2 stage | M3 | 只写平台暂存区;写 sha256;不动目标 home |
PlacementReceipt | M2 install | M3 | 目标位置已就绪且能被 runtime 找到;记录目标冲突处理方式;失败必须回滚到安装前状态 |
VerifyReceipt | M2 verify | M3 | 必须基于内容(回读事实)而非退出码;passed=false 时交接不得标 verified |
LeaseGrant | csched /leases | M4/M3 | fencing_token 单调;旧 token 写入被拒 |
HandoffReceipt | M3 | M1/M5/M6、看板、工单 | append-only;失败也写;字段足以复现(路由、哈希、校验) |
WorkLink | M5 | Gateway、DSH、看板 | 幂等键 (job_id, session_uid, role);孤儿不静默删 |
5. 存储与文件管理规范(M10)
这一节回答"文件怎么存、存哪、谁能删、留多久、怎么验"。它是所有其他模块的前置契约:M1 记录路径、M3 复制会话、M5 关联产物、M8 事件落盘,都必须遵守本节的布局与权限;否则"能 resume"很快会变成"到处是副本、不知道哪份是真的"。
5.1 为什么要单独成节(本机实测的账)
不治理的代价不是理论问题,本机已经发生:
| 事实 | 数值 | 影响 |
|---|---|---|
| Codex home | 十余 GB(sessions 数 GB、backups 数 GB、thread_history_1.sqlite 数 GB、packages 1.0 GB) | 系统盘 1.8 TB 已用 1.6 TB(89%);一次复制会话副本就要几 GB |
| 单月膨胀 | sessions/2026/10 5.3 GB,2026/11 已 205 MB | 按当前速率一个月数个 GB,没有保留策略就会撞盘 |
| 权限不一致 | Codex 会话文件 约三千 个 0664 / 两千余 个 0600;目录 68 个 0775 | 同目录内既有私有也有组可写;~/.codex 本身是 0775 |
| 无回收集 | history-index.sqlite 375 MB、events.jsonl 19.6 MB 持续追加 | 只增不减,且没有"这是可删"的标记 |
| 备份无轮转 | ~/.codex/backups/config2-history-20261008-182903 数 GB,仅 1 份 | 备份本身成了最大单目录 |
| 副本风险 | relocated 交接天然产生第二份会话(R7/R10/R12 已验证可行) | 不登记就会有两个写者、两份"真"历史 |
5.2 目录布局(平台根的唯一形状)
平台根默认 <平台根>/(沿用本机 ~/.local/share/ 惯例;$PLATFORM_HOME 可覆盖)。一个根,固定七个子类,不允许在根下再长出别的目录:
$PLATFORM_HOME/ 0700 平台根
├─ state/ 0700 可写、可并发、必须备份
│ ├─ platform.db 0600 sessions/copies/links/handoffs(§3.1)
│ ├─ platform.db-wal/-shm 0600 SQLite WAL
│ └─ locks/ 0700 单实例锁
├─ secrets/ 0700 只放位置与权限,绝不复制
│ ├─ <runtime>.token 0600 agent token
│ └─ README.md 0600 每份凭据的用途与轮换方式(不写值)
├─ sessions/ 0700 relocated 会话副本(源 home 不归这里管)
│ └─ <session_uid>/ 0700
│ ├─ payload/ 0700 复制来的会话产物(保持原相对路径)
│ ├─ receipt.json 0600 交接回执(§3.4)
│ └─ meta.json 0600 runtime、home、cwd、provider、model、哈希
├─ artifacts/ 0700 任务产物,一个 job 一个目录
│ └─ <job_id>/ 0700
│ ├─ manifest.json 0600 清单:文件名 + 字节数 + sha256 + 来源
│ └─ files/ 0700 实际产物
├─ evidence/ 0700 proof 类材料,append-only
│ └─ <yyyy-mm-dd>/<用途>/ 0700 before/after 成对命名(见 5.3)
├─ logs/ 0700 服务与任务日志,按天切
│ └─ <service>/<yyyy-mm-dd>.log 0600
└─ scratch/ 0700 可随时清空;重启后允许为空
规则:
- 类目即策略。一个目录属于哪个类,决定它的权限、保留期与预算;不允许"临时放一下"到别的类里。
- 不嵌套别的类。
artifacts/<job>/里不允许再出现logs/或sessions/语义的子树;跨类引用走manifest.json的路径字段。 - 不跟随软链。审计与回收都不跨软链(避免把
~/.codex整个拖进清理范围)。 - 只在本机。平台根不出现在远端机器;远端的任务根沿用 csched 既有约定
~/.compute-tasks/<job_id>/(compute_scheduler/bootstrap.py的TASK_ROOT_NAME),任务结束由cleanup()删除并留回执。 secrets/只存位置。凭据值仍留在各自既有位置(<凭据文件>(0600)、<令牌文件>(0600)、各 home 的auth.json),平台侧只记录"它是哪份、谁能读、怎么轮换"。
5.3 命名规范
| 对象 | 规则 | 例子 | 反例 | |
|---|---|---|---|---|
| 会话副本 | <runtime>-<home8>-<native_id>,只保留 [a-z0-9-] | codex-ds-3f9a12c8-01a197ba-... | 新建文件夹(2) | |
| 任务产物 | <job_id>,与 csched 的 job_id 完全一致 | a1465c0d0716 | eval-1-final-final | |
| 回执/清单 | 固定名 receipt.json、manifest.json、meta.json | — | 回执2.json | |
| 证据 | `<yyyy-mm-dd>/<场景>/<before\ | after>-<视图>-<序号>.<ext>` | 2026-11-01/platform/before-overview-01.png | 截图 2026-11-01 23.41.12.png |
| 日志 | <service>/<yyyy-mm-dd>.log,按天切 | scheduler/2026-11-01.log | out.log.1.old | |
| 时间 | 文件内容里的时间用 ISO 8601 带时区;文件名不用本地时间做排序键 | 2026-11-01T23:41:12+08:00 | final_v2 |
证据成对:UI 或行为类证据必须成对存放(before-/after-),并在 manifest.json 里写明基线与变更后的 revision;只有 after 的证据视为不完整(与本机既有证据命名一致,如 ops/evidence-20261101/)。
5.4 权限矩阵(谁能读、谁能写、谁能删)
| 类 | 目录 | 文件 | 平台 agent | 人类 | 其他用户 |
|---|---|---|---|---|---|
state/ | 0700 | 0600 | 读写 | 读写 | 不可见 |
secrets/ | 0700 | 0600 | 只读 | 读写 | 不可见 |
sessions/ | 0700 | 0600 | 读写自己的 lease 内副本 | 读写 | 不可见 |
artifacts/ | 0700 | 0600 | 写自己的 job;读全部 | 读写 | 不可见 |
evidence/ | 0700 | 0600 | 只追加(同名即拒绝) | 追加 | 不可见 |
logs/ | 0700 | 0600 | 追加 | 读 | 不可见 |
scratch/ | 0700 | 0600 | 随意 | 随意 | 不可见 |
硬规则:
- 不存在 0664/0775。本机实测存在(见 5.1),属待修而非"以后注意"。审计工具会把它们打成
file_permissions/dir_permissions。 - 权限位不是授权模型,只是一道兜底。同一个 UID 下运行的 agent 与人类进程,
0700/0600拦不住任何一个:
它只挡住其他 UID。所以上表的"agent 只读 / 人类读写"是平台接口层的约定,
由 token 档位(agent/human/read)强制,文件权限只保证"别的用户看不见"。
若真需要进程级隔离,只能靠独立 OS 账号或容器——那是另一层,本文不声称已经具备。
同理,"只追加"不是文件系统能力,而是 storage.write 的实现约束(同名即拒)。
- 可执行文件 0700,不允许 0755:
run.sh只需属主可执行。 - 不写 world/group 位。共享靠平台接口,不靠文件权限"图省事"。
secrets/写入由人类或install.sh完成,agent 只读;agent 需要新凭据时报告缺口,不自行落盘。
5.5 生命周期:创建 → 使用 → 回收
创建:mkdir 0700 + umask 077(与 bootstrap.py 现有做法一致)
│
使用:只在自己的类内写;跨类只读;append-only 类同名即拒绝
│
结束:写 manifest/receipt → 关掉租约与锁 → 进入保留期
│
回收:gc_plan 列出超期文件 → 人工/定时任务确认 → 删除 → 写回执
保留期(默认值,可按类调整):
| 类 | 保留 | 预算 | 依据 |
|---|---|---|---|
state/ | 永久(不自动删) | 8 GiB | 是唯一真相;只做备份,不做回收 |
secrets/ | 永久(人工轮换) | 64 KiB | 只存令牌文件,不存密钥内容;误删会直接让 agent 停摆 |
sessions/ | 30 天,但有活租约时不回收 | 32 GiB | 只有在目标 home 里还能重新落位时,副本才是可删的;见下方「副本何时才真是副本」 |
artifacts/ | 90 天 | 64 GiB | 覆盖一个季度的复现需求;训练产物是最大的一类 |
evidence/ | 不回收(只追加) | 8 GiB | 证据要能回溯到决策;写保留期却永不被回收,会让审计报告一个不成立的事实。超预算只报警、不删 |
logs/ | 30 天 | 16 GiB | 排障窗口 |
scratch/ | 1 天 | 16 GiB | 重启即可清空 |
远端 ~/.compute-tasks/<job> | 任务结束即删,但以产物校验完成为前提 | 不适用 | 既有规则:测试结束必须清理并留回执。顺序不能反:先取回并核验产物,再删远端;核验没过就不删,挂成待回收 |
三条附加规则:
- 先有回执再删。删除必须能回答"删了什么、为什么、谁批的";
gc_plan只产出计划,删除是独立动作。 - append-only 永不回收。
evidence/的旧文件只能被更新的证据取代语义,不能被 gc 删除。 - 超预算先报警再动手。超过类预算 25% 报
error,其余超出报warn;不允许静默删用户的证据或产物。 - 副本何时才真是副本(这一条推翻"30 天到期就删"的直觉)。
relocated 交接之后,目标 runtime 会继续往它自己 home 里的那份会话写:那份成了对话的新家,
而平台 sessions/<uid>/payload/ 里的暂存副本只是复制那一刻的快照,不会再被任何人写。
这两者必须分开看,否则会得出两个都错的结论:
- 把暂存副本当"新家"→ 会以为它一直在变,永远不敢删(第一版的毛病);
- 把 runtime home 里的那份当"平台的副本"→ 会把它纳入 GC 范围,那是删掉真实历史。
GC 只管平台自己的 sessions/ 暂存区,绝不碰任何 runtime home。
而暂存副本能否删,取决于目标 home 里那份权威副本是否已经独立成立(见下)。
因此 sessions/ 的回收必须满足全部条件才允许:
- 它对应的
session_copies.authority不是primary(权威位置已切到目标 home,平台暂存区降为stale); session_links里没有指向它的活跃 job(没有租约、没有 running 的 attempt);- 它最近
last_active_at已超过 30 天; - 存在一个与待删副本内容完全一致的另一个位置,且这个判断有不可变基线可依。
这一条第一版写错了两次,记录在此以免再犯:
- 错法一:"源还在、哈希没变就能删副本"。副本被续写之后,源恰恰因为没变而过期。
- 错法二:"副本当前哈希 == 最后登记哈希,说明没被续写"。这也不成立:扫描器
每轮都会把续写后的哈希登记回去,于是续写过的副本同样"等于最后已知"。
正确做法是交接基线不可变:handoff_state.baseline_hash 在交接那一刻写入、此后
永不更新。删除副本前要求:另一副本(或源)当前的哈希 == baseline_hash,
并且待删副本自基线以来没有新写入。基线之后两边都动过,就没有任何一方能证明
自己包含全部历史——此时不回收,交给人判断。
还有一条不能省:核验与删除之间不得有写入,而"删除前再算一次哈希"不是这个保证。
重算只能检测"两次计算之间"的写入,检测完到 unlink 之间仍有窗口;那不是消除了竞态,
只是把窗口缩小到看不见。
唯一正确的做法:在整个"核验 → 删除"窗口内持有该会话的写者锁(M4),
没有锁就不删——哪怕因此留下一个过期副本。删一个多余副本的代价是磁盘,
删掉唯一历史的代价是不可逆。GC 没有"拿不到锁就硬删"的选项。
三条任一不满足就不回收,并把原因写进 gc_plan 的输出。只在时间上做判断的 GC,
删掉的会是唯一一份真实历史。
- 写满时怎么办,必须先决定。三类的行为不同,不能都用"报警":
| 情况 | 行为 |
|---|---|
| artifacts/ 或 sessions/ 将超预算 | 拒绝新的写入/新的交接(快速失败),并报警;不自动删旧的 |
| logs/ 超预算 | 按天滚动并删最旧的(日志可再生) |
| scratch/ 超预算 | 直接清空 |
| 磁盘整体水位 > 90% | 停止一切新的 relocated 交接(因为它是磁盘增长最快的操作),报警 |
不写清这条,实现时就会有人"顺手删点东西"来腾空间,而那正是唯一一份历史的所在。
- 预算数字是可调常量,不是测出来的。上表的数字取自本机现状的数量级(单个 agent 会话已 数 GB)加上一个宽松倍数,用来触发讨论,不是容量规划结论;真正落地时应按实测增长调整,并把调整记进审计。
5.6 M10 的接口(给其他模块调用)
storage.path(cls, *parts) -> Path # 只允许拼七类之内的路径,越界即异常
storage.ensure(cls, *parts) -> Path # mkdir 0700 + 校验父类存在
storage.write(cls, *parts, data, mode=0o600) # 原子写(tmp + rename),append-only 类同名即拒
storage.manifest(job_id) -> dict # 生成/校验清单(文件名、字节、sha256)
storage.verify(manifest) -> VerifyReceipt # 逐文件核哈希;缺失/变动即 failed
storage.audit(root=None) -> AuditReport # 全部七类的权限/预算/清单审计
storage.audit_uniform(root) -> AuditReport # 对既有 agent home 只读审计(不改结构)
storage.gc_plan(now=None, live=(), sources=(), baseline_hashes={}, locked=()) -> [plan]
# 只产计划;live/sources/baseline_hashes/locked 决定合规不可删
实现见本工作区的 scripts/storage_audit.py(tests/test_storage_audit.py 23 项);
它只依赖标准库,拿到这两个文件即可在任何 Python 3.11+ 上复跑:python3 -m pytest tests -q。
可直接跑:
python3 scripts/storage_audit.py "$PLATFORM_HOME" # 全类审计
python3 scripts/storage_audit.py "$PLATFORM_HOME" --json # 机器可读
python3 scripts/storage_audit.py "$PLATFORM_HOME" --gc-plan # 附带回收计划
python3 scripts/storage_audit.py --uniform ~/.codex/sessions # 只读审计既有 home
对外稿以占位示例代替真实输出:
$ python3 scripts/storage_audit.py --uniform "<某个 agent home>/sessions"
uniform dirs=165 files=<N> bad_dirs=68 bad_files=<多数文件>
$ python3 scripts/storage_audit.py --uniform "<另一个 agent home>/chats"
uniform dirs=60 files=<M> bad_dirs=52 bad_files=<多数文件>
结论不依赖具体数字:同一个 home 里大量会话文件是组可写、目录也是组可写,
而各 runtime 的默认 umask 并不统一。规范必须由平台侧强制,不能指望各 runtime
自己收敛。
(findings=40 是"每类最多报 20 条"的采样上限;bad_dirs/bad_files 才是全量计数。)
读法:Codex 的 数千个会话文件里 约三千 个权限不合规、68 个目录组可写;Cursor 195 个文件里 187 个;Claude 204 个里 186 个;隔离的 codex-ds 93 个里 43 个;只有 DSH 基本干净(80 个文件里 1 个 session.lock 是 0664)。这不是"某个家目录没设好",而是每个 runtime 的默认 umask 都不同、且都允许组可写——所以规范必须由平台侧强制,不能指望各 runtime 自己统一。这是 M10 的第一个待修项。
5.7 各模块必须遵守的存储约束
| 模块 | 必须 | 禁止 |
|---|---|---|
| M1 Registry | 只存 store_path 与统计;sessions/ 只放副本 | 把对话正文写进 state/platform.db |
| M2 Adapter | stage 只写 sessions/<uid>/payload/;install 才写目标 home 的查找位置;两者分别写 meta.json 与安装回执 | 用 stage 冒充 install(runtime 找不到);install 失败后留下半装状态;就地改源 home 的权限或内容 |
| M3 Handoff | 先写 receipt.json 再起进程;失败要能删掉刚安装的副本;切 primary 与写事件在同一事务 | 交接成功但无回执;切了 primary 却没留事件 |
| M4 Lease | 锁文件放 state/locks/ | 用会话目录里的临时文件当锁 |
| M5 Link | 产物引用走 artifacts/<job>/manifest.json | 在 state/ 里存大文件 |
| M8 事件 | 事件进 state/ 的库表(带 seq),日志进 logs/ | 用 events.jsonl 无限追加当唯一账本 |
| 看板 | 只读 state/ 与各类统计 | 直接遍历 home 扫全量 rollout |
5.8 验收
storage_audit.py "$PLATFORM_HOME"返回 0,且findings只有warn。- 人为造一个 0664 文件与一个缺
manifest.json的产物目录,审计必须分别报file_permissions与missing_manifest。 gc_plan对evidence/永不产出条目(append-only),对scratch/超 1 天必产出;对sessions/超期但仍有活租约或无法重新落位的条目,产出的是action=refuse并带原因,不是删除项。- 一次
relocated交接后:sessions/<uid>/receipt.json存在、payload哈希与回执一致、state/里handoff_state.state='verified'。 - 对既有 home 的
--uniform审计不修改任何文件(只读);修复权限是显式的独立动作。
6. 组合成系统:分阶段落地与验收
原则:每一步都能独立上线并产生可验证价值,不出现"全部做完才有用"的阶段。
| 阶段 | 内容 | 依赖 | 验收(真实执行,不是单测) |
|---|---|---|---|
| P0 存储基线(1 天) | M10 布局 + 权限 + storage_audit.py + 既有 home 只读审计 | 无新权限 | PLATFORM_HOME 七类齐全、审计 0 error;既有 home 的权限问题被逐项列出(Codex 约三千/数千、Cursor 187/195、Claude 186/204、codex-ds 43/93 个文件不合规) |
| P0 索引(1~2 天) | M2 的 list/read(codex 家族 / cursor / claude / dsh)+ M1 扫描同步 + platform sessions 查询 | 无新权限 | platform sessions 能列出本机真实会话总数,与 find 计数一致;索引缺口(session_index.jsonl 441 条 vs 数千个会话文件)被显式报告,不把"索引里没有"当成"会话不存在" |
| P1 交接(单机)(2~3 天) | M3 的 stage+install+native/relocated + M4 租约锁 + verify(C2) | P0 | 复现并超越本文 R1/R7/R10/R12:三条跨 home/跨 cwd 交接全部 verified;按会话清单核对而不只比文件数;重复 resume 被拒;verify=false 时回执为 failed;上表五个崩溃点各注入一次 |
| P2 路由(1~2 天) | M9 路由块 + R2/R3/R4 的显式覆盖 + M7 注册 | P1 | 跨 provider 交接成功且模型真的是目标模型——证据取自服务端请求侧(网关日志中的模型字段),不接受 agent 自报;用错 launcher 时提前拒绝而不是跑到一半报 Unsupported model |
| P3 工作关联(2~3 天) | M5 job↔session↔ticket + csched session_links + 看板"谁在用哪个会话" | P1 | 一次真实 submit 后,能从 job 查到会话、从会话查到 job;工单派工后在 DSH 侧看到 link |
| P4 网关与 MCP(2~3 天) | M6 的 HTTP/MCP + 权限三档 + 幂等键 | P3 | 另一个 agent 通过 MCP 完成一次真实提交 + 一次 resume;agent token 无法强制接管锁 |
| P5 有损交接(2~3 天) | converted 模式 + 摘要/产物引用 + lossy 声明 | P2 | 从一个"没有导入通道"的方向交接,回执如实标 converted 与 lossy;人为断网后可续 |
| P6 事件与看板(1~2 天) | M8 游标 + 会话视图(在线/占用/锁/索引缺口/失败交接) | P3 | 看板能回答"现在哪些会话在被谁用、哪条交接失败了、为什么";数据带观测时间 |
| P7 故障自愈闭环(3~5 天) | M11:incidents 表 + signature 合并 + 复检规则 + 退避重试 + 汇报策略 | P0(需要 detect 规则) | §3.9.7 的 12 项故障注入全过;"agent 谎称已修"必须判 FAILED;积压降到可解释的数量 |
| P8 自动修复开启(2~3 天) | L1/L2 预授权、故障族→级别映射、回滚方案、冷却、审计 | P7 | 隧道断开与 deploy 失败各无人介入自动修好一次,且审计里有级别、动作、回滚方案与复检结果 |
| P9 复检规则补齐(2~3 天) | 按 §3.9.5.1 为每类故障落地判定;无规则的故障族一律 L0 | P8 | 每类故障都有可执行判定;故意造一个"现象消失但根因未除"的场景,必须判 PASSED 但标记 regressed |
验收必须包含的崩溃边界(只验"跑通了"是不够的,要验"在哪一步死掉不会留下坏状态"):
| 崩溃点 | 必须验的事 |
|---|---|
stage 之后、install 之前 | 目标 home 无残留;重跑幂等 |
install 之后、resume 之前 | 目标会话可被发现;平台里 primary 已切;无第二个写者 |
resume 之后、verify 之前 | 会话确实在跑;回执缺失时不得标 verified;重跑不得起第二个进程 |
verify 通过、写回执之前 | 状态可从事件重建;不得因缺回执而重复交接 |
| 删除副本时进程恰好写入 | 整个核验→删除窗口持写者锁;拿不到锁就不删(不得用"先算一次哈希"代替) |
每条都要求"重启后账本收敛":重复跑一遍 reconcile,不应产生新的进程、新的 primary 或新的删除。
验收必须包含的四个反例(来自实测,最容易"看起来成功"):
- 错误 cwd 的 Cursor resume(R11):必须判
failed,不能因为退出码 0 就算成功。 - 双写(R8/R13):第二个写者必须被平台拒绝,且拒绝发生在起进程之前。
- provider 不匹配(R2):必须提前判定并给出"改哪个字段"的可执行错误,而不是把
-32600原样抛给用户。 - 索引缺口(
session_index.jsonl441 条 < 数千个会话文件,且 数千 个是.jsonl.zst压缩档,索引未覆盖):必须显示"未知/待补扫",不能把"索引里没有"当成"会话不存在"。 - 未压缩与已压缩混存:同一个
sessions/下 545 个.jsonl与 数千 个.jsonl.zst。adapter 的list必须两种都认,materialize复制时保持原文件名(否则 runtime 找不到)。
7. 明确不做、未核实与风险
7.1 不做(本期)
- 不做会话内容的中央存储/同步。内容留在各 home;平台只存路径与哈希。理由:体积、隐私、格式归属。
- 不做跨 runtime 的"同一线程"伪装。只有
native/relocated才能叫"恢复";其余叫converted。 - 不替换 csched 的调度、不替换 DSH 的工单模型。本方案只加连接件。
- 不引入 Temporal/K8s/Ray 作为底座。理由见
compute-scheduler-design.md§4;本方案的持久性需求(租约、fencing、append-only 事件)csched 已满足。 - 不自动 merge、不自动接管他人会话;强制接管仅 human token 且留审计。
7.2 未核实(不得当作能力)
| 项 | 状态 |
|---|---|
codex queue --thread ... --message ...(向运行中会话投递) | 仅见帮助文本,未实测;若可用,可替代"抢写者",需在 P1 前验证 |
| Claude 的双写行为 | 未实测(本机无并发场景);不能假设与 Codex 一致 |
| Codex 导入 Claude/Cursor 的保真度 | 只验证了"导入后可 resume 并正确回读一条事实"(R15);未验证工具调用、审批记录、图片等是否完整 |
| DSH 会话在不同 profile(web vs headless)间能否互认 | 未测;headless 需显式模型凭据(实测缺凭据即报错),web 走凭据服务 → 凭据路由是独立问题(M9) |
| 跨机器交接(远端 agent home) | 本机全部实测均为同机;跨机还多一层传输与凭据,未测 |
external_agent_session_imports.json 的触发时机与去重 | 只知道存在且有 80 条记录、配置开启;不知是接入时同步还是周期扫描 |
压缩档(.jsonl.zst)会话能否原样落位并 resume | 本机 数千个会话文件里 数千 个是压缩档;实测只覆盖了 .jsonl。落位时是否要解压、解压后 runtime 是否还认,未验证 |
| 在线复制的一致性 | 复制一个正在被写的会话文件,可能得到截断或撕裂的副本。是否需要在复制前加写锁、或复制后校验尾部完整性,未验证 |
| 恢复所依赖的第二份东西 | 只验证了会话文件本身。若会话引用了附件、图片、工具缓存或独立的状态库(如 thread_history_*.sqlite),单独搬会话文件是否够用,未验证 |
7.3 风险
| 风险 | 影响 | 缓解 |
|---|---|---|
会话格式随 runtime 版本变化(Codex 已在做 migrate-rollouts/migrate-nfs-db) | adapter 落位逻辑失效 | 能力位版本化;每次升级跑"落位回归"(P1 的三条验收) |
relocated 产生多份会话副本 | 同一对话两个写者、磁盘膨胀(Codex 已 数 GB) | 副本登记在 session_copies;只允许一个 primary;副本 TTL 回收 |
| 静默失败(R11 类) | 用户以为续上了,实际空上下文 | verify 强制;失败即 failed,不写 ready |
| 锁泄漏 | 会话永久不可恢复 | TTL + human 强制接管 + 审计;看板常驻"异常锁" |
| 把"能 resume"当成"能继续干活" | 对话恢复了,但租约/产物/checkpoint 还在旧机器 | M5 强制关联;恢复时先读 job/lease/checkpoint,再决定是否重建 |
| 记忆/技能共享导致跨 agent 污染 | 错误结论被所有 agent 继承(fleet memory 已是全量摘要) | 记忆按出处标注(已有);平台侧只读引用,不写入各 agent 的记忆 |
8. 与现有系统的关系(一句话各自定位)
| 系统 | 定位 | 本方案如何用它 |
|---|---|---|
compute-scheduler | 资源与工作的真相源(谁占卡、任务到哪、租约/配额/审计) | 新增 sessions/session_copies/session_links/handoff_events/handoff_state 五张表;复用 lease+fencing 做 M4 |
| 各 runtime home | 会话的真相源 | 只读扫描 + 受控落位;不搬内容进库 |
dsh-superagent | 工单与派工的真相源 | 派工时写 session_links;Executor 从"只能是 DSH"扩展为"任意 runtime 的会话" |
agent-fleet | 机群对齐工具(技能/记忆/历史检索/健康) | M2 的 list/read 直接复用其扫描与摘要逻辑;fleet exec 成为最小 adapter 原型 |
compute-resource-agents 技能 | 台账与远端 agent 装配 | 远端 app-server 通道(agent_client.py)作为"远端 runtime adapter"的实现基础;thread/resume 已在其中 |
work-report-hub | 结果汇报 | 只引用结果,不混排运行态 |
9. 下一步(需要人确认的三件事)
- P0 先做哪个 runtime:建议
codex家族优先(本机 数千个会话文件里~/.codex占绝大多数,且已有 app-server 通道与导入能力),Cursor 第二(会话体积第二、静默失败风险最高)。 relocated副本的保留策略:是"交接后删源"(省空间,不可回滚)还是"保留 N 天"(可回滚,占空间)。建议默认保留 7 天并在看板显示占用;§5.5 的类默认值是 30 天,若不改就以类为准。- 既有 home 的权限是否要就地修:§5.6 审计出 Codex 约三千 个 0664 文件、Cursor 187 个、Claude 186 个。修复是显式动作(会改动各 runtime 自己的文件),建议先在
~/.codex、~/.cursor、~/.claude上各做一次chmod -R go-rwx并写回执,还是只对平台新建目录生效。
在这三点确定前,P0(存储基线 + 只读索引)不依赖任何决策,可以立即开始。