@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.
Files changed (166) hide show
  1. package/README.md +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -0,0 +1,337 @@
1
+ // lifecycle-manager 单测 —— V2 §5.2 模块 1(进程生命周期管理)五项职责。
2
+ //
3
+ // 测试策略:
4
+ // - idle timer / ceiling LRU 用 vi.useFakeTimers() + advanceTimersByTime(同时 mock
5
+ // Date.now(),让 lastTouched 时间戳可控)。
6
+ // - activate 互斥锁是纯 Promise 链(无 timer),用 advanceTimersByTimeAsync(0)
7
+ // flush microtask 验证串行化。
8
+ // - 每个用例 beforeEach 调 _resetLifecycleState() 隔离模块级单例状态。
9
+
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import {
13
+ DEFAULT_IDLE_TIMEOUT_MS,
14
+ DEFAULT_MAX_ALIVE_PROCESSES,
15
+ _resetLifecycleState,
16
+ acquireActivateLock,
17
+ armIdleTimer,
18
+ disarmIdleTimer,
19
+ evictIfOverCeiling,
20
+ getActiveProcessCount,
21
+ hasIdleTimer,
22
+ reapAllAliveProcesses,
23
+ registerActiveProcess,
24
+ scanOrphanProcesses,
25
+ touchActiveProcess,
26
+ unregisterActiveProcess,
27
+ } from "../lifecycle-manager.ts";
28
+
29
+ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
30
+ beforeEach(() => {
31
+ vi.useFakeTimers();
32
+ _resetLifecycleState();
33
+ });
34
+
35
+ afterEach(() => {
36
+ _resetLifecycleState();
37
+ vi.useRealTimers();
38
+ });
39
+
40
+ // ============================================================
41
+ // 职责 1:idle timer
42
+ // ============================================================
43
+ describe("职责1 idle timer", () => {
44
+ it("arm 后到 timeoutMs 触发 onTimeout(边界:差 1ms 不触发)", () => {
45
+ const onTimeout = vi.fn();
46
+ armIdleTimer("sa-1", onTimeout, 1000);
47
+
48
+ expect(hasIdleTimer("sa-1")).toBe(true);
49
+ vi.advanceTimersByTime(999);
50
+ expect(onTimeout).not.toHaveBeenCalled();
51
+
52
+ vi.advanceTimersByTime(1);
53
+ expect(onTimeout).toHaveBeenCalledTimes(1);
54
+ // 触发后自动从 Map 移除(不残留失效 entry)
55
+ expect(hasIdleTimer("sa-1")).toBe(false);
56
+ });
57
+
58
+ it("disarm 后不再触发 onTimeout", () => {
59
+ const onTimeout = vi.fn();
60
+ armIdleTimer("sa-1", onTimeout, 1000);
61
+ disarmIdleTimer("sa-1");
62
+
63
+ expect(hasIdleTimer("sa-1")).toBe(false);
64
+ vi.advanceTimersByTime(5000);
65
+ expect(onTimeout).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("disarm 不存在的 record 为 no-op(不抛错)", () => {
69
+ expect(() => disarmIdleTimer("never-armed")).not.toThrow();
70
+ });
71
+
72
+ it("重复 arm 刷新 timer:旧 timer 作废、重新计时", () => {
73
+ const onTimeout = vi.fn();
74
+ armIdleTimer("sa-1", onTimeout, 1000);
75
+ vi.advanceTimersByTime(500); // 过了 500ms
76
+
77
+ armIdleTimer("sa-1", onTimeout, 1000); // 刷新:重新计 1000ms
78
+ vi.advanceTimersByTime(999);
79
+ expect(onTimeout).not.toHaveBeenCalled(); // 旧 timer 已作废,新的还没到
80
+
81
+ vi.advanceTimersByTime(1);
82
+ expect(onTimeout).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it("默认超时 = DEFAULT_IDLE_TIMEOUT_MS", () => {
86
+ const onTimeout = vi.fn();
87
+ armIdleTimer("sa-1", onTimeout); // 不传 timeoutMs
88
+
89
+ vi.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT_MS - 1);
90
+ expect(onTimeout).not.toHaveBeenCalled();
91
+
92
+ vi.advanceTimersByTime(1);
93
+ expect(onTimeout).toHaveBeenCalledTimes(1);
94
+ });
95
+ });
96
+
97
+ // ============================================================
98
+ // 职责 2:全局 ceiling(LRU 挤出)
99
+ // ============================================================
100
+ describe("职责2 全局 ceiling", () => {
101
+ it("register 超过上限时 evictIfOverCeiling 挤出最久空闲", () => {
102
+ registerActiveProcess("sa-1"); // 最早
103
+ vi.advanceTimersByTime(10);
104
+ registerActiveProcess("sa-2");
105
+ vi.advanceTimersByTime(10);
106
+ registerActiveProcess("sa-3"); // 超限(size=3 > ceiling=2)
107
+ expect(getActiveProcessCount()).toBe(3);
108
+
109
+ const evicted: string[] = [];
110
+ evictIfOverCeiling((id) => evicted.push(id), 2);
111
+
112
+ expect(evicted).toEqual(["sa-1"]); // lastTouched 最老的被挤出
113
+ expect(getActiveProcessCount()).toBe(2);
114
+ });
115
+
116
+ it("不超过上限时 evictIfOverCeiling 不挤出", () => {
117
+ registerActiveProcess("sa-1");
118
+ registerActiveProcess("sa-2");
119
+
120
+ const evicted: string[] = [];
121
+ evictIfOverCeiling((id) => evicted.push(id), 5);
122
+
123
+ expect(evicted).toEqual([]);
124
+ expect(getActiveProcessCount()).toBe(2);
125
+ });
126
+
127
+ it("touch 更新 LRU:被 touch 的不再是挤出候选", () => {
128
+ registerActiveProcess("sa-1");
129
+ vi.advanceTimersByTime(10);
130
+ registerActiveProcess("sa-2");
131
+ vi.advanceTimersByTime(10);
132
+ touchActiveProcess("sa-1"); // sa-1 变最新 → sa-2 变最老
133
+
134
+ const evicted: string[] = [];
135
+ evictIfOverCeiling((id) => evicted.push(id), 1);
136
+
137
+ expect(evicted).toEqual(["sa-2"]); // sa-2 最久未 touch,被挤出
138
+ });
139
+
140
+ it("touch 不存在的 record 为 no-op(不隐式创建)", () => {
141
+ expect(() => touchActiveProcess("never-registered")).not.toThrow();
142
+ expect(getActiveProcessCount()).toBe(0);
143
+ });
144
+
145
+ it("unregister 清理活进程集合", () => {
146
+ registerActiveProcess("sa-1");
147
+ registerActiveProcess("sa-2");
148
+ unregisterActiveProcess("sa-1");
149
+ expect(getActiveProcessCount()).toBe(1);
150
+ });
151
+
152
+ it("挤出时连带 disarm 被挤 record 的 idle timer", () => {
153
+ registerActiveProcess("sa-1");
154
+ armIdleTimer("sa-1", vi.fn(), 1000);
155
+ registerActiveProcess("sa-2");
156
+ vi.advanceTimersByTime(10);
157
+ registerActiveProcess("sa-3"); // sa-1 最老,将被挤出
158
+
159
+ evictIfOverCeiling(() => {
160
+ /* kill */
161
+ }, 2);
162
+
163
+ expect(hasIdleTimer("sa-1")).toBe(false); // 挤出 cascade disarm
164
+ });
165
+
166
+ it("unregister 连带 disarm idle timer(进程终态 timer 不残留)", () => {
167
+ registerActiveProcess("sa-1");
168
+ armIdleTimer("sa-1", vi.fn(), 1000);
169
+ unregisterActiveProcess("sa-1");
170
+ expect(hasIdleTimer("sa-1")).toBe(false);
171
+ });
172
+
173
+ it("默认上限 = DEFAULT_MAX_ALIVE_PROCESSES(不传 maxAlive)", () => {
174
+ for (let i = 0; i < DEFAULT_MAX_ALIVE_PROCESSES; i++) {
175
+ registerActiveProcess(`sa-${i}`);
176
+ vi.advanceTimersByTime(1); // 保证 lastTouched 严格递增
177
+ }
178
+ registerActiveProcess("sa-over"); // 超限
179
+ const evicted: string[] = [];
180
+ evictIfOverCeiling((id) => evicted.push(id)); // 用默认上限
181
+
182
+ expect(evicted).toEqual(["sa-0"]); // 默认上限下挤出最早
183
+ expect(getActiveProcessCount()).toBe(DEFAULT_MAX_ALIVE_PROCESSES);
184
+ });
185
+
186
+ it("大幅超限时 while 循环挤出多个直到 ≤ ceiling", () => {
187
+ for (let i = 0; i < 5; i++) {
188
+ registerActiveProcess(`sa-${i}`);
189
+ vi.advanceTimersByTime(1);
190
+ }
191
+ const evicted: string[] = [];
192
+ evictIfOverCeiling((id) => evicted.push(id), 2);
193
+
194
+ expect(evicted).toEqual(["sa-0", "sa-1", "sa-2"]); // 挤出 3 个(5-2),按 LRU
195
+ });
196
+ });
197
+
198
+ // ============================================================
199
+ // 职责 3:shutdown 收割
200
+ // ============================================================
201
+ describe("职责3 shutdown 收割", () => {
202
+ it("reapAllAliveProcesses 遍历活进程调 killFn 并返回列表", () => {
203
+ registerActiveProcess("sa-1");
204
+ registerActiveProcess("sa-2");
205
+
206
+ const killed: string[] = [];
207
+ const reaped = reapAllAliveProcesses((id) => killed.push(id));
208
+
209
+ expect(killed).toHaveLength(2);
210
+ expect(killed).toEqual(expect.arrayContaining(["sa-1", "sa-2"]));
211
+ expect(reaped).toEqual(expect.arrayContaining(["sa-1", "sa-2"]));
212
+ expect(getActiveProcessCount()).toBe(0);
213
+ });
214
+
215
+ it("reap 后 idle timer 已 disarm(advance 不再触发)", () => {
216
+ registerActiveProcess("sa-1");
217
+ const onTimeout = vi.fn();
218
+ armIdleTimer("sa-1", onTimeout, 1000);
219
+
220
+ reapAllAliveProcesses(() => {
221
+ /* kill */
222
+ });
223
+ vi.advanceTimersByTime(10000);
224
+
225
+ expect(onTimeout).not.toHaveBeenCalled(); // timer 已被 reap 清理
226
+ });
227
+
228
+ it("无活进程时 reapAll 返回空列表", () => {
229
+ const reaped = reapAllAliveProcesses(() => {
230
+ /* kill */
231
+ });
232
+ expect(reaped).toEqual([]);
233
+ });
234
+ });
235
+
236
+ // ============================================================
237
+ // 职责 4:孤儿扫描
238
+ // ============================================================
239
+ describe("职责4 孤儿扫描", () => {
240
+ it("pid 存活 + 不在活进程集合 = 孤儿", () => {
241
+ registerActiveProcess("sa-active"); // 在集合内
242
+ const records = [
243
+ { id: "sa-active", pid: 100 },
244
+ { id: "sa-orphan", pid: 200 }, // pid 活但不在集合 → 孤儿
245
+ { id: "sa-dead", pid: 300 }, // pid 不活 → 非孤儿
246
+ { id: "sa-nopid" }, // 无 pid → 非孤儿
247
+ ];
248
+
249
+ const orphans = scanOrphanProcesses(records, (pid) => pid === 100 || pid === 200);
250
+
251
+ expect(orphans).toEqual(["sa-orphan"]);
252
+ });
253
+
254
+ it("pid 不存活 = 非孤儿", () => {
255
+ const records = [{ id: "sa-1", pid: 999 }];
256
+ const orphans = scanOrphanProcesses(records, () => false); // 全不活
257
+ expect(orphans).toEqual([]);
258
+ });
259
+
260
+ it("在活进程集合内 = 非孤儿(即使 pid 活)", () => {
261
+ registerActiveProcess("sa-1");
262
+ const records = [{ id: "sa-1", pid: 100 }];
263
+ const orphans = scanOrphanProcesses(records, () => true);
264
+ expect(orphans).toEqual([]);
265
+ });
266
+
267
+ it("无 pid 的 record 不视为孤儿", () => {
268
+ const records = [
269
+ { id: "sa-1" },
270
+ { id: "sa-2", sessionFile: "/x.jsonl" },
271
+ ];
272
+ const orphans = scanOrphanProcesses(records, () => true);
273
+ expect(orphans).toEqual([]);
274
+ });
275
+
276
+ it("扫描是只读(不改活进程集合)", () => {
277
+ registerActiveProcess("sa-1");
278
+ scanOrphanProcesses([{ id: "sa-2", pid: 200 }], () => true);
279
+ expect(getActiveProcessCount()).toBe(1); // 集合不变
280
+ });
281
+ });
282
+
283
+ // ============================================================
284
+ // 职责 5:activate 互斥锁
285
+ // ============================================================
286
+ describe("职责5 activate 互斥锁", () => {
287
+ it("首次 acquire 立即 resolve 返回 release 函数", async () => {
288
+ const release = await acquireActivateLock("sa-1");
289
+ expect(typeof release).toBe("function");
290
+ release();
291
+ });
292
+
293
+ it("并发 acquire 同一 recordId 串行化:第二次等首次 release", async () => {
294
+ const release1 = await acquireActivateLock("sa-1");
295
+
296
+ let secondResolved = false;
297
+ const secondPromise = acquireActivateLock("sa-1").then((r) => {
298
+ secondResolved = true;
299
+ return r;
300
+ });
301
+
302
+ await vi.advanceTimersByTimeAsync(0); // flush microtask
303
+ expect(secondResolved).toBe(false); // 还在等首次 release
304
+
305
+ release1();
306
+ await vi.advanceTimersByTimeAsync(0); // flush microtask
307
+ expect(secondResolved).toBe(true);
308
+
309
+ const release2 = await secondPromise;
310
+ release2();
311
+ });
312
+
313
+ it("不同 recordId 不互斥(各自独立链)", async () => {
314
+ const release1 = await acquireActivateLock("sa-1");
315
+
316
+ let secondResolved = false;
317
+ acquireActivateLock("sa-2").then((r) => {
318
+ secondResolved = true;
319
+ r();
320
+ });
321
+
322
+ await vi.advanceTimersByTimeAsync(0); // flush microtask
323
+ expect(secondResolved).toBe(true); // sa-2 不被 sa-1 阻塞
324
+
325
+ release1();
326
+ });
327
+
328
+ it("release 后同 recordId 可再次 acquire", async () => {
329
+ const r1 = await acquireActivateLock("sa-1");
330
+ r1();
331
+
332
+ const r2 = await acquireActivateLock("sa-1");
333
+ expect(typeof r2).toBe("function");
334
+ r2();
335
+ });
336
+ });
337
+ });
@@ -0,0 +1,116 @@
1
+ // lifecycle-predicates 单测(v4 B-1)。
2
+ // 验证 isIdle/isResumable/hasLiveProcessHandle 在两态收敛后的判定逻辑。
3
+ // 依赖 lifecycle-manager 模块级 idleTimers 与 session-runner.spawnedChildren(单例 Map),
4
+ // beforeEach 重置隔离。
5
+
6
+ import { describe, it, expect, beforeEach } from "vitest";
7
+
8
+ import {
9
+ armIdleTimer,
10
+ disarmIdleTimer,
11
+ _resetLifecycleState,
12
+ } from "../lifecycle-manager.ts";
13
+ import { spawnedChildren } from "../session-runner.ts";
14
+ import type { ChildProcess } from "node:child_process";
15
+ import type { ExecutionRecord } from "../types.ts";
16
+
17
+ import { hasLiveProcessHandle, isIdle, isResumable } from "../lifecycle-predicates.ts";
18
+
19
+ /** 构造最小 ExecutionRecord(status 默认 running)。 */
20
+ function makeRecord(overrides: Partial<ExecutionRecord> = {}): ExecutionRecord {
21
+ return {
22
+ id: "sa-test",
23
+ agent: "general-purpose",
24
+ model: "test/model",
25
+ thinkingLevel: undefined,
26
+ mode: "background",
27
+ task: "test",
28
+ slug: "test",
29
+ startedAt: Date.now(),
30
+ rootSessionId: "root",
31
+ parentRecordId: undefined,
32
+ depth: 0,
33
+ status: "running",
34
+ turns: [],
35
+ turnCount: 0,
36
+ totalTokens: 0,
37
+ lastError: undefined,
38
+ round: 0,
39
+ endedAt: undefined,
40
+ result: undefined,
41
+ error: undefined,
42
+ agentResult: undefined,
43
+ controller: undefined,
44
+ ...overrides,
45
+ } as ExecutionRecord;
46
+ }
47
+
48
+ /** 构造最小 ChildProcess fake(hasLiveProcessHandle 只读 .killed)。 */
49
+ function fakeChild(killed: boolean): ChildProcess {
50
+ // 测试 fake:被测代码仅读 .killed,其余字段不可达,用 unknown 中转满足 Map 类型。
51
+ return { killed } as unknown as ChildProcess;
52
+ }
53
+
54
+ describe("lifecycle-predicates (v4 B-1)", () => {
55
+ beforeEach(() => {
56
+ _resetLifecycleState();
57
+ spawnedChildren.clear();
58
+ });
59
+
60
+ describe("isIdle (= hasIdleTimer)", () => {
61
+ it("armed idle timer → true", () => {
62
+ const rec = makeRecord();
63
+ armIdleTimer(rec.id, () => {});
64
+ expect(isIdle(rec)).toBe(true);
65
+ });
66
+
67
+ it("no idle timer → false", () => {
68
+ expect(isIdle(makeRecord())).toBe(false);
69
+ });
70
+
71
+ it("disarmed timer → false", () => {
72
+ const rec = makeRecord();
73
+ armIdleTimer(rec.id, () => {});
74
+ disarmIdleTimer(rec.id);
75
+ expect(isIdle(rec)).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe("hasLiveProcessHandle", () => {
80
+ it("no child in map → false", () => {
81
+ expect(hasLiveProcessHandle("sa-test")).toBe(false);
82
+ });
83
+
84
+ it("child present and not killed → true", () => {
85
+ spawnedChildren.set("sa-test", fakeChild(false));
86
+ expect(hasLiveProcessHandle("sa-test")).toBe(true);
87
+ });
88
+
89
+ it("child present but killed → false", () => {
90
+ spawnedChildren.set("sa-test", fakeChild(true));
91
+ expect(hasLiveProcessHandle("sa-test")).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("isResumable (= running && !hasLiveProcessHandle)", () => {
96
+ it("running + no live process → true (Path B / 跨重启)", () => {
97
+ expect(isResumable(makeRecord({ status: "running" }))).toBe(true);
98
+ });
99
+
100
+ it("running + live process → false (Path A 保活 / 正在执行)", () => {
101
+ const rec = makeRecord({ status: "running" });
102
+ spawnedChildren.set(rec.id, fakeChild(false));
103
+ expect(isResumable(rec)).toBe(false);
104
+ });
105
+
106
+ it("closed + no live process → false (终态不可 resume)", () => {
107
+ expect(isResumable(makeRecord({ status: "closed" }))).toBe(false);
108
+ });
109
+
110
+ it("closed + live process → false (终态优先)", () => {
111
+ const rec = makeRecord({ status: "closed" });
112
+ spawnedChildren.set(rec.id, fakeChild(false));
113
+ expect(isResumable(rec)).toBe(false);
114
+ });
115
+ });
116
+ });
@@ -36,11 +36,13 @@ function makeTheme(): ThemeLike {
36
36
  } as ThemeLike;
37
37
  }
38
38
 
39
- /** service stub:list-component 只调 collectRecords(limit) 单参数。
39
+ /** service stub:list-component 只调 collectRecords(limit) 单参数 + getFullRecord([perf]
40
+ * 选中项详情懒加载,mock 回 undefined → fullRecordOf 回退 light record,与旧行为一致)。
40
41
  * 与 tool-action.test.ts 同模式:部分对象直接断言为 SubagentService(duck-type)。 */
41
42
  function makeService(records: SubagentRecord[] = []): SubagentService {
42
43
  return {
43
44
  collectRecords: vi.fn(() => records),
45
+ getFullRecord: vi.fn(() => undefined as SubagentRecord | undefined),
44
46
  } as SubagentService;
45
47
  }
46
48
 
@@ -50,7 +52,7 @@ function makeRecord(over: Partial<SubagentRecord> = {}): SubagentRecord {
50
52
  id: "run-1",
51
53
  agent: "worker",
52
54
  task: "do the thing",
53
- status: "done",
55
+ status: "closed",
54
56
  mode: "sync",
55
57
  startedAt: 1000,
56
58
  endedAt: 2000,
@@ -158,9 +160,10 @@ describe("SubagentsListComponent", () => {
158
160
 
159
161
  // ── hasRunning ────────────────────────────────────────
160
162
  describe("hasRunning", () => {
161
- it("records 全是 done false", () => {
163
+ it("records 全是 closed(终态)→ false", () => {
162
164
  const { comp } = makeComponent({
163
- records: [makeRecord({ status: "done" }), makeRecord({ status: "failed" })],
165
+ // v4 B-1:done/failed 等旧终态已收敛为 closed(fixture 同步迁移)
166
+ records: [makeRecord({ status: "closed" }), makeRecord({ status: "closed" })],
164
167
  });
165
168
  expect(comp.hasRunning()).toBe(false);
166
169
  });
@@ -168,7 +171,7 @@ describe("SubagentsListComponent", () => {
168
171
  it("records 含一个 running → true", () => {
169
172
  const { comp } = makeComponent({
170
173
  records: [
171
- makeRecord({ id: "a", status: "done" }),
174
+ makeRecord({ id: "a", status: "closed" }),
172
175
  makeRecord({ id: "b", status: "running" }),
173
176
  ],
174
177
  });
@@ -344,4 +347,55 @@ describe("SubagentsListComponent", () => {
344
347
  expect(detailJoined).toContain("session-abc.jsonl"); // sessionFile
345
348
  });
346
349
  });
350
+
351
+ // ── 帧缓存 collectRecordsFrame(TC3/IF3/DM5)──────────
352
+ describe("帧缓存 collectRecordsFrame", () => {
353
+ it("同一帧(TTL 50ms 内)hasRunning + render 只触发 1 次 service.collectRecords", () => {
354
+ // fake timers 已锁 Date.now()(beforeEach)→ 帧内多次消费共享同一份 records
355
+ const rec = makeRecord({ id: "a", status: "running" });
356
+ const { comp, service } = makeComponent({ records: [rec], rows: 24 });
357
+ expect(comp.hasRunning()).toBe(true); // 第 1 次扫描
358
+ comp.render(80); // buildLines 消费(帧命中,不重扫)
359
+ comp.handleInput("x"); // handleInput 消费(帧命中)
360
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(1);
361
+ });
362
+
363
+ it("advance 100ms 后 TTL 过期 → 重扫(collectRecords 递增)", () => {
364
+ const rec = makeRecord({ id: "a", status: "running" });
365
+ const { comp, service } = makeComponent({ records: [rec], rows: 24 });
366
+ comp.render(80);
367
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(1);
368
+ vi.advanceTimersByTime(100); // > FRAME_TTL_MS(50)
369
+ // 镜像 animTimer 真实序列:hasRunning(帧消费点)→ invalidate → render。
370
+ // hasRunning 直接走帧缓存(不经 render 行缓存),TTL 过期 → 重扫
371
+ expect(comp.hasRunning()).toBe(true);
372
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(2);
373
+ comp.invalidate();
374
+ comp.render(80); // 重建行,但帧刚刷新(100ms 后的同一时刻)→ 不再重扫
375
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(2);
376
+ });
377
+
378
+ it("invalidate 不清帧缓存:invalidate 后同帧 render 仍命中(不重扫)", () => {
379
+ // timer 序列 hasRunning → invalidate → requestRender 的共享前提:
380
+ // invalidate 只清渲染行缓存(cachedKey/cachedLines),帧缓存由 TTL 独立保证
381
+ const rec = makeRecord({ id: "a", status: "running" });
382
+ const { comp, service } = makeComponent({ records: [rec], rows: 24 });
383
+ const first = comp.render(80);
384
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(1);
385
+ comp.invalidate();
386
+ const second = comp.render(80);
387
+ expect(second).not.toBe(first); // 渲染行缓存已清 → 重建
388
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(1); // 帧缓存未清 → 不重扫
389
+ });
390
+
391
+ it("detailMode 渲染路径(buildDetailContent children)同帧共享:render 仍只 1 次扫描", () => {
392
+ // 选中项有子 record → buildDetailContent 内 children 计算消费 collectRecordsFrame
393
+ const parent = makeRecord({ id: "p", status: "closed" });
394
+ const child = makeRecord({ id: "c", parentRecordId: "p" });
395
+ const { comp, service } = makeComponent({ records: [parent, child], rows: 24, detailMode: true });
396
+ const joined = comp.render(80).join("\n");
397
+ expect(joined).toContain("children:");
398
+ expect(vi.mocked(service.collectRecords)).toHaveBeenCalledTimes(1); // buildLines + children 同帧合一
399
+ });
400
+ });
347
401
  });
@@ -0,0 +1,109 @@
1
+ // src/execution/__tests__/list-fields.test.ts
2
+ //
3
+ // [v4 A-6] recordToListItem 字段派生单测。
4
+ //
5
+ // 验证 list 输出的新增字段派生正确:
6
+ // - parent:从 record.parentRecordId 派生(顶层 record → undefined)
7
+ // - resumable:从 isResumable 派生(running && 无活进程句柄)
8
+ // - closedReason:透传 record.closedReason(SP-4 级联关闭告知替代)
9
+ //
10
+ // Mock 策略:vi.mock lifecycle-predicates 的 isResumable,控制其返回值模拟「有/无
11
+ // 活进程句柄」两种 running 子态。isResumable 真实逻辑(running && !hasLiveProcessHandle)
12
+ // 由 lifecycle-predicates.test.ts 覆盖;本测试聚焦 recordToListItem 的字段派生与透传。
13
+
14
+ import { describe, expect, it, vi } from "vitest";
15
+
16
+ // mock lifecycle-predicates:控制 isResumable 返回值。
17
+ // recordToListItem 内部 import 的 isResumable 经 vitest 自动接线拿到此 mock 版本。
18
+ vi.mock("../../execution/lifecycle-predicates.ts", () => ({
19
+ isResumable: vi.fn(),
20
+ }));
21
+
22
+ import { recordToListItem } from "../../interface/subagent-actions.ts";
23
+ import { isResumable } from "../../execution/lifecycle-predicates.ts";
24
+ import type { ClosedReason, SubagentRecord } from "../types.ts";
25
+
26
+ // ── SubagentRecord stub 工厂(最小合法 record) ──
27
+
28
+ function makeRecord(over: Partial<SubagentRecord> = {}): SubagentRecord {
29
+ return {
30
+ id: "sa-test",
31
+ agent: "general-purpose",
32
+ task: "test task",
33
+ slug: "test-slug",
34
+ status: "running",
35
+ mode: "background",
36
+ startedAt: Date.now(),
37
+ rootSessionId: "session-main",
38
+ parentRecordId: undefined,
39
+ depth: 0,
40
+ endedAt: undefined,
41
+ turns: 0,
42
+ totalTokens: 0,
43
+ model: "test/model",
44
+ thinkingLevel: undefined,
45
+ eventLog: [],
46
+ displayItems: [],
47
+ result: undefined,
48
+ error: undefined,
49
+ sessionFile: undefined,
50
+ ...over,
51
+ };
52
+ }
53
+
54
+ describe("recordToListItem — parent/resumable/closedReason (v4 A-6)", () => {
55
+ // ═══ parent:从 record.parentRecordId 派生 ═══
56
+
57
+ it("parent:嵌套 record(parentRecordId='sa-A')→ parent='sa-A'", () => {
58
+ const rec = makeRecord({ parentRecordId: "sa-A" });
59
+ expect(recordToListItem(rec).parent).toBe("sa-A");
60
+ });
61
+
62
+ it("parent:根层 record(parentRecordId=undefined)→ parent=undefined", () => {
63
+ const rec = makeRecord({ parentRecordId: undefined });
64
+ expect(recordToListItem(rec).parent).toBeUndefined();
65
+ });
66
+
67
+ // ═══ resumable:isResumable 透传(mock 模拟有/无活进程句柄) ═══
68
+
69
+ it("resumable:running + 无活进程句柄(isResumable=true)→ true(B-1 续聊态)", () => {
70
+ vi.mocked(isResumable).mockReturnValue(true);
71
+ const rec = makeRecord({ status: "running" });
72
+ expect(recordToListItem(rec).resumable).toBe(true);
73
+ });
74
+
75
+ it("resumable:running + 有活进程句柄(isResumable=false)→ false(正在执行)", () => {
76
+ vi.mocked(isResumable).mockReturnValue(false);
77
+ const rec = makeRecord({ status: "running" });
78
+ expect(recordToListItem(rec).resumable).toBe(false);
79
+ });
80
+
81
+ it("resumable:closed(isResumable=false)→ false(终态不可续聊)", () => {
82
+ vi.mocked(isResumable).mockReturnValue(false);
83
+ const rec = makeRecord({ status: "closed" });
84
+ expect(recordToListItem(rec).resumable).toBe(false);
85
+ });
86
+
87
+ // ═══ closedReason:透传 record.closedReason ═══
88
+
89
+ it("closedReason:closed + closedReason='parent-fork' → 透传", () => {
90
+ const rec = makeRecord({
91
+ status: "closed",
92
+ closedReason: "parent-fork" as ClosedReason,
93
+ });
94
+ expect(recordToListItem(rec).closedReason).toBe("parent-fork");
95
+ });
96
+
97
+ it("closedReason:closed + closedReason='parent-new' → 透传(SP-4 级联关闭场景)", () => {
98
+ const rec = makeRecord({
99
+ status: "closed",
100
+ closedReason: "parent-new" as ClosedReason,
101
+ });
102
+ expect(recordToListItem(rec).closedReason).toBe("parent-new");
103
+ });
104
+
105
+ it("closedReason:running record(无 closedReason)→ undefined", () => {
106
+ const rec = makeRecord({ status: "running" });
107
+ expect(recordToListItem(rec).closedReason).toBeUndefined();
108
+ });
109
+ });