@zhushanwen/pi-subagent-workflow 5.0.0 → 5.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"@earendil-works/pi-ai": "*",
|
|
50
50
|
"@earendil-works/pi-tui": "*",
|
|
51
51
|
"typebox": "*",
|
|
52
|
-
"@zhushanwen/pi-structured-output": "5.0.0"
|
|
52
|
+
"@zhushanwen/pi-structured-output": "5.0.0",
|
|
53
|
+
"@zhushanwen/pi-pending-notifications": "0.3.1"
|
|
53
54
|
},
|
|
54
55
|
"peerDependenciesMeta": {
|
|
55
56
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -48,6 +48,11 @@ vi.mock("../alive-store.ts", () => ({
|
|
|
48
48
|
writeAliveMarker: vi.fn(),
|
|
49
49
|
}));
|
|
50
50
|
|
|
51
|
+
// [recursive-orchestration] agent_end 后代判定独立 mock(判定函数本身在 session-pending.test.ts 单独测)。
|
|
52
|
+
vi.mock("../session-pending.ts", () => ({
|
|
53
|
+
readActivePendingFromSessionFile: vi.fn(() => ({ count: 0 })),
|
|
54
|
+
}));
|
|
55
|
+
|
|
51
56
|
vi.mock("../temp-prompt.ts", () => ({
|
|
52
57
|
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
53
58
|
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
@@ -56,7 +61,8 @@ vi.mock("../temp-prompt.ts", () => ({
|
|
|
56
61
|
cleanupTempPrompt: vi.fn(async () => {}),
|
|
57
62
|
}));
|
|
58
63
|
|
|
59
|
-
import { killAllSpawnedChildren, runSpawn, spawnedChildren } from "../session-runner.ts";
|
|
64
|
+
import { killAllSpawnedChildren, runSpawn, spawnedChildren, WAKEUP_GRACE_MS, computeWatchdogMs } from "../session-runner.ts";
|
|
65
|
+
import { readActivePendingFromSessionFile } from "../session-pending.ts";
|
|
60
66
|
import {
|
|
61
67
|
emitStdoutLine,
|
|
62
68
|
type FakeChild,
|
|
@@ -366,7 +372,177 @@ describe("runSpawn", () => {
|
|
|
366
372
|
// 主动 child.kill("SIGTERM") 让子进程退出,触发 close → runSpawn resolve。
|
|
367
373
|
// willRetry=true 时 agent 会重试,不能 kill。
|
|
368
374
|
describe("agent_end 自然完成", () => {
|
|
369
|
-
|
|
375
|
+
// [recursive-orchestration] 条件 kill:agent_end 时读子进程 session 文件算活跃后代
|
|
376
|
+
// (pending:register − unregister 差集)。有活跃后代 → 保持进程 idle 等 steer 唤醒。
|
|
377
|
+
const mockPending = vi.mocked(readActivePendingFromSessionFile);
|
|
378
|
+
|
|
379
|
+
// [MF-3] recentUnregister 竞态分支的秒级宽限:count=0 + recentUnregister=true → keep alive
|
|
380
|
+
// 挂 WAKEUP_GRACE_MS(15s)定时器,到期无新 agent_end(未被唤醒)即 kill。
|
|
381
|
+
// 背景:旧实现挂固定 2h(WAIT_DESCENDANT_TIMEOUT_MS)——层主 closeout 的最终 agent_end
|
|
382
|
+
// 距最后一次 unregister <60s 必命中此分支,空等 2h 才 kill + 冒牌完成通知级联。
|
|
383
|
+
it("MF-3: agent_end(count=0 + recentUnregister)→ 15s 宽限到期后 kill", async () => {
|
|
384
|
+
mockPending.mockReturnValue({ count: 0, recentUnregister: true });
|
|
385
|
+
const record = makeRecord();
|
|
386
|
+
const promise = runSpawn(record, "Task: wake-grace", makeOpts(), makeCtx());
|
|
387
|
+
|
|
388
|
+
await waitForSpawn();
|
|
389
|
+
const child = lastSpawnedChild();
|
|
390
|
+
|
|
391
|
+
// 必须在 emit agent_end 之前启用 fake timers——keep-alive 定时器在 agent_end 处理器里
|
|
392
|
+
// 新建,新建时若已是 fake 定时器才可被 advance。不 fake setImmediate:stream .write() 的
|
|
393
|
+
// data flush 靠真实事件循环(微任务/nextTick)交付,用真实 setImmediate 让出事件循环。
|
|
394
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
395
|
+
try {
|
|
396
|
+
emitStdoutLine(child, sessionHeader());
|
|
397
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
398
|
+
// 让事件 pump 处理 agent_end(stream flush 是异步的)
|
|
399
|
+
await new Promise((r) => setImmediate(r));
|
|
400
|
+
// keep alive:宽限挂起,不 kill
|
|
401
|
+
expect(child.killed).toBe(false);
|
|
402
|
+
|
|
403
|
+
// 宽限未到(14999ms):仍不 kill
|
|
404
|
+
await vi.advanceTimersByTimeAsync(WAKEUP_GRACE_MS - 1);
|
|
405
|
+
expect(child.killed).toBe(false);
|
|
406
|
+
|
|
407
|
+
// 第 15000ms 到期:无新 agent_end(未被唤醒)→ kill
|
|
408
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
409
|
+
expect(child.killed).toBe(true);
|
|
410
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
411
|
+
|
|
412
|
+
// 收尾:close 让 runSpawn resolve
|
|
413
|
+
child.stdout.end();
|
|
414
|
+
child.stderr.end();
|
|
415
|
+
child.emit("close", 143);
|
|
416
|
+
|
|
417
|
+
const result = await promise;
|
|
418
|
+
expect(result.success).toBe(true);
|
|
419
|
+
} finally {
|
|
420
|
+
vi.useRealTimers();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// [MF-3] 唤醒重评估:宽限挂起期间父被 steer 唤醒(续跑产出新一轮)→ 下一次 agent_end
|
|
425
|
+
// 重新判定;此时后代已完成(count=0,recentUnregister=false)→ 正常 kill,不等宽限到期。
|
|
426
|
+
it("MF-3: 宽限期内被唤醒 → 下一次 agent_end 重新评估(后代已完成 → kill)", async () => {
|
|
427
|
+
mockPending
|
|
428
|
+
.mockReturnValueOnce({ count: 0, recentUnregister: true })
|
|
429
|
+
.mockReturnValueOnce({ count: 0, recentUnregister: false });
|
|
430
|
+
const record = makeRecord();
|
|
431
|
+
const promise = runSpawn(record, "Task: wake-then-done", makeOpts(), makeCtx());
|
|
432
|
+
|
|
433
|
+
await waitForSpawn();
|
|
434
|
+
const child = lastSpawnedChild();
|
|
435
|
+
|
|
436
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
437
|
+
try {
|
|
438
|
+
emitStdoutLine(child, sessionHeader());
|
|
439
|
+
// 第一次 agent_end:recentUnregister 竞态 → keep alive(宽限挂起)
|
|
440
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
441
|
+
await new Promise((r) => setImmediate(r));
|
|
442
|
+
expect(child.killed).toBe(false);
|
|
443
|
+
|
|
444
|
+
// 被唤醒后父续跑产出新一轮 agent_end:后代已 unregister → 立即 kill(不等宽限)
|
|
445
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
446
|
+
await new Promise((r) => setImmediate(r));
|
|
447
|
+
expect(child.killed).toBe(true);
|
|
448
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
449
|
+
|
|
450
|
+
child.stdout.end();
|
|
451
|
+
child.stderr.end();
|
|
452
|
+
child.emit("close", 143);
|
|
453
|
+
|
|
454
|
+
const result = await promise;
|
|
455
|
+
expect(result.success).toBe(true);
|
|
456
|
+
} finally {
|
|
457
|
+
vi.useRealTimers();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// [MF-4] count>0 分支的动态超时:等待超时 = computeWatchdogMs(maxTurns),非固定 2h。
|
|
462
|
+
// maxTurns=20 → 20×5min = 100min 到期 kill。若回归到固定 2h 常量,100min 处不会 kill,
|
|
463
|
+
// 本用例 advance(computeWatchdogMs − 1) 后仍不 kill、再 +1 才 kill 的断言会失败。
|
|
464
|
+
it("MF-4: agent_end(count>0)→ 等待超时 = computeWatchdogMs(maxTurns)(动态,非固定 2h)", async () => {
|
|
465
|
+
const maxTurns = 20;
|
|
466
|
+
const expected = computeWatchdogMs(maxTurns);
|
|
467
|
+
mockPending.mockReturnValue({ count: 2 });
|
|
468
|
+
const record = makeRecord();
|
|
469
|
+
const promise = runSpawn(record, "Task: slow-desc", makeOpts({ maxTurns }), makeCtx());
|
|
470
|
+
|
|
471
|
+
await waitForSpawn();
|
|
472
|
+
const child = lastSpawnedChild();
|
|
473
|
+
|
|
474
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
475
|
+
try {
|
|
476
|
+
emitStdoutLine(child, sessionHeader());
|
|
477
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
478
|
+
await new Promise((r) => setImmediate(r));
|
|
479
|
+
// keep alive:动态超时挂起,不 kill
|
|
480
|
+
expect(child.killed).toBe(false);
|
|
481
|
+
|
|
482
|
+
// 未到动态超时(100min−1ms):不 kill
|
|
483
|
+
await vi.advanceTimersByTimeAsync(expected - 1);
|
|
484
|
+
expect(child.killed).toBe(false);
|
|
485
|
+
|
|
486
|
+
// 动态超时到期:kill。若误用固定 2h,此处不会 kill → 用例失败
|
|
487
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
488
|
+
expect(child.killed).toBe(true);
|
|
489
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
490
|
+
|
|
491
|
+
child.stdout.end();
|
|
492
|
+
child.stderr.end();
|
|
493
|
+
child.emit("close", 143);
|
|
494
|
+
|
|
495
|
+
const result = await promise;
|
|
496
|
+
expect(result.success).toBe(true);
|
|
497
|
+
} finally {
|
|
498
|
+
vi.useRealTimers();
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// [S-9] pending.error 分支(sessionFile 不可读 → 保守 keep-alive + re-arm dynamic watchdog)
|
|
503
|
+
// 集成行为 guard:session-pending 单测覆盖 error 返回值,但 session-runner 的 no-kill +
|
|
504
|
+
// re-arm 到 computeWatchdogMs(maxTurns) 行为无集成 guard。若 re-arm 误删/误用固定超时,
|
|
505
|
+
// 保守 keep-alive 会退化成永久挂起或被固定超时误杀。
|
|
506
|
+
it("S-9: agent_end(count=0 + error)→ 保守不 kill + watchdog re-arm 到动态超时", async () => {
|
|
507
|
+
const maxTurns = 20;
|
|
508
|
+
const expected = computeWatchdogMs(maxTurns);
|
|
509
|
+
mockPending.mockReturnValue({ count: 0, error: "session file unreadable: EACCES" });
|
|
510
|
+
const record = makeRecord();
|
|
511
|
+
const promise = runSpawn(record, "Task: unreadable", makeOpts({ maxTurns }), makeCtx());
|
|
512
|
+
|
|
513
|
+
await waitForSpawn();
|
|
514
|
+
const child = lastSpawnedChild();
|
|
515
|
+
|
|
516
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
517
|
+
try {
|
|
518
|
+
emitStdoutLine(child, sessionHeader());
|
|
519
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
520
|
+
await new Promise((r) => setImmediate(r));
|
|
521
|
+
// 保守策略:sessionFile 不可读时不 kill(宁可空等也不误杀有后代的进程)
|
|
522
|
+
expect(child.killed).toBe(false);
|
|
523
|
+
|
|
524
|
+
// watchdog re-arm 到动态超时(computeWatchdogMs(maxTurns)),未到期不 kill
|
|
525
|
+
await vi.advanceTimersByTimeAsync(expected - 1);
|
|
526
|
+
expect(child.killed).toBe(false);
|
|
527
|
+
|
|
528
|
+
// 动态超时到期:kill。若 error 分支漏了 re-arm(或误用固定超时),此断言失败
|
|
529
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
530
|
+
expect(child.killed).toBe(true);
|
|
531
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
532
|
+
|
|
533
|
+
child.stdout.end();
|
|
534
|
+
child.stderr.end();
|
|
535
|
+
child.emit("close", 143);
|
|
536
|
+
|
|
537
|
+
const result = await promise;
|
|
538
|
+
expect(result.success).toBe(true);
|
|
539
|
+
} finally {
|
|
540
|
+
vi.useRealTimers();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("agent_end(willRetry=false,无活跃后代)→ child.kill(SIGTERM) 被调用,close 后 success=true", async () => {
|
|
545
|
+
mockPending.mockReturnValue({ count: 0 });
|
|
370
546
|
const record = makeRecord();
|
|
371
547
|
const promise = runSpawn(record, "Task: done", makeOpts(), makeCtx());
|
|
372
548
|
|
|
@@ -389,7 +565,38 @@ describe("runSpawn", () => {
|
|
|
389
565
|
expect(result.success).toBe(true);
|
|
390
566
|
});
|
|
391
567
|
|
|
568
|
+
it("agent_end(willRetry=false,有活跃后代)→ 不 kill,进程保持 idle 等 steer 唤醒;后代完成后 kill", async () => {
|
|
569
|
+
// 第一次 agent_end:有活跃后代(count=1)→ 不 kill
|
|
570
|
+
mockPending.mockReturnValueOnce({ count: 1 });
|
|
571
|
+
// 第二次 agent_end:后代已 unregister(count=0)→ kill
|
|
572
|
+
mockPending.mockReturnValueOnce({ count: 0 });
|
|
573
|
+
const record = makeRecord();
|
|
574
|
+
const promise = runSpawn(record, "Task: orchestrate", makeOpts(), makeCtx());
|
|
575
|
+
|
|
576
|
+
await waitForSpawn();
|
|
577
|
+
const child = lastSpawnedChild();
|
|
578
|
+
|
|
579
|
+
emitStdoutLine(child, sessionHeader());
|
|
580
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
581
|
+
// 给事件 pump 一点时间处理 agent_end(判定读 session 文件)
|
|
582
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
583
|
+
expect(child.killed).toBe(false);
|
|
584
|
+
|
|
585
|
+
// 后代完成(unregister 已写入)后再次 agent_end → 无活跃后代 → kill
|
|
586
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
587
|
+
child.stdout.end();
|
|
588
|
+
child.stderr.end();
|
|
589
|
+
child.emit("close", 143);
|
|
590
|
+
|
|
591
|
+
const result = await promise;
|
|
592
|
+
|
|
593
|
+
expect(child.killed).toBe(true);
|
|
594
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
595
|
+
expect(result.success).toBe(true);
|
|
596
|
+
});
|
|
597
|
+
|
|
392
598
|
it("agent_end(willRetry=true)→ child.kill 不被调用(agent 会重试,等下一个 agent_end)", async () => {
|
|
599
|
+
mockPending.mockReturnValue({ count: 0 });
|
|
393
600
|
const record = makeRecord();
|
|
394
601
|
const promise = runSpawn(record, "Task: retry", makeOpts(), makeCtx());
|
|
395
602
|
|
|
@@ -418,6 +625,7 @@ describe("runSpawn", () => {
|
|
|
418
625
|
});
|
|
419
626
|
|
|
420
627
|
it("agent_end 后的后续 event 仍被 handleSdkEvent 处理(kill 不阻塞 event pump)", async () => {
|
|
628
|
+
mockPending.mockReturnValue({ count: 0 });
|
|
421
629
|
const record = makeRecord();
|
|
422
630
|
const promise = runSpawn(record, "Task: flush", makeOpts(), makeCtx());
|
|
423
631
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/execution/__tests__/session-pending.test.ts
|
|
2
|
+
//
|
|
3
|
+
// readActivePendingFromSessionFile:读 session 文件算活跃后代(pending 差集)。
|
|
4
|
+
// 覆盖:差集、快速路径过滤、文件不存在/坏行/未回填 sessionFile。
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { readActivePendingFromSessionFile } from "../session-pending.ts";
|
|
13
|
+
|
|
14
|
+
function makeTmpSessionFile(lines: string[]): string {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-pending-test-"));
|
|
16
|
+
const file = path.join(dir, "session.jsonl");
|
|
17
|
+
fs.writeFileSync(file, lines.join("\n") + "\n");
|
|
18
|
+
return file;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const mkRegister = (id: string, type: "subagent" | "workflow" = "subagent") =>
|
|
22
|
+
JSON.stringify({ type: "custom", customType: "pending:register", data: { id, type, name: id } });
|
|
23
|
+
|
|
24
|
+
const mkUnregister = (id: string, reason = "completed", ts = "2026-08-06T08:00:00.000Z") =>
|
|
25
|
+
JSON.stringify({ type: "custom", customType: "pending:unregister", data: { id, reason }, timestamp: ts });
|
|
26
|
+
|
|
27
|
+
const mkMessage = (role: string, text: string) =>
|
|
28
|
+
JSON.stringify({ type: "message", id: `m-${Date.now()}-${Math.random()}`, message: { role, content: [{ type: "text", text }] } });
|
|
29
|
+
|
|
30
|
+
describe("readActivePendingFromSessionFile", () => {
|
|
31
|
+
const tmpFiles: string[] = [];
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const f of tmpFiles.splice(0)) {
|
|
35
|
+
try {
|
|
36
|
+
fs.rmSync(path.dirname(f), { recursive: true, force: true });
|
|
37
|
+
} catch {
|
|
38
|
+
// 清理失败不影响断言
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("无 pending entries → count 0", () => {
|
|
44
|
+
const file = makeTmpSessionFile([mkMessage("user", "hi"), mkMessage("assistant", "hello")]);
|
|
45
|
+
tmpFiles.push(file);
|
|
46
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 0, recentUnregister: false });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("纯 register → count = 活跃后代数", () => {
|
|
50
|
+
const file = makeTmpSessionFile([mkRegister("bg-1"), mkRegister("bg-2", "workflow")]);
|
|
51
|
+
tmpFiles.push(file);
|
|
52
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 2, recentUnregister: false });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("register + unregister 同 id → 差集抵消", () => {
|
|
56
|
+
const file = makeTmpSessionFile([mkRegister("bg-1"), mkUnregister("bg-1")]);
|
|
57
|
+
tmpFiles.push(file);
|
|
58
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 0, recentUnregister: false });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("混合:部分注销 → 只统计仍活跃的(真实 e2e 场景:P 的 agent_end 时 explorer 未注销)", () => {
|
|
62
|
+
const file = makeTmpSessionFile([
|
|
63
|
+
mkRegister("bg-1"),
|
|
64
|
+
mkUnregister("bg-1", "completed"),
|
|
65
|
+
mkRegister("explorer-1"),
|
|
66
|
+
mkMessage("assistant", "waiting for explorer..."),
|
|
67
|
+
]);
|
|
68
|
+
tmpFiles.push(file);
|
|
69
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 1, recentUnregister: false });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("fork 继承的主 session register 已被 expired unregister 抵消 → 不干扰", () => {
|
|
73
|
+
// 模拟 P fork 主 session:继承 register(sessionId 不匹配),session_start 重建补 unregister(expired)
|
|
74
|
+
const file = makeTmpSessionFile([
|
|
75
|
+
mkRegister("parent-bg"),
|
|
76
|
+
mkUnregister("parent-bg", "expired"),
|
|
77
|
+
mkRegister("my-bg"),
|
|
78
|
+
]);
|
|
79
|
+
tmpFiles.push(file);
|
|
80
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 1, recentUnregister: false });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("坏行(截断 JSON)跳过,不影响其余判定", () => {
|
|
84
|
+
const file = makeTmpSessionFile([
|
|
85
|
+
'{"type":"custom","customType":"pending:register","data":{"id":"bg-1"',
|
|
86
|
+
mkRegister("bg-2"),
|
|
87
|
+
]);
|
|
88
|
+
tmpFiles.push(file);
|
|
89
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 1, recentUnregister: false });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("sessionFile 未回填(undefined)→ error(调用方保守不 kill)", () => {
|
|
93
|
+
const res = readActivePendingFromSessionFile(undefined);
|
|
94
|
+
expect(res.count).toBe(0);
|
|
95
|
+
expect(res.error).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("文件不存在 → error(调用方保守不 kill)", () => {
|
|
99
|
+
const res = readActivePendingFromSessionFile("/nonexistent/path/session.jsonl");
|
|
100
|
+
expect(res.count).toBe(0);
|
|
101
|
+
expect(res.error).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("最近 60s 内有 unregister → recentUnregister=true(后代刚完成,唤醒在路上——竞态窗口不 kill)", () => {
|
|
105
|
+
const file = makeTmpSessionFile([
|
|
106
|
+
mkRegister("bg-live"),
|
|
107
|
+
mkUnregister("bg-live", "completed", new Date().toISOString()),
|
|
108
|
+
]);
|
|
109
|
+
tmpFiles.push(file);
|
|
110
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 0, recentUnregister: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("unregister 在 60s 窗口外 → recentUnregister=false(正常 kill 路径)", () => {
|
|
114
|
+
const file = makeTmpSessionFile([
|
|
115
|
+
mkRegister("bg-live"),
|
|
116
|
+
mkUnregister("bg-live", "completed", "2020-01-01T00:00:00.000Z"),
|
|
117
|
+
]);
|
|
118
|
+
tmpFiles.push(file);
|
|
119
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 0, recentUnregister: false });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// [S-4] fast-path 按值匹配而非序列化格式。旧行 `line.includes('"customType":"pending:')`
|
|
123
|
+
// 耦合 pi 的 JSON 序列化空格习惯(冒号后无空格)。若 pi 改序列化(单行内冒号后加空格),
|
|
124
|
+
// 旧实现全过滤 → count=0 → keep-alive 静默失效 → recursive tree 被杀、steer 丢失。
|
|
125
|
+
// 本用例构造冒号后带空格的单行 JSON,验证值匹配仍命中 + 解析正确。
|
|
126
|
+
it("S-4: 序列化冒号后带空格 → 仍正确解析(防 fast-path 格式耦合导致 keep-alive 静默失效)", () => {
|
|
127
|
+
const file = makeTmpSessionFile([
|
|
128
|
+
`{ "type": "message", "message": { "role": "user", "content": [] } }`,
|
|
129
|
+
`{ "type": "custom", "customType": "pending:register", "data": { "id": "bg-1", "type": "subagent", "name": "bg-1" } }`,
|
|
130
|
+
`{ "type": "custom", "customType": "pending:unregister", "data": { "id": "bg-1", "reason": "completed" }, "timestamp": "2020-01-01T00:00:00.000Z" }`,
|
|
131
|
+
`{ "type": "custom", "customType": "pending:register", "data": { "id": "bg-2", "type": "subagent", "name": "bg-2" } }`,
|
|
132
|
+
]);
|
|
133
|
+
tmpFiles.push(file);
|
|
134
|
+
// bg-1 register+unregister 抵消,bg-2 仍活跃 → count=1。
|
|
135
|
+
// 旧 fast-path(`"customType":"pending:` 冒号无空格)会跳过所有行 → count=0(静默失效)。
|
|
136
|
+
expect(readActivePendingFromSessionFile(file)).toEqual({ count: 1, recentUnregister: false });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/execution/session-pending.ts
|
|
2
|
+
//
|
|
3
|
+
// agent_end 后代判定:读子进程的 session 文件,用 pending-notifications 的
|
|
4
|
+
// countActiveFromEntries(register − unregister 差集)判断该 subagent 是否还有
|
|
5
|
+
// 活跃后代(background subagent / workflow)。
|
|
6
|
+
//
|
|
7
|
+
// 背景(v4 递归编排):层主 planning-agent 派子 subagent 后结束 turn 等待被唤醒。
|
|
8
|
+
// 若 runSpawn 在 agent_end 无条件 kill,进程被回收、steer 唤醒送不到,递归树断。
|
|
9
|
+
// 判定依据:子进程的 session 文件里 pending:register entry(其进程内 appendEntry
|
|
10
|
+
// 同步写盘,见 pi SessionManager._persist)减去 pending:unregister 的差集。
|
|
11
|
+
// fork 继承的主 session register 残留由 pending-notifications 的 session_start
|
|
12
|
+
// 重建流程补 unregister(expired) 抵消,纯差集不受污染。
|
|
13
|
+
//
|
|
14
|
+
// 纯函数 + fs,独立于 runSpawn,可单测。
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
|
|
18
|
+
import { countActiveFromEntries } from "@zhushanwen/pi-pending-notifications";
|
|
19
|
+
|
|
20
|
+
/** 后代刚完成(unregister)后,notify 唤醒父 agent 可能仍在路上(triggerTurn steer
|
|
21
|
+
* 经进程内 EventBus 发送,与主进程处理 agent_end 行存在毫秒级竞态——explorer 3 秒完成
|
|
22
|
+
* 时实测 unregister 先于 agent_end 判定写入,导致差集 0 误判完成)。此窗口内的
|
|
23
|
+
* agent_end 不 kill,等父被唤醒后的下一次 agent_end 再判。 */
|
|
24
|
+
const RECENT_UNREGISTER_WINDOW_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** 判定结果:count > 0 = 有活跃后代(应保持进程等唤醒)。 */
|
|
27
|
+
export interface ActivePendingResult {
|
|
28
|
+
count: number;
|
|
29
|
+
/** 最近窗口内(60s)有 pending:unregister——后代刚完成,唤醒通知可能在路上。 */
|
|
30
|
+
recentUnregister: boolean;
|
|
31
|
+
/** 读取/解析失败的原因(undefined = 成功)。调用方对 error 采取保守策略(不 kill)。 */
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 读 session 文件计算活跃 pending 数。
|
|
37
|
+
*
|
|
38
|
+
* 快速路径:行内含 pending 值(`"pending:register"` / `"pending:unregister"`)才解析,
|
|
39
|
+
* 大文件(fork 继承主 session)只付 includes 扫描跳过大量 message 行的 JSON.parse。
|
|
40
|
+
* [S-4] 按值匹配而非 `"customType":"pending:` 序列化格式——后者耦合 pi 的 JSON 序列化
|
|
41
|
+
* 空格习惯(冒号后无空格),pi 改 pretty-print 会导致全过滤 → count=0 → keep-alive
|
|
42
|
+
* 静默失效 → recursive tree 被杀、steer 丢失。值字符串本身不受序列化空格影响。
|
|
43
|
+
*
|
|
44
|
+
* 文件不存在(sessionFile 未回填/首次 assistant 前)→ error(调用方保守不 kill)。
|
|
45
|
+
* 坏行跳过(append 中途崩溃的截断行)。
|
|
46
|
+
*/
|
|
47
|
+
export function readActivePendingFromSessionFile(
|
|
48
|
+
sessionFile: string | undefined,
|
|
49
|
+
): ActivePendingResult {
|
|
50
|
+
if (!sessionFile) {
|
|
51
|
+
return { count: 0, recentUnregister: false, error: "no sessionFile (handshake not settled)" };
|
|
52
|
+
}
|
|
53
|
+
let raw: string;
|
|
54
|
+
try {
|
|
55
|
+
raw = fs.readFileSync(sessionFile, "utf-8");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return {
|
|
58
|
+
count: 0,
|
|
59
|
+
recentUnregister: false,
|
|
60
|
+
error: `session file unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entries: unknown[] = [];
|
|
65
|
+
let latestUnregisterMs = 0;
|
|
66
|
+
for (const line of raw.split("\n")) {
|
|
67
|
+
// [S-4] 按值匹配:countActiveFromEntries 只消费 register/unregister 两种 customType,
|
|
68
|
+
// 故只需检测这两个值字符串;冒号前后空格变化不影响(值始终是连续子串)。
|
|
69
|
+
if (!line.includes('"pending:register"') && !line.includes('"pending:unregister"')) continue;
|
|
70
|
+
try {
|
|
71
|
+
const entry = JSON.parse(line) as { customType?: string; timestamp?: string };
|
|
72
|
+
entries.push(entry);
|
|
73
|
+
if (entry.customType === "pending:unregister" && entry.timestamp) {
|
|
74
|
+
const ts = Date.parse(entry.timestamp);
|
|
75
|
+
if (Number.isFinite(ts) && ts > latestUnregisterMs) latestUnregisterMs = ts;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// 截断行/坏行跳过——不影响其余 entry 的差集判定(罕见:append 中途崩溃)
|
|
79
|
+
console.debug("[session-pending] skipped malformed pending line in", sessionFile);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const active = countActiveFromEntries(entries);
|
|
84
|
+
return {
|
|
85
|
+
count: active.count,
|
|
86
|
+
recentUnregister:
|
|
87
|
+
latestUnregisterMs > 0 && Date.now() - latestUnregisterMs < RECENT_UNREGISTER_WINDOW_MS,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -10,6 +10,7 @@ import { type ChildProcess,execFileSync, spawn } from "node:child_process";
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
|
|
12
12
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
13
|
+
import { readActivePendingFromSessionFile } from "./session-pending.ts";
|
|
13
14
|
|
|
14
15
|
import type { ExtensionMode } from "./host-mode.ts";
|
|
15
16
|
|
|
@@ -92,6 +93,22 @@ const SPAWN_WATCHDOG_FLOOR_MS = 30 * 60 * 1000;
|
|
|
92
93
|
* 留 1-2 分钟余量。下限与按 turn 计算取 max,避免 maxTurns 过小时 watchdog 紧到误杀。 */
|
|
93
94
|
const WATCHDOG_MS_PER_TURN = 5 * 60 * 1000;
|
|
94
95
|
|
|
96
|
+
// [recursive-orchestration] agent_end keep-alive 的两类等待超时(不 kill 分支的兑底)。
|
|
97
|
+
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
98
|
+
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
99
|
+
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
100
|
+
// 两类超时分挂不同分支(见 agent_end handler):
|
|
101
|
+
// - 有活跃后代(count>0 / error)→ computeWatchdogMs(maxTurns) 动态超时(MF-4,不误杀慢后代)
|
|
102
|
+
// - 仅 recentUnregister 竞态 → WAKEUP_GRACE_MS 秒级宽限(MF-3,不空等 2h)
|
|
103
|
+
|
|
104
|
+
/** [MF-3] agent_end keep-alive 的 recentUnregister 竞态宽限(ms)。
|
|
105
|
+
* notify steer 唤醒的竞态宽限——15s 内无新 agent_end(未被唤醒)即 kill;
|
|
106
|
+
* 被唤醒后下一次 agent_end 重新评估。不用长超时:层主 closeout 的最终 agent_end
|
|
107
|
+
* 必然命中此分支(距最后一次 unregister <60s),挂长超时 = 空等 2h 才回收
|
|
108
|
+
* + 冒牌完成通知级联。
|
|
109
|
+
* [export] 测试可观测(run-spawn-edges MF-3 用例用 fake timers 断言 15s 后 kill)。 */
|
|
110
|
+
export const WAKEUP_GRACE_MS = 15_000;
|
|
111
|
+
|
|
95
112
|
/**
|
|
96
113
|
* [M-1] 基于 maxTurns 动态计算 watchdog 超时。
|
|
97
114
|
*
|
|
@@ -103,9 +120,13 @@ const WATCHDOG_MS_PER_TURN = 5 * 60 * 1000;
|
|
|
103
120
|
* - maxTurns=20 → 100 分钟
|
|
104
121
|
* - maxTurns=100 → 500 分钟(8 小时+,覆盖全量重构)
|
|
105
122
|
*
|
|
123
|
+
* [MF-4] 同时是 agent_end keep-alive 的「有活跃后代」等待超时(不 kill 分支),
|
|
124
|
+
* 替代旧固定 2h(WAIT_DESCENDANT_TIMEOUT_MS,已删除)——wave 开发 >2h 不被误杀。
|
|
125
|
+
* [export] 测试可观测(run-spawn-edges MF-4 用例断言 keep-alive 等待超时 = 动态值)。
|
|
126
|
+
*
|
|
106
127
|
* @param maxTurns 调用方指定的 turn 上限;undefined/null/0 视为默认 10 turns
|
|
107
128
|
*/
|
|
108
|
-
function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
129
|
+
export function computeWatchdogMs(maxTurns: number | undefined | null): number {
|
|
109
130
|
const effectiveTurns = maxTurns && maxTurns > 0 ? maxTurns : 10;
|
|
110
131
|
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, effectiveTurns * WATCHDOG_MS_PER_TURN);
|
|
111
132
|
}
|
|
@@ -710,9 +731,14 @@ export async function runSpawn(
|
|
|
710
731
|
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
711
732
|
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
712
733
|
const watchdogMs = computeWatchdogMs(opts.maxTurns);
|
|
713
|
-
|
|
734
|
+
let watchdog = setTimeout(() => child.kill("SIGTERM"), watchdogMs);
|
|
714
735
|
watchdog.unref();
|
|
715
736
|
|
|
737
|
+
// [recursive-orchestration] agent_end 有活跃后代时的等待超时(不 kill 分支的兑底)。
|
|
738
|
+
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
739
|
+
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
740
|
+
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
741
|
+
|
|
716
742
|
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest
|
|
717
743
|
const enqueueUiRequest = createUiRequestQueue(child, ctx);
|
|
718
744
|
// FR-4: get_state RPC response 监听器(id → resolver)。
|
|
@@ -796,7 +822,47 @@ export async function runSpawn(
|
|
|
796
822
|
//(runRpcMode 末尾 return new Promise(() => {}) 长驻等命令),需主动 kill
|
|
797
823
|
// 触发 close → runSpawn resolve。willRetry=true 时 agent 会重试,不能 kill。
|
|
798
824
|
if (isAgentEndEvt(evt)) {
|
|
799
|
-
if (
|
|
825
|
+
if (evt.willRetry) {
|
|
826
|
+
// agent 会重试,不能 kill。
|
|
827
|
+
} else {
|
|
828
|
+
// [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
|
|
829
|
+
// (pending:register − unregister 差集)。有活跃后代(background subagent /
|
|
830
|
+
// workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
|
|
831
|
+
// 无 → 正常完成,kill 触发 close → runSpawn resolve。
|
|
832
|
+
const pending = readActivePendingFromSessionFile(record.sessionFile);
|
|
833
|
+
if (pending.count > 0 || pending.error) {
|
|
834
|
+
if (pending.error) {
|
|
835
|
+
logger.warn(
|
|
836
|
+
`[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
|
|
837
|
+
);
|
|
838
|
+
} else {
|
|
839
|
+
logger.debug(
|
|
840
|
+
`[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
// 空闲等待期间不消耗 turn:清原 watchdog,换等待后代超时(每次 agent_end 重新计时)。
|
|
844
|
+
// [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
845
|
+
// 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
846
|
+
// killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
|
|
847
|
+
clearTimeout(watchdog);
|
|
848
|
+
watchdog = setTimeout(() => child.kill("SIGTERM"), computeWatchdogMs(opts.maxTurns));
|
|
849
|
+
watchdog.unref();
|
|
850
|
+
} else if (pending.recentUnregister) {
|
|
851
|
+
// 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
|
|
852
|
+
// 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
853
|
+
// [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
|
|
854
|
+
// 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
|
|
855
|
+
// 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
|
|
856
|
+
logger.debug(
|
|
857
|
+
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
858
|
+
);
|
|
859
|
+
clearTimeout(watchdog);
|
|
860
|
+
watchdog = setTimeout(() => child.kill("SIGTERM"), WAKEUP_GRACE_MS);
|
|
861
|
+
watchdog.unref();
|
|
862
|
+
} else {
|
|
863
|
+
child.kill("SIGTERM");
|
|
864
|
+
}
|
|
865
|
+
}
|
|
800
866
|
}
|
|
801
867
|
if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
|
|
802
868
|
} else if (parsed.kind === "response") {
|
|
@@ -313,7 +313,7 @@ function buildReviewCall(def, round, max, batchIndex, roundDir, scoped) {
|
|
|
313
313
|
model: MODEL || def.model,
|
|
314
314
|
schema: reviewerSchema,
|
|
315
315
|
description: def.name,
|
|
316
|
-
timeoutMs:
|
|
316
|
+
timeoutMs: 3_600_000, // 1h(只读审查 + retry 退避余量)
|
|
317
317
|
// returnMeta: true — 与 recursive-split 脚本的 executeActionAgent 对齐:失败时 resolve
|
|
318
318
|
// {value, error},raw.error 可检测(review- 前缀兜底/结构化终止可达);成功时
|
|
319
319
|
// value = parsedOutput ?? content,parseResult 作用于 raw.value(MF-1)。
|
|
@@ -558,7 +558,7 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
|
|
|
558
558
|
model: MODEL,
|
|
559
559
|
schema: aggregatorSchema,
|
|
560
560
|
description: "aggregate",
|
|
561
|
-
timeoutMs:
|
|
561
|
+
timeoutMs: 3_600_000, // 1h
|
|
562
562
|
returnMeta: true,
|
|
563
563
|
});
|
|
564
564
|
|
|
@@ -788,9 +788,8 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
|
|
|
788
788
|
// frontmatter model 字段同样生效(之前丢弃了 FIX_DEF.model,只在 review 阶段消费)
|
|
789
789
|
model: MODEL || (FIX_DEF && FIX_DEF.model),
|
|
790
790
|
description: (FIX_DEF && FIX_DEF.name) || "fix",
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
timeoutMs: 1_800_000,
|
|
791
|
+
// fix 不设 timeoutMs = 不限时(execute-options-mapper: undefined/<=0 → 不设超时)。
|
|
792
|
+
// 带写操作(改项目代码)可能很久(大重构/多文件),不应被墙钟超时打断。
|
|
794
793
|
returnMeta: true,
|
|
795
794
|
...(FIX_DEF && !FIX_DEF.isCustom ? { agent: FIX_DEF.name } : {}),
|
|
796
795
|
});
|