@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/subagent-core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Cross-host shared subagent execution layer and workflow orchestration core (dual-form npm package: TS source for workspace, dist ESM+CJS for npm)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -74,7 +74,7 @@
74
74
  "proper-lockfile": "^4.1.2",
75
75
  "yaml": "^2.9.0",
76
76
  "@xyz-agent/extension-protocol": "0.9.0",
77
- "@zhushanwen/subagent-engine-sdk": "0.2.1"
77
+ "@zhushanwen/subagent-engine-sdk": "0.3.0"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/node": "^24.0.0",
@@ -82,8 +82,8 @@
82
82
  "tsup": "^8.5.1",
83
83
  "typescript": "^5.7.0",
84
84
  "vitest": "^4.1.8",
85
- "@zhushanwen/pi-subagent-cli": "0.1.2",
86
- "@zhushanwen/zcode-subagent-cli": "0.2.0"
85
+ "@zhushanwen/pi-subagent-cli": "0.2.0",
86
+ "@zhushanwen/zcode-subagent-cli": "0.2.1"
87
87
  },
88
88
  "scripts": {
89
89
  "build": "tsup",
@@ -114,7 +114,10 @@ describe("ManifestStore", () => {
114
114
  });
115
115
  });
116
116
 
117
- describe("recoverTmpFiles", () => {
117
+ // [U4c / G3] tmp 恢复退役(D6):manifest 已是可丢可重建缓存(权威 = `.state`,
118
+ // 重建 = RecordStore.rebuildIndexes),promote 半写 tmp 的恢复语义失效——[H4/U5
119
+ // 更名收口] recoverTmpFiles → sweepTmpFiles(名实对齐静默删除),返回删除计数。
120
+ describe("sweepTmpFiles", () => {
118
121
  it("should delete tmp when manifest exists", async () => {
119
122
  const id = "test-recovery-1";
120
123
  const manifestPath = path.join(tmpDir, `${id}.json`);
@@ -123,20 +126,20 @@ describe("ManifestStore", () => {
123
126
  fs.writeFileSync(manifestPath, '{"id":"test-recovery-1"}');
124
127
  fs.writeFileSync(tmpPath, '{"id":"test-recovery-1"}');
125
128
 
126
- const result = await store.recoverTmpFiles();
129
+ const result = await store.sweepTmpFiles();
127
130
 
128
- expect(result.deleted).toBe(1);
131
+ expect(result).toBe(1);
129
132
  expect(fs.existsSync(tmpPath)).toBe(false);
130
133
  expect(fs.existsSync(manifestPath)).toBe(true);
131
134
  });
132
135
 
133
- it("should rename valid tmp when manifest missing", async () => {
136
+ it("should delete valid tmp when manifest missing (promote retired)", async () => {
134
137
  const id = "test-recovery-2";
135
138
  const tmpPath = path.join(tmpDir, `${id}.json.tmp.12345`);
136
139
  const manifestPath = path.join(tmpDir, `${id}.json`);
137
140
 
138
- // F2 recoverTmpFiles isValidManifest 严格校验——tmp 必须含全部必填字段
139
- // (id/rootSessionId/agentName/createdAt/status)才会 promote,否则走删除分支 3b。
141
+ // promote 分支形态(合法完整 manifest tmp + 正式文件缺失)也删——
142
+ // 半写 tmp 不再被复活成「看似权威」的索引,缺员由 rebuildIndexes 重建。
140
143
  fs.writeFileSync(tmpPath, JSON.stringify({
141
144
  id,
142
145
  rootSessionId: "session-123",
@@ -145,10 +148,10 @@ describe("ManifestStore", () => {
145
148
  createdAt: Date.now(),
146
149
  }));
147
150
 
148
- const result = await store.recoverTmpFiles();
151
+ const result = await store.sweepTmpFiles();
149
152
 
150
- expect(result.recovered).toBe(1);
151
- expect(fs.existsSync(manifestPath)).toBe(true);
153
+ expect(result).toBe(1);
154
+ expect(fs.existsSync(manifestPath)).toBe(false);
152
155
  expect(fs.existsSync(tmpPath)).toBe(false);
153
156
  });
154
157
 
@@ -158,20 +161,18 @@ describe("ManifestStore", () => {
158
161
 
159
162
  fs.writeFileSync(tmpPath, "invalid json {{{");
160
163
 
161
- const result = await store.recoverTmpFiles();
164
+ const result = await store.sweepTmpFiles();
162
165
 
163
- expect(result.deleted).toBe(1);
166
+ expect(result).toBe(1);
164
167
  expect(fs.existsSync(tmpPath)).toBe(false);
165
168
  });
166
169
 
167
- // F2:合法 JSON 但缺必填字段(非合法 manifest)应走删除(分支 3b),不能 promote
168
170
  it("should delete tmp when JSON valid but not a valid manifest (missing required fields)", async () => {
169
171
  const id = "test-recovery-4";
170
172
  const tmpPath = path.join(tmpDir, `${id}.json.tmp.12345`);
171
173
  fs.writeFileSync(tmpPath, JSON.stringify({ foo: "bar" })); // 合法 JSON,非 manifest
172
- const result = await store.recoverTmpFiles();
173
- expect(result.deleted).toBe(1);
174
- expect(result.recovered).toBe(0);
174
+ const result = await store.sweepTmpFiles();
175
+ expect(result).toBe(1);
175
176
  expect(fs.existsSync(tmpPath)).toBe(false);
176
177
  });
177
178
  });
@@ -1,6 +1,9 @@
1
1
  // Medium batch 1 robustness fixes verification
2
2
  //
3
- // M4: dispatchAgentCall .then() clears node.live BEFORE stale guard (not after)
3
+ // M4: [H2 W3 改写] dispatchAgentCall node.live 清理已随 trace.live 字段退役
4
+ // (设计 subagent-workflow-record-unification.md D2——旁路 progress record 族
5
+ // 整体删除,节点无运行期附属对象)。原「live 清理先于 stale guard」的顺序
6
+ // 断言改为退役回归:pump 无 node.live 写点。
4
7
  // M7: handleWorkerMessage validates msg shape before dereferencing msg.opts
5
8
  // M8: session-reconstructor guards Array.isArray(msg.content) before for...of
6
9
 
@@ -17,24 +20,17 @@ function readSrc(relPath: string): string {
17
20
  return readFileSync(join(PKG_ROOT, relPath), "utf-8");
18
21
  }
19
22
 
20
- // ── M4: node.live cleared before stale guard ─────────────────
23
+ // ── M4: node.live 写点已退役([H2 W3] 回归锁定) ─────────────
21
24
 
22
- describe("M4: dispatchAgentCall .then() clears node.live before stale guard", () => {
25
+ describe("M4: [H2 W3] dispatchAgentCall 不再写 node.live", () => {
23
26
  const src = readSrc(join("src", "orchestration", "worker-message-pump.ts"));
24
27
 
25
- it("node.live = undefined appears before the stale guard return", () => {
26
- // 找到 .then 回调中的 stale guard 和 node.live 清理的相对顺序
28
+ it("整个 pump 源码无 .live 写点(旁路 record 族退役),stale guard 仍在", () => {
29
+ expect(src).not.toMatch(/\.live\s*=/);
30
+ // .then 的 stale guard 仍在(原顺序断言的对象消失,守卫本身保留)
27
31
  const thenMatch = src.match(/\.then\(\(\)\s*=>\s*\{[\s\S]*?\}\)/);
28
32
  expect(thenMatch).toBeTruthy();
29
- const thenBlock = thenMatch![0];
30
-
31
- const liveIdx = thenBlock.indexOf("node.live = undefined");
32
- const guardIdx = thenBlock.indexOf('run.state.status !== "running"');
33
-
34
- expect(liveIdx).toBeGreaterThan(-1);
35
- expect(guardIdx).toBeGreaterThan(-1);
36
- // node.live 清理必须在 stale guard 之前(代码行号更小)
37
- expect(liveIdx).toBeLessThan(guardIdx);
33
+ expect(thenMatch![0]).toContain('run.state.status !== "running"');
38
34
  });
39
35
  });
40
36
 
@@ -24,11 +24,16 @@ function readSrc(relPath: string): string {
24
24
  // ── M6: worktree cleanup decoupled from patchOk ──────────────
25
25
 
26
26
  describe("M6: worktree cleanup not gated by patchOk", () => {
27
- const src = readSrc(join("src", "execution", "subagent-service.ts"));
27
+ // [H3/R3] worktree cleanup 调用点(disposeAllRecords / cancelBackground)已随终态
28
+ // 写面迁 execution/service/record-lifecycle.ts(strangler 抽取,壳只留单行转发)——
29
+ // 源文本守卫随被守卫本体改读聚合文件,断言强度不变(patchOk 解耦锁)。
30
+ const src = readSrc(join("src", "execution", "service", "record-lifecycle.ts"));
28
31
 
29
32
  it("worktree cleanup condition does not reference patchOk", () => {
30
33
  // 找到 worktree cleanup 调用前的条件判断
31
- const cleanupMatch = src.match(/if\s*\([^)]*worktreeHandle[^)]*\)\s*\{[\s\S]*?worktreeManager\.cleanup/);
34
+ // [H3/R3] cleanup 通道字面随依赖注入改造为 deps.getWorktreeManager().cleanup
35
+ //(this.worktreeManager → this.deps.getWorktreeManager(),方法体其余逐字节保留)。
36
+ const cleanupMatch = src.match(/if\s*\([^)]*worktreeHandle[^)]*\)\s*\{[\s\S]*?getWorktreeManager\(\)\.cleanup/);
32
37
  expect(cleanupMatch).toBeTruthy();
33
38
  const condition = cleanupMatch![0];
34
39
  // 条件中不应包含 patchOk(解耦后 worktree cleanup 只依赖 worktreeHandle 存在)
@@ -4,9 +4,33 @@ import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
 
7
- import { afterEach,describe, expect, it } from "vitest";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+
9
+ // [U1 A4] removeAliveMarker 失败留痕断言需要 warn 可观察。
10
+ const { loggerMock } = vi.hoisted(() => ({
11
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
12
+ }));
13
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
14
+
15
+ // fs partial mock:unlinkSync 可注错(release 失败留痕用例),其余转发真实实现。
16
+ // ESM namespace 不可直接赋值/spyOn,注错必须走模块 mock(state-marker.test.ts 同款)。
17
+ type UnlinkSyncFn = typeof import("node:fs").unlinkSync;
18
+ const { unlinkSyncMock, actualUnlinkRef } = vi.hoisted(() => ({
19
+ unlinkSyncMock: vi.fn(),
20
+ actualUnlinkRef: { current: undefined as UnlinkSyncFn | undefined },
21
+ }));
22
+ vi.mock("node:fs", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("node:fs")>();
24
+ actualUnlinkRef.current = actual.unlinkSync;
25
+ return {
26
+ ...actual,
27
+ unlinkSync: unlinkSyncMock,
28
+ default: { ...actual, unlinkSync: unlinkSyncMock },
29
+ };
30
+ });
8
31
 
9
32
  import {
33
+ findForeignLiveInstance,
10
34
  isProcessAlive,
11
35
  readAliveMarker,
12
36
  removeAliveMarker,
@@ -18,6 +42,13 @@ function makeTmpDir(): string {
18
42
  return fs.mkdtempSync(path.join(os.tmpdir(), "alive-store-test-"));
19
43
  }
20
44
 
45
+ /**
46
+ * [U1 A4] 「异进程且存活」的确定性模拟 pid:1 号进程(launchd/init)必然存在且非
47
+ * 本测试进程——kill(1, 0) 对普通用户返回 EPERM,isProcessAlive 按「存在但无权限」
48
+ * 保守判活。self-pid 排除后不能再以本进程 pid 模拟异进程实例。
49
+ */
50
+ const FOREIGN_LIVE_PID = 1;
51
+
21
52
  describe("alive-store", () => {
22
53
  let tmpDir: string;
23
54
 
@@ -25,6 +56,11 @@ describe("alive-store", () => {
25
56
  if (tmpDir) {
26
57
  fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
27
58
  }
59
+ // unlinkSync mock 基线复位 = 真实实现(个别用例注错覆盖后恢复)。
60
+ if (actualUnlinkRef.current !== undefined) {
61
+ unlinkSyncMock.mockReset().mockImplementation(actualUnlinkRef.current);
62
+ }
63
+ loggerMock.warn.mockClear();
28
64
  });
29
65
 
30
66
  // ── writeAliveMarker + readAliveMarker 往返 ──
@@ -144,4 +180,84 @@ describe("alive-store", () => {
144
180
  process.kill = originalKill;
145
181
  }
146
182
  });
183
+
184
+ // ── [U1 A4] findForeignLiveInstance:self-pid 排除 + pid 单判据三态 ──
185
+
186
+ describe("findForeignLiveInstance(D3c/D3b v7:self-pid 排除 + pid 单判据)", () => {
187
+ it("本进程 pid(marker.pid === process.pid)→ 放行(视同无 foreign,D3c 排除)", () => {
188
+ tmpDir = makeTmpDir();
189
+ const sessionFile = path.join(tmpDir, "session.jsonl");
190
+ writeAliveMarker(sessionFile, { pid: process.pid, id: "bg-1", startedAt: Date.now() });
191
+
192
+ expect(findForeignLiveInstance(sessionFile)).toBeUndefined();
193
+ });
194
+
195
+ it("异进程 pid 活 → 拦截(返回 marker,在持声明)", () => {
196
+ tmpDir = makeTmpDir();
197
+ const sessionFile = path.join(tmpDir, "session.jsonl");
198
+ const marker: AliveMarker = { pid: FOREIGN_LIVE_PID, id: "bg-1", startedAt: Date.now() };
199
+ writeAliveMarker(sessionFile, marker);
200
+
201
+ expect(findForeignLiveInstance(sessionFile)).toEqual(marker);
202
+ });
203
+
204
+ it("pid 死 → 放行(确死,允许透明重生)", () => {
205
+ tmpDir = makeTmpDir();
206
+ const sessionFile = path.join(tmpDir, "session.jsonl");
207
+ // 大 pid 用户空间必然不存在(isProcessAlive ESRCH 判死,与既有用例同款手法)
208
+ writeAliveMarker(sessionFile, { pid: 9999999, id: "bg-1", startedAt: Date.now() });
209
+
210
+ expect(findForeignLiveInstance(sessionFile)).toBeUndefined();
211
+ });
212
+
213
+ it("软超时不再参与判定:异进程 pid 活 + startedAt 超 1h 仍拦截(防御不随 idle 时长衰减)", () => {
214
+ tmpDir = makeTmpDir();
215
+ const sessionFile = path.join(tmpDir, "session.jsonl");
216
+ const stale: AliveMarker = {
217
+ pid: FOREIGN_LIVE_PID,
218
+ id: "bg-1",
219
+ startedAt: Date.now() - 2 * 3_600_000, // 2h 前——旧三判据形态下会超软超时放行
220
+ };
221
+ writeAliveMarker(sessionFile, stale);
222
+
223
+ expect(findForeignLiveInstance(sessionFile)).toEqual(stale);
224
+ });
225
+
226
+ it("marker 缺失/损坏 → 放行(无声明)", () => {
227
+ tmpDir = makeTmpDir();
228
+ const sessionFile = path.join(tmpDir, "session.jsonl");
229
+ expect(findForeignLiveInstance(sessionFile)).toBeUndefined();
230
+ fs.writeFileSync(`${sessionFile}.alive`, "not-json", "utf-8");
231
+ expect(findForeignLiveInstance(sessionFile)).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ // ── [U1] removeAliveMarker 失败留痕(D3a:release 失败 bestEffort 留痕)──
236
+
237
+ describe("removeAliveMarker 失败留痕", () => {
238
+ it("删除失败(非 ENOENT)→ warn 留痕不抛出", () => {
239
+ tmpDir = makeTmpDir();
240
+ const sessionFile = path.join(tmpDir, "session.jsonl");
241
+ writeAliveMarker(sessionFile, { pid: 1, id: "x", startedAt: 0 });
242
+ unlinkSyncMock.mockImplementation(() => {
243
+ const err = new Error("EACCES") as NodeJS.ErrnoException;
244
+ err.code = "EACCES";
245
+ throw err;
246
+ });
247
+ expect(() => removeAliveMarker(sessionFile)).not.toThrow();
248
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ it("ENOENT(marker 不存在)→ 静默(幂等终态,无 warn)", () => {
252
+ tmpDir = makeTmpDir();
253
+ const sessionFile = path.join(tmpDir, "session.jsonl");
254
+ unlinkSyncMock.mockImplementation(() => {
255
+ const err = new Error("ENOENT") as NodeJS.ErrnoException;
256
+ err.code = "ENOENT";
257
+ throw err;
258
+ });
259
+ expect(() => removeAliveMarker(sessionFile)).not.toThrow();
260
+ expect(loggerMock.warn).not.toHaveBeenCalled();
261
+ });
262
+ });
147
263
  });
@@ -0,0 +1,206 @@
1
+ // src/execution/__tests__/batch-finalized.test.ts
2
+ //
3
+ // [U3 / D1] 批写归口后的 barrier 语义测试(设计 docs/design/subagent-record-
4
+ // persistence-consolidation.md §3.1 markBatchFinalized 行 + §3.3 D4②/D5):
5
+ //
6
+ // 1. E1 调用面(SyncCollectDomain 直构 + 真 RecordStore):notifyBatch(写账入口)
7
+ // 被调用的时刻,全部成员 manifest 必已在磁盘——「manifest 落盘完成先于批通知
8
+ // 写账」的构造性保证(违反即红;迁移前 fire-and-forget 形态该时点只大概率成立);
9
+ // 2. 原语(manifestDir 接线,构造点同步分支):markBatchFinalized await 返回即
10
+ // manifest 文件可见(无 fire-and-forget)+ 落标 entry 携带 collectMode/batchFinalized
11
+ // 显式覆写 + 落标写出的时点 manifest 已在(原语内部序);
12
+ // 3. 原语(降级分支,manifestDir 缺省——双轨期形态):allSettled 屏障后落标,
13
+ // barrier 语义与同步分支等价。
14
+ //
15
+ // 与 record-store-intent-api.test.ts(U1,store 单体语义)的分工:本文件断言
16
+ // **调用面时序**——批通知写账(notifyBatch)/ 落标 entry 写出的时刻 manifest 必已
17
+ // 就位,即 D1 验收条款的字面断言面。
18
+ //
19
+ // 通路保真:RecordStore / SyncCollectDomain / ManifestStore 走真实实现(tmpdir
20
+ // 自建自删,红线),仅 pi.appendEntry / notifyHost 为 mock(写账与落标的观察点)。
21
+
22
+ import * as fs from "node:fs";
23
+ import * as os from "node:os";
24
+ import * as path from "node:path";
25
+
26
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
27
+
28
+ import { ManifestStore } from "../manifest-store.ts";
29
+ import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
30
+ import { RecordStore } from "../record-store.ts";
31
+ import { SyncCollectDomain } from "../service/sync-collect-domain.ts";
32
+ import type { SubagentRecord } from "../types.ts";
33
+
34
+ const ROOT_SESSION = "root-batch-finalized";
35
+
36
+ /** 种子成员 record(对齐 sync-collect-recovery.test.ts memberRecord 形态——
37
+ * rebuildEntryRecord 解析门槛字段齐备)。 */
38
+ function memberRecord(overrides: Partial<SubagentRecord> & { id: string }): SubagentRecord {
39
+ return {
40
+ agent: "/agents/worker.md",
41
+ task: "batch task",
42
+ slug: "batch",
43
+ status: "running",
44
+ mode: "background",
45
+ startedAt: 1000,
46
+ rootSessionId: ROOT_SESSION,
47
+ parentRecordId: undefined,
48
+ depth: 0,
49
+ endedAt: undefined,
50
+ turns: 0,
51
+ totalTokens: 0,
52
+ model: "prov/m1",
53
+ thinkingLevel: undefined,
54
+ eventLog: [],
55
+ displayItems: [],
56
+ result: undefined,
57
+ error: undefined,
58
+ sessionFile: undefined,
59
+ chatMode: false,
60
+ collectMode: "sync",
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe("batch-finalized 归口(U3/D1)——barrier:manifest 落盘完成先于批通知写账", () => {
66
+ let tmpDir: string;
67
+ let sessionsDir: string;
68
+ let manifestDir: string;
69
+ let mainFile: string;
70
+ let appendEntryMock: Mock<(customType: string, data: unknown) => void>;
71
+
72
+ beforeEach(() => {
73
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "core-batch-finalized-"));
74
+ sessionsDir = getSubagentSessionDir(tmpDir, tmpDir);
75
+ manifestDir = getSubagentRecordsDir(tmpDir, tmpDir);
76
+ fs.mkdirSync(sessionsDir, { recursive: true });
77
+ fs.mkdirSync(manifestDir, { recursive: true });
78
+ mainFile = path.join(tmpDir, "main-session.jsonl");
79
+ appendEntryMock = vi.fn();
80
+ });
81
+
82
+ afterEach(() => {
83
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
84
+ });
85
+
86
+ /** 写文件 pi(种子用):appendEntry 真写主 session JSONL(pi 落盘形态)。 */
87
+ function makeWritingPi() {
88
+ return {
89
+ appendEntry: (customType: string, data: unknown) => {
90
+ fs.appendFileSync(
91
+ mainFile,
92
+ `${JSON.stringify({
93
+ type: "custom",
94
+ id: `seed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
95
+ parentId: null,
96
+ timestamp: new Date().toISOString(),
97
+ customType,
98
+ data,
99
+ })}\n`,
100
+ "utf-8",
101
+ );
102
+ },
103
+ };
104
+ }
105
+
106
+ it("E1 调用面:notifyBatch 写账时点全成员 manifest 已落盘(构造性 barrier,违反即红)", async () => {
107
+ // 种子:两成员(register running+sync → 终态 closed)经真实 reportSubagentRecord
108
+ // 序列化落主文件——崩溃残留(全员终态 + 无标记)形态。
109
+ const seedStore = new RecordStore(sessionsDir, undefined, makeWritingPi());
110
+ for (const id of ["sa-bf-a", "sa-bf-b"]) {
111
+ seedStore.reportSubagentRecord(memberRecord({ id }));
112
+ seedStore.reportSubagentRecord(
113
+ memberRecord({ id, status: "closed", closedReason: "gc", endedAt: 2000, result: `done-${id}` }),
114
+ );
115
+ }
116
+
117
+ // 被测面:真 RecordStore(manifestDir 接线——构造点形态)+ 直构 SyncCollectDomain。
118
+ const store = new RecordStore(sessionsDir, undefined, { appendEntry: appendEntryMock }, manifestDir);
119
+ const notifyBatch = vi.fn(() => true);
120
+ const domain = new SyncCollectDomain({
121
+ getStore: () => store,
122
+ getNotifyHost: () => ({
123
+ toNotifyRecord: () => undefined,
124
+ notify: () => {},
125
+ notifyBatch,
126
+ }),
127
+ getPi: () => null,
128
+ getSessionRootId: () => ROOT_SESSION,
129
+ getMainSessionFile: () => mainFile,
130
+ getCollectSyncSection: () => undefined,
131
+ });
132
+
133
+ // 写账时点钩子(严格时序门):notifyBatch 被调用的时刻,两成员 manifest 必已
134
+ // 在磁盘——这是 D1 barrier 的字面断言面(「通知可达 ⇒ 索引就位」)。
135
+ const manifestsAtLedgerWrite: boolean[] = [];
136
+ notifyBatch.mockImplementation(() => {
137
+ manifestsAtLedgerWrite.push(
138
+ fs.existsSync(path.join(manifestDir, "sa-bf-a.json")),
139
+ fs.existsSync(path.join(manifestDir, "sa-bf-b.json")),
140
+ );
141
+ return true;
142
+ });
143
+
144
+ await domain.recoverSyncCollectBatch();
145
+
146
+ expect(notifyBatch).toHaveBeenCalledTimes(1);
147
+ expect(manifestsAtLedgerWrite).toEqual([true, true]);
148
+ // 落标随 markBatchFinalized 前置完成(U3 归口):两成员各一笔带标记 entry。
149
+ const marks = appendEntryMock.mock.calls
150
+ .filter((c) => c[0] === "subagent-record")
151
+ .map((c) => c[1] as Record<string, unknown>)
152
+ .filter((d) => d["batchFinalized"] === true);
153
+ expect(marks.map((m) => m["id"]).sort()).toEqual(["sa-bf-a", "sa-bf-b"]);
154
+ for (const mark of marks) expect(mark["collectMode"]).toBe("sync");
155
+ });
156
+
157
+ it("原语(manifestDir 接线 / 同步分支):await 返回即 manifest 可见;落标 entry 写出时点 manifest 已在", async () => {
158
+ const store = new RecordStore(sessionsDir, undefined, { appendEntry: appendEntryMock }, manifestDir);
159
+ const members = [memberRecord({ id: "sa-sync-1" }), memberRecord({ id: "sa-sync-2" })];
160
+
161
+ // 落标时点钩子:appendEntry(落标写面)触发的时刻,该成员 manifest 必已存在
162
+ // (原语内部序 = manifest 落盘完成先于批通知写账/落标 entry)。
163
+ appendEntryMock.mockImplementation((_type: string, data: unknown) => {
164
+ const d = data as { id?: string };
165
+ if (typeof d?.id === "string") {
166
+ expect(fs.existsSync(path.join(manifestDir, `${d.id}.json`)), `manifest of ${d.id} at entry time`).toBe(true);
167
+ }
168
+ });
169
+
170
+ await store.markBatchFinalized(members);
171
+
172
+ // 写后即刻可见(无 fire-and-forget——D8 writeSync 语义)。
173
+ expect(fs.existsSync(path.join(manifestDir, "sa-sync-1.json"))).toBe(true);
174
+ expect(fs.existsSync(path.join(manifestDir, "sa-sync-2.json"))).toBe(true);
175
+ // 落标 entry:collectMode/batchFinalized 显式覆写(防非 entry 源重建丢标记)。
176
+ expect(appendEntryMock).toHaveBeenCalledTimes(2);
177
+ expect(appendEntryMock).toHaveBeenCalledWith(
178
+ "subagent-record",
179
+ expect.objectContaining({ id: "sa-sync-1", collectMode: "sync", batchFinalized: true }),
180
+ );
181
+ });
182
+
183
+ it("原语(降级分支 / manifestDir 缺省——双轨期形态):allSettled 屏障后落标,barrier 语义等价", async () => {
184
+ // 缺省 manifestDir:store 降级走 manifestStore.writeManifest 异步屏障(双轨期
185
+ // 现行语义,D7)——await 完成后 manifest 照常落盘 + 落标在其后(顺序不变)。
186
+ const store = new RecordStore(
187
+ sessionsDir,
188
+ new ManifestStore(manifestDir),
189
+ { appendEntry: appendEntryMock },
190
+ );
191
+ appendEntryMock.mockImplementation((_type: string, data: unknown) => {
192
+ const d = data as { id?: string };
193
+ if (typeof d?.id === "string") {
194
+ expect(fs.existsSync(path.join(manifestDir, `${d.id}.json`)), `manifest of ${d.id} at entry time`).toBe(true);
195
+ }
196
+ });
197
+
198
+ await store.markBatchFinalized([memberRecord({ id: "sa-degraded" })]);
199
+
200
+ expect(fs.existsSync(path.join(manifestDir, "sa-degraded.json"))).toBe(true);
201
+ expect(appendEntryMock).toHaveBeenCalledWith(
202
+ "subagent-record",
203
+ expect.objectContaining({ id: "sa-degraded", collectMode: "sync", batchFinalized: true }),
204
+ );
205
+ });
206
+ });
@@ -334,6 +334,21 @@ describe("chat 工具域引擎路由分叉(U0:D4/D5/D10)", () => {
334
334
  // 2. pi 缺省守护(D5 字节级)
335
335
  // ============================================================
336
336
 
337
+ it("[F6] pi 路由 kickOffChatRound:runCtx 带 sessionRootId(initSession 注入的根 id,relay 归属键权威源)", async () => {
338
+ // [S5 同根] initSession 读 env PI_SUBAGENT_ROOT_SESSION_ID 优先于 init.sessionId——
339
+ // 在 pi subagent 进程内跑测试(该 env 已注入)必红。显式 stub 为 undefined(空串
340
+ // 会被条件 spread 过滤为缺键,同样破坏断言),不依赖外层环境。
341
+ vi.stubEnv("PI_SUBAGENT_ROOT_SESSION_ID", undefined);
342
+ const { service, piEngine } = setup(agentDir);
343
+ const handle = await service.execute(baseOpts(agentDir));
344
+ await vi.waitFor(() => expect(piEngine.runs.length).toBe(1));
345
+ // initSession({sessionId: "test-session"}) 无 env → sessionRootId = "test-session";
346
+ // kickOffChatRound 是 pi 引擎 background 派发主路径(isPiRoute 恒路由至此,含
347
+ // workflow 域一次性 run),漏注 = GUI pi 派发 relay 拒绝 exit 13(Gate B F6 形态)。
348
+ expect(piEngine.runs[0]!.ctx.sessionRootId).toBe("test-session");
349
+ vi.unstubAllEnvs();
350
+ });
351
+
337
352
  it("[D5] 全缺省 pi record:engine===undefined 且 entry JSON 不含 engine 键", async () => {
338
353
  const { service, piEngine } = setup(agentDir);
339
354
  const handle = await service.execute(baseOpts(agentDir));
@@ -367,7 +382,7 @@ describe("chat 工具域引擎路由分叉(U0:D4/D5/D10)", () => {
367
382
  .execute(baseOpts(agentDir, { engine: "zcode", conversation: true }))
368
383
  .catch((e: unknown) => e);
369
384
  expect(err).toBeInstanceOf(Error);
370
- expect((err as Error).message).toContain("不支持 conversation");
385
+ expect((err as Error).message).toContain("不支持 resume 续聊");
371
386
  // 恢复指引(EngineError.recovery):W3 协议化口径——调参数 / 修 manifest / 升级
372
387
  // 引擎包(引擎包是能力声明载体;「改用 engine: pi」内置兜底指引已随协议化删除)
373
388
  expect((err as { recovery?: string }).recovery).toContain("去掉 conversation 参数");
@@ -519,6 +534,47 @@ describe("chat 工具域引擎路由分叉(U0:D4/D5/D10)", () => {
519
534
  // 按句移除断言(W10 注):inproc 双模不回灌 childStateChanged,镜像移除由
520
535
  // protocol-blackbox 承载(同 subprocess-agent-runner-routing D10 注)。
521
536
  });
537
+
538
+ // ============================================================
539
+ // 6. [F6] sessionRootId 注入(execute / executeAndAwait 两 runCtx 构造点)
540
+ // ============================================================
541
+
542
+ it("[F6] service.sessionRootId 注入 runCtx(根 session id 贯穿引擎派发——relay 归属键 SESSION_ID 权威源)", async () => {
543
+ // 根进程形态:env 无 PI_SUBAGENT_ROOT_SESSION_ID 时 sessionRootId = init.sessionId。
544
+ // 删除防外层 env 污染(该键存在时 initSession 走子进程形态,断言基准漂移)。
545
+ const prevRootEnv = process.env["PI_SUBAGENT_ROOT_SESSION_ID"];
546
+ delete process.env["PI_SUBAGENT_ROOT_SESSION_ID"];
547
+ try {
548
+ const { service, zcode, piEngine } = setup(agentDir);
549
+ zcode.runImpl = () => Promise.resolve({ handle: fakeHandle(), outcome: doneOutcome("ok") });
550
+ piEngine.runImpl = () => Promise.resolve({ handle: fakeHandle(), outcome: doneOutcome("ok") });
551
+
552
+ // chat 域 background 派发(runEngineTask 的 runCtx 构造点)
553
+ await service.execute(baseOpts(agentDir, { engine: "zcode" }));
554
+ await vi.waitFor(() => expect(zcode.runs.length).toBe(1));
555
+ expect(zcode.runs[0].ctx.sessionRootId).toBe("test-session");
556
+
557
+ // workflow 域 sync 派发(runAndFinalize 的 runCtx 构造点;其引擎解析恒走
558
+ // registry 'pi'——resolveChatEnginePort,不消费 opts.engine)。fire-and-forget:
559
+ // 本 harness 无 idle-notify 驱动面,收尾链(finalizeRoundToIdle)不收敛——
560
+ // 只断言构造点(runs 捕获在收尾之前),与既有用例的挂起 record 同等形态。
561
+ let awaitErr: unknown;
562
+ void service
563
+ .executeAndAwait(baseOpts(agentDir, { slug: "f6-await" }))
564
+ .catch((e: unknown) => {
565
+ awaitErr = e;
566
+ });
567
+ await vi.waitFor(() => {
568
+ if (awaitErr !== undefined) {
569
+ throw new Error(`executeAndAwait rejected before engine.run: ${String(awaitErr)}`);
570
+ }
571
+ expect(piEngine.runs.length).toBe(1);
572
+ });
573
+ expect(piEngine.runs[0].ctx.sessionRootId).toBe("test-session");
574
+ } finally {
575
+ if (prevRootEnv !== undefined) process.env["PI_SUBAGENT_ROOT_SESSION_ID"] = prevRootEnv;
576
+ }
577
+ }, 10_000);
522
578
  });
523
579
 
524
580
  // ============================================================
@@ -676,6 +732,66 @@ describe("chat 引擎分支 U2:probe 兜底 / journal / engineHandle", () => {
676
732
  await vi.waitFor(() => expect(service["findRecord"](handle.subagentId)).toBeUndefined());
677
733
  }, 10_000);
678
734
 
735
+ it("[onHandleReady F1] sessionId 先落 + 迟到只补 sessionFile → 按字段补缺落位;已有值不被迟到值覆盖(幂等)", async () => {
736
+ process.env.XYZ_AGENT_DATA_DIR = agentDir;
737
+ const { service, zcode, pi } = setup(agentDir);
738
+ const POOL = "zcode-appserver-home";
739
+ const LATE_SESSION_FILE = "/tmp/late/session-abc.jsonl";
740
+ let releaseRun!: (v: { handle: EngineHandle; outcome: AgentOutcome }) => void;
741
+ zcode.runImpl = (task, ctx) => {
742
+ ctx.onPoolResolved?.(POOL);
743
+ // ① create 应答:只带 sessionId(本 replay 批次新打通的可达面——close 期 LC-4
744
+ // 后缀反查在 sessionId 已知后才补发 sessionFile)。
745
+ ctx.onHandleReady?.({
746
+ sessionRef: { dbPath: ".zcode/cli/db/db.sqlite", sessionId: "sess-live-1" },
747
+ poolKey: POOL,
748
+ });
749
+ // ② 迟到 handleReady:只补 sessionFile——旧守卫「有 sessionId 即整条 return」
750
+ // 会把它整条吞掉,sessionFile 永不落位(消费点:冷续 resume 锚点 / interact 定位)。
751
+ ctx.onHandleReady?.({
752
+ sessionRef: { sessionId: "sess-live-1", sessionFile: LATE_SESSION_FILE },
753
+ poolKey: POOL,
754
+ });
755
+ // ③ 再迟到一次且带冲突值:已有值必须原样保留(幂等),poolKey 不被重置。
756
+ ctx.onHandleReady?.({
757
+ sessionRef: { sessionId: "sess-OVERWRITE", sessionFile: "/tmp/late/other.jsonl" },
758
+ poolKey: "hijacked-pool",
759
+ });
760
+ return new Promise((resolve) => {
761
+ releaseRun = resolve;
762
+ });
763
+ };
764
+ const handle = await service.execute(baseOpts(agentDir, { engine: "zcode" }));
765
+ await vi.waitFor(() => expect(zcode.runs.length).toBe(1));
766
+
767
+ const running = service["collectRecords"](10, "running").find((r) => r.id === handle.subagentId);
768
+ // 补缺落位:sessionFile 进 record.engineHandle(①级会话取证 / 冷续 resume 锚点钥匙)
769
+ expect(running?.engineHandle?.sessionRef["sessionFile"]).toBe(LATE_SESSION_FILE);
770
+ expect(running?.engineHandle).toEqual({
771
+ sessionRef: {
772
+ dbPath: ".zcode/cli/db/db.sqlite",
773
+ sessionId: "sess-live-1",
774
+ sessionFile: LATE_SESSION_FILE,
775
+ },
776
+ poolKey: POOL,
777
+ journalPath: resolveJournalPath(agentDir, "zcode", POOL, handle.subagentId),
778
+ });
779
+ // 补缺经 entry 持久化(运行中 GUI 经 entry 重建 record 即可见),且 ③ 的
780
+ // 「无新字段」重复回调不产生第二条写噪(同 sessionFile 恰好一条)。
781
+ const entries = pi.appendEntry.mock.calls.filter((c) => c[0] === "subagent-record");
782
+ const withLateSessionFile = entries.filter(
783
+ (c) =>
784
+ ((c[1] as Record<string, unknown>).engineHandle as
785
+ | { sessionRef?: Record<string, string> }
786
+ | undefined)?.sessionRef?.["sessionFile"] === LATE_SESSION_FILE,
787
+ );
788
+ expect(withLateSessionFile).toHaveLength(1);
789
+
790
+ // 终态收口(防 dangling)
791
+ releaseRun({ handle: fakeHandle(), outcome: doneOutcome("ok") });
792
+ await vi.waitFor(() => expect(service["findRecord"](handle.subagentId)).toBeUndefined());
793
+ }, 10_000);
794
+
679
795
  it("[onHandleReady] 引擎不回调(spawn 形态)时零回填——终态回填仍兜底(行为不变)", async () => {
680
796
  process.env.XYZ_AGENT_DATA_DIR = agentDir;
681
797
  const { service, zcode, pi } = setup(agentDir);