@zhushanwen/pi-subagent-workflow 7.4.0 → 8.1.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.
Files changed (166) hide show
  1. package/README.md +15 -0
  2. package/package.json +7 -4
  3. package/scripts/rfl.mjs +308 -0
  4. package/skills/workflow-script-format/SKILL.md +1 -1
  5. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  7. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  8. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  9. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  10. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  11. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  12. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  13. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  14. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  15. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  16. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  17. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  18. package/src/execution/__tests__/execution-record.test.ts +199 -0
  19. package/src/execution/__tests__/finalize-record.test.ts +197 -15
  20. package/src/execution/__tests__/format.test.ts +131 -7
  21. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  22. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
  24. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  26. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  27. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  28. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  29. package/src/execution/__tests__/list-component.test.ts +59 -5
  30. package/src/execution/__tests__/list-fields.test.ts +109 -0
  31. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  32. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  33. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  34. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  35. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  36. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  37. package/src/execution/__tests__/record-store.test.ts +442 -54
  38. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  39. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  40. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  41. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  42. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  43. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  44. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  45. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  46. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  47. package/src/execution/__tests__/session-pending.test.ts +61 -2
  48. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  49. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  50. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  51. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  52. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  53. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  54. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  55. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  56. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  57. package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  59. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  60. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  61. package/src/execution/__tests__/tool-action.test.ts +12 -10
  62. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  63. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  64. package/src/execution/__tests__/worktree-manager.test.ts +300 -90
  65. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
  66. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  67. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  68. package/src/execution/agent-result-mapper.ts +4 -1
  69. package/src/execution/argv-mirror.ts +21 -2
  70. package/src/execution/channel-registry-access.ts +3 -1
  71. package/src/execution/execution-record.ts +126 -9
  72. package/src/execution/finalize-record.ts +99 -13
  73. package/src/execution/idle-gc.ts +47 -0
  74. package/src/execution/lifecycle-manager.ts +491 -0
  75. package/src/execution/lifecycle-predicates.ts +65 -0
  76. package/src/execution/manifest-store.ts +61 -16
  77. package/src/execution/model-resolver.ts +26 -5
  78. package/src/execution/notifier.ts +69 -12
  79. package/src/execution/pi-invocation.ts +21 -1
  80. package/src/execution/record-entry.ts +118 -0
  81. package/src/execution/record-store.ts +844 -108
  82. package/src/execution/session-pending.ts +121 -49
  83. package/src/execution/session-reconstructor.ts +224 -7
  84. package/src/execution/session-runner.ts +713 -316
  85. package/src/execution/sessions-index.ts +304 -0
  86. package/src/execution/stdin-writer.ts +93 -7
  87. package/src/execution/stream-sink.ts +20 -3
  88. package/src/execution/subagent-service.ts +917 -138
  89. package/src/execution/temp-prompt.ts +8 -3
  90. package/src/execution/turn-limiter.ts +14 -0
  91. package/src/execution/types.ts +218 -19
  92. package/src/execution/worktree-manager.ts +449 -59
  93. package/src/execution/worktree-registry.ts +97 -29
  94. package/src/index.ts +318 -20
  95. package/src/injectors/subagent-list-injector.ts +26 -8
  96. package/src/injectors/workflow-list-injector.ts +25 -8
  97. package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
  98. package/src/interface/__tests__/tool-render.test.ts +10 -8
  99. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  100. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  101. package/src/interface/bg-notify-render.ts +32 -8
  102. package/src/interface/command-actions.ts +26 -7
  103. package/src/interface/commands.ts +21 -22
  104. package/src/interface/format.ts +53 -20
  105. package/src/interface/gui-mappers.ts +6 -8
  106. package/src/interface/helpers.ts +170 -10
  107. package/src/interface/list-component.ts +53 -14
  108. package/src/interface/subagent-actions.ts +235 -17
  109. package/src/interface/subagent-tool.ts +81 -16
  110. package/src/interface/subagents.ts +2 -1
  111. package/src/interface/tool-render.ts +21 -27
  112. package/src/interface/tool-workflow-script.ts +29 -33
  113. package/src/interface/tool-workflow.ts +67 -100
  114. package/src/interface/views/WorkflowsView.ts +89 -32
  115. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  116. package/src/interface/views/detail-content.ts +1 -1
  117. package/src/interface/views/format.ts +3 -3
  118. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
  119. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  120. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  121. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  122. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  123. package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
  124. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  128. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  129. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  130. package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
  131. package/src/orchestration/__tests__/test-mocks.ts +197 -0
  132. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  133. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
  134. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  135. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  136. package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
  137. package/src/orchestration/agent-opts-resolver.ts +4 -1
  138. package/src/orchestration/args-validator.ts +2 -2
  139. package/src/orchestration/config-loader.ts +30 -1
  140. package/src/orchestration/error-recovery.ts +137 -30
  141. package/src/orchestration/execute-agent-call.ts +44 -10
  142. package/src/orchestration/jsonl-run-store.ts +397 -71
  143. package/src/orchestration/launcher.ts +7 -1
  144. package/src/orchestration/lifecycle.ts +145 -133
  145. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  146. package/src/orchestration/models/budget.ts +1 -1
  147. package/src/orchestration/models/run-runtime.ts +15 -17
  148. package/src/orchestration/models/run-spec.ts +2 -2
  149. package/src/orchestration/models/run-state.ts +3 -3
  150. package/src/orchestration/models/trace.ts +95 -15
  151. package/src/orchestration/models/types.ts +8 -9
  152. package/src/orchestration/models/workflow-run.ts +50 -71
  153. package/src/orchestration/models/workflow-script.ts +32 -1
  154. package/src/orchestration/skill-discovery.ts +30 -0
  155. package/src/orchestration/worker-handle.ts +1 -1
  156. package/src/orchestration/worker-host.ts +1 -1
  157. package/src/orchestration/worker-script-builder.ts +38 -11
  158. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  159. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  160. package/src/shared/__tests__/resource-discovery.test.ts +79 -0
  161. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  162. package/src/shared/agent-ref.ts +22 -1
  163. package/src/shared/resource-discovery.ts +162 -59
  164. package/src/shared/schema-jsonify.ts +56 -0
  165. package/workflows/review-fix-loop-utils.cjs +542 -32
  166. package/workflows/review-fix-loop.js +462 -109
@@ -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
- // execFileSync: vi.fn(() => ""),
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 () => {
@@ -54,6 +63,15 @@ import { PassThrough } from "node:stream";
54
63
  import { createRecord } from "../../execution-record.ts";
55
64
  import type { RunOptions, SessionRunnerContext } from "../../session-runner.ts";
56
65
 
66
+ /** FakeChild 的假 pid(满足 ChildProcess.pid 形状,无真实进程语义)。 */
67
+ const FAKE_PID = 12345;
68
+
69
+ /** waitForSpawn 默认超时(ms),超时说明 spawn 未被调(runSpawn 前置 await 卡死)。 */
70
+ const WAIT_SPAWN_TIMEOUT_MS = 1000;
71
+
72
+ /** waitForSpawn 轮询间隔(ms),微任务级等待的粒度。 */
73
+ const SPAWN_POLL_INTERVAL_MS = 5;
74
+
57
75
  /**
58
76
  * FakeChild:模拟 ChildProcess(EventEmitter + PassThrough streams)。
59
77
  *
@@ -65,7 +83,7 @@ import type { RunOptions, SessionRunnerContext } from "../../session-runner.ts";
65
83
  * 2. C13 e2e 测试可直接 `new FakeChild()` 手动构造 child 喂给 ui-request-queue(不经 spawn)。
66
84
  */
67
85
  export class FakeChild extends EventEmitter {
68
- pid = 12345;
86
+ pid = FAKE_PID;
69
87
  stdout = new PassThrough();
70
88
  stderr = new PassThrough();
71
89
  stdin = new PassThrough();
@@ -111,21 +129,30 @@ export function lastSpawnedChild<
111
129
  /**
112
130
  * 等待 runSpawn 内部调到 spawn(拿到 child 控制器)。
113
131
  *
114
- * runSpawn 是 async,spawn 在 mkdirSync + writePromptToTempFile 之后才调(均有微任务/
115
- * I/O 延迟)。用 setTimeout 轮询 mockSpawn.mock.results,比 vi.waitFor 在该 vitest 版本
116
- * 下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
132
+ * runSpawn 是 async,spawn 在 buildEnvBlock + writePromptToTempFile 两个 await 之后才调
133
+ *(均为微任务级延迟)。用 setTimeout 轮询 mockSpawn.mock.results,比 vi.waitFor 在该
134
+ * vitest 版本下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
135
+ *
136
+ * [快照语义] 等待的是「调用时刻之后**新发生**的一次 spawn」,而非「任意历史 spawn」。
137
+ * 旧实现只在 results 为空时等待(即只在文件/测试内首次 spawn 有效):同一文件第二次
138
+ * runSpawn 时立即返回,而新 runSpawn 尚未跨过 await 到达 spawn → lastSpawnedChild 取回
139
+ * **上一次**的 child,stdout/close 事件发给已死的旧 child,当前 runSpawn 永远收不到
140
+ * close → 测试超时。buildEnvBlock 异步化(多一个 await 微任务)后该隐式时序假设失效,
141
+ * 故改为快照 baseline:等待 results.length 超过调用时的快照值,天然对「每测试/每文件
142
+ * N 次 spawn」都正确(调用前先记 baseline,本次 runSpawn 的 spawn 必然使 length 增长)。
117
143
  *
118
144
  * @param mockSpawn 调用方用 `vi.mocked(spawn)` 取回的 mock 引用
119
145
  */
120
146
  export async function waitForSpawn<
121
147
  T extends { mock: { results: unknown[] } },
122
- >(mockSpawn: T, timeoutMs = 1000): Promise<void> {
148
+ >(mockSpawn: T, timeoutMs: number = WAIT_SPAWN_TIMEOUT_MS): Promise<void> {
123
149
  const start = Date.now();
124
- while (mockSpawn.mock.results.length === 0) {
150
+ const baseline = mockSpawn.mock.results.length;
151
+ while (mockSpawn.mock.results.length <= baseline) {
125
152
  if (Date.now() - start > timeoutMs) {
126
153
  throw new Error(`spawn was not called within ${timeoutMs}ms`);
127
154
  }
128
- await new Promise((r) => setTimeout(r, 5));
155
+ await new Promise((r) => setTimeout(r, SPAWN_POLL_INTERVAL_MS));
129
156
  }
130
157
  }
131
158
 
@@ -159,8 +186,8 @@ export function mockSessionFileExists(
159
186
  // ── record / opts / ctx 构造(3 文件一致的最小合法形状)──
160
187
 
161
188
  /** 构造最小合法的 ExecutionRecord(runSpawn 入参)。 */
162
- export function makeRecord() {
163
- return createRecord("run-1", {
189
+ export function makeRecord(id = "run-1") {
190
+ return createRecord(id, {
164
191
  agent: "general-purpose",
165
192
  model: "test-model",
166
193
  mode: "sync",