@zhushanwen/pi-subagent-workflow 0.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,545 @@
1
+ // src/__tests__/record-store.test.ts
2
+ //
3
+ // RecordStore 专属测试。
4
+ // 覆盖:
5
+ // - archive 立即移除(终态 record 不留内存,读时从 session.jsonl 重建)
6
+ // - collectRecords 合并内存(running) + 磁盘(session.jsonl 重建)
7
+ // - collectRecords statusFilter("running" vs "all")
8
+ // - cancelled tombstone override
9
+ // - compareRecords 排序(status priority + startedAt desc)
10
+ // - 重建缓存(notifyChange 失效)
11
+ //
12
+ // 用 tmpdir + 真实 .jsonl fixture(隔离真实文件系统,同 session-reconstructor.test.ts 模式)。
13
+
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+
18
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
19
+
20
+ import { createRecord } from "../execution-record.ts";
21
+ import { writeAliveMarker } from "../alive-store.ts";
22
+ import { writeFinalized } from "../finalized-marker.ts";
23
+ import type { StatusFilter } from "../record-store.ts";
24
+ import { RecordStore } from "../record-store.ts";
25
+ import { writeCancelledTombstone } from "../tombstone-store.ts";
26
+ import type { AliveMarker, ExecutionRecord } from "../types.ts";
27
+
28
+ /** 构造 ExecutionRecord(base 默认 running,over 覆盖任意字段)。 */
29
+ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
30
+ const base = createRecord("r1", {
31
+ agent: "worker",
32
+ model: "m",
33
+ mode: "sync",
34
+ task: "t",
35
+ startedAt: 1000,
36
+ rootSessionId: "sess-current",
37
+ });
38
+ return { ...base, ...over };
39
+ }
40
+
41
+ /**
42
+ * 写一个最小合法的 session.jsonl(含 identity custom entry + 1 个 assistant message)。
43
+ * 用于 collectRecords 磁盘源重建测试。
44
+ */
45
+ function writeSessionJsonl(
46
+ filePath: string,
47
+ identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; lastTs?: number },
48
+ assistantText = "result text",
49
+ ): void {
50
+ const lastTs = identity.lastTs ?? identity.startedAt + 1000;
51
+ const header = JSON.stringify({
52
+ type: "session", version: 3, id: "sess-uuid", timestamp: new Date(identity.startedAt).toISOString(), cwd: "/tmp",
53
+ });
54
+ const identityData: Record<string, unknown> = {
55
+ id: identity.id,
56
+ agent: identity.agent,
57
+ mode: identity.mode,
58
+ task: identity.task,
59
+ startedAt: identity.startedAt,
60
+ };
61
+ if (identity.rootSessionId !== undefined) identityData.rootSessionId = identity.rootSessionId;
62
+ const identityEntry = JSON.stringify({
63
+ type: "custom",
64
+ id: "id-1",
65
+ parentId: null,
66
+ timestamp: new Date(identity.startedAt).toISOString(),
67
+ customType: "subagent-identity",
68
+ data: identityData,
69
+ });
70
+ const assistantMsg = JSON.stringify({
71
+ type: "message",
72
+ id: "msg-1",
73
+ parentId: "id-1",
74
+ timestamp: new Date(lastTs).toISOString(),
75
+ message: {
76
+ role: "assistant",
77
+ content: [{ type: "text", text: assistantText }],
78
+ usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30, cost: { total: 0 } },
79
+ stopReason: "stop",
80
+ timestamp: lastTs,
81
+ },
82
+ });
83
+ fs.writeFileSync(filePath, `${header}\n${identityEntry}\n${assistantMsg}\n`, "utf-8");
84
+ }
85
+
86
+ describe("RecordStore", () => {
87
+ let tmpDir: string;
88
+
89
+ beforeEach(() => {
90
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-test-"));
91
+ });
92
+ afterEach(() => {
93
+ fs.rmSync(tmpDir, { recursive: true, force: true });
94
+ });
95
+
96
+ // ============================================================
97
+ // archive 立即移除
98
+ // ============================================================
99
+ describe("archive 立即移除", () => {
100
+ it("archive 后 record 立即从内存移除(不再 linger)", () => {
101
+ const store = new RecordStore(tmpDir);
102
+ const r = makeRecord({ id: "sync-1", mode: "sync", status: "done" });
103
+ store.register(r);
104
+ expect(store.getMutable("sync-1")).toBeDefined();
105
+ store.archive(r);
106
+ expect(store.getMutable("sync-1")).toBeUndefined();
107
+ });
108
+
109
+ it("background record 同样立即移除(不再 FIFO)", () => {
110
+ const store = new RecordStore(tmpDir);
111
+ const r = makeRecord({ id: "bg-1", mode: "background", status: "done" });
112
+ store.register(r);
113
+ store.archive(r);
114
+ expect(store.getMutable("bg-1")).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ // ============================================================
119
+ // collectRecords:内存(running) + 磁盘(重建) 合并
120
+ // ============================================================
121
+ describe("collectRecords 合并", () => {
122
+ it("内存 running record 出现在结果中", () => {
123
+ const store = new RecordStore(tmpDir);
124
+ store.register(makeRecord({ id: "run-1", mode: "background", startedAt: 1000 }));
125
+ const ids = store.collectRecords(100).map((r) => r.id);
126
+ expect(ids).toContain("run-1");
127
+ });
128
+
129
+ it("磁盘 session.jsonl 重建的终态 record 出现在结果中(无 sidecar → crashed)", () => {
130
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
131
+ writeSessionJsonl(sessionFile, {
132
+ id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
133
+ });
134
+ // 无 sidecar → 四分支兜底 crashed
135
+ const store = new RecordStore(tmpDir);
136
+ const found = store.collectRecords(100).find((r) => r.id === "bg-1");
137
+ expect(found).toBeDefined();
138
+ expect(found?.status).toBe("crashed");
139
+ expect(found?.agent).toBe("worker");
140
+ expect(found?.turns).toBe(1);
141
+ expect(found?.totalTokens).toBe(30);
142
+ expect(found?.result).toBe("result text");
143
+ });
144
+
145
+ it("磁盘 session.jsonl + .finalized sidecar → done", () => {
146
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-b.jsonl");
147
+ writeSessionJsonl(sessionFile, {
148
+ id: "bg-2", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
149
+ });
150
+ writeFinalized(sessionFile);
151
+ const store = new RecordStore(tmpDir);
152
+ const found = store.collectRecords(100).find((r) => r.id === "bg-2");
153
+ expect(found).toBeDefined();
154
+ expect(found?.status).toBe("done");
155
+ });
156
+
157
+ it("statusFilter='running' 只返回 running(磁盘终态被滤掉)", () => {
158
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
159
+ writeSessionJsonl(sessionFile, {
160
+ id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
161
+ });
162
+ const store = new RecordStore(tmpDir);
163
+ store.register(makeRecord({ id: "run-1", mode: "background", startedAt: 1000 }));
164
+ const filter: StatusFilter = "running";
165
+ const ids = store.collectRecords(100, filter).map((r) => r.id);
166
+ expect(ids).toEqual(["run-1"]); // 只有内存 running,磁盘 crashed 被滤
167
+ });
168
+
169
+ it("statusFilter='all'(默认)返回内存 + 磁盘", () => {
170
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
171
+ writeSessionJsonl(sessionFile, {
172
+ id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
173
+ });
174
+ const store = new RecordStore(tmpDir);
175
+ store.register(makeRecord({ id: "run-1", mode: "background", startedAt: 1000 }));
176
+ const ids = store.collectRecords(100).map((r) => r.id);
177
+ expect(ids).toContain("run-1");
178
+ expect(ids).toContain("bg-1");
179
+ });
180
+
181
+ it("内存 running 优先于磁盘同 id(内存覆盖)", () => {
182
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
183
+ writeSessionJsonl(sessionFile, {
184
+ id: "dup-1", agent: "worker", mode: "background", task: "from disk", startedAt: 5000,
185
+ });
186
+ const store = new RecordStore(tmpDir);
187
+ store.register(makeRecord({ id: "dup-1", mode: "background", status: "running", startedAt: 5000 }));
188
+ const found = store.collectRecords(100).find((r) => r.id === "dup-1");
189
+ expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 crashed
190
+ });
191
+ });
192
+
193
+ // ============================================================
194
+ // cancelled tombstone override
195
+ // ============================================================
196
+ describe("cancelled tombstone", () => {
197
+ it("有 .cancelled sidecar → status override 为 cancelled", () => {
198
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
199
+ writeSessionJsonl(sessionFile, {
200
+ id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
201
+ });
202
+ writeCancelledTombstone(sessionFile, {
203
+ id: "bg-1", status: "cancelled", agent: "worker", startedAt: 5000, endedAt: 6000,
204
+ });
205
+ const store = new RecordStore(tmpDir);
206
+ const found = store.collectRecords(100).find((r) => r.id === "bg-1");
207
+ expect(found?.status).toBe("cancelled");
208
+ expect(found?.error).toBe("cancelled by user");
209
+ });
210
+ });
211
+
212
+ // ============================================================
213
+ // compareRecords 排序稳定性(内存 running record)
214
+ // ============================================================
215
+ describe("compareRecords 排序", () => {
216
+ it("status priority(running < crashed)", () => {
217
+ const store = new RecordStore(tmpDir);
218
+ // 内存 running record vs 磁盘无 sidecar → crashed
219
+ const running = makeRecord({ id: "run-1", mode: "background", startedAt: 3000, status: "running" });
220
+ store.register(running);
221
+ // 磁盘 crashed record(无 sidecar → 四分支兜底)
222
+ writeSessionJsonl(path.join(tmpDir, "a.jsonl"), {
223
+ id: "done-1", agent: "w", mode: "background", task: "t", startedAt: 5000,
224
+ });
225
+ const ids = store.collectRecords(100).map((r) => r.id);
226
+ expect(ids[0]).toBe("run-1"); // running 排前
227
+ });
228
+
229
+ it("同 status 时 startedAt desc(新→旧)", () => {
230
+ const store = new RecordStore(tmpDir);
231
+ writeSessionJsonl(path.join(tmpDir, "old.jsonl"), {
232
+ id: "old", agent: "w", mode: "background", task: "t", startedAt: 1000,
233
+ });
234
+ writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
235
+ id: "new", agent: "w", mode: "background", task: "t", startedAt: 9000,
236
+ });
237
+ // 两个都是 crashed(磁盘重建,无 sidecar),按 startedAt desc
238
+ const ids = store.collectRecords(100).map((r) => r.id);
239
+ expect(ids).toEqual(["new", "old"]);
240
+ });
241
+ });
242
+
243
+ // ============================================================
244
+ // 重建缓存
245
+ // ============================================================
246
+ describe("重建缓存", () => {
247
+ it("notifyChange 后缓存失效(新 session.jsonl 可见)", () => {
248
+ const store = new RecordStore(tmpDir);
249
+ // 首次 collect:空目录
250
+ expect(store.collectRecords(100)).toHaveLength(0);
251
+ // 写新 session.jsonl
252
+ writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
253
+ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 1000,
254
+ });
255
+ // 缓存仍命中旧结果(notifyChange 未触发)
256
+ expect(store.collectRecords(100)).toHaveLength(0);
257
+ // register 触发 notifyChange → 缓存失效
258
+ store.register(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
259
+ store.archive(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
260
+ // 现在 bg-1 可见
261
+ const ids = store.collectRecords(100).map((r) => r.id);
262
+ expect(ids).toContain("bg-1");
263
+ });
264
+ });
265
+
266
+ // ============================================================
267
+ // dispose / revive
268
+ // ============================================================
269
+ describe("dispose / revive", () => {
270
+ it("dispose 后 notifyChange 不再触发 listener", () => {
271
+ const store = new RecordStore(tmpDir);
272
+ let count = 0;
273
+ store.onChange(() => { count++; });
274
+ store.register(makeRecord({ id: "r1", startedAt: 1000 }));
275
+ expect(count).toBe(1);
276
+ store.dispose();
277
+ store.register(makeRecord({ id: "r2", startedAt: 2000 }));
278
+ expect(count).toBe(1); // dispose 后不再通知
279
+ });
280
+ });
281
+
282
+ // ============================================================
283
+ // 四分支 sidecar 矩阵(D-006 + D-021)
284
+ // ============================================================
285
+ describe("四分支 sidecar 矩阵", () => {
286
+ const SESSION_ID = "bg-1";
287
+ const STARTED_AT = 1000;
288
+
289
+ function writeBaseSession(): string {
290
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-sidecar.jsonl");
291
+ writeSessionJsonl(sessionFile, {
292
+ id: SESSION_ID, agent: "worker", mode: "background", task: "do it", startedAt: STARTED_AT,
293
+ });
294
+ return sessionFile;
295
+ }
296
+
297
+ // ── 分支 1: .cancelled ──
298
+ it(".cancelled sidecar → cancelled", () => {
299
+ const sessionFile = writeBaseSession();
300
+ writeCancelledTombstone(sessionFile, {
301
+ id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 6000,
302
+ });
303
+ const store = new RecordStore(tmpDir);
304
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
305
+ expect(found?.status).toBe("cancelled");
306
+ expect(found?.error).toBe("cancelled by user");
307
+ expect(found?.endedAt).toBe(6000);
308
+ });
309
+
310
+ // ── 分支 2: .finalized done ──
311
+ it(".finalized sidecar + stopReason=stop → done", () => {
312
+ const sessionFile = writeBaseSession();
313
+ writeFinalized(sessionFile);
314
+ const store = new RecordStore(tmpDir);
315
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
316
+ expect(found?.status).toBe("done");
317
+ });
318
+
319
+ // ── 分支 2: .finalized failed ──
320
+ it(".finalized sidecar + stopReason=error → failed", () => {
321
+ // 写一个 stopReason=error 的 session.jsonl
322
+ const sessionFile = path.join(tmpDir, "2026-01-01-uuid-fail.jsonl");
323
+ const header = JSON.stringify({
324
+ type: "session", version: 3, id: "sess-uuid", timestamp: new Date(STARTED_AT).toISOString(), cwd: "/tmp",
325
+ });
326
+ const identityEntry = JSON.stringify({
327
+ type: "custom", id: "id-1", parentId: null, timestamp: new Date(STARTED_AT).toISOString(),
328
+ customType: "subagent-identity",
329
+ data: { id: SESSION_ID, agent: "worker", mode: "background", task: "do it", startedAt: STARTED_AT },
330
+ });
331
+ const assistantMsg = JSON.stringify({
332
+ type: "message", id: "msg-1", parentId: "id-1",
333
+ timestamp: new Date(STARTED_AT + 1000).toISOString(),
334
+ message: {
335
+ role: "assistant",
336
+ content: [{ type: "text", text: "error output" }],
337
+ usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30, cost: { total: 0 } },
338
+ stopReason: "error",
339
+ errorMessage: "something went wrong",
340
+ timestamp: STARTED_AT + 1000,
341
+ },
342
+ });
343
+ fs.writeFileSync(sessionFile, `${header}\n${identityEntry}\n${assistantMsg}\n`, "utf-8");
344
+
345
+ writeFinalized(sessionFile);
346
+ const store = new RecordStore(tmpDir);
347
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
348
+ expect(found?.status).toBe("failed");
349
+ });
350
+
351
+ // ── 分支 3: .alive + 活 pid → running + externalInstance ──
352
+ it(".alive + 存活 pid → running + externalInstance=true", () => {
353
+ const sessionFile = writeBaseSession();
354
+ const recentStartedAt = Date.now() - 1000; // 1 秒前,确保未超 24h
355
+ const marker: AliveMarker = { pid: process.pid, id: SESSION_ID, startedAt: recentStartedAt };
356
+ writeAliveMarker(sessionFile, marker);
357
+ const store = new RecordStore(tmpDir);
358
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
359
+ expect(found?.status).toBe("running");
360
+ expect(found?.externalInstance).toEqual(marker);
361
+ });
362
+
363
+ // ── 分支 3→4: .alive + 死 pid → crashed ──
364
+ it(".alive + 死 pid → crashed", () => {
365
+ const sessionFile = writeBaseSession();
366
+ const marker: AliveMarker = { pid: 9999999, id: SESSION_ID, startedAt: STARTED_AT };
367
+ writeAliveMarker(sessionFile, marker);
368
+ const store = new RecordStore(tmpDir);
369
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
370
+ expect(found?.status).toBe("crashed");
371
+ expect(found?.externalInstance).toBeUndefined();
372
+ });
373
+
374
+ // ── 分支 4: 都无 sidecar → crashed ──
375
+ it("无任何 sidecar → crashed", () => {
376
+ writeBaseSession();
377
+ const store = new RecordStore(tmpDir);
378
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
379
+ expect(found?.status).toBe("crashed");
380
+ });
381
+
382
+ // ── 分支 4: >24h 软超时 → crashed(无视探活)──
383
+ it(">24h 软超时 → crashed(即使 pid 存活)", () => {
384
+ const sessionFile = writeBaseSession();
385
+ // startedAt 设为 25 小时前
386
+ const oldStartedAt = Date.now() - 25 * 60 * 60 * 1000;
387
+ const marker: AliveMarker = { pid: process.pid, id: SESSION_ID, startedAt: oldStartedAt };
388
+ writeAliveMarker(sessionFile, marker);
389
+
390
+ // 重写 session.jsonl 使 startedAt 匹配
391
+ fs.unlinkSync(sessionFile);
392
+ writeSessionJsonl(sessionFile, {
393
+ id: SESSION_ID, agent: "worker", mode: "background", task: "do it", startedAt: oldStartedAt,
394
+ });
395
+
396
+ const store = new RecordStore(tmpDir);
397
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
398
+ expect(found?.status).toBe("crashed");
399
+ expect(found?.externalInstance).toBeUndefined();
400
+ });
401
+
402
+ // ── 回归:.cancelled 优先于 .finalized ──
403
+ it(".cancelled 优先于 .finalized(即使两者共存)", () => {
404
+ const sessionFile = writeBaseSession();
405
+ writeFinalized(sessionFile);
406
+ writeCancelledTombstone(sessionFile, {
407
+ id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 6000,
408
+ });
409
+ const store = new RecordStore(tmpDir);
410
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
411
+ expect(found?.status).toBe("cancelled");
412
+ });
413
+
414
+ // ── 回归:旧 .cancelled 单分支行为不变 ──
415
+ it("旧 .cancelled 单分支行为不变(回归)", () => {
416
+ const sessionFile = writeBaseSession();
417
+ writeCancelledTombstone(sessionFile, {
418
+ id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 7000,
419
+ });
420
+ const store = new RecordStore(tmpDir);
421
+ const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
422
+ expect(found?.status).toBe("cancelled");
423
+ expect(found?.error).toBe("cancelled by user");
424
+ expect(found?.endedAt).toBe(7000);
425
+ });
426
+ });
427
+
428
+ // ============================================================
429
+ // session 隔离(问题 1 修复)
430
+ // ============================================================
431
+ describe("session 隔离(rootSessionId 过滤)", () => {
432
+ it("磁盘源:只返回 rootSessionId 匹配的 record", () => {
433
+ writeSessionJsonl(path.join(tmpDir, "mine.jsonl"), {
434
+ id: "mine", agent: "w", mode: "background", task: "t", startedAt: 1000, rootSessionId: "sess-A",
435
+ });
436
+ writeSessionJsonl(path.join(tmpDir, "other.jsonl"), {
437
+ id: "other", agent: "w", mode: "background", task: "t", startedAt: 2000, rootSessionId: "sess-B",
438
+ });
439
+ const store = new RecordStore(tmpDir);
440
+ const ids = store.collectRecords(100, "all", "sess-A").map((r) => r.id);
441
+ expect(ids).toEqual(["mine"]); // other 属于 sess-B,被隔离
442
+ });
443
+
444
+ it("磁盘源:rootSessionId 缺失(旧文件)被排除(无法判定归属)", () => {
445
+ writeSessionJsonl(path.join(tmpDir, "legacy.jsonl"), {
446
+ id: "legacy", agent: "w", mode: "background", task: "t", startedAt: 1000, // 无 rootSessionId
447
+ });
448
+ const store = new RecordStore(tmpDir);
449
+ const ids = store.collectRecords(100, "all", "sess-A").map((r) => r.id);
450
+ expect(ids).toEqual([]); // 旧文件被排除
451
+ });
452
+
453
+ it("磁盘源:不传 filter(undefined)不过滤(向后兼容)", () => {
454
+ writeSessionJsonl(path.join(tmpDir, "legacy.jsonl"), {
455
+ id: "legacy", agent: "w", mode: "background", task: "t", startedAt: 1000,
456
+ });
457
+ writeSessionJsonl(path.join(tmpDir, "tagged.jsonl"), {
458
+ id: "tagged", agent: "w", mode: "background", task: "t", startedAt: 2000, rootSessionId: "sess-A",
459
+ });
460
+ const store = new RecordStore(tmpDir);
461
+ const ids = store.collectRecords(100).map((r) => r.id);
462
+ expect(ids.sort()).toEqual(["legacy", "tagged"]); // 全返回,不过滤
463
+ });
464
+
465
+ it("内存源:只返回 rootSessionId 匹配的 running record", () => {
466
+ const store = new RecordStore(tmpDir);
467
+ store.register(makeRecord({ id: "mine", startedAt: 1000, rootSessionId: "sess-A" }));
468
+ store.register(makeRecord({ id: "other", startedAt: 2000, rootSessionId: "sess-B" }));
469
+ const ids = store.collectRecords(100, "all", "sess-A").map((r) => r.id);
470
+ expect(ids).toEqual(["mine"]);
471
+ });
472
+
473
+ it("内存+磁盘混合:同时按 session 过滤", () => {
474
+ writeSessionJsonl(path.join(tmpDir, "disk-A.jsonl"), {
475
+ id: "disk-A", agent: "w", mode: "background", task: "t", startedAt: 1000, rootSessionId: "sess-A",
476
+ });
477
+ writeSessionJsonl(path.join(tmpDir, "disk-B.jsonl"), {
478
+ id: "disk-B", agent: "w", mode: "background", task: "t", startedAt: 2000, rootSessionId: "sess-B",
479
+ });
480
+ const store = new RecordStore(tmpDir);
481
+ store.register(makeRecord({ id: "mem-A", startedAt: 3000, rootSessionId: "sess-A" }));
482
+ store.register(makeRecord({ id: "mem-B", startedAt: 4000, rootSessionId: "sess-B" }));
483
+ const ids = store.collectRecords(100, "all", "sess-A").map((r) => r.id).sort();
484
+ expect(ids).toEqual(["disk-A", "mem-A"]);
485
+ });
486
+
487
+ it("重建缓存:不同 filter 共享缓存,不交叉污染", () => {
488
+ writeSessionJsonl(path.join(tmpDir, "a.jsonl"), {
489
+ id: "a", agent: "w", mode: "background", task: "t", startedAt: 1000, rootSessionId: "sess-A",
490
+ });
491
+ writeSessionJsonl(path.join(tmpDir, "b.jsonl"), {
492
+ id: "b", agent: "w", mode: "background", task: "t", startedAt: 2000, rootSessionId: "sess-B",
493
+ });
494
+ const store = new RecordStore(tmpDir);
495
+ // 先查 sess-A(建缓存)
496
+ expect(store.collectRecords(100, "all", "sess-A").map((r) => r.id)).toEqual(["a"]);
497
+ // 再查 sess-B(复用缓存基底,过滤不交叉)
498
+ expect(store.collectRecords(100, "all", "sess-B").map((r) => r.id)).toEqual(["b"]);
499
+ // 不带 filter(复用缓存,全量)
500
+ expect(store.collectRecords(100).map((r) => r.id).sort()).toEqual(["a", "b"]);
501
+ });
502
+ });
503
+
504
+ // ============================================================
505
+ // endedAt 重建(问题 2 修复:终态耗时不再随墙钟增长)
506
+ // ============================================================
507
+ describe("endedAt 重建(耗时不再无限增长)", () => {
508
+ it(".finalized → endedAt 为最后 entry 时间戳(非 now)", () => {
509
+ const sessionFile = path.join(tmpDir, "fin.jsonl");
510
+ writeSessionJsonl(sessionFile, {
511
+ id: "bg-1", agent: "w", mode: "background", task: "t",
512
+ startedAt: 5000, lastTs: 9000, rootSessionId: "sess-A",
513
+ });
514
+ writeFinalized(sessionFile);
515
+ const store = new RecordStore(tmpDir);
516
+ const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
517
+ expect(found?.endedAt).toBe(9000); // 不是 Date.now()
518
+ });
519
+
520
+ it("无 sidecar(crashed)→ endedAt 为最后 entry 时间戳(非 now)", () => {
521
+ const sessionFile = path.join(tmpDir, "crash.jsonl");
522
+ writeSessionJsonl(sessionFile, {
523
+ id: "bg-1", agent: "w", mode: "background", task: "t",
524
+ startedAt: 5000, lastTs: 9000, rootSessionId: "sess-A",
525
+ });
526
+ const store = new RecordStore(tmpDir);
527
+ const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
528
+ expect(found?.status).toBe("crashed");
529
+ expect(found?.endedAt).toBe(9000);
530
+ });
531
+
532
+ it(".alive running → endedAt 保持 undefined(running 耗时继续增长是正确的)", () => {
533
+ const sessionFile = path.join(tmpDir, "alive.jsonl");
534
+ writeSessionJsonl(sessionFile, {
535
+ id: "bg-1", agent: "w", mode: "background", task: "t",
536
+ startedAt: Date.now() - 1000, lastTs: Date.now(), rootSessionId: "sess-A",
537
+ });
538
+ writeAliveMarker(sessionFile, { pid: process.pid, id: "bg-1", startedAt: Date.now() - 1000 });
539
+ const store = new RecordStore(tmpDir);
540
+ const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
541
+ expect(found?.status).toBe("running");
542
+ expect(found?.endedAt).toBeUndefined();
543
+ });
544
+ });
545
+ });