# 2026-05-16 — Agent Pool 释放分级（abandoned 态 + 三道防线）

## 背景

发现池里 4 个 slot 全部"假占用"——主 agent 已退，但心跳 daemon 还在跑（slot-1
已经跑 2 天 3 小时），lock 文件被持续刷新心跳，让 `ap_lock_is_stale` 永远返回
false。最严重的 slot-5 里还堆了几十个 staged 文件（PR0.5 monorepo skeleton 的
完整实现）—— 如果按"PID 死就清"的简单策略一刀切，会把没 commit 的工作全 reset。

老 sweep 只覆盖一种特殊场景（"orphan"）：远端分支已删 + 工作区干净 + commit
已合并。对于"分支还在 / PR 没合 / 工作区脏"的常见 stale，sweep 没办法。

## 核心设计：释放按 workspace 状态分级，不一刀切

| workspace 状态 | 处理 |
|---|---|
| `clean` + 全部 commit 已 push | 真释放：删 lock + reset worktree 回 park 分支 |
| `dirty`（uncommitted 改动） | 标 `state=abandoned reason=dirty`，保留 lock 和 worktree |
| `unpushed`（HEAD 领先 upstream） | 标 `state=abandoned reason=unpushed`，同上 |

`abandoned` slot **绝不被自动清理**——`claim` 跳过它，`sweep` 跳过它，
只能人工 `--force` 或 `--keep-changes`。

## 三道防线

1. **SessionEnd hook**（主路）—— `hooks/on-session-end.sh` 在 Claude session 退出
   时立即调 release。0 延迟。
2. **心跳 daemon 自检**（次防线）—— heartbeat 每轮检查 `agent_ppid` 是否还活着，
   死了就自调 release 然后退出。**只在 lock 里 `agent_ppid` 字段非空时生效**。
3. **crontab 兜底 sweep**（兜底）—— 每 5 分钟扫一次，处理前两道都没跑成的情况。

## 关键坑：`$PPID` 默认不可用

最初实现把 `agent_ppid=$PPID` 默认写进 lock，理由是"$PPID 是 claim 调用者的 PID"。
跑测试时 heartbeat 一启动就把所有 slot 全清了——因为 claim 的常见调用是
`eval "$(bash agent-claim.sh ...)"`，**`$PPID` 是 `$()` 子 shell**，claim 一
返回那一瞬间就死，heartbeat 第一轮检查就判"agent 死"触发释放。

**修复**：`agent_ppid="${FFOA_AGENT_PPID:-}"`（默认空）。调用方必须显式 export
`FFOA_AGENT_PPID=$$`（SessionEnd hook 包装器 / 长 driver shell）才记录。字段空 →
心跳自检和 sweep agent-dead 路径都跳过 → 行为退化到老逻辑（靠 SessionEnd hook
或 crontab 兜底）。**永远不要把 ephemeral subshell 的 PID 当作 "agent 活性"指标**。

## 关键坑：squash merge 后 `rev-list --count upstream..HEAD` 不认 patch-id 等价

`ap_workspace_status` 用 `rev-list --count` 判断 unpushed，但 squash merge 后
本地 commit 在远端是不同 SHA，rev-list 看 SHA 祖先，会把"已被 squash 吸收的
本地 commit"判成 unpushed → slot 被错误标 abandoned。

**修复**：`rev-list --count > 0` 后再过一遍 `ap_branch_absorbed`（含 L1/L2/L3
三层 patch-id 等价检测，复用 sweep orphan 路径的逻辑）。这条规则同样适用于
任何"判断本地分支领先于远端"的场景。

## 第二轮迭代：claim/release 分支状态闭环

发现 release 分级修了"清不清"问题，但**没修"清了之后下次拿到的是不是新鲜的"**。
真实痛点：agent 经常拿到 stale 本地 HEAD 干活，merge 时撞冲突或重复工作。

两端互为保险：
- **claim 端**：本地分支已存在时，按 `本地 vs origin/<task>` 关系决定动作：
  - 本地是远端 ancestor → `git merge --ff-only` 自动拉新
  - 本地 ahead → 不动 + 警告（保护未 push 的 work）
  - 分歧 → 不动 + 警告（要求人工解冲突）
- **release 端**（clean 路径）：删本地 `refs/heads/<task>` 当且仅当 远端已删 或
  已被任一 protected base 吸收。abandoned 路径永不删。

为啥两端都改：只改 claim ff，本地 ref 越攒越多；只删 ref 但 claim 不 ff，仍能在
"远端还在 + 本地分支 stale" 场景拿到老 HEAD。

### 测试构造的坑

T19/T20 测试构造"本地 vs 远端"特定关系时，用 `git update-ref refs/remotes/origin/*`
伪造远端 ref。两个问题：

1. **`fetch.prune=true`（全局配置）会让 claim 内 `git fetch origin` 把伪造的远端 ref
   prune 掉**。修复：测试入口 `git config --local fetch.prune false`，cleanup 恢复。
2. **测试构造的远端 commit 不在 develop history 上 → release 时 absorbed 检测
   返回 NO → ws_status=unpushed → 标 abandoned 留住** → 下个测试池满。修复：
   T19/T20 末尾用 `--force` 强制真释放。

## 测试覆盖（54/54 通过）

- T10：dirty → abandoned（不 reset）；`--force` → wip 分支兜底
- T17：`FFOA_AGENT_PPID` 显式 + 父死 → sweep agent-dead 路径回收（隔离测试：
  先 kill 心跳避免心跳自检抢先）
- T18：agent-dead + dirty → 转 abandoned；claim 跳过 abandoned slot
- T16 回归：squash merge zombie 仍能识别

## 影响

- `scripts/dev/agent-pool/agent-claim.sh`：`agent_ppid` 显式记录
- `scripts/dev/agent-pool/agent-release.sh`：分级释放，新增 `--force`
- `scripts/dev/agent-pool/agent-heartbeat.sh`：`agent_ppid` 自检
- `scripts/dev/agent-pool/agent-sweep.sh`：`agent-dead` 路径，跳过 abandoned
- `scripts/dev/agent-pool/agent-status.sh`：`abandoned` 显示 + 处理提示
- `scripts/dev/agent-pool/hooks/on-session-end.sh`：新文件
- `scripts/dev/agent-pool/install-release-hooks.sh`：新文件（一键装 hook + cron）
- `scripts/dev/lib/agent-pool-lib.sh`：`ap_workspace_status` / `ap_agent_alive` /
  `ap_slot_state` 改造
- `docs/standards/10-agent-pool.md`：N1 章节重写
