Skip to content

fix(engine): passive 实例在 active 退出后重新竞选,不再终身旁观 - #48

Open
AmberCXX wants to merge 1 commit into
LinekForge:mainfrom
AmberCXX:fix/engine-passive-reelection
Open

AmberCXX wants to merge 1 commit into
LinekForge:mainfrom
AmberCXX:fix/engine-passive-reelection

Conversation

@AmberCXX

@AmberCXX AmberCXX commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

动机

273bf3a(修 #27)引入 PID lock 后,第二个 engine 实例会进入 passive 模式以避免重复触发。但 acquirePidLock() 只在 startScheduler() 里调用一次——active 实例退出后,已经处于 passive 的实例不会重新竞选,即使它们还活着、MCP 还连着。调度就此静默停摆,直到下一个新 session 启动。

根因:原实现处理了 stale 锁回收,但只在启动路径上——新实例发现旧持锁者已死时接管。没覆盖的是相反顺序——已经在场的 passive 实例,发现 active 死了该怎么办。

if (!acquirePidLock()) {
  isPassive = true;
  log("🔇 passive mode — ...");
  return;          // 到此为止,此后再不看锁文件一眼
}

顺带一提,isPassive 这个变量在改动前是只写不读的(除 stopScheduler() 里重置外没有任何读取点),这大概也说明当初留了个没接上的口子。

复现:

  1. 开一个 Claude Code session(实例 A 拿到锁,active)
  2. 再开两个 session(实例 B、C 进入 passive)
  3. 关掉 A

此时 B、C 仍在运行、/mcp 显示 forge-engine 连接正常、MCP 工具可正常调用,但 engine-data/engine.pid 已消失、engine.log 停止输出、所有定时任务不再触发。故障唯一的可见信号是一个没人会主动去看的锁文件。 2026-09-08 实测遇到,停摆约 10 分钟才被发现;此前 09-03 有过一次同类症状的静默停摆(config.ts 顶部注释记录为 10 小时 31 分、漏跑 12 个任务)。

改动概要

  • passive 实例启动 30s 间隔的重新竞选定时器,夺锁成功即接管调度
  • 抽出 activate(),首次启动与事后接管共用同一段排程逻辑
  • stopScheduler() 清理竞选定时器
  • 导出 isPassiveMode() / retryPassivePromotion(),测试可直接驱动一次竞选,不必等 interval
  • 新增 forge-engine/scheduler-pid-lock.test.ts,4 个用例,用 FORGE_ENGINE_DATA 指向临时目录做隔离:
场景 期望
持锁者存活 进入 passive
持锁者正常退出(锁文件删除) 自我提升为 active
持锁者崩溃(stale 锁残留) 自我提升为 active
持锁者仍存活 保持 passive,且不夺锁

最后一条是防止修过头——如果接管条件放松了,它会失败。

影响范围

  • 只动 forge-engine/ 的 scheduler(PID lock 之后的 passive 分支)与一个新测试文件;hub-server / hub-client / cli 不涉及。
  • acquirePidLock() 本身未改:持锁者仍存活时照旧返回 false。所以接管只可能发生在锁被释放(正常退出)或变成 stale(崩溃)之后,不会退回 forge-engine: multi-instance duplicate triggers from per-session engine-channel #27 的重复触发。
  • 单实例场景行为完全不变;多实例场景多一个 30s 定时器(passive 实例上),active 实例无额外开销。
  • 与 task affinity 的关系:forge-engine: multi-instance duplicate triggers from per-session engine-channel #27 里提到的长期方向(给任务加 assignee、按实例 tag 分配)解决的是「谁该执行」,本 PR 解决的是「负责的那个死了之后谁来接」——两者正交,不冲突。加了 affinity 之后,如果被指派的实例退出,任务同样会丢,这个竞选机制仍然需要。

self-test 结果

(cd forge-engine && bunx tsc --noEmit && bun test)   → 23 pass / 0 fail
bun hub-test-harness/harness.ts                     → 8/8
fh hub self-test                                    → 8/8
  • 安全自检 grep -rE '@im\.wechat|ou_[a-f0-9]{16,}|sk-ant-…|sk-…|[0-9]{9,10}:[A-Za-z0-9_-]{35}' 对本 PR 改动文件 → 无命中。
  • 环境:macOS 14.6.1 · Bun 1.3.13

issue LinekForge#27 的 PID lock(273bf3a)让第二个实例进入 passive 模式以避免重复触发,
但 acquirePidLock() 只在启动时调用一次。active 实例退出后,已经处于 passive 的
实例不会重新竞选——即使它们还活着、MCP 还连着,调度也静默停摆到下一个新 session
启动为止。

原实现处理了 stale 锁回收,但只在启动路径上:新实例发现旧持锁者已死时接管。
没覆盖的是反过来的顺序——已经在场的 passive 实例发现 active 死了该怎么办。

实测(2026-09-08):三个实例,active 被关闭后剩下两个 passive。此时 `/mcp` 显示
连接正常、进程在、MCP 工具可调用,但 PID 锁文件消失、engine.log 停止输出,全部
定时任务不再触发。故障唯一的可见信号是一个没人会主动去看的锁文件。

改动:
- passive 实例启动 30s 间隔的重新竞选定时器,夺锁成功即接管调度
- 抽出 activate(),首次启动与事后接管共用同一段排程逻辑
- stopScheduler() 清理竞选定时器
- 导出 isPassiveMode() / retryPassivePromotion() 供测试直接驱动,不必等 interval

acquirePidLock() 本身未改:持锁者仍存活时照旧返回 false。所以接管只可能发生在锁
被释放(正常退出)或变成 stale(崩溃)之后,不会退回 LinekForge#27 的重复触发。

测试 forge-engine/scheduler-pid-lock.test.ts(4 个,含一条防止修过头的反向测试):
- 持锁者存活时进入 passive
- 持锁者正常退出后自我提升
- 持锁者崩溃留下 stale 锁时自我提升
- 持锁者仍存活时保持 passive,且不夺锁

验证:
- (cd forge-engine && bunx tsc --noEmit && bun test) → 23 pass / 0 fail
- bun hub-test-harness/harness.ts → 8/8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pGyNVmhbA1Z1vRoxLZbFe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant