@zhushanwen/subagent-core 0.8.0 → 0.9.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 (167) hide show
  1. package/dist/{chunk-OL5BK4VN.js → chunk-LZJOEDGQ.js} +455 -203
  2. package/dist/engine-discovery-scan-Da4Dkzsi.d.cts +818 -0
  3. package/dist/engine-discovery-scan-Da4Dkzsi.d.ts +818 -0
  4. package/dist/execution/engine/engine-discovery-scan.cjs +216 -155
  5. package/dist/execution/engine/engine-discovery-scan.d.cts +1 -2
  6. package/dist/execution/engine/engine-discovery-scan.d.ts +1 -2
  7. package/dist/execution/engine/engine-discovery-scan.js +1 -1
  8. package/dist/execution/relay-env.js +5 -4
  9. package/dist/index.cjs +7686 -5894
  10. package/dist/index.d.cts +1876 -1360
  11. package/dist/index.d.ts +1876 -1360
  12. package/dist/index.js +7048 -5493
  13. package/dist.bundle/index.cjs +7747 -5957
  14. package/package.json +4 -4
  15. package/src/__tests__/manifest-store.test.ts +16 -15
  16. package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
  17. package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
  18. package/src/execution/__tests__/alive-store.test.ts +117 -1
  19. package/src/execution/__tests__/batch-finalized.test.ts +206 -0
  20. package/src/execution/__tests__/chat-engine-routing.test.ts +117 -1
  21. package/src/execution/__tests__/{cold-resurrect.test.ts → cold-lookup.test.ts} +133 -33
  22. package/src/execution/__tests__/collect-coordinator-service.test.ts +7 -5
  23. package/src/execution/__tests__/conversation-continuation.test.ts +1452 -0
  24. package/src/execution/__tests__/delivery-methods.test.ts +90 -176
  25. package/src/execution/__tests__/dialog-queue.test.ts +70 -3
  26. package/src/execution/__tests__/dispose-manifest-recovery.test.ts +475 -0
  27. package/src/execution/__tests__/execute-and-await-worktree.test.ts +3 -3
  28. package/src/execution/__tests__/execute-nesting.test.ts +8 -5
  29. package/src/execution/__tests__/execution-record.test.ts +8 -148
  30. package/src/execution/__tests__/finalize-record.test.ts +295 -129
  31. package/src/execution/__tests__/get-record-for-action-restart.test.ts +21 -29
  32. package/src/execution/__tests__/helpers/fake-engine-port.ts +13 -68
  33. package/src/execution/__tests__/helpers/legacy-sidecar.ts +33 -0
  34. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +9 -5
  35. package/src/execution/__tests__/idle-gc.test.ts +60 -0
  36. package/src/execution/__tests__/lifecycle-manager.test.ts +3 -254
  37. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +43 -22
  38. package/src/execution/__tests__/model-config-service.test.ts +111 -0
  39. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +10 -6
  40. package/src/execution/__tests__/notify-ledger.test.ts +42 -1
  41. package/src/execution/__tests__/rebuild-indexes.test.ts +329 -0
  42. package/src/execution/__tests__/record-binding.test.ts +613 -0
  43. package/src/execution/__tests__/record-origin.test.ts +318 -0
  44. package/src/execution/__tests__/record-store-intent-api.test.ts +600 -0
  45. package/src/execution/__tests__/record-store-orphan-revive.test.ts +71 -3
  46. package/src/execution/__tests__/record-store.test.ts +72 -54
  47. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +8 -5
  48. package/src/execution/__tests__/round-supervisor-workflow-origin.test.ts +205 -0
  49. package/src/execution/__tests__/run-orchestration-write-lease.test.ts +293 -0
  50. package/src/execution/__tests__/run-orchestration.test.ts +199 -0
  51. package/src/execution/__tests__/session-file-gc.test.ts +35 -4
  52. package/src/execution/__tests__/session-reconstructor.test.ts +78 -0
  53. package/src/execution/__tests__/state-marker.test.ts +288 -0
  54. package/src/execution/__tests__/stream-sink-retirement.test.ts +8 -5
  55. package/src/execution/__tests__/subagent-actions-core.test.ts +67 -16
  56. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +0 -1
  57. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -11
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +17 -11
  59. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +130 -53
  60. package/src/execution/__tests__/subagent-service.test.ts +17 -215
  61. package/src/execution/__tests__/subprocess-agent-runner-no-progress-killall.test.ts +138 -0
  62. package/src/execution/__tests__/subprocess-agent-runner.test.ts +105 -660
  63. package/src/execution/__tests__/sync-collect-recovery.test.ts +23 -16
  64. package/src/execution/__tests__/terminal-write-latency.test.ts +79 -0
  65. package/src/execution/__tests__/workflow-agent-dispatch.test.ts +782 -0
  66. package/src/execution/alive-store.ts +37 -28
  67. package/src/execution/{cold-resurrect.ts → cold-lookup.ts} +48 -45
  68. package/src/execution/config.ts +1 -1
  69. package/src/execution/conversation-continuation.ts +541 -0
  70. package/src/execution/dialog-queue.ts +60 -12
  71. package/src/execution/engine/__tests__/common/capability-gate.test.ts +1 -1
  72. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +16 -1
  73. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +17 -51
  74. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +0 -5
  75. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +1 -1
  76. package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +5 -9
  77. package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +0 -3
  78. package/src/execution/engine/__tests__/engine-discovery.test.ts +0 -3
  79. package/src/execution/engine/__tests__/model-prompt.test.ts +0 -3
  80. package/src/execution/engine/__tests__/registry.test.ts +0 -1
  81. package/src/execution/engine/__tests__/routing.test.ts +0 -1
  82. package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +0 -3
  83. package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +0 -3
  84. package/src/execution/engine/client/__tests__/engine-client-reap.test.ts +212 -0
  85. package/src/execution/engine/client/__tests__/mirror.test.ts +73 -1
  86. package/src/execution/engine/client/__tests__/protocol-closure.test.ts +2 -9
  87. package/src/execution/engine/client/__tests__/remote-engine.test.ts +46 -12
  88. package/src/execution/engine/client/engine-client.ts +81 -25
  89. package/src/execution/engine/client/mirror.ts +9 -0
  90. package/src/execution/engine/client/remote-engine.ts +52 -34
  91. package/src/execution/engine/client/reverse-router.ts +6 -27
  92. package/src/execution/engine/common/__tests__/run-signals.test.ts +79 -0
  93. package/src/execution/engine/common/capability-gate.ts +27 -0
  94. package/src/execution/engine/common/data-dir.ts +3 -2
  95. package/src/execution/engine/common/errors.ts +1 -1
  96. package/src/execution/engine/common/journal-wiring.ts +18 -9
  97. package/src/execution/engine/common/run-signals.ts +83 -0
  98. package/src/execution/engine/d8-compat.ts +1 -1
  99. package/src/execution/engine/host/__tests__/host-bridge.test.ts +3 -18
  100. package/src/execution/engine/host/host-bridge.ts +19 -41
  101. package/src/execution/engine/host/pi-host-binding.ts +5 -5
  102. package/src/execution/engine/port.ts +34 -54
  103. package/src/execution/engine/types.ts +11 -14
  104. package/src/execution/execution-record.ts +15 -47
  105. package/src/execution/finalize-record.ts +94 -197
  106. package/src/execution/idle-gc.ts +9 -3
  107. package/src/execution/lifecycle-manager.ts +25 -361
  108. package/src/execution/lifecycle-predicates.ts +22 -19
  109. package/src/execution/manifest-store.ts +40 -42
  110. package/src/execution/model-config-service.ts +1 -1
  111. package/src/execution/notifier.ts +39 -1
  112. package/src/execution/notify-host.ts +13 -4
  113. package/src/execution/record-entry.ts +20 -2
  114. package/src/execution/record-store.ts +925 -160
  115. package/src/execution/relay-env.ts +8 -3
  116. package/src/execution/round-supervisor/domain.ts +17 -5
  117. package/src/execution/round-supervisor/service-binding.test.ts +89 -53
  118. package/src/execution/round-supervisor/service-binding.ts +71 -21
  119. package/src/execution/round-supervisor/supervisor.ts +23 -9
  120. package/src/execution/service/record-access.ts +547 -0
  121. package/src/execution/service/record-lifecycle.ts +542 -0
  122. package/src/execution/service/run-orchestration.ts +1647 -0
  123. package/src/execution/service/service-bootstrap.ts +111 -0
  124. package/src/execution/service/service-constants.ts +27 -0
  125. package/src/execution/service/session-baselines.ts +401 -0
  126. package/src/execution/service/sync-collect-domain.ts +442 -0
  127. package/src/execution/service/workflow-dispatch.ts +567 -0
  128. package/src/execution/session-file-gc.ts +11 -8
  129. package/src/execution/session-reconstructor.ts +44 -1
  130. package/src/execution/sessions-index.ts +20 -3
  131. package/src/execution/settled-watchdog.ts +86 -43
  132. package/src/execution/state-marker.ts +516 -0
  133. package/src/execution/stream-sink.ts +1 -1
  134. package/src/execution/subagent-actions-core.ts +53 -31
  135. package/src/execution/subagent-service.ts +643 -2980
  136. package/src/execution/subprocess-agent-runner.ts +46 -323
  137. package/src/execution/types.ts +60 -43
  138. package/src/execution/ui-request-handler-factory.ts +5 -0
  139. package/src/execution/worktree-manager.ts +1 -1
  140. package/src/execution/worktree-registry.ts +1 -1
  141. package/src/index.ts +6 -5
  142. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -3
  143. package/src/orchestration/__tests__/agent-call-stream.test.ts +24 -49
  144. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +4 -4
  145. package/src/orchestration/__tests__/file-run-store.test.ts +5 -7
  146. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +2 -3
  147. package/src/orchestration/__tests__/run-snapshot.test.ts +27 -43
  148. package/src/orchestration/__tests__/worker-message-pump-workflow-dispatch.test.ts +274 -0
  149. package/src/orchestration/execute-agent-call.ts +1 -1
  150. package/src/orchestration/file-run-store.ts +1 -1
  151. package/src/orchestration/models/ports.ts +20 -13
  152. package/src/orchestration/models/types.ts +11 -13
  153. package/src/orchestration/run-snapshot.ts +18 -22
  154. package/src/orchestration/worker-message-pump.ts +33 -57
  155. package/src/shared/atomic-write.ts +10 -8
  156. package/dist/chunk-T2SYBW3I.js +0 -24
  157. package/dist/engine-discovery-scan-BXLA8y-z.d.cts +0 -1624
  158. package/dist/engine-discovery-scan-BXLA8y-z.d.ts +0 -1624
  159. package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +0 -255
  160. package/src/execution/__tests__/finalized-marker.test.ts +0 -104
  161. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +0 -211
  162. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +0 -488
  163. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +0 -75
  164. package/src/execution/__tests__/tombstone-store.test.ts +0 -73
  165. package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +0 -230
  166. package/src/execution/finalized-marker.ts +0 -70
  167. package/src/execution/tombstone-store.ts +0 -72
@@ -1,11 +1,16 @@
1
- // doFinalizeRecord — manifest status 透传测试(M3: 4 态方案)。
1
+ // doFinalizeRecord — manifest status 透传 + [U2a] 终态四件套归口 store 意图原语测试。
2
2
  //
3
3
  // 验证 finalize-record.ts 的 status 映射:done→completed, failed→failed, cancelled→cancelled
4
4
  // (cancelled 不再归并 failed)。crashed 不进 finalize 入参(TS 签名锁定 done/failed/cancelled)。
5
5
  //
6
- // 测试策略:record 不带 sessionFile/worktreeHandle,跳过 Step 0 (collectPatch) 和 Step 3
7
- // (finalized/tombstone/aliveMarker/worktree cleanup) 的文件操作,聚焦 Step 4 writeManifest
8
- // status 产出。FinalizeDeps stub 注入(manifestStore 为真实实例,指向 tmpDir)。
6
+ // [U2a/B1] 终态持久化四件套(.state/binding/archive/manifest/.alive 删)归口
7
+ // store.markFinalized / markCancelled——测试面相应切换为真实 RecordStore 承载
8
+ // (manifestDir 接线第 4 构造参数 终态 manifest writeSync 同步落盘,D8 v7);
9
+ // manifest 写失败路径走缺省降级(manifestStore 异步 + mock 注入 throw)。
10
+ // [B5] doFinalizeRoundToIdle 簿记归口 store.markRoundIdle:record 须先 register 进
11
+ // store 内存(markRoundIdle 按 id 查内存),轮终 `.alive` 保留(D3a 跨轮延续)。
12
+ //
13
+ // FinalizeDeps 其余 stub 注入(manifestStore 为真实实例,指向 tmpDir)。
9
14
 
10
15
  import * as fs from "node:fs";
11
16
  import * as os from "node:os";
@@ -24,8 +29,23 @@ vi.mock("../../core/logger.ts", () => ({
24
29
  getLogger: () => loggerMock,
25
30
  }));
26
31
 
32
+ // [B1] state-marker 写面 mock(默认透传真实实现):终态写失败(重试耗尽返回 false)
33
+ // 用例经 vi.mocked(writeFinalizedState).mockReturnValueOnce(false) 注入——绕开真实
34
+ // 重试 3 次 ×100ms 指数退避的墙钟等待。
35
+ vi.mock("../state-marker.ts", async (importOriginal) => {
36
+ const actual = await importOriginal<typeof import("../state-marker.ts")>();
37
+ return {
38
+ ...actual,
39
+ writeFinalizedState: vi.fn(actual.writeFinalizedState),
40
+ writeCancelledState: vi.fn(actual.writeCancelledState),
41
+ };
42
+ });
43
+
44
+ import { writeFinalizedState } from "../state-marker.ts";
45
+
27
46
  import { doFinalizeRecord, doFinalizeRoundToIdle } from "../finalize-record.ts";
28
47
  import { ManifestStore } from "../manifest-store.ts";
48
+ import { RecordStore } from "../record-store.ts";
29
49
  import { getSubagentSessionDir } from "../path-encoding.ts";
30
50
  import type { AgentResult, ExecutionRecord, WorktreeHandle } from "../types.ts";
31
51
 
@@ -80,14 +100,24 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
80
100
  fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
81
101
  });
82
102
 
83
- /** 构造最小 FinalizeDeps:record sessionFile/worktreeHandle,跳过 Step 0/3 文件操作。 */
84
- function makeDeps(): Parameters<typeof doFinalizeRecord>[0] {
85
- return {
103
+ /** 构造最小 FinalizeDeps。[U2a/B1] store = 真实 RecordStore(manifestDir 4 参数
104
+ * 接线 markFinalized/markCancelled 的终态 manifest writeSync 同步落盘);pi 经
105
+ * 构造注入(reportRecordTransition/写失败 entry 上报可断言)。
106
+ * @param opts.syncManifest false = 不接 manifestDir(manifest 走 manifestStore 异步
107
+ * 降级——manifest 写失败用例 mock manifestStore.writeManifest 注入 throw 用)。 */
108
+ function makeDeps(opts: { syncManifest?: boolean } = {}): Parameters<typeof doFinalizeRecord>[0] {
109
+ const pi = { appendEntry: vi.fn() };
110
+ const store = new RecordStore(
111
+ tmpDir,
86
112
  manifestStore,
113
+ pi,
114
+ opts.syncManifest === false ? undefined : tmpDir,
115
+ );
116
+ return {
87
117
  worktreeManager: {},
88
- store: { archive: vi.fn(), reportRecordTransition: vi.fn() },
118
+ store,
89
119
  modelService: {},
90
- pi: { appendEntry: vi.fn() },
120
+ pi,
91
121
  emitUnregister: vi.fn(),
92
122
  } as unknown as Parameters<typeof doFinalizeRecord>[0];
93
123
  }
@@ -118,9 +148,27 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
118
148
  expect(manifest?.status).toBe("closed");
119
149
  });
120
150
 
121
- it("manifest write 抛错时 cleanup-first 顺序仍执行(Step 3 before Step 4 throw)", async () => {
122
- // record sessionFile Step 3 finalized/aliveMarker 走真实路径;
123
- // 不设 worktreeHandle Step 0 (collectPatch) 和 Step 3 worktree cleanup 都跳过。
151
+ it("[M2 Gate B] closedReason manifest 持久化(manifest 源快照三分流唯一依据)", async () => {
152
+ // 曾不携带 closedReason:sessionFile 未回填 / sidecar 缺席形态下,重启后 manifest
153
+ // 源重建的快照丢死因,endedMessageGuard user-close/cancelled 误分流进
154
+ // 「reconnectable/fork-from」分支。
155
+ for (const closedReason of ["user-close", "cancelled", "parent-shutdown", "gc"] as const) {
156
+ const id = `rec-reason-${closedReason}`;
157
+ await doFinalizeRecord(makeDeps(), makeMinimalRecord({ id }), makeMinimalResult(), "closed", closedReason);
158
+ const manifest = await manifestStore.readManifest(id);
159
+ expect(manifest?.status).toBe("closed");
160
+ expect(manifest?.closedReason).toBe(closedReason);
161
+ }
162
+ });
163
+
164
+ it("manifest write 抛错时 cleanup-first 顺序仍执行(终态原语内 .state 先于 manifest)", async () => {
165
+ // record 带 sessionFile 让终态原语走真实 .state/.alive 路径;
166
+ // 不设 worktreeHandle → Step 0 (collectPatch) 和 worktree cleanup 都跳过。
167
+ // [U2a] manifest 写失败经 store 内部 fire-and-forget catch(record-store
168
+ // reportManifestWriteFailure:logger.error + subagent:manifest-write-failed entry),
169
+ // 不 throw 不跳过编排层后续步骤——对齐 [Critical #1](manifest 是反查索引非正确性
170
+ // 依赖)。走异步降级(syncManifest:false——同步 writeAtomicFileSync 路径不经
171
+ // manifestStore.writeManifest,mock 无法注入)。
124
172
  const sessionFile = path.join(tmpDir, "session.jsonl");
125
173
  const record = makeMinimalRecord({ id: "rec-cleanup-first", sessionFile });
126
174
 
@@ -133,33 +181,35 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
133
181
  );
134
182
 
135
183
  // mock writeManifest 抛错(模拟 disk full)。在 mock 内捕获「writeManifest 被调用时
136
- // finalized marker 是否已存在」——这是 cleanup-first 顺序的关键断言:若有人把
137
- // manifest write 前移到 cleanup 之前,本标志会是 false([Critical #1] 反例)。
184
+ // finalized marker 是否已存在」——这是终态原语内部写序的关键断言:D8 v7 要求
185
+ // `.state` writeSync 先、manifest 后(若有人反转写序,本标志会是 false)。
138
186
  const finalizedBeforeManifestWrite = { value: false };
139
187
  vi.spyOn(manifestStore, "writeManifest").mockImplementation(async () => {
140
- finalizedBeforeManifestWrite.value = fs.existsSync(`${sessionFile}.finalized`);
188
+ finalizedBeforeManifestWrite.value = fs.existsSync(`${sessionFile}.state`);
141
189
  throw new Error("disk full");
142
190
  });
143
191
  loggerMock.error.mockClear();
144
192
 
145
- const deps = makeDeps();
193
+ const deps = makeDeps({ syncManifest: false });
146
194
 
147
- // ── 核心 claim 1:不抛错(cleanup-first manifest write 失败不 throw)──
195
+ // ── 核心 claim 1:不抛错(manifest 写失败在 store 内部被吞,不 throw)──
148
196
  await expect(
149
197
  doFinalizeRecord(deps, record, makeMinimalResult(), "closed"),
150
198
  ).resolves.toBeUndefined();
151
199
 
152
- // ── 核心 claim 2:Step 3 cleanup 先执行 —— finalized marker 真实写入 ──
153
- expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
200
+ // ── 核心 claim 2:终态权威 .state 真实写入 ──
201
+ expect(fs.existsSync(`${sessionFile}.state`)).toBe(true);
154
202
 
155
- // ── 核心 claim 3:Step 3 aliveMarker 被移除(预写的 .alive 不再存在)──
203
+ // ── 核心 claim 3:.alive 被移除(release 出口①——预写的 .alive 不再存在)──
156
204
  expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
157
205
 
158
206
  // ── 核心 claim 4:pending-notifications 注销仍触发(emitUnregister)──
159
207
  expect(deps.emitUnregister).toHaveBeenCalledWith("rec-cleanup-first", "closed");
160
208
 
161
- // ── 核心 claim 5:manifest 写失败被 logger.error 记录(含 record id + error)──
162
- expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining("manifest 写入失败"));
209
+ // ── 核心 claim 5:manifest 写失败被 logger.error 记录(store reportManifestWriteFailure)──
210
+ await vi.waitFor(() => {
211
+ expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining("manifest write failed"));
212
+ });
163
213
  const errMsg = loggerMock.error.mock.calls[0]?.[0];
164
214
  expect(errMsg).toContain("rec-cleanup-first");
165
215
  expect(errMsg).toContain("disk full");
@@ -173,12 +223,57 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
173
223
  // ── 核心 claim 7:manifest 实际未写入(writeManifest 抛错被吞咽)──
174
224
  expect(await manifestStore.readManifest("rec-cleanup-first")).toBeNull();
175
225
 
176
- // ── 核心 claim 8:[Critical #1] cleanup manifest 写之前 —— 顺序锁定 ──
177
- // 若有人把 manifest write 前移到 cleanup 之前,本标志会是 false(mock 捕获时刻
178
- // .finalized 尚未被 Step 3 写入),保护 Critical #1 时序不变量。
226
+ // ── 核心 claim 8:[D8 v7] 终态原语内部写序 —— .state 先于 manifest ──
227
+ // 若有人把 manifest 写前移到 .state 之前,mock 捕获时刻 .state 尚未写入,
228
+ // 本标志会是 false(D8 写序不变量:终态权威优先落)。
179
229
  expect(finalizedBeforeManifestWrite.value).toBe(true);
180
230
 
181
231
  // 清理 mock 调用记录防污染
232
+ vi.restoreAllMocks();
233
+ loggerMock.error.mockClear();
234
+ });
235
+
236
+ it("[B1/§3.4] markFinalized 返回 false(.state 重试耗尽)→ 响亮 entry 上报 + 零持久化副作用 + cleanup 编排不跳过", async () => {
237
+ // 终态写失败(磁盘满/权限)语义:record 留 running(磁盘无终态位、不 archive、
238
+ // 不删 .alive),GUI 通知面腿 = subagent:state-write-failed entry(日志 error 腿
239
+ // 由 state-marker/store 层完成,此处断言编排层接线);worktree cleanup 等
240
+ // 副作用继续(幂等,不可被写失败跳过——否则 worktree 泄漏)。
241
+ vi.mocked(writeFinalizedState).mockReturnValueOnce(false);
242
+ const sessionFile = path.join(tmpDir, "session-fail.jsonl");
243
+ fs.writeFileSync(sessionFile, "{}\n", "utf-8");
244
+ fs.writeFileSync(
245
+ `${sessionFile}.alive`,
246
+ `${JSON.stringify({ pid: process.pid, id: "rec-write-fail", startedAt: 1000 })}\n`,
247
+ "utf-8",
248
+ );
249
+ const record = makeMinimalRecord({ id: "rec-write-fail", sessionFile });
250
+ const deps = makeDeps();
251
+ const cleanup = vi.fn().mockResolvedValue(undefined);
252
+ (deps as { worktreeManager: unknown }).worktreeManager = { cleanup };
253
+ // 「record 留 running」口径(U1 偏差 5)= 磁盘面(无 .state)+ 不 archive:
254
+ // record 未 register 进内存(doFinalizeRecord 不 register),未 archive 以 spy 钉。
255
+ const archiveSpy = vi.spyOn(deps.store as RecordStore, "archive");
256
+
257
+ await expect(
258
+ doFinalizeRecord(deps, record, makeMinimalResult(), "closed", "gc"),
259
+ ).resolves.toBeUndefined();
260
+
261
+ // 响亮 entry 上报(§3.4 GUI 通知面——U1 偏差 6 的接线点)
262
+ expect(deps.pi!.appendEntry).toHaveBeenCalledWith(
263
+ "subagent:state-write-failed",
264
+ expect.objectContaining({ id: "rec-write-fail", status: "closed", closedReason: "gc" }),
265
+ );
266
+ expect(loggerMock.error).toHaveBeenCalledWith(
267
+ expect.stringContaining("terminal state write failed"),
268
+ );
269
+ // 零持久化副作用(D8 失败语义):无 .state / 无 manifest / .alive 未删 / 未 archive
270
+ expect(fs.existsSync(`${sessionFile}.state`)).toBe(false);
271
+ expect(await manifestStore.readManifest("rec-write-fail")).toBeNull();
272
+ expect(fs.existsSync(`${sessionFile}.alive`)).toBe(true);
273
+ expect(archiveSpy).not.toHaveBeenCalled();
274
+ // 副作用编排继续:worktree cleanup / emitUnregister 不被写失败跳过
275
+ expect(cleanup).toHaveBeenCalledTimes(0); // 本用例 record 无 worktreeHandle
276
+ expect(deps.emitUnregister).toHaveBeenCalledWith("rec-write-fail", "closed");
182
277
  loggerMock.error.mockClear();
183
278
  });
184
279
 
@@ -213,7 +308,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
213
308
  return sessionFile;
214
309
  }
215
310
 
216
- it("sessionFile 缺失 + 反查命中 → .finalized 落盘 + .alive 清理 + record/manifest 回填", async () => {
311
+ it("sessionFile 缺失 + 反查命中 → .state 落盘 + .alive 清理 + record/manifest 回填", async () => {
217
312
  const sessionFile = writeSessionFileWithIdentity("20260901T12000000_ps9.jsonl", "rec-ps9");
218
313
  // 预写 .alive 残留(模拟 running 期崩溃恢复窗口的 marker)
219
314
  fs.writeFileSync(
@@ -228,9 +323,11 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
228
323
 
229
324
  // 反查回填 record.sessionFile
230
325
  expect(record.sessionFile).toBe(sessionFile);
231
- // 终态原因持久化不再丢失(closedReason gc 写入 .finalized 内容)
232
- expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
233
- expect(fs.readFileSync(`${sessionFile}.finalized`, "utf-8")).toBe("gc");
326
+ // 终态原因持久化不再丢失(closedReason gc 写入 .state 的 reason 字段)
327
+ expect(JSON.parse(fs.readFileSync(`${sessionFile}.state`, "utf-8"))).toEqual({
328
+ status: "finalized",
329
+ reason: "gc",
330
+ });
234
331
  // alive marker 不再残留
235
332
  expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
236
333
  // manifest 拿到真实 sessionFile(诊断/重建源不再失真)
@@ -238,7 +335,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
238
335
  expect(manifest?.sessionFile).toBe(sessionFile);
239
336
  });
240
337
 
241
- it("cancelled + 反查命中 → tombstone 写到反查路径(cancelled 语义不因 sessionFile 缺失丢失)", async () => {
338
+ it("cancelled + 反查命中 → cancelled 终态写反查路径(语义不因 sessionFile 缺失丢失)", async () => {
242
339
  const sessionFile = writeSessionFileWithIdentity("20260901T12000001_ps9c.jsonl", "rec-ps9c");
243
340
  const record = makeMinimalRecord({ id: "rec-ps9c", sessionFile: undefined });
244
341
  const deps = { ...makeDeps(), sessionDir };
@@ -246,12 +343,12 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
246
343
  await doFinalizeRecord(deps, record, makeMinimalResult(), "closed", "cancelled");
247
344
 
248
345
  expect(record.sessionFile).toBe(sessionFile);
249
- expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(true);
250
- const tomb = JSON.parse(
251
- fs.readFileSync(`${sessionFile}.cancelled`, "utf-8"),
252
- ) as { id: string; status: string };
253
- expect(tomb.id).toBe("rec-ps9c");
254
- expect(tomb.status).toBe("cancelled");
346
+ const marker = JSON.parse(fs.readFileSync(`${sessionFile}.state`, "utf-8")) as {
347
+ status: string;
348
+ endedAt: number;
349
+ };
350
+ expect(marker.status).toBe("cancelled");
351
+ expect(typeof marker.endedAt).toBe("number");
255
352
  });
256
353
 
257
354
  it("反查未命中(目录无 identity 匹配文件)→ 不抛、sessionFile 保持缺失、无 marker(行为退回修复前)", async () => {
@@ -272,14 +369,14 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
272
369
  });
273
370
  });
274
371
 
275
- // ── Step 3a 判别 wiring:closedReason → tombstone vs finalizedMF-1 fix / v4 B-1)──
372
+ // ── Step 3a 判别 wiring:closedReason → .state 单一终态 sidecarL4 合并后)──
276
373
  //
277
- // record 带 sessionFile 直接命中 writeFinalizedOrTombstone 的核心判别分支:
278
- // cancelled → writeCancelledTombstone(防重建丢失 cancelled 语义)
279
- // 其余 reason → writeFinalized(真实 reason 进 sidecar 内容,磁盘重建还原 closedReason)
280
- // 两路 sidecar 互斥(BC-4):断言「写了哪个 + 没写哪个」双向锁定。
281
- describe("writeFinalizedOrTombstone 判别 wiring(closedReason → tombstone vs finalized)", () => {
282
- it("closedReason=cancelled → tombstone sidecar 携带 id/status/agent/startedAt/endedAt 且不写 .finalized", async () => {
374
+ // record 带 sessionFile 直接命中 writeTerminalState 的判别分支:
375
+ // cancelled → .state {status:"cancelled", endedAt}(重建还原 cancelled 语义 + 精确结束时间)
376
+ // 其余 reason → .state {status:"finalized", reason}(真实 reason 进 sidecar,重建还原 closedReason)
377
+ // 单文件单 status 字段 互斥构造性成立;兼容期旧名(.finalized/.cancelled)不再被写。
378
+ describe("writeTerminalState 判别 wiring(closedReason → .state status)", () => {
379
+ it("closedReason=cancelled → .state 携带 status/endedAt 且不写旧名 sidecar", async () => {
283
380
  const sessionFile = path.join(tmpDir, "session.jsonl");
284
381
  const record = makeMinimalRecord({
285
382
  id: "rec-wire-cancelled",
@@ -290,42 +387,41 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
290
387
 
291
388
  await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "cancelled");
292
389
 
293
- // tombstone 写出且内容为完整 CancelledTombstone(经 readCancelledTombstone 形态校验的
294
- // 字段逐项断言,不用 objectContaining 放宽——重建链路靠这些字段还原)
295
- expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(true);
296
- const tomb = JSON.parse(fs.readFileSync(`${sessionFile}.cancelled`, "utf-8")) as {
297
- id: string;
390
+ // .state 写出且内容为终态 marker(字段逐项断言,不用 objectContaining 放宽——
391
+ // 重建链路靠这些字段还原)
392
+ expect(fs.existsSync(`${sessionFile}.state`)).toBe(true);
393
+ const marker = JSON.parse(fs.readFileSync(`${sessionFile}.state`, "utf-8")) as {
298
394
  status: string;
299
- agent: string;
300
- startedAt: number;
301
395
  endedAt: number;
302
396
  };
303
- expect(tomb.id).toBe("rec-wire-cancelled");
304
- expect(tomb.status).toBe("cancelled");
305
- expect(tomb.agent).toBe("worker");
306
- expect(tomb.startedAt).toBe(1000);
397
+ expect(marker.status).toBe("cancelled");
307
398
  // endedAt 来自 completeRecord 冻结后的 record.endedAt(Step 1 先于 Step 3a,
308
- // record 预设的 endedAt 会被冻结值覆盖)——tombstone 携带真实收尾时间戳
309
- expect(tomb.endedAt).toBe(record.endedAt);
310
- expect(typeof tomb.endedAt).toBe("number");
311
- // 互斥:cancelled 路径绝不写 finalized(否则重建判「正常结束」,cancelled 语义丢失)
399
+ // record 预设的 endedAt 会被冻结值覆盖)——sidecar 携带真实收尾时间戳
400
+ expect(marker.endedAt).toBe(record.endedAt);
401
+ expect(typeof marker.endedAt).toBe("number");
402
+ // 兼容期旧名不再被写(写侧单点收敛)
312
403
  expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
404
+ expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
313
405
  });
314
406
 
315
407
  it.each(["user-close", "gc"] as const)(
316
- "closedReason=%s → .finalized 内容携带真实 reason 且不写 tombstone",
408
+ "closedReason=%s → .state 内容携带真实 reason 且不写旧名 sidecar",
317
409
  async (reason) => {
318
410
  const sessionFile = path.join(tmpDir, "session.jsonl");
319
411
  const record = makeMinimalRecord({ id: `rec-wire-${reason}`, sessionFile });
320
412
 
321
413
  await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", reason);
322
414
 
323
- // finalized sidecar 写出且内容 = 真实 reason(磁盘重建用它还原 closedReason,
415
+ // .state 写出且 reason = 真实关因(磁盘重建用它还原 closedReason,
324
416
  // 不再一律硬编码 gc)
325
- expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
326
- expect(fs.readFileSync(`${sessionFile}.finalized`, "utf-8")).toBe(reason);
327
- // 互斥:非 cancelled 路径不写 tombstone
417
+ expect(fs.existsSync(`${sessionFile}.state`)).toBe(true);
418
+ expect(JSON.parse(fs.readFileSync(`${sessionFile}.state`, "utf-8"))).toEqual({
419
+ status: "finalized",
420
+ reason,
421
+ });
422
+ // 兼容期旧名不再被写
328
423
  expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
424
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
329
425
  },
330
426
  );
331
427
  });
@@ -419,7 +515,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
419
515
  ).resolves.toBeUndefined();
420
516
 
421
517
  // Step 0 失败不影响后续步骤:finalized sidecar 与 manifest 照常落地
422
- expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
518
+ expect(fs.existsSync(`${sessionFile}.state`)).toBe(true);
423
519
  const manifest = await manifestStore.readManifest("rec-patch-err");
424
520
  expect(manifest?.status).toBe("closed");
425
521
  expect(deps.worktreeManager.cleanup).toHaveBeenCalledTimes(1);
@@ -446,165 +542,237 @@ describe("doFinalizeRoundToIdle — chatMode 轮次完成进 idle (M2-A)", () =>
446
542
  fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
447
543
  });
448
544
 
449
- /** 构造 FinalizeDeps:worktreeManager.cleanup / store.archive vi.fn 以断言「不调」。 */
450
- function makeDeps(): Parameters<typeof doFinalizeRoundToIdle>[0] {
451
- return {
452
- manifestStore,
545
+ /** 构造 FinalizeDeps + 真实 RecordStore(markRoundIdle id 查内存——record 须先
546
+ * register;返回 store register/断言用)。worktreeManager.cleanup / store.archive
547
+ * 为 spy 以断言「不调」。 */
548
+ function makeDeps(): { deps: Parameters<typeof doFinalizeRoundToIdle>[0]; store: RecordStore } {
549
+ const pi = { appendEntry: vi.fn() };
550
+ const store = new RecordStore(tmpDir, manifestStore, pi, tmpDir);
551
+ const deps = {
453
552
  worktreeManager: { cleanup: vi.fn(), collectPatch: vi.fn() },
454
- store: { archive: vi.fn(), reportRecordTransition: vi.fn() },
553
+ store,
455
554
  modelService: {},
456
- pi: { appendEntry: vi.fn() },
555
+ pi,
457
556
  emitUnregister: vi.fn(),
458
557
  } as unknown as Parameters<typeof doFinalizeRoundToIdle>[0];
558
+ return { deps, store };
459
559
  }
460
560
 
461
- it("record 带 sessionFile → .alive + record.status=running + round 0→1", async () => {
561
+ it("[B5/D3a] record 带 sessionFile → 轮终后 .alive marker 仍在(写权声明跨轮延续)+ record.status=running + round 0→1", async () => {
462
562
  const sessionFile = path.join(tmpDir, "session.jsonl");
463
- // 预写 .alive marker(验证 doFinalizeRoundToIdle 删除它——进程已回收)
563
+ // 预写 .alive marker([B5] 行为变化锚点:轮终**保留** marker——release = 终态原语
564
+ // 或 idle-GC 归档两出口,轮终 record 仍 resumable 随时续聊 spawn 写同一
565
+ // sessionFile,删则轮后跨进程防御空窗,D3a)
464
566
  fs.writeFileSync(
465
567
  `${sessionFile}.alive`,
466
568
  `${JSON.stringify({ pid: 99999, id: "rec-idle", startedAt: 1000 })}\n`,
467
569
  "utf-8",
468
570
  );
571
+ const { deps, store } = makeDeps();
469
572
  const record = makeMinimalRecord({ id: "rec-idle", sessionFile, chatMode: true, round: 0 });
470
573
  // tryTransition 已把 status 设为 done,模拟 runAndFinalize 调用前的状态
471
574
  record.status = "closed";
575
+ store.register(record);
472
576
 
473
- await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
577
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
474
578
 
475
- // 状态机(v4 B-1:旧 idle 折入 running,finalizeRoundToIdle 设 running)
579
+ // 状态机(v4 B-1:旧 idle 折入 running,markRoundIdle 设 running)
476
580
  expect(record.status).toBe("running");
477
581
  expect(record.round).toBe(1);
478
- // .alive marker 被删(进程已 SIGTERM 回收)
479
- expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
582
+ // [B5] .alive marker 轮终保留(旧行为「删 marker」随 D3a 跨轮延续退役)
583
+ expect(fs.existsSync(`${sessionFile}.alive`)).toBe(true);
480
584
  });
481
585
 
482
- it("W16: 轮终上报 reportRecordTransition(record-store 类外恢复写点迁移落 entry)", async () => {
483
- const deps = makeDeps();
586
+ it("[A3] 终态簿记已冻结(endedAt 已设 = completeRecord 已跑)→ store 内硬断言 throw(S7:禁复活已终态化 record)", async () => {
587
+ const { deps, store } = makeDeps();
588
+ const record = makeMinimalRecord({ id: "rec-frozen", chatMode: true });
589
+ // closeChatIdle / disposeAllRecords 等完整终态化路径的产物:completeRecord 已跑
590
+ //(endedAt 冻结)+ status=closed。迟到的轮末分流调用必须被断言拒绝。
591
+ record.status = "closed";
592
+ record.closedReason = "user-close";
593
+ record.endedAt = 12345;
594
+ store.register(record);
595
+ const transitionSpy = vi.spyOn(store, "reportRecordTransition");
596
+
597
+ await expect(doFinalizeRoundToIdle(deps, record, { kind: "success", content: "late" })).rejects.toThrow(
598
+ /terminal bookkeeping already frozen/,
599
+ );
600
+ // 断言先于任何簿记副作用:round 不推进、状态不回滚、无迁移上报
601
+ expect(record.round).toBeUndefined();
602
+ expect(record.status).toBe("closed");
603
+ expect(transitionSpy).not.toHaveBeenCalled();
604
+ expect(deps.emitUnregister).not.toHaveBeenCalled();
605
+ });
606
+
607
+ it("W16: 轮终上报 reportRecordTransition(markRoundIdle 簿记⑨,entry 携带新轮计数)", async () => {
608
+ const { deps, store } = makeDeps();
609
+ const transitionSpy = vi.spyOn(store, "reportRecordTransition");
484
610
  const record = makeMinimalRecord({ id: "rec-report", chatMode: true, round: 2 });
485
611
  record.status = "closed";
486
- await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
487
- expect(deps.store.reportRecordTransition).toHaveBeenCalledTimes(1);
612
+ store.register(record);
613
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
614
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
488
615
  // 上报发生在 round 推进之后(entry 携带新轮计数,重建源不滞后)
489
616
  expect(record.round).toBe(3);
490
617
  expect(record.status).toBe("running");
491
618
  });
492
619
 
493
620
  it("record.round 已为 N → round 变 N+1", async () => {
621
+ const { deps, store } = makeDeps();
494
622
  const record = makeMinimalRecord({ id: "rec-round", round: 3 });
495
623
  record.status = "closed";
496
- await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
624
+ store.register(record);
625
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
497
626
  expect(record.round).toBe(4);
498
627
  expect(record.status).toBe("running");
499
628
  });
500
629
 
501
630
  it("不调 store.archive(record 留内存,getMutable 仍可查)", async () => {
502
- const deps = makeDeps();
631
+ const { deps, store } = makeDeps();
632
+ const archiveSpy = vi.spyOn(store, "archive");
503
633
  const record = makeMinimalRecord({ id: "rec-noarchive" });
504
634
  record.status = "closed";
505
- await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
506
- expect(deps.store.archive).not.toHaveBeenCalled();
635
+ store.register(record);
636
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
637
+ expect(archiveSpy).not.toHaveBeenCalled();
638
+ expect(store.getMutable("rec-noarchive")).toBeDefined();
507
639
  });
508
640
 
509
641
  it("不 cleanup worktree(即使 record 带 worktreeHandle——保留对话模式工作目录)", async () => {
510
- const deps = makeDeps();
642
+ const { deps, store } = makeDeps();
511
643
  const record = makeMinimalRecord({
512
644
  id: "rec-noworktree",
513
645
  worktreeHandle: { path: "/tmp/x", branch: "b", baseCommit: "c", mainCwd: "/tmp" } as never,
514
646
  });
515
647
  record.status = "closed";
516
- await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
648
+ store.register(record);
649
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
517
650
  expect(deps.worktreeManager.cleanup).not.toHaveBeenCalled();
518
651
  });
519
652
 
520
- it("emitUnregister 被调(status=running,进程已死从 pending 活跃差集移除)", async () => {
521
- const deps = makeDeps();
653
+ it("轮终注销经 store 簿记⑧发射(setPendingUnregister 注入闭包,status=running", async () => {
654
+ const { deps, store } = makeDeps();
655
+ // [B5/U5 收口] 注销由 store.markRoundIdle 簿记⑧统一发射(SubagentService 构造点
656
+ // 注入 emitPendingUnregister;调用方薄壳不再双轨重复发)。
657
+ const unregisterSpy = vi.fn();
658
+ store.setPendingUnregister(unregisterSpy);
522
659
  const record = makeMinimalRecord({ id: "rec-emit" });
523
660
  record.status = "closed";
524
- await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
525
- expect(deps.emitUnregister).toHaveBeenCalledWith("rec-emit", "running");
661
+ store.register(record);
662
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
663
+ expect(unregisterSpy).toHaveBeenCalledWith("rec-emit", "running");
526
664
  });
527
665
 
528
666
  it("不调 completeRecord:record 不冻结(endedAt / agentResult 仍 undefined)", async () => {
667
+ const { deps, store } = makeDeps();
529
668
  const record = makeMinimalRecord({ id: "rec-nofreeze" });
530
669
  record.status = "closed";
531
- await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
670
+ store.register(record);
671
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
532
672
  expect(record.endedAt).toBeUndefined();
533
673
  expect(record.agentResult).toBeUndefined();
534
674
  });
535
675
 
536
676
  it("不写 manifest(idle 非终态,manifest 是终态诊断辅助)", async () => {
677
+ const { deps, store } = makeDeps();
537
678
  const record = makeMinimalRecord({ id: "rec-nomanifest" });
538
679
  record.status = "closed";
539
- await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
680
+ store.register(record);
681
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "done" });
540
682
  const manifest = await manifestStore.readManifest("rec-nomanifest");
541
683
  expect(manifest).toBeNull();
542
684
  });
543
685
 
544
- it("MF-2: record.result 设为 result.text(否则 notifier idle 回复恒为 (empty),G1/G2 不成立)", async () => {
686
+ it("MF-2: record.result 设为轮终 content(否则 notifier idle 回复恒为 (empty),G1/G2 不成立)", async () => {
687
+ const { deps, store } = makeDeps();
545
688
  const record = makeMinimalRecord({ id: "rec-result" });
546
689
  record.status = "closed";
547
- const result = makeMinimalResult();
548
- result.text = "review done, found 3 issues";
549
- await doFinalizeRoundToIdle(makeDeps(), record, result);
550
- // MF-2:record.result 被 result.text 填充,notifier idle 分支读取后携带回复正文
690
+ store.register(record);
691
+ // [H1 U2 / D7] outcome 入参适配:成功轮正文 = content
692
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "review done, found 3 issues" });
693
+ // MF-2:record.result 被 content 填充,notifier idle 分支读取后携带回复正文
551
694
  expect(record.result).toBe("review done, found 3 issues");
552
695
  });
553
696
 
554
- it("MF-2 兑底:失败轮次 result.text 为空时用 result.error record.result(MF-6 回退 idle 路径)", async () => {
697
+ it("MF-2 兑底:失败轮次(无前值)record.result 用失败摘要填充(D7 outcome 入参:前值 ?? 失败摘要)", async () => {
698
+ const { deps, store } = makeDeps();
555
699
  const record = makeMinimalRecord({ id: "rec-result-err" });
556
700
  record.status = "closed";
557
- const result = makeMinimalResult();
558
- result.text = "";
559
- result.success = false;
560
- result.error = "spawn timeout";
561
- await doFinalizeRoundToIdle(makeDeps(), record, result);
562
- // 失败轮次的 notify 需可读:result.text 空 → 兑底用 error
701
+ store.register(record);
702
+ await doFinalizeRoundToIdle(deps, record, { kind: "failed", reason: "spawn timeout" });
703
+ // 失败轮次的 notify 需可读:无前值可保 → 兜底失败摘要
563
704
  expect(record.result).toBe("round did not complete: spawn timeout");
564
705
  });
565
706
 
566
- it("T2-③/LC-1: 失败轮 text error 并存时 error 优先——collectResult 恒带 getFullText 正文,text 优先会覆盖恢复指引(S-B-1 首轮 settled watchdog 实测)", async () => {
707
+ it("D7 失败轮前值保留:有最后成功正文时 result 不被失败摘要覆盖(GUI record 视图不被失败污染)+ lastError 写失败原因", async () => {
708
+ const { deps, store } = makeDeps();
709
+ const record = makeMinimalRecord({ id: "rec-fail-keep-prev", chatMode: true });
710
+ record.status = "closed";
711
+ record.result = "last successful round output"; // 前值(有最后成功正文则保留)
712
+ store.register(record);
713
+ await doFinalizeRoundToIdle(deps, record, { kind: "failed", reason: "watchdog kill" });
714
+ // 前值 ?? 失败摘要 → 前值保留;失败原因归 lastError 字段与失败通知(可达性迁移)
715
+ expect(record.result).toBe("last successful round output");
716
+ expect(record.lastError).toBe("watchdog kill");
717
+ });
718
+
719
+ it("D7 失败轮 lastError 写失败原因(排障面——renderer 在 result 非 undefined 时不显示 error,无视觉影响)", async () => {
720
+ const { deps, store } = makeDeps();
721
+ const record = makeMinimalRecord({ id: "rec-fail-last-error", chatMode: true });
722
+ record.status = "closed";
723
+ record.lastError = undefined;
724
+ store.register(record);
725
+ await doFinalizeRoundToIdle(deps, record, { kind: "failed", reason: "engine_round_crashed: x" });
726
+ expect(record.lastError).toBe("engine_round_crashed: x");
727
+ expect(record.result).toBe("round did not complete: engine_round_crashed: x");
728
+ });
729
+
730
+ it("D7 成功轮 lastError 不混入正文:空 content 成功轮兜底 '(no output this round)'(旧 lastError 混入形态退役)", async () => {
731
+ const { deps, store } = makeDeps();
732
+ const record = makeMinimalRecord({ id: "rec-success-no-mix", chatMode: true });
733
+ record.status = "closed";
734
+ record.lastError = "stale error from previous round";
735
+ store.register(record);
736
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "" });
737
+ expect(record.result).toBe("(no output this round)");
738
+ expect(record.result).not.toContain("stale error");
739
+ });
740
+
741
+ it("T2-③/LC-1: 失败轮 record.result 写失败摘要、不回显轮内旧正文(D7:可达性从 result 字段迁移到通知 outcome——失败通知由 Continuation 独立承载失败原因 + 恢复指引)", async () => {
742
+ const { deps, store } = makeDeps();
567
743
  const record = makeMinimalRecord({ id: "rec-result-err-text", chatMode: true });
568
744
  record.status = "closed";
569
- const result = makeMinimalResult();
570
- // runSpawn settledWatchdogFired 分支的 collectResult 形态:正文(T1 回补采集)与
571
- // watchdog 错误并存
572
- result.text = "DONE";
573
- result.success = false;
574
- result.error =
745
+ store.register(record);
746
+ const failureReason =
575
747
  "subagent did not reach agent_settled within 10 min (settled watchdog); the process was terminated to bound the wait. Recovery: check state with subagents action:'list', then re-send your message to continue.";
576
- await doFinalizeRoundToIdle(makeDeps(), record, result);
577
- // 失败的 record.result(= 通知正文)必须携带失败原因与恢复指引,不得回显轮内旧正文
578
- expect(record.result).toBe(`round did not complete: ${result.error}`);
748
+ await doFinalizeRoundToIdle(deps, record, { kind: "failed", reason: failureReason });
749
+ // 失败轮 record.result 携带失败摘要(前值 undefined → 失败摘要),不构成成功谎报
750
+ expect(record.result).toBe(`round did not complete: ${failureReason}`);
579
751
  expect(record.result).not.toBe("DONE");
580
752
  });
581
753
 
582
754
  it("C1TC10: chatMode 空增量轮占位——record.result 固定 (no output this round),不含上一轮文本(D5)", async () => {
755
+ const { deps, store } = makeDeps();
583
756
  const record = makeMinimalRecord({ id: "rec-increment-empty", chatMode: true });
584
757
  record.status = "closed";
758
+ store.register(record);
585
759
  // 预置上一轮通知文本(模拟增量语义前的 record.result 残留)
586
760
  record.result = "PREV-ROUND-TEXT";
587
- const result = makeMinimalResult();
588
- result.text = "";
589
- result.success = true;
590
- result.error = undefined;
591
- await doFinalizeRoundToIdle(makeDeps(), record, result);
761
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "" });
592
762
  // 空增量轮通知不含上一轮文本:沿用旧值会让父 agent 误读为原样重复回复(D5 判定)
593
763
  expect(record.result).toBe("(no output this round)");
594
764
  expect(record.result).not.toContain("PREV-ROUND-TEXT");
595
765
  });
596
766
 
597
767
  it("C1TC11 [R2-1]: one-shot 首轮空文本成功完成 → record.result 补占位「(empty)」(非 undefined)", async () => {
768
+ const { deps, store } = makeDeps();
598
769
  const record = makeMinimalRecord({ id: "rec-oneshot-empty", chatMode: false });
599
770
  record.status = "closed";
771
+ store.register(record);
600
772
  // one-shot 成功空文本完成路径(collectResult getFullText 返回 ""、success=true):
601
773
  // result 前值 undefined
602
774
  record.result = undefined;
603
- const result = makeMinimalResult();
604
- result.text = "";
605
- result.success = true;
606
- result.error = undefined;
607
- await doFinalizeRoundToIdle(makeDeps(), record, result);
775
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "" });
608
776
  // [R2-1] 轮终信号优先:占位非 undefined 是 renderer hasRunning 排除轮终 running 的
609
777
  // 判据——保持 undefined 会让完成注入后末位 turn 永久「工作中」
610
778
  expect(record.result).toBe("(empty)");
@@ -614,15 +782,13 @@ describe("doFinalizeRoundToIdle — chatMode 轮次完成进 idle (M2-A)", () =>
614
782
  });
615
783
 
616
784
  it("C1TC11b [R2-1]: one-shot 续轮空文本 → 沿用前值(one-shot 无增量语义,不覆盖为占位)", async () => {
785
+ const { deps, store } = makeDeps();
617
786
  const record = makeMinimalRecord({ id: "rec-oneshot-cont", chatMode: false });
618
787
  record.status = "closed";
788
+ store.register(record);
619
789
  // 上一轮真实产出(续轮 record.result 前值)
620
790
  record.result = "first round output";
621
- const result = makeMinimalResult();
622
- result.text = "";
623
- result.success = true;
624
- result.error = undefined;
625
- await doFinalizeRoundToIdle(makeDeps(), record, result);
791
+ await doFinalizeRoundToIdle(deps, record, { kind: "success", content: "" });
626
792
  // one-shot record.result = 该 subagent 最终输出:空文本续轮沿用前值,不被 "(empty)" 覆盖
627
793
  expect(record.result).toBe("first round output");
628
794
  });