@zhushanwen/subagent-core 0.2.0 → 0.4.0
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/README.md +45 -2
- package/agents/analyst.md +60 -0
- package/agents/coder.md +69 -0
- package/agents/debugger.md +66 -0
- package/agents/doc-reviewer.md +49 -0
- package/agents/explorer.md +63 -0
- package/agents/general-purpose.md +32 -0
- package/agents/orchestrator.md +61 -0
- package/agents/planner.md +53 -0
- package/agents/researcher.md +67 -0
- package/agents/reviewer.md +73 -0
- package/dist/chunk-4KN4TTG7.js +240 -0
- package/dist/chunk-APZY4IME.js +27 -0
- package/dist/chunk-X7SZ5HLQ.js +66 -0
- package/dist/execution/engine/engines/zcode/constants.cjs +65 -0
- package/dist/execution/engine/engines/zcode/constants.d.cts +93 -1
- package/dist/execution/engine/engines/zcode/constants.d.ts +93 -1
- package/dist/execution/engine/engines/zcode/constants.js +43 -1
- package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
- package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
- package/dist/execution/engine/engines/zcode/reader.js +4 -234
- package/dist/execution/engine/paths.js +7 -19
- package/dist/index.cjs +14709 -100
- package/dist/index.d.cts +4053 -200
- package/dist/index.d.ts +4053 -200
- package/dist/index.js +14358 -102
- package/dist/{types-BxyAidGf.d.cts → types-DpUO16pj.d.cts} +464 -1
- package/dist/{types-BxyAidGf.d.ts → types-DpUO16pj.d.ts} +464 -1
- package/package.json +6 -3
- package/src/__tests__/review-fix-loop-script.test.ts +90 -2
- package/src/__tests__/review-fix-loop-utils.test.ts +21 -0
- package/src/__tests__/smoke.test.ts +9 -2
- package/src/execution/__tests__/agent-profile.test.ts +232 -0
- package/src/execution/__tests__/agents-assembly.test.ts +219 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +71 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +1 -0
- package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
- package/src/execution/__tests__/delivery-methods.test.ts +2 -0
- package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
- package/src/execution/__tests__/dialog-queue.test.ts +199 -1
- package/src/execution/__tests__/epipe-fallback.test.ts +2 -0
- package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
- package/src/execution/__tests__/finalize-record.test.ts +106 -0
- package/src/execution/__tests__/gc-timer.test.ts +2 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +2 -0
- package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
- package/src/execution/__tests__/keep-alive-no-progress.test.ts +378 -0
- package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
- package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
- package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
- package/src/execution/__tests__/notify-ledger.test.ts +294 -0
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
- package/src/execution/__tests__/record-store.test.ts +83 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +4 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +1 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +407 -17
- package/src/execution/__tests__/run-spawn-integration.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +12 -4
- package/src/execution/__tests__/service-kill-escalation.test.ts +89 -0
- package/src/execution/__tests__/session-pending.test.ts +228 -0
- package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
- package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
- package/src/execution/__tests__/session-runner-epipe.test.ts +1 -0
- package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +226 -0
- package/src/execution/__tests__/settled-watchdog.test.ts +289 -0
- package/src/execution/__tests__/spawned-children.test.ts +153 -10
- package/src/execution/__tests__/subagent-actions-core.test.ts +997 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +71 -10
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +266 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +3 -0
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +316 -0
- package/src/execution/__tests__/timeout-integration.test.ts +7 -7
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +95 -1
- package/src/execution/__tests__/ui-request-queue.test.ts +25 -0
- package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
- package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
- package/src/execution/agent-registry.ts +225 -1
- package/src/execution/agents-assembly.ts +88 -0
- package/src/execution/concurrency-pool.ts +71 -9
- package/src/execution/dialog-queue.ts +101 -3
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +49 -2
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +87 -1
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +26 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +83 -1
- package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +131 -0
- package/src/execution/engine/__tests__/registry.test.ts +90 -1
- package/src/execution/engine/engine-discovery.ts +12 -16
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +3 -3
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +274 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
- package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +472 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +387 -0
- package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +780 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +815 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +462 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +119 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +136 -6
- package/src/execution/engine/engines/zcode/appserver-home.ts +442 -0
- package/src/execution/engine/engines/zcode/appserver-probe.ts +141 -0
- package/src/execution/engine/engines/zcode/connection.ts +585 -0
- package/src/execution/engine/engines/zcode/constants.ts +129 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +54 -8
- package/src/execution/engine/engines/zcode/preparer.ts +25 -22
- package/src/execution/engine/engines/zcode/session-channel.ts +656 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +909 -41
- package/src/execution/engine/host-task-spec.ts +3 -3
- package/src/execution/engine/port.ts +41 -1
- package/src/execution/engine/registry.ts +53 -0
- package/src/execution/finalize-record.ts +63 -5
- package/src/execution/get-state-handshake.ts +85 -10
- package/src/execution/lifecycle-manager.ts +27 -3
- package/src/execution/manifest-store.ts +53 -62
- package/src/execution/notifier.ts +10 -10
- package/src/execution/notify-ledger.ts +117 -16
- package/src/execution/record-entry.ts +8 -2
- package/src/execution/record-store.ts +29 -15
- package/src/execution/session-pending.ts +213 -62
- package/src/execution/session-runner.ts +974 -128
- package/src/execution/sessions-index.ts +10 -55
- package/src/execution/settled-watchdog.ts +99 -0
- package/src/execution/subagent-actions-core.ts +686 -0
- package/src/execution/subagent-service.ts +396 -49
- package/src/execution/ui-request-handler-factory.ts +27 -4
- package/src/execution/worktree-git-ops.ts +397 -0
- package/src/execution/worktree-manager.ts +72 -10
- package/src/execution/worktree-registry.ts +6 -12
- package/src/index.ts +413 -6
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -4
- package/src/orchestration/__tests__/args-meta.test.ts +358 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +1 -8
- package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +382 -0
- package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store.test.ts +389 -0
- package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
- package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +77 -0
- package/src/orchestration/__tests__/run-snapshot.test.ts +353 -0
- package/src/orchestration/__tests__/script-generate.test.ts +314 -0
- package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
- package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
- package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
- package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
- package/src/orchestration/agent-opts-resolver.ts +7 -7
- package/src/orchestration/args-meta.ts +198 -0
- package/src/orchestration/error-recovery.ts +416 -146
- package/src/orchestration/execute-agent-call.ts +9 -9
- package/src/orchestration/file-run-store.ts +327 -0
- package/src/orchestration/launcher.ts +35 -26
- package/src/orchestration/lifecycle.ts +355 -79
- package/src/orchestration/models/agent-call.ts +7 -7
- package/src/orchestration/models/budget.ts +5 -5
- package/src/orchestration/models/run-runtime.ts +13 -13
- package/src/orchestration/models/trace.ts +8 -8
- package/src/orchestration/models/workflow-run.ts +27 -27
- package/src/orchestration/models/workflow-script.ts +4 -4
- package/src/orchestration/run-snapshot.ts +266 -0
- package/src/orchestration/script-generate.ts +154 -0
- package/src/orchestration/script-lint.ts +33 -33
- package/src/orchestration/worker-handle.ts +10 -10
- package/src/orchestration/worker-host.ts +10 -10
- package/src/orchestration/worker-script-builder.ts +401 -346
- package/src/orchestration/workflow-files.ts +37 -11
- package/src/orchestration/workflow-run-summary.ts +69 -0
- package/src/orchestration/workflow-script-registry-impl.ts +31 -9
- package/src/shared/__tests__/agent-ref.test.ts +209 -2
- package/src/shared/__tests__/atomic-write.test.ts +267 -0
- package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
- package/src/shared/__tests__/injection-render.test.ts +518 -0
- package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
- package/src/shared/__tests__/resource-discovery.test.ts +3 -2
- package/src/shared/agent-ref.ts +143 -2
- package/src/shared/atomic-write.ts +320 -0
- package/src/shared/bounded-serialize.ts +154 -0
- package/src/shared/injection-render.ts +279 -0
- package/src/shared/meta-parser.ts +41 -1
- package/src/shared/resource-discovery.ts +104 -37
- package/src/shared/resource-meta.ts +14 -0
- package/src/shared/xml-injection.ts +9 -9
- package/workflows/README.md +9 -9
- package/workflows/chain.js +4 -2
- package/workflows/map-reduce.js +5 -3
- package/workflows/parallel.js +5 -3
- package/workflows/review-fix-loop-utils.cjs +14 -3
- package/workflows/review-fix-loop.js +18 -7
- package/workflows/scatter-gather.js +4 -2
- package/dist/chunk-3VOERJPJ.js +0 -22
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// src/execution/__tests__/create-concurrency-pool.test.ts
|
|
2
|
+
//
|
|
3
|
+
// U4(subagent-core-sink-design D7):并发池工厂 createConcurrencyPool({maxConcurrent, queuePolicy})。
|
|
4
|
+
// 排队策略差异是宿主声明的有意决策(pi=priority / zsw=strict-fifo),保留为参数而非消灭。
|
|
5
|
+
// 锚定:
|
|
6
|
+
// ①缺省策略与既有 DefaultConcurrencyPool 行为逐点等值(pi 零回归);
|
|
7
|
+
// ②priority / strict-fifo 两策略排队顺序语义互斥可辨。
|
|
8
|
+
//
|
|
9
|
+
// pool 的排队/放行是纯 Promise resolve(microtask),无 timer 参与——microtask flush
|
|
10
|
+
// 推进,不引入真实 setTimeout(TEST-STRATEGY 禁真实等待),无需 fake timers。
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { createConcurrencyPool, DefaultConcurrencyPool } from "../concurrency-pool.ts";
|
|
14
|
+
|
|
15
|
+
/** microtask flush:让排队条目的 resolve/.then 链跑完(pool 无 timer 语义)。 */
|
|
16
|
+
async function flushMicrotasks(): Promise<void> {
|
|
17
|
+
for (let i = 0; i < 10; i++) {
|
|
18
|
+
await Promise.resolve();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("createConcurrencyPool 工厂(U4/D7)", () => {
|
|
23
|
+
it("缺省 queuePolicy 与既有 DefaultConcurrencyPool 行为逐点等值(active/排队/release 对照)", async () => {
|
|
24
|
+
const factory = createConcurrencyPool({ maxConcurrent: 2 });
|
|
25
|
+
const classic = new DefaultConcurrencyPool(2);
|
|
26
|
+
|
|
27
|
+
// 槽位放行与 active 计数一致
|
|
28
|
+
await factory.acquire(0);
|
|
29
|
+
await factory.acquire(0);
|
|
30
|
+
await classic.acquire(0);
|
|
31
|
+
await classic.acquire(0);
|
|
32
|
+
expect(factory.active).toBe(classic.active);
|
|
33
|
+
expect(factory.active).toBe(2);
|
|
34
|
+
|
|
35
|
+
// 超额 acquire 同样排队(不放行)
|
|
36
|
+
let factoryQueued = false;
|
|
37
|
+
let classicQueued = false;
|
|
38
|
+
const fq = factory.acquire(0).then(() => {
|
|
39
|
+
factoryQueued = true;
|
|
40
|
+
});
|
|
41
|
+
const cq = classic.acquire(0).then(() => {
|
|
42
|
+
classicQueued = true;
|
|
43
|
+
});
|
|
44
|
+
await flushMicrotasks();
|
|
45
|
+
expect(factoryQueued).toBe(false);
|
|
46
|
+
expect(classicQueued).toBe(false);
|
|
47
|
+
|
|
48
|
+
// release 后排队条目同样获得槽位,active 语义一致
|
|
49
|
+
factory.release();
|
|
50
|
+
classic.release();
|
|
51
|
+
await flushMicrotasks();
|
|
52
|
+
expect(factoryQueued).toBe(true);
|
|
53
|
+
expect(classicQueued).toBe(true);
|
|
54
|
+
expect(factory.active).toBe(classic.active);
|
|
55
|
+
|
|
56
|
+
// 清理后归零一致
|
|
57
|
+
factory.release();
|
|
58
|
+
classic.release();
|
|
59
|
+
factory.release();
|
|
60
|
+
classic.release();
|
|
61
|
+
await flushMicrotasks();
|
|
62
|
+
expect(factory.active).toBe(0);
|
|
63
|
+
expect(classic.active).toBe(0);
|
|
64
|
+
await Promise.allSettled([fq, cq]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("queuePolicy 缺省值等值:不传 queuePolicy 与显式 'priority' 产出同序放行", async () => {
|
|
68
|
+
const implicit = createConcurrencyPool({ maxConcurrent: 1 });
|
|
69
|
+
const explicit = createConcurrencyPool({ maxConcurrent: 1, queuePolicy: "priority" });
|
|
70
|
+
|
|
71
|
+
const implicitOrder: string[] = [];
|
|
72
|
+
const explicitOrder: string[] = [];
|
|
73
|
+
|
|
74
|
+
// 同一序列:低优先级先入队、高优先级后入队 → priority 策略下高优先级插队
|
|
75
|
+
for (const [pool, order] of [
|
|
76
|
+
[implicit, implicitOrder],
|
|
77
|
+
[explicit, explicitOrder],
|
|
78
|
+
] as const) {
|
|
79
|
+
await pool.acquire(0);
|
|
80
|
+
const low = pool.acquire(1000).then(() => {
|
|
81
|
+
order.push("low");
|
|
82
|
+
pool.release();
|
|
83
|
+
});
|
|
84
|
+
const high = pool.acquire(0).then(() => {
|
|
85
|
+
order.push("high");
|
|
86
|
+
pool.release();
|
|
87
|
+
});
|
|
88
|
+
pool.release();
|
|
89
|
+
await Promise.all([low, high]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
expect(implicitOrder).toEqual(["high", "low"]);
|
|
93
|
+
expect(explicitOrder).toEqual(implicitOrder);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("queuePolicy:'priority' 高优先级先获得释放的槽位(pi 既有行为)", async () => {
|
|
97
|
+
const pool = createConcurrencyPool({ maxConcurrent: 1, queuePolicy: "priority" });
|
|
98
|
+
await pool.acquire(1000); // 低优先级占满
|
|
99
|
+
|
|
100
|
+
let lowAcquired = false;
|
|
101
|
+
let highAcquired = false;
|
|
102
|
+
const low = pool.acquire(1000).then(() => {
|
|
103
|
+
lowAcquired = true;
|
|
104
|
+
});
|
|
105
|
+
const high = pool.acquire(0).then(() => {
|
|
106
|
+
highAcquired = true;
|
|
107
|
+
});
|
|
108
|
+
await flushMicrotasks();
|
|
109
|
+
expect(lowAcquired).toBe(false);
|
|
110
|
+
expect(highAcquired).toBe(false);
|
|
111
|
+
|
|
112
|
+
pool.release();
|
|
113
|
+
await flushMicrotasks();
|
|
114
|
+
expect(highAcquired).toBe(true);
|
|
115
|
+
expect(lowAcquired).toBe(false);
|
|
116
|
+
pool.release();
|
|
117
|
+
await Promise.allSettled([low, high]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("queuePolicy:'strict-fifo' 忽略 priority,纯入队顺序放行", async () => {
|
|
121
|
+
const pool = createConcurrencyPool({ maxConcurrent: 1, queuePolicy: "strict-fifo" });
|
|
122
|
+
await pool.acquire(0); // 占满
|
|
123
|
+
|
|
124
|
+
const order: number[] = [];
|
|
125
|
+
// 入队顺序 priority: 10 → 0 → 5。priority 策略会让 0 最先;strict-fifo 必须按 10 → 0 → 5
|
|
126
|
+
const a = pool.acquire(10).then(() => {
|
|
127
|
+
order.push(10);
|
|
128
|
+
pool.release();
|
|
129
|
+
});
|
|
130
|
+
const b = pool.acquire(0).then(() => {
|
|
131
|
+
order.push(0);
|
|
132
|
+
pool.release();
|
|
133
|
+
});
|
|
134
|
+
const c = pool.acquire(5).then(() => {
|
|
135
|
+
order.push(5);
|
|
136
|
+
pool.release();
|
|
137
|
+
});
|
|
138
|
+
pool.release(); // 放行队首(seq 最小 = a)
|
|
139
|
+
|
|
140
|
+
await Promise.all([a, b, c]);
|
|
141
|
+
expect(order).toEqual([10, 0, 5]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("queuePolicy:'strict-fifo' 高优先级不插队(与 priority 策略语义互斥可辨)", async () => {
|
|
145
|
+
const pool = createConcurrencyPool({ maxConcurrent: 1, queuePolicy: "strict-fifo" });
|
|
146
|
+
await pool.acquire(0); // 占满
|
|
147
|
+
|
|
148
|
+
let lowFirstAcquired = false;
|
|
149
|
+
let highSecondAcquired = false;
|
|
150
|
+
// 低优先级先入队,高优先级后入队——priority 策略会放行 high;strict-fifo 必须放行 low
|
|
151
|
+
const low = pool.acquire(1000).then(() => {
|
|
152
|
+
lowFirstAcquired = true;
|
|
153
|
+
});
|
|
154
|
+
const high = pool.acquire(0).then(() => {
|
|
155
|
+
highSecondAcquired = true;
|
|
156
|
+
});
|
|
157
|
+
await flushMicrotasks();
|
|
158
|
+
expect(lowFirstAcquired).toBe(false);
|
|
159
|
+
expect(highSecondAcquired).toBe(false);
|
|
160
|
+
|
|
161
|
+
pool.release();
|
|
162
|
+
await flushMicrotasks();
|
|
163
|
+
expect(lowFirstAcquired).toBe(true);
|
|
164
|
+
expect(highSecondAcquired).toBe(false);
|
|
165
|
+
|
|
166
|
+
pool.release();
|
|
167
|
+
await flushMicrotasks();
|
|
168
|
+
expect(highSecondAcquired).toBe(true);
|
|
169
|
+
await Promise.allSettled([low, high]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("策略不影响并发语义:strict-fifo 的 clamp/active 与 priority 一致", async () => {
|
|
173
|
+
const pool = createConcurrencyPool({ maxConcurrent: 0, queuePolicy: "strict-fifo" });
|
|
174
|
+
// clamp 到 1(C3 修复语义策略无关)
|
|
175
|
+
expect(pool.maxConcurrent).toBe(1);
|
|
176
|
+
await pool.acquire(0);
|
|
177
|
+
expect(pool.active).toBe(1);
|
|
178
|
+
pool.release();
|
|
179
|
+
expect(pool.active).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("strict-fifo 下排队条目 abort 仍 reject(AbortError,H2 语义策略无关)", async () => {
|
|
183
|
+
const pool = createConcurrencyPool({ maxConcurrent: 1, queuePolicy: "strict-fifo" });
|
|
184
|
+
await pool.acquire(0); // 占满
|
|
185
|
+
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const queued = pool.acquire(0, undefined, controller.signal);
|
|
188
|
+
await flushMicrotasks();
|
|
189
|
+
expect(pool.active).toBe(1); // 仍在排队
|
|
190
|
+
|
|
191
|
+
controller.abort();
|
|
192
|
+
await expect(queued).rejects.toMatchObject({ name: "AbortError" });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -34,6 +34,8 @@ vi.mock("../session-runner.ts", () => {
|
|
|
34
34
|
return {
|
|
35
35
|
runSpawn: vi.fn(),
|
|
36
36
|
killAllSpawnedChildren: vi.fn(),
|
|
37
|
+
registerSpawnedChildForRecord: vi.fn(),
|
|
38
|
+
killRecordChildWithEscalation: vi.fn(),
|
|
37
39
|
spawnedChildren,
|
|
38
40
|
getChildByRecord: (id: string): unknown => spawnedChildren.get(id),
|
|
39
41
|
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// src/execution/__tests__/descendant-sweep.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [T2-② / P-T2b 主路径] 后代级联补杀 sweep + session-pending 差集清单导出。
|
|
4
|
+
//
|
|
5
|
+
// 设计:docs/design/subagent-core-unbounded-wait-audit.md §7.2 T2-②;P-T2b 裁决
|
|
6
|
+
//(probe/p-t2b-report.md):pi SIGTERM 不级联孤儿后台后代(NO-CASCADE 三次稳定复现),
|
|
7
|
+
// 补杀为主路径。覆盖:
|
|
8
|
+
// - listActivePendingFromSessionFile:register−unregister 差集、sessionId 提取、
|
|
9
|
+
// 与 count 口径共享增量游标(交错调用不错位)。
|
|
10
|
+
// - sweepDescendantsOfSession:迭代展开至叶、pid 存活校验、cmdline(pi/--mode rpc)
|
|
11
|
+
// 防误杀校验、反查失败 skip(T5 marker fallback TODO 锚点)。
|
|
12
|
+
// - looksLikePiRpcProcess:命中 / 拒绝形态矩阵。
|
|
13
|
+
// - killPidWithEscalation:SIGTERM 后 30s 仍活升级 SIGKILL(fake timers)。
|
|
14
|
+
//
|
|
15
|
+
// 真实 fs(临时目录)+ mock process.kill / spawnSync——session-pending 增量游标与
|
|
16
|
+
// findSessionFileByHeaderId 的文件形态语义被真实执行。
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
24
|
+
|
|
25
|
+
// 只 mock spawnSync(ps 探测受控);spawn 等其余实现保留 actual(本文件不经 runSpawn)。
|
|
26
|
+
vi.mock("node:child_process", async () => {
|
|
27
|
+
const actual = await import("node:child_process");
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
spawnSync: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
looksLikePiRpcProcess,
|
|
36
|
+
sweepDescendantsOfSession,
|
|
37
|
+
} from "../session-runner.ts";
|
|
38
|
+
import {
|
|
39
|
+
clearPendingCursors,
|
|
40
|
+
listActivePendingFromSessionFile,
|
|
41
|
+
readActivePendingFromSessionFile,
|
|
42
|
+
} from "../session-pending.ts";
|
|
43
|
+
import { writeAliveMarker } from "../alive-store.ts";
|
|
44
|
+
|
|
45
|
+
const mockSpawnSync = vi.mocked(spawnSync);
|
|
46
|
+
|
|
47
|
+
const ALIVE_PID_CHILD = 424_242;
|
|
48
|
+
const ALIVE_PID_GRAND = 424_243;
|
|
49
|
+
const DEAD_PID = 999_999;
|
|
50
|
+
|
|
51
|
+
/** process.kill 受控:signal=0 探活(死 pid 抛 ESRCH),其余记录到 kills。 */
|
|
52
|
+
const kills: Array<{ pid: number; signal: string | number }> = [];
|
|
53
|
+
|
|
54
|
+
function entryLine(customType: string, data: Record<string, unknown>): string {
|
|
55
|
+
return `${JSON.stringify({
|
|
56
|
+
type: "custom",
|
|
57
|
+
customType,
|
|
58
|
+
data,
|
|
59
|
+
timestamp: "2026-09-01T00:00:00.000Z",
|
|
60
|
+
id: `e-${Math.random().toString(36).slice(2)}`,
|
|
61
|
+
})}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function registerData(id: string, sessionId: string): Record<string, unknown> {
|
|
65
|
+
return { id, type: "session", name: `desc-${id}`, status: "active", registeredAt: 1, expiresAt: undefined, sessionId };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let sessionDir = "";
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
clearPendingCursors();
|
|
72
|
+
kills.length = 0;
|
|
73
|
+
mockSpawnSync.mockReset();
|
|
74
|
+
sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "desc-sweep-"));
|
|
75
|
+
vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: string | number) => {
|
|
76
|
+
if ((signal ?? 0) === 0) {
|
|
77
|
+
if (pid === DEAD_PID) {
|
|
78
|
+
const err = new Error("No such process") as NodeJS.ErrnoException;
|
|
79
|
+
err.code = "ESRCH";
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
kills.push({ pid, signal: signal as string | number });
|
|
85
|
+
return true;
|
|
86
|
+
}) as typeof process.kill);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
clearPendingCursors();
|
|
92
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** 标准三层树:root(层主)→ child-sess → grand-sess(叶)。 */
|
|
96
|
+
function writeThreeLevelTree(): { rootFile: string; childFile: string; grandFile: string } {
|
|
97
|
+
const rootFile = path.join(sessionDir, "20260901T000000-000_root-sess.jsonl");
|
|
98
|
+
const childFile = path.join(sessionDir, "20260901T000001-000_child-sess.jsonl");
|
|
99
|
+
const grandFile = path.join(sessionDir, "20260901T000002-000_grand-sess.jsonl");
|
|
100
|
+
// 层主:注册了 child(未 unregister)
|
|
101
|
+
fs.writeFileSync(rootFile, entryLine("pending:register", registerData("bg-1", "child-sess")));
|
|
102
|
+
// child:注册了 grand(未 unregister)——sweep 须迭代至叶
|
|
103
|
+
fs.writeFileSync(childFile, entryLine("pending:register", registerData("bg-2", "grand-sess")));
|
|
104
|
+
// grand:叶,无 pending
|
|
105
|
+
fs.writeFileSync(grandFile, "other entry line\n");
|
|
106
|
+
// .alive sidecar:pid 线索来源
|
|
107
|
+
writeAliveMarker(childFile, { pid: ALIVE_PID_CHILD, id: "child-sess", startedAt: Date.now() });
|
|
108
|
+
writeAliveMarker(grandFile, { pid: ALIVE_PID_GRAND, id: "grand-sess", startedAt: Date.now() });
|
|
109
|
+
return { rootFile, childFile, grandFile };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** ps -p <pid> -o command= 的完整 SpawnSyncReturns 形态(测试桩用,字段补齐省 as 链)。 */
|
|
113
|
+
function psResult(stdout: string): ReturnType<typeof spawnSync> {
|
|
114
|
+
return {
|
|
115
|
+
pid: 0,
|
|
116
|
+
output: [null, stdout, ""],
|
|
117
|
+
stdout,
|
|
118
|
+
stderr: "",
|
|
119
|
+
status: 0,
|
|
120
|
+
signal: null,
|
|
121
|
+
} as ReturnType<typeof spawnSync>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** ps 探测默认桩:全部存活 pid 返回 pi rpc 形态。 */
|
|
125
|
+
function stubCmdlineByPid(): void {
|
|
126
|
+
mockSpawnSync.mockImplementation((_cmd: string, args: readonly string[]) => {
|
|
127
|
+
const pid = Number(args[1]);
|
|
128
|
+
return psResult(`node /usr/local/bin/pi --mode rpc --no-extensions --session-dir /tmp/sess-${pid}\n`);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("[T2-②] listActivePendingFromSessionFile(差集清单)", () => {
|
|
133
|
+
it("register−unregister 差集 + sessionId/type 提取", () => {
|
|
134
|
+
const f = path.join(sessionDir, "a.jsonl");
|
|
135
|
+
fs.writeFileSync(
|
|
136
|
+
f,
|
|
137
|
+
entryLine("pending:register", registerData("bg-1", "sess-1")) +
|
|
138
|
+
entryLine("pending:register", registerData("bg-2", "sess-2")) +
|
|
139
|
+
entryLine("pending:unregister", { id: "bg-1" }),
|
|
140
|
+
);
|
|
141
|
+
const r = listActivePendingFromSessionFile(f);
|
|
142
|
+
expect(r.error).toBeUndefined();
|
|
143
|
+
expect(r.items).toHaveLength(1);
|
|
144
|
+
expect(r.items[0]).toMatchObject({ id: "bg-2", sessionId: "sess-2", type: "session" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("count 与 list 交错调用共享增量游标(结果一致、不丢条目)", () => {
|
|
148
|
+
const f = path.join(sessionDir, "b.jsonl");
|
|
149
|
+
fs.writeFileSync(f, entryLine("pending:register", registerData("bg-1", "sess-1")));
|
|
150
|
+
// 先 count(建立游标),后 append 再 list——增量读入新行
|
|
151
|
+
const c1 = readActivePendingFromSessionFile(f);
|
|
152
|
+
expect(c1.count).toBe(0); // 端口缺席时缺省 0(core notify-ports 默认实现)
|
|
153
|
+
fs.appendFileSync(f, entryLine("pending:register", registerData("bg-2", "sess-2")));
|
|
154
|
+
const r = listActivePendingFromSessionFile(f);
|
|
155
|
+
expect(r.items.map((i) => i.id).sort()).toEqual(["bg-1", "bg-2"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("sessionFile undefined / 不可读 → error 面空清单", () => {
|
|
159
|
+
expect(listActivePendingFromSessionFile(undefined).error).toBeDefined();
|
|
160
|
+
expect(listActivePendingFromSessionFile(path.join(sessionDir, "missing.jsonl")).error).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("[T2-②] sweepDescendantsOfSession(迭代补杀)", () => {
|
|
165
|
+
it("层主→后代→孙迭代展开至叶,双校验通过者逐个 SIGTERM", () => {
|
|
166
|
+
const { rootFile } = writeThreeLevelTree();
|
|
167
|
+
stubCmdlineByPid();
|
|
168
|
+
|
|
169
|
+
const r = sweepDescendantsOfSession(rootFile, sessionDir, "keep-alive watchdog");
|
|
170
|
+
expect(r.killed.sort()).toEqual([ALIVE_PID_CHILD, ALIVE_PID_GRAND].sort());
|
|
171
|
+
expect(r.skipped).toHaveLength(0);
|
|
172
|
+
const sigterms = kills.filter((k) => k.signal === "SIGTERM").map((k) => k.pid);
|
|
173
|
+
expect(sigterms.sort()).toEqual([ALIVE_PID_CHILD, ALIVE_PID_GRAND].sort());
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("SIGTERM 后 30s 仍存活 → 升级 SIGKILL(fake timers)", async () => {
|
|
177
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
178
|
+
const { rootFile } = writeThreeLevelTree();
|
|
179
|
+
stubCmdlineByPid();
|
|
180
|
+
|
|
181
|
+
sweepDescendantsOfSession(rootFile, sessionDir, "test-escalation");
|
|
182
|
+
expect(kills.filter((k) => k.signal === "SIGKILL")).toHaveLength(0);
|
|
183
|
+
|
|
184
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
185
|
+
const sigkills = kills.filter((k) => k.signal === "SIGKILL").map((k) => k.pid);
|
|
186
|
+
expect(sigkills.sort()).toEqual([ALIVE_PID_CHILD, ALIVE_PID_GRAND].sort());
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("cmdline 非 pi --mode rpc → pid 复用守卫拦截,不杀(但迭代继续展开其后代)", () => {
|
|
190
|
+
const { rootFile, childFile, grandFile } = writeThreeLevelTree();
|
|
191
|
+
// 伪造无关进程占住 child 的 pid;grand 正常
|
|
192
|
+
mockSpawnSync.mockImplementation((_cmd: string, args: readonly string[]) => {
|
|
193
|
+
const pid = Number(args[1]);
|
|
194
|
+
return psResult(
|
|
195
|
+
pid === ALIVE_PID_CHILD ? "vim notes.txt\n" : "pi --mode rpc\n",
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
void childFile;
|
|
199
|
+
void grandFile;
|
|
200
|
+
|
|
201
|
+
const r = sweepDescendantsOfSession(rootFile, sessionDir, "test-guard");
|
|
202
|
+
expect(r.killed).toEqual([ALIVE_PID_GRAND]); // child 被拦,grand 不受影响
|
|
203
|
+
expect(r.skipped).toHaveLength(1);
|
|
204
|
+
expect(r.skipped[0]).toMatchObject({ sessionId: "child-sess", pid: ALIVE_PID_CHILD });
|
|
205
|
+
expect(r.skipped[0].reason).toContain("pid reuse guard");
|
|
206
|
+
expect(kills.map((k) => k.pid)).toEqual([ALIVE_PID_GRAND]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("存活校验不过(pid 已死)→ skip 不杀", () => {
|
|
210
|
+
const { rootFile, childFile } = writeThreeLevelTree();
|
|
211
|
+
// child 的 marker 指向死 pid
|
|
212
|
+
writeAliveMarker(childFile, { pid: DEAD_PID, id: "child-sess", startedAt: Date.now() });
|
|
213
|
+
stubCmdlineByPid();
|
|
214
|
+
|
|
215
|
+
const r = sweepDescendantsOfSession(rootFile, sessionDir, "test-dead");
|
|
216
|
+
expect(r.killed).toEqual([ALIVE_PID_GRAND]);
|
|
217
|
+
expect(r.skipped[0]).toMatchObject({ pid: DEAD_PID, reason: expect.stringContaining("not alive") });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("sessionId 反查失败 / 无 alive marker → skip 留 T5 锚点原因,不中断其余分支", () => {
|
|
221
|
+
const rootFile = path.join(sessionDir, "20260901T000010-000_root2.jsonl");
|
|
222
|
+
const childFile = path.join(sessionDir, "20260901T000011-000_naked-sess.jsonl");
|
|
223
|
+
// 两条 register:missing-sess 反查不到;naked-sess 有文件但无 .alive marker
|
|
224
|
+
fs.writeFileSync(
|
|
225
|
+
rootFile,
|
|
226
|
+
entryLine("pending:register", registerData("bg-m", "missing-sess")) +
|
|
227
|
+
entryLine("pending:register", { id: "bg-n", type: "session", name: "naked", status: "active", registeredAt: 1, expiresAt: undefined, sessionId: "naked-sess" }),
|
|
228
|
+
);
|
|
229
|
+
fs.writeFileSync(childFile, "");
|
|
230
|
+
|
|
231
|
+
const r = sweepDescendantsOfSession(rootFile, sessionDir, "test-skip");
|
|
232
|
+
expect(r.killed).toHaveLength(0);
|
|
233
|
+
expect(r.skipped).toHaveLength(2);
|
|
234
|
+
const reasons = r.skipped.map((s) => s.reason).join(" | ");
|
|
235
|
+
expect(reasons).toContain("not found in sessionDir");
|
|
236
|
+
expect(reasons).toContain("pending T5");
|
|
237
|
+
expect(reasons).toContain("no alive marker");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("rootSessionFile undefined → 空 result(无层主快照不扫)", () => {
|
|
241
|
+
stubCmdlineByPid();
|
|
242
|
+
const r = sweepDescendantsOfSession(undefined, sessionDir, "test-empty");
|
|
243
|
+
expect(r.killed).toHaveLength(0);
|
|
244
|
+
expect(r.skipped).toHaveLength(0);
|
|
245
|
+
expect(kills).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("[T2-②] looksLikePiRpcProcess(pid 复用防误杀校验)", () => {
|
|
250
|
+
it.each([
|
|
251
|
+
["pi --mode rpc --no-extensions --session-dir /tmp/x", true],
|
|
252
|
+
["node /usr/local/bin/pi --mode rpc --session-dir /tmp/x", true],
|
|
253
|
+
["/opt/pi/bin/pi --mode=rpc", true],
|
|
254
|
+
["pi.js --mode rpc", true],
|
|
255
|
+
])("命中 pi rpc 形态: %s", (cmdline, expected) => {
|
|
256
|
+
expect(looksLikePiRpcProcess(cmdline)).toBe(expected);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it.each([
|
|
260
|
+
["vim notes.txt"],
|
|
261
|
+
["nginx: master process"],
|
|
262
|
+
["tail -f /dev/null"],
|
|
263
|
+
["node server.js --mode rpc"], // 有 --mode rpc 但命令不是 pi
|
|
264
|
+
["pi --mode json"], // pi 但非 rpc
|
|
265
|
+
["api --mode rpc"], // 含 "pi" 子串但非 pi 词形
|
|
266
|
+
])("拒绝无关形态: %s", (cmdline) => {
|
|
267
|
+
expect(looksLikePiRpcProcess(cmdline)).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -26,7 +26,18 @@
|
|
|
26
26
|
|
|
27
27
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
30
|
+
loggerMock: {
|
|
31
|
+
debug: vi.fn(),
|
|
32
|
+
warn: vi.fn(),
|
|
33
|
+
error: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
vi.mock("../../core/logger.ts", () => ({
|
|
37
|
+
getLogger: () => loggerMock,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { DEFAULT_DIALOG_TIMEOUT_MS, DialogGlobalQueue, type UiRequest, type UiResponse } from "../dialog-queue.ts";
|
|
30
41
|
|
|
31
42
|
// ── 类型助手 ────────────────────────────────────────────────
|
|
32
43
|
// UiRequest 最小形状(method + id)。dialog 类:select/confirm/input/editor。
|
|
@@ -44,6 +55,8 @@ function fireAndForgetReq(id: string): UiRequest {
|
|
|
44
55
|
|
|
45
56
|
beforeEach(() => {
|
|
46
57
|
vi.useFakeTimers();
|
|
58
|
+
loggerMock.warn.mockClear();
|
|
59
|
+
loggerMock.error.mockClear();
|
|
47
60
|
});
|
|
48
61
|
|
|
49
62
|
afterEach(() => {
|
|
@@ -297,3 +310,188 @@ describe("DialogGlobalQueue — #19 单一推进点 + child close 集成语义",
|
|
|
297
310
|
expect(queue.size).toBe(0);
|
|
298
311
|
});
|
|
299
312
|
});
|
|
313
|
+
|
|
314
|
+
// ── LC-3/T2⑦:dialog 超时上界(有意的行为变更:「等用户无限久」→ 默认 30min 有界)──
|
|
315
|
+
// 设计权威源:docs/design/subagent-core-unbounded-wait-audit.md §7.2 T2 措施⑦。
|
|
316
|
+
// 覆盖:①传 timeout 按传值 settle(错误消息含恢复指引与已等待时长)②未传挂 30min 默认上界
|
|
317
|
+
// ③超时后队列继续(L2 processing 释放,不全局卡死)④handler 与队列级竞态 settle 恰一次。
|
|
318
|
+
|
|
319
|
+
describe("DialogGlobalQueue — dialog 超时上界(LC-3/T2⑦)", () => {
|
|
320
|
+
/** 探测 pending 是否已 settle(fake timers 下不能直接 await 未定 Promise)。 */
|
|
321
|
+
function probe(pending: Promise<UiResponse>): { settled: () => boolean } {
|
|
322
|
+
const state = { settled: false };
|
|
323
|
+
void pending.then(() => {
|
|
324
|
+
state.settled = true;
|
|
325
|
+
});
|
|
326
|
+
return { settled: () => state.settled };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
it("请求方传 timeout → 按传值超时 settle cancelled,日志含恢复指引与已等待时长", async () => {
|
|
330
|
+
const queue = new DialogGlobalQueue();
|
|
331
|
+
const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
|
|
332
|
+
const p = queue.enqueue({ ...dialogReq("r1"), timeout: 1000 }, handler);
|
|
333
|
+
|
|
334
|
+
// 999ms(未到点):不 settle
|
|
335
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
336
|
+
const before = probe(p);
|
|
337
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
338
|
+
expect(before.settled()).toBe(false);
|
|
339
|
+
|
|
340
|
+
// 第 1000ms:到点 settle cancelled(不 reject——enqueue 公共契约 Promise 不 reject)
|
|
341
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
342
|
+
await expect(p).resolves.toEqual({ cancelled: true });
|
|
343
|
+
|
|
344
|
+
// 完整错误消息承载在父进程日志:含已等待时长 + 「重新发起提问」恢复指引
|
|
345
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
346
|
+
const msg = loggerMock.warn.mock.calls[0][0] as string;
|
|
347
|
+
expect(msg).toContain("1000ms");
|
|
348
|
+
expect(msg).toContain("重新发起提问");
|
|
349
|
+
// 队列状态已释放
|
|
350
|
+
expect(queue.size).toBe(0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("未传 timeout → 挂 DEFAULT_DIALOG_TIMEOUT_MS(30min)默认上界 settle cancelled", async () => {
|
|
354
|
+
const queue = new DialogGlobalQueue();
|
|
355
|
+
const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
|
|
356
|
+
const p = queue.enqueue(dialogReq("r1"), handler);
|
|
357
|
+
|
|
358
|
+
// 30min - 1ms:不 settle(原行为是永不 settle,此处锁死「有上界」语义)
|
|
359
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_DIALOG_TIMEOUT_MS - 1);
|
|
360
|
+
const before = probe(p);
|
|
361
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
362
|
+
expect(before.settled()).toBe(false);
|
|
363
|
+
|
|
364
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
365
|
+
await expect(p).resolves.toEqual({ cancelled: true });
|
|
366
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
367
|
+
const msg = loggerMock.warn.mock.calls[0][0] as string;
|
|
368
|
+
expect(msg).toContain(`${DEFAULT_DIALOG_TIMEOUT_MS}ms`);
|
|
369
|
+
expect(msg).toContain("重新发起提问");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("timeout 非法(<=0)→ 回落默认上界(不因脏参数拆掉上界回到无界)", async () => {
|
|
373
|
+
const queue = new DialogGlobalQueue();
|
|
374
|
+
const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
|
|
375
|
+
const p = queue.enqueue({ ...dialogReq("r1"), timeout: 0 }, handler);
|
|
376
|
+
|
|
377
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_DIALOG_TIMEOUT_MS - 1);
|
|
378
|
+
const before = probe(p);
|
|
379
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
380
|
+
expect(before.settled()).toBe(false);
|
|
381
|
+
|
|
382
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
383
|
+
await expect(p).resolves.toEqual({ cancelled: true });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("超时后 L2 processing 释放,队列继续处理后续项(不全局卡死)", async () => {
|
|
387
|
+
const queue = new DialogGlobalQueue();
|
|
388
|
+
const blocking = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
|
|
389
|
+
const nextHandler = vi.fn(async (): Promise<UiResponse> => ({ value: "r2-ok" }));
|
|
390
|
+
|
|
391
|
+
// r1 永挂(host UI promise 挂死形态),带 timeout:1000;r2 排队
|
|
392
|
+
const p1 = queue.enqueue({ ...dialogReq("r1"), timeout: 1000 }, blocking);
|
|
393
|
+
const p2 = queue.enqueue(dialogReq("r2"), nextHandler);
|
|
394
|
+
expect(nextHandler).toHaveBeenCalledTimes(0);
|
|
395
|
+
|
|
396
|
+
// r1 超时 → settle + 推进 r2
|
|
397
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
398
|
+
await expect(p1).resolves.toEqual({ cancelled: true });
|
|
399
|
+
expect(nextHandler).toHaveBeenCalledTimes(1);
|
|
400
|
+
await expect(p2).resolves.toEqual({ value: "r2-ok" });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("竞态恰一次(超时先到):超时 settle 后迟到的 handler 结果不覆盖,队列不重复推进", async () => {
|
|
404
|
+
const queue = new DialogGlobalQueue();
|
|
405
|
+
let resolveHandler: ((v: UiResponse) => void) | undefined;
|
|
406
|
+
const handler = vi.fn(
|
|
407
|
+
(): Promise<UiResponse> =>
|
|
408
|
+
new Promise<UiResponse>((resolve) => {
|
|
409
|
+
resolveHandler = resolve;
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
const nextHandler = vi.fn(async (): Promise<UiResponse> => ({ value: "next-ok" }));
|
|
413
|
+
|
|
414
|
+
const p1 = queue.enqueue({ ...dialogReq("r1"), timeout: 1000 }, handler);
|
|
415
|
+
const p2 = queue.enqueue(dialogReq("r2"), nextHandler);
|
|
416
|
+
|
|
417
|
+
// 超时先 settle
|
|
418
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
419
|
+
await expect(p1).resolves.toEqual({ cancelled: true });
|
|
420
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
421
|
+
|
|
422
|
+
// 迟到的 handler 结果不覆盖(Promise 恰 settle 一次),r2 也已被推进恰一次
|
|
423
|
+
resolveHandler!({ value: "late" });
|
|
424
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
425
|
+
await expect(p1).resolves.toEqual({ cancelled: true });
|
|
426
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
427
|
+
expect(nextHandler).toHaveBeenCalledTimes(1);
|
|
428
|
+
await expect(p2).resolves.toEqual({ value: "next-ok" });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("竞态恰一次(handler 先到):handler 正常完成后迟到超时点不触发第二次 settle 与超时日志", async () => {
|
|
432
|
+
const queue = new DialogGlobalQueue();
|
|
433
|
+
let resolveHandler: ((v: UiResponse) => void) | undefined;
|
|
434
|
+
const handler = vi.fn(
|
|
435
|
+
(): Promise<UiResponse> =>
|
|
436
|
+
new Promise<UiResponse>((resolve) => {
|
|
437
|
+
resolveHandler = resolve;
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const p = queue.enqueue({ ...dialogReq("r1"), timeout: 1000 }, handler);
|
|
442
|
+
resolveHandler!({ value: "fast" });
|
|
443
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
444
|
+
await expect(p).resolves.toEqual({ value: "fast" });
|
|
445
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(0);
|
|
446
|
+
|
|
447
|
+
// 越过原超时点:timer 已被 clearTimeout,无第二次 settle、无超时日志
|
|
448
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
449
|
+
await expect(p).resolves.toEqual({ value: "fast" });
|
|
450
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(0);
|
|
451
|
+
expect(queue.size).toBe(0);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ── A2-2:reject/rejectAll 抢先 settle 后超时 timer 必须撤下 ────────────────
|
|
456
|
+
// 修复前:clearTimeout 只在 processNext 的 handler settle/throw 两路径——handler 永挂时
|
|
457
|
+
// await 永不 resume,rejectChildDialogs/rejectAll 抢先 settle 后 timer 仍 armed 到期,
|
|
458
|
+
// 触发虚假「dialog timed out」warn + 句柄滞留至超时点(30min~24.8 天)。
|
|
459
|
+
|
|
460
|
+
describe("DialogGlobalQueue — reject 抢先 settle 后超时 timer 清理(A2-2)", () => {
|
|
461
|
+
it("current 被 rejectChildDialogs 抢先 settle → timer 撤下,到期无虚假 warn、无句柄滞留", async () => {
|
|
462
|
+
const queue = new DialogGlobalQueue();
|
|
463
|
+
// host UI promise 永挂(rejectChildDialogs 的设计触发场景)
|
|
464
|
+
const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
|
|
465
|
+
const p = queue.enqueue({ ...dialogReq("r1"), timeout: 1000 }, handler, { child: { pid: 42 } });
|
|
466
|
+
|
|
467
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
468
|
+
expect(handler).toHaveBeenCalledTimes(1); // 已成 current,超时 timer 已 armed
|
|
469
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
470
|
+
|
|
471
|
+
// child close:抢先 settle 为 cancelled
|
|
472
|
+
queue.rejectChildDialogs({ pid: 42 });
|
|
473
|
+
await expect(p).resolves.toEqual({ cancelled: true });
|
|
474
|
+
expect(vi.getTimerCount()).toBe(0); // timer 已随 settle 清除,无句柄滞留
|
|
475
|
+
|
|
476
|
+
// 越过原超时点:不触发虚假「dialog timed out」warn、无第二次 settle
|
|
477
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
478
|
+
await expect(p).resolves.toEqual({ cancelled: true });
|
|
479
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("rejectAll 抢先 settle current → 同样撤下 timer(无虚假 warn、无句柄滞留)", async () => {
|
|
483
|
+
const queue = new DialogGlobalQueue();
|
|
484
|
+
const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
|
|
485
|
+
const p = queue.enqueue({ ...dialogReq("r1"), timeout: 1000 }, handler);
|
|
486
|
+
|
|
487
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
488
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
489
|
+
|
|
490
|
+
queue.rejectAll();
|
|
491
|
+
await expect(p).resolves.toEqual({ cancelled: true });
|
|
492
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
493
|
+
|
|
494
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
495
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
});
|