@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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [OR-3] abort 广播(主线程半边)+ [OR-7] signal abort listener 终态移除 测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(unbounded-wait-audit §4.3 OR-7 / §7.2 T3③⑥ / §7.3 P-T3 主线程半边):
|
|
5
|
+
* - abortRun / terminateRunningRuns 在 worker.terminate 之前向 worker 广播
|
|
6
|
+
* {type:"abort", reason}(worker 侧 pending 优雅解阻,P-T3);postMessage 抛错
|
|
7
|
+
* (worker 已退出)不阻断 abort 主流程;runtime 缺失为 no-op
|
|
8
|
+
* - abort listener 注册后,run 经任意终态路径收敛时 removeEventListener:
|
|
9
|
+
* abortRun(用户/预算/超时 abort)、terminateRunningRuns(session 切换)、
|
|
10
|
+
* 消息面终态(return → onRunDone 包装收口)——修复 {once:true} 只在 abort
|
|
11
|
+
* 触发时自清的泄漏(同 signal 连续多 run 泄漏 listener)
|
|
12
|
+
* - [OR-8] abortRun 收口残留 in-flight call(先收口再落盘)
|
|
13
|
+
*/
|
|
14
|
+
import { getEventListeners } from "node:events";
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
abortRun,
|
|
20
|
+
runWorkflow,
|
|
21
|
+
terminateRunningRuns,
|
|
22
|
+
} from "../lifecycle.ts";
|
|
23
|
+
import { AgentCall } from "../models/agent-call.ts";
|
|
24
|
+
import { RunRuntime } from "../models/run-runtime.ts";
|
|
25
|
+
import { Trace } from "../models/trace.ts";
|
|
26
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
27
|
+
import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
|
|
28
|
+
import type { AgentResult, ExecutionTraceNode } from "../models/types.ts";
|
|
29
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
30
|
+
import type { WorkerHandle } from "../worker-handle.ts";
|
|
31
|
+
|
|
32
|
+
// ── helpers ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function makeSpec(): RunSpec {
|
|
35
|
+
return {
|
|
36
|
+
scriptSource: "execute() {}",
|
|
37
|
+
args: {},
|
|
38
|
+
scriptName: "test-wf",
|
|
39
|
+
scriptPath: "/fake/test.js",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeDeps(): LifecycleDeps & {
|
|
44
|
+
store: { save: ReturnType<typeof vi.fn>; loadAll: ReturnType<typeof vi.fn> };
|
|
45
|
+
workerHost: { start: ReturnType<typeof vi.fn> };
|
|
46
|
+
eventBus: { emit: ReturnType<typeof vi.fn> };
|
|
47
|
+
onRunDone: ReturnType<typeof vi.fn>;
|
|
48
|
+
log: ReturnType<typeof vi.fn>;
|
|
49
|
+
} {
|
|
50
|
+
return {
|
|
51
|
+
store: { save: vi.fn(async () => {}), loadAll: vi.fn(async () => []) },
|
|
52
|
+
workerHost: {
|
|
53
|
+
start: vi.fn(
|
|
54
|
+
() => ({ postMessage: vi.fn(), terminate: vi.fn(async () => {}) }) as unknown as WorkerHandle,
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
runner: { run: vi.fn(async () => ({}) as AgentResult) },
|
|
58
|
+
runs: new Map(),
|
|
59
|
+
eventBus: { emit: vi.fn() },
|
|
60
|
+
onRunDone: vi.fn(),
|
|
61
|
+
log: vi.fn(),
|
|
62
|
+
} as unknown as ReturnType<typeof makeDeps>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 从 mock worker 提取 postMessage spy。 */
|
|
66
|
+
function postSpy(handle: unknown): ReturnType<typeof vi.fn> {
|
|
67
|
+
return (handle as { postMessage: ReturnType<typeof vi.fn> }).postMessage;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 从 workerHost.start mock 取第 n 次启动的 handle。 */
|
|
71
|
+
function startedHandle(deps: ReturnType<typeof makeDeps>, n = 0): unknown {
|
|
72
|
+
return deps.workerHost.start.mock.results[n]?.value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.useFakeTimers();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
vi.restoreAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── [OR-3] abort 广播(主线程半边) ──────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("[OR-3] abort 广播(abortRun / terminateRunningRuns)", () => {
|
|
87
|
+
it("abortRun:在 terminate 之前向 worker 广播 {type:\"abort\", reason}", async () => {
|
|
88
|
+
const deps = makeDeps();
|
|
89
|
+
const runId = await runWorkflow(makeSpec(), deps);
|
|
90
|
+
const handle = startedHandle(deps);
|
|
91
|
+
const post = postSpy(handle);
|
|
92
|
+
const terminate = (handle as { terminate: ReturnType<typeof vi.fn> }).terminate;
|
|
93
|
+
post.mockClear();
|
|
94
|
+
|
|
95
|
+
await abortRun(runId, deps, "user requested abort");
|
|
96
|
+
|
|
97
|
+
expect(post).toHaveBeenCalledWith({ type: "abort", reason: "user requested abort" });
|
|
98
|
+
// 顺序锚:广播先于 terminate(terminate 后广播发不进去)
|
|
99
|
+
expect(terminate).toHaveBeenCalledTimes(1);
|
|
100
|
+
expect(post.mock.invocationCallOrder[0]).toBeLessThan(terminate.mock.invocationCallOrder[0]);
|
|
101
|
+
expect(deps.runs.get(runId)?.state.reason).toBe("aborted");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("abortRun 未传 reason:广播 reason 带 doneReason(time_limited 场景可归因)", async () => {
|
|
105
|
+
const deps = makeDeps();
|
|
106
|
+
const runId = await runWorkflow(makeSpec(), deps);
|
|
107
|
+
postSpy(startedHandle(deps)).mockClear();
|
|
108
|
+
|
|
109
|
+
await abortRun(runId, deps, undefined, "time_limited");
|
|
110
|
+
|
|
111
|
+
expect(postSpy(startedHandle(deps))).toHaveBeenCalledWith(
|
|
112
|
+
{ type: "abort", reason: "Workflow aborted (time_limited)" },
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("postMessage 同步抛错(worker 已退出):不阻断 abort 主流程(P-T3)", async () => {
|
|
117
|
+
const deps = makeDeps();
|
|
118
|
+
const runId = await runWorkflow(makeSpec(), deps);
|
|
119
|
+
const handle = startedHandle(deps);
|
|
120
|
+
postSpy(handle).mockImplementation(() => {
|
|
121
|
+
throw new Error("postMessage after worker exited");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await expect(abortRun(runId, deps, "abort despite dead worker")).resolves.toBeUndefined();
|
|
125
|
+
|
|
126
|
+
// transition / 持久化照常完成(save 2 次 = runWorkflow 启动 1 次 + abortRun 1 次)
|
|
127
|
+
expect(deps.runs.get(runId)?.state.reason).toBe("aborted");
|
|
128
|
+
expect(deps.store.save).toHaveBeenCalledTimes(2);
|
|
129
|
+
expect(deps.eventBus.emit).toHaveBeenCalledWith("pending:unregister", expect.anything());
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("runtime 缺失(无 worker):广播 no-op,abort 照常", async () => {
|
|
133
|
+
const deps = makeDeps();
|
|
134
|
+
const runId = await runWorkflow(makeSpec(), deps);
|
|
135
|
+
const run = deps.runs.get(runId)!;
|
|
136
|
+
run.runtime = undefined;
|
|
137
|
+
|
|
138
|
+
await expect(abortRun(runId, deps, "no runtime")).resolves.toBeUndefined();
|
|
139
|
+
expect(run.state.reason).toBe("aborted");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("terminateRunningRuns:每个 running run 各自广播(先于 terminate)", async () => {
|
|
143
|
+
const deps = makeDeps();
|
|
144
|
+
const idA = await runWorkflow(makeSpec(), deps);
|
|
145
|
+
const idB = await runWorkflow(makeSpec(), deps);
|
|
146
|
+
const handleA = startedHandle(deps, 0);
|
|
147
|
+
const handleB = startedHandle(deps, 1);
|
|
148
|
+
postSpy(handleA).mockClear();
|
|
149
|
+
postSpy(handleB).mockClear();
|
|
150
|
+
|
|
151
|
+
await terminateRunningRuns(deps, "Session switched: run terminated");
|
|
152
|
+
|
|
153
|
+
expect(postSpy(handleA)).toHaveBeenCalledWith({ type: "abort", reason: "Session switched: run terminated" });
|
|
154
|
+
expect(postSpy(handleB)).toHaveBeenCalledWith({ type: "abort", reason: "Session switched: run terminated" });
|
|
155
|
+
expect(deps.runs.get(idA)?.state.reason).toBe("failed");
|
|
156
|
+
expect(deps.runs.get(idB)?.state.reason).toBe("failed");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("[OR-8] abortRun 收口残留 in-flight call:先收口再落盘,快照无 running 节点", async () => {
|
|
160
|
+
const deps = makeDeps();
|
|
161
|
+
const runId = await runWorkflow(makeSpec(), deps);
|
|
162
|
+
const run = deps.runs.get(runId)!;
|
|
163
|
+
// 注入 in-flight call(真实 AgentCall 实例:running + live record)
|
|
164
|
+
const node: ExecutionTraceNode = {
|
|
165
|
+
stepIndex: 1,
|
|
166
|
+
agent: "a",
|
|
167
|
+
task: "t",
|
|
168
|
+
model: "m",
|
|
169
|
+
status: "running",
|
|
170
|
+
live: { turns: [] } as never,
|
|
171
|
+
};
|
|
172
|
+
run.state.trace.append(node);
|
|
173
|
+
const call = new AgentCall(1, { prompt: "t" }, node);
|
|
174
|
+
call.markRunning();
|
|
175
|
+
run.state.calls.set(1, call);
|
|
176
|
+
|
|
177
|
+
await abortRun(runId, deps, "mid-flight abort");
|
|
178
|
+
|
|
179
|
+
// 收口:call done + trace failed(Cancelled 文案)+ live 清除
|
|
180
|
+
expect(call.status).toBe("done");
|
|
181
|
+
expect(call.result?.error).toContain("Cancelled");
|
|
182
|
+
expect(run.state.trace.find(1)?.status).toBe("failed");
|
|
183
|
+
// 落盘在收口之后:save 时 run 已无 running 节点
|
|
184
|
+
expect(run.state.trace.toArray().every((n) => n.status !== "running")).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── [OR-7] signal abort listener 终态移除 ────────────────────
|
|
189
|
+
|
|
190
|
+
describe("[OR-7] signal abort listener run 终态移除", () => {
|
|
191
|
+
/** 构造带 spy 的 controller(记录 add/remove 的 listener,原行为保留)。 */
|
|
192
|
+
function makeSignal(): {
|
|
193
|
+
controller: AbortController;
|
|
194
|
+
added: Array<() => void>;
|
|
195
|
+
removed: Array<() => void>;
|
|
196
|
+
} {
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const added: Array<() => void> = [];
|
|
199
|
+
const removed: Array<() => void> = [];
|
|
200
|
+
const origAdd = controller.signal.addEventListener.bind(controller.signal);
|
|
201
|
+
const origRemove = controller.signal.removeEventListener.bind(controller.signal);
|
|
202
|
+
vi.spyOn(controller.signal, "addEventListener").mockImplementation(
|
|
203
|
+
(type: string, listener: EventListenerOrEventListenerObject, options?: unknown): void => {
|
|
204
|
+
added.push(listener as () => void);
|
|
205
|
+
origAdd(type as "abort", listener, options as never);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
vi.spyOn(controller.signal, "removeEventListener").mockImplementation(
|
|
209
|
+
(type: string, listener: EventListenerOrEventListenerObject, options?: unknown): void => {
|
|
210
|
+
removed.push(listener as () => void);
|
|
211
|
+
origRemove(type as "abort", listener, options as never);
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
return { controller, added, removed };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it("abortRun 终止 run → removeEventListener(listener 不残留)", async () => {
|
|
218
|
+
const deps = makeDeps();
|
|
219
|
+
const { controller, added, removed } = makeSignal();
|
|
220
|
+
|
|
221
|
+
const runId = await runWorkflow(makeSpec(), deps, controller.signal);
|
|
222
|
+
expect(added).toHaveLength(1);
|
|
223
|
+
expect(removed).toHaveLength(0);
|
|
224
|
+
|
|
225
|
+
await abortRun(runId, deps, "user abort");
|
|
226
|
+
|
|
227
|
+
expect(removed).toEqual([added[0]]);
|
|
228
|
+
// 手动再派发 abort 事件:listener 已移除,无任何 abort 副作用
|
|
229
|
+
const emitSpy = deps.eventBus.emit;
|
|
230
|
+
emitSpy.mockClear();
|
|
231
|
+
controller.signal.dispatchEvent(new Event("abort"));
|
|
232
|
+
expect(emitSpy).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("消息面终态(worker return → onRunDone)→ removeEventListener(不再泄漏)", async () => {
|
|
236
|
+
const deps = makeDeps();
|
|
237
|
+
const { controller, added, removed } = makeSignal();
|
|
238
|
+
|
|
239
|
+
const runId = await runWorkflow(makeSpec(), deps, controller.signal);
|
|
240
|
+
const handle = startedHandle(deps);
|
|
241
|
+
// 捕获 makeHandlers 构造的 handlers(workerHost.start 的第 3 参)并投递 return
|
|
242
|
+
const handlers = deps.workerHost.start.mock.calls[0]?.[2] as WorkerHandlers;
|
|
243
|
+
await handlers.onMessage({ type: "return", result: { ok: true } });
|
|
244
|
+
|
|
245
|
+
expect(deps.runs.get(runId)?.state.reason).toBe("completed");
|
|
246
|
+
expect(removed).toEqual([added[0]]);
|
|
247
|
+
expect(postSpy(handle).mock.calls.some((c) => (c[0] as { type?: string })?.type === "abort")).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("terminateRunningRuns 终止 → removeEventListener(不走 onRunDone 的路径也收口)", async () => {
|
|
251
|
+
const deps = makeDeps();
|
|
252
|
+
const { controller, added, removed } = makeSignal();
|
|
253
|
+
|
|
254
|
+
await runWorkflow(makeSpec(), deps, controller.signal);
|
|
255
|
+
expect(removed).toHaveLength(0);
|
|
256
|
+
|
|
257
|
+
await terminateRunningRuns(deps, "session switch");
|
|
258
|
+
|
|
259
|
+
expect(removed).toEqual([added[0]]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("signal 触发 abort → listener 自移除(显式 once 语义)且 run 转 aborted", async () => {
|
|
263
|
+
const deps = makeDeps();
|
|
264
|
+
const { controller, added, removed } = makeSignal();
|
|
265
|
+
|
|
266
|
+
const runId = await runWorkflow(makeSpec(), deps, controller.signal);
|
|
267
|
+
controller.abort();
|
|
268
|
+
|
|
269
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
270
|
+
expect(deps.runs.get(runId)?.state.reason).toBe("aborted");
|
|
271
|
+
expect(removed).toEqual([added[0]]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("同一 signal 连续跑 12 个 run(逐一终态)→ 同时刻至多 1 个 listener(无累积)", async () => {
|
|
275
|
+
const deps = makeDeps();
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
let live = 0;
|
|
278
|
+
let peak = 0;
|
|
279
|
+
const origAdd = controller.signal.addEventListener.bind(controller.signal);
|
|
280
|
+
const origRemove = controller.signal.removeEventListener.bind(controller.signal);
|
|
281
|
+
vi.spyOn(controller.signal, "addEventListener").mockImplementation(
|
|
282
|
+
(type: string, listener: EventListenerOrEventListenerObject, options?: unknown): void => {
|
|
283
|
+
live += 1;
|
|
284
|
+
peak = Math.max(peak, live);
|
|
285
|
+
origAdd(type as "abort", listener, options as never);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
vi.spyOn(controller.signal, "removeEventListener").mockImplementation(
|
|
289
|
+
(type: string, listener: EventListenerOrEventListenerObject, options?: unknown): void => {
|
|
290
|
+
live -= 1;
|
|
291
|
+
origRemove(type as "abort", listener, options as never);
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < 12; i++) {
|
|
296
|
+
const runId = await runWorkflow(makeSpec(), deps, controller.signal);
|
|
297
|
+
await abortRun(runId, deps, `done ${i}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
expect(peak).toBe(1);
|
|
301
|
+
expect(live).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("终态后 listener 不再触发 abortRun(完成态不被迟到的 signal abort 改写)", async () => {
|
|
305
|
+
const deps = makeDeps();
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
const runId = await runWorkflow(makeSpec(), deps, controller.signal);
|
|
308
|
+
|
|
309
|
+
// 正常完成
|
|
310
|
+
const handlers = deps.workerHost.start.mock.calls[0]?.[2] as WorkerHandlers;
|
|
311
|
+
await handlers.onMessage({ type: "return", result: "ok" });
|
|
312
|
+
expect(deps.runs.get(runId)?.state.reason).toBe("completed");
|
|
313
|
+
|
|
314
|
+
// 完成后外部 signal 才 abort——listener 已移除,run 保持 completed(不被改写)
|
|
315
|
+
controller.abort();
|
|
316
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
317
|
+
expect(deps.runs.get(runId)?.state.reason).toBe("completed");
|
|
318
|
+
expect(deps.store.save).toHaveBeenCalledTimes(2); // runWorkflow 启动 + return 各 1 次
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── [B-2] fail-fast throw 路径不泄漏 signal abort listener ──────────────
|
|
323
|
+
|
|
324
|
+
describe("[B-2] runWorkflow fail-fast throw 路径不泄漏 signal abort listener", () => {
|
|
325
|
+
/** signal 上当前真实挂载的 abort listener 数(EventTarget 权威查询,非 spy 计数)。 */
|
|
326
|
+
function liveListenerCount(signal: AbortSignal): number {
|
|
327
|
+
return getEventListeners(signal).length;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
it("budgetTimeMs 越界 fail-fast 重试 12 次 → 每次拒绝后 listener 计数归零(无 MaxListeners 累积)", async () => {
|
|
331
|
+
const deps = makeDeps();
|
|
332
|
+
const controller = new AbortController();
|
|
333
|
+
// 越界值(>2^31-1)→ assertSafeTimerDelay 在 scheduleTimeBudget 内 fail-fast throw
|
|
334
|
+
const spec: RunSpec = { ...makeSpec(), budgetTimeMs: 3_000_000_000 };
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < 12; i++) {
|
|
337
|
+
await expect(runWorkflow(spec, deps, controller.signal)).rejects.toThrow(/2147483647/);
|
|
338
|
+
// 旧实现 listener 先注册、throw 路径不 dispose——12 次重试后泄漏 12 个
|
|
339
|
+
expect(liveListenerCount(controller.signal)).toBe(0);
|
|
340
|
+
}
|
|
341
|
+
expect(deps.runs.size).toBe(0); // fail-fast:无孤儿 run
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("workerHost.start 抛错重试 12 次 → 每次拒绝后 listener 计数归零", async () => {
|
|
345
|
+
const deps = makeDeps();
|
|
346
|
+
deps.workerHost.start = vi.fn(() => {
|
|
347
|
+
throw new Error("worker boot failed");
|
|
348
|
+
});
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
|
|
351
|
+
for (let i = 0; i < 12; i++) {
|
|
352
|
+
await expect(runWorkflow(makeSpec(), deps, controller.signal)).rejects.toThrow("worker boot failed");
|
|
353
|
+
expect(liveListenerCount(controller.signal)).toBe(0);
|
|
354
|
+
}
|
|
355
|
+
expect(deps.runs.size).toBe(0);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// lifecycle-recover-crashed.test.ts —— recoverCrashedRuns 崩溃恢复四步装配测试(U7/D8/B1)。
|
|
2
|
+
//
|
|
3
|
+
// 四步序列 = loadAll → failed → save → evict(平移 pi session_start 恢复循环,
|
|
4
|
+
// pending:unregister 宿主事件经 hooks 外置)。本文件用真实 WorkflowRun(真实
|
|
5
|
+
// 状态机 + I1/I2 不变式)+ mock RunStore(可观察调用序列)。
|
|
6
|
+
//
|
|
7
|
+
// 覆盖:
|
|
8
|
+
// - running 残留 → done,failed(state.error=reason)+ save 落盘 + runs Map 注册
|
|
9
|
+
// - 序列:hooks 在 transition 后、save 前(对齐 pi emit 位置);save 收到的已是终态 run
|
|
10
|
+
// - done run 原样保留(不重复 save)但也注册进 runs Map
|
|
11
|
+
// - hooks 每 running run 恰好一次、参数 {id, reason:"failed"};无 hooks 不炸
|
|
12
|
+
// - 单 run save 失败不中断其余 run(幂等恢复);loadAll 失败向上抛
|
|
13
|
+
// - onRunRecovered 同步 throw 被围栏捕获(warn 留痕),不中断其余 run 恢复
|
|
14
|
+
// - evict 步:超 MAX_RETAINED_DONE_RUNS 的 done run 被淘汰(最旧优先)
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { configureCore, resetCoreForTests } from "../../core/host-services.ts";
|
|
18
|
+
import {
|
|
19
|
+
MAX_RETAINED_DONE_RUNS,
|
|
20
|
+
recoverCrashedRuns,
|
|
21
|
+
} from "../lifecycle.ts";
|
|
22
|
+
import { Budget } from "../models/budget.ts";
|
|
23
|
+
import type { RunStore } from "../models/ports.ts";
|
|
24
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
25
|
+
import { Trace } from "../models/trace.ts";
|
|
26
|
+
import type { DoneReason } from "../models/types.ts";
|
|
27
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
28
|
+
|
|
29
|
+
// ── helpers ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function makeSpec(name = "test-wf"): RunSpec {
|
|
32
|
+
return {
|
|
33
|
+
scriptSource: "execute() {}",
|
|
34
|
+
args: {},
|
|
35
|
+
scriptName: name,
|
|
36
|
+
scriptPath: "/fake/test.js",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 重水合形态构造(对齐 store.loadAll 真实产物:running 快照无 runtime——
|
|
42
|
+
* reconstruct 不恢复 worker,transition 的 releaseRuntime 对 undefined no-op)。
|
|
43
|
+
*/
|
|
44
|
+
function makeRun(
|
|
45
|
+
runId: string,
|
|
46
|
+
opts: {
|
|
47
|
+
status?: "running" | "done";
|
|
48
|
+
reason?: DoneReason;
|
|
49
|
+
completedAt?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
scriptName?: string;
|
|
52
|
+
} = {},
|
|
53
|
+
): WorkflowRun {
|
|
54
|
+
const status = opts.status ?? "running";
|
|
55
|
+
const state = {
|
|
56
|
+
status,
|
|
57
|
+
...(status === "done"
|
|
58
|
+
? { reason: opts.reason ?? "completed" }
|
|
59
|
+
: {}),
|
|
60
|
+
budget: new Budget({ maxTokens: 1000 }),
|
|
61
|
+
calls: new Map(),
|
|
62
|
+
trace: new Trace(),
|
|
63
|
+
errorLogs: [],
|
|
64
|
+
...(opts.error !== undefined ? { error: opts.error } : {}),
|
|
65
|
+
};
|
|
66
|
+
const meta = {
|
|
67
|
+
startedAt: "2026-08-30T00:00:00.000Z",
|
|
68
|
+
...(opts.completedAt !== undefined ? { completedAt: opts.completedAt } : {}),
|
|
69
|
+
};
|
|
70
|
+
return WorkflowRun.reconstruct(runId, makeSpec(opts.scriptName), state, meta);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** mock RunStore:loadAll 返回预置 runs,save 可观察(可注入失败)。 */
|
|
74
|
+
function makeStore(loaded: WorkflowRun[], opts: { failSaveFor?: string } = {}) {
|
|
75
|
+
const saves: WorkflowRun[] = [];
|
|
76
|
+
const store: RunStore = {
|
|
77
|
+
loadAll: vi.fn(async () => loaded),
|
|
78
|
+
save: vi.fn(async (run: WorkflowRun) => {
|
|
79
|
+
if (opts.failSaveFor !== undefined && run.runId === opts.failSaveFor) {
|
|
80
|
+
throw new Error("disk full");
|
|
81
|
+
}
|
|
82
|
+
saves.push(run);
|
|
83
|
+
}),
|
|
84
|
+
stateFilePath: vi.fn((runId: string) => `/fake/workflow-state/${runId}.jsonl`),
|
|
85
|
+
};
|
|
86
|
+
return { store, saves, saveSpy: store.save as ReturnType<typeof vi.fn> };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 四步序列:failed 转换 + save + 注册 ─────────────────────
|
|
90
|
+
|
|
91
|
+
describe("recoverCrashedRuns — 四步序列(loadAll→failed→save→evict)", () => {
|
|
92
|
+
it("running 残留转 done,failed:state.error=reason + save 落盘 + 注册进 runs Map", async () => {
|
|
93
|
+
const running = makeRun("wf-1", { status: "running" });
|
|
94
|
+
const done = makeRun("wf-2", { status: "done", reason: "completed", completedAt: "2026-08-30T01:00:00.000Z" });
|
|
95
|
+
const { store, saves } = makeStore([running, done]);
|
|
96
|
+
const runs = new Map<string, WorkflowRun>();
|
|
97
|
+
|
|
98
|
+
const result = await recoverCrashedRuns(store, runs, "Process killed (kill-9 or crash recovery)");
|
|
99
|
+
|
|
100
|
+
// 返回计数口径:loaded 含 done 历史快照,recovered 只计 running→failed 转换
|
|
101
|
+
expect(result.loaded).toBe(2);
|
|
102
|
+
expect(result.recovered).toBe(1);
|
|
103
|
+
|
|
104
|
+
// 步骤 2:running → done,failed(I2:done 必有 reason)
|
|
105
|
+
expect(running.state.status).toBe("done");
|
|
106
|
+
expect(running.state.reason).toBe("failed");
|
|
107
|
+
expect(running.state.error).toBe("Process killed (kill-9 or crash recovery)");
|
|
108
|
+
expect(running.meta.completedAt).toBeDefined();
|
|
109
|
+
// done run 原样(未被二次转换)
|
|
110
|
+
expect(done.state.status).toBe("done");
|
|
111
|
+
expect(done.state.reason).toBe("completed");
|
|
112
|
+
expect(done.state.error).toBeUndefined();
|
|
113
|
+
// 步骤 3:仅转换的 run 落盘(done 不重复 save)
|
|
114
|
+
expect(saves).toHaveLength(1);
|
|
115
|
+
expect(saves[0].runId).toBe("wf-1");
|
|
116
|
+
// 全部 loaded run(含 done)注册进 runs Map
|
|
117
|
+
expect(runs.size).toBe(2);
|
|
118
|
+
expect(runs.get("wf-1")).toBe(running);
|
|
119
|
+
expect(runs.get("wf-2")).toBe(done);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("序列:hooks 回调在 transition 后、save 前;save 收到的已是终态 run", async () => {
|
|
123
|
+
const run = makeRun("wf-seq", { status: "running" });
|
|
124
|
+
const calls: string[] = [];
|
|
125
|
+
const statusesAtSave: Array<string | undefined> = [];
|
|
126
|
+
const store: RunStore = {
|
|
127
|
+
loadAll: vi.fn(async () => [run]),
|
|
128
|
+
save: vi.fn(async (r: WorkflowRun) => {
|
|
129
|
+
calls.push(`save:${r.runId}`);
|
|
130
|
+
statusesAtSave.push(r.state.status);
|
|
131
|
+
}),
|
|
132
|
+
stateFilePath: vi.fn(() => "/fake"),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
await recoverCrashedRuns(store, new Map(), "crashed", {
|
|
136
|
+
onRunRecovered: (payload) => calls.push(`hook:${payload.id}`),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// hook 先于 save(对齐 pi:emit 在 transition 后、save 前)
|
|
140
|
+
expect(calls).toEqual(["hook:wf-seq", "save:wf-seq"]);
|
|
141
|
+
// save 收到的 run 已是 done,failed(终态落盘语义)
|
|
142
|
+
expect(statusesAtSave).toEqual(["done"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("多个 running run 全部恢复;hooks 每个恰好一次、参数 {id, reason:'failed'}", async () => {
|
|
146
|
+
const r1 = makeRun("wf-a", { status: "running" });
|
|
147
|
+
const r2 = makeRun("wf-b", { status: "running" });
|
|
148
|
+
const { store, saves } = makeStore([r1, r2]);
|
|
149
|
+
const hookPayloads: Array<{ id: string; reason: string }> = [];
|
|
150
|
+
|
|
151
|
+
await recoverCrashedRuns(store, new Map(), "test crash", {
|
|
152
|
+
onRunRecovered: (payload) => hookPayloads.push(payload),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(r1.state.status).toBe("done");
|
|
156
|
+
expect(r2.state.status).toBe("done");
|
|
157
|
+
expect(saves).toHaveLength(2);
|
|
158
|
+
expect(hookPayloads).toEqual([
|
|
159
|
+
{ id: "wf-a", reason: "failed" },
|
|
160
|
+
{ id: "wf-b", reason: "failed" },
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("无 hooks(缺省)不炸,恢复语义不受影响", async () => {
|
|
165
|
+
const run = makeRun("wf-nohooks", { status: "running" });
|
|
166
|
+
const { store, saves } = makeStore([run]);
|
|
167
|
+
|
|
168
|
+
await recoverCrashedRuns(store, new Map(), "crashed");
|
|
169
|
+
|
|
170
|
+
expect(run.state.status).toBe("done");
|
|
171
|
+
expect(saves).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("单 run save 失败不中断其余 run(warn + 继续恢复)", async () => {
|
|
175
|
+
const r1 = makeRun("wf-fail", { status: "running" });
|
|
176
|
+
const r2 = makeRun("wf-ok", { status: "running" });
|
|
177
|
+
const { store, saves } = makeStore([r1, r2], { failSaveFor: "wf-fail" });
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
recoverCrashedRuns(store, new Map(), "crashed"),
|
|
181
|
+
).resolves.toEqual({ loaded: 2, recovered: 2 });
|
|
182
|
+
|
|
183
|
+
// 失败 run 状态机转换已发生(内存终态),仅落盘失败
|
|
184
|
+
expect(r1.state.status).toBe("done");
|
|
185
|
+
expect(r2.state.status).toBe("done");
|
|
186
|
+
expect(saves.map((r) => r.runId)).toEqual(["wf-ok"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("onRunRecovered 同步 throw 被围栏捕获(warn 留痕),不中断其余 run 恢复", async () => {
|
|
190
|
+
// warn 断言经宿主 log 端口 spy 捕获(logger facade 每次调用动态解析宿主实现,
|
|
191
|
+
// 对齐 file-run-store.test.ts 的配置态隔离模式)
|
|
192
|
+
resetCoreForTests();
|
|
193
|
+
const logSpy = vi.fn();
|
|
194
|
+
configureCore({ dataRoot: () => "/fake", log: logSpy });
|
|
195
|
+
|
|
196
|
+
const r1 = makeRun("wf-hook-throw", { status: "running" });
|
|
197
|
+
const r2 = makeRun("wf-after-throw", { status: "running" });
|
|
198
|
+
const { store, saves } = makeStore([r1, r2]);
|
|
199
|
+
const seen: string[] = [];
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
recoverCrashedRuns(store, new Map(), "crashed", {
|
|
203
|
+
onRunRecovered: (payload) => {
|
|
204
|
+
seen.push(payload.id);
|
|
205
|
+
if (payload.id === "wf-hook-throw") {
|
|
206
|
+
throw new Error("host notification channel down");
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
).resolves.toEqual({ loaded: 2, recovered: 2 });
|
|
211
|
+
|
|
212
|
+
// 循环未中断:后续 run 的 hook 仍被调用;两 run 状态机转换 + 落盘照常
|
|
213
|
+
//(含 throw 的 r1——围栏包住整个迭代的宿主事件点,save 不受牵连)
|
|
214
|
+
expect(seen).toEqual(["wf-hook-throw", "wf-after-throw"]);
|
|
215
|
+
expect(r1.state.status).toBe("done");
|
|
216
|
+
expect(r2.state.status).toBe("done");
|
|
217
|
+
expect(saves.map((r) => r.runId)).toEqual(["wf-hook-throw", "wf-after-throw"]);
|
|
218
|
+
// 围栏可见性:warn 已发出且定位到出错的 run
|
|
219
|
+
expect(
|
|
220
|
+
logSpy.mock.calls.some(
|
|
221
|
+
(c) => c[0] === "warn" && String(c[2]).includes("wf-hook-throw"),
|
|
222
|
+
),
|
|
223
|
+
).toBe(true);
|
|
224
|
+
|
|
225
|
+
resetCoreForTests();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("loadAll 失败向上抛(fail-fast 策归宿主决定)", async () => {
|
|
229
|
+
const store: RunStore = {
|
|
230
|
+
loadAll: vi.fn(async () => {
|
|
231
|
+
throw new Error("store corrupted");
|
|
232
|
+
}),
|
|
233
|
+
save: vi.fn(async () => {}),
|
|
234
|
+
stateFilePath: vi.fn(() => "/fake"),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
recoverCrashedRuns(store, new Map(), "crashed"),
|
|
239
|
+
).rejects.toThrow("store corrupted");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── 步骤 4:evict(done run 内存有界性) ────────────────────
|
|
244
|
+
|
|
245
|
+
describe("recoverCrashedRuns — evict 步", () => {
|
|
246
|
+
it("done run 超 MAX_RETAINED_DONE_RUNS 时淘汰最旧;恢复 run(completedAt 最新)必保留", async () => {
|
|
247
|
+
// cap=20:造 22 个 done(completedAt 各异)+ 1 个 running(恢复后 completedAt=当下最新)
|
|
248
|
+
const loaded: WorkflowRun[] = [];
|
|
249
|
+
for (let i = 0; i < MAX_RETAINED_DONE_RUNS + 2; i++) {
|
|
250
|
+
loaded.push(
|
|
251
|
+
makeRun(`wf-old-${i}`, {
|
|
252
|
+
status: "done",
|
|
253
|
+
reason: "completed",
|
|
254
|
+
// ISO 字典序=时间序:i 越小越旧
|
|
255
|
+
completedAt: `2026-08-01T00:00:${String(i).padStart(2, "0")}.000Z`,
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const running = makeRun("wf-recovered", { status: "running" });
|
|
260
|
+
loaded.push(running);
|
|
261
|
+
const { store } = makeStore(loaded);
|
|
262
|
+
const runs = new Map<string, WorkflowRun>();
|
|
263
|
+
|
|
264
|
+
await recoverCrashedRuns(store, runs, "crashed");
|
|
265
|
+
|
|
266
|
+
// 23 个 done(22 + 1 恢复)裁到 K=20:最旧 3 个被淘汰
|
|
267
|
+
expect(runs.size).toBe(MAX_RETAINED_DONE_RUNS);
|
|
268
|
+
expect(runs.has("wf-old-0")).toBe(false);
|
|
269
|
+
expect(runs.has("wf-old-1")).toBe(false);
|
|
270
|
+
expect(runs.has("wf-old-2")).toBe(false);
|
|
271
|
+
expect(runs.has("wf-old-3")).toBe(true);
|
|
272
|
+
// 恢复转换的 run completedAt 为 transition 时刻(全局最新)必在保留端
|
|
273
|
+
expect(runs.has("wf-recovered")).toBe(true);
|
|
274
|
+
expect(runs.get("wf-recovered")).toBe(running);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("未超 cap 时 evict 为 no-op(全部保留)", async () => {
|
|
278
|
+
const loaded = [
|
|
279
|
+
makeRun("wf-d1", { status: "done", reason: "completed", completedAt: "2026-08-01T00:00:00.000Z" }),
|
|
280
|
+
makeRun("wf-r1", { status: "running" }),
|
|
281
|
+
];
|
|
282
|
+
const { store } = makeStore(loaded);
|
|
283
|
+
const runs = new Map<string, WorkflowRun>();
|
|
284
|
+
|
|
285
|
+
await recoverCrashedRuns(store, runs, "crashed");
|
|
286
|
+
|
|
287
|
+
expect(runs.size).toBe(2);
|
|
288
|
+
});
|
|
289
|
+
});
|