@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
|
@@ -70,15 +70,11 @@ describe("statusGlyph", () => {
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("done → checkmark, success", () => {
|
|
73
|
-
expect(statusGlyph("
|
|
73
|
+
expect(statusGlyph("closed")).toEqual({ icon: "✓", color: "success" });
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it("
|
|
77
|
-
expect(statusGlyph("
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("cancelled → square, muted", () => {
|
|
81
|
-
expect(statusGlyph("cancelled")).toEqual({ icon: "■", color: "muted" });
|
|
76
|
+
it("closed → checkmark, success", () => {
|
|
77
|
+
expect(statusGlyph("closed")).toEqual({ icon: "✓", color: "success" });
|
|
82
78
|
});
|
|
83
79
|
});
|
|
84
80
|
|
|
@@ -260,6 +256,134 @@ describe("truncLine", () => {
|
|
|
260
256
|
});
|
|
261
257
|
});
|
|
262
258
|
|
|
259
|
+
// ============================================================
|
|
260
|
+
// truncLine — legacy 对拍(indexOf 化重构的逐字节等价锚定)
|
|
261
|
+
// ============================================================
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 旧实现参照:改造前的 per-char 版本,逐字取自 git 历史(093e28fe3~1 的
|
|
265
|
+
* format.ts truncLine)。现行实现(indexOf("\\x1b") 段跳过)必须与其逐字节
|
|
266
|
+
* 等价——本函数即对拍 oracle,与 helpers-bounded-serialize.test.ts 的
|
|
267
|
+
* legacySerialize 同形态(参照物随测试落盘,防「验证只存在于改造当时」)。
|
|
268
|
+
*/
|
|
269
|
+
const legacySegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
270
|
+
|
|
271
|
+
function legacyTruncLine(text: string, maxWidth: number): string {
|
|
272
|
+
if (maxWidth <= 0) return "";
|
|
273
|
+
const flat = text.replace(/[\r\n]+/g, " ");
|
|
274
|
+
if (visibleWidth(flat) <= maxWidth) return flat;
|
|
275
|
+
|
|
276
|
+
const targetWidth = Math.max(0, maxWidth - 1);
|
|
277
|
+
let result = "";
|
|
278
|
+
let currentWidth = 0;
|
|
279
|
+
let activeStyles: string[] = [];
|
|
280
|
+
let i = 0;
|
|
281
|
+
|
|
282
|
+
while (i < flat.length) {
|
|
283
|
+
// 捕获 ANSI SGR 序列
|
|
284
|
+
const ansiMatch = flat.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
285
|
+
if (ansiMatch) {
|
|
286
|
+
const code = ansiMatch[0];
|
|
287
|
+
result += code;
|
|
288
|
+
|
|
289
|
+
if (code === "\x1b[0m" || code === "\x1b[m") {
|
|
290
|
+
activeStyles = []; // reset → 清空栈
|
|
291
|
+
} else {
|
|
292
|
+
activeStyles.push(code);
|
|
293
|
+
}
|
|
294
|
+
i += code.length;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 找到下一段纯文本(非 ANSI)的边界
|
|
299
|
+
let end = i;
|
|
300
|
+
while (end < flat.length && !flat.slice(end).match(/^\x1b\[[0-9;]*m/)) {
|
|
301
|
+
end++;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 按 grapheme 迭代这段文本,累加到 targetWidth
|
|
305
|
+
const textPortion = flat.slice(i, end);
|
|
306
|
+
for (const seg of legacySegmenter.segment(textPortion)) {
|
|
307
|
+
const grapheme = seg.segment;
|
|
308
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
309
|
+
|
|
310
|
+
if (currentWidth + graphemeWidth > targetWidth) {
|
|
311
|
+
return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
result += grapheme;
|
|
315
|
+
currentWidth += graphemeWidth;
|
|
316
|
+
}
|
|
317
|
+
i = end;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 理论上 visibleWidth 检查已提前返回,此行兜底
|
|
321
|
+
return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 确定性 PRNG(mulberry32):fuzz 用例固定 seed 生成,失败可按 case 编号 + seed 复现。 */
|
|
325
|
+
function mulberry32(seed: number): () => number {
|
|
326
|
+
let s = seed | 0;
|
|
327
|
+
return () => {
|
|
328
|
+
s = (s + 0x6d2b79f5) | 0;
|
|
329
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
330
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
331
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 定向混排片段池:ASCII / CJK / emoji(含肤色修饰与 ZWJ 组合)/ SGR / 非 SGR ESC(OSC、CSI-K、裸 ESC)/ 换行。 */
|
|
336
|
+
const FUZZ_PIECES = [
|
|
337
|
+
"plain", " ", "a", "0", "MiXeD-Case_123",
|
|
338
|
+
"你好世界", "汉字宽度测试", "日本語テキスト",
|
|
339
|
+
"😀", "😁😂🤣", "👍🏽", "👨👩👧👦", "é", "é",
|
|
340
|
+
"\x1b[31m", "\x1b[0m", "\x1b[m", "\x1b[1;32m", "\x1b[38;5;196m",
|
|
341
|
+
"\x1b[K", "\x1b]0;title\x07", "\x1bZ", "\x1b",
|
|
342
|
+
"\n", "\r\n",
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
function genFuzzText(rand: () => number): string {
|
|
346
|
+
const pieceCount = Math.floor(rand() * 40);
|
|
347
|
+
let out = "";
|
|
348
|
+
for (let p = 0; p < pieceCount; p++) {
|
|
349
|
+
out += FUZZ_PIECES[Math.floor(rand() * FUZZ_PIECES.length)];
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
describe("truncLine — legacy 对拍(per-char 参照逐字节等价)", () => {
|
|
355
|
+
it("定向 fuzz:300 随机 ESC/SGR/CJK/emoji 混排用例(固定 seed 可复现)与旧实现逐字节一致", () => {
|
|
356
|
+
const rand = mulberry32(20260815);
|
|
357
|
+
let truncated = 0;
|
|
358
|
+
for (let i = 0; i < 300; i++) {
|
|
359
|
+
const text = genFuzzText(rand);
|
|
360
|
+
const maxWidth = Math.floor(rand() * 61); // 0..60,含 0/1 边界
|
|
361
|
+
const actual = truncLine(text, maxWidth);
|
|
362
|
+
expect(
|
|
363
|
+
actual,
|
|
364
|
+
`case #${i} text=${JSON.stringify(text)} maxWidth=${maxWidth}`,
|
|
365
|
+
).toBe(legacyTruncLine(text, maxWidth));
|
|
366
|
+
if (actual.endsWith("…")) truncated++;
|
|
367
|
+
}
|
|
368
|
+
// 前置有效性:fuzz 确实命中了截断路径(否则对拍只测了「宽度内原样返回」早退分支)
|
|
369
|
+
expect(truncated).toBeGreaterThan(100);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("手工对抗用例:SGR 重叠 / OSC 内截断 / CSI-K 混排 / 串尾裸 ESC", () => {
|
|
373
|
+
const cases: Array<[string, number]> = [
|
|
374
|
+
["\x1b[31m红\x1b[32m绿\x1b[1;4m加粗下划线尾巴超宽截断点", 9],
|
|
375
|
+
["AB\x1b]0;title\x07CDEFGHIJKLMNOP", 10],
|
|
376
|
+
["\x1b[31mred\x1b[K\x1b[32mgreen tail past width", 10],
|
|
377
|
+
["text almost at width limit\x1b", 22],
|
|
378
|
+
["before \x1bZ after bare esc tail", 12],
|
|
379
|
+
["\x1b[m你\x1b[0m好\x1b[38;5;196m世👨👩👧👦界".repeat(3), 14],
|
|
380
|
+
];
|
|
381
|
+
for (const [text, maxWidth] of cases) {
|
|
382
|
+
expect(truncLine(text, maxWidth)).toBe(legacyTruncLine(text, maxWidth));
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
263
387
|
// ============================================================
|
|
264
388
|
// wrapText
|
|
265
389
|
// ============================================================
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/execution/__tests__/gc-timer.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [M8] idle record GC 定时器测试(subagent-service.ts startGcTimer,L485-507)。
|
|
4
|
+
//
|
|
5
|
+
// 背景:GC 定时器(1h interval 扫描 + isResumable && idleSince 30 天 TTL + store.archive)
|
|
6
|
+
// 此前零测试覆盖——所有相关测试把 startGcTimer mock 成 no-op(index-session-start /
|
|
7
|
+
// crash-recovery / stream-sink-guard / session-start-reaper / index-session-start-identity)。
|
|
8
|
+
// 误判 isResumable/idleSince 会把可续聊 record 从内存错误归档(用户数据路径),
|
|
9
|
+
// 或永不触发(内存驻留失效)——两个方向都无回归拦截。
|
|
10
|
+
//
|
|
11
|
+
// 本文件用真实 SubagentService + 真实 startGcTimer(不 mock GC 逻辑本身),仅 mock
|
|
12
|
+
// session-runner(import 链依赖 + isResumable 的 hasLiveProcessHandle 查询点)与 logger。
|
|
13
|
+
// timer 全部 vi.useFakeTimers,不真实等待。
|
|
14
|
+
|
|
15
|
+
import type { ChildProcess } from "node:child_process";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
23
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
|
|
26
|
+
|
|
27
|
+
// mock session-runner:runSpawn/killAllSpawnedChildren 占位(import 链需要),
|
|
28
|
+
// getChildByRecord 默认返回 undefined(无活进程 → isResumable=true),用例内按需覆盖。
|
|
29
|
+
vi.mock("../session-runner.ts", () => ({
|
|
30
|
+
runSpawn: vi.fn(),
|
|
31
|
+
killAllSpawnedChildren: vi.fn(),
|
|
32
|
+
getChildByRecord: vi.fn(() => undefined),
|
|
33
|
+
spawnedChildren: new Map(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { getChildByRecord } from "../session-runner.ts";
|
|
37
|
+
import { createRecord } from "../execution-record.ts";
|
|
38
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
39
|
+
import { RecordStore } from "../record-store.ts";
|
|
40
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
41
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
42
|
+
|
|
43
|
+
const mockGetChildByRecord = vi.mocked(getChildByRecord);
|
|
44
|
+
|
|
45
|
+
/** 与 startGcTimer 内部常量一致(1h 扫描 / 30 天 TTL)。 */
|
|
46
|
+
const GC_INTERVAL_MS = 60 * 60 * 1000;
|
|
47
|
+
const IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
/** initSession 注入的最小 pi duck-type(同 subagent-service PiLike 形状,结构匹配即可)。 */
|
|
50
|
+
interface PiStub {
|
|
51
|
+
appendEntry(customType: string, data?: unknown): void;
|
|
52
|
+
events: { emit(channel: string, data: unknown): void };
|
|
53
|
+
sendMessage(
|
|
54
|
+
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
55
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
56
|
+
): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makePi(): PiStub {
|
|
60
|
+
return {
|
|
61
|
+
appendEntry: vi.fn(),
|
|
62
|
+
events: { emit: vi.fn() },
|
|
63
|
+
sendMessage: vi.fn(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 构造 running + 无活进程 + 指定 idleSince 的 record(GC 扫描的目标态)。
|
|
68
|
+
* listAllActive 只返回 status==="running"(createRecord 初始即 running)。 */
|
|
69
|
+
function makeIdleRecord(id: string, idleSince: number): ExecutionRecord {
|
|
70
|
+
const record = createRecord(id, {
|
|
71
|
+
agent: "general-purpose",
|
|
72
|
+
model: "test/model",
|
|
73
|
+
mode: "background",
|
|
74
|
+
task: "test",
|
|
75
|
+
slug: "test",
|
|
76
|
+
startedAt: idleSince,
|
|
77
|
+
rootSessionId: "root-session",
|
|
78
|
+
chatMode: true,
|
|
79
|
+
controller: new AbortController(),
|
|
80
|
+
});
|
|
81
|
+
record.idleSince = idleSince;
|
|
82
|
+
return record;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 暴露私有字段供测试读取 store。 */
|
|
86
|
+
interface ServiceInternals {
|
|
87
|
+
store: RecordStore;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("[M8] idle record GC 定时器(startGcTimer)", () => {
|
|
91
|
+
let agentDir: string;
|
|
92
|
+
let service: SubagentService;
|
|
93
|
+
let store: RecordStore;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.useFakeTimers();
|
|
97
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "gc-timer-"));
|
|
98
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
99
|
+
service = new SubagentService({ cwd: agentDir, modelService });
|
|
100
|
+
service.initSession({ pi: makePi(), sessionId: "root-session" });
|
|
101
|
+
store = (service as unknown as ServiceInternals).store;
|
|
102
|
+
mockGetChildByRecord.mockReset();
|
|
103
|
+
// 默认无活进程(isResumable=true)——Path B:进程已回收、可冷路径 resume 的等待续聊态
|
|
104
|
+
mockGetChildByRecord.mockImplementation(() => undefined);
|
|
105
|
+
loggerMock.warn.mockClear();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
service.dispose();
|
|
110
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
111
|
+
vi.useRealTimers();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("TTL 边界:超 1ms 归档、差 1ms 不归档(同一轮扫描)", () => {
|
|
115
|
+
// idleSince 相对 interval 首次触发时刻(fake clock + 1h)构造:
|
|
116
|
+
// over = fireAt - TTL - 1 → 扫描时 age = TTL + 1 > TTL → archive
|
|
117
|
+
// under = fireAt - TTL + 1 → 扫描时 age = TTL - 1 ≤ TTL → 留内存
|
|
118
|
+
const fireAt = Date.now() + GC_INTERVAL_MS;
|
|
119
|
+
store.register(makeIdleRecord("sa-over", fireAt - IDLE_TTL_MS - 1));
|
|
120
|
+
store.register(makeIdleRecord("sa-under", fireAt - IDLE_TTL_MS + 1));
|
|
121
|
+
|
|
122
|
+
service.startGcTimer();
|
|
123
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS);
|
|
124
|
+
|
|
125
|
+
expect(store.getMutable("sa-over")).toBeUndefined(); // archive 从内存移除
|
|
126
|
+
expect(store.getMutable("sa-under")).toBeDefined(); // 未超 TTL 留内存
|
|
127
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
129
|
+
expect.stringContaining("GC: archiving idle record sa-over"),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("有活进程的 record 不归档(isResumable=false,即使 idleSince 超 TTL)", () => {
|
|
134
|
+
const fireAt = Date.now() + GC_INTERVAL_MS;
|
|
135
|
+
// Path A:进程保活等待续聊(idle timer armed、child 未 kill)——归档会让活进程失管
|
|
136
|
+
store.register(makeIdleRecord("sa-live", fireAt - IDLE_TTL_MS - 1));
|
|
137
|
+
mockGetChildByRecord.mockImplementation(
|
|
138
|
+
(id: string): ChildProcess | undefined =>
|
|
139
|
+
id === "sa-live" ? ({ killed: false } as unknown as ChildProcess) : undefined,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
service.startGcTimer();
|
|
143
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS);
|
|
144
|
+
|
|
145
|
+
expect(store.getMutable("sa-live")).toBeDefined(); // 有活进程 → 不归档
|
|
146
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("每小时重复扫描:上一轮差 1ms 的 record 下一轮超龄被归档", () => {
|
|
150
|
+
const fireAt = Date.now() + GC_INTERVAL_MS;
|
|
151
|
+
store.register(makeIdleRecord("sa-late", fireAt - IDLE_TTL_MS + 1));
|
|
152
|
+
|
|
153
|
+
service.startGcTimer();
|
|
154
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS);
|
|
155
|
+
expect(store.getMutable("sa-late")).toBeDefined(); // 第一轮:age = TTL - 1,未到
|
|
156
|
+
|
|
157
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS); // 第二轮:age = TTL + 1h - 1 > TTL
|
|
158
|
+
expect(store.getMutable("sa-late")).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("startGcTimer 幂等:重复调用不产生第二个 interval", () => {
|
|
162
|
+
const fireAt = Date.now() + GC_INTERVAL_MS;
|
|
163
|
+
store.register(makeIdleRecord("sa-over", fireAt - IDLE_TTL_MS - 1));
|
|
164
|
+
|
|
165
|
+
service.startGcTimer();
|
|
166
|
+
service.startGcTimer(); // 已有 timer 直接 return(防重复 interval)
|
|
167
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS);
|
|
168
|
+
|
|
169
|
+
// 同一 record 一轮只扫一次(重复 interval 会对同一 record 产生第二条 GC warn)
|
|
170
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("dispose 后 interval 停止:超 TTL record 不再被归档", () => {
|
|
174
|
+
service.startGcTimer();
|
|
175
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS); // 第一轮正常触发
|
|
176
|
+
service.dispose(); // stopGcTimer + disposeAllRecords
|
|
177
|
+
|
|
178
|
+
// dispose 后塞入超 TTL record,interval 已停 → 无论推进多久都不归档
|
|
179
|
+
store.register(makeIdleRecord("sa-after-dispose", Date.now() - IDLE_TTL_MS - 1));
|
|
180
|
+
vi.advanceTimersByTime(GC_INTERVAL_MS * 10);
|
|
181
|
+
|
|
182
|
+
expect(store.getMutable("sa-after-dispose")).toBeDefined();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// src/execution/__tests__/get-record-for-action-restart.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [M10] getRecordForAction 跨重启磁盘重建分支测试(subagent-service.ts L933-951)。
|
|
4
|
+
//
|
|
5
|
+
// 背景:内存 miss → collectRecords(1000,"all",undefined).find(status==="running") →
|
|
6
|
+
// createRecord({chatMode: true}) → register → 回填 sessionFile/round。代码注释
|
|
7
|
+
// (L946-949)自设强制约束「改动此处必须带 S3 回归场景(跨重启 message 续聊验证)」
|
|
8
|
+
// ——该 S3 场景此前不存在,分支零测试。该分支含多个仅此处独有的决策:
|
|
9
|
+
// - 无条件 chatMode: true(v4 A-3 跨重启恢复入口,V3 方案 A 方向)
|
|
10
|
+
// - rootSessionFilter 传 undefined 后置校验(异树仍 throw not owned)
|
|
11
|
+
// - sessionFile/round 从磁盘重建结果回填(round 无磁盘持久化 → undefined)
|
|
12
|
+
//
|
|
13
|
+
// fixture 构造参照 record-store.test.ts 的 writeSessionJsonl(真实 .jsonl 文件,
|
|
14
|
+
// identity custom entry + assistant message;无 .alive sidecar → record-store
|
|
15
|
+
// sidecar 矩阵分支 4 → status="running" 跨重启可续聊态)。
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
|
+
|
|
23
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
24
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
|
|
27
|
+
|
|
28
|
+
// mock session-runner:import 链需要(getRecordForAction 本身不调 spawn,仅守卫读
|
|
29
|
+
// hasLiveProcessHandle——默认无活进程,与跨重启场景一致)。
|
|
30
|
+
vi.mock("../session-runner.ts", () => ({
|
|
31
|
+
runSpawn: vi.fn(),
|
|
32
|
+
killAllSpawnedChildren: vi.fn(),
|
|
33
|
+
getChildByRecord: vi.fn(() => undefined),
|
|
34
|
+
spawnedChildren: new Map(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { writeFinalized } from "../finalized-marker.ts";
|
|
38
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
39
|
+
import { getSubagentSessionDir } from "../path-encoding.ts";
|
|
40
|
+
import { RecordStore } from "../record-store.ts";
|
|
41
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
42
|
+
|
|
43
|
+
// 身份 env 名(与 subagent-service.ts 常量一致;beforeEach/afterEach 清理防泄漏——
|
|
44
|
+
// 测试进程可能继承 subagent env,会污染 rootCwd 编码目录与 sessionRootId 基线)。
|
|
45
|
+
const IDENTITY_ENV_KEYS = [
|
|
46
|
+
"PI_SUBAGENT_ROOT_SESSION_ID",
|
|
47
|
+
"PI_SUBAGENT_SELF_RECORD_ID",
|
|
48
|
+
"PI_SUBAGENT_DEPTH",
|
|
49
|
+
"PI_SUBAGENT_ROOT_CWD",
|
|
50
|
+
"PI_SUBAGENT_FORK_DEPTH",
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
/** initSession 注入的最小 pi duck-type(同 subagent-service PiLike 形状,结构匹配即可)。 */
|
|
54
|
+
interface PiStub {
|
|
55
|
+
appendEntry(customType: string, data?: unknown): void;
|
|
56
|
+
events: { emit(channel: string, data: unknown): void };
|
|
57
|
+
sendMessage(
|
|
58
|
+
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
59
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
60
|
+
): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makePi(): PiStub {
|
|
64
|
+
return {
|
|
65
|
+
appendEntry: vi.fn(),
|
|
66
|
+
events: { emit: vi.fn() },
|
|
67
|
+
sendMessage: vi.fn(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 写一个最小合法 session.jsonl(session header + identity entry + 1 条 assistant message)。
|
|
72
|
+
* 不写任何 sidecar(.alive/.cancelled/.finalized)→ sidecar 矩阵分支 4 → running。 */
|
|
73
|
+
function writeSessionJsonl(
|
|
74
|
+
sessionsDir: string,
|
|
75
|
+
identity: {
|
|
76
|
+
id: string;
|
|
77
|
+
rootSessionId: string;
|
|
78
|
+
parentRecordId?: string;
|
|
79
|
+
depth?: number;
|
|
80
|
+
chatMode?: boolean;
|
|
81
|
+
worktree?: boolean;
|
|
82
|
+
},
|
|
83
|
+
): string {
|
|
84
|
+
const file = path.join(sessionsDir, `${identity.id}.jsonl`);
|
|
85
|
+
const startedAt = 1_700_000_000_000;
|
|
86
|
+
const lines = [
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
type: "session",
|
|
89
|
+
version: 3,
|
|
90
|
+
id: "sess-uuid",
|
|
91
|
+
timestamp: new Date(startedAt).toISOString(),
|
|
92
|
+
cwd: "/tmp",
|
|
93
|
+
}),
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
type: "custom",
|
|
96
|
+
id: "id-1",
|
|
97
|
+
parentId: null,
|
|
98
|
+
timestamp: new Date(startedAt).toISOString(),
|
|
99
|
+
customType: "subagent-identity",
|
|
100
|
+
data: {
|
|
101
|
+
id: identity.id,
|
|
102
|
+
agent: "general-purpose",
|
|
103
|
+
mode: "background",
|
|
104
|
+
task: "restart recovery task",
|
|
105
|
+
slug: "restart-test",
|
|
106
|
+
startedAt,
|
|
107
|
+
rootSessionId: identity.rootSessionId,
|
|
108
|
+
...(identity.parentRecordId !== undefined ? { parentRecordId: identity.parentRecordId } : {}),
|
|
109
|
+
...(identity.depth !== undefined ? { depth: identity.depth } : {}),
|
|
110
|
+
...(identity.chatMode !== undefined ? { chatMode: identity.chatMode } : {}),
|
|
111
|
+
...(identity.worktree !== undefined ? { worktree: identity.worktree } : {}),
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
type: "message",
|
|
116
|
+
id: "msg-1",
|
|
117
|
+
parentId: "id-1",
|
|
118
|
+
timestamp: new Date(startedAt + 1000).toISOString(),
|
|
119
|
+
message: {
|
|
120
|
+
role: "assistant",
|
|
121
|
+
content: [{ type: "text", text: "first round done" }],
|
|
122
|
+
usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0 },
|
|
123
|
+
stopReason: "stop",
|
|
124
|
+
timestamp: startedAt + 1000,
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
];
|
|
128
|
+
fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf-8");
|
|
129
|
+
return file;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 暴露私有 store 供断言 register 副作用。 */
|
|
133
|
+
interface ServiceInternals {
|
|
134
|
+
store: RecordStore;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("[M10] getRecordForAction 跨重启磁盘重建(S3 回归场景)", () => {
|
|
138
|
+
let agentDir: string;
|
|
139
|
+
let sessionsDir: string;
|
|
140
|
+
let service: SubagentService;
|
|
141
|
+
let store: RecordStore;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
for (const k of IDENTITY_ENV_KEYS) delete process.env[k];
|
|
145
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "restart-recon-"));
|
|
146
|
+
sessionsDir = getSubagentSessionDir(agentDir, agentDir);
|
|
147
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
150
|
+
service = new SubagentService({ cwd: agentDir, modelService });
|
|
151
|
+
// 根进程身份:无 env → sessionRootId = sessionId(自己是 root)、execCtxBaseline = null
|
|
152
|
+
service.initSession({ pi: makePi(), sessionId: "root-session" });
|
|
153
|
+
store = (service as unknown as ServiceInternals).store;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
service.dispose();
|
|
158
|
+
// maxRetries:collectRecords 触发的 fire-and-forget sessions-index 写可能与删除
|
|
159
|
+
// 并发(ENOTEMPTY 竞态,全量并行跑时机器负载高会放大窗口)
|
|
160
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
161
|
+
for (const k of IDENTITY_ENV_KEYS) delete process.env[k];
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("内存 miss + 磁盘 running(无 .alive)→ 重建 record:chatMode 无条件 true、sessionFile 回填、register 进内存", () => {
|
|
165
|
+
// identity entry 故意不带 chatMode 字段——证明重建分支的无条件 chatMode:true
|
|
166
|
+
//(不依赖磁盘是否记录过对话模式标志)
|
|
167
|
+
const file = writeSessionJsonl(sessionsDir, { id: "sa-restart-1", rootSessionId: "root-session" });
|
|
168
|
+
expect(store.getMutable("sa-restart-1")).toBeUndefined(); // 前置:内存确无
|
|
169
|
+
|
|
170
|
+
const record = service.getRecordForAction("sa-restart-1");
|
|
171
|
+
|
|
172
|
+
// [v4 A-3] 无条件 chatMode=true(跨重启恢复入口)
|
|
173
|
+
expect(record.chatMode).toBe(true);
|
|
174
|
+
// sessionFile 从磁盘重建结果回填
|
|
175
|
+
expect(record.sessionFile).toBe(file);
|
|
176
|
+
// round 从 found 回填:light 磁盘重建无 round(内存态字段,跨重启不恢复)→ undefined
|
|
177
|
+
expect(record.round).toBeUndefined();
|
|
178
|
+
// 身份字段从磁盘 identity entry 回填
|
|
179
|
+
expect(record.agent).toBe("general-purpose");
|
|
180
|
+
expect(record.task).toBe("restart recovery task");
|
|
181
|
+
expect(record.slug).toBe("restart-test");
|
|
182
|
+
expect(record.rootSessionId).toBe("root-session");
|
|
183
|
+
expect(record.parentRecordId).toBeUndefined();
|
|
184
|
+
expect(record.status).toBe("running");
|
|
185
|
+
// register 生效:进内存,二次调用走内存命中(同一引用)
|
|
186
|
+
expect(store.getMutable("sa-restart-1")).toBe(record);
|
|
187
|
+
expect(service.getRecordForAction("sa-restart-1")).toBe(record);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("rootSessionId 校验仍生效:异树 record(other-session)→ throw not found or not owned", () => {
|
|
191
|
+
// rootSessionFilter 传 undefined(扫全量磁盘),异树 record 会被 find 命中并重建,
|
|
192
|
+
// 但后置校验 record.rootSessionId !== this.sessionRootId 必须拦截
|
|
193
|
+
writeSessionJsonl(sessionsDir, { id: "sa-foreign", rootSessionId: "other-session" });
|
|
194
|
+
|
|
195
|
+
expect(() => service.getRecordForAction("sa-foreign")).toThrow(/not found or not owned/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("直接父校验仍生效:孙级 record(parentRecordId=sa-parent)→ 主进程 throw direct parent", () => {
|
|
199
|
+
writeSessionJsonl(sessionsDir, {
|
|
200
|
+
id: "sa-grand",
|
|
201
|
+
rootSessionId: "root-session",
|
|
202
|
+
parentRecordId: "sa-parent",
|
|
203
|
+
depth: 2,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// 重建后 parentRecordId=sa-parent ≠ 主进程 baseline(undefined) → cross-layer 守卫拦截
|
|
207
|
+
expect(() => service.getRecordForAction("sa-grand")).toThrow(/direct parent/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it(".finalized sidecar(closed 终态)不重建 → throw not found or not owned", () => {
|
|
211
|
+
const file = writeSessionJsonl(sessionsDir, { id: "sa-fin", rootSessionId: "root-session" });
|
|
212
|
+
writeFinalized(file); // sidecar 矩阵分支 2 → status=closed → find(status==="running") miss
|
|
213
|
+
|
|
214
|
+
expect(() => service.getRecordForAction("sa-fin")).toThrow(/not found or not owned/);
|
|
215
|
+
expect(store.getMutable("sa-fin")).toBeUndefined(); // 未重建注册
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ============================================================
|
|
219
|
+
// [review round2] 跨重启 worktree 绑定丢失防护
|
|
220
|
+
// ============================================================
|
|
221
|
+
// 场景:worktree:true + conversation:true 的 subagent 在父进程重启后续聊。WorktreeHandle
|
|
222
|
+
// 不可序列化、重建后恒缺失,若无守卫 → 冷路径 resume 的 spawn cwd 静默回落主 repo
|
|
223
|
+
//(隔离失效,正是 worktree 要防的并发写冲突场景)。
|
|
224
|
+
it("[review round2] worktree record 跨重启重建 → hadWorktree 标记 + 续聊被拒(行动语言)", async () => {
|
|
225
|
+
writeSessionJsonl(sessionsDir, {
|
|
226
|
+
id: "sa-wt",
|
|
227
|
+
rootSessionId: "root-session",
|
|
228
|
+
worktree: true,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const record = service.getRecordForAction("sa-wt");
|
|
232
|
+
// hadWorktree 从磁盘 identity entry 的 worktree 标志恢复
|
|
233
|
+
expect(record.hadWorktree).toBe(true);
|
|
234
|
+
// handle 无法恢复(不可序列化)
|
|
235
|
+
expect(record.worktreeHandle).toBeUndefined();
|
|
236
|
+
|
|
237
|
+
// 冷路径续聊(无活进程)→ resumeRound 守卫拒绝,不 spawn 回落主 repo
|
|
238
|
+
await expect(service.deliverMessage(record, "resume after restart", false)).rejects.toThrow(
|
|
239
|
+
/worktree isolation.*lost when the parent process restarted/,
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("[review round2] 非 worktree record 跨重启重建 → 续聊不受 worktree 守卫拦截(向后兼容)", async () => {
|
|
244
|
+
// identity entry 无 worktree 字段(旧文件)→ found.worktree undefined → hadWorktree false
|
|
245
|
+
writeSessionJsonl(sessionsDir, { id: "sa-nowt", rootSessionId: "root-session" });
|
|
246
|
+
|
|
247
|
+
const record = service.getRecordForAction("sa-nowt");
|
|
248
|
+
expect(record.hadWorktree).toBe(false);
|
|
249
|
+
|
|
250
|
+
// deliverMessage 冷路径不被 worktree 守卫拦截(resume 正常发起;后续 spawn 编排
|
|
251
|
+
// 超出本用例关注点——runSpawn 在本文件是 no-op mock)
|
|
252
|
+
await expect(service.deliverMessage(record, "resume normal", false)).resolves.toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -24,7 +24,16 @@
|
|
|
24
24
|
// const { FakeChild } = await import("./helpers/spawn-mock.ts");
|
|
25
25
|
// return {
|
|
26
26
|
// spawn: vi.fn(() => new FakeChild()),
|
|
27
|
-
//
|
|
27
|
+
// // buildEnvBlock 用 execFile 异步取 git branch:默认 err-first 兜底(catch → branch=""),
|
|
28
|
+
// // 形态同 worktree-manager.test.ts 的 setupExecFile
|
|
29
|
+
// execFile: vi.fn(
|
|
30
|
+
// (
|
|
31
|
+
// _cmd: string,
|
|
32
|
+
// _args: readonly string[],
|
|
33
|
+
// _opts: unknown,
|
|
34
|
+
// cb: (err: Error | null, stdout?: string, stderr?: string) => void,
|
|
35
|
+
// ) => cb(new Error("execFile not configured in this test")),
|
|
36
|
+
// ),
|
|
28
37
|
// };
|
|
29
38
|
// });
|
|
30
39
|
// vi.mock("node:fs", async () => {
|
|
@@ -111,9 +120,17 @@ export function lastSpawnedChild<
|
|
|
111
120
|
/**
|
|
112
121
|
* 等待 runSpawn 内部调到 spawn(拿到 child 控制器)。
|
|
113
122
|
*
|
|
114
|
-
* runSpawn 是 async,spawn 在
|
|
115
|
-
|
|
116
|
-
*
|
|
123
|
+
* runSpawn 是 async,spawn 在 buildEnvBlock + writePromptToTempFile 两个 await 之后才调
|
|
124
|
+
*(均为微任务级延迟)。用 setTimeout 轮询 mockSpawn.mock.results,比 vi.waitFor 在该
|
|
125
|
+
* vitest 版本下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
|
|
126
|
+
*
|
|
127
|
+
* [快照语义] 等待的是「调用时刻之后**新发生**的一次 spawn」,而非「任意历史 spawn」。
|
|
128
|
+
* 旧实现只在 results 为空时等待(即只在文件/测试内首次 spawn 有效):同一文件第二次
|
|
129
|
+
* runSpawn 时立即返回,而新 runSpawn 尚未跨过 await 到达 spawn → lastSpawnedChild 取回
|
|
130
|
+
* **上一次**的 child,stdout/close 事件发给已死的旧 child,当前 runSpawn 永远收不到
|
|
131
|
+
* close → 测试超时。buildEnvBlock 异步化(多一个 await 微任务)后该隐式时序假设失效,
|
|
132
|
+
* 故改为快照 baseline:等待 results.length 超过调用时的快照值,天然对「每测试/每文件
|
|
133
|
+
* N 次 spawn」都正确(调用前先记 baseline,本次 runSpawn 的 spawn 必然使 length 增长)。
|
|
117
134
|
*
|
|
118
135
|
* @param mockSpawn 调用方用 `vi.mocked(spawn)` 取回的 mock 引用
|
|
119
136
|
*/
|
|
@@ -121,7 +138,8 @@ export async function waitForSpawn<
|
|
|
121
138
|
T extends { mock: { results: unknown[] } },
|
|
122
139
|
>(mockSpawn: T, timeoutMs = 1000): Promise<void> {
|
|
123
140
|
const start = Date.now();
|
|
124
|
-
|
|
141
|
+
const baseline = mockSpawn.mock.results.length;
|
|
142
|
+
while (mockSpawn.mock.results.length <= baseline) {
|
|
125
143
|
if (Date.now() - start > timeoutMs) {
|
|
126
144
|
throw new Error(`spawn was not called within ${timeoutMs}ms`);
|
|
127
145
|
}
|
|
@@ -159,8 +177,8 @@ export function mockSessionFileExists(
|
|
|
159
177
|
// ── record / opts / ctx 构造(3 文件一致的最小合法形状)──
|
|
160
178
|
|
|
161
179
|
/** 构造最小合法的 ExecutionRecord(runSpawn 入参)。 */
|
|
162
|
-
export function makeRecord() {
|
|
163
|
-
return createRecord(
|
|
180
|
+
export function makeRecord(id = "run-1") {
|
|
181
|
+
return createRecord(id, {
|
|
164
182
|
agent: "general-purpose",
|
|
165
183
|
model: "test-model",
|
|
166
184
|
mode: "sync",
|