fix(pi): 等人点头的 worker 不再占并发额度,也不会被硬超时杀掉

实测(今天全 Agent 演练时撞到的):pi 的 `maxWorkers=3` 被**三个正在等人工授权**的
worker 吃满,于是新邮件只能排队 —— 而人可能十分钟后才看邮箱。清掉卡住的请求后队列
立即排空,机制本身没错,错的是"等待"被当成了"在干活"。

两处改动(`src/pool.mjs`):

1. **等待期间让出并发额度**。worker 发 `permission_pending` 时把它的 entry 标为
   parked,`pump()` 只数"在干活"的(`activeCount()`)。停放另有上限 `maxParked`
   (默认 5,防止内存无界:每 worker 约 140MB);超出后仍占额度并打日志说明。
   决策到达(`routePermission`)时解除停放,回到额度里。

2. **等待期间暂停硬超时**。硬超时的用途是回收**卡死**的进程,而等人点头不是卡死:
   停放时清掉计时器,决策到达后重新起一个完整窗口。否则 worker 会在人还在读邮件时
   被 SIGKILL —— 那次工具调用直接消失,人后来批了也没人接(这类"批了没反应"的现象
   与此吻合)。

判据(`test/pool.test.mjs` 新增 3 条):
  · ★ 等待授权的 worker 让出额度:另一个会话的邮件必须能开跑
  · ★ 等人点头期间(800ms > 300ms 硬超时)不得被强杀,且恢复后能跑完
  · 停放有上限:超出后仍占额度(不无限超发)
**扰动验证**:整块回退到 HEAD → 3 条全红;修复后 3/3。全量 pi 套件 420/420。

已部署(快照 20260913-131216,桥重启并重新心跳)。
This commit is contained in:
2026-09-13 13:14:56 +08:00
parent 35f71d5144
commit 49ec22f916
2 changed files with 122 additions and 13 deletions

View File

@ -92,6 +92,7 @@ function makePool(opts = {}) {
config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }),
onReconfigure: opts.onReconfigure || (() => {}),
maxWorkers: opts.maxWorkers ?? 2,
maxParked: opts.maxParked ?? 5,
workerMaxMs: opts.workerMaxMs ?? 5000,
maxAttempts: opts.maxAttempts,
workerPath: opts.workerPath || STUB,
@ -418,3 +419,63 @@ test('重投上限之下不会无限重投maxAttempts=1 就是不重投)',
assert.equal(jobs(lines).length, 1, 'maxAttempts=1 时只跑一次');
});
// ─── 等人点头 ≠ 卡死2026-09-13 实测后加的)────────────────────────
//
// 实测故障maxWorkers=3 被三个"正在等人工授权"的 worker 吃满,于是新邮件只能
// 排队 —— 而人可能十分钟后才看邮箱。等待不是故障:它既不占内存活动量也不打上游,
// 不该占并发额度;同理它也不是"卡死",不该被硬超时回收(否则人还在读邮件,
// 那次工具调用就被 SIGKILL 了,人后来批了也没人接)。
test('★ 等人工授权的 worker 让出并发额度:别的会话不再被它堵住', async () => {
const { pool, lines } = makePool({ maxWorkers: 1 });
pool.submit('mail', { mail_id: 'waiting', session_id: 'WAIT', __pending: 'rk-wait' });
await until(() => pool.stats().running === 1, 1500);
// 另一个会话的邮件:修复前额度被等待者占着,它永远不会开跑
pool.submit('mail', { mail_id: 'other', session_id: 'OTHER', __hold: 30 });
const started = await until(() => jobs(lines).some((j) => j.mailID === 'other'), 2000);
assert.ok(started, '等待授权的 worker 不该占着额度 —— 另一个会话必须能开跑');
// 决策到达 → 等待者恢复、继续跑完
assert.equal(pool.routePermission('rk-wait', '同意'), true);
const drained = await until(() => pool.stats().running === 0, 4000);
pool.stop();
assert.ok(drained, '收到决策后应正常结束');
assert.ok(lines.some((l) => l.includes('DECISION rk-wait=同意')), `worker 应收到决策,实际:\n${lines.join('\n')}`);
});
test('★ 等人点头期间不被硬超时杀掉(等待不是卡死)', async () => {
const { pool, lines } = makePool({ maxWorkers: 1, workerMaxMs: 300 });
pool.submit('mail', { mail_id: 'hold-perm', session_id: 'HP', __pending: 'rk-hp' });
await until(() => pool.stats().running === 1, 1000);
await sleep(800); // 远超 300ms 硬超时
assert.equal(pool.stats().running, 1, '等待决策的 worker 不该被硬超时回收');
assert.ok(!lines.join('\n').includes('强杀'), '等待期间的强杀不该发生');
pool.routePermission('rk-hp', '同意');
const drained = await until(() => pool.stats().running === 0, 3000);
pool.stop();
assert.ok(drained, '恢复后应能正常跑完(超时窗口重启)');
});
test('停放有上限:超出后仍占用额度(不无限超发)', async () => {
const { pool, lines } = makePool({ maxWorkers: 3, maxParked: 1 });
try {
pool.submit('mail', { mail_id: 'p1', session_id: 'P1', __pending: 'rk-p1' });
pool.submit('mail', { mail_id: 'p2', session_id: 'P2', __pending: 'rk-p2' });
await until(() => jobs(lines).length >= 2, 2500);
await sleep(250);
assert.ok(
lines.some((l) => l.includes('停放额度已满')),
`第二个等待者应记录"额度已满"并继续占额度,实际:\n${lines.join('\n')}`
);
for (const rk of ['rk-p1', 'rk-p2']) pool.routePermission(rk, '同意');
await until(() => pool.stats().running === 0, 3000);
} finally {
// 断言失败时也要收掉子进程:否则挂住的 worker 会拖住整个测试文件(实测过)
pool.stop();
}
});