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:
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user