@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +170 -14
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +158 -52
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +44 -17
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +29 -10
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -10,20 +10,27 @@
|
|
|
10
10
|
* / 未超限 → rebuildRuntime(workerHost.start 重建)
|
|
11
11
|
* - handleScriptError:超限 → transition done,failed / workerLogs 捕获
|
|
12
12
|
* - postBudgetUpdate:postMessage budget-update(usedTokens/usedCost)
|
|
13
|
-
* - stale handle 过滤(handle.isCurrent=false)+
|
|
13
|
+
* - stale handle 过滤(handle.isCurrent=false)+ terminal stale 守卫(isTerminal 语义)
|
|
14
14
|
* - rebuildRuntime:worker 崩溃后 workerHost.start + scheduleTimeBudget 重排 + replaceRuntime
|
|
15
|
+
* + 在飞 call 清理(discardInFlightCalls 生效:在飞清除、done 保留)
|
|
15
16
|
*/
|
|
16
17
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
18
|
|
|
19
|
+
import { ConcurrencyGate } from "../concurrency-gate.ts";
|
|
18
20
|
import {
|
|
19
21
|
handleScriptError,
|
|
20
22
|
handleWorkerError,
|
|
21
23
|
handleWorkerExit,
|
|
24
|
+
handleWorkerMessage,
|
|
22
25
|
postBudgetUpdate,
|
|
23
26
|
rebuildRuntime,
|
|
24
27
|
} from "../error-recovery.ts";
|
|
28
|
+
import { Budget } from "../models/budget.ts";
|
|
29
|
+
import { RunRuntime } from "../models/run-runtime.ts";
|
|
30
|
+
import { Trace } from "../models/trace.ts";
|
|
31
|
+
import type { AgentResult } from "../models/types.ts";
|
|
32
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
25
33
|
import type { LifecycleDeps, WorkerHandlers } from "../models/ports.ts";
|
|
26
|
-
import type { WorkflowRun } from "../models/workflow-run.ts";
|
|
27
34
|
import type { WorkerHandle } from "../worker-handle.ts";
|
|
28
35
|
|
|
29
36
|
// ── helpers ──────────────────────────────────────────────────
|
|
@@ -41,6 +48,10 @@ function makeRunningRun(opts: {
|
|
|
41
48
|
budget: { usedTokens: 50, usedCost: 0.1 },
|
|
42
49
|
// L9: errorLogs 现在用 push 追加——必须是真实数组,不能省略
|
|
43
50
|
errorLogs: [],
|
|
51
|
+
// rebuildRuntime 内 discardInFlightCalls 遍历 calls + 移除 trace 节点——
|
|
52
|
+
// 必须是真实 Map(空 = 无在飞 call,discard 为 no-op)
|
|
53
|
+
calls: new Map(),
|
|
54
|
+
trace: { removeByStepIndex: vi.fn() },
|
|
44
55
|
},
|
|
45
56
|
meta: {
|
|
46
57
|
startedAt: new Date().toISOString(),
|
|
@@ -206,9 +217,10 @@ describe("handleWorkerError", () => {
|
|
|
206
217
|
expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
|
|
207
218
|
});
|
|
208
219
|
|
|
209
|
-
it("
|
|
220
|
+
it("终态(done):stale 守卫前置丢弃(不递增 workerErrorCount)", async () => {
|
|
210
221
|
const run = makeRunningRun();
|
|
211
|
-
run.state.status = "
|
|
222
|
+
run.state.status = "done";
|
|
223
|
+
(run.state as { reason?: string }).reason = "completed";
|
|
212
224
|
const deps = makeDeps();
|
|
213
225
|
|
|
214
226
|
await handleWorkerError(run, new Error("stale"), deps, makeHandlers());
|
|
@@ -331,4 +343,382 @@ describe("rebuildRuntime", () => {
|
|
|
331
343
|
expect(() => rebuildRuntime(run, deps, makeHandlers())).not.toThrow();
|
|
332
344
|
expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
|
|
333
345
|
});
|
|
346
|
+
|
|
347
|
+
it("rebuildRuntime 后同步清理在飞 call(status !== done 清除、done 保留)", () => {
|
|
348
|
+
const run = makeRunningRun({ budgetTimeMs: 0 });
|
|
349
|
+
// 注入在飞 call(running)与已完成 call(done)+ trace 节点移除 spy
|
|
350
|
+
const removeByStepIndex = vi.fn();
|
|
351
|
+
const calls = new Map<number, { id: number; status: string }>([
|
|
352
|
+
[7, { id: 7, status: "running" }],
|
|
353
|
+
[8, { id: 8, status: "done" }],
|
|
354
|
+
]);
|
|
355
|
+
(run.state as { calls: unknown }).calls = calls;
|
|
356
|
+
(run.state as { trace: unknown }).trace = { removeByStepIndex };
|
|
357
|
+
const deps = makeDeps();
|
|
358
|
+
|
|
359
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
360
|
+
|
|
361
|
+
// 在飞 call(running)被移除(含 trace 节点);已完成 call(done)保留供重跑 replay
|
|
362
|
+
expect(calls.has(7)).toBe(false);
|
|
363
|
+
expect(calls.has(8)).toBe(true);
|
|
364
|
+
expect(removeByStepIndex).toHaveBeenCalledTimes(1);
|
|
365
|
+
expect(removeByStepIndex).toHaveBeenCalledWith(7);
|
|
366
|
+
// 重建本身不受影响
|
|
367
|
+
expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ── orphan call guard(S7-second 竞态回归) ─────────────────
|
|
372
|
+
//
|
|
373
|
+
// 复刻 gate-report §3 的竞态形态:dispatch agent-call → 立即 rebuildRuntime(同步
|
|
374
|
+
// discard 在飞 call)→ 旧 dispatch 的 promise 以失败/成功 resolve → 迟到的旧代际
|
|
375
|
+
// 结果不得经 postAgentResult 投给**新 worker** 的同 callId pending(否则重跑中的
|
|
376
|
+
// agent() 被旧结果劫持 resolve 为空串 → 脚本假成功,PHASE_B 子进程被连带收割)。
|
|
377
|
+
//
|
|
378
|
+
// 用真实 WorkflowRun/RunRuntime/Trace/Budget(而非 makeRunningRun 的简化 mock):
|
|
379
|
+
// rebuildRuntime → replaceRuntime → release 的 abort 旧 controller / terminate 旧
|
|
380
|
+
// worker / discardInFlightCalls 全链路需要真实聚合根行为才成立。
|
|
381
|
+
|
|
382
|
+
/** 手动控制的 deferred——精确编排「dispatch 挂起 → rebuild → 旧 promise settle」交错。 */
|
|
383
|
+
interface Deferred<T> {
|
|
384
|
+
promise: Promise<T>;
|
|
385
|
+
resolve: (value: T) => void;
|
|
386
|
+
reject: (err: unknown) => void;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function createDeferred<T>(): Deferred<T> {
|
|
390
|
+
let resolve!: (value: T) => void;
|
|
391
|
+
let reject!: (err: unknown) => void;
|
|
392
|
+
const promise = new Promise<T>((res, rej) => {
|
|
393
|
+
resolve = res;
|
|
394
|
+
reject = rej;
|
|
395
|
+
});
|
|
396
|
+
return { promise, resolve, reject };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** flush 微任务队列(dispatch 的 fire-and-forget promise 链推进到稳定态)。 */
|
|
400
|
+
async function flushMicrotasks(ticks = 10): Promise<void> {
|
|
401
|
+
for (let i = 0; i < ticks; i++) {
|
|
402
|
+
// eslint-disable-next-line no-await-in-loop
|
|
403
|
+
await Promise.resolve();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 构造真实 WorkflowRun(真实状态机/replaceRuntime/Trace/Budget)+ 初始 RunRuntime。 */
|
|
408
|
+
function makeRealRun(runId: string, opts: { budgetTimeMs?: number } = {}): WorkflowRun {
|
|
409
|
+
const run = new WorkflowRun(
|
|
410
|
+
runId,
|
|
411
|
+
{
|
|
412
|
+
scriptName: "test-wf",
|
|
413
|
+
scriptSource: "agent('hi')",
|
|
414
|
+
args: {},
|
|
415
|
+
scriptPath: "/tmp/test-wf.js",
|
|
416
|
+
budgetTimeMs: opts.budgetTimeMs,
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
status: "running",
|
|
420
|
+
budget: new Budget(),
|
|
421
|
+
calls: new Map(),
|
|
422
|
+
trace: new Trace(),
|
|
423
|
+
errorLogs: [],
|
|
424
|
+
},
|
|
425
|
+
{ startedAt: new Date().toISOString() },
|
|
426
|
+
);
|
|
427
|
+
// 初始 worker mock 必须带 terminate——真实 replaceRuntime 会 release 旧 runtime
|
|
428
|
+
const initialWorker = {
|
|
429
|
+
postMessage: vi.fn(),
|
|
430
|
+
terminate: vi.fn(async () => {}),
|
|
431
|
+
} as unknown as WorkerHandle;
|
|
432
|
+
run.assignRuntime(new RunRuntime(initialWorker, new ConcurrencyGate(), new AbortController()));
|
|
433
|
+
return run;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** agent-call 消息(opts 无 skill/schema——resolveAgentOpts 直通,无 IO)。 */
|
|
437
|
+
function makeAgentCallMsg(callId: number): unknown {
|
|
438
|
+
return {
|
|
439
|
+
type: "agent-call",
|
|
440
|
+
callId,
|
|
441
|
+
opts: { prompt: "test task", agent: "worker", description: "test-slug" },
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 从 postMessage spy 的调用记录中找 type:"agent-result" 且 callId 匹配的报文。 */
|
|
446
|
+
function findAgentResultPost(
|
|
447
|
+
postMessage: ReturnType<typeof vi.fn>,
|
|
448
|
+
callId: number,
|
|
449
|
+
): { type: string; callId: number; result: AgentResult; cached: boolean } | undefined {
|
|
450
|
+
for (const call of postMessage.mock.calls) {
|
|
451
|
+
const msg = call[0] as { type?: string; callId?: number };
|
|
452
|
+
if (msg?.type === "agent-result" && msg.callId === callId) {
|
|
453
|
+
return msg as never;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
describe("orphan call guard(rebuild 后迟到 completion 不投递新 worker)", () => {
|
|
460
|
+
it("失败路径:discard + 重跑替换后,旧 dispatch 的失败 completion 不投新 worker、不复活 Map 条目", async () => {
|
|
461
|
+
const run = makeRealRun("wf-orphan-1");
|
|
462
|
+
const deps = makeDeps();
|
|
463
|
+
// rebuild 的新 worker——postMessage 记录用于断言「孤儿结果未投递」
|
|
464
|
+
const newWorkerPost = vi.fn();
|
|
465
|
+
deps.workerHost.start.mockImplementation(
|
|
466
|
+
() =>
|
|
467
|
+
({ postMessage: newWorkerPost, terminate: vi.fn(async () => {}) }) as unknown as WorkerHandle,
|
|
468
|
+
);
|
|
469
|
+
const handlers = makeHandlers();
|
|
470
|
+
|
|
471
|
+
// 旧 runtime 代际:dispatch callId=1,runner 挂起(模拟在飞子进程)
|
|
472
|
+
const deferredA = createDeferred<AgentResult>();
|
|
473
|
+
deps.runner.run.mockImplementation(() => deferredA.promise);
|
|
474
|
+
await handleWorkerMessage(run, makeAgentCallMsg(1), deps, handlers);
|
|
475
|
+
await flushMicrotasks();
|
|
476
|
+
expect(run.state.calls.get(1)?.status).toBe("running");
|
|
477
|
+
|
|
478
|
+
// worker 崩溃 → rebuildRuntime:replaceRuntime(abort 旧 controller)+ 同步 discard 在飞 call
|
|
479
|
+
rebuildRuntime(run, deps, handlers);
|
|
480
|
+
expect(run.state.calls.has(1)).toBe(false);
|
|
481
|
+
expect(run.state.trace.find(1)).toBeUndefined();
|
|
482
|
+
|
|
483
|
+
// 新 worker 重跑脚本:同 callId=1 再 dispatch(重跑实例 B 挂起在飞)
|
|
484
|
+
const deferredB = createDeferred<AgentResult>();
|
|
485
|
+
deps.runner.run.mockImplementation(() => deferredB.promise);
|
|
486
|
+
await handleWorkerMessage(run, makeAgentCallMsg(1), deps, handlers);
|
|
487
|
+
await flushMicrotasks();
|
|
488
|
+
const rerunCall = run.state.calls.get(1);
|
|
489
|
+
expect(rerunCall).toBeDefined();
|
|
490
|
+
|
|
491
|
+
// 旧 dispatch 的 promise 以失败 resolve(abort 收割子进程后的迟到 finalize,
|
|
492
|
+
// error 含 "aborted" 命中 stale-context 快速路径——真实形态)
|
|
493
|
+
deferredA.resolve({ content: "", error: "Subprocess aborted by runtime shutdown" });
|
|
494
|
+
await flushMicrotasks();
|
|
495
|
+
|
|
496
|
+
// 孤儿失败结果不得投给新 worker(否则劫持重跑 pending 为空串假成功)
|
|
497
|
+
expect(findAgentResultPost(newWorkerPost, 1)).toBeUndefined();
|
|
498
|
+
// calls Map 无孤儿复活条目:callId=1 仍是重跑实例 B
|
|
499
|
+
expect(run.state.calls.get(1)).toBe(rerunCall);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("成功路径:discard 后旧 dispatch 的成功 completion 不投新 worker、不复活 Map/trace 条目", async () => {
|
|
503
|
+
const run = makeRealRun("wf-orphan-2");
|
|
504
|
+
const deps = makeDeps();
|
|
505
|
+
const newWorkerPost = vi.fn();
|
|
506
|
+
deps.workerHost.start.mockImplementation(
|
|
507
|
+
() =>
|
|
508
|
+
({ postMessage: newWorkerPost, terminate: vi.fn(async () => {}) }) as unknown as WorkerHandle,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const deferred = createDeferred<AgentResult>();
|
|
512
|
+
deps.runner.run.mockImplementation(() => deferred.promise);
|
|
513
|
+
await handleWorkerMessage(run, makeAgentCallMsg(2), deps, makeHandlers());
|
|
514
|
+
await flushMicrotasks();
|
|
515
|
+
|
|
516
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
517
|
+
expect(run.state.calls.has(2)).toBe(false);
|
|
518
|
+
|
|
519
|
+
// 旧 promise 以成功 resolve(runner 已完成、结果晚于 rebuild 到达)
|
|
520
|
+
deferred.resolve({ content: "late success", durationMs: 5, error: undefined, toolCalls: [] });
|
|
521
|
+
await flushMicrotasks();
|
|
522
|
+
|
|
523
|
+
expect(findAgentResultPost(newWorkerPost, 2)).toBeUndefined();
|
|
524
|
+
expect(run.state.calls.has(2)).toBe(false);
|
|
525
|
+
expect(run.state.trace.find(2)).toBeUndefined();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("catch 路径:discard 后旧 dispatch 的异常 reject 不投新 worker、不复活 Map 条目", async () => {
|
|
529
|
+
// catch 块的 logger.error 会打印——静默防噪音
|
|
530
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
531
|
+
try {
|
|
532
|
+
const run = makeRealRun("wf-orphan-3");
|
|
533
|
+
const deps = makeDeps();
|
|
534
|
+
const newWorkerPost = vi.fn();
|
|
535
|
+
deps.workerHost.start.mockImplementation(
|
|
536
|
+
() =>
|
|
537
|
+
({ postMessage: newWorkerPost, terminate: vi.fn(async () => {}) }) as unknown as WorkerHandle,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const deferred = createDeferred<AgentResult>();
|
|
541
|
+
deps.runner.run.mockImplementation(() => deferred.promise);
|
|
542
|
+
await handleWorkerMessage(run, makeAgentCallMsg(3), deps, makeHandlers());
|
|
543
|
+
await flushMicrotasks();
|
|
544
|
+
|
|
545
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
546
|
+
expect(run.state.calls.has(3)).toBe(false);
|
|
547
|
+
|
|
548
|
+
deferred.reject(new Error("runner exploded"));
|
|
549
|
+
await flushMicrotasks();
|
|
550
|
+
|
|
551
|
+
expect(findAgentResultPost(newWorkerPost, 3)).toBeUndefined();
|
|
552
|
+
expect(run.state.calls.has(3)).toBe(false);
|
|
553
|
+
} finally {
|
|
554
|
+
spy.mockRestore();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("非孤儿正常路径:成功 completion 照常投递 agent-result(守卫不误伤)", async () => {
|
|
559
|
+
const run = makeRealRun("wf-orphan-4");
|
|
560
|
+
const deps = makeDeps();
|
|
561
|
+
const postMessage = run.runtime!.worker.postMessage as ReturnType<typeof vi.fn>;
|
|
562
|
+
deps.runner.run.mockImplementation(
|
|
563
|
+
async () => ({ content: "real result", durationMs: 1, error: undefined, toolCalls: [] }) as AgentResult,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
await handleWorkerMessage(run, makeAgentCallMsg(4), deps, makeHandlers());
|
|
567
|
+
await flushMicrotasks(20);
|
|
568
|
+
|
|
569
|
+
const posted = findAgentResultPost(postMessage, 4);
|
|
570
|
+
expect(posted).toBeDefined();
|
|
571
|
+
expect(posted?.result.content).toBe("real result");
|
|
572
|
+
expect(run.state.calls.get(4)?.status).toBe("done");
|
|
573
|
+
expect(run.state.trace.find(4)?.status).toBe("completed");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("U6/S3 场景重放:discard + 重跑替换后,旧 finalize 不污染重跑新 trace 节点(旧 call 实例仍 markDone)", async () => {
|
|
577
|
+
const run = makeRealRun("wf-orphan-s3");
|
|
578
|
+
const deps = makeDeps();
|
|
579
|
+
const newWorkerPost = vi.fn();
|
|
580
|
+
deps.workerHost.start.mockImplementation(
|
|
581
|
+
() => ({ postMessage: newWorkerPost, terminate: vi.fn(async () => {}) }) as unknown as WorkerHandle,
|
|
582
|
+
);
|
|
583
|
+
const handlers = makeHandlers();
|
|
584
|
+
|
|
585
|
+
// 旧代际 dispatch callId=9:runner 挂起(模拟在飞子进程)
|
|
586
|
+
const deferredA = createDeferred<AgentResult>();
|
|
587
|
+
deps.runner.run.mockImplementation(() => deferredA.promise);
|
|
588
|
+
await handleWorkerMessage(run, makeAgentCallMsg(9), deps, handlers);
|
|
589
|
+
await flushMicrotasks();
|
|
590
|
+
const oldCall = run.state.calls.get(9);
|
|
591
|
+
expect(oldCall?.status).toBe("running");
|
|
592
|
+
|
|
593
|
+
// rebuild:discard 移除旧 call 条目 + trace 节点(replaceRuntime 同步 abort 旧 signal)
|
|
594
|
+
rebuildRuntime(run, deps, handlers);
|
|
595
|
+
expect(run.state.calls.has(9)).toBe(false);
|
|
596
|
+
expect(run.state.trace.find(9)).toBeUndefined();
|
|
597
|
+
|
|
598
|
+
// 重跑 dispatch 同 callId=9:新实例 + 新 trace 节点 running,挂起在飞
|
|
599
|
+
const deferredB = createDeferred<AgentResult>();
|
|
600
|
+
deps.runner.run.mockImplementation(() => deferredB.promise);
|
|
601
|
+
await handleWorkerMessage(run, makeAgentCallMsg(9), deps, handlers);
|
|
602
|
+
await flushMicrotasks();
|
|
603
|
+
const rerunCall = run.state.calls.get(9);
|
|
604
|
+
expect(rerunCall).toBeDefined();
|
|
605
|
+
expect(rerunCall).not.toBe(oldCall);
|
|
606
|
+
expect(run.state.trace.find(9)?.status).toBe("running");
|
|
607
|
+
|
|
608
|
+
// 旧 runner promise 以非 stale 失败 resolve——rebuild 已 abort 旧 signal,旧
|
|
609
|
+
// executeAgentCall 醒来走 signal.aborted finalize 调用点(错误文案不含 stale
|
|
610
|
+
// 模式词,确保不进 stale 分支)。红性锚点:无 OB2 守卫时此处 trace.update(9)
|
|
611
|
+
// 命中重跑新节点 → 短暂污染为 failed。
|
|
612
|
+
deferredA.resolve({ content: "", durationMs: 3, error: "old generation failure", toolCalls: [] });
|
|
613
|
+
await flushMicrotasks();
|
|
614
|
+
|
|
615
|
+
// 新 trace 节点未被旧 finalize 污染:仍 running、无 result、无 completedAt
|
|
616
|
+
const newNode = run.state.trace.find(9);
|
|
617
|
+
expect(newNode?.status).toBe("running");
|
|
618
|
+
expect(newNode?.result).toBeUndefined();
|
|
619
|
+
expect(newNode?.completedAt).toBeUndefined();
|
|
620
|
+
// 旧实例 markDone 保留(dispatch 层 catch 路径依赖 call.status 语义)
|
|
621
|
+
expect(oldCall?.status).toBe("done");
|
|
622
|
+
expect(oldCall?.result?.error).toBe("old generation failure");
|
|
623
|
+
// 重跑实例仍在飞,未被旧 completion 干扰;孤儿结果不投新 worker
|
|
624
|
+
expect(rerunCall?.status).toBe("running");
|
|
625
|
+
expect(findAgentResultPost(newWorkerPost, 9)).toBeUndefined();
|
|
626
|
+
|
|
627
|
+
// 收尾:resolve 重跑 deferred,让挂起的 promise 链走完(非孤儿 → 正常完成路径)
|
|
628
|
+
deferredB.resolve({ content: "rerun ok", durationMs: 1, error: undefined, toolCalls: [] });
|
|
629
|
+
await flushMicrotasks();
|
|
630
|
+
expect(run.state.trace.find(9)?.status).toBe("completed");
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ── rebuildRuntime 可观察性(OB3 日志点 L1-L4) ─────────────
|
|
635
|
+
|
|
636
|
+
/** 从 deps.log 的 mock 调用记录提取 (message, payload)——message 非字符串时置空串(断言自然失败)。 */
|
|
637
|
+
function toLogEntries(calls: unknown[][]): Array<{ message: string; payload: unknown }> {
|
|
638
|
+
return calls.map((c) => ({
|
|
639
|
+
message: typeof c[2] === "string" ? c[2] : "",
|
|
640
|
+
payload: c[3],
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
describe("rebuildRuntime 可观察性(OB3 日志点)", () => {
|
|
645
|
+
it("U7: 无 budgetTimeMs 时按序打 L1 start → L4 complete(payload 含 runId),无 L2 重排日志", () => {
|
|
646
|
+
const run = makeRealRun("wf-rebuild-log-1");
|
|
647
|
+
const deps = makeDeps();
|
|
648
|
+
|
|
649
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
650
|
+
|
|
651
|
+
const entries = toLogEntries(deps.log.mock.calls);
|
|
652
|
+
const messages = entries.map((e) => e.message);
|
|
653
|
+
expect(messages).toContain("runtime rebuild start");
|
|
654
|
+
expect(messages).toContain("runtime rebuild complete");
|
|
655
|
+
// L4 在 L1 之后(顺序锚)
|
|
656
|
+
expect(messages.indexOf("runtime rebuild complete")).toBeGreaterThan(
|
|
657
|
+
messages.indexOf("runtime rebuild start"),
|
|
658
|
+
);
|
|
659
|
+
// 无 budgetTimeMs → 该分支本就跳过重排,L2 不打
|
|
660
|
+
expect(messages).not.toContain("time budget rescheduled");
|
|
661
|
+
// L1/L4 payload 含 runId
|
|
662
|
+
const l1 = entries.find((e) => e.message === "runtime rebuild start");
|
|
663
|
+
expect(l1?.payload).toMatchObject({ runId: "wf-rebuild-log-1" });
|
|
664
|
+
const l4 = entries.find((e) => e.message === "runtime rebuild complete");
|
|
665
|
+
expect(l4?.payload).toEqual({ runId: "wf-rebuild-log-1" });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("U7: 带 budgetTimeMs + scheduleTimeBudget 注入时含 L2(在 L1 之后、L3 之前,payload 含 budgetTimeMs)", () => {
|
|
669
|
+
const run = makeRealRun("wf-rebuild-log-2", { budgetTimeMs: 5000 });
|
|
670
|
+
const scheduleTimeBudget = vi.fn(() => undefined);
|
|
671
|
+
const deps = makeDeps({ scheduleTimeBudget });
|
|
672
|
+
|
|
673
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
674
|
+
|
|
675
|
+
const entries = toLogEntries(deps.log.mock.calls);
|
|
676
|
+
const messages = entries.map((e) => e.message);
|
|
677
|
+
expect(messages).toContain("time budget rescheduled");
|
|
678
|
+
expect(messages.indexOf("time budget rescheduled")).toBeGreaterThan(
|
|
679
|
+
messages.indexOf("runtime rebuild start"),
|
|
680
|
+
);
|
|
681
|
+
expect(messages.indexOf("time budget rescheduled")).toBeLessThan(
|
|
682
|
+
messages.indexOf("in-flight calls discarded"),
|
|
683
|
+
);
|
|
684
|
+
const l2 = entries.find((e) => e.message === "time budget rescheduled");
|
|
685
|
+
expect(l2?.payload).toEqual({ runId: "wf-rebuild-log-2", budgetTimeMs: 5000 });
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("U8: 含 2 个在飞 call 的 run 经 rebuild → L3 callIds 升序、count === 2,与实际被弃 callId 一致", async () => {
|
|
689
|
+
const run = makeRealRun("wf-rebuild-log-3");
|
|
690
|
+
const deps = makeDeps();
|
|
691
|
+
const handlers = makeHandlers();
|
|
692
|
+
|
|
693
|
+
// 两个在飞 call(dispatch 插入序 5 → 3,验证 L3 callIds 是升序而非插入序)
|
|
694
|
+
const deferreds = [createDeferred<AgentResult>(), createDeferred<AgentResult>()];
|
|
695
|
+
let next = 0;
|
|
696
|
+
deps.runner.run.mockImplementation(() => {
|
|
697
|
+
const d = deferreds[next];
|
|
698
|
+
next += 1;
|
|
699
|
+
return d!.promise;
|
|
700
|
+
});
|
|
701
|
+
await handleWorkerMessage(run, makeAgentCallMsg(5), deps, handlers);
|
|
702
|
+
await handleWorkerMessage(run, makeAgentCallMsg(3), deps, handlers);
|
|
703
|
+
await flushMicrotasks();
|
|
704
|
+
expect(run.state.calls.get(5)?.status).toBe("running");
|
|
705
|
+
expect(run.state.calls.get(3)?.status).toBe("running");
|
|
706
|
+
|
|
707
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
708
|
+
|
|
709
|
+
// L3 payload:callIds 升序 [3, 5]、count === 2(即 discardInFlightCalls 返回值)
|
|
710
|
+
const entries = toLogEntries(deps.log.mock.calls);
|
|
711
|
+
const l3 = entries.find((e) => e.message === "in-flight calls discarded");
|
|
712
|
+
expect(l3?.payload).toEqual({ runId: "wf-rebuild-log-3", callIds: [3, 5], count: 2 });
|
|
713
|
+
// 返回值(经 L3 暴露)与实际被弃 callId 一致:Map/trace 条目均已移除
|
|
714
|
+
expect(run.state.calls.has(5)).toBe(false);
|
|
715
|
+
expect(run.state.calls.has(3)).toBe(false);
|
|
716
|
+
expect(run.state.trace.find(5)).toBeUndefined();
|
|
717
|
+
expect(run.state.trace.find(3)).toBeUndefined();
|
|
718
|
+
|
|
719
|
+
// 收尾:resolve 两个挂起的 deferred(孤儿守卫 drop,无投递无污染)
|
|
720
|
+
deferreds[0]!.resolve({ content: "", durationMs: 1, error: undefined, toolCalls: [] });
|
|
721
|
+
deferreds[1]!.resolve({ content: "", durationMs: 1, error: undefined, toolCalls: [] });
|
|
722
|
+
await flushMicrotasks();
|
|
723
|
+
});
|
|
334
724
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - 成功时 postMessage(workflow-result, result)
|
|
7
7
|
* - onWorkflowCall reject 时 postMessage 含 error
|
|
8
8
|
* - onWorkflowCall 未注入时 postMessage 含 error(向后兼容)
|
|
9
|
-
* - stale 完成守卫(resolve 前 run
|
|
9
|
+
* - stale 完成守卫(resolve 前 run 已终态 → 不 postMessage)
|
|
10
10
|
*/
|
|
11
11
|
import { describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
@@ -138,7 +138,7 @@ describe("dispatchWorkflowCall (workflow-call routing)", () => {
|
|
|
138
138
|
expect(sent.result.error).toContain("onWorkflowCall not injected");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
it("does not post when run
|
|
141
|
+
it("does not post when run reaches terminal state before result arrives", async () => {
|
|
142
142
|
const postMessage = vi.fn();
|
|
143
143
|
let resolveWorkflow: (value: unknown) => void = () => {};
|
|
144
144
|
const workflowPromise = new Promise<unknown>((r) => {
|
|
@@ -156,8 +156,8 @@ describe("dispatchWorkflowCall (workflow-call routing)", () => {
|
|
|
156
156
|
);
|
|
157
157
|
|
|
158
158
|
// dispatchWorkflowCall 已触发,onWorkflowCall pending。
|
|
159
|
-
// 在 resolve
|
|
160
|
-
run.state.status = "
|
|
159
|
+
// 在 resolve 前终止 run(转终态 done)—— stale 完成守卫应阻止 postMessage。
|
|
160
|
+
run.state.status = "done";
|
|
161
161
|
resolveWorkflow({ content: "late" });
|
|
162
162
|
await flushMicrotasks();
|
|
163
163
|
|
|
@@ -169,3 +169,98 @@ describe("U3: executeAgentCall retry 透传 stream", () => {
|
|
|
169
169
|
vi.useRealTimers();
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
// ── isOrphaned 守卫(OB2:S7 残留瞬时污染根除) ──
|
|
174
|
+
//
|
|
175
|
+
// 谓词为 true 时 finalizeCall 跳过 trace.update,但 markDone 与
|
|
176
|
+
// sessionId/sessionFile 同步保留。U4 锁定不传谓词时的现状回归。
|
|
177
|
+
|
|
178
|
+
describe("isOrphaned 守卫", () => {
|
|
179
|
+
it("U1: 谓词 () => true + 终态成功路径 → trace.update 0 次,markDone 保留(status done + result 已设置)", async () => {
|
|
180
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
181
|
+
const updateSpy = vi.spyOn(trace, "update");
|
|
182
|
+
const runner = createMockRunner(); // 默认成功 result
|
|
183
|
+
const budget = new Budget();
|
|
184
|
+
|
|
185
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace, undefined, undefined, () => true);
|
|
186
|
+
|
|
187
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
188
|
+
expect(call.status).toBe("done");
|
|
189
|
+
expect(call.result).toBeDefined();
|
|
190
|
+
expect(call.result?.content).toBe("OK");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("U2: 谓词 true + stale-context 失败路径 → trace.update 0 次,markDone 保留(覆盖 stale finalize 调用点)", async () => {
|
|
194
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
195
|
+
const updateSpy = vi.spyOn(trace, "update");
|
|
196
|
+
const runner = createMockRunner(
|
|
197
|
+
vi.fn().mockResolvedValue(makeMockResult({ error: "context canceled" })),
|
|
198
|
+
);
|
|
199
|
+
const budget = new Budget();
|
|
200
|
+
|
|
201
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace, undefined, undefined, () => true);
|
|
202
|
+
|
|
203
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
204
|
+
expect(call.status).toBe("done");
|
|
205
|
+
expect(call.result?.error).toBe("context canceled");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("U3: 谓词 true + 信号 abort 路径 → trace.update 0 次,markDone 保留(覆盖 abort finalize 调用点)", async () => {
|
|
209
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
210
|
+
const updateSpy = vi.spyOn(trace, "update");
|
|
211
|
+
// 错误文案不含 stale 模式词,确保走 signal.aborted 分支而非 stale 分支
|
|
212
|
+
const runner = createMockRunner(
|
|
213
|
+
vi.fn().mockResolvedValue(makeMockResult({ error: "old generation failure" })),
|
|
214
|
+
);
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
controller.abort();
|
|
217
|
+
const budget = new Budget();
|
|
218
|
+
|
|
219
|
+
await executeAgentCall(call, runner, budget, controller.signal, trace, undefined, undefined, () => true);
|
|
220
|
+
|
|
221
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
222
|
+
expect(call.status).toBe("done");
|
|
223
|
+
expect(call.result?.error).toBe("old generation failure");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("U4: 谓词 undefined(不传)→ trace.update 恰被调用 1 次(现状回归锁定)", async () => {
|
|
227
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
228
|
+
const updateSpy = vi.spyOn(trace, "update");
|
|
229
|
+
const runner = createMockRunner();
|
|
230
|
+
const budget = new Budget();
|
|
231
|
+
|
|
232
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
233
|
+
|
|
234
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(trace.find(0)?.status).toBe("completed");
|
|
236
|
+
expect(call.status).toBe("done");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("U5: 递归重试透传——可重试失败后成功,谓词 true → 重试后终态 trace.update 仍 0 次", async () => {
|
|
240
|
+
vi.useFakeTimers();
|
|
241
|
+
try {
|
|
242
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
243
|
+
const updateSpy = vi.spyOn(trace, "update");
|
|
244
|
+
const runner = createMockRunner(
|
|
245
|
+
vi.fn()
|
|
246
|
+
.mockResolvedValueOnce(makeMockResult({ error: "transient error" }))
|
|
247
|
+
.mockResolvedValueOnce(makeMockResult()),
|
|
248
|
+
);
|
|
249
|
+
const budget = new Budget();
|
|
250
|
+
|
|
251
|
+
const promise = executeAgentCall(call, runner, budget, new AbortController().signal, trace, undefined, undefined, () => true);
|
|
252
|
+
// 推进首退避 1s(BACKOFF_BASE_MS = 1000)
|
|
253
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
254
|
+
await promise;
|
|
255
|
+
|
|
256
|
+
expect(runner.run).toHaveBeenCalledTimes(2);
|
|
257
|
+
expect(call.attempts).toBe(2);
|
|
258
|
+
// 谓词穿透递归层:重试后终态也不写 trace
|
|
259
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
260
|
+
expect(call.status).toBe("done");
|
|
261
|
+
expect(call.result?.error).toBeUndefined();
|
|
262
|
+
} finally {
|
|
263
|
+
vi.useRealTimers();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|