@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
@@ -18,11 +18,12 @@ import * as path from "node:path";
18
18
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
19
 
20
20
  import { writeAliveMarker } from "../alive-store.ts";
21
- import { createRecord } from "../execution-record.ts";
21
+ import { completeRecord, createRecord, tryTransition } from "../execution-record.ts";
22
22
  import { writeFinalized } from "../finalized-marker.ts";
23
23
  import type { ManifestRecord } from "../manifest-store.ts";
24
24
  import { ManifestStore } from "../manifest-store.ts";
25
25
  import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
26
+ import { SUBAGENT_RECORD_CUSTOM_TYPE } from "../record-entry.ts";
26
27
  import type { StatusFilter } from "../record-store.ts";
27
28
  import { RecordStore } from "../record-store.ts";
28
29
  import { writeCancelledTombstone } from "../tombstone-store.ts";
@@ -37,6 +38,8 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
37
38
  task: "t",
38
39
  startedAt: 1000,
39
40
  rootSessionId: "sess-current",
41
+ // 对齐生产 register 路径(subagent-service createRecord:one-shot 显式 false)
42
+ chatMode: false,
40
43
  });
41
44
  return { ...base, ...over };
42
45
  }
@@ -47,7 +50,7 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
47
50
  */
48
51
  function writeSessionJsonl(
49
52
  filePath: string,
50
- identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number },
53
+ identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number; chatMode?: boolean },
51
54
  assistantText = "result text",
52
55
  ): void {
53
56
  const lastTs = identity.lastTs ?? identity.startedAt + 1000;
@@ -64,6 +67,7 @@ function writeSessionJsonl(
64
67
  if (identity.rootSessionId !== undefined) identityData.rootSessionId = identity.rootSessionId;
65
68
  if (identity.parentRecordId !== undefined) identityData.parentRecordId = identity.parentRecordId;
66
69
  if (identity.depth !== undefined) identityData.depth = identity.depth;
70
+ if (identity.chatMode !== undefined) identityData.chatMode = identity.chatMode;
67
71
  const identityEntry = JSON.stringify({
68
72
  type: "custom",
69
73
  id: "id-1",
@@ -89,13 +93,19 @@ function writeSessionJsonl(
89
93
  }
90
94
 
91
95
  describe("RecordStore", () => {
96
+ let rootDir: string;
92
97
  let tmpDir: string;
93
98
 
94
99
  beforeEach(() => {
95
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-test-"));
100
+ // [DS3] 两层布局:tmpDir 深一层(对齐生产 <enc> 段结构),sessions-index.json
101
+ // dirname(tmpDir)=rootDir 内随 afterEach 清理(含 MF-3/S-20 的嵌套 <enc> 段索引)。
102
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-test-"));
103
+ tmpDir = path.join(rootDir, "sessions");
104
+ fs.mkdirSync(tmpDir);
96
105
  });
97
106
  afterEach(() => {
98
- fs.rmSync(tmpDir, { recursive: true, force: true });
107
+ // maxRetries:fire-and-forget sessions-index 写可能与删除并发(ENOTEMPTY 竞态)
108
+ fs.rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
99
109
  });
100
110
 
101
111
  // ============================================================
@@ -104,7 +114,7 @@ describe("RecordStore", () => {
104
114
  describe("archive 立即移除", () => {
105
115
  it("archive 后 record 立即从内存移除(不再 linger)", () => {
106
116
  const store = new RecordStore(tmpDir);
107
- const r = makeRecord({ id: "sync-1", mode: "sync", status: "done" });
117
+ const r = makeRecord({ id: "sync-1", mode: "sync", status: "closed" });
108
118
  store.register(r);
109
119
  expect(store.getMutable("sync-1")).toBeDefined();
110
120
  store.archive(r);
@@ -113,7 +123,7 @@ describe("RecordStore", () => {
113
123
 
114
124
  it("background record 同样立即移除(不再 FIFO)", () => {
115
125
  const store = new RecordStore(tmpDir);
116
- const r = makeRecord({ id: "bg-1", mode: "background", status: "done" });
126
+ const r = makeRecord({ id: "bg-1", mode: "background", status: "closed" });
117
127
  store.register(r);
118
128
  store.archive(r);
119
129
  expect(store.getMutable("bg-1")).toBeUndefined();
@@ -131,20 +141,25 @@ describe("RecordStore", () => {
131
141
  expect(ids).toContain("run-1");
132
142
  });
133
143
 
134
- it("磁盘 session.jsonl 重建的终态 record 出现在结果中(无 sidecar → crashed)", () => {
144
+ it("磁盘 session.jsonl 重建的 record 出现在结果中(无 sidecar → running, SP-2 跨重启可恢复)", () => {
135
145
  const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
136
146
  writeSessionJsonl(sessionFile, {
137
147
  id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
138
148
  });
139
- // 无 sidecar → 四分支兜底 crashed
149
+ // 无 sidecar → 四分支兜底 running(v4 B-1:旧 idle 折入 running,SP-2 跨重启可恢复)
140
150
  const store = new RecordStore(tmpDir);
141
151
  const found = store.collectRecords(100).find((r) => r.id === "bg-1");
142
152
  expect(found).toBeDefined();
143
- expect(found?.status).toBe("crashed");
153
+ expect(found?.status).toBe("running");
144
154
  expect(found?.agent).toBe("worker");
145
- expect(found?.turns).toBe(1);
146
- expect(found?.totalTokens).toBe(30);
147
- expect(found?.result).toBe("result text");
155
+ // [perf] 列表是 light(头部 identity,详情字段缺省)——turns/tokens/result 走 getFullRecord 懒加载
156
+ expect(found?.turns).toBe(0);
157
+ expect(found?.result).toBeUndefined();
158
+ const full = store.getFullRecord("bg-1");
159
+ expect(full?.turns).toBe(1);
160
+ expect(full?.totalTokens).toBe(30);
161
+ expect(full?.result).toBe("result text");
162
+ expect(full?.status).toBe("running"); // 状态矩阵在全量路径同样套用
148
163
  });
149
164
 
150
165
  it("磁盘 session.jsonl + .finalized sidecar → done", () => {
@@ -156,10 +171,10 @@ describe("RecordStore", () => {
156
171
  const store = new RecordStore(tmpDir);
157
172
  const found = store.collectRecords(100).find((r) => r.id === "bg-2");
158
173
  expect(found).toBeDefined();
159
- expect(found?.status).toBe("done");
174
+ expect(found?.status).toBe("closed");
160
175
  });
161
176
 
162
- it("statusFilter='running' 只返回 running(磁盘终态被滤掉)", () => {
177
+ it("statusFilter='running' 含内存 + 磁盘跨重启 running(v4 B-1:旧 idle 折入 running)", () => {
163
178
  const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
164
179
  writeSessionJsonl(sessionFile, {
165
180
  id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
@@ -168,7 +183,8 @@ describe("RecordStore", () => {
168
183
  store.register(makeRecord({ id: "run-1", mode: "background", startedAt: 1000 }));
169
184
  const filter: StatusFilter = "running";
170
185
  const ids = store.collectRecords(100, filter).map((r) => r.id);
171
- expect(ids).toEqual(["run-1"]); // 只有内存 running,磁盘 crashed 被滤
186
+ // 内存 run-1 + 磁盘跨重启 bg-1 都是 running(旧 idle 折入);startedAt desc → bg-1(5000) 先
187
+ expect(ids).toEqual(["bg-1", "run-1"]);
172
188
  });
173
189
 
174
190
  it("statusFilter='all'(默认)返回内存 + 磁盘", () => {
@@ -191,7 +207,7 @@ describe("RecordStore", () => {
191
207
  const store = new RecordStore(tmpDir);
192
208
  store.register(makeRecord({ id: "dup-1", mode: "background", status: "running", startedAt: 5000 }));
193
209
  const found = store.collectRecords(100).find((r) => r.id === "dup-1");
194
- expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 crashed
210
+ expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 idle(SP-2)
195
211
  });
196
212
 
197
213
  it("[MF-3/S-20] 跨进程组合:子进程(worktree)写入 enc(ROOT) 的深层 record 被 ROOT store 重建,同 rootSessionId 全树可见、他树被排除", () => {
@@ -235,7 +251,7 @@ describe("RecordStore", () => {
235
251
  // cancelled tombstone override
236
252
  // ============================================================
237
253
  describe("cancelled tombstone", () => {
238
- it("有 .cancelled sidecar → status override 为 cancelled", () => {
254
+ it("有 .cancelled sidecar → status override 为 closed + closedReason=cancelled", () => {
239
255
  const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
240
256
  writeSessionJsonl(sessionFile, {
241
257
  id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
@@ -245,7 +261,9 @@ describe("RecordStore", () => {
245
261
  });
246
262
  const store = new RecordStore(tmpDir);
247
263
  const found = store.collectRecords(100).find((r) => r.id === "bg-1");
248
- expect(found?.status).toBe("cancelled");
264
+ // v4 B-1:cancelled 折入 closed,closedReason='cancelled' 保留用户取消语义
265
+ expect(found?.status).toBe("closed");
266
+ expect(found?.closedReason).toBe("cancelled");
249
267
  expect(found?.error).toBe("cancelled by user");
250
268
  });
251
269
  });
@@ -254,17 +272,19 @@ describe("RecordStore", () => {
254
272
  // compareRecords 排序稳定性(内存 running record)
255
273
  // ============================================================
256
274
  describe("compareRecords 排序", () => {
257
- it("status priority(running < crashed)", () => {
275
+ it("status priority(running < closed)", () => {
258
276
  const store = new RecordStore(tmpDir);
259
- // 内存 running record vs 磁盘无 sidecar → crashed
277
+ // 内存 running record
260
278
  const running = makeRecord({ id: "run-1", mode: "background", startedAt: 3000, status: "running" });
261
279
  store.register(running);
262
- // 磁盘 crashed record(无 sidecar → 四分支兜底)
263
- writeSessionJsonl(path.join(tmpDir, "a.jsonl"), {
280
+ // 磁盘 closed record(.finalized sidecar → closed,v4 B-1 统一终态)
281
+ const doneFile = path.join(tmpDir, "a.jsonl");
282
+ writeSessionJsonl(doneFile, {
264
283
  id: "done-1", agent: "w", mode: "background", task: "t", startedAt: 5000,
265
284
  });
285
+ writeFinalized(doneFile);
266
286
  const ids = store.collectRecords(100).map((r) => r.id);
267
- expect(ids[0]).toBe("run-1"); // running 排前
287
+ expect(ids[0]).toBe("run-1"); // running(0) 排在 closed(3) 前
268
288
  });
269
289
 
270
290
  it("同 status 时 startedAt desc(新→旧)", () => {
@@ -275,30 +295,55 @@ describe("RecordStore", () => {
275
295
  writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
276
296
  id: "new", agent: "w", mode: "background", task: "t", startedAt: 9000,
277
297
  });
278
- // 两个都是 crashed(磁盘重建,无 sidecar),按 startedAt desc
298
+ // 两个都是 running(磁盘重建,无 sidecar → SP-2 兜底 running,旧 idle 折入),按 startedAt desc
279
299
  const ids = store.collectRecords(100).map((r) => r.id);
280
300
  expect(ids).toEqual(["new", "old"]);
281
301
  });
282
302
  });
283
303
 
304
+ // ============================================================
305
+ // running 态 record(M2-A chatMode 续聊;v4 B-1:旧 idle 折入 running)
306
+ // ============================================================
307
+ describe("running 态 record(旧 idle 折入,等待续聊)", () => {
308
+ it("getMutable 能查到 running record(不筛 status,running record 留内存不 archive)", () => {
309
+ const store = new RecordStore(tmpDir);
310
+ const r = makeRecord({ id: "idle-1", status: "running" });
311
+ store.register(r);
312
+ // running record 不 archive → getMutable 仍可查(续聊定位依赖此)
313
+ expect(store.getMutable("idle-1")).toBe(r);
314
+ });
315
+
316
+ it("running record 经 collectRecords(statusFilter=all) 可见", () => {
317
+ const store = new RecordStore(tmpDir);
318
+ store.register(makeRecord({ id: "idle-2", status: "running" }));
319
+ const ids = store.collectRecords(100, "all").map((r) => r.id);
320
+ expect(ids).toContain("idle-2");
321
+ });
322
+
323
+ it("running record 经 collectRecords(statusFilter=running) 可见(v4 B-1:旧 idle 折入 running)", () => {
324
+ const store = new RecordStore(tmpDir);
325
+ store.register(makeRecord({ id: "idle-3", status: "running" }));
326
+ const ids = store.collectRecords(100, "running").map((r) => r.id);
327
+ expect(ids).toContain("idle-3");
328
+ });
329
+ });
330
+
331
+
284
332
  // ============================================================
285
333
  // 重建缓存
286
334
  // ============================================================
287
335
  describe("重建缓存", () => {
288
- it("notifyChange 后缓存失效(新 session.jsonl 可见)", () => {
336
+ it(" session.jsonl 无需内存事件即立即可见([perf] stat 校验取代 notifyChange 整体失效)", () => {
289
337
  const store = new RecordStore(tmpDir);
290
338
  // 首次 collect:空目录
291
339
  expect(store.collectRecords(100)).toHaveLength(0);
292
- // 写新 session.jsonl
340
+ // 写新 session.jsonl(模拟外部进程/子进程写入——不经本进程任何内存事件)
293
341
  writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
294
342
  id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 1000,
295
343
  });
296
- // 缓存仍命中旧结果(notifyChange 未触发)
297
- expect(store.collectRecords(100)).toHaveLength(0);
298
- // register 触发 notifyChange 缓存失效
299
- store.register(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
300
- store.archive(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
301
- // 现在 bg-1 可见
344
+ // [perf] 旧实现:reconCache 建立后只能靠 register/archive 触发 notifyChange 失效,
345
+ // 否则永远 stale(外部进程写入的文件对本进程不可见)。新实现:每次 collectRecords
346
+ // readdir + stat 校验,新文件立即可见(同时修复跨进程可见性)。
302
347
  const ids = store.collectRecords(100).map((r) => r.id);
303
348
  expect(ids).toContain("bg-1");
304
349
  });
@@ -336,14 +381,16 @@ describe("RecordStore", () => {
336
381
  }
337
382
 
338
383
  // ── 分支 1: .cancelled ──
339
- it(".cancelled sidecar → cancelled", () => {
384
+ it(".cancelled sidecar → closed + closedReason=cancelled", () => {
340
385
  const sessionFile = writeBaseSession();
341
386
  writeCancelledTombstone(sessionFile, {
342
387
  id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 6000,
343
388
  });
344
389
  const store = new RecordStore(tmpDir);
345
390
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
346
- expect(found?.status).toBe("cancelled");
391
+ // v4 B-1:cancelled 折入 closed(closedReason='cancelled' 区分用户取消)
392
+ expect(found?.status).toBe("closed");
393
+ expect(found?.closedReason).toBe("cancelled");
347
394
  expect(found?.error).toBe("cancelled by user");
348
395
  expect(found?.endedAt).toBe(6000);
349
396
  });
@@ -354,7 +401,7 @@ describe("RecordStore", () => {
354
401
  writeFinalized(sessionFile);
355
402
  const store = new RecordStore(tmpDir);
356
403
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
357
- expect(found?.status).toBe("done");
404
+ expect(found?.status).toBe("closed");
358
405
  });
359
406
 
360
407
  // ── 分支 2: .finalized failed ──
@@ -386,7 +433,7 @@ describe("RecordStore", () => {
386
433
  writeFinalized(sessionFile);
387
434
  const store = new RecordStore(tmpDir);
388
435
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
389
- expect(found?.status).toBe("failed");
436
+ expect(found?.status).toBe("closed");
390
437
  });
391
438
 
392
439
  // ── 分支 3: .alive + 活 pid → running + externalInstance ──
@@ -401,27 +448,27 @@ describe("RecordStore", () => {
401
448
  expect(found?.externalInstance).toEqual(marker);
402
449
  });
403
450
 
404
- // ── 分支 3→4: .alive + 死 pid → crashed ──
405
- it(".alive + 死 pid → crashed", () => {
451
+ // ── 分支 3→4: .alive + 死 pid → running(SP-2:跨重启可恢复)──
452
+ it(".alive + 死 pid → running(SP-2 跨重启可恢复)", () => {
406
453
  const sessionFile = writeBaseSession();
407
454
  const marker: AliveMarker = { pid: 9999999, id: SESSION_ID, startedAt: STARTED_AT };
408
455
  writeAliveMarker(sessionFile, marker);
409
456
  const store = new RecordStore(tmpDir);
410
457
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
411
- expect(found?.status).toBe("crashed");
458
+ expect(found?.status).toBe("running");
412
459
  expect(found?.externalInstance).toBeUndefined();
413
460
  });
414
461
 
415
- // ── 分支 4: 都无 sidecar → crashed ──
416
- it("无任何 sidecar → crashed", () => {
462
+ // ── 分支 4: 都无 sidecar → running(SP-2:跨重启可恢复)──
463
+ it("无任何 sidecar → running(SP-2 跨重启可恢复)", () => {
417
464
  writeBaseSession();
418
465
  const store = new RecordStore(tmpDir);
419
466
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
420
- expect(found?.status).toBe("crashed");
467
+ expect(found?.status).toBe("running");
421
468
  });
422
469
 
423
- // ── 分支 4: >24h 软超时 → crashed(无视探活)──
424
- it(">24h 软超时 → crashed(即使 pid 存活)", () => {
470
+ // ── 分支 4: >24h 软超时 → running(SP-2:跨重启可恢复,无视探活)──
471
+ it(">24h 软超时 → running(SP-2,即使 pid 存活)", () => {
425
472
  const sessionFile = writeBaseSession();
426
473
  // startedAt 设为 25 小时前
427
474
  const oldStartedAt = Date.now() - 25 * 60 * 60 * 1000;
@@ -436,7 +483,7 @@ describe("RecordStore", () => {
436
483
 
437
484
  const store = new RecordStore(tmpDir);
438
485
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
439
- expect(found?.status).toBe("crashed");
486
+ expect(found?.status).toBe("running");
440
487
  expect(found?.externalInstance).toBeUndefined();
441
488
  });
442
489
 
@@ -449,18 +496,21 @@ describe("RecordStore", () => {
449
496
  });
450
497
  const store = new RecordStore(tmpDir);
451
498
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
452
- expect(found?.status).toBe("cancelled");
499
+ // v4 B-1:.cancelled 优先级不变,但 status 折入 closed(closedReason='cancelled')
500
+ expect(found?.status).toBe("closed");
501
+ expect(found?.closedReason).toBe("cancelled");
453
502
  });
454
503
 
455
504
  // ── 回归:旧 .cancelled 单分支行为不变 ──
456
- it("旧 .cancelled 单分支行为不变(回归)", () => {
505
+ it("旧 .cancelled 单分支行为不变(回归,status 折入 closed)", () => {
457
506
  const sessionFile = writeBaseSession();
458
507
  writeCancelledTombstone(sessionFile, {
459
508
  id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 7000,
460
509
  });
461
510
  const store = new RecordStore(tmpDir);
462
511
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
463
- expect(found?.status).toBe("cancelled");
512
+ expect(found?.status).toBe("closed");
513
+ expect(found?.closedReason).toBe("cancelled");
464
514
  expect(found?.error).toBe("cancelled by user");
465
515
  expect(found?.endedAt).toBe(7000);
466
516
  });
@@ -546,7 +596,7 @@ describe("RecordStore", () => {
546
596
  // endedAt 重建(问题 2 修复:终态耗时不再随墙钟增长)
547
597
  // ============================================================
548
598
  describe("endedAt 重建(耗时不再无限增长)", () => {
549
- it(".finalized → endedAt 为最后 entry 时间戳(非 now)", () => {
599
+ it(".finalized → light endedAt 用 mtime 近似,全量 endedAt 为最后 entry 时间戳", () => {
550
600
  const sessionFile = path.join(tmpDir, "fin.jsonl");
551
601
  writeSessionJsonl(sessionFile, {
552
602
  id: "bg-1", agent: "w", mode: "background", task: "t",
@@ -555,10 +605,15 @@ describe("RecordStore", () => {
555
605
  writeFinalized(sessionFile);
556
606
  const store = new RecordStore(tmpDir);
557
607
  const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
558
- expect(found?.endedAt).toBe(9000); // 不是 Date.now()
608
+ // [perf] light 分支 2:endedAt jsonl mtime 近似(finalize 后文件不再变化,
609
+ // 与最后 entry ts 差 <1s)——不是 entry ts 9000,也不是随墙钟无限增长的 now 基准。
610
+ expect(found?.endedAt).toBeDefined();
611
+ expect(found?.endedAt).toBeGreaterThan(5000);
612
+ // 全量(getFullRecord):精确用最后 entry 时间戳(非 now)。
613
+ expect(store.getFullRecord("bg-1")?.endedAt).toBe(9000);
559
614
  });
560
615
 
561
- it("无 sidecar(crashed)→ endedAt 为最后 entry 时间戳(非 now)", () => {
616
+ it("无 sidecar(running, SP-2)→ endedAt 保持 undefined(非终态)", () => {
562
617
  const sessionFile = path.join(tmpDir, "crash.jsonl");
563
618
  writeSessionJsonl(sessionFile, {
564
619
  id: "bg-1", agent: "w", mode: "background", task: "t",
@@ -566,8 +621,9 @@ describe("RecordStore", () => {
566
621
  });
567
622
  const store = new RecordStore(tmpDir);
568
623
  const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
569
- expect(found?.status).toBe("crashed");
570
- expect(found?.endedAt).toBe(9000);
624
+ expect(found?.status).toBe("running");
625
+ // SP-2: running 是非终态(旧 idle 折入),endedAt 保持 undefined(待续聊)
626
+ expect(found?.endedAt).toBeUndefined();
571
627
  });
572
628
 
573
629
  it(".alive running → endedAt 保持 undefined(running 耗时继续增长是正确的)", () => {
@@ -602,7 +658,7 @@ describe("RecordStore", () => {
602
658
  id: "broken-1",
603
659
  rootSessionId: "sess-current",
604
660
  agentName: "worker",
605
- status: "crashed", // 越界值(union 不含 "crashed")
661
+ status: "crashed", // 越界值(union 不含 "crashed",SP-1 后 valid statuses = running/closed/cancelled
606
662
  createdAt: 1000,
607
663
  } as unknown as ManifestRecord;
608
664
  }
@@ -666,4 +722,336 @@ describe("RecordStore", () => {
666
722
  );
667
723
  });
668
724
  });
725
+
726
+ // ============================================================
727
+ // SP-2: reconstructAll 兜底分支输出 running(非 crashed;v4 两态后旧 idle 折入 running)
728
+ // ============================================================
729
+ describe("SP-2: reconstructAll 兜底 running(跨重启恢复)", () => {
730
+ // TC-1: 兜底分支输出 running(非 crashed)
731
+ it("TC-1: 无 sidecar + 死 pid → reconstructAll 输出 running(可冷路径 resume)", () => {
732
+ const sessionFile = path.join(tmpDir, "sp2-tc1.jsonl");
733
+ writeSessionJsonl(sessionFile, {
734
+ id: "sa-sp2-1", agent: "worker", mode: "background", task: "cross-restart task",
735
+ startedAt: 1000, rootSessionId: "sess-sp2",
736
+ });
737
+ // 无任何 sidecar marker → 分支 4 兜底
738
+ const store = new RecordStore(tmpDir);
739
+ const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-1");
740
+ expect(found).toBeDefined();
741
+ expect(found?.status).toBe("running");
742
+ // SP-2: running 是非终态(旧 idle 折入),endedAt 保持 undefined
743
+ expect(found?.endedAt).toBeUndefined();
744
+ });
745
+
746
+ // TC-2: 有 .finalized marker 的 record 仍归档为 done(不回归)
747
+ it("TC-2: .finalized marker → done(分支 1/2 不回归)", () => {
748
+ const sessionFile = path.join(tmpDir, "sp2-tc2.jsonl");
749
+ writeSessionJsonl(sessionFile, {
750
+ id: "sa-sp2-2", agent: "worker", mode: "background", task: "finalized task",
751
+ startedAt: 2000, rootSessionId: "sess-sp2",
752
+ });
753
+ writeFinalized(sessionFile);
754
+ const store = new RecordStore(tmpDir);
755
+ const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-2");
756
+ expect(found).toBeDefined();
757
+ // 分支 2 仍归档为 done,不受 SP-2 影响
758
+ expect(found?.status).toBe("closed");
759
+ expect(found?.endedAt).toBeDefined();
760
+ });
761
+
762
+ // TC-2b: .cancelled marker → closed+cancelled(分支 1 不回归)
763
+ it("TC-2b: .cancelled marker → closed+cancelled reason(分支 1 不回归)", () => {
764
+ const sessionFile = path.join(tmpDir, "sp2-tc2b.jsonl");
765
+ writeSessionJsonl(sessionFile, {
766
+ id: "sa-sp2-2b", agent: "worker", mode: "background", task: "cancelled task",
767
+ startedAt: 3000, rootSessionId: "sess-sp2",
768
+ });
769
+ writeCancelledTombstone(sessionFile, {
770
+ id: "sa-sp2-2b", status: "cancelled", agent: "worker", startedAt: 3000, endedAt: 4000,
771
+ });
772
+ const store = new RecordStore(tmpDir);
773
+ const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-2b");
774
+ expect(found).toBeDefined();
775
+ expect(found?.status).toBe("closed");
776
+ expect(found?.closedReason).toBe("cancelled");
777
+ });
778
+ });
779
+
780
+ // ============================================================
781
+ // 孤儿终态恢复(residual-fixes):分支 4 兜底 record 的判定与落盘
782
+ // ============================================================
783
+ describe("recoverOrphanRecords 孤儿终态恢复", () => {
784
+ /** 带真实磁盘 fixture + appendEntry 捕获的 store。 */
785
+ function makeRecoveryStore(): { store: RecordStore; appended: Array<{ customType: string; data: Record<string, unknown> }> } {
786
+ const appended: Array<{ customType: string; data: Record<string, unknown> }> = [];
787
+ const store = new RecordStore(tmpDir, undefined, {
788
+ appendEntry: (customType: string, data: unknown) => {
789
+ appended.push({ customType, data: data as Record<string, unknown> });
790
+ },
791
+ } as never);
792
+ return { store, appended };
793
+ }
794
+
795
+ it("末行完整 → closed 终态 entry + .finalized sidecar(防重锚)", () => {
796
+ const sessionFile = path.join(tmpDir, "orphan-done.jsonl");
797
+ writeSessionJsonl(sessionFile, {
798
+ id: "sa-orphan-1", agent: "worker", mode: "background", task: "orphan done",
799
+ startedAt: 1000, rootSessionId: "sess-orphan",
800
+ });
801
+ const { store, appended } = makeRecoveryStore();
802
+ store.recoverOrphanRecords("sess-orphan");
803
+
804
+ const entry = appended.find((c) => c.data.id === "sa-orphan-1");
805
+ expect(entry?.customType).toBe("subagent-record");
806
+ expect(entry?.data.status).toBe("closed");
807
+ expect(entry?.data.closedReason).toBe("gc");
808
+ expect(entry?.data.endedAt).toEqual(expect.any(Number));
809
+ expect(entry?.data.error).toBeUndefined();
810
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
811
+ // 防重:sidecar 使下次重建走分支 2(closed),不再进判定
812
+ const again = [...appended];
813
+ store.recoverOrphanRecords("sess-orphan");
814
+ expect(appended.length).toBe(again.length);
815
+ const found = store.collectRecords(100, "all", "sess-orphan").find((r) => r.id === "sa-orphan-1");
816
+ expect(found?.status).toBe("closed");
817
+ });
818
+
819
+ it("末行截断 → closed + error(保守方向)", () => {
820
+ const sessionFile = path.join(tmpDir, "orphan-truncated.jsonl");
821
+ writeSessionJsonl(sessionFile, {
822
+ id: "sa-orphan-2", agent: "worker", mode: "background", task: "orphan truncated",
823
+ startedAt: 2000, rootSessionId: "sess-orphan",
824
+ });
825
+ // 制造截断:append 半行 JSON(无换行结尾)
826
+ fs.appendFileSync(sessionFile, '{"type":"message","id":"msg-2","pare', "utf-8");
827
+ const { store, appended } = makeRecoveryStore();
828
+ store.recoverOrphanRecords("sess-orphan");
829
+
830
+ const entry = appended.find((c) => c.data.id === "sa-orphan-2");
831
+ expect(entry?.data.status).toBe("closed");
832
+ expect(entry?.data.error).toContain("truncated");
833
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
834
+ });
835
+
836
+ it("末行 >64KB 完整 JSON → 不误判截断,判 done(V1 探针实测回归)", () => {
837
+ const sessionFile = path.join(tmpDir, "orphan-longline.jsonl");
838
+ writeSessionJsonl(sessionFile, {
839
+ id: "sa-orphan-5", agent: "worker", mode: "background", task: "orphan long line",
840
+ startedAt: 5000, rootSessionId: "sess-orphan",
841
+ });
842
+ // 真实库实测形态:末行为超长完整 entry(subagent-identity 的 task 内嵌大 payload,
843
+ // 28/4822 个文件末行 65KB-776KB)——固定 64KB 尾窗曾把这类行从中间切开误判截断。
844
+ const bigEntry = JSON.stringify({
845
+ type: "custom", id: "id-big", parentId: null,
846
+ timestamp: new Date(5000).toISOString(), customType: "subagent-identity",
847
+ data: { id: "sa-orphan-5", agent: "worker", mode: "background", task: "x".repeat(70 * 1024), startedAt: 5000 },
848
+ });
849
+ fs.appendFileSync(sessionFile, bigEntry + "\n", "utf-8");
850
+ const { store, appended } = makeRecoveryStore();
851
+ store.recoverOrphanRecords("sess-orphan");
852
+
853
+ const entry = appended.find((c) => c.data.id === "sa-orphan-5");
854
+ expect(entry?.data.status).toBe("closed");
855
+ expect(entry?.data.error).toBeUndefined();
856
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
857
+ });
858
+
859
+ it("chatMode 孤儿 → 不终态化,落 resumable entry(可续聊产品语义)", () => {
860
+ const sessionFile = path.join(tmpDir, "orphan-chat.jsonl");
861
+ writeSessionJsonl(sessionFile, {
862
+ id: "sa-orphan-3", agent: "worker", mode: "background", task: "orphan chat",
863
+ startedAt: 3000, rootSessionId: "sess-orphan", chatMode: true,
864
+ });
865
+ const { store, appended } = makeRecoveryStore();
866
+ store.recoverOrphanRecords("sess-orphan");
867
+
868
+ const entry = appended.find((c) => c.data.id === "sa-orphan-3");
869
+ expect(entry?.data.status).toBe("running");
870
+ expect(entry?.data.resumable).toBe(true);
871
+ expect(entry?.data.chatMode).toBe(true);
872
+ // chat 分流不写终态 sidecar
873
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
874
+ // orphanJudged 缓存:同 store 重复调用不重复 append
875
+ const count = appended.length;
876
+ store.recoverOrphanRecords("sess-orphan");
877
+ expect(appended.length).toBe(count);
878
+ });
879
+
880
+ it("非分支 4 record(closed / 活进程)不进判定", () => {
881
+ const closedFile = path.join(tmpDir, "orphan-closed.jsonl");
882
+ writeSessionJsonl(closedFile, {
883
+ id: "sa-orphan-4", agent: "worker", mode: "background", task: "already closed",
884
+ startedAt: 4000, rootSessionId: "sess-orphan",
885
+ });
886
+ writeFinalized(closedFile);
887
+ const { store, appended } = makeRecoveryStore();
888
+ store.recoverOrphanRecords("sess-orphan");
889
+ expect(appended.find((c) => c.data.id === "sa-orphan-4")).toBeUndefined();
890
+ });
891
+
892
+ /** 写主 session fixture(含指定 subagent-record entry 序列)。 */
893
+ function writeMainSession(entries: Array<Record<string, unknown>>): string {
894
+ const mainFile = path.join(tmpDir, "main-session.jsonl");
895
+ const lines = entries.map((d) => JSON.stringify({ type: "custom", id: `e-${Math.random()}`, parentId: null, customType: "subagent-record", data: d }));
896
+ fs.writeFileSync(mainFile, lines.join("\n") + "\n", "utf-8");
897
+ return mainFile;
898
+ }
899
+
900
+ it("entry-born 孤儿(无子文件,spawn 窗口期死亡)→ closed+gc+error(E2E 实测回归)", () => {
901
+ const mainFile = writeMainSession([
902
+ { v: 1, id: "sa-entryonly-1", agent: "worker", task: "spawn interrupted", slug: "s", status: "running", mode: "background", startedAt: 6000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
903
+ ]);
904
+ const { store, appended } = makeRecoveryStore();
905
+ store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
906
+
907
+ const entry = appended.find((c) => c.data.id === "sa-entryonly-1");
908
+ expect(entry?.data.status).toBe("closed");
909
+ expect(entry?.data.closedReason).toBe("gc");
910
+ expect(entry?.data.error).toContain("no child session file");
911
+ // 防重:orphanJudged 缓存拦截二次判定
912
+ const count = appended.length;
913
+ store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
914
+ expect(appended.length).toBe(count);
915
+ });
916
+
917
+ it("entry-born chatMode 孤儿 → 不终态化,落 resumable entry", () => {
918
+ const mainFile = writeMainSession([
919
+ { v: 1, id: "sa-entryonly-2", agent: "worker", task: "chat spawn interrupted", slug: "s", status: "running", mode: "background", startedAt: 7000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [], chatMode: true },
920
+ ]);
921
+ const { store, appended } = makeRecoveryStore();
922
+ store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
923
+
924
+ const entry = appended.find((c) => c.data.id === "sa-entryonly-2");
925
+ expect(entry?.data.status).toBe("running");
926
+ expect(entry?.data.resumable).toBe(true);
927
+ expect(entry?.data.error).toBeUndefined();
928
+ });
929
+
930
+ it("有子文件锚 / 末条已终态 / 他 session 的 entry-born id 不进判定", () => {
931
+ const anchoredFile = path.join(tmpDir, "orphan-anchored.jsonl");
932
+ writeSessionJsonl(anchoredFile, {
933
+ id: "sa-anchored", agent: "worker", mode: "background", task: "has file",
934
+ startedAt: 8000, rootSessionId: "sess-orphan",
935
+ });
936
+ const mainFile = writeMainSession([
937
+ { v: 1, id: "sa-anchored", agent: "worker", task: "has file", slug: "s", status: "running", mode: "background", startedAt: 8000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
938
+ { v: 1, id: "sa-settled", agent: "worker", task: "settled", slug: "s", status: "closed", mode: "background", startedAt: 8100, rootSessionId: "sess-orphan", depth: 0, turns: 1, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
939
+ { v: 1, id: "sa-foreign", agent: "worker", task: "other session", slug: "s", status: "running", mode: "background", startedAt: 8200, rootSessionId: "sess-other", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
940
+ ]);
941
+ const { store, appended } = makeRecoveryStore();
942
+ store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
943
+ expect(appended.find((c) => c.data.id === "sa-anchored")).toBeUndefined();
944
+ expect(appended.find((c) => c.data.id === "sa-settled")).toBeUndefined();
945
+ expect(appended.find((c) => c.data.id === "sa-foreign")).toBeUndefined();
946
+ });
947
+ });
948
+
949
+ // ============================================================
950
+ // W16 [D4]:subagent-record 自描述 appendEntry 上报(状态迁移点)
951
+ // ============================================================
952
+ describe("W16 subagent-record 自描述 appendEntry 上报", () => {
953
+ /** appendEntry 捕获(RecordStorePi 最小实现)。 */
954
+ interface AppendedCall {
955
+ customType: string;
956
+ data: unknown;
957
+ }
958
+
959
+ /** 构造带 appendEntry 捕获的 store(pi 注入通道,对齐生产 setPi 后形态)。 */
960
+ function makeStoreWithPi(): { store: RecordStore; appended: AppendedCall[] } {
961
+ const appended: AppendedCall[] = [];
962
+ const store = new RecordStore(tmpDir, undefined, {
963
+ appendEntry: (customType: string, data: unknown) => {
964
+ appended.push({ customType, data });
965
+ },
966
+ });
967
+ return { store, appended };
968
+ }
969
+
970
+ /** unknown → 对象的运行时 guard(taste/no-unsafe-cast:断言前先收窄)。 */
971
+ function asEntryData(d: unknown): Record<string, unknown> {
972
+ if (typeof d !== "object" || d === null) throw new Error("entry data is not an object");
973
+ return d as Record<string, unknown>;
974
+ }
975
+
976
+ it("register:append subagent-record entry,data = v1 + SubagentRecord 完整快照 schema", () => {
977
+ const { store, appended } = makeStoreWithPi();
978
+ store.register(makeRecord());
979
+
980
+ expect(appended).toHaveLength(1);
981
+ // 写点字面量与 record-entry.ts 常量等值(钉住双源一致性)
982
+ expect(appended[0]?.customType).toBe(SUBAGENT_RECORD_CUSTOM_TYPE);
983
+ // [E2E 实测教训] 必须先 JSON 序列化再断言:appendEntry 捕获的是内存对象,
984
+ // undefined 值的键名仍在(旧 27-key 断言因此漏检 recordToSubagent 丢
985
+ // chatMode 的 bug);真实 JSONL 会丢 undefined 值键,此处对齐生产行为。
986
+ const data = asEntryData(JSON.parse(JSON.stringify(appended[0]?.data)));
987
+ // register 时点序列化存活字段(undefined 值字段按生产序列化丢弃):
988
+ // chatMode 必须显式在场(one-shot=false)——renderer isDone 判据依赖它。
989
+ expect(Object.keys(data).sort()).toEqual([
990
+ "agent", "chatMode", "depth", "displayItems", "eventLog",
991
+ "id", "mode", "model", "rootSessionId", "round", "startedAt",
992
+ "status", "task", "totalTokens", "turns", "v", "worktree",
993
+ ]);
994
+ expect(data).toMatchObject({
995
+ v: 1,
996
+ id: "r1",
997
+ agent: "worker",
998
+ task: "t",
999
+ status: "running",
1000
+ mode: "sync",
1001
+ startedAt: 1000,
1002
+ rootSessionId: "sess-current",
1003
+ chatMode: false,
1004
+ turns: 0,
1005
+ totalTokens: 0,
1006
+ model: "m",
1007
+ eventLog: [],
1008
+ displayItems: [],
1009
+ });
1010
+ });
1011
+
1012
+ it("archive:append 终态完整快照(result/endedAt/closedReason);one-shot 生命周期共 2 次 append", () => {
1013
+ const { store, appended } = makeStoreWithPi();
1014
+ const r = makeRecord();
1015
+ store.register(r);
1016
+ // 模拟正常终态路径:tryTransition CAS → completeRecord 冻结 → archive(D-017 时序)
1017
+ tryTransition(r, "closed", "gc");
1018
+ completeRecord(r, { text: "task done", turns: 1, durationMs: 500, success: true, sessionId: "r1", toolCalls: [] }, "closed", "gc");
1019
+ store.archive(r);
1020
+
1021
+ // 探针基线(单测级):one-shot 生命周期 = register + archive = 2 次 append
1022
+ expect(appended).toHaveLength(2);
1023
+ const data = asEntryData(appended[1]?.data);
1024
+ expect(data).toMatchObject({
1025
+ v: 1,
1026
+ id: "r1",
1027
+ status: "closed",
1028
+ closedReason: "gc",
1029
+ result: "task done",
1030
+ endedAt: expect.any(Number) as number,
1031
+ });
1032
+ });
1033
+
1034
+ it("reportRecordTransition:类外恢复写点上报(chatMode 续轮 round 携带)", () => {
1035
+ const { store, appended } = makeStoreWithPi();
1036
+ const r = makeRecord({ chatMode: true, round: 1 });
1037
+ store.reportRecordTransition(r);
1038
+
1039
+ expect(appended).toHaveLength(1);
1040
+ expect(appended[0]?.customType).toBe(SUBAGENT_RECORD_CUSTOM_TYPE);
1041
+ expect(asEntryData(appended[0]?.data)).toMatchObject({
1042
+ v: 1,
1043
+ id: "r1",
1044
+ status: "running",
1045
+ round: 1,
1046
+ });
1047
+ });
1048
+
1049
+ it("pi 未注入(session_start 前):三写点均安全降级不抛错", () => {
1050
+ const store = new RecordStore(tmpDir);
1051
+ const r = makeRecord();
1052
+ expect(() => store.register(r)).not.toThrow();
1053
+ expect(() => store.archive(r)).not.toThrow();
1054
+ expect(() => store.reportRecordTransition(r)).not.toThrow();
1055
+ });
1056
+ });
669
1057
  });