@zhushanwen/subagent-core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/dist/{chunk-OL5BK4VN.js → chunk-LZJOEDGQ.js} +455 -203
  2. package/dist/engine-discovery-scan-Da4Dkzsi.d.cts +818 -0
  3. package/dist/engine-discovery-scan-Da4Dkzsi.d.ts +818 -0
  4. package/dist/execution/engine/engine-discovery-scan.cjs +216 -155
  5. package/dist/execution/engine/engine-discovery-scan.d.cts +1 -2
  6. package/dist/execution/engine/engine-discovery-scan.d.ts +1 -2
  7. package/dist/execution/engine/engine-discovery-scan.js +1 -1
  8. package/dist/execution/relay-env.js +5 -4
  9. package/dist/index.cjs +7686 -5894
  10. package/dist/index.d.cts +1876 -1360
  11. package/dist/index.d.ts +1876 -1360
  12. package/dist/index.js +7048 -5493
  13. package/dist.bundle/index.cjs +7747 -5957
  14. package/package.json +4 -4
  15. package/src/__tests__/manifest-store.test.ts +16 -15
  16. package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
  17. package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
  18. package/src/execution/__tests__/alive-store.test.ts +117 -1
  19. package/src/execution/__tests__/batch-finalized.test.ts +206 -0
  20. package/src/execution/__tests__/chat-engine-routing.test.ts +117 -1
  21. package/src/execution/__tests__/{cold-resurrect.test.ts → cold-lookup.test.ts} +133 -33
  22. package/src/execution/__tests__/collect-coordinator-service.test.ts +7 -5
  23. package/src/execution/__tests__/conversation-continuation.test.ts +1452 -0
  24. package/src/execution/__tests__/delivery-methods.test.ts +90 -176
  25. package/src/execution/__tests__/dialog-queue.test.ts +70 -3
  26. package/src/execution/__tests__/dispose-manifest-recovery.test.ts +475 -0
  27. package/src/execution/__tests__/execute-and-await-worktree.test.ts +3 -3
  28. package/src/execution/__tests__/execute-nesting.test.ts +8 -5
  29. package/src/execution/__tests__/execution-record.test.ts +8 -148
  30. package/src/execution/__tests__/finalize-record.test.ts +295 -129
  31. package/src/execution/__tests__/get-record-for-action-restart.test.ts +21 -29
  32. package/src/execution/__tests__/helpers/fake-engine-port.ts +13 -68
  33. package/src/execution/__tests__/helpers/legacy-sidecar.ts +33 -0
  34. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +9 -5
  35. package/src/execution/__tests__/idle-gc.test.ts +60 -0
  36. package/src/execution/__tests__/lifecycle-manager.test.ts +3 -254
  37. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +43 -22
  38. package/src/execution/__tests__/model-config-service.test.ts +111 -0
  39. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +10 -6
  40. package/src/execution/__tests__/notify-ledger.test.ts +42 -1
  41. package/src/execution/__tests__/rebuild-indexes.test.ts +329 -0
  42. package/src/execution/__tests__/record-binding.test.ts +613 -0
  43. package/src/execution/__tests__/record-origin.test.ts +318 -0
  44. package/src/execution/__tests__/record-store-intent-api.test.ts +600 -0
  45. package/src/execution/__tests__/record-store-orphan-revive.test.ts +71 -3
  46. package/src/execution/__tests__/record-store.test.ts +72 -54
  47. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +8 -5
  48. package/src/execution/__tests__/round-supervisor-workflow-origin.test.ts +205 -0
  49. package/src/execution/__tests__/run-orchestration-write-lease.test.ts +293 -0
  50. package/src/execution/__tests__/run-orchestration.test.ts +199 -0
  51. package/src/execution/__tests__/session-file-gc.test.ts +35 -4
  52. package/src/execution/__tests__/session-reconstructor.test.ts +78 -0
  53. package/src/execution/__tests__/state-marker.test.ts +288 -0
  54. package/src/execution/__tests__/stream-sink-retirement.test.ts +8 -5
  55. package/src/execution/__tests__/subagent-actions-core.test.ts +67 -16
  56. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +0 -1
  57. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -11
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +17 -11
  59. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +130 -53
  60. package/src/execution/__tests__/subagent-service.test.ts +17 -215
  61. package/src/execution/__tests__/subprocess-agent-runner-no-progress-killall.test.ts +138 -0
  62. package/src/execution/__tests__/subprocess-agent-runner.test.ts +105 -660
  63. package/src/execution/__tests__/sync-collect-recovery.test.ts +23 -16
  64. package/src/execution/__tests__/terminal-write-latency.test.ts +79 -0
  65. package/src/execution/__tests__/workflow-agent-dispatch.test.ts +782 -0
  66. package/src/execution/alive-store.ts +37 -28
  67. package/src/execution/{cold-resurrect.ts → cold-lookup.ts} +48 -45
  68. package/src/execution/config.ts +1 -1
  69. package/src/execution/conversation-continuation.ts +541 -0
  70. package/src/execution/dialog-queue.ts +60 -12
  71. package/src/execution/engine/__tests__/common/capability-gate.test.ts +1 -1
  72. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +16 -1
  73. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +17 -51
  74. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +0 -5
  75. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +1 -1
  76. package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +5 -9
  77. package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +0 -3
  78. package/src/execution/engine/__tests__/engine-discovery.test.ts +0 -3
  79. package/src/execution/engine/__tests__/model-prompt.test.ts +0 -3
  80. package/src/execution/engine/__tests__/registry.test.ts +0 -1
  81. package/src/execution/engine/__tests__/routing.test.ts +0 -1
  82. package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +0 -3
  83. package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +0 -3
  84. package/src/execution/engine/client/__tests__/engine-client-reap.test.ts +212 -0
  85. package/src/execution/engine/client/__tests__/mirror.test.ts +73 -1
  86. package/src/execution/engine/client/__tests__/protocol-closure.test.ts +2 -9
  87. package/src/execution/engine/client/__tests__/remote-engine.test.ts +46 -12
  88. package/src/execution/engine/client/engine-client.ts +81 -25
  89. package/src/execution/engine/client/mirror.ts +9 -0
  90. package/src/execution/engine/client/remote-engine.ts +52 -34
  91. package/src/execution/engine/client/reverse-router.ts +6 -27
  92. package/src/execution/engine/common/__tests__/run-signals.test.ts +79 -0
  93. package/src/execution/engine/common/capability-gate.ts +27 -0
  94. package/src/execution/engine/common/data-dir.ts +3 -2
  95. package/src/execution/engine/common/errors.ts +1 -1
  96. package/src/execution/engine/common/journal-wiring.ts +18 -9
  97. package/src/execution/engine/common/run-signals.ts +83 -0
  98. package/src/execution/engine/d8-compat.ts +1 -1
  99. package/src/execution/engine/host/__tests__/host-bridge.test.ts +3 -18
  100. package/src/execution/engine/host/host-bridge.ts +19 -41
  101. package/src/execution/engine/host/pi-host-binding.ts +5 -5
  102. package/src/execution/engine/port.ts +34 -54
  103. package/src/execution/engine/types.ts +11 -14
  104. package/src/execution/execution-record.ts +15 -47
  105. package/src/execution/finalize-record.ts +94 -197
  106. package/src/execution/idle-gc.ts +9 -3
  107. package/src/execution/lifecycle-manager.ts +25 -361
  108. package/src/execution/lifecycle-predicates.ts +22 -19
  109. package/src/execution/manifest-store.ts +40 -42
  110. package/src/execution/model-config-service.ts +1 -1
  111. package/src/execution/notifier.ts +39 -1
  112. package/src/execution/notify-host.ts +13 -4
  113. package/src/execution/record-entry.ts +20 -2
  114. package/src/execution/record-store.ts +925 -160
  115. package/src/execution/relay-env.ts +8 -3
  116. package/src/execution/round-supervisor/domain.ts +17 -5
  117. package/src/execution/round-supervisor/service-binding.test.ts +89 -53
  118. package/src/execution/round-supervisor/service-binding.ts +71 -21
  119. package/src/execution/round-supervisor/supervisor.ts +23 -9
  120. package/src/execution/service/record-access.ts +547 -0
  121. package/src/execution/service/record-lifecycle.ts +542 -0
  122. package/src/execution/service/run-orchestration.ts +1647 -0
  123. package/src/execution/service/service-bootstrap.ts +111 -0
  124. package/src/execution/service/service-constants.ts +27 -0
  125. package/src/execution/service/session-baselines.ts +401 -0
  126. package/src/execution/service/sync-collect-domain.ts +442 -0
  127. package/src/execution/service/workflow-dispatch.ts +567 -0
  128. package/src/execution/session-file-gc.ts +11 -8
  129. package/src/execution/session-reconstructor.ts +44 -1
  130. package/src/execution/sessions-index.ts +20 -3
  131. package/src/execution/settled-watchdog.ts +86 -43
  132. package/src/execution/state-marker.ts +516 -0
  133. package/src/execution/stream-sink.ts +1 -1
  134. package/src/execution/subagent-actions-core.ts +53 -31
  135. package/src/execution/subagent-service.ts +643 -2980
  136. package/src/execution/subprocess-agent-runner.ts +46 -323
  137. package/src/execution/types.ts +60 -43
  138. package/src/execution/ui-request-handler-factory.ts +5 -0
  139. package/src/execution/worktree-manager.ts +1 -1
  140. package/src/execution/worktree-registry.ts +1 -1
  141. package/src/index.ts +6 -5
  142. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -3
  143. package/src/orchestration/__tests__/agent-call-stream.test.ts +24 -49
  144. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +4 -4
  145. package/src/orchestration/__tests__/file-run-store.test.ts +5 -7
  146. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +2 -3
  147. package/src/orchestration/__tests__/run-snapshot.test.ts +27 -43
  148. package/src/orchestration/__tests__/worker-message-pump-workflow-dispatch.test.ts +274 -0
  149. package/src/orchestration/execute-agent-call.ts +1 -1
  150. package/src/orchestration/file-run-store.ts +1 -1
  151. package/src/orchestration/models/ports.ts +20 -13
  152. package/src/orchestration/models/types.ts +11 -13
  153. package/src/orchestration/run-snapshot.ts +18 -22
  154. package/src/orchestration/worker-message-pump.ts +33 -57
  155. package/src/shared/atomic-write.ts +10 -8
  156. package/dist/chunk-T2SYBW3I.js +0 -24
  157. package/dist/engine-discovery-scan-BXLA8y-z.d.cts +0 -1624
  158. package/dist/engine-discovery-scan-BXLA8y-z.d.ts +0 -1624
  159. package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +0 -255
  160. package/src/execution/__tests__/finalized-marker.test.ts +0 -104
  161. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +0 -211
  162. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +0 -488
  163. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +0 -75
  164. package/src/execution/__tests__/tombstone-store.test.ts +0 -73
  165. package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +0 -230
  166. package/src/execution/finalized-marker.ts +0 -70
  167. package/src/execution/tombstone-store.ts +0 -72
@@ -1,14 +1,17 @@
1
1
  // src/execution/__tests__/delivery-methods.test.ts
2
2
  //
3
- // 冷路径续轮 + chatMode 统一投递单元测试(M2-B1 投递基础设施)。
3
+ // 会话形态续聊投递单元测试(M2-B1 投递基础设施 H1 chat-run 统一定形)。
4
4
  //
5
5
  // [W3 改写] 投递链路协议化:deliverChatMessage → pi EnginePort(registry cli 形态
6
- // port 的替身 registerFakePiEngine)interact(message);冷路径 =
7
- // engine_session_not_resumable resumeColdRound run chat + resume 锚点。
6
+ // port 的替身 registerFakePiEngine)。
7
+ // [H1 U2/U6 改写] 每轮 = 新 run + resume 锚点(ctx.resume,键切换后唯一会话形态键),
8
+ // Continuation 单飞承接防双写者守卫(原 resumeColdRound resumesInFlight 守卫退役);
9
+ // 轮末分流 = run 应答驱动(onRunSettled,D7)——[H1 U6] 旧 interact 热路径/冷路径
10
+ // 分流与 settleChatRoundFromResponse 结算载体的断言段随载体退役删除。
8
11
  // 原 inproc stdin 字节断言(sendPromptCommand/streamingBehavior/EPIPE 写后死检测)
9
- // 随 inproc pi 引擎目录 删除归 pi-subagent-cli 包内测试(chat-session.test.ts 同语义覆盖)。
12
+ // 随 inproc pi 引擎目录 删除归 pi-subagent-cli 包内测试。
10
13
  //
11
- // 本文件断言的编排语义(分流/守卫/状态迁移/在途守卫)与改线前逐点同构。
14
+ // 本文件断言的编排语义(守卫/状态迁移/单飞隔离)与改线前逐点同构。
12
15
 
13
16
  import * as fs from "node:fs";
14
17
  import * as os from "node:os";
@@ -62,7 +65,7 @@ function makeIdleRecord(id = "sa-chat"): ExecutionRecord {
62
65
  return record;
63
66
  }
64
67
 
65
- describe("冷路径续轮(M2-B1 idle 投递;engine_session_not_resumable → run chat + resume", () => {
68
+ describe("会话形态续聊投递(run + resume 锚点)", () => {
66
69
  let agentDir: string;
67
70
  let service: SubagentService;
68
71
  let record: ExecutionRecord;
@@ -78,12 +81,9 @@ describe("冷路径续轮(M2-B1 idle 投递;engine_session_not_resumable →
78
81
  record = makeIdleRecord();
79
82
  // sessionFile 用 agentDir 下路径(finalizeRoundToIdle 写 .idle sidecar 不留 /tmp 垃圾)
80
83
  record.sessionFile = path.join(agentDir, "fake-session.jsonl");
81
- // 冷路径替身:interact 返回 engine_session_not_resumable(引擎无活进程)
82
- fake.interactMessageResult = {
83
- ok: false,
84
- code: "engine_session_not_resumable",
85
- message: "no live process (cold path). Recovery: dispatch a new run with ctx chat resume.",
86
- };
84
+ // [U2a/B5] 轮终簿记归口 store.markRoundIdle(按 id 查内存)——record 须 register 进
85
+ // store(生产链路 getRecordForAction/run 流程的 record 恒在内存,测试补齐同形态)。
86
+ (service as unknown as { store: { register: (r: ExecutionRecord) => void } }).store.register(record);
87
87
  });
88
88
 
89
89
  afterEach(() => {
@@ -92,20 +92,20 @@ describe("冷路径续轮(M2-B1 idle 投递;engine_session_not_resumable →
92
92
  fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
93
93
  });
94
94
 
95
- it("冷路径续轮(running) → run chat 收到 resume 锚点;chatMode+done running/round+1", async () => {
95
+ it("续聊 message → run 收到 resume 锚点(ctx.resume 唯一会话形态键);轮终 round+1 保持 running", async () => {
96
96
  const beforeRound = record.round;
97
97
 
98
- await service.chatActions.deliverChatMessage(record, "next round msg", false);
98
+ await service.chatActions.deliverChatMessage(record, "next round msg");
99
99
 
100
- // 冷路径守卫通过后:status 已手动设回 running(M2-A 边界,绕过 tryTransition
100
+ // Continuation:锚点校验通过后派发(record 保持 running)
101
101
  expect(record.status).toBe("running");
102
102
 
103
- // detached:等协议 run 被调(冷续 = chat.resume 锚点)
103
+ // detached:等协议 run 被调(续聊 = ctx.resume 锚点)
104
104
  await vi.waitFor(() => expect(fake.runs.length).toBe(1));
105
105
  const run = fake.runs[0]!;
106
106
  expect(run.task.prompt).toBe("next round msg");
107
107
  expect(run.task.conversation).toBe(true);
108
- expect(run.ctx.chat).toEqual({
108
+ expect(run.ctx.resume).toEqual({
109
109
  recordId: record.id,
110
110
  resume: {
111
111
  sessionRef: { recordId: record.id, sessionFile: record.sessionFile },
@@ -113,40 +113,44 @@ describe("冷路径续轮(M2-B1 idle 投递;engine_session_not_resumable →
113
113
  },
114
114
  });
115
115
 
116
- // 模拟引擎首轮 agent_settled 应答(idle 帧先于应答帧)
117
- run.emitLifecycle({ phase: "idle", anchor: { sessionRef: { recordId: record.id, sessionFile: record.sessionFile }, poolKey: "shared" } });
116
+ // 模拟引擎 agent_settled 应答(轮末分流 = run 应答驱动,D7)
118
117
  run.settle({ content: "round text" });
119
118
 
120
- // 等 detached 完成:chatMode+done→running(v4 B-1 idle 折入 running,M2-A 分流),round 累加
119
+ // 等 detached 完成:round 累加、record 保持 running-resumable(v4 B-1 idle 折入 running
121
120
  await vi.waitFor(() => expect(record.round).toBe(beforeRound! + 1));
122
121
  expect(record.status).toBe("running");
123
122
  });
124
123
 
125
- it("终态 closed record → throw 行动语言(MF-4,仅 running 可续聊),不触发 interact", async () => {
124
+ it("终态 closed record → throw 行动语言(D4 closed 硬拒格),不派发 run", async () => {
126
125
  record.status = "closed";
127
- // MF-4:行动语言(spec §3.1),不暴露 resume/controller 内部词汇
128
- await expect(service.chatActions.deliverChatMessage(record, "msg", false)).rejects.toThrow(/not ready for a new message/);
129
- expect(fake.interacts.length).toBe(0);
126
+ // [H1 U2] D4 表 closed 硬拒格:closedReason 非 可重连集(undefined/gc)→ 硬拒 +
127
+ // start 新的指引(Continuation reviveOrThrow 文案)
128
+ await expect(service.chatActions.deliverChatMessage(record, "msg")).rejects.toThrow(
129
+ /cannot be messaged or resumed/,
130
+ );
131
+ expect(fake.runs.length).toBe(0);
130
132
  });
131
133
 
132
- it("record 无 sessionFile → 冷路径续轮 throw 行动语言(MF-4 canonical session unavailable),不触发 kickOff", async () => {
134
+ it("record 无 sessionFile → 同步拒绝(D4 表锚点缺失格:no transcript anchor + re-dispatch 指引),不触发 kickOff", async () => {
133
135
  record.sessionFile = undefined;
134
- await expect(service.chatActions.deliverChatMessage(record, "msg", false)).rejects.toThrow(/session unavailable/);
136
+ await expect(service.chatActions.deliverChatMessage(record, "msg")).rejects.toThrow(
137
+ /no transcript anchor/,
138
+ );
135
139
  expect(fake.runs.length).toBe(0);
136
140
  });
137
141
 
138
- it("record 无 controller → 冷路径续轮 throw 行动语言(MF-4),不触发 kickOff", async () => {
142
+ it("record 无 controller → 投递 throw 行动语言(MF-4),不触发 kickOff", async () => {
139
143
  record.controller = undefined;
140
- await expect(service.chatActions.deliverChatMessage(record, "msg", false)).rejects.toThrow(/not ready for a new message/);
144
+ await expect(service.chatActions.deliverChatMessage(record, "msg")).rejects.toThrow(/not ready for a new message/);
141
145
  expect(fake.runs.length).toBe(0);
142
146
  });
143
147
  });
144
148
 
145
149
  // ============================================================
146
- // deliverChatMessage(V2 决策 3 chatMode 统一投递:协议 interact message 分流)
150
+ // deliverChatMessage(chatMode 统一投递入口 Continuation 编排)
147
151
  // ============================================================
148
152
 
149
- describe("deliverChatMessage (V2 决策 3 chatMode 统一投递;协议 interact)", () => {
153
+ describe("deliverChatMessage(chatMode 统一投递 Continuation 派发)", () => {
150
154
  let agentDir: string;
151
155
  let service: SubagentService;
152
156
  let record: ExecutionRecord;
@@ -159,9 +163,11 @@ describe("deliverChatMessage (V2 决策 3 chatMode 统一投递;协议 interac
159
163
  const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
160
164
  service = new SubagentService({ cwd: agentDir, modelService });
161
165
  service.initSession({ pi: makePi(), sessionId: "root-session" });
162
- record = makeIdleRecord(); // chatMode:true, idle, round=1
163
- // sessionFile:冷路径续轮需要(热路径不用,设了无害)
166
+ record = makeIdleRecord(); // chatMode:true, running, round=1
167
+ // sessionFile:续聊锚点需要
164
168
  record.sessionFile = path.join(agentDir, "fake-session.jsonl");
169
+ // [U2a/B5] markRoundIdle 按 id 查 store 内存——record 须 register(见上 describe 注)。
170
+ (service as unknown as { store: { register: (r: ExecutionRecord) => void } }).store.register(record);
165
171
  lifecycle._resetLifecycleState();
166
172
  });
167
173
 
@@ -172,91 +178,49 @@ describe("deliverChatMessage (V2 决策 3 chatMode 统一投递;协议 interac
172
178
  fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
173
179
  });
174
180
 
175
- it("热路径 interrupt=false:引擎受理interact(message, interrupt:false) + status=running + 执行态信号清除", async () => {
181
+ it("轮间 messageContinuation 派发新轮 + 执行态信号清除([H1 U2] 承接原冷路径语义)", async () => {
176
182
  record.result = "上一轮增量";
177
183
  record.resumable = true;
178
184
 
179
- await service.chatActions.deliverChatMessage(record, "after you finish", false);
185
+ await service.chatActions.deliverChatMessage(record, "after you finish");
180
186
 
181
- expect(fake.interacts.length).toBe(1);
182
- const call = fake.interacts[0]!;
183
- expect(call.action).toEqual({ kind: "message", payload: "after you finish", interrupt: false });
184
- expect(call.handle.data.sessionRef["recordId"]).toBe(record.id);
185
- // 受理成功:status 回 running + 上一轮执行态信号清除(§5.4 isStreaming 公式)
187
+ // [H1 U2] 每轮 = 新 run + resume 锚点(§3.4 dispatchRound)
188
+ await vi.waitFor(() => expect(fake.runs.length).toBe(1));
189
+ const run = fake.runs[0]!;
190
+ expect(run.task.prompt).toBe("after you finish");
191
+ expect(run.ctx.resume?.recordId).toBe(record.id);
192
+ expect(run.ctx.resume?.resume?.sessionRef["sessionFile"]).toBe(record.sessionFile);
193
+ // 轮始执行态信号清除(§5.4 isStreaming 公式)+ 迁移上报
186
194
  expect(record.status).toBe("running");
187
195
  expect(record.result).toBeUndefined();
188
196
  expect(record.resumable).toBeUndefined();
189
197
  });
190
198
 
191
- it("热路径 interrupt=true:引擎受理 interact(message, interrupt:true)(steer 抢占)", async () => {
192
- await service.chatActions.deliverChatMessage(record, "stop now", true);
193
-
194
- expect(fake.interacts[0]!.action).toEqual({ kind: "message", payload: "stop now", interrupt: true });
195
- expect(record.status).toBe("running");
196
- });
197
-
198
- it("冷路径:engine_session_not_resumable → 续轮 run chat + resume 锚点", async () => {
199
- fake.interactMessageResult = {
200
- ok: false,
201
- code: "engine_session_not_resumable",
202
- message: "no live process (cold path)",
203
- };
204
-
205
- await service.chatActions.deliverChatMessage(record, "resume msg", false);
206
-
199
+ it("settle 交棒 = run 应答驱动(D7):派发后挂中段守护,应答 settle 后轮终守护清空", async () => {
200
+ await service.chatActions.deliverChatMessage(record, "msg");
207
201
  await vi.waitFor(() => expect(fake.runs.length).toBe(1));
208
- const run = fake.runs[0]!;
209
- expect(run.task.prompt).toBe("resume msg");
210
- expect(run.ctx.chat?.recordId).toBe(record.id);
211
- expect(run.ctx.chat?.resume?.sessionRef["sessionFile"]).toBe(record.sessionFile);
212
- });
213
-
214
- it("业务拒绝(EPIPE 兜底耗尽等)→ 原样 throw(错误文本逐字节保持),不进冷路径", async () => {
215
- fake.interactMessageResult = {
216
- ok: false,
217
- code: "engine_interact_failed",
218
- message: `EPIPE fallback exhausted for ${record.id}: 3 consecutive failures.`,
219
- };
220
-
221
- await expect(service.chatActions.deliverChatMessage(record, "msg", false)).rejects.toThrow(
222
- /EPIPE fallback exhausted/,
223
- );
224
- expect(fake.runs.length).toBe(0); // 不进冷路径
225
- });
226
-
227
- it("热路径 disarm idle timer:arm 后投递 → timer 清除(防 turn 期间误杀)", async () => {
228
- // 先 arm idle timer(模拟首轮 agent_settled 后 armed)
229
- lifecycle.armIdleTimer(record.id, () => {}, 10000);
230
- expect(lifecycle.hasIdleTimer(record.id)).toBe(true);
231
-
232
- await service.chatActions.deliverChatMessage(record, "msg", false);
233
-
234
- // disarmIdleTimer 被调 → timer 清除(新 turn 不被 idle timer 误杀)
235
- expect(lifecycle.hasIdleTimer(record.id)).toBe(false);
236
- });
237
-
238
- it("受理后挂中段守护(settled-watchdog mid-round armed);settled 相位交棒收尾段", async () => {
239
- await service.chatActions.deliverChatMessage(record, "msg", false);
240
202
 
241
203
  const { hasSettledWatchdog, getSettledWatchdogPhase } = await import("../settled-watchdog.ts");
242
204
  expect(hasSettledWatchdog(record.id)).toBe(true);
243
205
  expect(getSettledWatchdogPhase(record.id)).toBe("mid-round");
244
206
 
245
- // 引擎 settled 相位(recordId 键)→ 中段让位收尾段(W4 noteRoundSettledFromProtocol
246
- fake.emitRecordLifecycle(record.id, { phase: "settled" });
247
- expect(getSettledWatchdogPhase(record.id)).toBe("settled");
207
+ // [H1 U2 / D7] settle 交棒 = run 应答驱动(onRunSettled 内 noteRoundSettledFromProtocol):
208
+ // 应答后轮终簿记完成,两段守护一并清(不残留 armed——收尾段 fire 会对已收敛轮误杀)
209
+ fake.runs[0]!.settle({ content: "round text" });
210
+ await vi.waitFor(() => expect(record.round).toBe(2));
211
+ expect(hasSettledWatchdog(record.id)).toBe(false);
248
212
  });
249
213
  });
250
214
 
251
215
  // ============================================================
252
- // 冷路径并发守卫(review round2 MF1:同 turn 批量两条 message 双冷路径双 spawn)
216
+ // 续聊并发守卫(review round2 MF1:同 turn 批量两条 message kickOff 双 spawn)
253
217
  // ============================================================
254
218
  // 复现链(reviewer 探针实证):pi 对同一条 assistant message 的 tool calls 顺序执行,
255
- // tool1 的投递在冷路径续轮返回即 resolve——早于协议 run 完成(pool.acquire await 等
256
- // 异步点);tool2 立即执行 → 引擎仍无活进程 → 再次冷路径。v4 两态收敛后续轮的
257
- // `status !== "running"` 守卫对 idle-resumable record 恒放行 → 两次 kickOff →
258
- // 两个 pi 子进程以 --session 同一 JSONL 双写 + 第一个进程脱离记账成孤儿。
259
- describe("deliverChatMessage 冷路径并发守卫(review round2 MF1)", () => {
219
+ // tool1 的投递在续聊返回即 resolve——早于协议 run 完成(pool.acquire await 等
220
+ // 异步点);tool2 立即执行 → 第二次 kickOff。v4 两态收敛后的 status 守卫对
221
+ // running-resumable record 恒放行 → 两次 kickOff → 两个 pi 子进程以 --session 同一
222
+ // JSONL 双写 + 第一个进程脱离记账成孤儿。
223
+ describe("deliverChatMessage 并发守卫(review round2 MF1)", () => {
260
224
  let agentDir: string;
261
225
  let service: SubagentService;
262
226
  let record: ExecutionRecord;
@@ -266,16 +230,13 @@ describe("deliverChatMessage 冷路径并发守卫(review round2 MF1)", () =
266
230
  agentDir = makeTmpAgentDir();
267
231
  clearEngines();
268
232
  fake = registerFakePiEngine();
269
- fake.interactMessageResult = {
270
- ok: false,
271
- code: "engine_session_not_resumable",
272
- message: "no live process (cold path)",
273
- };
274
233
  const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
275
234
  service = new SubagentService({ cwd: agentDir, modelService });
276
235
  service.initSession({ pi: makePi(), sessionId: "root-session" });
277
236
  record = makeIdleRecord();
278
237
  record.sessionFile = path.join(agentDir, "fake-session.jsonl");
238
+ // [U2a/B5] markRoundIdle 按 id 查 store 内存——record 须 register(见上 describe 注)。
239
+ (service as unknown as { store: { register: (r: ExecutionRecord) => void } }).store.register(record);
279
240
  lifecycle._resetLifecycleState();
280
241
  });
281
242
 
@@ -286,96 +247,49 @@ describe("deliverChatMessage 冷路径并发守卫(review round2 MF1)", () =
286
247
  fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
287
248
  });
288
249
 
289
- it("同 record 连续两条 message 冷路径 第二条 throw 行动语言,协议 run 仅 1 次", async () => {
290
- // 第一条:正常冷路径 resume(run 挂起不 resolve——模拟 pool.acquire 排队窗口)
291
- await expect(service.chatActions.deliverChatMessage(record, "first msg", false)).resolves.toBeUndefined();
250
+ it("同 record 轮在途连续两条 message → 第二条入队不打断单飞(构造性单写者),轮终 drain 聚合派发", async () => {
251
+ // 第一条:派发新轮(run 挂起不 resolve——模拟在途轮)
252
+ await expect(service.chatActions.deliverChatMessage(record, "first msg")).resolves.toBeUndefined();
292
253
  await vi.waitFor(() => expect(fake.runs.length).toBe(1));
293
254
 
294
- // 第二条:run 仍在途(引擎侧仍无活进程)→ 再走冷路径。
295
- // 修复前:续轮守卫恒放行 → 第二次 kickOff 2 run(双 spawn 双写 session)。
296
- // 修复后:in-flight 守卫 throw 行动语言(MF-4)。
297
- await expect(service.chatActions.deliverChatMessage(record, "second msg", false)).rejects.toThrow(
298
- /already starting a new round/,
299
- );
255
+ // 第二条:run 在途 → [H1 U2 / D2 打断语义] abort 在途轮 signal + 入队(不打断单飞、
256
+ // 不二次派发——修复前双 kickOff 双写 session 的守卫由 Continuation 单飞构造性承接)
257
+ await expect(service.chatActions.deliverChatMessage(record, "second msg")).resolves.toBeUndefined();
300
258
  expect(fake.runs.length).toBe(1);
301
-
302
- // 轮次完成 守卫清除 后续冷路径可再 resume(守卫不得永久死锁 record)
259
+ const continuation = (
260
+ // [R4 深绑改写] continuations 队列已迁 RunOrchestration 聚合——读取路径改经
261
+ // 聚合实例(断言对象与强度不变)。
262
+ service as unknown as {
263
+ runOrchestration: { continuations: Map<string, { pendingCount: number }> };
264
+ }
265
+ ).runOrchestration.continuations.get(record.id);
266
+ expect(continuation?.pendingCount).toBe(1);
267
+
268
+ // 轮终(应答收敛)→ drain → 队列消息派发为下一轮(单写者前置满足)
303
269
  fake.runs[0]!.settle({ content: "done" });
304
- await vi.waitFor(() =>
305
- expect((service as unknown as { resumesInFlight: Set<string> }).resumesInFlight.has(record.id)).toBe(false),
306
- );
307
- await expect(service.chatActions.deliverChatMessage(record, "third msg", false)).resolves.toBeUndefined();
308
270
  await vi.waitFor(() => expect(fake.runs.length).toBe(2));
309
- expect(fake.runs[1]!.task.prompt).toBe("third msg");
271
+ expect(fake.runs[1]!.task.prompt).toBe("second msg");
310
272
  });
311
273
 
312
- it("守卫是 record 级:A 在途 resume 不拦截 B 的冷路径 message", async () => {
274
+ it("守卫是 record 级:A 在途轮不拦截 B message", async () => {
313
275
  const recordB = makeIdleRecord("sa-chat-b");
314
276
  recordB.sessionFile = path.join(agentDir, "fake-session-b.jsonl");
277
+ // [U2a/B5] 同 beforeEach——recordB 须 register 进 store(markRoundIdle 内存查)。
278
+ (service as unknown as { store: { register: (r: ExecutionRecord) => void } }).store.register(recordB);
315
279
 
316
- await service.chatActions.deliverChatMessage(record, "A msg", false);
280
+ await service.chatActions.deliverChatMessage(record, "A msg");
317
281
  await vi.waitFor(() => expect(fake.runs.length).toBe(1));
318
282
 
319
- // B 的冷路径不受 A 在途影响
320
- await expect(service.chatActions.deliverChatMessage(recordB, "B msg", false)).resolves.toBeUndefined();
283
+ // B 的续聊轮不受 A 在途影响(Continuation per-record 实例)
284
+ await expect(service.chatActions.deliverChatMessage(recordB, "B msg")).resolves.toBeUndefined();
321
285
  await vi.waitFor(() => expect(fake.runs.length).toBe(2));
322
- expect(fake.runs[1]!.ctx.chat?.recordId).toBe(recordB.id);
286
+ expect(fake.runs[1]!.ctx.resume?.recordId).toBe(recordB.id);
323
287
 
324
288
  fake.runs[0]!.settle({ content: "A done" });
325
289
  fake.runs[1]!.settle({ content: "B done" });
326
- await vi.waitFor(() =>
327
- expect((service as unknown as { resumesInFlight: Set<string> }).resumesInFlight.has(recordB.id)).toBe(false),
328
- );
329
- });
330
- });
331
-
332
- // ============================================================
333
- // 本轮 settle(协议形态:run 应答 = 首轮 agent_settled)
334
- // ============================================================
335
-
336
- describe("settleChatRoundFromResponse(W3:协议形态的本轮结算)", () => {
337
- let agentDir: string;
338
- let service: SubagentService;
339
- let record: ExecutionRecord;
340
-
341
- beforeEach(() => {
342
- agentDir = makeTmpAgentDir();
343
- const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
344
- service = new SubagentService({ cwd: agentDir, modelService });
345
- service.initSession({ pi: makePi(), sessionId: "root-session" });
346
- record = makeIdleRecord();
347
- record.sessionFile = path.join(agentDir, "fake-session.jsonl");
348
- });
349
-
350
- afterEach(() => {
351
- service.dispose();
352
- fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
353
- });
354
-
355
- it("应答到达 → round+1 + result=本轮内容 + 通知回注(W2 契约:outcome = 本轮内容)", () => {
356
- const notifyRouted: string[] = [];
357
- const coord = (service as unknown as { collectCoordinator: { route(r: ExecutionRecord): void } }).collectCoordinator;
358
- const original = coord.route.bind(coord);
359
- Object.assign(coord, { route: (r: ExecutionRecord) => { notifyRouted.push(r.id); original(r); } });
360
-
361
- const settle = (service as unknown as {
362
- settleChatRoundFromResponse(record: ExecutionRecord, outcome: { content: string }): void;
363
- }).settleChatRoundFromResponse.bind(service);
364
- settle(record, { content: "本轮回复文本" });
365
-
366
- expect(record.round).toBe(2);
367
- expect(record.result).toBe("本轮回复文本");
368
- expect(notifyRouted).toEqual([record.id]);
369
- });
370
-
371
- it("closeAfterRound 挂起 → 轮终兑现终态化(closed + user-close)", async () => {
372
- record.closeAfterRound = true;
373
- const settle = (service as unknown as {
374
- settleChatRoundFromResponse(record: ExecutionRecord, outcome: { content: string }): void;
375
- }).settleChatRoundFromResponse.bind(service);
376
- settle(record, { content: "final" });
377
-
378
- await vi.waitFor(() => expect(record.status).toBe("closed"));
379
- expect(record.closeAfterRound).toBeUndefined();
290
+ // 轮终簿记完成(Continuation 无全局在途集合——终态守卫由 per-record 实例承载),
291
+ // 断言两轮各自 round+1 即单飞收口的可观察结果。
292
+ await vi.waitFor(() => expect(record.round).toBe(2));
293
+ await vi.waitFor(() => expect(recordB.round).toBe(2));
380
294
  });
381
295
  });
@@ -37,7 +37,15 @@ vi.mock("../../core/logger.ts", () => ({
37
37
  getLogger: () => loggerMock,
38
38
  }));
39
39
 
40
- import { DEFAULT_DIALOG_TIMEOUT_MS, DialogGlobalQueue, type UiRequest, type UiResponse } from "../dialog-queue.ts";
40
+ import {
41
+ DEFAULT_DIALOG_TIMEOUT_MS,
42
+ DialogGlobalQueue,
43
+ _getActiveDialogQueueForTest,
44
+ notifyChildProcessExited,
45
+ registerActiveDialogQueue,
46
+ type UiRequest,
47
+ type UiResponse,
48
+ } from "../dialog-queue.ts";
41
49
 
42
50
  // ── 类型助手 ────────────────────────────────────────────────
43
51
  // UiRequest 最小形状(method + id)。dialog 类:select/confirm/input/editor。
@@ -287,8 +295,9 @@ describe("DialogGlobalQueue — #19 单一推进点 + child close 集成语义",
287
295
  });
288
296
 
289
297
  it("child close 集成语义:queue 中同一 child 的多个 pending 被 rejectChildDialogs 一次全部 settle cancelled", async () => {
290
- // 模拟 session-runner child 'close' 事件 → dialogQueue.rejectChildDialogs(child) 清理路径。
291
- // 绑定点在 session-runner(非 dialog-queue 内部),本测试验证 rejectChildDialogs 公共方法语义。
298
+ // 模拟子进程退出事件 → dialogQueue.rejectChildDialogs(child) 清理路径。
299
+ // 生产绑定点 = 宿主侧引擎镜像层(SpawnedChildrenMirror notifyChildProcessExited,
300
+ // 见 mirror.test.ts 的接线用例);本用例聚焦 rejectChildDialogs 本身的批量语义。
292
301
  // 用 blocker 占 current(另一 child)使被测 child 的项全排队,聚焦验证 queue 批量 cancel。
293
302
  const queue = new DialogGlobalQueue();
294
303
  const blocker = { pid: 70000 };
@@ -495,3 +504,61 @@ describe("DialogGlobalQueue — reject 抢先 settle 后超时 timer 清理(A2
495
504
  expect(loggerMock.warn).not.toHaveBeenCalled();
496
505
  });
497
506
  });
507
+
508
+ describe("DialogGlobalQueue — 活跃队列登记 + 子进程退出通知(SR-4 接线)", () => {
509
+ beforeEach(() => {
510
+ vi.useFakeTimers();
511
+ });
512
+ afterEach(() => {
513
+ registerActiveDialogQueue(undefined);
514
+ vi.useRealTimers();
515
+ });
516
+
517
+ it("notifyChildProcessExited:取消该 pid 的当前项与排队项(该 pid 的 pending 全 settle cancelled)", async () => {
518
+ const queue = new DialogGlobalQueue();
519
+ registerActiveDialogQueue(queue);
520
+ const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
521
+
522
+ // 同 pid:1 项占住 current(永不 settle)+ 1 项排队
523
+ const current = queue.enqueue(dialogReq("r1"), handler, { child: { pid: 20001 } });
524
+ const queued = queue.enqueue(dialogReq("r2"), handler, { child: { pid: 20001 } });
525
+ expect(queue.size).toBe(1); // r2 仍在排队(enqueue 同步入队,current 立即置位)
526
+
527
+ notifyChildProcessExited(20001);
528
+
529
+ await expect(current).resolves.toEqual({ cancelled: true });
530
+ await expect(queued).resolves.toEqual({ cancelled: true });
531
+ expect(queue.size).toBe(0);
532
+ });
533
+
534
+ it("notifyChildProcessExited:不影响其他 pid 的挂起项", async () => {
535
+ const queue = new DialogGlobalQueue();
536
+ registerActiveDialogQueue(queue);
537
+ const handler = vi.fn((): Promise<UiResponse> => new Promise<UiResponse>(() => {}));
538
+
539
+ const other = queue.enqueue(dialogReq("r1"), handler, { child: { pid: 30001 } });
540
+
541
+ notifyChildProcessExited(30002); // 另一个 pid 退出
542
+
543
+ let settled = false;
544
+ void other.then(() => {
545
+ settled = true;
546
+ });
547
+ await vi.advanceTimersByTimeAsync(0);
548
+ expect(settled).toBe(false);
549
+ expect(queue.size).toBe(0); // 该项仍是 current,未被动过
550
+ });
551
+
552
+ it("未登记队列时 notifyChildProcessExited 为 no-op(不抛)", () => {
553
+ expect(_getActiveDialogQueueForTest()).toBeUndefined();
554
+ expect(() => notifyChildProcessExited(40001)).not.toThrow();
555
+ });
556
+
557
+ it("登记后 _getActiveDialogQueueForTest 可读;注销后回落 undefined", () => {
558
+ const queue = new DialogGlobalQueue();
559
+ registerActiveDialogQueue(queue);
560
+ expect(_getActiveDialogQueueForTest()).toBe(queue);
561
+ registerActiveDialogQueue(undefined);
562
+ expect(_getActiveDialogQueueForTest()).toBeUndefined();
563
+ });
564
+ });