@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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// W1: jsonl-run-store 序列化/反序列化 sessionFile round-trip 测试
|
|
4
4
|
//
|
|
5
5
|
// 防的 bug:sessionFile 加入 AgentCall + ExecutionTraceNode 后,序列化时必须写入快照,
|
|
6
|
-
//
|
|
6
|
+
// 反序列化时必须恢复——否则跨 session 重水合后 agent 的 session jsonl
|
|
7
7
|
// 路径丢失,overlay 无法定位。
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
@@ -12,6 +12,19 @@ import * as path from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
14
|
|
|
15
|
+
// logger mock(文件级):jsonl-run-store.ts 模块级 getLogger 拿到此 mock,
|
|
16
|
+
// W2TC10 断言 dispose 后 save no-op 的 debug 留痕;其余 describe 不消费 logger。
|
|
17
|
+
const loggerMock = vi.hoisted(() => ({
|
|
18
|
+
debug: vi.fn(),
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
error: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
23
|
+
getLogger: () => loggerMock,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import type { CustomEntry } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
|
|
15
28
|
import { AgentCall } from "../models/agent-call.ts";
|
|
16
29
|
import { Budget } from "../models/budget.ts";
|
|
17
30
|
import { Trace } from "../models/trace.ts";
|
|
@@ -19,6 +32,7 @@ import type { ExecutionTraceNode } from "../models/types.ts";
|
|
|
19
32
|
import type { RunSpec } from "../models/run-spec.ts";
|
|
20
33
|
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
21
34
|
import { JsonlRunStore } from "../jsonl-run-store.ts";
|
|
35
|
+
import { mkCtx, mkPi } from "./test-mocks.ts";
|
|
22
36
|
|
|
23
37
|
function makeSpec(): RunSpec {
|
|
24
38
|
return {
|
|
@@ -44,11 +58,15 @@ function makeRunWithDoneCall(): WorkflowRun {
|
|
|
44
58
|
const trace = new Trace();
|
|
45
59
|
const node = makeTraceNode(0);
|
|
46
60
|
trace.append(node);
|
|
47
|
-
const call = new AgentCall(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
const call = new AgentCall(
|
|
62
|
+
0,
|
|
63
|
+
{
|
|
64
|
+
prompt: "task",
|
|
65
|
+
agent: "worker",
|
|
66
|
+
cwd: "/tmp",
|
|
67
|
+
},
|
|
68
|
+
node,
|
|
69
|
+
);
|
|
52
70
|
// 模拟已完成 agent call:带 sessionId + sessionFile
|
|
53
71
|
call.markRunning();
|
|
54
72
|
call.markDone({
|
|
@@ -81,6 +99,40 @@ function makeRunWithDoneCall(): WorkflowRun {
|
|
|
81
99
|
);
|
|
82
100
|
}
|
|
83
101
|
|
|
102
|
+
/**
|
|
103
|
+
* W4+ fixture:running 状态 run(热路径去抖测试的前提)。
|
|
104
|
+
*
|
|
105
|
+
* 用 WorkflowRun.reconstruct 构造——running 且 runtime undefined 违反 I1
|
|
106
|
+
* (持久化的 running 快照无 worker),constructor 会抛错;reconstruct 跳过 I1
|
|
107
|
+
* 校验(可信快照重水合语义)。serializeRun 不读 runtime 字段,序列化合法。
|
|
108
|
+
* 热路径「中间态演进」直接对同一 run 引用 trace.append 后再次 save(latestRun 语义)。
|
|
109
|
+
*/
|
|
110
|
+
function makeRunningRun(runId: string): WorkflowRun {
|
|
111
|
+
const trace = new Trace();
|
|
112
|
+
trace.append(makeTraceNode(0));
|
|
113
|
+
return WorkflowRun.reconstruct(
|
|
114
|
+
runId,
|
|
115
|
+
makeSpec(),
|
|
116
|
+
{
|
|
117
|
+
status: "running",
|
|
118
|
+
budget: new Budget(),
|
|
119
|
+
calls: new Map(),
|
|
120
|
+
trace,
|
|
121
|
+
errorLogs: [],
|
|
122
|
+
},
|
|
123
|
+
{ startedAt: new Date().toISOString() },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 读 run 状态文件并解析快照(W4+ 断言「磁盘真实内容」用)。 */
|
|
128
|
+
function readStateFile(
|
|
129
|
+
tmpDir: string,
|
|
130
|
+
runId: string,
|
|
131
|
+
): { state: { status: string; trace: Array<{ stepIndex: number }> } } {
|
|
132
|
+
const raw = fs.readFileSync(path.join(tmpDir, "workflow-state", `${runId}.jsonl`), "utf8");
|
|
133
|
+
return JSON.parse(raw.trim()) as { state: { status: string; trace: Array<{ stepIndex: number }> } };
|
|
134
|
+
}
|
|
135
|
+
|
|
84
136
|
describe("W1: JsonlRunStore sessionFile 序列化 round-trip", () => {
|
|
85
137
|
let tmpDir: string;
|
|
86
138
|
let store: JsonlRunStore;
|
|
@@ -129,21 +181,15 @@ describe("W1: JsonlRunStore sessionFile 序列化 round-trip", () => {
|
|
|
129
181
|
// 闭环测试:serialize(save)→ deserialize(loadAll)→ 验证 run.state.calls 的 AgentCall.sessionFile
|
|
130
182
|
const sessionFilePath = "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl";
|
|
131
183
|
|
|
132
|
-
// mock pi + ctx
|
|
133
|
-
const entries:
|
|
134
|
-
const mockPi =
|
|
135
|
-
|
|
136
|
-
entries.push({ type: "custom", customType: type, data });
|
|
137
|
-
}),
|
|
138
|
-
};
|
|
139
|
-
const mockCtx = {
|
|
140
|
-
sessionManager: { getEntries: () => entries },
|
|
141
|
-
};
|
|
184
|
+
// mock pi + ctx 共享同一 entries 数组:save 写 pointer entry,loadAll 读同一组 entries
|
|
185
|
+
const entries: CustomEntry[] = [];
|
|
186
|
+
const mockPi = mkPi(entries);
|
|
187
|
+
const mockCtx = mkCtx(entries);
|
|
142
188
|
|
|
143
189
|
const storeWithCtx = new JsonlRunStore({
|
|
144
190
|
sessionDir: tmpDir,
|
|
145
|
-
pi: mockPi
|
|
146
|
-
ctx: mockCtx
|
|
191
|
+
pi: mockPi,
|
|
192
|
+
ctx: mockCtx,
|
|
147
193
|
});
|
|
148
194
|
|
|
149
195
|
const run = makeRunWithDoneCall();
|
|
@@ -176,6 +222,75 @@ describe("W2: RunStore.stateFilePath 暴露 run 状态文件路径", () => {
|
|
|
176
222
|
});
|
|
177
223
|
});
|
|
178
224
|
|
|
225
|
+
// ── W9: 快照版本守卫(v2 当前 / v1 跳过)──────────────────────────────
|
|
226
|
+
//
|
|
227
|
+
// U2 快照格式 v1→v2(status 两态、无 pausedAt)。v1 遗留文件经 loadAll 静默跳过
|
|
228
|
+
// (D-5 边界声明:旧 run 历史价值低,不做兼容迁移)。
|
|
229
|
+
|
|
230
|
+
describe("W9: 快照版本守卫(v2 当前 / v1 跳过)", () => {
|
|
231
|
+
let tmpDir: string;
|
|
232
|
+
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-ver-"));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("v1 头快照(升级前遗留)→ loadAll 静默跳过:不崩、不显示", async () => {
|
|
242
|
+
// 模拟 v1 时代的遗留快照(版本头 wf-run-v1)。版本守卫只比对 v 字段,
|
|
243
|
+
// status 值不影响跳过判定——v1 running 残留同样静默消失不显示(D-5 边界声明)。
|
|
244
|
+
const stateDir = path.join(tmpDir, "workflow-state");
|
|
245
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
246
|
+
const filePath = path.join(stateDir, "run-legacy-v1.jsonl");
|
|
247
|
+
const legacySnapshot = {
|
|
248
|
+
v: "wf-run-v1",
|
|
249
|
+
runId: "run-legacy-v1",
|
|
250
|
+
state: { status: "running", calls: [], trace: [], errorLogs: [] },
|
|
251
|
+
meta: { startedAt: new Date().toISOString() },
|
|
252
|
+
};
|
|
253
|
+
fs.writeFileSync(filePath, JSON.stringify(legacySnapshot) + "\n", "utf8");
|
|
254
|
+
|
|
255
|
+
const entries: CustomEntry[] = [
|
|
256
|
+
{
|
|
257
|
+
type: "custom",
|
|
258
|
+
customType: "workflow-state-link",
|
|
259
|
+
data: { runId: "run-legacy-v1", path: filePath },
|
|
260
|
+
id: "seed-pointer",
|
|
261
|
+
parentId: null,
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
|
|
266
|
+
|
|
267
|
+
// 版本不匹配 → deserializeRun 返回 null → loadAll 跳过(空数组),不崩
|
|
268
|
+
await expect(store.loadAll()).resolves.toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("新快照 version === wf-run-v2:status 两态、meta 投影无 pausedAt", async () => {
|
|
272
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
273
|
+
const run = makeRunningRun("run-v2-check");
|
|
274
|
+
await store.save(run);
|
|
275
|
+
|
|
276
|
+
const raw = fs.readFileSync(
|
|
277
|
+
path.join(tmpDir, "workflow-state", "run-v2-check.jsonl"),
|
|
278
|
+
"utf8",
|
|
279
|
+
);
|
|
280
|
+
const snapshot = JSON.parse(raw.trim()) as {
|
|
281
|
+
v: string;
|
|
282
|
+
state: { status: string };
|
|
283
|
+
meta: Record<string, unknown>;
|
|
284
|
+
};
|
|
285
|
+
// 版本头 v2(持久化契约锚定字面量)
|
|
286
|
+
expect(snapshot.v).toBe("wf-run-v2");
|
|
287
|
+
// status 两态(running/done)
|
|
288
|
+
expect(snapshot.state.status).toBe("running");
|
|
289
|
+
// F6:meta 投影不再含 pausedAt 字段
|
|
290
|
+
expect(snapshot.meta).not.toHaveProperty("pausedAt");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
179
294
|
// W3: save 兜底容错——run 工作目录被并发清理时 mkdir 抛 ENOENT,save 静默返回。
|
|
180
295
|
//
|
|
181
296
|
// 防的 bug(PR #166 CI 回归):review-fix-loop-e2e 等 runAndWait 测试中,
|
|
@@ -227,3 +342,527 @@ describe("W3: JsonlRunStore.save 兜底容错(run 工作目录被并发清理
|
|
|
227
342
|
expect(spy).toHaveBeenCalled();
|
|
228
343
|
});
|
|
229
344
|
});
|
|
345
|
+
|
|
346
|
+
// ── W4-W8: save 去抖(cw swf-perf wave2,W2TC1-15)──────────────────────
|
|
347
|
+
//
|
|
348
|
+
// 状态机前提:热路径 = running 中间态且本实例已首写(writtenOnce 已记)→ 进去抖批;
|
|
349
|
+
// 冷路径 = 本实例首写(任何 status)或 status !== "running"(done)→ 同步
|
|
350
|
+
// flush 绕过 timer。所有用例先做一次冷路径首写 + await 落盘,再进热路径。
|
|
351
|
+
//
|
|
352
|
+
// fake timers 惯例对齐 lifecycle.test.ts(vi.useFakeTimers + advanceTimersByTimeAsync);
|
|
353
|
+
// writeFile/mkdir 计数用 vi.spyOn 保留原实现(真实落盘,读磁盘断言内容)。
|
|
354
|
+
|
|
355
|
+
describe("W4: save 去抖(热路径合并 / 冷路径同步 flush)", () => {
|
|
356
|
+
let tmpDir: string;
|
|
357
|
+
let store: JsonlRunStore;
|
|
358
|
+
|
|
359
|
+
beforeEach(() => {
|
|
360
|
+
vi.useFakeTimers();
|
|
361
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-debounce-"));
|
|
362
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
vi.useRealTimers();
|
|
367
|
+
vi.restoreAllMocks();
|
|
368
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("W2TC1: 热路径去抖合并:3 次 save 合并 1 次写盘、内容为 flush 时刻最新状态", async () => {
|
|
372
|
+
const run = makeRunningRun("run-w2tc1");
|
|
373
|
+
// 首写冷路径立即落盘(writtenOnce 记录,热路径前提)
|
|
374
|
+
await store.save(run);
|
|
375
|
+
const wfSpy = vi.spyOn(fs.promises, "writeFile");
|
|
376
|
+
|
|
377
|
+
// 热路径 3 次 save(同一 run 引用 mutate,中间态演进)
|
|
378
|
+
run.state.trace.append(makeTraceNode(1));
|
|
379
|
+
const p1 = store.save(run);
|
|
380
|
+
run.state.trace.append(makeTraceNode(2));
|
|
381
|
+
const p2 = store.save(run);
|
|
382
|
+
const p3 = store.save(run);
|
|
383
|
+
|
|
384
|
+
// advance 前:无写盘,文件停留首写状态(1 个节点)
|
|
385
|
+
expect(wfSpy).not.toHaveBeenCalled();
|
|
386
|
+
expect(readStateFile(tmpDir, "run-w2tc1").state.trace).toHaveLength(1);
|
|
387
|
+
|
|
388
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
389
|
+
// 3 个 save() Promise 全部 resolved(await 落盘完成——fake timers 不等真实 IO)
|
|
390
|
+
await Promise.all([p1, p2, p3]);
|
|
391
|
+
// N=3 合并 1 次写盘
|
|
392
|
+
expect(wfSpy).toHaveBeenCalledTimes(1);
|
|
393
|
+
// latestRun 语义:写的是 flush 时刻最新聚合状态(node1 + node2 都在)
|
|
394
|
+
const snap = readStateFile(tmpDir, "run-w2tc1");
|
|
395
|
+
const steps = snap.state.trace.map((n) => n.stepIndex);
|
|
396
|
+
expect(steps).toContain(1);
|
|
397
|
+
expect(steps).toContain(2);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("W2TC2: 终态 save 绕过 timer 立即落盘:合并 pending 批 + 取消 timer 无二次写", async () => {
|
|
401
|
+
const run = makeRunningRun("run-w2tc2");
|
|
402
|
+
await store.save(run); // 首写
|
|
403
|
+
const wfSpy = vi.spyOn(fs.promises, "writeFile");
|
|
404
|
+
|
|
405
|
+
const p1 = store.save(run); // 热路径批 pending(1 个未 settle Promise)
|
|
406
|
+
run.transition("done", "completed");
|
|
407
|
+
const p2 = store.save(run); // 终态冷路径
|
|
408
|
+
// pending 批(save #1)settlers 并入终态批合并 settle(await 落盘完成)
|
|
409
|
+
await p1;
|
|
410
|
+
await p2;
|
|
411
|
+
|
|
412
|
+
// 不 advance(timer 未走):终态已落盘
|
|
413
|
+
expect(readStateFile(tmpDir, "run-w2tc2").state.status).toBe("done");
|
|
414
|
+
|
|
415
|
+
// timer 已取消:advance 后无第二次写,终态内容不被中间态覆盖
|
|
416
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
417
|
+
expect(wfSpy).toHaveBeenCalledTimes(1);
|
|
418
|
+
expect(readStateFile(tmpDir, "run-w2tc2").state.status).toBe("done");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("W2TC3: 同批多次调用共享同一 flush Promise——timer 触发路径全 resolved", async () => {
|
|
422
|
+
const run = makeRunningRun("run-w2tc3a");
|
|
423
|
+
await store.save(run);
|
|
424
|
+
const p1 = store.save(run);
|
|
425
|
+
const p2 = store.save(run);
|
|
426
|
+
const p3 = store.save(run);
|
|
427
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
428
|
+
// 同批全部 resolved(成功场景无 rejected)
|
|
429
|
+
await Promise.all([p1, p2, p3]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("W2TC3: 冷路径合并 pending 批 settlers 共同 settle——await p4 后 p1-p3 全部 resolved", async () => {
|
|
433
|
+
const run = makeRunningRun("run-w2tc3b");
|
|
434
|
+
await store.save(run);
|
|
435
|
+
const p1 = store.save(run);
|
|
436
|
+
const p2 = store.save(run);
|
|
437
|
+
const p3 = store.save(run);
|
|
438
|
+
run.transition("done", "completed");
|
|
439
|
+
const p4 = store.save(run); // 冷路径合并 p1-p3 的批
|
|
440
|
+
await p4; // 终态写盘完成后 resolve
|
|
441
|
+
// settlers 数组统一 settle,无悬挂 Promise(防 unhandled rejection)
|
|
442
|
+
await Promise.all([p1, p2, p3]);
|
|
443
|
+
expect(readStateFile(tmpDir, "run-w2tc3b").state.status).toBe("done");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("W2TC7: 终态冷路径同步 flush:立即落盘(绕过 timer)+ 终态指针", async () => {
|
|
447
|
+
const mockPi = mkPi();
|
|
448
|
+
const store7 = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
|
|
449
|
+
const run = makeRunningRun("run-w2tc7");
|
|
450
|
+
await store7.save(run); // 首写 + 创建指针
|
|
451
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
|
|
452
|
+
|
|
453
|
+
const pHot = store7.save(run); // 热路径批 pending
|
|
454
|
+
run.transition("done", "completed");
|
|
455
|
+
await store7.save(run); // 终态冷路径
|
|
456
|
+
|
|
457
|
+
// 不 advance 立即读磁盘:终态优先持久化(去抖窗口内的崩溃不吞终态)
|
|
458
|
+
expect(readStateFile(tmpDir, "run-w2tc7").state.status).toBe("done");
|
|
459
|
+
// 合并的热路径批 Promise 一并 resolved
|
|
460
|
+
await pHot;
|
|
461
|
+
// 终态冷路径写终态指针(创建 + 终态 = 2 次)
|
|
462
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
|
|
463
|
+
// advance 后无追加写
|
|
464
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
465
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("W2TC8: 首写冷路径(跨 session resume):本 store 实例首 save 立即落盘 + 写创建指针", async () => {
|
|
469
|
+
const mockPi = mkPi();
|
|
470
|
+
const storeA = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
|
|
471
|
+
const run = makeRunningRun("run-w2tc8");
|
|
472
|
+
|
|
473
|
+
// 实例 A 首写:status 是 running 也立即落盘(首写判定优先于 status)
|
|
474
|
+
await storeA.save(run);
|
|
475
|
+
expect(readStateFile(tmpDir, "run-w2tc8").state.status).toBe("running");
|
|
476
|
+
// 创建指针(即使 status 是 running——新 store 实例对该 runId 的首写即建指针)
|
|
477
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
|
|
478
|
+
|
|
479
|
+
// 实例 B(另一 session 的 store)对同一 runId 再 save:又是一次实例首写
|
|
480
|
+
const storeB = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
|
|
481
|
+
run.state.trace.append(makeTraceNode(9));
|
|
482
|
+
await storeB.save(run);
|
|
483
|
+
// 跨实例指针仍有界:每实例每 run ≤2 条(此处各 1 条创建指针)
|
|
484
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
|
|
485
|
+
expect(readStateFile(tmpDir, "run-w2tc8").state.trace).toHaveLength(2);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("W2TC15: saveDebounceMs 构造参数可调:短参数窗口生效", async () => {
|
|
489
|
+
const store50 = new JsonlRunStore({ sessionDir: tmpDir, saveDebounceMs: 50 });
|
|
490
|
+
const run = makeRunningRun("run-w2tc15");
|
|
491
|
+
await store50.save(run); // 首写
|
|
492
|
+
const wfSpy = vi.spyOn(fs.promises, "writeFile");
|
|
493
|
+
|
|
494
|
+
run.state.trace.append(makeTraceNode(1));
|
|
495
|
+
const p = store50.save(run);
|
|
496
|
+
await vi.advanceTimersByTimeAsync(49);
|
|
497
|
+
expect(wfSpy).not.toHaveBeenCalled(); // 窗口未到
|
|
498
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
499
|
+
await p; // flush 落盘完成
|
|
500
|
+
expect(wfSpy).toHaveBeenCalledTimes(1); // 50ms 参数生效
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("W5: 批 settle 与 IO 错误语义", () => {
|
|
505
|
+
let tmpDir: string;
|
|
506
|
+
let store: JsonlRunStore;
|
|
507
|
+
|
|
508
|
+
beforeEach(() => {
|
|
509
|
+
vi.useFakeTimers();
|
|
510
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-ioerr-"));
|
|
511
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
afterEach(() => {
|
|
515
|
+
vi.useRealTimers();
|
|
516
|
+
vi.restoreAllMocks();
|
|
517
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("W2TC4: flush IO 错误 reject 批内全部调用 + 失败不粘滞(下一 save 开新批)", async () => {
|
|
521
|
+
const run = makeRunningRun("run-w2tc4");
|
|
522
|
+
await store.save(run); // 首写
|
|
523
|
+
vi.spyOn(fs.promises, "writeFile").mockRejectedValueOnce(
|
|
524
|
+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const p1 = store.save(run);
|
|
528
|
+
const p2 = store.save(run); // 同批两次调用
|
|
529
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
530
|
+
// 批内全部 Promise rejects 同一错误(EACCES 消息传播)
|
|
531
|
+
await expect(p1).rejects.toThrow("permission denied");
|
|
532
|
+
await expect(p2).rejects.toThrow("permission denied");
|
|
533
|
+
|
|
534
|
+
// 失败不粘滞:pending Map 条目已清除,下一 save 开新批正常 flush 成功
|
|
535
|
+
const p3 = store.save(run);
|
|
536
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
537
|
+
await p3;
|
|
538
|
+
expect(readStateFile(tmpDir, "run-w2tc4").state.status).toBe("running");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("W2TC4(ES9): 首写失败回滚 writtenOnce——running 中间态 save 重走冷路径补写指针", async () => {
|
|
542
|
+
const mockPi = mkPi();
|
|
543
|
+
const store4 = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
|
|
544
|
+
const run = makeRunningRun("run-w2tc4b");
|
|
545
|
+
|
|
546
|
+
// 首写冷路径遇 writeFile EACCES reject
|
|
547
|
+
vi.spyOn(fs.promises, "writeFile").mockRejectedValueOnce(
|
|
548
|
+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
|
549
|
+
);
|
|
550
|
+
await expect(store4.save(run)).rejects.toThrow("permission denied");
|
|
551
|
+
expect(mockPi.appendEntry).not.toHaveBeenCalled(); // 指针未写
|
|
552
|
+
|
|
553
|
+
// 恢复 IO 后 running 中间态 save:不经 timer 立即落盘(首写资格已回滚,冷路径重试指针)
|
|
554
|
+
const p = store4.save(run);
|
|
555
|
+
await p; // 冷路径同步 flush 完成
|
|
556
|
+
expect(readStateFile(tmpDir, "run-w2tc4b").state.status).toBe("running");
|
|
557
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(1); // 指针经冷路径补写而非热路径永失
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("W2TC5: ENOENT 静默语义保留——热路径去抖批 mkdir ENOENT resolve 全部批 Promise", async () => {
|
|
561
|
+
const run = makeRunningRun("run-w2tc5");
|
|
562
|
+
await store.save(run); // 首写
|
|
563
|
+
vi.spyOn(fs.promises, "mkdir").mockRejectedValueOnce(
|
|
564
|
+
Object.assign(new Error("ENOENT: no such file or directory, mkdir"), {
|
|
565
|
+
code: "ENOENT",
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const p1 = store.save(run); // 热路径批 pending
|
|
570
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
571
|
+
await p1; // resolved(不抛 unhandled rejection)
|
|
572
|
+
// 无新写入:内容停留首写状态
|
|
573
|
+
expect(readStateFile(tmpDir, "run-w2tc5").state.trace).toHaveLength(1);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("W2TC5: 冷路径 mkdir ENOENT → save Promise resolves(对齐 W3 既有语义)", async () => {
|
|
577
|
+
const run = makeRunningRun("run-w2tc5b");
|
|
578
|
+
// running 首写冷路径(与 W3 的 done 用例互补):mkdir ENOENT → 静默 resolve
|
|
579
|
+
vi.spyOn(fs.promises, "mkdir").mockRejectedValueOnce(
|
|
580
|
+
Object.assign(new Error("ENOENT: no such file or directory, mkdir"), {
|
|
581
|
+
code: "ENOENT",
|
|
582
|
+
}),
|
|
583
|
+
);
|
|
584
|
+
await expect(store.save(run)).resolves.toBeUndefined();
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("W6: 指针收敛(仅创建/终态写)", () => {
|
|
589
|
+
let tmpDir: string;
|
|
590
|
+
|
|
591
|
+
beforeEach(() => {
|
|
592
|
+
vi.useFakeTimers();
|
|
593
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-ptr-"));
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
afterEach(() => {
|
|
597
|
+
vi.useRealTimers();
|
|
598
|
+
vi.restoreAllMocks();
|
|
599
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("W2TC6: 指针 appendEntry 计数:创建+终态=2 次,中间 N 次 save 0 次", async () => {
|
|
603
|
+
const mockPi = mkPi();
|
|
604
|
+
const store6 = new JsonlRunStore({ sessionDir: tmpDir, pi: mockPi });
|
|
605
|
+
const run = makeRunningRun("run-w2tc6");
|
|
606
|
+
|
|
607
|
+
// 创建首写 → 1 次
|
|
608
|
+
await store6.save(run);
|
|
609
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
|
|
610
|
+
|
|
611
|
+
// 3 次 running 中间态 save(各自 advance 推进批)→ 仍 1 次
|
|
612
|
+
for (let i = 1; i <= 3; i++) {
|
|
613
|
+
run.state.trace.append(makeTraceNode(i));
|
|
614
|
+
const p = store6.save(run);
|
|
615
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
616
|
+
await p;
|
|
617
|
+
}
|
|
618
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(1);
|
|
619
|
+
|
|
620
|
+
// done 终态 save → 2 次(终态指针)
|
|
621
|
+
run.transition("done", "completed");
|
|
622
|
+
await store6.save(run);
|
|
623
|
+
expect(mockPi.appendEntry).toHaveBeenCalledTimes(2);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe("W7: flushPendingSaves / dispose / 串行链", () => {
|
|
628
|
+
let tmpDir: string;
|
|
629
|
+
let store: JsonlRunStore;
|
|
630
|
+
|
|
631
|
+
beforeEach(() => {
|
|
632
|
+
vi.useFakeTimers();
|
|
633
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-dispose-"));
|
|
634
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
635
|
+
loggerMock.debug.mockClear();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
afterEach(() => {
|
|
639
|
+
vi.useRealTimers();
|
|
640
|
+
vi.restoreAllMocks();
|
|
641
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("W2TC9: flushPendingSaves 立即刷全部 pending 批且 store 保持可用", async () => {
|
|
645
|
+
const runA = makeRunningRun("run-w2tc9a");
|
|
646
|
+
const runB = makeRunningRun("run-w2tc9b");
|
|
647
|
+
await store.save(runA);
|
|
648
|
+
await store.save(runB); // 两 runId 首写
|
|
649
|
+
|
|
650
|
+
const pa = store.save(runA); // 两批独立 pending
|
|
651
|
+
runB.state.trace.append(makeTraceNode(1));
|
|
652
|
+
const pb = store.save(runB);
|
|
653
|
+
|
|
654
|
+
await store.flushPendingSaves(); // 不 advance 直接刷
|
|
655
|
+
expect(readStateFile(tmpDir, "run-w2tc9a").state.status).toBe("running");
|
|
656
|
+
expect(readStateFile(tmpDir, "run-w2tc9b").state.trace).toHaveLength(2);
|
|
657
|
+
await Promise.all([pa, pb]); // 两批 save Promise 全部 resolved
|
|
658
|
+
|
|
659
|
+
// store 保持可用:后续 save 正常进入新去抖批
|
|
660
|
+
const pa2 = store.save(runA);
|
|
661
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
662
|
+
await pa2;
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("W2TC10: dispose 刷 pending + 停 timer + 幂等 + dispose 后 save 静默 no-op + debug 日志", async () => {
|
|
666
|
+
const run = makeRunningRun("run-w2tc10");
|
|
667
|
+
await store.save(run); // 首写
|
|
668
|
+
const wfSpy = vi.spyOn(fs.promises, "writeFile");
|
|
669
|
+
const pHot = store.save(run); // 热路径批 pending
|
|
670
|
+
wfSpy.mockClear(); // 清零基准
|
|
671
|
+
|
|
672
|
+
const d1 = store.dispose();
|
|
673
|
+
const d2 = store.dispose(); // 并发交叠第二次
|
|
674
|
+
expect(d1).toBe(d2); // 同一 Promise 引用(dispose 缓存自身 Promise,幂等)
|
|
675
|
+
await Promise.all([d1, d2]);
|
|
676
|
+
await pHot; // pending 批已刷、settled
|
|
677
|
+
|
|
678
|
+
expect(wfSpy).toHaveBeenCalledTimes(1); // 第二次 dispose 不重复刷(清零基准 === 1)
|
|
679
|
+
const d3 = store.dispose(); // 串行第三次
|
|
680
|
+
expect(d3).toBe(d1); // 返回同一已 resolve 的 Promise
|
|
681
|
+
await d3;
|
|
682
|
+
|
|
683
|
+
// timer 清除:advance 后无追加写
|
|
684
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
685
|
+
expect(wfSpy).toHaveBeenCalledTimes(1);
|
|
686
|
+
|
|
687
|
+
// dispose 后 save 返回 resolved Promise 且不写盘(静默 no-op)
|
|
688
|
+
run.transition("done", "completed");
|
|
689
|
+
await expect(store.save(run)).resolves.toBeUndefined();
|
|
690
|
+
expect(wfSpy).toHaveBeenCalledTimes(1);
|
|
691
|
+
|
|
692
|
+
// debug 日志留痕(R5 断言锚点:含 runId,不外抛)
|
|
693
|
+
expect(loggerMock.debug).toHaveBeenCalled();
|
|
694
|
+
const debugDump = loggerMock.debug.mock.calls.map((c) => String(c[0])).join("\n");
|
|
695
|
+
expect(debugDump).toContain("run-w2tc10");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("W2TC11: per-runId 串行 flush 链——前一 flush in-flight 时后续 flush 排队,无并发 writeFile", async () => {
|
|
699
|
+
const run = makeRunningRun("run-w2tc11");
|
|
700
|
+
await store.save(run); // 首写
|
|
701
|
+
// mkdir 立即 resolve:时序控制点收敛到 writeFile(真实 mkdir 的 IO 完成时机
|
|
702
|
+
// 不受 fake timers 管,会挡住 writeFile 的调用观察)
|
|
703
|
+
vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined);
|
|
704
|
+
const wfSpy = vi.spyOn(fs.promises, "writeFile");
|
|
705
|
+
|
|
706
|
+
// 第一次 writeFile 调用返回手动 gate 控制的 pending Promise,之后恢复原实现
|
|
707
|
+
let gateResolve!: () => void;
|
|
708
|
+
const gate = new Promise<void>((r) => {
|
|
709
|
+
gateResolve = r;
|
|
710
|
+
});
|
|
711
|
+
wfSpy.mockImplementationOnce(() => gate);
|
|
712
|
+
|
|
713
|
+
run.state.trace.append(makeTraceNode(1));
|
|
714
|
+
const p1 = store.save(run); // save#1
|
|
715
|
+
await vi.advanceTimersByTimeAsync(200); // flush#1 触发,writeFile#1 挂起
|
|
716
|
+
expect(wfSpy).toHaveBeenCalledTimes(1);
|
|
717
|
+
|
|
718
|
+
run.state.trace.append(makeTraceNode(2));
|
|
719
|
+
const p2 = store.save(run); // save#2 新批
|
|
720
|
+
await vi.advanceTimersByTimeAsync(200); // flush#2 timer 到点,排队等待 flush#1
|
|
721
|
+
expect(wfSpy).toHaveBeenCalledTimes(1); // writeFile 仅 1 次(无并发)
|
|
722
|
+
|
|
723
|
+
gateResolve(); // 释放 flush#1
|
|
724
|
+
await p1;
|
|
725
|
+
await p2; // flush#2 在 flush#1 完成后顺序执行
|
|
726
|
+
expect(wfSpy).toHaveBeenCalledTimes(2);
|
|
727
|
+
// flush#2 落盘 save#2 时刻的最新状态
|
|
728
|
+
expect(readStateFile(tmpDir, "run-w2tc11").state.trace).toHaveLength(3);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("W2TC11: 链上前序 flush 失败不传染后续批(链尾吞链错误,错误只经批 Promise 传播)", async () => {
|
|
732
|
+
const run = makeRunningRun("run-w2tc11b");
|
|
733
|
+
await store.save(run); // 首写
|
|
734
|
+
vi.spyOn(fs.promises, "writeFile").mockRejectedValueOnce(
|
|
735
|
+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const p1 = store.save(run);
|
|
739
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
740
|
+
await expect(p1).rejects.toThrow("permission denied"); // flush#1 失败经批 Promise 传播
|
|
741
|
+
|
|
742
|
+
// 链未断:flush#2 正常执行并落盘
|
|
743
|
+
run.state.trace.append(makeTraceNode(1));
|
|
744
|
+
const p2 = store.save(run);
|
|
745
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
746
|
+
await p2;
|
|
747
|
+
expect(readStateFile(tmpDir, "run-w2tc11b").state.trace).toHaveLength(2);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("W2TC14: 不同 runId 批互不阻塞:并发 workflow 各自独立去抖", async () => {
|
|
751
|
+
const runA = makeRunningRun("run-w2tc14a");
|
|
752
|
+
const runB = makeRunningRun("run-w2tc14b");
|
|
753
|
+
await store.save(runA);
|
|
754
|
+
await store.save(runB); // 两 runId 首写
|
|
755
|
+
|
|
756
|
+
// runA 的 writeFile 挂 gate(慢 IO),其他路径(runB)正常;mkdir 立即 resolve
|
|
757
|
+
//(时序控制点收敛到 writeFile,真实 mkdir 的 IO 完成不受 fake timers 管)
|
|
758
|
+
vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined);
|
|
759
|
+
const realWriteFile = fs.promises.writeFile.bind(fs.promises);
|
|
760
|
+
const pathA = path.join(tmpDir, "workflow-state", "run-w2tc14a.jsonl");
|
|
761
|
+
let gateResolve!: () => void;
|
|
762
|
+
const gate = new Promise<void>((r) => {
|
|
763
|
+
gateResolve = r;
|
|
764
|
+
});
|
|
765
|
+
vi.spyOn(fs.promises, "writeFile").mockImplementation(
|
|
766
|
+
(p: unknown, ...rest: unknown[]) =>
|
|
767
|
+
String(p) === pathA
|
|
768
|
+
? gate.then(() =>
|
|
769
|
+
(realWriteFile as (p: unknown, ...r: unknown[]) => Promise<void>)(p, ...rest),
|
|
770
|
+
)
|
|
771
|
+
: (realWriteFile as (p: unknown, ...r: unknown[]) => Promise<void>)(p, ...rest),
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
const pa = store.save(runA); // runA 批
|
|
775
|
+
runA.state.trace.append(makeTraceNode(1)); // 中间态演进(serialize-at-flush)
|
|
776
|
+
runB.state.trace.append(makeTraceNode(1));
|
|
777
|
+
const pb = store.save(runB); // runB 批
|
|
778
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
779
|
+
await pb; // runB 真实写盘完成
|
|
780
|
+
|
|
781
|
+
// runA flush 挂起中,runB 已落盘(无全局锁,per-runId 独立链)
|
|
782
|
+
expect(readStateFile(tmpDir, "run-w2tc14b").state.trace).toHaveLength(2);
|
|
783
|
+
// runA 文件停留首写状态(热批被 gate 挂起未写入)
|
|
784
|
+
expect(readStateFile(tmpDir, "run-w2tc14a").state.trace).toHaveLength(1);
|
|
785
|
+
|
|
786
|
+
gateResolve(); // 释放 runA
|
|
787
|
+
await pa;
|
|
788
|
+
expect(readStateFile(tmpDir, "run-w2tc14a").state.trace).toHaveLength(2);
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe("W8: 去抖窗口崩溃语义与 timer unref", () => {
|
|
793
|
+
let tmpDir: string;
|
|
794
|
+
let store: JsonlRunStore;
|
|
795
|
+
|
|
796
|
+
beforeEach(() => {
|
|
797
|
+
vi.useFakeTimers();
|
|
798
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-crash-"));
|
|
799
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
afterEach(() => {
|
|
803
|
+
vi.useRealTimers();
|
|
804
|
+
vi.restoreAllMocks();
|
|
805
|
+
vi.unstubAllGlobals();
|
|
806
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("W2TC12: 去抖 timer 必须 unref(不钉住 extension 进程)", async () => {
|
|
810
|
+
const fakeTimer = { unref: vi.fn(), ref: vi.fn() };
|
|
811
|
+
const setTimeoutSpy = vi.fn(() => fakeTimer);
|
|
812
|
+
const clearTimeoutSpy = vi.fn();
|
|
813
|
+
vi.stubGlobal("setTimeout", setTimeoutSpy);
|
|
814
|
+
vi.stubGlobal("clearTimeout", clearTimeoutSpy);
|
|
815
|
+
|
|
816
|
+
const run = makeRunningRun("run-w2tc12");
|
|
817
|
+
// 全程用同一 store50 实例:首写必须落在 store50 上(writtenOnce 是 per-instance,
|
|
818
|
+
// 用另一个实例首写会让本实例的 save 走冷路径,测不到建批 timer)
|
|
819
|
+
const store50 = new JsonlRunStore({ sessionDir: tmpDir, saveDebounceMs: 50 });
|
|
820
|
+
await store50.save(run); // 首写冷路径:不经 timer
|
|
821
|
+
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
822
|
+
|
|
823
|
+
const p = store50.save(run); // 热路径建批
|
|
824
|
+
// setTimeout 以构造参数 50 被调用,返回的 timer unref() 恰 1 次
|
|
825
|
+
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
|
826
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 50);
|
|
827
|
+
expect(fakeTimer.unref).toHaveBeenCalledTimes(1);
|
|
828
|
+
|
|
829
|
+
void store50.save(run); // 并入已有批:不重复创建 timer
|
|
830
|
+
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
|
831
|
+
// 不 advance(timer 是 stub 假对象永不触发);save Promise 由 store 实例持有,
|
|
832
|
+
// tmpDir 随 afterEach 清理,无悬挂写盘
|
|
833
|
+
void p;
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("W2TC13: 去抖窗口崩溃语义:未 flush 的 run 对 loadAll 不可见(丢失边界 = 最后一次成功 flush)", async () => {
|
|
837
|
+
// mockPi + mockCtx entries 数组模式(对齐 W1 round-trip 用例)
|
|
838
|
+
const entries: CustomEntry[] = [];
|
|
839
|
+
const mockPi = mkPi(entries);
|
|
840
|
+
const mockCtx = mkCtx(entries);
|
|
841
|
+
|
|
842
|
+
const storeA = new JsonlRunStore({
|
|
843
|
+
sessionDir: tmpDir,
|
|
844
|
+
pi: mockPi,
|
|
845
|
+
ctx: mockCtx,
|
|
846
|
+
});
|
|
847
|
+
const run = makeRunningRun("run-w2tc13");
|
|
848
|
+
await storeA.save(run); // 首写冷路径落盘 + 创建指针
|
|
849
|
+
|
|
850
|
+
// 去抖窗口内「崩溃」:中间态批 pending(不 advance;save Promise 留引用供
|
|
851
|
+
// 对照阶段 await,防 unhandled rejection)
|
|
852
|
+
run.state.trace.append(makeTraceNode(1));
|
|
853
|
+
const pHot = storeA.save(run);
|
|
854
|
+
|
|
855
|
+
// 重启恢复:新 store 实例 loadAll 只能看到最后一次成功 flush 的状态
|
|
856
|
+
const storeB = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
|
|
857
|
+
const loaded1 = await storeB.loadAll();
|
|
858
|
+
expect(loaded1).toHaveLength(1);
|
|
859
|
+
expect(loaded1[0]!.state.trace.toArray()).toHaveLength(1); // 中间态丢失(崩溃窗口 ≤saveDebounceMs 的已接受语义)
|
|
860
|
+
|
|
861
|
+
// 对照锚点:advance 后(无崩溃)新 loadAll 返回含中间态的最新快照
|
|
862
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
863
|
+
await pHot; // 热批 flush 落盘完成
|
|
864
|
+
const loaded2 = await storeB.loadAll();
|
|
865
|
+
expect(loaded2).toHaveLength(1);
|
|
866
|
+
expect(loaded2[0]!.state.trace.toArray()).toHaveLength(2);
|
|
867
|
+
});
|
|
868
|
+
});
|