Skip to content

Latest commit

 

History

History
191 lines (136 loc) · 9.64 KB

protocol-spec.md

File metadata and controls

191 lines (136 loc) · 9.64 KB

协议规范 (Protocol Specification)

为了保证完全解耦,Agent-Bridge 所有的通信均依赖于项目根目录下的特定文件结构。任何支持读写本地文件的脚本或 Agent,只要遵循本协议,即可无缝接入本协作网络。

1. 核心状态机 (bridge.md)

bridge.md 是整个系统的心脏。它必须位于项目的根目录下。 它的结构由顶部的 YAML Frontmatter 与下方的 Markdown 内容 组成。

1.1 YAML 数据规范

每个字段都严格定义了当前的流水线状态:

status: PENDING_DEV      # 当前状态,见下方七态说明
task_id: T1              # 对应 docs/impl.md 中的任务 ID
round: 1                 # 当前处于第几次迭代(被打回则递增)
retry: 0                 # 重试次数(预防 API 崩溃死循环用)
updated_at: 2026-09-16T12:00:00.000Z # 最后修改时间
last_commit: abc1234     # 与该状态绑定的 Git Commit Hash
error: null              # 如果发生系统级错误,记录于此

1.2 七态说明 (State Definitions)

  • PENDING_DEV:施工方可获取控制权,准备开始开发。
  • IN_DEV:施工方正在开发中,独占锁定工作区。
  • PENDING_REVIEW:施工方开发完成,等待监工方审查。
  • IN_REVIEW:监工方正在审查代码,独占锁定工作区。
  • REJECTED:审查未通过。施工方在下一个 Tick 被唤醒进行修复。
  • APPROVED:审查通过,当前 task_id 完成,进入下个任务的 PENDING_DEV。
  • COMPLETED:所有任务完成,守护进程自动关闭。

2. 审查裁决文件 (verdict.json)

当监工方完成审查后,它必须在 .bridge/verdict.json 中写入裁决结果。看门狗会读取此文件进行状态翻转。

{
  "verdict": "approve", // 只能是 "approve" 或 "reject"
  "issues": [
    // 如果 reject,这里必须填入详尽的打回原因,供 Builder 阅读
    "Line 45: Missing TypeScript type definition.",
    "Failed to pass 'npm run test'."
  ],
  "task_id": "T1",
  "round": 1
}

3. 防碰撞目录 (.bridge/)

系统会自动在项目下生成 .bridge/ 隐藏目录,用于存放信号文件、裁决文件与锁文件:

3.1 业务流转文件

  • review-request.md:Watchdog 组装的审查材料,Supervisor 的唯一输入。
  • verdict.json:Supervisor 输出的裁决结果。
  • signal-builder.txt:GUI 模式下唤醒 Builder 的信号文件。
  • internal-state.json:Watchdog 的内部持久化状态(已审批 commit、已完成任务列表)。
  • logs/:历史审查意见归档。

3.2 锁与租约文件

Agent-Bridge 采用两层互斥机制:底层是操作系统保证的机制锁,上层是角色卡约束的约定锁。

文件 类型 作用
watchdog.lock 独占锁(机制锁) 防止启动多个 Watchdog。第二个实例启动时直接中止并报出持有者 PID。
bridge.md.lock 互斥锁(机制锁) 保护 bridge.md 的「读—改—写」临界区,防止状态被并发覆盖。
builder.pid 租约(约定锁) Builder 认领施工权的标记,见下方说明。

锁的原理

锁的创建使用 fs.openSync(path, 'wx') —— wx 标志表示「写 + 排他创建」。 该动作由操作系统保证原子性:要么成功创建全新文件,要么抛出 EEXIST。 这是本地文件锁中唯一不存在「先检查再创建」竞态窗口的部分。

陈旧锁的三段式接管(为什么不能简单覆盖)

进程崩溃会残留锁。若锁中记录的 PID 已不存在(通过 process.kill(pid, 0) 探测), 则需判定为陈旧锁并接管。但直接覆盖是错的 —— 多个进程可能同时判定为陈旧并都认为自己接管成功,互斥反而失效。

因此接管必须经过三阶段,每阶段都有一次内核级原子判定:

  1. 抢占凭证:创建 ${lockPath}.reclaim.${token} 排他凭证(名字含唯一 token)。 凭证名天然唯一,只有拿到凭证的进程才有资格接管,其余进程明确判负退出。
  2. 搬运陈旧锁:用 rename 将陈旧锁原子搬到隔离区 ${lockPath}.stale.${tag}, 并校验搬走的字节与判定时完全一致(防 ABA:期间被合法释放并易主)。
  3. 归属仲裁:此时锁路径为空,对其再做一次 openSync(path,'wx') 抢占 —— 这次原子判定是归属的唯一权威。抢到者进入临界区,抢不到者回滚隔离区并判负。

还需处理凭证残留:若凭证持有者在接管途中崩溃,凭证会变成新的陈旧锁, 后续进程需能识别并清理,否则形成新的永久死锁。

损坏锁的宽限期(0 字节问题)

openSync('wx') 成功但内容尚未落盘时进程崩溃,会留下 0 字节锁。 JSON.parse('') 失败后若一律判负 → 永久死锁;若一律判可抢 → 会误抢正在写入的锁。

因此按文件 mtime 年龄区分两种损坏态:

  • corrupt-fresh(mtime 在宽限期 CORRUPT_LOCK_GRACE_MS = 3000ms 内):可能正被并发写入,保守拒绝。
  • corrupt-stale(mtime 超出宽限期):确为崩溃残留,判定为陈旧,允许接管。

同进程重入

markTaskCompleted 会在 writeBridge 的临界区内调用 saveInternalState,后者需要获取另一把锁。 若不做重入处理会形成自死锁。

因此 withLock 使用 AsyncLocalStorage + Symbol.for('agent-bridge.lockQueues') 记录 「本异步上下文已持有哪些锁」:同上下文 + 同锁 → 直接执行临界区,不再排队也不再抢锁。 用 Symbol.for 而非普通 Symbol,是为了让同一 worker 内的多份模块副本命中同一注册表。

该重入按异步上下文判定。不同上下文或不同进程持同一把锁,仍会正常排队互斥。

关于 builder.pid 的说明(重要)

Builder 通常是 GUI 形态的 LLM 会话(Cursor、WorkBuddy 等 IDE), 其宿主进程号不可稳定获取,因此这个文件在实现上被定义为**租约(Lease)**而非真正的 PID 探针:

{
  "pid": 12345,
  "type": "builder",
  "task_id": "T1",
  "claimed_at": "2026-09-16T12:00:00.000Z"
}
  • claimed_at 是主要价值:供 Watchdog 判断是否存在长期无人认领的挂死状态。
  • pid 是尽力而为的元数据,可能填会话进程号,也可能填 unknown。
  • 该文件由 Builder 在开工时创建、挂起前释放,属于约定锁(依赖角色卡纪律),而非机制锁。

3.3 原子写入约定

所有共享文件的写入必须经由「临时文件 + rename」完成,禁止直接覆盖写入:

写入 → .<文件名>.tmp.<pid>.<时间戳>.<计数器>   (本进程唯一暂存区)
提交 → fs.renameSync(tmp, target)              (同卷内原子替换)
失败 → 删除 tmp 文件,不留残留

两级保障缺一不可:

  • 临时文件唯一命名保证多个并发写者各自拥有独立暂存区,不会互相覆盖对方的半成品。
  • rename 原子性保证读者永远只能读到「完整旧版本」或「完整新版本」,不会读到半截内容。

注意:rename 只能保证单个文件不撕裂,无法保证「读旧值 → 修改 → 写回」这一组合动作的原子性。 因此 bridge.md 的状态写入额外由 bridge.md.lock 互斥锁包裹临界区。

3.4 行尾约定(CRLF 陷阱)

状态机的解析正则依赖 \n 行尾。若 bridge.md 是 CRLF 行尾,正则会匹配失败、 parseBridge() 返回 null,而 tick() 首行直接 return —— 表现为 状态机彻底静摆、零日志、零报错,用户只会看到「什么都没发生」。

这是一个高危且隐蔽的陷阱:在 Windows 上 core.autocrlf=true 会把 LF 转成 CRLF, 因此任何 Windows 用户 clone 后都会触发。

三重防线:

  1. 仓库层:根目录 .gitattributes 声明 * text=auto eol=lf,从根源归一化行尾。
  2. 解析层:parseBridge() 在做任何正则匹配前统一调用 normalizeEol(), 将 CRLF / CR 一律转为 LF。
  3. 兜底层:所有派生正则加 \r?,逐行取值时再 replace(/\r$/, '')。

已 clone 过的 Windows 用户需执行一次 git rm --cached -r . && git reset --hard 让归一化对已有工作区文件生效。

3.5 git 命令的执行目录(静默降级陷阱)

看门狗要审查「用户项目」的 git 历史(git diff、git log -p、config.checks 里的 npm test 等)。但进程 cwd 取决于用户从哪个目录启动,这是一个隐蔽的故障源:

  • cwd 在仓库内(哪怕深层子目录):git 会自动向上查找 .git,命令正常执行。
  • cwd 完全不在仓库内:git rev-parse 抛 fatal: not a git repository。

旧实现把异常吞掉返回 null,导致 diff 审查静默降级 —— 不报错、只是审查内容为空, 用户看到的是「Supervisor 说没问题」,而不是「审查根本没跑」。

因此所有外部命令统一走 execInProject(cmd):

  1. 启动期探测一次仓库根:git rev-parse --show-toplevel,结果缓存。
  2. 探测成功:后续所有 git 命令与用户自定义 checks 都以该仓库根为 cwd, 不依赖用户从哪启动。
  3. 探测失败:明确 warn 到控制台,说明 diff 审查不可用、应在仓库内启动。

返回值契约:探测不到仓库根时返回 null(而非空字符串)。 调用方必须区分「项目不可用」(配置问题,需 warn)与「命令成功但无输出」(正常状态)—— 旧代码把两者都变成空字符串,正是静默降级的成因。