@zhushanwen/pi-subagent-workflow 8.4.0 → 8.6.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/package.json +22 -7
- package/relay/relay.mjs +390 -0
- package/skills/subagent-ext-config/SKILL.md +80 -0
- package/src/execution/__tests__/agent-registry.test.ts +110 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
- package/src/execution/__tests__/delivery-methods.test.ts +38 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
- package/src/execution/__tests__/execution-record.test.ts +237 -1
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
- package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
- package/src/execution/__tests__/index-session-start.test.ts +86 -7
- package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
- package/src/execution/__tests__/list-fields.test.ts +45 -14
- package/src/execution/__tests__/model-resolver.test.ts +57 -5
- package/src/execution/__tests__/notifier-flush.test.ts +64 -26
- package/src/execution/__tests__/notify-ledger.test.ts +826 -0
- package/src/execution/__tests__/output-collector.test.ts +299 -2
- package/src/execution/__tests__/pi-invocation.test.ts +62 -1
- package/src/execution/__tests__/relay-agent.test.ts +448 -0
- package/src/execution/__tests__/relay-env.test.ts +42 -0
- package/src/execution/__tests__/rpc-mode.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
- package/src/execution/__tests__/spawn-args.test.ts +37 -26
- package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
- package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
- package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
- package/src/execution/__tests__/timeout-integration.test.ts +220 -2
- package/src/execution/__tests__/tool-action.test.ts +92 -1
- package/src/execution/agent-registry.ts +16 -0
- package/src/execution/argv-mirror.ts +5 -1
- package/src/execution/concurrency-pool.ts +1 -1
- package/src/execution/config.ts +25 -2
- package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
- package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
- package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
- package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
- package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
- package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
- package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
- package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
- package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
- package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
- package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
- package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
- package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
- package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
- package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
- package/src/execution/engine/__tests__/paths.test.ts +39 -0
- package/src/execution/engine/__tests__/registry.test.ts +120 -0
- package/src/execution/engine/__tests__/routing.test.ts +231 -0
- package/src/execution/engine/common/data-dir.ts +62 -0
- package/src/execution/engine/common/errors.ts +183 -0
- package/src/execution/engine/common/event-journal.ts +254 -0
- package/src/execution/engine/common/journal-replay.ts +62 -0
- package/src/execution/engine/common/kill-chain.ts +221 -0
- package/src/execution/engine/common/nesting-guard.ts +50 -0
- package/src/execution/engine/common/persona-router.ts +108 -0
- package/src/execution/engine/common/pool-manager.ts +226 -0
- package/src/execution/engine/common/schema-emulation.ts +189 -0
- package/src/execution/engine/common/session-view-projection.ts +51 -0
- package/src/execution/engine/engine-discovery.ts +65 -0
- package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
- package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
- package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
- package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
- package/src/execution/engine/engines/pi/reader.ts +48 -0
- package/src/execution/engine/engines/pi/registration.ts +35 -0
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
- package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
- package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
- package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
- package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
- package/src/execution/engine/engines/zcode/constants.ts +43 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
- package/src/execution/engine/engines/zcode/launcher.ts +161 -0
- package/src/execution/engine/engines/zcode/parser.ts +436 -0
- package/src/execution/engine/engines/zcode/preparer.ts +363 -0
- package/src/execution/engine/engines/zcode/reader.ts +381 -0
- package/src/execution/engine/engines/zcode/registration.ts +37 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
- package/src/execution/engine/host-task-spec.ts +47 -0
- package/src/execution/engine/model-prompt.ts +59 -0
- package/src/execution/engine/paths.ts +42 -0
- package/src/execution/engine/port.ts +153 -0
- package/src/execution/engine/registry.ts +123 -0
- package/src/execution/engine/routing.ts +218 -0
- package/src/execution/engine/types.ts +309 -0
- package/src/execution/execute-options-mapper.ts +13 -8
- package/src/execution/execution-record.ts +66 -1
- package/src/execution/lifecycle-manager.ts +23 -1
- package/src/execution/model-config-service.ts +16 -1
- package/src/execution/model-resolver.ts +37 -59
- package/src/execution/notifier.ts +105 -35
- package/src/execution/notify-ledger.ts +580 -0
- package/src/execution/output-collector.ts +143 -3
- package/src/execution/pi-invocation.ts +32 -2
- package/src/execution/record-entry.ts +14 -0
- package/src/execution/record-store.ts +34 -0
- package/src/execution/relay-env.ts +37 -0
- package/src/execution/session-runner.ts +328 -71
- package/src/execution/stream-sink.ts +26 -0
- package/src/execution/subagent-service.ts +273 -13
- package/src/execution/subprocess-agent-runner.ts +210 -14
- package/src/execution/types.ts +124 -5
- package/src/execution/ui-request-queue.ts +14 -4
- package/src/index.ts +99 -1
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
- package/src/interface/bg-notify-render.ts +33 -12
- package/src/interface/helpers.ts +2 -2
- package/src/interface/subagent-actions.ts +29 -9
- package/src/interface/subagent-tool-schema.ts +156 -0
- package/src/interface/subagent-tool.ts +56 -119
- package/src/interface/subagents.ts +2 -2
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
- package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
- package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
- package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
- package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
- package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
- package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
- package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +22 -3
- package/src/orchestration/agent-opts-resolver.ts +104 -23
- package/src/orchestration/error-recovery.ts +189 -33
- package/src/orchestration/execute-agent-call.ts +39 -0
- package/src/orchestration/jsonl-run-store.ts +121 -7
- package/src/orchestration/launcher.ts +60 -15
- package/src/orchestration/lifecycle.ts +10 -7
- package/src/orchestration/models/__tests__/budget.test.ts +1 -61
- package/src/orchestration/models/budget.ts +5 -35
- package/src/orchestration/models/run-runtime.ts +24 -9
- package/src/orchestration/models/types.ts +16 -0
- package/src/orchestration/script-lint.ts +1 -1
- package/src/orchestration/skill-discovery.ts +31 -8
- package/src/orchestration/worker-script-builder.ts +19 -3
- package/src/shared/__tests__/model-ref.test.ts +306 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
- package/src/shared/__tests__/timer-delay.test.ts +61 -0
- package/src/shared/meta-parser.ts +5 -1
- package/src/shared/model-ref.ts +286 -0
- package/src/shared/resource-meta.ts +5 -0
- package/src/shared/schema-env.ts +44 -0
- package/src/shared/schema-jsonify.ts +6 -4
- package/src/shared/timer-delay.ts +54 -0
- package/workflows/review-fix-loop-utils.cjs +9 -7
- package/workflows/review-fix-loop.js +20 -12
- package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
- package/src/orchestration/concurrency-gate.ts +0 -69
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
18
|
|
|
19
|
-
import { ConcurrencyGate } from "../concurrency-gate.ts";
|
|
20
19
|
import {
|
|
21
20
|
handleScriptError,
|
|
22
21
|
handleWorkerError,
|
|
@@ -41,6 +40,8 @@ function makeRunningRun(opts: {
|
|
|
41
40
|
scriptErrorCount?: number;
|
|
42
41
|
budgetTimeMs?: number;
|
|
43
42
|
postMessage?: ReturnType<typeof vi.fn>;
|
|
43
|
+
/** [F1] 预置本 runtime 代际已收到终态消息(return/error)。 */
|
|
44
|
+
receivedTerminalMessage?: boolean;
|
|
44
45
|
} = {}): WorkflowRun {
|
|
45
46
|
return {
|
|
46
47
|
state: {
|
|
@@ -66,6 +67,7 @@ function makeRunningRun(opts: {
|
|
|
66
67
|
},
|
|
67
68
|
runtime: {
|
|
68
69
|
worker: { postMessage: opts.postMessage ?? vi.fn() },
|
|
70
|
+
receivedTerminalMessage: opts.receivedTerminalMessage,
|
|
69
71
|
},
|
|
70
72
|
// transition 副作用——run.state.status 由调用方通过 mock 控制后再次断言
|
|
71
73
|
transition(target: string, reason?: string): void {
|
|
@@ -125,8 +127,11 @@ afterEach(() => {
|
|
|
125
127
|
// ── handleWorkerExit ─────────────────────────────────────────
|
|
126
128
|
|
|
127
129
|
describe("handleWorkerExit", () => {
|
|
128
|
-
it("code=0
|
|
129
|
-
|
|
130
|
+
it("code=0 且已收到终态消息:no-op(不 transition、不 save)", async () => {
|
|
131
|
+
// [F1] 语义更新:exit(0) no-op 的前提是本代际已交付 return/error(正常收尾退出,
|
|
132
|
+
// 或 script-error 重试退避窗口)。无终态消息的 exit(0) 现转 done,failed,
|
|
133
|
+
// 见下方用例与 worker-exit-without-result.test.ts。
|
|
134
|
+
const run = makeRunningRun({ receivedTerminalMessage: true });
|
|
130
135
|
const deps = makeDeps();
|
|
131
136
|
const handle = makeHandle(true);
|
|
132
137
|
|
|
@@ -137,6 +142,20 @@ describe("handleWorkerExit", () => {
|
|
|
137
142
|
expect(deps.eventBus.emit).not.toHaveBeenCalled();
|
|
138
143
|
});
|
|
139
144
|
|
|
145
|
+
it("code=0 且无终态消息:[F1] 转 done,failed(不可克隆 return 被吞的悬挂防线)", async () => {
|
|
146
|
+
const run = makeRunningRun();
|
|
147
|
+
const deps = makeDeps();
|
|
148
|
+
const handle = makeHandle(true);
|
|
149
|
+
|
|
150
|
+
await handleWorkerExit(run, 0, handle, deps, makeHandlers());
|
|
151
|
+
|
|
152
|
+
expect(run.state.status).toBe("done");
|
|
153
|
+
expect(run.state.reason).toBe("failed");
|
|
154
|
+
expect(run.state.error).toContain("structured-cloneable");
|
|
155
|
+
expect(deps.store.save).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(deps.onRunDone).toHaveBeenCalledTimes(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
140
159
|
it("code!=0 异常退出:委托 handleWorkerError → 超 MAX 重试 → transition done,failed", async () => {
|
|
141
160
|
// workerErrorCount 已达 MAX=3 → handleWorkerError 内 count=4 > 3 → failed
|
|
142
161
|
const run = makeRunningRun({ workerErrorCount: 3 });
|
|
@@ -379,6 +398,72 @@ describe("rebuildRuntime", () => {
|
|
|
379
398
|
// rebuildRuntime → replaceRuntime → release 的 abort 旧 controller / terminate 旧
|
|
380
399
|
// worker / discardInFlightCalls 全链路需要真实聚合根行为才成立。
|
|
381
400
|
|
|
401
|
+
// ── race-F3:rebuild 时间预算折算(已耗墙钟不重置) ─────────────
|
|
402
|
+
//
|
|
403
|
+
// 旧实现 rebuildRuntime 重排计时器用满额 budgetTimeMs——每吃一次 worker/script
|
|
404
|
+
// 错误重试就重置一次预算,最坏 6 次重试放大 ~6× 墙钟。修复后:
|
|
405
|
+
// - 重排值 = max(0, 原预算 - 已耗墙钟)(从 run.meta.startedAt 推算)
|
|
406
|
+
// - 重试前已耗尽 → 不 rebuild,直接 done,time_limited 终态
|
|
407
|
+
//
|
|
408
|
+
// 确定性说明:本文件 beforeEach 已 useFakeTimers()(默认 fake Date)——Date.now
|
|
409
|
+
// 冻结,startedAt 倒拨值即精确已耗墙钟,断言可精确等值。
|
|
410
|
+
describe("race-F3: rebuild 时间预算折算", () => {
|
|
411
|
+
it("剩余 30% → 重排预算 = 30% 而非满额", () => {
|
|
412
|
+
const run = makeRunningRun({ budgetTimeMs: 5000 });
|
|
413
|
+
// 已耗 70%(3500ms)→ 剩余 1500ms;fake Date 冻结,elapsed 精确
|
|
414
|
+
run.meta.startedAt = new Date(Date.now() - 3500).toISOString();
|
|
415
|
+
const scheduleTimeBudget = vi.fn(() => undefined);
|
|
416
|
+
const deps = makeDeps({ scheduleTimeBudget });
|
|
417
|
+
|
|
418
|
+
rebuildRuntime(run, deps, makeHandlers());
|
|
419
|
+
|
|
420
|
+
expect(scheduleTimeBudget).toHaveBeenCalledTimes(1);
|
|
421
|
+
const args = scheduleTimeBudget.mock.calls[0]!;
|
|
422
|
+
expect(args[1]).toBe(1500);
|
|
423
|
+
// run 保持 running(正常 rebuild 路径不受影响)
|
|
424
|
+
expect(run.state.status).toBe("running");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("重试前预算已耗尽(已耗 > 预算)→ 不 rebuild,直接 done,time_limited", async () => {
|
|
428
|
+
const run = makeRunningRun({ budgetTimeMs: 5000 });
|
|
429
|
+
// 已耗 6000ms > 预算 5000ms(退避 advance 1000ms 后已耗 7000ms,仍耗尽)
|
|
430
|
+
run.meta.startedAt = new Date(Date.now() - 6000).toISOString();
|
|
431
|
+
const scheduleTimeBudget = vi.fn(() => undefined);
|
|
432
|
+
const deps = makeDeps({ scheduleTimeBudget });
|
|
433
|
+
|
|
434
|
+
const p = handleScriptError(run, "boom", [], deps, makeHandlers());
|
|
435
|
+
await vi.advanceTimersByTimeAsync(1000); // 推进退避(1s)
|
|
436
|
+
await p;
|
|
437
|
+
|
|
438
|
+
// 不 rebuild:不启新 worker、不重排计时器
|
|
439
|
+
expect(deps.workerHost.start).not.toHaveBeenCalled();
|
|
440
|
+
expect(scheduleTimeBudget).not.toHaveBeenCalled();
|
|
441
|
+
// 直接 time_limited 终态 + 持久化 + 注销通知 + onRunDone
|
|
442
|
+
expect(run.state.status).toBe("done");
|
|
443
|
+
expect(run.state.reason).toBe("time_limited");
|
|
444
|
+
expect(deps.store.save).toHaveBeenCalled();
|
|
445
|
+
expect(deps.eventBus.emit).toHaveBeenCalledWith(
|
|
446
|
+
"pending:unregister",
|
|
447
|
+
expect.objectContaining({ reason: "time_limited" }),
|
|
448
|
+
);
|
|
449
|
+
expect(deps.onRunDone).toHaveBeenCalled();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("预算未耗尽(fresh run)→ 照常 rebuild,不误转 time_limited(防误伤回归)", async () => {
|
|
453
|
+
const run = makeRunningRun({ budgetTimeMs: 5000 }); // startedAt ≈ now,remaining 满
|
|
454
|
+
const scheduleTimeBudget = vi.fn(() => undefined);
|
|
455
|
+
const deps = makeDeps({ scheduleTimeBudget });
|
|
456
|
+
|
|
457
|
+
const p = handleScriptError(run, "boom", [], deps, makeHandlers());
|
|
458
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
459
|
+
await p;
|
|
460
|
+
|
|
461
|
+
expect(deps.workerHost.start).toHaveBeenCalledTimes(1);
|
|
462
|
+
expect(scheduleTimeBudget).toHaveBeenCalledTimes(1);
|
|
463
|
+
expect(run.state.status).toBe("running");
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
382
467
|
/** 手动控制的 deferred——精确编排「dispatch 挂起 → rebuild → 旧 promise settle」交错。 */
|
|
383
468
|
interface Deferred<T> {
|
|
384
469
|
promise: Promise<T>;
|
|
@@ -429,7 +514,7 @@ function makeRealRun(runId: string, opts: { budgetTimeMs?: number } = {}): Workf
|
|
|
429
514
|
postMessage: vi.fn(),
|
|
430
515
|
terminate: vi.fn(async () => {}),
|
|
431
516
|
} as unknown as WorkerHandle;
|
|
432
|
-
run.assignRuntime(new RunRuntime(initialWorker, new
|
|
517
|
+
run.assignRuntime(new RunRuntime(initialWorker, new AbortController()));
|
|
433
518
|
return run;
|
|
434
519
|
}
|
|
435
520
|
|
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
DETERMINISTIC_SCHEMA_FAILURE_PREFIX,
|
|
9
10
|
executeAgentCall,
|
|
11
|
+
isDeterministicSchemaFailureMsg,
|
|
10
12
|
isStaleContextErrorMsg,
|
|
11
13
|
STALE_CONTEXT_PATTERNS,
|
|
12
14
|
} from "../execute-agent-call.ts";
|
|
13
15
|
import { AgentCall } from "../models/agent-call.ts";
|
|
14
16
|
import { Budget } from "../models/budget.ts";
|
|
15
17
|
import type { AgentRunner } from "../models/ports.ts";
|
|
18
|
+
import { describeMissingParsedOutput } from "../../execution/output-collector.ts";
|
|
19
|
+
import type { ToolCall } from "../../execution/types.ts";
|
|
16
20
|
import { Trace } from "../models/trace.ts";
|
|
17
21
|
import type { ExecutionTraceNode } from "../models/types.ts";
|
|
18
22
|
import type { AgentCallOpts, AgentResult } from "../models/types.ts";
|
|
@@ -312,3 +316,136 @@ describe("W4b: stale 分诊对齐 pi 真实文案", () => {
|
|
|
312
316
|
expect(isStaleContextErrorMsg(undefined)).toBe(false);
|
|
313
317
|
});
|
|
314
318
|
});
|
|
319
|
+
|
|
320
|
+
// ── MF-1: 确定性 schema 失败不重试 ──
|
|
321
|
+
//
|
|
322
|
+
// 回归背景(第五轮实测):gate 终止子进程后 collectResult F-1 置归因 error →
|
|
323
|
+
// executeAgentCall 判非 stale 走可重试分支 → 不可满足 schema 下循环至
|
|
324
|
+
// MAX_ATTEMPTS=3(实测 attempts=3、4 子进程、235s vs 修复前 67.3s)。
|
|
325
|
+
// 修复:归因 error 带机器标记(SSOT = DETERMINISTIC_SCHEMA_FAILURE_PREFIX),
|
|
326
|
+
// 分诊短路。用真实链路产物(describeMissingParsedOutput)作 error 输入,锁定
|
|
327
|
+
// collectResult → executeAgentCall 全链路分诊行为。
|
|
328
|
+
//
|
|
329
|
+
// 三态可重试性矩阵(完整锁定在 output-collector.test 的 MF-1 describe):
|
|
330
|
+
// 态① 从未调用 SO → 不可重试;态② isError(gate 终止/不可满足 schema)→ 不可重试;
|
|
331
|
+
// 态③ 调用过但无 details → 可重试(保留既有重试语义)。
|
|
332
|
+
|
|
333
|
+
/** 构造态②真实产物(isError SO 调用,schema 校验失败——gate 终止回归场景)。 */
|
|
334
|
+
function state2Attribution(): string {
|
|
335
|
+
const soCalls: ToolCall[] = [
|
|
336
|
+
{
|
|
337
|
+
toolName: "structured-output",
|
|
338
|
+
isError: true,
|
|
339
|
+
result: { details: {}, content: [{ type: "text", text: "Schema validation failed: /target is required" }] },
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
const msg = describeMissingParsedOutput(soCalls);
|
|
343
|
+
expect(msg).toBeDefined();
|
|
344
|
+
return msg!;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
describe("MF-1: 确定性 schema 失败不重试", () => {
|
|
348
|
+
it("态②真实产物(gate 终止/schema 校验失败归因)→ runner.run 恰 1 次 + 终态 failed", async () => {
|
|
349
|
+
const attribution = state2Attribution();
|
|
350
|
+
expect(isDeterministicSchemaFailureMsg(attribution)).toBe(true);
|
|
351
|
+
|
|
352
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
353
|
+
const runner = createMockRunner(
|
|
354
|
+
vi.fn().mockResolvedValue(makeMockResult({ error: attribution })),
|
|
355
|
+
);
|
|
356
|
+
const budget = new Budget();
|
|
357
|
+
|
|
358
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
359
|
+
|
|
360
|
+
// 修复前:循环至 MAX_ATTEMPTS=3(4 次子进程);修复后:恰 1 次
|
|
361
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
362
|
+
expect(call.attempts).toBe(1);
|
|
363
|
+
expect(call.status).toBe("done");
|
|
364
|
+
expect(trace.find(0)?.status).toBe("failed");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("态①真实产物(never called,缺 extension 环境确定性)→ 同样不重试", async () => {
|
|
368
|
+
const attribution = describeMissingParsedOutput([])!;
|
|
369
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
370
|
+
const runner = createMockRunner(
|
|
371
|
+
vi.fn().mockResolvedValue(makeMockResult({ error: attribution })),
|
|
372
|
+
);
|
|
373
|
+
const budget = new Budget();
|
|
374
|
+
|
|
375
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
376
|
+
|
|
377
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
378
|
+
expect(call.attempts).toBe(1);
|
|
379
|
+
expect(trace.find(0)?.status).toBe("failed");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("MF-2 计数不再低估:retry 不发生 → usage consume 恰一次、totalCallCount=1", async () => {
|
|
383
|
+
const attribution = state2Attribution();
|
|
384
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
385
|
+
const runner = createMockRunner(
|
|
386
|
+
vi.fn().mockResolvedValue(
|
|
387
|
+
makeMockResult({ error: attribution, usage: { input: 100, output: 50, cost: 0.01 } }),
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
const budget = new Budget();
|
|
391
|
+
|
|
392
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
393
|
+
|
|
394
|
+
// 修复前:3 attempts → consume×3(usedTokens×3);修复后:恰一次加权消耗
|
|
395
|
+
// (input×1 + output×2 = 100 + 100 = 200,权重见 budget.ts)
|
|
396
|
+
expect(budget.usedTokens).toBe(200);
|
|
397
|
+
expect(budget.usedCost).toBeCloseTo(0.01);
|
|
398
|
+
expect(budget.totalCallCount).toBe(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("MF-3 归因不再混合:终态 error 保持归因原文(不被重试轮次覆盖)", async () => {
|
|
402
|
+
const attribution = state2Attribution();
|
|
403
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
404
|
+
const runner = createMockRunner(
|
|
405
|
+
vi.fn().mockResolvedValue(makeMockResult({ error: attribution })),
|
|
406
|
+
);
|
|
407
|
+
const budget = new Budget();
|
|
408
|
+
|
|
409
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
410
|
+
|
|
411
|
+
expect(call.result?.error).toBe(attribution);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("态③(no details,无标记)→ 照常重试(可重试语义保留)", async () => {
|
|
415
|
+
vi.useFakeTimers();
|
|
416
|
+
try {
|
|
417
|
+
// 态③真实文案(不带确定性标记)——矩阵执行面锁定:无标记 = 可重试
|
|
418
|
+
const msg = describeMissingParsedOutput([
|
|
419
|
+
{ toolName: "structured-output", result: { content: [] } },
|
|
420
|
+
])!;
|
|
421
|
+
expect(isDeterministicSchemaFailureMsg(msg)).toBe(false);
|
|
422
|
+
|
|
423
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
424
|
+
const runner = createMockRunner(
|
|
425
|
+
vi.fn()
|
|
426
|
+
.mockResolvedValueOnce(makeMockResult({ error: msg }))
|
|
427
|
+
.mockResolvedValueOnce(makeMockResult()),
|
|
428
|
+
);
|
|
429
|
+
const budget = new Budget();
|
|
430
|
+
|
|
431
|
+
const promise = executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
432
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
433
|
+
await promise;
|
|
434
|
+
|
|
435
|
+
expect(runner.run).toHaveBeenCalledTimes(2);
|
|
436
|
+
expect(call.status).toBe("done");
|
|
437
|
+
} finally {
|
|
438
|
+
vi.useRealTimers();
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("分诊交叉锁定:标记词不命中任何 STALE_CONTEXT_PATTERNS(stale 分诊在前也不误吞)", () => {
|
|
443
|
+
const lower = DETERMINISTIC_SCHEMA_FAILURE_PREFIX.toLowerCase();
|
|
444
|
+
for (const pattern of STALE_CONTEXT_PATTERNS) {
|
|
445
|
+
expect(lower.includes(pattern)).toBe(false);
|
|
446
|
+
}
|
|
447
|
+
// 两个分诊只命中确定性分支,互不污染
|
|
448
|
+
expect(isStaleContextErrorMsg(DETERMINISTIC_SCHEMA_FAILURE_PREFIX)).toBe(false);
|
|
449
|
+
expect(isDeterministicSchemaFailureMsg(DETERMINISTIC_SCHEMA_FAILURE_PREFIX)).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [SO-DATA-2] collectRecordRun 的 per-entry 隔离:1 条残缺 workflow-record entry
|
|
4
|
+
// 不得让整个 loadAll 返回空。
|
|
5
|
+
//
|
|
6
|
+
// 防的 bug:deserializeRun 在 v guard 之后直接读 snapshot.state.budget 等嵌套字段,
|
|
7
|
+
// 残缺 entry(截断/手改/跨版本半写)抛 TypeError 会沿 collectEntrySources 穿透
|
|
8
|
+
// loadAll 的 catch → 返回空——单条损坏让全部 run 不可见(读序优先 entry,state
|
|
9
|
+
// 文件兜底也不再走)。修复:collectRecordRun 单条 try/catch,损坏 entry 跳过 +
|
|
10
|
+
// logger.warn 留证(含 entry 索引与原因),其余 entry 正常重建。
|
|
11
|
+
//
|
|
12
|
+
// 好坏对照:2 条好 entry 经真实 save 通路产出(快照形态可信,与
|
|
13
|
+
// jsonl-run-store-loadall-sources.test.ts 同一搭建方式),1 条坏 entry 手工构造
|
|
14
|
+
//(模拟损坏正是本用例的目的——绕开 schema 契约即 corruption 的本质)。
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
19
|
+
loggerMock: {
|
|
20
|
+
debug: vi.fn(),
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
|
|
27
|
+
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as os from "node:os";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
|
|
32
|
+
import type { CustomEntry } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
|
|
34
|
+
import { Budget } from "../models/budget.ts";
|
|
35
|
+
import { Trace } from "../models/trace.ts";
|
|
36
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
37
|
+
import type { ExecutionTraceNode } from "../models/types.ts";
|
|
38
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
39
|
+
import { JsonlRunStore, SNAPSHOT_VERSION, WORKFLOW_RECORD_CUSTOM_TYPE } from "../jsonl-run-store.ts";
|
|
40
|
+
import { mkCtx, mkPi } from "./test-mocks.ts";
|
|
41
|
+
|
|
42
|
+
function makeSpec(): RunSpec {
|
|
43
|
+
return {
|
|
44
|
+
scriptSource: "module.exports = async () => {};",
|
|
45
|
+
args: {},
|
|
46
|
+
scriptName: "test-script",
|
|
47
|
+
scriptPath: "/tmp/test.js",
|
|
48
|
+
description: "test",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeTraceNode(stepIndex: number): ExecutionTraceNode {
|
|
53
|
+
return { stepIndex, agent: "worker", task: "do thing", model: "default", status: "pending" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeRunningRun(runId: string): WorkflowRun {
|
|
57
|
+
const trace = new Trace();
|
|
58
|
+
trace.append(makeTraceNode(0));
|
|
59
|
+
return WorkflowRun.reconstruct(runId, makeSpec(), {
|
|
60
|
+
status: "running",
|
|
61
|
+
budget: new Budget(),
|
|
62
|
+
calls: new Map(),
|
|
63
|
+
trace,
|
|
64
|
+
errorLogs: [],
|
|
65
|
+
}, { startedAt: new Date().toISOString() });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** running → done 的完整生命周期 save(终态快照可被 deserializeRun 重建)。 */
|
|
69
|
+
async function saveDoneRun(
|
|
70
|
+
sessionDir: string,
|
|
71
|
+
entries: CustomEntry[],
|
|
72
|
+
runId: string,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const store = new JsonlRunStore({ sessionDir, pi: mkPi(entries) });
|
|
75
|
+
const run = makeRunningRun(runId);
|
|
76
|
+
await store.save(run);
|
|
77
|
+
run.transition("done", "completed");
|
|
78
|
+
await store.save(run);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 手工构造残缺 record entry:v1 guard 通过,但 snapshot.state 缺全部必读嵌套字段
|
|
82
|
+
* → deserializeRun 读 snapshot.state.budget.maxTokens 抛 TypeError。 */
|
|
83
|
+
function corruptRecordEntry(runId: string): CustomEntry {
|
|
84
|
+
return {
|
|
85
|
+
type: "custom",
|
|
86
|
+
customType: WORKFLOW_RECORD_CUSTOM_TYPE,
|
|
87
|
+
data: {
|
|
88
|
+
v: 1,
|
|
89
|
+
snapshot: { v: SNAPSHOT_VERSION, runId, state: {} },
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
},
|
|
92
|
+
id: `seed-corrupt-${runId}`,
|
|
93
|
+
parentId: null,
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("SO-DATA-2: 残缺 record entry 的 per-entry 隔离", () => {
|
|
99
|
+
let tmpDir: string;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-corrupt-entry-"));
|
|
103
|
+
loggerMock.warn.mockClear();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("3 条 entry(2 好 1 坏)→ 返回 2 条 + warn 留证(含 entry 索引与原因)", async () => {
|
|
111
|
+
const entries: CustomEntry[] = [];
|
|
112
|
+
await saveDoneRun(tmpDir, entries, "run-a");
|
|
113
|
+
await saveDoneRun(tmpDir, entries, "run-b");
|
|
114
|
+
const corrupt = corruptRecordEntry("run-corrupt");
|
|
115
|
+
const seedEntries: CustomEntry[] = [...entries, corrupt];
|
|
116
|
+
|
|
117
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(seedEntries) });
|
|
118
|
+
const loaded = await store.loadAll();
|
|
119
|
+
|
|
120
|
+
// 损坏 entry 只跳过自身,其余 run 全部可见
|
|
121
|
+
expect(loaded.map((r) => r.runId).sort()).toEqual(["run-a", "run-b"]);
|
|
122
|
+
// warn 留证:entry 索引 + 损坏原因
|
|
123
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
124
|
+
const warnMsg = String(loggerMock.warn.mock.calls[0]?.[0] ?? "");
|
|
125
|
+
expect(warnMsg).toContain(`entry #${seedEntries.indexOf(corrupt)}`);
|
|
126
|
+
expect(warnMsg).toContain("corrupted");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("全部 entry 均损坏 → 返回空(不再抛 TypeError 穿透)+ 每条各留一条 warn", async () => {
|
|
130
|
+
const seedEntries: CustomEntry[] = [
|
|
131
|
+
corruptRecordEntry("run-corrupt-1"),
|
|
132
|
+
corruptRecordEntry("run-corrupt-2"),
|
|
133
|
+
];
|
|
134
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(seedEntries) });
|
|
135
|
+
const loaded = await store.loadAll();
|
|
136
|
+
expect(loaded).toEqual([]);
|
|
137
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("损坏 entry 位于同 runId 好 entry 之前 → 后写覆盖语义不受影响(好快照胜出)", async () => {
|
|
141
|
+
const corrupt = corruptRecordEntry("run-x");
|
|
142
|
+
const entries: CustomEntry[] = [corrupt];
|
|
143
|
+
await saveDoneRun(tmpDir, entries, "run-x");
|
|
144
|
+
|
|
145
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
|
|
146
|
+
const loaded = await store.loadAll();
|
|
147
|
+
expect(loaded.map((r) => r.runId)).toEqual(["run-x"]);
|
|
148
|
+
expect(loaded[0]?.state.status).toBe("done");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// src/orchestration/__tests__/jsonl-run-store-retention.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [B1] workflow-state 磁盘保留清理(opt-in,默认关)。
|
|
4
|
+
//
|
|
5
|
+
// 锁定的语义:
|
|
6
|
+
// - 默认关:XYZ_SUBAGENT_STATE_MAX_RUNS 未设/空/0/负数/非数值 → 永不删除
|
|
7
|
+
// (「limits 默认关」裁决基线,watchdog 风格解析);
|
|
8
|
+
// - opt-in:每次新 run state 文件首写成功后,目录内 wf-*.jsonl 按 mtime 升序
|
|
9
|
+
// 裁剪到上限(删最旧);上限 3 写 5 个 → 剩最新 3;
|
|
10
|
+
// - glob 外文件(非 wf- 前缀 / 非 .jsonl)与父目录 session JSONL 永不误删;
|
|
11
|
+
// - 单个删除失败(unlink 目录 → EPERM,非 ENOENT)logger.warn 留证不抛,
|
|
12
|
+
// save 主链路不受影响。
|
|
13
|
+
//
|
|
14
|
+
// mtime 确定性:每个文件落盘后立即 utimesSync 钉死 mtime(基线 + i 分钟,全部
|
|
15
|
+
// 过去时),消除同毫秒写入的排序抖动——被删集合 = mtime 最旧的 (N - cap) 个,
|
|
16
|
+
// 断言精确到文件名。
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
21
|
+
loggerMock: {
|
|
22
|
+
debug: vi.fn(),
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
error: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
|
|
29
|
+
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as os from "node:os";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
|
|
34
|
+
import { Budget } from "../models/budget.ts";
|
|
35
|
+
import { Trace } from "../models/trace.ts";
|
|
36
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
37
|
+
import type { ExecutionTraceNode } from "../models/types.ts";
|
|
38
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
39
|
+
import { JsonlRunStore, STATE_MAX_RUNS_ENV } from "../jsonl-run-store.ts";
|
|
40
|
+
|
|
41
|
+
function makeSpec(): RunSpec {
|
|
42
|
+
return {
|
|
43
|
+
scriptSource: "module.exports = async () => {};",
|
|
44
|
+
args: {},
|
|
45
|
+
scriptName: "test-script",
|
|
46
|
+
scriptPath: "/tmp/test.js",
|
|
47
|
+
description: "test",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeTraceNode(stepIndex: number): ExecutionTraceNode {
|
|
52
|
+
return { stepIndex, agent: "worker", task: "do thing", model: "default", status: "pending" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeRunningRun(runId: string): WorkflowRun {
|
|
56
|
+
const trace = new Trace();
|
|
57
|
+
trace.append(makeTraceNode(0));
|
|
58
|
+
return WorkflowRun.reconstruct(runId, makeSpec(), {
|
|
59
|
+
status: "running",
|
|
60
|
+
budget: new Budget(),
|
|
61
|
+
calls: new Map(),
|
|
62
|
+
trace,
|
|
63
|
+
errorLogs: [],
|
|
64
|
+
}, { startedAt: new Date().toISOString() });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** runId 形如 lifecycle 生成器(wf-<ts>-<rand>),i 只进 ts 段保证唯一。 */
|
|
68
|
+
function runIdAt(i: number): string {
|
|
69
|
+
return `wf-${1719500000000 + i * 1000}-retent`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stateFile(stateDir: string, runId: string): string {
|
|
73
|
+
return path.join(stateDir, `${runId}.jsonl`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 把文件/目录 mtime 钉到过去(基线 + i 分钟)——排序判定不依赖真实写入时序。 */
|
|
77
|
+
function pinMtime(fullPath: string, i: number, base: number): void {
|
|
78
|
+
const t = new Date(base + i * 60_000);
|
|
79
|
+
fs.utimesSync(fullPath, t, t);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("B1: workflow-state 保留清理(opt-in XYZ_SUBAGENT_STATE_MAX_RUNS)", () => {
|
|
83
|
+
let tmpDir: string;
|
|
84
|
+
let stateDir: string;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-retention-"));
|
|
88
|
+
stateDir = path.join(tmpDir, "workflow-state");
|
|
89
|
+
// 双保险:vitest.setup 全局净化 + 本文件显式 delete(防用例间经 stub 栈泄漏)
|
|
90
|
+
delete process.env[STATE_MAX_RUNS_ENV];
|
|
91
|
+
loggerMock.warn.mockClear();
|
|
92
|
+
loggerMock.debug.mockClear();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
delete process.env[STATE_MAX_RUNS_ENV];
|
|
97
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("上限 3 写 5 个 → 剩最新 3(mtime 最旧的 2 个被删)", async () => {
|
|
101
|
+
process.env[STATE_MAX_RUNS_ENV] = "3";
|
|
102
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
103
|
+
const base = Date.now() - 10 * 60_000;
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < 5; i++) {
|
|
106
|
+
const runId = runIdAt(i);
|
|
107
|
+
await store.save(makeRunningRun(runId));
|
|
108
|
+
// save 返回 = 首写 flush + 本轮 prune 已执行;此刻钉 mtime 供下一轮 prune 排序
|
|
109
|
+
pinMtime(stateFile(stateDir, runId), i, base);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 最后一轮 prune 按钉死的 mtime(0<1<2<3<4 分钟)裁到 3:留 i=2,3,4,删 i=0,1
|
|
113
|
+
expect(fs.readdirSync(stateDir).sort()).toEqual(
|
|
114
|
+
[runIdAt(2), runIdAt(3), runIdAt(4)].map((id) => `${id}.jsonl`).sort(),
|
|
115
|
+
);
|
|
116
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("未设 / 空 / 0 / 负数 / 非数值 → 不清理(默认关语义锁定)", async () => {
|
|
120
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
121
|
+
|
|
122
|
+
// 未设:写 5 个全保留(无上限即无删除)
|
|
123
|
+
for (let i = 0; i < 5; i++) {
|
|
124
|
+
await store.save(makeRunningRun(runIdAt(i)));
|
|
125
|
+
}
|
|
126
|
+
expect(fs.readdirSync(stateDir)).toHaveLength(5);
|
|
127
|
+
|
|
128
|
+
// 显式非法值同样不启用(解析回落 undefined):继续写、文件只增不减
|
|
129
|
+
for (const value of ["", "0", "-2", "abc", "NaN", "Infinity"]) {
|
|
130
|
+
process.env[STATE_MAX_RUNS_ENV] = value;
|
|
131
|
+
await store.save(makeRunningRun(runIdAt(5)));
|
|
132
|
+
}
|
|
133
|
+
// runIdAt(5) 首写(冷路径)flush 落盘第 6 个文件;后续 5 次为 running 热路径
|
|
134
|
+
// 去抖批(未 flush)——磁盘 6 个文件且全程无删除
|
|
135
|
+
expect(fs.readdirSync(stateDir)).toHaveLength(6);
|
|
136
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
137
|
+
await store.dispose();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("glob 外文件与父目录 session JSONL 不误删(只删本目录 wf-*.jsonl)", async () => {
|
|
141
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
142
|
+
const bystanders = [
|
|
143
|
+
"notes.txt",
|
|
144
|
+
"keep-me.jsonl",
|
|
145
|
+
"wf-truncated-noext",
|
|
146
|
+
"xwf-1719500000000-notwf.jsonl",
|
|
147
|
+
];
|
|
148
|
+
for (const name of bystanders) {
|
|
149
|
+
fs.writeFileSync(path.join(stateDir, name), "x");
|
|
150
|
+
}
|
|
151
|
+
// session JSONL 在父目录(sessionDir),结构性不在扫描范围,实测钉住
|
|
152
|
+
const sessionFile = path.join(tmpDir, "main-session.jsonl");
|
|
153
|
+
fs.writeFileSync(sessionFile, "{}\n");
|
|
154
|
+
|
|
155
|
+
process.env[STATE_MAX_RUNS_ENV] = "1";
|
|
156
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
157
|
+
const base = Date.now() - 10 * 60_000;
|
|
158
|
+
|
|
159
|
+
await store.save(makeRunningRun(runIdAt(0)));
|
|
160
|
+
pinMtime(stateFile(stateDir, runIdAt(0)), 0, base); // 钉成最旧
|
|
161
|
+
await store.save(makeRunningRun(runIdAt(1)));
|
|
162
|
+
|
|
163
|
+
// 2 个 state 文件裁到 1:runIdAt(0) 被删,4 个旁观文件原封不动
|
|
164
|
+
expect(fs.readdirSync(stateDir).sort()).toEqual(
|
|
165
|
+
[...bystanders, `${runIdAt(1)}.jsonl`].sort(),
|
|
166
|
+
);
|
|
167
|
+
expect(fs.existsSync(sessionFile)).toBe(true);
|
|
168
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("删除失败(unlink 目录 → EPERM)→ logger.warn 留证不抛,save 正常 resolve", async () => {
|
|
172
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
173
|
+
// 用「名字命中 glob 的目录」制造确定性 unlink 失败(unlink 目录 → EPERM)
|
|
174
|
+
const blockerDir = stateFile(stateDir, runIdAt(0));
|
|
175
|
+
fs.mkdirSync(blockerDir);
|
|
176
|
+
pinMtime(blockerDir, 0, Date.now() - 10 * 60_000); // 钉成最旧 → 成为删除受害者
|
|
177
|
+
|
|
178
|
+
process.env[STATE_MAX_RUNS_ENV] = "1";
|
|
179
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
180
|
+
|
|
181
|
+
// save 1 的 prune:受害者 = blockerDir → EPERM → warn,但 save 正常 resolve
|
|
182
|
+
await store.save(makeRunningRun(runIdAt(1)));
|
|
183
|
+
expect(fs.existsSync(stateFile(stateDir, runIdAt(1)))).toBe(true);
|
|
184
|
+
expect(fs.existsSync(blockerDir)).toBe(true);
|
|
185
|
+
|
|
186
|
+
// save 2 的 prune:3 项裁到 1,受害者 = dir(最旧,重试仍 EPERM)+ file1(次旧,删成)
|
|
187
|
+
await store.save(makeRunningRun(runIdAt(2)));
|
|
188
|
+
expect(fs.existsSync(stateFile(stateDir, runIdAt(1)))).toBe(false);
|
|
189
|
+
expect(fs.existsSync(stateFile(stateDir, runIdAt(2)))).toBe(true);
|
|
190
|
+
expect(fs.existsSync(blockerDir)).toBe(true);
|
|
191
|
+
|
|
192
|
+
// 每轮 prune 独立重试受害者:save1 与 save2 各 warn 一次(均指向 blockerDir),
|
|
193
|
+
// 不抛错、不阻断其余文件删除
|
|
194
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(2);
|
|
195
|
+
for (const call of loggerMock.warn.mock.calls) {
|
|
196
|
+
const msg = String(call[0] ?? "");
|
|
197
|
+
expect(msg).toContain("state retention");
|
|
198
|
+
expect(msg).toContain(blockerDir);
|
|
199
|
+
}
|
|
200
|
+
await store.dispose();
|
|
201
|
+
});
|
|
202
|
+
});
|