@zhushanwen/subagent-core 0.7.1 → 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 (176) hide show
  1. package/dist/{chunk-VURUAGMM.js → chunk-LZJOEDGQ.js} +603 -154
  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 +325 -154
  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 +9799 -7954
  10. package/dist/index.d.cts +1896 -1360
  11. package/dist/index.d.ts +1896 -1360
  12. package/dist/index.js +8952 -7513
  13. package/dist.bundle/index.cjs +7897 -6054
  14. package/package.json +5 -5
  15. package/src/__tests__/manifest-store.test.ts +16 -15
  16. package/src/__tests__/record-store-last-line.test.ts +12 -0
  17. package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
  18. package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
  19. package/src/core/logger.ts +1 -1
  20. package/src/execution/__tests__/alive-store.test.ts +117 -1
  21. package/src/execution/__tests__/batch-finalized.test.ts +206 -0
  22. package/src/execution/__tests__/chat-engine-routing.test.ts +117 -1
  23. package/src/execution/__tests__/{cold-resurrect.test.ts → cold-lookup.test.ts} +133 -33
  24. package/src/execution/__tests__/collect-coordinator-service.test.ts +7 -5
  25. package/src/execution/__tests__/conversation-continuation.test.ts +1452 -0
  26. package/src/execution/__tests__/delivery-methods.test.ts +90 -176
  27. package/src/execution/__tests__/dialog-queue.test.ts +70 -3
  28. package/src/execution/__tests__/dispose-manifest-recovery.test.ts +475 -0
  29. package/src/execution/__tests__/execute-and-await-worktree.test.ts +3 -3
  30. package/src/execution/__tests__/execute-nesting.test.ts +8 -5
  31. package/src/execution/__tests__/execution-record.test.ts +8 -148
  32. package/src/execution/__tests__/finalize-record.test.ts +295 -129
  33. package/src/execution/__tests__/get-record-for-action-restart.test.ts +21 -29
  34. package/src/execution/__tests__/helpers/fake-engine-port.ts +13 -68
  35. package/src/execution/__tests__/helpers/legacy-sidecar.ts +33 -0
  36. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +9 -5
  37. package/src/execution/__tests__/idle-gc.test.ts +60 -0
  38. package/src/execution/__tests__/inflight-production-wiring.test.ts +223 -0
  39. package/src/execution/__tests__/lifecycle-manager.test.ts +3 -254
  40. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +43 -22
  41. package/src/execution/__tests__/model-config-service.test.ts +111 -0
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +10 -6
  43. package/src/execution/__tests__/nested-visibility.test.ts +13 -1
  44. package/src/execution/__tests__/notify-ledger.test.ts +42 -1
  45. package/src/execution/__tests__/rebuild-indexes.test.ts +329 -0
  46. package/src/execution/__tests__/record-binding.test.ts +613 -0
  47. package/src/execution/__tests__/record-origin.test.ts +318 -0
  48. package/src/execution/__tests__/record-store-intent-api.test.ts +600 -0
  49. package/src/execution/__tests__/record-store-orphan-revive.test.ts +71 -3
  50. package/src/execution/__tests__/record-store.test.ts +72 -54
  51. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +8 -5
  52. package/src/execution/__tests__/round-supervisor-workflow-origin.test.ts +205 -0
  53. package/src/execution/__tests__/run-orchestration-write-lease.test.ts +293 -0
  54. package/src/execution/__tests__/run-orchestration.test.ts +199 -0
  55. package/src/execution/__tests__/session-file-gc.test.ts +35 -4
  56. package/src/execution/__tests__/session-reconstructor.test.ts +78 -0
  57. package/src/execution/__tests__/state-marker.test.ts +288 -0
  58. package/src/execution/__tests__/stream-sink-retirement.test.ts +8 -5
  59. package/src/execution/__tests__/subagent-actions-core.test.ts +67 -16
  60. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +0 -1
  61. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -11
  62. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +17 -11
  63. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +130 -53
  64. package/src/execution/__tests__/subagent-service.test.ts +17 -215
  65. package/src/execution/__tests__/subprocess-agent-runner-no-progress-killall.test.ts +138 -0
  66. package/src/execution/__tests__/subprocess-agent-runner.test.ts +105 -660
  67. package/src/execution/__tests__/sync-collect-recovery.test.ts +23 -16
  68. package/src/execution/__tests__/terminal-write-latency.test.ts +79 -0
  69. package/src/execution/__tests__/workflow-agent-dispatch.test.ts +782 -0
  70. package/src/execution/alive-store.ts +37 -28
  71. package/src/execution/{cold-resurrect.ts → cold-lookup.ts} +48 -45
  72. package/src/execution/config.ts +1 -1
  73. package/src/execution/conversation-continuation.ts +541 -0
  74. package/src/execution/dialog-queue.ts +60 -12
  75. package/src/execution/engine/__tests__/common/capability-gate.test.ts +1 -1
  76. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +16 -1
  77. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +17 -51
  78. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +0 -5
  79. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +1 -1
  80. package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +5 -9
  81. package/src/execution/engine/__tests__/conformance/registry-fork-filter.test.ts +55 -53
  82. package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +0 -3
  83. package/src/execution/engine/__tests__/engine-discovery.test.ts +0 -3
  84. package/src/execution/engine/__tests__/inflight-snapshot.test.ts +180 -0
  85. package/src/execution/engine/__tests__/model-prompt.test.ts +0 -3
  86. package/src/execution/engine/__tests__/registry.test.ts +0 -1
  87. package/src/execution/engine/__tests__/routing.test.ts +0 -1
  88. package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +0 -3
  89. package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +0 -3
  90. package/src/execution/engine/client/__tests__/engine-client-reap.test.ts +212 -0
  91. package/src/execution/engine/client/__tests__/mirror.test.ts +73 -1
  92. package/src/execution/engine/client/__tests__/protocol-closure.test.ts +2 -9
  93. package/src/execution/engine/client/__tests__/remote-engine.test.ts +46 -12
  94. package/src/execution/engine/client/engine-client.ts +111 -26
  95. package/src/execution/engine/client/mirror.ts +9 -0
  96. package/src/execution/engine/client/remote-engine.ts +52 -34
  97. package/src/execution/engine/client/reverse-router.ts +6 -27
  98. package/src/execution/engine/common/__tests__/run-signals.test.ts +79 -0
  99. package/src/execution/engine/common/capability-gate.ts +27 -0
  100. package/src/execution/engine/common/data-dir.ts +3 -2
  101. package/src/execution/engine/common/errors.ts +1 -1
  102. package/src/execution/engine/common/journal-wiring.ts +18 -9
  103. package/src/execution/engine/common/run-signals.ts +83 -0
  104. package/src/execution/engine/d8-compat.ts +1 -1
  105. package/src/execution/engine/host/__tests__/host-bridge.test.ts +3 -18
  106. package/src/execution/engine/host/host-bridge.ts +26 -41
  107. package/src/execution/engine/host/pi-host-binding.ts +5 -5
  108. package/src/execution/engine/host/spawned-children.ts +1 -1
  109. package/src/execution/engine/inflight-snapshot.ts +92 -0
  110. package/src/execution/engine/port.ts +52 -54
  111. package/src/execution/engine/types.ts +11 -14
  112. package/src/execution/execution-record.ts +15 -47
  113. package/src/execution/finalize-record.ts +94 -197
  114. package/src/execution/idle-gc.ts +9 -3
  115. package/src/execution/lifecycle-manager.ts +25 -361
  116. package/src/execution/lifecycle-predicates.ts +22 -19
  117. package/src/execution/manifest-store.ts +40 -42
  118. package/src/execution/model-config-service.ts +1 -1
  119. package/src/execution/notifier.ts +39 -1
  120. package/src/execution/notify-host.ts +13 -4
  121. package/src/execution/path-encoding.ts +6 -0
  122. package/src/execution/record-entry.ts +20 -2
  123. package/src/execution/record-store.ts +925 -160
  124. package/src/execution/relay-env.ts +8 -3
  125. package/src/execution/round-supervisor/domain.ts +17 -5
  126. package/src/execution/round-supervisor/service-binding.test.ts +89 -53
  127. package/src/execution/round-supervisor/service-binding.ts +71 -21
  128. package/src/execution/round-supervisor/supervisor.ts +23 -9
  129. package/src/execution/service/record-access.ts +547 -0
  130. package/src/execution/service/record-lifecycle.ts +542 -0
  131. package/src/execution/service/run-orchestration.ts +1647 -0
  132. package/src/execution/service/service-bootstrap.ts +111 -0
  133. package/src/execution/service/service-constants.ts +27 -0
  134. package/src/execution/service/session-baselines.ts +401 -0
  135. package/src/execution/service/sync-collect-domain.ts +442 -0
  136. package/src/execution/service/workflow-dispatch.ts +567 -0
  137. package/src/execution/session-file-gc.ts +11 -8
  138. package/src/execution/session-reconstructor.ts +44 -1
  139. package/src/execution/sessions-index.ts +20 -3
  140. package/src/execution/settled-watchdog.ts +86 -43
  141. package/src/execution/state-marker.ts +516 -0
  142. package/src/execution/stream-sink.ts +1 -1
  143. package/src/execution/subagent-actions-core.ts +53 -31
  144. package/src/execution/subagent-service.ts +643 -2955
  145. package/src/execution/subprocess-agent-runner.ts +46 -323
  146. package/src/execution/types.ts +60 -43
  147. package/src/execution/ui-request-handler-factory.ts +5 -0
  148. package/src/execution/worktree-manager.ts +1 -1
  149. package/src/execution/worktree-registry.ts +12 -8
  150. package/src/index.ts +16 -5
  151. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -3
  152. package/src/orchestration/__tests__/agent-call-stream.test.ts +24 -49
  153. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +4 -4
  154. package/src/orchestration/__tests__/file-run-store.test.ts +5 -7
  155. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +2 -3
  156. package/src/orchestration/__tests__/run-snapshot.test.ts +27 -43
  157. package/src/orchestration/__tests__/worker-message-pump-workflow-dispatch.test.ts +274 -0
  158. package/src/orchestration/execute-agent-call.ts +1 -1
  159. package/src/orchestration/file-run-store.ts +1 -1
  160. package/src/orchestration/models/ports.ts +20 -13
  161. package/src/orchestration/models/types.ts +11 -13
  162. package/src/orchestration/run-snapshot.ts +18 -22
  163. package/src/orchestration/worker-message-pump.ts +33 -57
  164. package/src/shared/atomic-write.ts +10 -8
  165. package/dist/chunk-T2SYBW3I.js +0 -24
  166. package/dist/engine-discovery-scan-ocpM8FmI.d.cts +0 -1608
  167. package/dist/engine-discovery-scan-ocpM8FmI.d.ts +0 -1608
  168. package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +0 -255
  169. package/src/execution/__tests__/finalized-marker.test.ts +0 -104
  170. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +0 -211
  171. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +0 -488
  172. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +0 -75
  173. package/src/execution/__tests__/tombstone-store.test.ts +0 -73
  174. package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +0 -230
  175. package/src/execution/finalized-marker.ts +0 -70
  176. package/src/execution/tombstone-store.ts +0 -72
@@ -0,0 +1,199 @@
1
+ // src/execution/__tests__/run-orchestration.test.ts
2
+ //
3
+ // [H3/R5 测试归位] run 域执行编排聚合(execution/service/run-orchestration.ts)的
4
+ // 同名域测试文件。本文件用例自 subagent-service.test.ts「execute() worktree 路径」
5
+ // 块整块迁入(用例名与断言零改动,纯移动);壳级生命周期/查询/emit 面用例留在
6
+ // subagent-service.test.ts。同域姊妹文件:conversation-continuation.test.ts(Continuation
7
+ // 协作面)/ chat-engine-routing.test.ts(引擎编排分叉)/ execute-nesting.test.ts /
8
+ // execute-and-await-worktree.test.ts / delivery-methods.test.ts /
9
+ // stream-sink-retirement.test.ts / explicit-agent-ref-guard.test.ts /
10
+ // subagent-service-notify-gate.test.ts / workflow-agent-dispatch.test.ts(派发链 × run 域协作面)。
11
+ //
12
+ // 覆盖(execute 入口 = RunOrchestration.execute,worktree 与 fork 解耦后):
13
+ // 1. worktree:true + fork:false → 解耦后正常(不抛 requires fork)
14
+ // 2. worktree:true + fork:true → 创建 worktree 路径(不抛 requires fork)
15
+ // 3. worktree:false + fork:false → 默认路径(不创建 worktree)
16
+ // 4. create-await 竞态守卫:create 的 await 窗口内 dispose 抢先 → cleanup 被调 +
17
+ // early-failed 返回(不 kickOff)——「赋值 worktreeHandle → 终态检查 → 轮次
18
+ // kick-off 同一同步段」不变量的回归锚点。
19
+ //
20
+ // 策略:真实 ModelConfigService 指向 os.tmpdir() 空目录,mock PiLike;本文件不 mock
21
+ // spawn(与 subagent-service.test.ts 文件头约定一致——execute 的 spawn 集成测试在
22
+ // execute-nesting / execute-integration)。
23
+
24
+ import * as fs from "node:fs";
25
+ import * as os from "node:os";
26
+ import * as path from "node:path";
27
+
28
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
29
+
30
+ import { ModelConfigService } from "../model-config-service.ts";
31
+ import type { PiLike } from "../subagent-service.ts";
32
+ import { SubagentService } from "../subagent-service.ts";
33
+ import type { WorktreeManager } from "../worktree-manager.ts";
34
+
35
+ // ── 工具:建临时 agentDir + 真实 ModelConfigService(自 subagent-service.test.ts 迁移段自持副本)──
36
+
37
+ function makeTmpAgentDir(): string {
38
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subagents-test-"));
39
+ // agentDir/subagents/ 子目录(config 默认路径会用,空即可)
40
+ return dir;
41
+ }
42
+
43
+ function makeModelService(agentDir: string): ModelConfigService {
44
+ return new ModelConfigService({ agentDir, cwd: agentDir });
45
+ }
46
+
47
+ function makePi(): PiLike & {
48
+ appendEntry: ReturnType<typeof vi.fn<(customType: string, data?: unknown) => void>>;
49
+ events: { emit: ReturnType<typeof vi.fn<(channel: string, data: unknown) => void>> };
50
+ sendMessage: ReturnType<typeof vi.fn<(message: Parameters<PiLike["sendMessage"]>[0], options?: Parameters<PiLike["sendMessage"]>[1]) => void>>;
51
+ } {
52
+ return {
53
+ appendEntry: vi.fn((customType: string, data?: unknown) => {}),
54
+ events: { emit: vi.fn((channel: string, data: unknown) => {}) },
55
+ sendMessage: vi.fn(() => {}),
56
+ };
57
+ }
58
+
59
+ describe("execute() worktree 路径(worktree 与 fork 解耦)", () => {
60
+ let agentDir: string;
61
+ let modelService: ModelConfigService;
62
+
63
+ beforeEach(() => {
64
+ agentDir = makeTmpAgentDir();
65
+ modelService = makeModelService(agentDir);
66
+ });
67
+
68
+ afterEach(() => {
69
+ fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
70
+ });
71
+
72
+ // ============================================================
73
+ // worktree(文件隔离)与 fork(上下文继承)已解耦:worktree:true 可独立于 fork 工作
74
+ // (worktreeManager.create 只看 opts.worktree,不读 fork)。此组验证三种 fork/worktree
75
+ // 组合下 worktree 路径的行为(均不应抛 'requires fork'——该 guard 已移除):
76
+ // 1. worktree:true + fork:false → 解耦后正常(创建 worktree 路径,不抛 requires fork)
77
+ // 2. worktree:true + fork:true → 创建 worktree 路径(测试环境 git 失败,抛非 requires fork 错)
78
+ // 3. worktree:false + fork:false → 默认路径(不创建 worktree)
79
+ //
80
+ // 本文件不 mock spawn(保持与源文件头声明一致——execute 集成测试在 execute-nesting /
81
+ // run-spawn-integration),因此 case 验证「不抛 requires fork」而非「执行完成」:
82
+ // 执行越过 worktree 创建后在后续步骤(worktreeManager.create 调 git / runSpawn 调
83
+ // spawn)抛与 fork/worktree 无关的错。用 try/catch 断言抛出的不是 requires fork。
84
+ // ============================================================
85
+
86
+ /** 构造已就绪的 service(initSession + initModel 注入 ctxModel,使 resolveIdentity 不因 model 拗错)。 */
87
+ function makeReadyService(): SubagentService {
88
+ const service = new SubagentService({ cwd: agentDir, modelService });
89
+ service.initSession({ pi: makePi(), sessionId: "s1" });
90
+ // 注入 modelRegistry + ctxModel:让 resolveIdentity 越过 resolveModel,
91
+ // 使 guard 之后的失败点稳定在 worktreeManager.create(git)或 runSpawn(spawn),
92
+ // 而非 modelService.resolveModel——避免与 guard 无关的 model 错误掩盖被测点。
93
+ modelService.initModel({
94
+ modelRegistry: {
95
+ getAvailable: () => [],
96
+ find: () => undefined,
97
+ hasConfiguredAuth: () => false,
98
+ },
99
+ sessionId: "s1",
100
+ ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
101
+ });
102
+ return service;
103
+ }
104
+
105
+ it("worktree:true + fork:false → 解耦后不抛 'requires fork'(worktree 独立于 fork)", async () => {
106
+ const service = makeReadyService();
107
+ // 解耦后 worktree:true+fork:false 不再 throw requires fork(worktreeManager.create 只看 worktree)
108
+ try {
109
+ await service.execute({
110
+ task: "worktree without fork (decoupled)",
111
+ slug: "test",
112
+ worktree: true,
113
+ fork: false,
114
+ ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
115
+ });
116
+ } catch (err) {
117
+ // 解耦后绝不抛 requires fork(执行继续到 worktreeManager.create/spawn 才可能抛其他错)
118
+ expect((err as Error).message).not.toMatch(/requires fork/);
119
+ }
120
+ });
121
+
122
+ it("worktree:true + fork:true → 创建 worktree 路径(不抛 'requires fork')", async () => {
123
+ const service = makeReadyService();
124
+ // 执行继续:先创建 record,然后 worktreeManager.create 调 git(测试环境无 repo → 抛与 fork 无关的错)
125
+ try {
126
+ await service.execute({
127
+ task: "worktree with fork",
128
+ slug: "test",
129
+ worktree: true,
130
+ fork: true,
131
+ ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
132
+ });
133
+ // 若未抛(理论上 worktreeManager.create 在某些环境成功)也 OK——重点是没命中 guard
134
+ } catch (err) {
135
+ // guard 放行:抛出的错误绝不能是 "requires fork"
136
+ expect((err as Error).message).not.toMatch(/requires fork/);
137
+ }
138
+ });
139
+
140
+ it("worktree:false + fork:false → 默认路径(不创建 worktree,不抛 'requires fork')", async () => {
141
+ const service = makeReadyService();
142
+ // 默认路径:runSpawn 调 child_process.spawn(测试环境无真实 pi → 抛与 fork 无关的错)
143
+ try {
144
+ await service.execute({
145
+ task: "default path",
146
+ slug: "test",
147
+ worktree: false,
148
+ fork: false,
149
+ ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
150
+ });
151
+ } catch (err) {
152
+ // guard 放行:抛出的错误绝不能是 "requires fork"
153
+ expect((err as Error).message).not.toMatch(/requires fork/);
154
+ }
155
+ });
156
+
157
+ // ============================================================
158
+ // create-await 竞态守卫(Phase 2):create 的 await 窗口内 dispose/cancel
159
+ // 可把 record CAS 成 closed——守卫须主动 cleanup + early-failed 返回,不 kickOff。
160
+ // 实现约束固化:「赋值 record.worktreeHandle → 终态检查 → 轮次 kick-off」
161
+ // 必须同一同步段(中间禁止 await),本用例即该不变量的回归锚点。
162
+ // ============================================================
163
+ it("守卫:create await 窗口内 dispose 抢先 → cleanup 被调 + early-failed 返回(不 kickOff)", async () => {
164
+ const service = makeReadyService();
165
+ const wtm = Reflect.get(service, "worktreeManager") as WorktreeManager;
166
+ const handle = Object.freeze({
167
+ path: "/tmp/wt-guard",
168
+ branch: "pi-sub-guard",
169
+ baseCommit: "abc123",
170
+ mainCwd: "/repo",
171
+ }) as Parameters<WorktreeManager["cleanup"]>[0];
172
+ let resolveCreate!: (h: unknown) => void;
173
+ vi.spyOn(wtm, "create").mockImplementation(
174
+ () => new Promise<unknown>((r) => { resolveCreate = r; }) as ReturnType<WorktreeManager["create"]>,
175
+ );
176
+ const cleanupSpy = vi.spyOn(wtm, "cleanup").mockResolvedValue(undefined);
177
+
178
+ const execP = service.execute({
179
+ task: "guard test",
180
+ slug: "test",
181
+ worktree: true,
182
+ fork: false,
183
+ ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
184
+ });
185
+ // 微任务推进:record 已建 + execute 挂在 pending create 上
186
+ await new Promise((r) => { setTimeout(r, 0); });
187
+ // dispose 抢先(无需 record id):CAS running → closed,此时 worktreeHandle 仍
188
+ // undefined(dispose 的 fire-and-forget cleanup 跳过)——守卫是唯一的清理点
189
+ service.disposeAllRecords("parent-shutdown");
190
+ resolveCreate(handle);
191
+
192
+ const ret = await execP;
193
+ // 守卫生效:handle 被主动清理(不等 60s reaper)
194
+ expect(cleanupSpy).toHaveBeenCalledWith(handle);
195
+ // 返回 early-failed 形态(details.status 已 closed),而非 kickOff 的 running 形态
196
+ expect(ret.mode).toBe("background");
197
+ expect(ret.details).toMatchObject({ status: "closed" });
198
+ });
199
+ });
@@ -154,7 +154,35 @@ describe("maybeCleanupExpiredSessionFiles", () => {
154
154
  expect(() => maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd")).not.toThrow();
155
155
  });
156
156
 
157
- // ---- .finalized sidecar 清理 ----
157
+ // ---- 终态 .state sidecar 清理(L4 合并后新名)+ 兼容期旧名保留清理 ----
158
+
159
+ it("deletes .state sidecar along with expired .jsonl", () => {
160
+ forceCleanupTrigger();
161
+ const jsonl = createSessionFile("sess-state.jsonl", 31);
162
+ const state = `${jsonl}.state`;
163
+ fs.writeFileSync(state, JSON.stringify({ status: "finalized", reason: "gc" }), "utf-8");
164
+ const targetTime = Date.now() / 1000 - 31 * 86400;
165
+ fs.utimesSync(state, targetTime, targetTime);
166
+
167
+ maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
168
+
169
+ expect(fs.existsSync(jsonl)).toBe(false);
170
+ expect(fs.existsSync(state)).toBe(false);
171
+ });
172
+
173
+ it("cleans orphan .state sidecar when .jsonl already gone", () => {
174
+ forceCleanupTrigger();
175
+ const subagentsDir = path.join(tmpAgentDir, "subagents");
176
+ fs.mkdirSync(subagentsDir, { recursive: true });
177
+ const orphan = path.join(subagentsDir, "dead-session.jsonl.state");
178
+ fs.writeFileSync(orphan, JSON.stringify({ status: "cancelled", endedAt: 1 }), "utf-8");
179
+ const targetTime = Date.now() / 1000 - 31 * 86400;
180
+ fs.utimesSync(orphan, targetTime, targetTime);
181
+
182
+ maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
183
+
184
+ expect(fs.existsSync(orphan)).toBe(false);
185
+ });
158
186
 
159
187
  it("deletes .finalized sidecar along with expired .jsonl", () => {
160
188
  forceCleanupTrigger();
@@ -230,12 +258,14 @@ describe("maybeCleanupExpiredSessionFiles", () => {
230
258
  expect(fs.existsSync(orphan)).toBe(false);
231
259
  });
232
260
 
233
- it("deletes .jsonl + all sidecars (.cancelled, .finalized, .alive) together", () => {
261
+ it("deletes .jsonl + all sidecars (.state, 兼容期 .cancelled/.finalized, .alive) together", () => {
234
262
  forceCleanupTrigger();
235
263
  const jsonl = createSessionFile("sess-all-sidecars.jsonl", 31);
264
+ const state = `${jsonl}.state`;
236
265
  const cancelled = `${jsonl}.cancelled`;
237
266
  const finalized = `${jsonl}.finalized`;
238
267
  const alive = `${jsonl}.alive`;
268
+ fs.writeFileSync(state, JSON.stringify({ status: "finalized", reason: "gc" }), "utf-8");
239
269
  fs.writeFileSync(cancelled, "", "utf-8");
240
270
  fs.writeFileSync(finalized, "", "utf-8");
241
271
  fs.writeFileSync(alive, JSON.stringify({ pid: 99999, id: "s4", startedAt: Date.now() }), "utf-8");
@@ -246,6 +276,7 @@ describe("maybeCleanupExpiredSessionFiles", () => {
246
276
  maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
247
277
 
248
278
  expect(fs.existsSync(jsonl)).toBe(false);
279
+ expect(fs.existsSync(state)).toBe(false);
249
280
  expect(fs.existsSync(cancelled)).toBe(false);
250
281
  expect(fs.existsSync(finalized)).toBe(false);
251
282
  expect(fs.existsSync(alive)).toBe(false);
@@ -286,8 +317,8 @@ describe("maybeCleanupExpiredSessionFiles", () => {
286
317
  expect(fs.existsSync(worktrees)).toBe(true);
287
318
  });
288
319
 
289
- it("[F2] preserves .json.tmp.* inside records/ (recoverTmpFiles owns them)", () => {
290
- // 跳过 .tmp.:session_start 的 recoverTmpFiles 同步处理 tmp,GC 不重复。
320
+ it("[F2] preserves .json.tmp.* inside records/ (sweepTmpFiles owns them)", () => {
321
+ // 跳过 .tmp.:session_start 的 sweepTmpFiles 同步处理 tmp,GC 不重复。
291
322
  forceCleanupTrigger();
292
323
  const tmp = createSessionFile(
293
324
  path.join("--Users-x-proj--", "records", "rec-2.json.tmp.123"),
@@ -728,3 +728,81 @@ describe("轻量 identity 扫描(readIdentityHeader / readIdentityTail / readI
728
728
  expect(readIdentityHeader(filePath)?.id).toBe("bg-prefilter");
729
729
  });
730
730
  });
731
+
732
+ // ============================================================
733
+ // [H2 S3] identity 面 origin/parentRunId 透传(写 → 读 → 重建往返保真)
734
+ // ============================================================
735
+ //
736
+ // Gate B S3 FAIL 根因的 identity entry 侧防线:identity custom entry 携带
737
+ // origin/parentRunId 时,reconstructFromFile(全量重建面)与 readIdentityHeader
738
+ // (light 列表面)必须透传;缺省/非法值守卫归一 undefined(= "tool" 语义),
739
+ // 对齐 readEntryOriginFields 主 entry 重建侧守卫。
740
+ describe("[H2 S3] identity 面 origin/parentRunId 透传", () => {
741
+ let tmpDir: string;
742
+ let filePath: string;
743
+
744
+ beforeEach(() => {
745
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sr-origin-"));
746
+ filePath = path.join(tmpDir, "origin.jsonl");
747
+ });
748
+ afterEach(() => {
749
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
750
+ });
751
+
752
+ function writeOriginJsonl(identity: object): void {
753
+ const fd = fs.openSync(filePath, "w");
754
+ writeLine(fd, headerLine());
755
+ writeLine(fd, identityEntry(identity));
756
+ writeLine(fd, assistantEntry([{ type: "text", text: "done" }]));
757
+ fs.closeSync(fd);
758
+ }
759
+
760
+ it("往返保真:identity(origin=workflow, parentRunId) → 全量重建 + 轻量扫描均透传", () => {
761
+ writeOriginJsonl({
762
+ id: "bg-wf", agent: "worker", mode: "background", task: "wf task", startedAt: 10,
763
+ origin: "workflow", parentRunId: "wf-run-9",
764
+ });
765
+
766
+ const full = reconstructFromFile(filePath);
767
+ expect(full).toBeDefined();
768
+ expect(full!.origin).toBe("workflow");
769
+ expect(full!.parentRunId).toBe("wf-run-9");
770
+
771
+ const light = readIdentityHeader(filePath);
772
+ expect(light).toBeDefined();
773
+ expect(light!.origin).toBe("workflow");
774
+ expect(light!.parentRunId).toBe("wf-run-9");
775
+ });
776
+
777
+ it("缺省负向:旧 identity(无 origin/parentRunId)→ 重建两字段 undefined(= tool 语义)", () => {
778
+ writeOriginJsonl({ id: "bg-old", agent: "w", mode: "background", task: "t", startedAt: 1 });
779
+
780
+ expect(reconstructFromFile(filePath)!.origin).toBeUndefined();
781
+ expect(reconstructFromFile(filePath)!.parentRunId).toBeUndefined();
782
+ expect(readIdentityHeader(filePath)!.origin).toBeUndefined();
783
+ expect(readIdentityHeader(filePath)!.parentRunId).toBeUndefined();
784
+ });
785
+
786
+ it("非法值守卫:origin 非白名单 / parentRunId 非 string → 守卫归一 undefined(不抛)", () => {
787
+ writeOriginJsonl({
788
+ id: "bg-bad", agent: "w", mode: "background", task: "t", startedAt: 1,
789
+ origin: "bogus", parentRunId: 42,
790
+ });
791
+
792
+ const full = reconstructFromFile(filePath);
793
+ expect(full!.origin).toBeUndefined();
794
+ expect(full!.parentRunId).toBeUndefined();
795
+ const light = readIdentityHeader(filePath)!;
796
+ expect(light.origin).toBeUndefined();
797
+ expect(light.parentRunId).toBeUndefined();
798
+ });
799
+
800
+ it("origin=tool 显式值 → 透传保留(负向判定 `!== \"workflow\"` 依赖真实值)", () => {
801
+ writeOriginJsonl({
802
+ id: "bg-tool", agent: "w", mode: "background", task: "t", startedAt: 1,
803
+ origin: "tool",
804
+ });
805
+ expect(reconstructFromFile(filePath)!.origin).toBe("tool");
806
+ expect(readIdentityHeader(filePath)!.origin).toBe("tool");
807
+ });
808
+ });
@@ -0,0 +1,288 @@
1
+ // src/execution/__tests__/state-marker.test.ts
2
+ //
3
+ // state-marker 专属测试(L4 合并:finalized-marker + tombstone-store 两模块收编)。
4
+ //
5
+ // 覆盖四层:
6
+ // 1. 写侧(.state 单一权威):finalized/cancelled 往返、旧名残留清理;
7
+ // 2. 写侧响亮重试(U1 A3 / §3.4):3 次指数退避(100ms 起)+ 仍失败 logger.error
8
+ // 响亮暴露返回 false(旧 best-effort 静默语义退役);
9
+ // 3. 读侧(兼容读):.state 优先、旧 .finalized / .cancelled 归一、旧名共存优先级
10
+ // (.cancelled > .finalized,对齐合并前判定分支序)、损坏降级边界;
11
+ // 4. statStateStamp(缓存校验戳):三文件合并戳的存在性/变化语义。
12
+
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // [U1 A3] 响亮重试断言需要 error 级日志可观察(对齐 record-store.test.ts mock 模式)。
20
+ const { loggerMock } = vi.hoisted(() => ({
21
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
+ }));
23
+ vi.mock("../../core/logger.ts", () => ({
24
+ getLogger: () => loggerMock,
25
+ }));
26
+
27
+ // fs partial mock:writeFileSync 可注错(暂时/持久失败注入),其余转发真实实现
28
+ // (模式对齐 manifest-store-tmp-recovery.test.ts)。ESM namespace 不可 spyOn,
29
+ // 注错必须走模块 mock;真实实现引用收进 hoisted holder(vi.mock factory 先于
30
+ // 模块级 let 初始化执行,裸 let 会 TDZ)。
31
+ type WriteFileSyncFn = typeof import("node:fs").writeFileSync;
32
+ const { writeFileSyncMock, actualWriteRef } = vi.hoisted(() => ({
33
+ writeFileSyncMock: vi.fn(),
34
+ actualWriteRef: { current: undefined as WriteFileSyncFn | undefined },
35
+ }));
36
+ vi.mock("node:fs", async (importOriginal) => {
37
+ const actual = await importOriginal<typeof import("node:fs")>();
38
+ actualWriteRef.current = actual.writeFileSync;
39
+ return {
40
+ ...actual,
41
+ writeFileSync: writeFileSyncMock,
42
+ default: { ...actual, writeFileSync: writeFileSyncMock },
43
+ };
44
+ });
45
+
46
+ import {
47
+ _setStateMarkerSleepForTest,
48
+ readStateMarker,
49
+ statStateStamp,
50
+ writeCancelledState,
51
+ writeFinalizedState,
52
+ } from "../state-marker.ts";
53
+ import { writeLegacyCancelledSidecar, writeLegacyFinalizedSidecar } from "./helpers/legacy-sidecar.ts";
54
+
55
+ describe("state-marker", () => {
56
+ let tmpDir: string;
57
+ let sessionFile: string;
58
+ /** 退避替身记录的延迟序列(fake sleep——免真实等待,可断言指数退避)。 */
59
+ let sleepDelays: number[];
60
+
61
+ beforeEach(() => {
62
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "state-marker-test-"));
63
+ sessionFile = path.join(tmpDir, "2026-01-01_uuid.jsonl");
64
+ sleepDelays = [];
65
+ _setStateMarkerSleepForTest((ms) => {
66
+ sleepDelays.push(ms);
67
+ });
68
+ loggerMock.error.mockClear();
69
+ // 基线 = 真实写(个别用例以 mockImplementationOnce/Implementation 注错覆盖)。
70
+ if (actualWriteRef.current === undefined) throw new Error("node:fs mock not initialized");
71
+ writeFileSyncMock.mockReset().mockImplementation(actualWriteRef.current);
72
+ });
73
+ afterEach(() => {
74
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
75
+ _setStateMarkerSleepForTest(undefined); // 恢复实装同步等待
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ const readStateRaw = (): { status?: unknown; reason?: unknown; endedAt?: unknown } =>
80
+ JSON.parse(fs.readFileSync(`${sessionFile}.state`, "utf-8")) as Record<string, unknown>;
81
+
82
+ // ============================================================
83
+ // 写侧
84
+ // ============================================================
85
+ describe("写侧 .state", () => {
86
+ it("writeFinalizedState 带 reason → .state {status:finalized, reason}", () => {
87
+ writeFinalizedState(sessionFile, "user-close");
88
+ expect(readStateRaw()).toEqual({ status: "finalized", reason: "user-close" });
89
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
90
+ });
91
+
92
+ it("writeFinalizedState 无 reason → .state {status:finalized}(死因不可考语义)", () => {
93
+ writeFinalizedState(sessionFile);
94
+ expect(readStateRaw()).toEqual({ status: "finalized" });
95
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
96
+ });
97
+
98
+ it("writeCancelledState → .state {status:cancelled, endedAt}", () => {
99
+ writeCancelledState(sessionFile, 7000);
100
+ expect(readStateRaw()).toEqual({ status: "cancelled", endedAt: 7000 });
101
+ expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 7000 });
102
+ });
103
+
104
+ it("覆盖写:后写的 status 取代前者(单文件单状态,构造性互斥)", () => {
105
+ writeFinalizedState(sessionFile, "gc");
106
+ writeCancelledState(sessionFile, 8000);
107
+ expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 8000 });
108
+ });
109
+
110
+ it("写 .state 时清理存量旧名 sidecar(.finalized / .cancelled 一并删)", () => {
111
+ writeLegacyFinalizedSidecar(sessionFile, "gc");
112
+ writeLegacyCancelledSidecar(sessionFile, {
113
+ id: "bg-1", status: "cancelled", agent: "w", startedAt: 1, endedAt: 2,
114
+ });
115
+
116
+ writeFinalizedState(sessionFile, "user-close");
117
+
118
+ expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
119
+ expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
120
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
121
+ });
122
+
123
+ it("[§3.4] 写路径父目录不存在 → 重试耗尽不抛出、未写入、响亮暴露 + 返回 false", () => {
124
+ const badPath = path.join(tmpDir, "nonexistent-sub", "session.jsonl");
125
+ expect(writeFinalizedState(badPath)).toBe(false);
126
+ expect(writeCancelledState(badPath, 1)).toBe(false);
127
+ expect(readStateMarker(badPath)).toBeUndefined();
128
+ // 响亮暴露:每次写函数调用各产出一条 error(终态权威未落必须可见)。
129
+ expect(loggerMock.error).toHaveBeenCalledTimes(2);
130
+ });
131
+ });
132
+
133
+ // ============================================================
134
+ // 写侧响亮重试(U1 A3 / §3.4:3 次指数退避 100ms 起 + 仍失败 logger.error)
135
+ // ============================================================
136
+ describe("写侧响亮重试", () => {
137
+ it("暂时性失败 → 指数退避重试后成功:返回 true,无 error 日志", () => {
138
+ writeFileSyncMock
139
+ .mockImplementationOnce(() => {
140
+ throw new Error("transient EBUSY");
141
+ })
142
+ .mockImplementationOnce(() => {
143
+ throw new Error("transient EACCES");
144
+ });
145
+
146
+ expect(writeFinalizedState(sessionFile, "user-close")).toBe(true);
147
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(3); // 初始尝试 + 2 次重试后成功
148
+ expect(sleepDelays).toEqual([100, 200]); // 指数退避:100ms 起
149
+ expect(loggerMock.error).not.toHaveBeenCalled();
150
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
151
+ });
152
+
153
+ it("持久失败 → 4 次尝试(1+3 重试)后返回 false + logger.error 响亮暴露(含恢复指引)", () => {
154
+ writeFileSyncMock.mockImplementation(() => {
155
+ throw new Error("ENOSPC: no space left");
156
+ });
157
+
158
+ expect(writeCancelledState(sessionFile, 7000)).toBe(false);
159
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(4); // 初始 + 3 次重试
160
+ expect(sleepDelays).toEqual([100, 200, 400]); // 完整指数退避序列
161
+ expect(loggerMock.error).toHaveBeenCalledTimes(1);
162
+ const msg = String(loggerMock.error.mock.calls[0]?.[0]);
163
+ expect(msg).toMatch(/terminal state marker write failed after retries/);
164
+ expect(msg).toMatch(/record stays running/); // §3.4:不因写失败翻终态的处置指引
165
+ // 磁盘确无终态位(record 留 running 形态的构造前提)。
166
+ expect(fs.existsSync(`${sessionFile}.state`)).toBe(false);
167
+ });
168
+
169
+ it("writeCancelledState 同款重试语义(终态二态共用 writeStateMarker)", () => {
170
+ writeFileSyncMock.mockImplementationOnce(() => {
171
+ throw new Error("transient");
172
+ });
173
+
174
+ expect(writeCancelledState(sessionFile, 9000)).toBe(true);
175
+ expect(sleepDelays).toEqual([100]);
176
+ expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 9000 });
177
+ });
178
+ });
179
+
180
+ // ============================================================
181
+ // 读侧:无 sidecar / .state 边界
182
+ // ============================================================
183
+ describe("读侧 .state", () => {
184
+ it("无任何 sidecar → undefined(未终态化)", () => {
185
+ expect(readStateMarker(sessionFile)).toBeUndefined();
186
+ });
187
+
188
+ it(".state JSON 损坏 → {status:finalized}(存在性即信号;不误判 cancelled)", () => {
189
+ fs.writeFileSync(`${sessionFile}.state`, "not-json", "utf-8");
190
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
191
+ });
192
+
193
+ it(".state status 非枚举值 → {status:finalized}(结构不合法降级)", () => {
194
+ fs.writeFileSync(`${sessionFile}.state`, JSON.stringify({ status: "bogus" }), "utf-8");
195
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
196
+ });
197
+
198
+ it(".state finalized 且 reason 非字符串 → 忽略 reason 字段(不抛)", () => {
199
+ fs.writeFileSync(`${sessionFile}.state`, JSON.stringify({ status: "finalized", reason: 42 }), "utf-8");
200
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
201
+ });
202
+
203
+ it(".state cancelled 缺 endedAt → {status:cancelled}(endedAt undefined,重建回落 mtime)", () => {
204
+ fs.writeFileSync(`${sessionFile}.state`, JSON.stringify({ status: "cancelled" }), "utf-8");
205
+ expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled" });
206
+ });
207
+ });
208
+
209
+ // ============================================================
210
+ // 读侧:兼容旧名(存量文件不迁移)
211
+ // ============================================================
212
+ describe("读侧兼容旧名", () => {
213
+ it("旧 .finalized 带内容 → {status:finalized, reason}", () => {
214
+ writeLegacyFinalizedSidecar(sessionFile, "parent-shutdown");
215
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "parent-shutdown" });
216
+ });
217
+
218
+ it("旧 .finalized 空文件(v8.5 前格式)→ reason 空串(= 死因不可考)", () => {
219
+ writeLegacyFinalizedSidecar(sessionFile);
220
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "" });
221
+ });
222
+
223
+ it("旧 .cancelled 完整 tombstone → {status:cancelled, endedAt}", () => {
224
+ writeLegacyCancelledSidecar(sessionFile, {
225
+ id: "bg-1", status: "cancelled", agent: "w", startedAt: 100, endedAt: 200,
226
+ });
227
+ expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 200 });
228
+ });
229
+
230
+ it("旧 .cancelled JSON 损坏 → undefined(不认 cancelled,对齐合并前降级)", () => {
231
+ fs.writeFileSync(`${sessionFile}.cancelled`, "{broken", "utf-8");
232
+ expect(readStateMarker(sessionFile)).toBeUndefined();
233
+ });
234
+
235
+ it("旧 .cancelled status 非 cancelled → undefined(结构不合法降级)", () => {
236
+ fs.writeFileSync(`${sessionFile}.cancelled`, JSON.stringify({ status: "other", endedAt: 1 }), "utf-8");
237
+ expect(readStateMarker(sessionFile)).toBeUndefined();
238
+ });
239
+
240
+ it("旧名共存(.cancelled + .finalized)→ cancelled 胜出(优先级对齐合并前分支序)", () => {
241
+ writeLegacyFinalizedSidecar(sessionFile, "gc");
242
+ writeLegacyCancelledSidecar(sessionFile, {
243
+ id: "bg-1", status: "cancelled", agent: "w", startedAt: 100, endedAt: 200,
244
+ });
245
+ expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 200 });
246
+ });
247
+
248
+ it(".state 优先于存量旧名(新写侧权威)", () => {
249
+ writeLegacyCancelledSidecar(sessionFile, {
250
+ id: "bg-1", status: "cancelled", agent: "w", startedAt: 100, endedAt: 200,
251
+ });
252
+ writeFinalizedState(sessionFile, "user-close");
253
+ expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
254
+ });
255
+ });
256
+
257
+ // ============================================================
258
+ // statStateStamp(缓存校验)
259
+ // ============================================================
260
+ describe("statStateStamp", () => {
261
+ it("三者皆无 → null(未终态化,缓存校验 null 语义)", () => {
262
+ expect(statStateStamp(sessionFile)).toBeNull();
263
+ });
264
+
265
+ it(".state 存在 → 非 null;重写改变戳(mtime/size 至少一项变化)", () => {
266
+ writeFinalizedState(sessionFile, "gc");
267
+ const first = statStateStamp(sessionFile);
268
+ expect(first).not.toBeNull();
269
+
270
+ writeFinalizedState(sessionFile, "a-much-longer-reason-string");
271
+ const second = statStateStamp(sessionFile);
272
+ expect(second).not.toBeNull();
273
+ expect(second).not.toEqual(first); // size 变化即戳变化
274
+ });
275
+
276
+ it("仅旧名存在 → 非 null(合并戳覆盖兼容读面)", () => {
277
+ writeLegacyFinalizedSidecar(sessionFile, "gc");
278
+ expect(statStateStamp(sessionFile)).not.toBeNull();
279
+ });
280
+
281
+ it("旧名删除后戳变化(GC 清理旧侧车 → 缓存正确失效)", () => {
282
+ writeLegacyFinalizedSidecar(sessionFile, "gc");
283
+ const withLegacy = statStateStamp(sessionFile);
284
+ fs.rmSync(`${sessionFile}.finalized`, { force: true });
285
+ expect(statStateStamp(sessionFile)).not.toEqual(withLegacy);
286
+ });
287
+ });
288
+ });