arch-git-versioning.md 16.2 KB

Git Versioning · 每次 Agent 完工 = 一个 commit

把每个 DevLoop 项目变成独立 git repo,每个 Agent 成功完成工作 → LoopController 自动 commit。
项目结束 → 打 tag。这一层补上文档之前缺的真版本管理——崩了能 reset,丢了能 archive,3 次 retry 能 diff。

① 现状 vs 提议

之前(无 git)

  • 文件 mtime 是唯一时间戳
  • history/ 目录是 ad-hoc 归档
  • 没有 atomic snapshot
  • 回滚靠 fs.rename + 手动拷贝
  • retry 之间无法 diff(旧文件被覆盖)
  • 项目导出 = 拷整个目录

提议(每项目一个 repo)

  • 每个 Agent 完工 → git commit
  • shipped → git tag
  • 整个 retry 链是 git log(可 diff 任意两次)
  • 崩 → git reset 干净恢复
  • 导出 → git archive
  • 未来可加 git push 到 GitHub(v2)

② Repo 范围决策

方案 位置 优 劣
A · 每项目一个 ← 选这个 data/companies/{cid}/projects/{pid}/.git 隔离强 · 删项目就删 repo · 导出干净 · 可分别 push 不同 remote repo 数量随项目增长(每个 ~10-50MB)
B · 一个公司一个 data/companies/{cid}/.git 跨项目共享对象 跨项目混在 git log;不易导出单项目
C · 全局一个 data/.git 对象去重最大 所有 company 混;权限灾难
D · 主仓库 lemonboss/.git 不增 repo 污染 LemonBoss 主提交历史;不可接受

选 A 的核心理由:DevLoop 项目本质是临时工作空间,shipped 后或归档或导出,独立 repo 让生命周期边界清晰。

③ 落盘后的目录

data/companies/{cid}/projects/{pid}/
├── .git/                    # NEW · 整个项目的 git 历史
├── .gitignore               # NEW · 项目创建时初始化
├── project.json
├── prd/                     → tracked
├── sdd/                     → tracked
├── units.json               → tracked
├── integration/spec.json    → tracked
├── timeline.ndjson          → tracked (append-only,git OK)
├── bugs.ndjson              → tracked
├── changelog.ndjson         → tracked
├── units/                   → tracked (code + tests + contract)
├── integration/runs/        → tracked (result.json + triage.json)
├── agent-state/             → **.gitignore** (高频写,运行态)
├── history/                 → **.gitignore** (git 本身就是 history)
└── stream.log               → **.gitignore** (SSE 流,7 天滚动)

④ .gitignore 模板

# LemonBoss DevLoop 项目 .gitignore (项目创建时落盘)

# 高频写的运行态,不需要版本
agent-state/

# 真冗余 (git 本身就是 history)
history/

# 大日志
stream.log
units/*/runs/*/stdout.txt
integration/runs/*/process.log
integration/runs/*/http-trace.ndjson.gz

# Node / 依赖
node_modules/
*.log
.DS_Store
.cache/
.tmp/
.coverage/

# 测试中间文件
units/*/.vitest-cache/

# 但保留: result.json triage.json contract.json 等小 JSON

由 GitVault.init 工具在 git init 后立即写入;首个 commit 就含 .gitignore。

⑤ Commit 时机表

事件 commit msg 模板 触发位置 files added
项目创建 init: project {pid} · {requirement 摘要 30 字} LoopController.createProject 末尾 project.json + .gitignore
PM 写完 PRD prd: rev {N} · {change_summary} PM Agent 成功返回后 prd/** + changelog.ndjson + timeline 增量
Architect 生成 SDD sdd: rev {N} · {units_count} units · {summary} Architect generate 成功后 sdd/** + units.json + integration/spec.json + changelog/timeline
Architect patch sdd: patch · {affected_units 列表} Architect patch 成功后 仅改的 sdd/mvcp/{x}.html + units.json (hash 更新)
Coder 实现 code({unit_id}): implement · attempt {N} Coder 成功 + Tester 绿才 commit units/{id}/code/** + contract.json + coder.log
Coder retry 通过 code({unit_id}): retry → green · attempt {N} Tester 绿(attempt > 1) 同上
Tester 写测试 合并到上一条 commit 同 Coder commit 一起 tests/ + runs/{}/result.json 一起
BugTriager 归因 triage({run_id}): {category} · confidence {n} Triager 写完 triage.json 后 integration/runs/{}/triage.json + bugs.ndjson 增量
Integrator 跑 `integ({run_id}): {ok fail} · {cases_passed}/{cases_total}` Integrator 完工
Unit reset (L2 修复后) 不 commit,下一次 Coder commit 时一起 — —
项目 shipped ship: prd@{prd_rev} sdd@{sdd_rev}
+ git tag shipped-{rev}
LoopController.shipProject —(tag only)
项目 sign-off chore: sign-off by {user_id}
+ git tag final-{rev}
POST /sign-off project.json (status=shipped-final)
项目 abandon chore: abandon · {reason}
+ git tag abandoned
POST /abandon project.json (status=abandoned)

关键决策:失败不 commit(避免噪音 + 失败的代码不该留在 git 主线)。Tester 红 → Coder retry 时旧代码被工作区覆盖,反正没人需要回到失败态。

⑥ 谁调 git · 集中控制

关键原则:Agent 不动 git。LoopController 在 callAgentWithPersistence 的 success 分支统一调 GitVault.commit。

这样 Agent 沙箱继续严格(不允许 Bash 调 git),但项目状态真实有版本。

⑦ GitVault · C-18 服务方法

· server/services/git-vault.js

module.exports.gitVault = {
  // 初始化(项目创建时)
  init(pid: string, opts?: {
    name?: string, email?: string,    // 默认 "DevLoop <devloop@lemonboss.local>"
    initialBranch?: string,            // 默认 "main"
  }): Promise<{ ok: boolean, head: string }>,

  // 主提交入口 (LoopController 调)
  commit(pid: string, {
    kind:    'init' | 'prd' | 'sdd' | 'code' | 'triage' | 'integ' | 'ship' | 'chore',
    unit_id?: string,                  // code 时填
    run_id?:  string,                  // triage / integ 时填
    summary:  string,                  // 简短描述
    files?:   string[],               // 限定 add 的路径 (默认 git add -A 受 .gitignore)
    author?:  { name, email },        // 哪个 Agent 干的,记到 commit author
    skipEmpty?: boolean,              // nothing-to-commit 时是否抛错,默认 false
  }): Promise<{ ok, sha?: string, skipped?: boolean }>,

  // 打 tag
  tag(pid, tagName: string, opts?: { message?, sha? }):
    Promise<{ ok, tag }>,

  // 查询
  log(pid, opts?: { limit?, since?, grep? }): Promise<Commit[]>,
  diff(pid, refA: string, refB: string, opts?: { path? }):
    Promise<string>,                    // unified diff text
  show(pid, sha: string): Promise<Commit + diff>,
  listTags(pid): Promise<Tag[]>,

  // 写回滚 (P-8 recovery 用)
  resetWorkingTree(pid, ref = 'HEAD'): Promise<{ ok }>,   // 抛弃未 commit 的脏文件
  revert(pid, sha: string): Promise<{ ok, newSha }>,        // 反向应用

  // 导出
  archive(pid, ref = 'HEAD', format = 'zip'):
    Promise<{ path: string }>,      // 跑 git archive

  // v2 留口
  addRemote?(pid, name, url),
  push?(pid, remote = 'origin'),
};
  • 实现可走 simple-git 包 或 spawn git;推荐 spawn git(零依赖,CLAUDE.md 强约束的 JSON 也不涉及)
  • 所有方法串行化 per-pid(避免同项目并发 git 操作打架)
  • commit author = Agent 名("PM Agent ")便于 git log 看哪个 Agent 干的 - 失败处理:git 子命令非零退出 → 抛错;nothing-to-commit 默认 skipEmpty=false 返 skipped=true,不抛 - tests: init 后 .git 存在 + .gitignore 已 commit - tests: commit({kind:'prd'}) 后 git log 有该 commit + author 是 PM Agent - tests: nothing-to-commit 不抛错 - tests: tag('shipped-v1') 后 git tag --list 含 - tests: diff(sha1, sha2) 返回非空 unified diff

⑧ P-10 · 自动提交流程 · server/src/core/loop-controller.js · callAgentWithPersistence 的 success 分支扩展 ts // 在 P-8 callAgentWithPersistence success 分支末尾添加 // (LoopController 调,Agent 完全无感) async function commitAfterAgent(pid, name, result, input) { const spec = buildCommitSpec(name, result, input); if (!spec) return; // 某些 agent 不 commit (如 Integrator 失败时) const { ok, sha, skipped } = await gitVault.commit(pid, spec); if (ok && !skipped) { timelineLogger.append(pid, { source: 'system', kind: 'git_committed', ref: sha.slice(0, 7), summary: spec.summary, }); sseBroker.emit(pid, 'git.committed', { sha, kind: spec.kind, summary: spec.summary }); } } function buildCommitSpec(name, result, input) { if (name === 'pm') { return { kind: 'prd', summary: `prd: rev ${result.prd_rev} · ${result.change_summary}`, author: { name: 'PM Agent', email: 'pm@devloop.local' }, }; } if (name === 'architect') { const prefix = input.mode === 'patch' ? 'sdd: patch' : 'sdd: rev ' + result.sdd_rev; return { kind: 'sdd', summary: `${prefix} · ${result.units_count} units · ${result.change_summary}`, author: { name: 'Architect Agent', email: 'architect@devloop.local' }, }; } if (name === 'coder' || name === 'tester') { // Coder/Tester 合并 commit:Tester 绿后才 commit,把 code + tests 一起 if (name === 'coder') return null; // 等 Tester 绿 if (!result.ok) return null; // Tester 红不 commit const verb = result.attempt > 1 ? 'retry → green' : 'implement'; return { kind: 'code', unit_id: result.unit_id, summary: `code(${result.unit_id}): ${verb} · attempt ${result.attempt}`, author: { name: 'Coder Agent', email: 'coder@devloop.local' }, }; } if (name === 'integrator') { return { kind: 'integ', run_id: result.run_id, summary: `integ(${result.run_id.slice(-12)}): ${result.ok ? 'pass' : 'fail'} · ${result.cases_passed}/${result.cases_total}`, author: { name: 'Integrator Agent', email: 'integrator@devloop.local' }, }; } if (name === 'bug-triager') { return { kind: 'triage', run_id: input.run_id, summary: `triage(${input.run_id.slice(-12)}): ${result.category} · confidence ${result.confidence}`, author: { name: 'BugTriager Agent', email: 'triager@devloop.local' }, }; } return null; } - Coder commit 推迟到 Tester 绿(避免红色代码进 main) - 所有 commit author 标 Agent 名,git log 可读「哪个 Agent 改了哪段」 - nothing-to-commit 是 ok(某些 retry 没改文件) - commit 失败 → 上报 LoopController · 不阻塞 Agent 返回(git 是 nice-to-have,不是阻塞依赖) ### 项目级 tag 时机 | 事件 | tag 名 | 触发 | | --- | --- | --- | | L2 通过 | shipped-v{prd_rev}-{sdd_rev} | LoopController.runProject return 'shipped' | | 人类 sign-off | final-v{prd_rev} | POST /sign-off | | abandon | abandoned | POST /abandon | | 每个 PRD rev | prd-rev-{N} | PM 完成且写完 changelog | | 每个 SDD rev | sdd-rev-{N} | Architect 完成 generate(不含 patch) |

⑨ 与 timeline / changelog / bugs 的关系

git log

真源:文件层变化。git diff 看代码 / 文档真实改了什么。
包括 author / sha / 时间。

timeline.ndjson

真源:事件流。含非文件改动(如 SSE / Agent 进出 / state transition)。比 git log 信息量更大。

changelog.ndjson

派生:文档级摘要。每条对应一次 PRD/SDD/units rev,与 git tag prd-rev-N / sdd-rev-N 一一对应。

三者互补不冲突。git log 和 timeline 各自append-only,drift 风险低(都由 LoopController 在 callAgent 末尾原子写)。

⑩ 失败恢复 · 新能力

场景 没 git 时 有 git 时
Server crash agent-state.paused,但代码可能半写 git status 看脏文件 · git reset --hard HEAD 一键恢复
Coder 3 次 retry 都红 只能看 coder.log 文字 git log --author='Coder' 看 3 次 commit · git diff 看模型 3 次都改了啥
L2 通过后人类突然改主意 整个手动 hack 回退 git revert ship-v1 即可
L3 反馈引入回归 需要重新跑全套 git bisect 找到哪次 commit 引入了破坏
项目导出 cp -r 整个目录 30MB git archive HEAD 干净打包,仅 tracked 文件 ~5MB
跨机器同步项目状态 不可能(带 .vitest-cache 等垃圾) git push + 对端 git clone

⑪ P-8 状态机的协同

P-8 持久化层与 git 不重叠:

// Agent 调用结束的完整流程 (P-8 + P-10)
async function callAgentWithPersistence(name, input) {
  // P-8 step 1: 写 runtime status=running
  await writeRuntimeState(pid, name, 'running');

  // 实际调用 (Agent 通过工具写文件)
  const result = await agentManager.callAgent(...);

  // P-8 step 2: 写 runtime status=done
  await writeRuntimeState(pid, name, 'done');

  // P-10 step 3: git commit (新增)
  if (result.ok) await commitAfterAgent(pid, name, result, input);

  // timeline / SSE / changelog 等照旧
  ...
}

// recoverOnBoot 增加 git 清理:
async function recoverOnBoot() {
  for (const pid of listAllProjects()) {
    // 既有: 扫 agent-state · 改 running → paused
    ...

    // 新增: 检查 git 工作区是否脏
    const status = await gitVault.status(pid);
    if (status.dirty) {
      // 上次 crash 留下未提交脏文件
      sseBroker.emit(pid, 'recovery_dirty_tree', {
        files: status.files,
        options: ['commit_as_wip', 'reset_hard', 'manual_inspect'],
      });
    }
  }
}

⑫ 推 remote · v2 留口

v1 不做。但 GitVault API 已留 addRemote + push。v2 可加:

# 把项目代码推到 GitHub
POST /api/devloop/projects/:pid/push
{
  "remote": "origin",
  "url": "git@github.com:yibo/auto-todo.git",
  "branch": "main",
  "open_pr": false
}

# GitHub Actions 接管 CI/CD,DevLoop 只负责生产 + 提交

v2 还可加 git submodule 共享:跨项目复用 utils → 抽 shared/ submodule。

⑬ 安全考量

风险 缓解
git push 凭据泄露(v2) v1 不实现 push;v2 用 SSH key per company,不存 password
Agent 越权调 git Agent 的 toolPolicy 不含 git 工具,Bash 不允许 git * 命令(jsonGuard 类似的 cmdGuard)
commit 含敏感数据 项目 .gitignore 排除 secrets/ · token.txt · *.env
仓库膨胀 stream.log 大文件 .gitignore;定期 git gc
并发 commit race GitVault 按 pid 串行化(mutex per pid)

⑭ P0 工时增量

P0 子项 新增工时
GitVault 实现 (~150 行 spawn git wrapper) 1 天
.gitignore 模板 + LoopController.createProject git init 集成 0.5 天
commitAfterAgent + buildCommitSpec 0.5 天
recoverOnBoot 加 git status 检查 0.5 天
验证: phase0-verify 加 git init + 一次 commit 验证 0.5 天
P0 +3 天(总 P0 14 天) —

原 11 天 P0 → 14 天,总 9.5 周 → 10 周。代价相对收益(真版本管理)合理。

⑮ 实战命令

# P0 后验证 git 集成

# 1. 创建项目应该自动 git init
curl -X POST http://localhost:3010/api/devloop/projects -d @/tmp/create.json
ls data/companies/c-test/projects/p-XXXXXX/.git/
# 期望: HEAD config objects refs/ 等

# 2. 跑完 PM 应该有 1 个 prd commit
git -C data/companies/c-test/projects/p-XXXXXX log --oneline
# 期望: 至少 2 行,init + prd: rev 1

# 3. 跑完 Architect
# 期望: 多 1 行 sdd: rev 1

# 4. 跑完 Coder→Tester 绿后
# 期望: 多 1 行 code(todo-be): implement · attempt 1

# 5. L2 通过后
git -C ... log --oneline
# 期望: ship: prd@1 sdd@1
git -C ... tag --list
# 期望: prd-rev-1, sdd-rev-1, shipped-v1-1

# 6. retry 演示
# 注入 bug 让 Coder retry 2 次再绿
git -C ... log --author='Coder' --oneline
# 期望: 仅 1 个 commit (因为只在 Tester 绿才 commit)
# 但 coder.log 有 2 条 attempt 记录

# 7. 导出
node -e "require('./server/services/git-vault').gitVault.archive('p-XXXXXX').then(r => console.log(r))"
# 期望: /tmp/p-XXXXXX-HEAD.zip 含 tracked files only

⑯ 关联