# Agent Pool（开发 worktree 池）

> **最后更新**: 2026-05-16
> **类别**: 流程 / 开发期机制
> **使用工具**: `scripts/dev/agent-pool/`（命令、env 详见该目录 README）

## 这是什么

agent pool 是一组**预热好的 git worktree**，每个 worktree（slot）自带独立端口段、
独立 PG/Redis 容器、独立 `.env`、独立 Caddy 域名。每次新任务从池里 claim 一个
slot，秒级进入开发态，不需要每次等 80–90 秒做环境初始化。

## 为什么需要

AI-first 开发模式下，多 agent 并行 + 高频开 PR 的场景每天可能开 10+ 任务。
传统在单 worktree 切分支的方式有两个不可接受的成本：

- 切分支带来 docker volume / `.env` / 编译产物的状态污染
- 每次现建新 worktree 80–90 秒，AI 节奏下被这段时间拖死

池把"环境创建"成本一次性前置（`pool-init` 5 分钟），后续每次任务 0.5 秒。
**实测加速比 ~140×**。

## 核心组成

| 组件 | 作用 | 位置 |
|---|---|---|
| **slot** | 一个常驻 worktree，临时占用执行任务 | `<repo-parent>/<repo>-wt/.agent-pool/slot-N/` |
| **lock 文件** | 标识 slot 占用状态、心跳、当前分支 | `slot-N.lock`（同目录） |
| **flock 句柄** | claim 事务锁，保护并发 claim | `slot-N.lock.flock` |
| **park 分支** | slot 空闲时停在这条分支上 | `pool/slot-N` |
| **wip 分支** | `--force` 强删脏 slot 时未提交改动自动 stash 保命 | `wip/slot-N-<timestamp>` |
| **abandoned 态** | 脏 / 未推送 commit 的 slot 标记，等人工处理 | lock 字段 `state=abandoned` |
| **IDE 入口文件** | VSCode multi-root workspace，列 main + 全部 slot | `<repo-parent>/<repo>.code-workspace` |

## 原则性流程

### 首次开池（一次性）
1. `setup-project` skill 默认会跑 `pool-init`，3 slot，约 5 分钟
2. 完成后自动生成 `.code-workspace` 文件
3. 本地 VSCode → Remote-SSH 连开发机 → Open Workspace from File → 选这个文件

### 每次新任务
1. `start-feature` skill 自动 claim 一个空闲 slot
2. 在 slot 里写代码 / 测试 / 提交
3. 任务完成后 release 回池（未提交改动自动 stash 到 wip 分支保命）

### 多 agent 并行
- 同时存在 ≥ 2 个需求时，**默认用池**而不是单 worktree 切分支
- 一需求一 slot，互不干扰（独立端口、独立容器、独立 DB）
- 单 IDE 窗口通过 multi-root 看到所有 slot 的改动

## 不变量

1. **slot 是临时执行环境，不是长期 feature 沙箱**——长期沙箱用命名 worktree（`setup-worktree.sh`）
2. **任务分支不能被两个 slot 同时锁**——同分支 claim 第二次返回 exit 2，由 park 分支机制保证
3. **未提交改动不能被静默丢弃**——release 时自动 stash 到 `wip/slot-N-<ts>`
4. **slot 之间完全隔离**——独立端口段、独立 PG/Redis 容器、独立 DB schema
5. **池路径固化在 `<repo-parent>/<repo>-wt/.agent-pool`**——legacy 路径 `ffworkspace-wt/.agent-pool` 仍兼容
6. **IDE 入口文件由池脚本自动维护**——人不应手工编辑 `.code-workspace`，每次 claim/release 会被覆盖
7. **池存在的前提下，所有需求改动必须在 slot 或命名 worktree 里做**——主仓库（`<repo>/`）只用作"基线 + 同步 develop + 跨 slot 协调"，不当作工作目录。即使是一行 typo / 一个文档调整也要 claim slot 再做。池**未**初始化（exit 4）时**先 init 池**再 claim，不接受"反正改动小、直接在主仓库干"。例外：只读探索（`git log` / `grep` / 看文档）、跨 slot 协调（同步 develop / 看整体状态）、claim 失败的诊断

## 关键运行不变量补充

以下规则从踩坑沉淀升级而来。脚本实现已落地，但运维 / 调试 / 改造 agent-pool
时必须遵守，否则会触发同类事故。

### N0. claim 拉新 / release 删 ref：分支状态闭环

claim 的常见坑：本地 task 分支已存在（之前在别的 slot 干过同分支），claim 直接
`git checkout`，HEAD 留在 stale 位置。期间远端可能已有新 commit（别人 push 了 /
CI 补了 fixup）—— agent 拿到的是过时代码继续干，merge 时撞冲突。

修复闭环（claim + release 两端同时改）：

**claim 端**（`agent-claim.sh`）：
- `git fetch origin` 后比较本地 HEAD vs `origin/<task-branch>`
  - 本地是远端 ancestor → `git merge --ff-only` 自动同步到远端最新
  - 本地领先远端（有未 push 的 work）→ **不动 + 警告**
  - 本地远端分歧 → **不动 + 警告**，提示用户手工 rebase/merge
- 计算 `origin/<base>` 自分支创建以来领先多少 commit，提示但不自动 rebase

**release 端**（`agent-release.sh`，仅 clean 路径）：
- task 分支若满足任一 → 删 `refs/heads/<task-branch>`：
  - 远端已删（PR merged + auto-delete-on-merge）
  - 本地内容已被任一 protected base 吸收（L1 ancestor / L2 squash patch-id / L3 rebase patch-id）
- 不满足 → 保留本地 ref（避免误删未上游化的 work）
- abandoned 路径**永不删** ref

**为什么是两端同改不能只改一边**：只改 claim ff 但不删 ref，本地 ref 越积越多；
只删 ref 但 claim 不 ff，仍可能在边界场景拿到 stale HEAD。两端互为对方的保险。

### N1. heartbeat 守护 ≠ agent 活性

`agent-claim.sh` 用 `nohup ... &` + `disown` 启动 `agent-heartbeat.sh`，**生命周期
与 agent 进程解耦**。release 路径假设"agent 干完会调 `agent-release.sh`"——但 AI
工作流里这个假设经常破：commit + push + PR 后直接结束，slot 不主动 release，
heartbeat 守护却一直在更新 `heartbeat_at`，让 `ap_lock_is_stale` 永远 false。

**释放有三道防线**（按优先级）：

1. **Claude Code SessionEnd hook**（主路）—— `scripts/dev/agent-pool/hooks/on-session-end.sh`
   在 session 退出时立即调 `agent-release.sh`。安装：`install-release-hooks.sh`。
2. **心跳 daemon 自检**（次防线）—— `agent-heartbeat.sh` 每轮检查 lock 里
   `agent_ppid` 是否还活着，死了就自调 `agent-release.sh` 然后退出。**仅在
   `agent_ppid` 字段非空时生效**——字段非空意味着调用方显式 export `FFOA_AGENT_PPID`
   告诉池一个长命父进程的 PID。`eval "$(bash agent-claim.sh)"` 模式下 `$PPID` 是
   `$()` 子 shell，claim 一返回就死，所以**默认不记录** `agent_ppid`，避免误杀。
3. **crontab 兜底 sweep**（兜底）—— 每 5 分钟跑 `agent-sweep.sh`，命中
   `agent-dead` / `orphan` 路径就走 release 分级。

**release 按 workspace 状态分级**：
- `clean` + 所有 commit 已 push → 真释放（删 lock + reset worktree 到 park 分支，回 free 池）
- `dirty`（uncommitted 改动）→ 标 `state=abandoned reason=dirty`，保留 lock 和
  worktree，**绝不自动 reset**，等人工 review
- `unpushed`（HEAD 领先 upstream 且未被任一 protected base 吸收）→ 同上，`reason=unpushed`

`abandoned` slot 不被 claim 自动分配（claim 跳过非 free 非 stale 非 orphan 的
slot）；处理需人工：
- 接手继续干：`cd` 进 worktree，commit/push 后 `agent-release.sh <slot> --keep-changes`
- 确认丢弃：`agent-release.sh <slot> --force`（未提交内容会进 `wip/slot-N-<ts>` 兜底）

> **⚠ BREAKING（vs. 旧版 `--keep-changes`）**：旧版 `--keep-changes` 是 "rm lock +
> 保留 worktree"，slot **立刻回 free 池**可被新 claim 占用。新版改成 "标
> `state=abandoned` + 保留 lock + 保留 worktree"，slot **不再回 free 池**——claim
> 和 sweep 都会跳过它，直到人工 `--force` 或再次 `--keep-changes`。
> 如果有外部脚本/团队成员的肌肉记忆按旧语义调用（释放后期望新 claim 能占用），
> 会在池满时撞 exit 3。需要"释放且让 slot 可被 claim"的场景请直接走默认 release。

详细技术沉淀：`.learnings/2026-05-10-agent-pool-orphan-slot-leak.md`、
`.learnings/2026-05-16-agent-pool-release-tiering.md`。

### N2. lock 字段 `pid` 是装饰，活性必须看 `heartbeat_pid`

| 字段 | 写入时机 | 期望生命 |
|---|---|---|
| `pid` | claim 时写入调用方 PID（`$$`） | **预期会死**——claim 脚本一行就退 |
| `heartbeat_pid` | `agent-heartbeat.sh` 在 while 循环写自己的 `$$` | 跟随 slot 占用全程 |

调试 slot 状态时必须 `awk '/^heartbeat_pid=/{print $2}' lock` 拿真活性 PID；
看 `agent-status.sh` 表格里的 PID 列只是"当时谁 claim 的"，不代表活性。误信
导致两种事故：一是按"PID 死就 sweep"陷入死循环排查；二是手工 `rm lock` 让真在干
活的 agent 失去 lock 导致工作丢失。

### N3. release 前必须先沉淀 `.learnings/`

`agent-release.sh` 默认 `git clean -fdx` 排除列表只有 `node_modules` / `.next`
/ `.env*` / `TASK.md`——**`.learnings/` 不在内**。release 一跑就物理删除 untracked
learning 文件。

防御：
1. **首选**：commit 前先沉淀（CLAUDE.md 「本次 PR 相关的踩坑沉淀必须同 PR commit」
   铁律）
2. **次选**：`agent-release.sh <slot> --keep-changes` 显式保留 worktree
3. **检查**：release 前跑 `git status -uall --porcelain | grep '^?? \.learnings/'`，
   有就先处理

### N4. `.code-workspace` 单文件由脚本自动维护，旧手工文件要删

`ap_codeworkspace_file()` 把 `<repo>.code-workspace` 路径固定为
`<repo-parent>/<repo>.code-workspace`，**不扫描兄弟 `*.code-workspace` 文件**。
本机如果还有旧手工维护的同目录 workspace 文件（如 `ffoa.code-workspace`），
VSCode 通过 "recent workspaces" 可能默认打开它——用户看到的 slot 状态会是几周
前的快照。

排查"VSCode 侧边栏 slot 状态错位 / 缺 slot"必先做：

```bash
ls -la <repo-parent>/*.code-workspace   # 只有一个 = 不是这个陷阱
```

多于一个 → 删旧文件，让 VSCode 用 File → Open Workspace from File 重选自动维护
的那个。

## 设计决策

### 路径固化代替 git worktree list 探测
早期实现用"`git worktree list` 第一个非主 entry"推断池父目录，顺序依赖脆弱。
2026-05-10 起固化为 `<repo-parent>/<repo>-wt`，保留 legacy fallback 平滑过渡。

### VSCode multi-root + Remote-SSH 作为 IDE 入口
而不是把池放项目内部 `.worktree/`——后者要对抗 IDE / tsc / next / eslint 的全局
扫描默认行为，工具链摩擦面太大。multi-root + Remote-SSH 是 VSCode 原生支持的
正交特性，零侵入。

### `.code-workspace` 命名采用 `<repo>.code-workspace`
这是 VSCode 官方约定后缀，任何 IDE 看到这个后缀都识别为 multi-root workspace。
位置在 repo-parent 下、跟主仓库平级，避免侵入仓库内部。

详细技术沉淀见 `.learnings/2026-05-10-pool-pin-and-codeworkspace.md`。

## 例外：什么时候**不**用池

- **单 feature 持续多周**（asset-management 这种长期模块）→ 用命名 worktree（`setup-worktree.sh`）
- **CI / 资源紧张机器** → `FFOA_AGENT_POOL_ENABLED=false` 一票禁用，自动 fallback 到单 worktree
- **池满（exit 3）** → 扩容（`pool-resize`）/ 等其他 slot 释放 / 临时走旧路

## 工具与 skill 索引

| 用途 | 入口 |
|---|---|
| 完整工具参考（命令清单 / env 表 / 调试 / 测试） | [`scripts/dev/agent-pool/README.md`](../../scripts/dev/agent-pool/README.md) |
| 首次开池 | `setup-project` skill |
| 每次新任务 | `start-feature` skill |
| 命名 worktree（长期 feature） | `setup-worktree.sh`（不在池里） |
| 池槽 vs 命名 worktree 选择 | [开发工作流 §「并行开发选择树」](./05-development-workflow.md#并行开发选择树重要) |

## 沉淀的踩坑（详细技术细节）

本文的不变量 + 设计决策来自这些 learnings，本文 distill 出"必须遵守的规则"；
具体事故复盘 / 误诊路径 / 代码引用见原文：

- `.learnings/2026-05-10-agent-pool-orphan-slot-leak.md` — 假占用泄漏（N1 来源）
- `.learnings/2026-05-10-agent-pool-pid-vs-heartbeat-pid.md` — lock 字段语义（N2 来源）
- `.learnings/2026-05-10-agent-release-clean-removes-untracked-learnings.md` — release clean 删 learning（N3 来源）
- `.learnings/2026-05-10-codeworkspace-dual-file-trap.md` — 双 workspace 文件陷阱（N4 来源）
- `.learnings/2026-05-10-pool-pin-and-codeworkspace.md` — 路径固化 + multi-root + Remote-SSH 叠加（设计决策来源）
