@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
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
15
  import { EngineClient } from "../engine-client.ts";
16
16
  import { RemoteEngine, type RemoteEngineManifestSnapshot } from "../remote-engine.ts";
17
17
  import { SubagentStream } from "../../../stream-sink.ts";
18
+ import { getSubagentSessionDir } from "../../../path-encoding.ts";
18
19
  import { isProcessAlive } from "../pid-file.ts";
19
20
  import { getLogger, type UiRequest } from "@zhushanwen/subagent-engine-sdk";
20
21
 
@@ -253,6 +254,49 @@ describe("RemoteEngine run 帧映射", () => {
253
254
  await cleanup();
254
255
  });
255
256
 
257
+ it("ctx.sessionRootId → wire ctx.sessionRootId 逐字保真;缺省不传 → wire 无该键(F6 additive)", async () => {
258
+ // 正向:根 session id 上 wire(pi 引擎 relay 归属键 SESSION_ID 的权威来源)
259
+ const withRoot = makeEngine();
260
+ const { ctx: ctxRoot, events: eventsRoot } = makeCtx({ sessionRootId: "root-sess-f6" });
261
+ await withRoot.engine.run({ prompt: "p" }, ctxRoot);
262
+ expect(extractRunParams(eventsRoot).ctx.sessionRootId).toBe("root-sess-f6");
263
+
264
+ // 负向:缺省 → wire ctx 不出现该键(undefined 不上 wire,旧引擎 additive 兼容)
265
+ const bare = makeEngine();
266
+ const { ctx: ctxBare, events: eventsBare } = makeCtx();
267
+ await bare.engine.run({ prompt: "p" }, ctxBare);
268
+ expect(extractRunParams(eventsBare).ctx).not.toHaveProperty("sessionRootId");
269
+
270
+ await withRoot.cleanup();
271
+ await bare.cleanup();
272
+ });
273
+
274
+ it("[Option C] wire ctx.sessionDir = getSubagentSessionDir(env 同源推导);ctx 显式注入优先", async () => {
275
+ // stub env 固定推导输入(测试进程可能已带这两键——stub 后期望值确定)
276
+ vi.stubEnv("PI_CODING_AGENT_DIR", join(dataDir, "agent-dir"));
277
+ vi.stubEnv("PI_SUBAGENT_ROOT_CWD", join(dataDir, "root-cwd"));
278
+ try {
279
+ // 正向:env 同源推导值上 wire(宿主单一权威 getSubagentSessionDir,引擎不自推导)
280
+ const derived = makeEngine();
281
+ const { ctx, events } = makeCtx();
282
+ await derived.engine.run({ prompt: "p" }, ctx);
283
+ expect(extractRunParams(events).ctx.sessionDir).toBe(
284
+ getSubagentSessionDir(join(dataDir, "agent-dir"), join(dataDir, "root-cwd")),
285
+ );
286
+
287
+ // 宿主编排层显式注入(ctx.sessionDir)优先于 env 推导
288
+ const injected = makeEngine();
289
+ const { ctx: ctxInj, events: eventsInj } = makeCtx({ sessionDir: "/explicit/session-dir" });
290
+ await injected.engine.run({ prompt: "p" }, ctxInj);
291
+ expect(extractRunParams(eventsInj).ctx.sessionDir).toBe("/explicit/session-dir");
292
+
293
+ await derived.cleanup();
294
+ await injected.cleanup();
295
+ } finally {
296
+ vi.unstubAllEnvs();
297
+ }
298
+ });
299
+
256
300
  it("poolResolved / handleReady → RunContext 回调(journal 路径权威 + 运行中句柄回填)", async () => {
257
301
  const pools: string[] = [];
258
302
  const readies: Array<{ sessionRef: Record<string, string>; poolKey: string }> = [];
@@ -393,7 +437,7 @@ describe("gate 位多声明 run 期判定(W3 契约⑤接线:manifest vs ini
393
437
  });
394
438
  });
395
439
 
396
- describe("interact / read / probe / dispose 门面", () => {
440
+ describe("read / probe / dispose 门面([H1 U6] interact 断言随 interact 面退役删除)", () => {
397
441
  it("read 协议帧携带 dataDir(必填:存量池时代相对 dbPath 定位)", async () => {
398
442
  const { engine, cleanup } = makeEngine();
399
443
  const view = await engine.read({
@@ -411,21 +455,11 @@ describe("interact / read / probe / dispose 门面", () => {
411
455
  await cleanup();
412
456
  });
413
457
 
414
- it("probe → ProbeReport;interact → delivered;dispose → client 停机(幂等)", async () => {
458
+ it("probe → ProbeReport;dispose → client 停机(幂等)", async () => {
415
459
  const { engine, cleanup } = makeEngine();
416
460
  const report = await engine.probe();
417
461
  expect(report.ok).toBe(true);
418
462
  expect(report.engineVersion).toBe("fake-1.0.0");
419
- const interact = await engine.interact({
420
- data: {
421
- v: 1,
422
- engineId: "fake",
423
- sessionRef: { sessionId: "s-1" },
424
- poolKey: "shared",
425
- adapterVersion: "fake-adapter",
426
- },
427
- }, { kind: "message", payload: "hi" });
428
- expect(interact).toEqual({ ok: true, delivered: true });
429
463
  await engine.dispose();
430
464
  await expect(engine.dispose()).resolves.toBeUndefined(); // 幂等
431
465
  await cleanup();
@@ -104,8 +104,7 @@ export type EngineClientState =
104
104
  /**
105
105
  * run 作用域反向通知路由(RemoteEngine.run 注册,终态后注销)。
106
106
  * 回调允许返回 Promise(数据面 10s 应答守卫的计时对象;同步回调不受守卫影响)。
107
- * [W3 v1.x] onRoundLifecycle:host/roundLifecycle 消费口(首轮 runId 键;续聊轮
108
- * recordId 键走 registerRecordRoute,D1-A 关联键分路)。
107
+ * [H1 U6] 轮次相位消费口(旧第 9 反向通道)已随 chat 域相位机退役删除。
109
108
  */
110
109
  export interface RunRoute {
111
110
  onEvent?: (event: unknown) => void | Promise<void>;
@@ -114,7 +113,6 @@ export interface RunRoute {
114
113
  onHandleReady?: (
115
114
  partial: Pick<EngineHandleData, "sessionRef" | "poolKey">,
116
115
  ) => void | Promise<void>;
117
- onRoundLifecycle?: (phase: unknown) => void | Promise<void>;
118
116
  }
119
117
 
120
118
  /** spawn 引擎 CLI 平台形态(impl-plan §2.2 必写死的两行)。 */
@@ -163,9 +161,6 @@ export class EngineClient {
163
161
  private nextRequestId = 1;
164
162
  private readonly pending = new Map<number, PendingRequest>();
165
163
  private readonly runRoutes = new Map<string, RunRoute>();
166
- /** [W3 v1.x] chat 轮次路由(recordId 键,D1-A——续聊轮无 runId);注册经
167
- * RemoteEngine.registerChatRoundRoute,record 终态注销。 */
168
- private readonly recordRoutes = new Map<string, RunRoute>();
169
164
  private stderrTail = "";
170
165
  private unavailableReason: EngineSdkError | undefined;
171
166
  private connectInFlight: Promise<void> | undefined;
@@ -186,7 +181,6 @@ export class EngineClient {
186
181
  permissionHandler: opts.permissionHandler,
187
182
  log: opts.log,
188
183
  runRoutes: this.runRoutes,
189
- recordRoutes: this.recordRoutes,
190
184
  mirror: this.mirror,
191
185
  setPartialHandle: (partial) => {
192
186
  this.lastPartialHandle = partial;
@@ -560,15 +554,6 @@ export class EngineClient {
560
554
  };
561
555
  }
562
556
 
563
- /** [W3 v1.x] 注册 chat 轮次路由(recordId 键);分发见 reverse-router.ts
564
- * (与 runRoutes 同构,键空间互斥:runId=run 帧分配 / recordId=宿主铸造)。 */
565
- registerRecordRoute(recordId: string, route: RunRoute): () => void {
566
- this.recordRoutes.set(recordId, route);
567
- return () => {
568
- if (this.recordRoutes.get(recordId) === route) this.recordRoutes.delete(recordId);
569
- };
570
- }
571
-
572
557
  /** 健康检查(ADR-0047:静默 ≠ 卡死,不据此杀任务)。 */
573
558
  async ping(): Promise<void> {
574
559
  await this.ensureConnected();
@@ -635,10 +620,12 @@ export class EngineClient {
635
620
  }
636
621
 
637
622
  /**
638
- * 进程死亡 / 失败后的统一清理:镜像整体置死(失效语义 2+3:killed=true 广播后
639
- * 清空)→ pidfile 删 → 在途请求 engine_crashed(附 stderr 尾)→ run 路由清空。
623
+ * 进程死亡 / 失败后的统一清理(收割/组杀分支各成子函数,此处只做顺序编排):
624
+ * 孤儿收割(镜像置死前)→ 镜像置死(失效语义 2+3)→ pidfile 删 → 在途请求
625
+ * engine_crashed(附 stderr 尾)→ run 路由清空 → 活引擎组杀兜底。
640
626
  */
641
627
  private teardownProcess(detail: string): void {
628
+ this.reapOrphansAfterUnexpectedDeath(detail);
642
629
  this.mirror.killAll();
643
630
  if (this.pidfileWritten !== undefined) {
644
631
  removePidfile(this.pidfileWritten);
@@ -651,23 +638,92 @@ export class EngineClient {
651
638
  }
652
639
  this.pending.clear();
653
640
  this.runRoutes.clear();
654
- this.recordRoutes.clear();
655
641
  this.lastPartialHandle = undefined;
642
+ this.killLeakedAliveChild(detail);
643
+ if (this.state !== "unavailable" && this.state !== "disposed") {
644
+ this.state = "exited";
645
+ }
646
+ }
647
+
648
+ /**
649
+ * [H1 U2 / 红线①收割链] 非主动死亡且引擎已死的路径,在镜像置死**之前**补发收割
650
+ * 信号:孤儿真因 = 引擎意外死时宿主未发收割信号(原先 alreadyDead 分支直接跳过
651
+ * killProcessTree),任务子进程留在无主进程组继续写 session 文件。插入点约束:
652
+ * - 位置 = mirror.killAll() 置死清空之前——Windows 通道依赖镜像快照过滤活孤儿
653
+ * (置死后全量项 killed=true 不可过滤);
654
+ * - 条件 = !intentionalKill && alreadyDead——主动 dispose 链已有两层杀(引擎
655
+ * killAllActiveChildren SIGTERM+30s + 宿主 killAll 组杀 5s 升级),跳过即不叠加、
656
+ * 优雅窗零压缩、pi trap-flush 零截断;进程仍活的路径(握手失败重建)由
657
+ * killLeakedAliveChild 组杀兜底承接。
658
+ */
659
+ private reapOrphansAfterUnexpectedDeath(detail: string): void {
660
+ if (!this.intentionalKill && this.child !== undefined) {
661
+ const dying = this.child;
662
+ const alreadyDead = dying.exitCode !== null || dying.signalCode !== null;
663
+ if (alreadyDead && dying.pid !== undefined) {
664
+ if (process.platform === "win32") {
665
+ // Windows:killProcessTree 的 taskkill 以引擎 pid 为树根,树根已死 → not
666
+ // found → 空操作(不可复用)——镜像通道收割(置死前快照,见下)。
667
+ this.reapOrphansViaMirrorSnapshot(detail);
668
+ } else {
669
+ // POSIX:复用 killProcessTree 负 pid 组杀(SIGTERM → 5s grace → SIGKILL
670
+ // 升级链内建,fire-and-forget 不阻塞同步 teardown)。组长死后进程组存活到
671
+ // 最后一员退出,组杀可达任务子进程(引擎 detached:true spawn = 组长,任务
672
+ // 子进程 detached:false 同组,组杀目标 -child.pid 不依赖镜像)。
673
+ // 无外层逐 pid 兜底(SIGTERM 同步返回后 5s 窗内孤儿必然仍活,同步校验恒
674
+ // 命中、立即兜底 = 优雅窗归零截断 trap-flush——升级链已覆盖残余职责)。
675
+ killProcessTree(
676
+ dying.pid,
677
+ `reap orphaned children after unexpected engine death (${detail})`,
678
+ { engineId: this.engineId },
679
+ );
680
+ }
681
+ }
682
+ }
683
+ }
684
+
685
+ /**
686
+ * 握手失败路径(版本越界/握手超时/启动僵死)引擎进程仍活着:必须组杀,否则
687
+ * crash 重建循环每次 spawn 泄漏一个常驻孤儿(测试机实测积累 370+)。主动杀
688
+ * (intentionalKill=true)与进程已死(exitCode/signalCode 非空)跳过重复发信号。
689
+ */
690
+ private killLeakedAliveChild(detail: string): void {
656
691
  if (this.child !== undefined) {
657
692
  const child = this.child;
658
693
  this.child = undefined;
659
- // 握手失败路径(版本越界 / 握手超时 / 启动僵死)引擎进程仍活着:必须组杀,
660
- // 否则泄漏为常驻孤儿(crash 重建循环每次 spawn 泄漏一个;测试机实测积累
661
- // 370+ 假引擎进程)。主动杀路径(killAll 已发信号,intentionalKill=true)与
662
- // 进程已死路径(onEngineExit,exitCode/signalCode 非空)跳过重复发信号。
663
694
  const alreadyDead = child.exitCode !== null || child.signalCode !== null;
664
695
  if (!alreadyDead && !this.intentionalKill && child.pid !== undefined) {
665
696
  killProcessTree(child.pid, detail, { engineId: this.engineId });
666
697
  }
667
698
  child.removeAllListeners();
668
699
  }
669
- if (this.state !== "unavailable" && this.state !== "disposed") {
670
- this.state = "exited";
700
+ }
701
+
702
+ /**
703
+ * [H1 U2 / 红线①Windows 通道] 镜像快照收割活孤儿:镜像快照同步抓清单(置死前,
704
+ * 全量项含引擎一生历史死 pid——镜像对已退出子进程只改 state 不删项、唯一清空点 =
705
+ * killAll)→ 过滤活孤儿(state === "running" && !killed,活孤儿 ≤ 并发数)→ 逐 pid
706
+ * **异步 spawn** taskkill /T /F fire-and-forget(树根 = 活着的任务子进程自身,树杀
707
+ * 有效;快照同步抓、杀动作异步发——与 POSIX fire-and-forget 对称;禁 spawnSync:
708
+ * 逐个同步 N×10s 上界会冻结 onEngineExit 同步回调、推迟 pending reject(Continuation
709
+ * 失败通知链源头)与宿主事件循环)。
710
+ */
711
+ private reapOrphansViaMirrorSnapshot(detail: string): void {
712
+ const liveOrphans = this.mirror.snapshot().filter((e) => e.state === "running" && !e.killed);
713
+ if (liveOrphans.length === 0) return;
714
+ logger.warn(
715
+ `[engine-client:${this.engineId}] reaping ${liveOrphans.length} orphaned task child(ren) via mirror snapshot after unexpected engine death (${detail})`,
716
+ );
717
+ for (const entry of liveOrphans) {
718
+ const killer = spawn("taskkill", ["/PID", String(entry.pid), "/T", "/F"], { stdio: "ignore" });
719
+ killer.unref();
720
+ killer.on("error", (err: Error) => {
721
+ // spawn 失败(taskkill 缺失等)留 debug 痕迹——残余孤儿由红线③登记承接
722
+ //(下一轮派发前兜底 / 宿主重启窗口)。
723
+ logger.debug(
724
+ `[engine-client:${this.engineId}] taskkill spawn failed for orphan pid ${entry.pid} (${err.message})`,
725
+ );
726
+ });
671
727
  }
672
728
  }
673
729
 
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { getLogger } from "@zhushanwen/subagent-engine-sdk";
21
21
 
22
+ import { notifyChildProcessExited } from "../../dialog-queue.ts";
23
+
22
24
  const logger = getLogger("subagents");
23
25
 
24
26
  /** 镜像项(childSpawned/childStateChanged 载荷的落库形态)。 */
@@ -90,6 +92,11 @@ export class SpawnedChildrenMirror {
90
92
  };
91
93
  this.entries.set(patch.pid, entry);
92
94
  this.emit({ reason: "childStateChanged", pid: patch.pid, recordId: patch.recordId });
95
+ // [SR-4 接线] 子进程退出 → 取消其挂起 dialog:原绑定点(宿主 spawn pi 子进程时代
96
+ // 的 session-runner child close)随协议化消失(宿主不再持句柄),新落点 = 引擎经
97
+ // host/childStateChanged 上报 → 本镜像消费。未接线时该子进程的 pending dialog 要
98
+ // 占住队列串行位直到 30min 队列级超时(延迟清理,非死锁)。
99
+ if (patch.state === "exited") notifyChildProcessExited(patch.pid);
93
100
  }
94
101
 
95
102
  /**
@@ -106,6 +113,8 @@ export class SpawnedChildrenMirror {
106
113
  entry.state = "exited";
107
114
  entry.updatedAt = Date.now();
108
115
  this.emit({ reason: "killedAll", pid: entry.pid, recordId: entry.recordId });
116
+ // [SR-4 接线] 引擎进程消亡 = 其全部子进程已死 → 逐 pid 取消挂起 dialog
117
+ notifyChildProcessExited(entry.pid);
109
118
  }
110
119
  this.entries.clear();
111
120
  }
@@ -1,7 +1,8 @@
1
1
  // src/execution/engine/client/remote-engine.ts
2
2
  //
3
3
  // RemoteEngine:cli 形态 EnginePort 适配(W2,impl-plan §2.2「RemoteEngine 同步成员
4
- // 形态映射」必写死)。把 core EnginePort 9 成员映射到 EngineClient 协议请求;
4
+ // 形态映射」必写死;[H1 U6] 交互控制面与 recordId 键路由面已随 chat 域退役删除)。
5
+ // 把 core EnginePort 的成员映射到 EngineClient 协议请求;
5
6
  // 同步成员(capabilities / listModels / validateModel)**只读 manifest 注册期快照**
6
7
  // ——单源化原则(设计 §3.3「同步成员清单」v6 减法):无握手缓存、无失效时机,
7
8
  // initialize 应答仅诊断(warn 由 EngineClient 留痕)。
@@ -12,31 +13,31 @@
12
13
  //
13
14
  // W3 消费契约:routing/registry 的 cli 形态 EnginePort 实例 = 本类(先写后读)。
14
15
 
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
15
19
  import {
16
20
  CANCEL_SETTLE_GRACE_MS,
17
21
  EngineSdkError,
18
22
  type AgentCallOpts as SdkAgentCallOpts,
19
23
  type AgentOutcome as SdkAgentOutcome,
20
24
  type EngineHandleData as SdkEngineHandleData,
21
- type InteractAction as SdkInteractAction,
22
- type InteractResult as SdkInteractResult,
23
25
  type ModelCatalogEntry,
24
26
  type ProbeReport as SdkProbeReport,
25
27
  type SessionView as SdkSessionView,
26
28
  } from "@zhushanwen/subagent-engine-sdk";
27
29
 
28
30
  import type { AgentCallOpts } from "../../../orchestration/models/types.ts";
31
+ import { getSubagentSessionDir } from "../../path-encoding.ts";
29
32
  import { assertGateCapabilitiesMatched } from "../common/capability-gate.ts";
30
33
  import type {
31
34
  EngineCapabilities,
32
35
  EngineHandle,
33
36
  EngineHandleData,
34
- InteractAction,
35
- InteractResult,
36
37
  ProbeReport,
37
38
  SessionView,
38
39
  } from "../types.ts";
39
- import type { ChatRoundRoute, EnginePort, EngineRunResult, RunContext } from "../port.ts";
40
+ import type { EnginePort, EngineRunResult, RunContext } from "../port.ts";
40
41
  import type { EngineClient, RunRoute } from "./engine-client.ts";
41
42
 
42
43
  /** manifest 注册期快照(发现器/注册表读取,构造时注入——同步成员唯一源)。 */
@@ -91,7 +92,7 @@ export class RemoteEngine implements EnginePort {
91
92
  // **不实现**(消费方 model-validation.ts:62 `typeof validateModel !== "function"`
92
93
  // → 跳过校验恒放行)。实例 own property 置 undefined 遮蔽原型方法——
93
94
  // typeof engine.validateModel === "undefined"。
94
- (this as unknown as { validateModel?: unknown }).validateModel = undefined;
95
+ (this as { validateModel?: unknown }).validateModel = undefined;
95
96
  }
96
97
  }
97
98
 
@@ -232,24 +233,6 @@ export class RemoteEngine implements EnginePort {
232
233
  }
233
234
  }
234
235
 
235
- async interact(handle: EngineHandle, action: InteractAction): Promise<InteractResult> {
236
- await this.opts.client.ensureConnected();
237
- const result = (await this.opts.client.request("interact", {
238
- handle: handle.data,
239
- action: action as SdkInteractAction,
240
- })) as SdkInteractResult;
241
- return result;
242
- }
243
-
244
- /**
245
- * [W3 v1.x] chat 轮次反向通道路由注册(recordId 键,EnginePort 可选面实现):
246
- * interact 续聊轮的 streamDelta / roundLifecycle 分发目标。薄委托 EngineClient
247
- * 的 recordRoutes(键分发见 reverse-router.ts)。
248
- */
249
- registerChatRoundRoute(recordId: string, route: ChatRoundRoute): () => void {
250
- return this.opts.client.registerRecordRoute(recordId, route as RunRoute);
251
- }
252
-
253
236
  /** 协议 read(dataDir 必填——引擎数据根,构造注入)。 */
254
237
  async read(handle: EngineHandle): Promise<SessionView> {
255
238
  await this.opts.client.ensureConnected();
@@ -308,16 +291,47 @@ interface WireRunParams {
308
291
  ctxModel: string | undefined;
309
292
  engineFallback: RunContext["engineFallback"];
310
293
  streamMode: "stream" | undefined;
294
+ sessionRootId?: string;
295
+ /** [Option C] 恒有值(宿主注入 ?? 同源 env 推导)——与 sessionRootId 的
296
+ * "undefined 不上 wire" 不同,本字段派生恒产出字符串。 */
297
+ sessionDir: string;
311
298
  };
312
- chat?: NonNullable<RunContext["chat"]>;
299
+ resume?: NonNullable<RunContext["resume"]>;
300
+ }
301
+
302
+ // pi 壳宿主进程内贯穿的两条 env(与 subagent-service / workflow-state-root 同源推导):
303
+ // - PI_CODING_AGENT_DIR:pi SDK getAgentDir 的 env 覆盖通道——xyz-agent 生产链路由
304
+ // runtime spawn pi 时显式注入(rpc-client buildPiOutboundEnv,经
305
+ // buildOutboundChildEnv 共享构建器出站);缺省 ~/.pi/agent 与 pi
306
+ // 实装版 dist config.js getAgentDir 逐字同构(锚定先例 workflow-state-root.ts)。
307
+ // - PI_SUBAGENT_ROOT_CWD:真 ROOT 的 cwd(MF-3 贯穿)——嵌套 subagent 场景宿主
308
+ // spawn 子进程时注入,与 subagent-service 构造处的 rootCwd 同 env 同值。
309
+ const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
310
+ const PI_ROOT_CWD_ENV = "PI_SUBAGENT_ROOT_CWD";
311
+
312
+ /**
313
+ * [Option C 协议化] 宿主权威 subagent session 目录(Gate B S6 修复):宿主进程内
314
+ * 同源 env 推导 agentDir/rootCwd 后调 getSubagentSessionDir(宿主单一权威推导,
315
+ * path-encoding.ts——引擎本地推导与宿主布局三处不等价,已降级 [LEGACY] fallback)。
316
+ * 每次调用重新解析(env 读取零成本,不缓存防测试/宿主切换读旧值,对齐
317
+ * common/data-dir.ts getEngineDataDir 惯例)。rootCwd 缺省 process.cwd():pi 壳
318
+ * ctx.cwd = pi 进程启动 cwd(session-lifecycle 侧同用进程 cwd 的既有锚定)。
319
+ */
320
+ function deriveHostSubagentSessionDir(): string {
321
+ const agentDir = process.env[PI_AGENT_DIR_ENV];
322
+ const resolvedAgentDir =
323
+ agentDir !== undefined && agentDir !== "" ? agentDir : join(homedir(), ".pi", "agent");
324
+ const rootCwd = process.env[PI_ROOT_CWD_ENV];
325
+ const resolvedRootCwd = rootCwd !== undefined && rootCwd !== "" ? rootCwd : process.cwd();
326
+ return getSubagentSessionDir(resolvedAgentDir, resolvedRootCwd);
313
327
  }
314
328
 
315
329
  /**
316
330
  * run 帧 wire 载荷构建。协议 ctx 承载(RunContext 字段映射表):cwd 取任务声明值
317
331
  * (缺省进程 cwd);ctxModel 投影 canonical 词形(provider/id,ModelInfo 字段裁决)。
318
- * [W3 v1.x] chat 会话形态参数直传(RunContext.chat → run.params.chat;结构由
319
- * RunContext.chat 注释与 SDK RunChatParams 的 implements 互证承载)。非 chat 轮
320
- * ctx.chat === undefined → wire 上不出现该键(协议 additive 语义)。
332
+ * [H1 U6] 会话形态参数直传(RunContext.resume → run.params.resume;结构由
333
+ * RunContext.resume 注释与 SDK RunResumeParams 的 implements 互证承载)。一次性轮
334
+ * ctx.resume === undefined → wire 上不出现该键(协议 additive 语义)。
321
335
  */
322
336
  function buildRunParams(task: AgentCallOpts, ctx: RunContext, runId: string): WireRunParams {
323
337
  const ctxModelRef = ctx.ctxModel ? `${ctx.ctxModel.provider}/${ctx.ctxModel.id}` : undefined;
@@ -332,21 +346,25 @@ function buildRunParams(task: AgentCallOpts, ctx: RunContext, runId: string): Wi
332
346
  ctxModel: ctxModelRef,
333
347
  engineFallback: ctx.engineFallback,
334
348
  streamMode: ctx.stream !== undefined ? ("stream" as const) : undefined,
349
+ // [F6] 根 session id(relay 归属键 SESSION_ID 权威源)——undefined 不上 wire
350
+ //(additive 语义,与顶层 chat 参数同写法)。
351
+ ...(ctx.sessionRootId !== undefined ? { sessionRootId: ctx.sessionRootId } : {}),
352
+ // [Option C 协议化] 权威 subagent session 目录(Gate B S6):宿主注入值优先,
353
+ // 缺省同源 env 推导(deriveHostSubagentSessionDir)——恒有值恒上 wire,引擎
354
+ // 据此组装 --session-dir 不自推导(引擎本地推导降级 [LEGACY] fallback)。
355
+ sessionDir: ctx.sessionDir ?? deriveHostSubagentSessionDir(),
335
356
  },
336
- ...(ctx.chat !== undefined ? { chat: ctx.chat } : {}),
357
+ ...(ctx.resume !== undefined ? { resume: ctx.resume } : {}),
337
358
  };
338
359
  }
339
360
 
340
- /** run 作用域事件路由(event / streamDelta / poolResolved / handleReady / roundLifecycle)。 */
361
+ /** run 作用域事件路由(event / streamDelta / poolResolved / handleReady)。 */
341
362
  function buildRunRouteHandlers(ctx: RunContext): RunRoute {
342
363
  return {
343
364
  onEvent: (event) => ctx.onEvent?.(event as Parameters<NonNullable<RunContext["onEvent"]>>[0]),
344
365
  onStreamDelta: (delta) => ctx.stream?.onDelta(delta),
345
366
  onPoolResolved: (poolKey) => ctx.onPoolResolved?.(poolKey),
346
367
  onHandleReady: (partial) => ctx.onHandleReady?.(partial),
347
- // [W3 v1.x] 首轮(run 会话形态)轮次生命周期帧(runId 键)→ 宿主消费口。
348
- onRoundLifecycle: (phase) =>
349
- ctx.onRoundLifecycle?.(phase as Parameters<NonNullable<RunContext["onRoundLifecycle"]>>[0]),
350
368
  };
351
369
  }
352
370
 
@@ -24,7 +24,6 @@ import {
24
24
  type HostLogParams,
25
25
  type HostPermissionParams,
26
26
  type HostPoolResolvedParams,
27
- type HostRoundLifecycleParams,
28
27
  type HostStreamDeltaParams,
29
28
  type UiRequest,
30
29
  } from "@zhushanwen/subagent-engine-sdk";
@@ -45,8 +44,6 @@ export interface ReverseRouterDeps {
45
44
  log?: (params: HostLogParams) => void;
46
45
  /** run 作用域通知路由表(EngineClient 持有,EngineClient 生命周期内同一引用)。 */
47
46
  runRoutes: Map<string, RunRoute>;
48
- /** [W3 v1.x] chat 轮次路由表(recordId 键;EngineClient 持有,同上)。 */
49
- recordRoutes: Map<string, RunRoute>;
50
47
  /** childSpawned/childStateChanged 的镜像落点。 */
51
48
  mirror: SpawnedChildrenMirror;
52
49
  /** handleReady 的 partial handle 回填(崩溃合成 handle 数据源)。 */
@@ -159,11 +156,11 @@ function handleDataPlaneRequest(deps: ReverseRouterDeps, frame: { id: string; me
159
156
  /** 数据面单通道分发器(params 形态自证——发帧前引擎侧自检,分发层只做强转)。 */
160
157
  type DataPlaneHandler = (deps: ReverseRouterDeps, params: unknown) => Promise<void> | void;
161
158
 
162
- /** method → 分发器表(表外 method = 无操作;路由面准入已由 REVERSE_CHANNEL_TIMEOUT_CLASS 收敛为 data-plane)。 */
159
+ /** method → 分发器表(表外 method = 无操作;路由面准入已由 REVERSE_CHANNEL_TIMEOUT_CLASS 收敛为 data-plane)。
160
+ * [H1 U6] 旧轮次相位反向通道已随 chat 域相位机退役删除(交棒改 run 应答驱动)。 */
163
161
  const DATA_PLANE_HANDLERS: Record<string, DataPlaneHandler> = {
164
162
  "host/log": (deps, params) => dispatchLog(deps, params as HostLogParams),
165
163
  "host/streamDelta": (deps, params) => dispatchStreamDelta(deps, params as HostStreamDeltaParams),
166
- "host/roundLifecycle": (deps, params) => dispatchRoundLifecycle(deps, params as HostRoundLifecycleParams),
167
164
  "host/poolResolved": (deps, params) => dispatchPoolResolved(deps, params as HostPoolResolvedParams),
168
165
  "host/handleReady": (deps, params) => dispatchHandleReady(deps, params as HostHandleReadyParams),
169
166
  "host/childSpawned": (deps, params) => dispatchChildSpawned(deps, params as HostChildSpawnedParams),
@@ -180,31 +177,13 @@ function dispatchLog(deps: ReverseRouterDeps, p: HostLogParams): void {
180
177
  }
181
178
 
182
179
  async function dispatchStreamDelta(deps: ReverseRouterDeps, p: HostStreamDeltaParams): Promise<void> {
183
- // [W3 v1.x 接线(W1 偏差 #1 的替换落地)] 关联键分路(D1-A):
184
- // - recordId = chat 续聊轮 delta(interact 发起,无独立 runId)→ recordRoutes;
185
- // - runId 键 = run 域轮(含 run 会话形态首轮)→ runRoutes(v1 现状不变)。
186
- // 无注册路由 = 该轮宿主消费面未挂(诊断形态)——静默丢弃(数据面已 ack,
187
- // 引擎侧不重发;路由缺席非引擎故障)。
188
- if (p.recordId !== undefined) {
189
- await deps.recordRoutes.get(p.recordId)?.onStreamDelta?.(p.delta);
190
- return;
191
- }
180
+ // [H1 U6] 关联键收敛为 runId 单键(recordId 分路随 chat 续聊轮 interact 面退役——
181
+ // 每轮 = run,delta 恒经 run 作用域路由)。无注册路由 = 该轮宿主消费面未挂
182
+ //(诊断形态)——静默丢弃(数据面已 ack,引擎侧不重发;路由缺席非引擎故障)。
183
+ if (p.runId === undefined) return;
192
184
  await deps.runRoutes.get(p.runId)?.onStreamDelta?.(p.delta);
193
185
  }
194
186
 
195
- async function dispatchRoundLifecycle(deps: ReverseRouterDeps, p: HostRoundLifecycleParams): Promise<void> {
196
- // [W3 v1.x] 第 9 反向通道消费(settled/idle/failed 三相位 × runId|recordId 键)。
197
- // 相位语义(arm/disarm/交棒/失败分诊)归宿主编排层——本路由只做键分发,
198
- // 消费方 = RunContext.onRoundLifecycle(首轮 runId 键)与 registerChatRoundRoute
199
- // 路由(续聊轮 recordId 键)。载荷形状自证(isHostRoundLifecycleParams)归
200
- // 引擎侧发帧前自检 + 消费方,分发层不重复判别(与 streamDelta 一致的薄分发)。
201
- if (p.recordId !== undefined) {
202
- await deps.recordRoutes.get(p.recordId)?.onRoundLifecycle?.(p);
203
- return;
204
- }
205
- await deps.runRoutes.get(p.runId)?.onRoundLifecycle?.(p);
206
- }
207
-
208
187
  async function dispatchPoolResolved(deps: ReverseRouterDeps, p: HostPoolResolvedParams): Promise<void> {
209
188
  await deps.runRoutes.get(p.runId)?.onPoolResolved?.(p.poolKey);
210
189
  }
@@ -0,0 +1,79 @@
1
+ // src/execution/engine/common/__tests__/run-signals.test.ts
2
+ //
3
+ // [H2 W4] mergeRunSignals 纯函数行为测试——自 subprocess-agent-runner-timeout.test.ts
4
+ // 迁移改写:原 mergeTimeoutSignal 薄包装(timeoutMs 无守护信号源时原样返回 external
5
+ // signal;有则返回 .signal 视图)随 SAR.run 掏空删除(生产零消费),断言面改直测
6
+ // 公共 helper 权威实现(engine/common/run-signals.ts,W2 迁移步⑥自 SAR 提取)。
7
+ // 原用例行为语义逐条保持(T3.6 / T3.17 编号沿用)。
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+
11
+ import { mergeRunSignals } from "../run-signals.ts";
12
+
13
+ describe("mergeRunSignals (D-A9 / H2 W2 迁移步⑥)", () => {
14
+ it("T3.6 timeoutMs===undefined → 原样返回 external signal", () => {
15
+ const ctrl = new AbortController();
16
+ const handle = mergeRunSignals(ctrl.signal, undefined);
17
+ expect(handle.signal).toBe(ctrl.signal);
18
+ handle.dispose();
19
+ });
20
+
21
+ it("T3.6 timeoutMs<=0 → 原样返回 external signal", () => {
22
+ const ctrl = new AbortController();
23
+ const handle = mergeRunSignals(ctrl.signal, 0);
24
+ expect(handle.signal).toBe(ctrl.signal);
25
+ handle.dispose();
26
+ });
27
+
28
+ it("T3.6 timeoutMs>0 → 返回新 signal(合并外部+超时两路)", () => {
29
+ const ctrl = new AbortController();
30
+ const handle = mergeRunSignals(ctrl.signal, 50);
31
+ expect(handle.signal).not.toBe(ctrl.signal);
32
+ expect(handle.signal.aborted).toBe(false);
33
+ handle.dispose();
34
+ });
35
+
36
+ it("T3.6 timeoutMs 到期 → merged signal abort", () => {
37
+ vi.useFakeTimers();
38
+ const ctrl = new AbortController();
39
+ const handle = mergeRunSignals(ctrl.signal, 50);
40
+
41
+ expect(handle.signal.aborted).toBe(false);
42
+ vi.advanceTimersByTime(51);
43
+ expect(handle.signal.aborted).toBe(true);
44
+ handle.dispose();
45
+ vi.useRealTimers();
46
+ });
47
+
48
+ it("T3.6 外部 signal abort → merged signal abort", () => {
49
+ const ctrl = new AbortController();
50
+ const handle = mergeRunSignals(ctrl.signal, 5000);
51
+ ctrl.abort();
52
+ expect(handle.signal.aborted).toBe(true);
53
+ handle.dispose();
54
+ });
55
+
56
+ it("T3.6 外部 signal 已 abort → 返回已 abort 的 signal", () => {
57
+ const ctrl = new AbortController();
58
+ ctrl.abort();
59
+ const handle = mergeRunSignals(ctrl.signal, 5000);
60
+ expect(handle.signal.aborted).toBe(true);
61
+ handle.dispose();
62
+ });
63
+
64
+ it("T3.17 NFR: merged signal abort → timeout timer 清理", () => {
65
+ vi.useFakeTimers();
66
+ const ctrl = new AbortController();
67
+ const handle = mergeRunSignals(ctrl.signal, 50);
68
+
69
+ ctrl.abort(); // 外部 abort → merged 也 abort
70
+ expect(handle.signal.aborted).toBe(true);
71
+
72
+ // 推进时间,不应再有副作用
73
+ vi.advanceTimersByTime(100);
74
+ // timer 应被清理(通过 abort event listener)
75
+ // 无异常 = timer 已正确清理
76
+ handle.dispose();
77
+ vi.useRealTimers();
78
+ });
79
+ });
@@ -168,6 +168,33 @@ export function assertGateCapabilitiesMatched(
168
168
  }
169
169
  }
170
170
 
171
+ // ============================================================
172
+ // 能力位方向判定③:SP-5 升级 gate(H1 U2 / D5 双写点共用文案单源)
173
+ // ============================================================
174
+
175
+ /**
176
+ * [H1 U2 / D5 双写点] SP-5 升级(one-shot → chatMode)被 conversation 位拒绝时的
177
+ * 统一错误构造(文案/错误码/恢复指引单一权威)。
178
+ *
179
+ * 两个写点共同消费(现场契约「改动这两处必须协同」的 gate 化落点):
180
+ * - 写点①进程内热升级:subagent-actions-core messageHandler;
181
+ * - 写点②跨重启冷升级(D4 revive 格):conversation-continuation(U6 已删
182
+ * cold-resurrect 无条件置位——语义迁入本格,磁盘重建只水合持久化 chatMode)。
183
+ *
184
+ * unsupported 引擎(zcode)的 one-shot 收到 message 不升级——升级后续聊行为悬空
185
+ * (Continuation 续聊轮依赖引擎 resume 能力),guard 文案指引 fork/重派。
186
+ */
187
+ export function engineConversationUpgradeUnsupportedError(engineId: string): EngineError {
188
+ return new EngineError(
189
+ "engine_capability_unsupported",
190
+ `subagent completed as one-shot on engine '${engineId}' (capabilities.conversation = 'unsupported') — ` +
191
+ `it cannot be upgraded to a resumable conversation, so the message is rejected.`,
192
+ `Recovery: re-dispatch the follow-up as a new subagent (action:'start'), ` +
193
+ `or branch from its session history with action:'fork-from', ` +
194
+ `or pick an engine that declares the conversation capability.`,
195
+ );
196
+ }
197
+
171
198
  /** engine_capability_mismatch 的统一构造(manifest 权威 + 恢复指引 = 修 manifest / 升级引擎包)。 */
172
199
  function gateMismatchError(engineId: string, cap: string, declared: string, answered: string): EngineError {
173
200
  return new EngineError(
@@ -5,8 +5,9 @@
5
5
  // 顶层(journal / 隔离池的父目录),extension 写侧与 runtime 校验侧同源推导。
6
6
  //
7
7
  // 通道调查结论(2026-08-25,P2 实测证据链):
8
- // - runtime spawn pi 主会话时 RpcClient.buildSafeEnv 从 runtime process.env 继承
9
- // XYZ_ 前缀白名单变量(rpc-client.ts:14-30),且显式设置 PI_CODING_AGENT_DIR;
8
+ // - runtime spawn pi 主会话时 RpcClient 出站 env 从 runtime process.env 继承
9
+ // XYZ_ 前缀白名单变量(调查当时经私有 buildSafeEnv,现已被 buildPiOutboundEnv
10
+ // → buildOutboundChildEnv 共享构建器取代),且显式设置 PI_CODING_AGENT_DIR;
10
11
  // - dev 模式 Electron main 设置 XYZ_AGENT_DATA_DIR(apps/electron/main/main.ts:122),
11
12
  // 经 process-control.ts:262 透传给 runtime → 透传链成立;
12
13
  // - prod 默认路径(~/.xyz-agent 缺省)下 runtime 进程 env 中**没有**该变量
@@ -59,7 +59,7 @@ export class EngineError extends Error {
59
59
  this.recovery = recovery;
60
60
  }
61
61
 
62
- /** 结构化投影(InteractResult.code/message 与 GUI 警告条共用形态)。 */
62
+ /** 结构化投影(code/message 与 GUI 警告条共用形态)。 */
63
63
  toStructured(): { code: EngineErrorCode; message: string; recovery: string } {
64
64
  return { code: this.code, message: this.message, recovery: this.recovery };
65
65
  }