---
date: 2026-05-10
tags: [agent-pool, design, lifecycle, leak]
severity: high-impact
---

# agent-pool slot 假占用泄漏：heartbeat 守护 detached + 无人调 release = 池永久打满

## 现象

某天发现 5 个 slot 全部 `claimed`：

```
SLOT   STATE     BRANCH                              HEARTBEAT
1      claimed   chore/issues-batch-cleanup          2026-05-10T13:55:21Z
2      claimed   chore/simplify-audit-module         2026-05-10T13:55:24Z
3      claimed   chore/simplify-permission-module    2026-05-10T13:55:05Z
4      claimed   fix/ai-review-mcp-structured-output 2026-05-10T13:55:26Z
5      claimed   chore/simplify-logging-system       2026-05-10T13:55:03Z
```

`agent-sweep.sh` 跑了回收 0 个。直觉"应该是僵尸"，但 sweep 又确认全部 alive，进入怀疑。

实际上 5 个 task_branch **远端全部已删**（PR 全部 merged + Gitea auto-delete-on-merge 触发），slot 路径下也**没有任何 Claude/node 进程**。但每个 slot 的 heartbeat 守护进程（`agent-heartbeat.sh`）还在每 30s 更新 `heartbeat_at`，让 `ap_lock_is_stale` 永远返回 not-stale。

## 根因（设计层）

`agent-claim.sh` 用 `nohup ... &` + `disown` 启动 heartbeat 守护，**与 agent 进程生命周期解耦**。设计意图：claim 命令一行就退（一次性），守护跟着 slot 占用全程。

但 release 路径有个隐含假设：**agent 干完活会调 `agent-release.sh`**，release 才 kill heartbeat。

实际工作流里这条假设经常破：
- AI 沉浸在任务里，做完直接 commit + push + PR，**忘了 release slot**
- PR merged → Gitea auto-delete-on-merge 删远端分支 → 但 slot lock 还在 + heartbeat 守护还在
- 累积到池满 → 下次 claim 撞 exit 3

`ap_lock_is_stale` 看 heartbeat_at 是否超时、heartbeat_pid 是否活——两个信号都被守护进程"伪装"得活蹦乱跳。stale 判断本身没 bug，是它能识别的"死法"覆盖不全：
- ✅ heartbeat 守护进程被 kill（OOM、reboot、手工杀）
- ❌ heartbeat 守护好好的活着，但 agent 早就退出了

## 解法：增加 orphan 状态

stale 之外，引入 **orphan**：agent 退了但 heartbeat 还活的"假占用"。判定要 6 项 AND，避免误杀真活的 agent：

| 判定 | 防的是 |
|---|---|
| 1. lock 文件 + task_branch + slot 目录都在 | 基本前提 |
| 2. claim 时间 ≥ AP_ORPHAN_MIN_AGE（默认 5min） | 误杀刚 claim 还没产生 commit 的活 agent |
| 3. slot worktree 干净（git status --porcelain 空） | 误杀正在编辑文件的 agent |
| 4. 本地 HEAD 没有未合并到 origin/develop\|staging\|production 的 commit | 误杀 commit 已写但 PR 还在 review 的分支 |
| 5. 远端 origin 已无 task_branch 同名分支 | 误杀本地刚 commit 还没 push 的分支 |
| 6. （网络 IO 放最后做 short-circuit） | 性能 |

`ap_lock_is_orphan` 通过这 6 项 AND 才返回 0。`agent-sweep.sh` 调它，识别到就调 `agent-release.sh`（必须走 release 才能 kill heartbeat + wip 兜底）。`agent-claim.sh` 池满 fallback 也调它（stale 回收循环之后的二级兜底）。

## 关键设计决策

1. **不改 `ap_lock_is_stale`**：被 `ap_slot_state` 高频调用，每次 status 都跑 git ls-remote 太慢。orphan 是独立慢路径，只在 sweep / claim 池满 fallback 触发。

2. **AP_ORPHAN_MIN_AGE 默认 5 分钟**：小于此年龄一律放过。代价是池满后必须等 5 分钟才能识别 orphan，但收益是绝不误杀活 agent。AI 单任务通常 ≥ 10 分钟，5 分钟阈值没有实际损失。

3. **orphan 检测必须走 release 而非 rm lock**：heartbeat 守护进程还活着，rm lock 后守护进程会自己看到 lock 没了然后退出（设计上是这样），但**风险窗口期内**别的 claim 可能撞同 slot；走 release 显式 kill heartbeat，原子性更强。

4. **commit 不在 develop/staging/production 之内 → 不算 orphan**：同时验证"远端无该分支"才算。两个条件 AND 才能精确识别"PR 已合并 + auto-delete"的精确状态——这是真正的 orphan 信号，不是其他场景。

## 给未来 AI 的判断口诀

- 看到 sweep 回收 0 但池满，别相信"全部 alive"的表象，立刻看 lock 里的 `task_branch` 是否远端还有：`git ls-remote origin "refs/heads/$(awk -F= '/^task_branch=/{print $2}' lock)"` 空 = 已删 = 大概率 orphan。
- 修这种 leak 不能只删 lock 不杀 heartbeat，否则下次 claim 时同 slot 会跑两个 heartbeat。
- 给 detached daemon 设计的命令脚手架（这里是 `nohup ... &`）务必给一条"不依赖 release 的兜底回收路径"，不要赌"用户/AI 一定会调 release"。

## 关联

- ERR-20260510-004：测试基础设施隔离缺陷（park 分支命名空间冲突），跟本 fix 同时浮出，独立 follow-up
- 设计文档：`docs/standards/10-agent-pool.md`（待补 orphan 段）
- 入口工具：`scripts/dev/agent-pool/README.md`（待补 sweep 双对象语义）
