@zhushanwen/subagent-core 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/dist/{chunk-VURUAGMM.js → chunk-LZJOEDGQ.js} +603 -154
  2. package/dist/engine-discovery-scan-Da4Dkzsi.d.cts +818 -0
  3. package/dist/engine-discovery-scan-Da4Dkzsi.d.ts +818 -0
  4. package/dist/execution/engine/engine-discovery-scan.cjs +325 -154
  5. package/dist/execution/engine/engine-discovery-scan.d.cts +1 -2
  6. package/dist/execution/engine/engine-discovery-scan.d.ts +1 -2
  7. package/dist/execution/engine/engine-discovery-scan.js +1 -1
  8. package/dist/execution/relay-env.js +5 -4
  9. package/dist/index.cjs +9799 -7954
  10. package/dist/index.d.cts +1896 -1360
  11. package/dist/index.d.ts +1896 -1360
  12. package/dist/index.js +8952 -7513
  13. package/dist.bundle/index.cjs +7897 -6054
  14. package/package.json +5 -5
  15. package/src/__tests__/manifest-store.test.ts +16 -15
  16. package/src/__tests__/record-store-last-line.test.ts +12 -0
  17. package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
  18. package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
  19. package/src/core/logger.ts +1 -1
  20. package/src/execution/__tests__/alive-store.test.ts +117 -1
  21. package/src/execution/__tests__/batch-finalized.test.ts +206 -0
  22. package/src/execution/__tests__/chat-engine-routing.test.ts +117 -1
  23. package/src/execution/__tests__/{cold-resurrect.test.ts → cold-lookup.test.ts} +133 -33
  24. package/src/execution/__tests__/collect-coordinator-service.test.ts +7 -5
  25. package/src/execution/__tests__/conversation-continuation.test.ts +1452 -0
  26. package/src/execution/__tests__/delivery-methods.test.ts +90 -176
  27. package/src/execution/__tests__/dialog-queue.test.ts +70 -3
  28. package/src/execution/__tests__/dispose-manifest-recovery.test.ts +475 -0
  29. package/src/execution/__tests__/execute-and-await-worktree.test.ts +3 -3
  30. package/src/execution/__tests__/execute-nesting.test.ts +8 -5
  31. package/src/execution/__tests__/execution-record.test.ts +8 -148
  32. package/src/execution/__tests__/finalize-record.test.ts +295 -129
  33. package/src/execution/__tests__/get-record-for-action-restart.test.ts +21 -29
  34. package/src/execution/__tests__/helpers/fake-engine-port.ts +13 -68
  35. package/src/execution/__tests__/helpers/legacy-sidecar.ts +33 -0
  36. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +9 -5
  37. package/src/execution/__tests__/idle-gc.test.ts +60 -0
  38. package/src/execution/__tests__/inflight-production-wiring.test.ts +223 -0
  39. package/src/execution/__tests__/lifecycle-manager.test.ts +3 -254
  40. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +43 -22
  41. package/src/execution/__tests__/model-config-service.test.ts +111 -0
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +10 -6
  43. package/src/execution/__tests__/nested-visibility.test.ts +13 -1
  44. package/src/execution/__tests__/notify-ledger.test.ts +42 -1
  45. package/src/execution/__tests__/rebuild-indexes.test.ts +329 -0
  46. package/src/execution/__tests__/record-binding.test.ts +613 -0
  47. package/src/execution/__tests__/record-origin.test.ts +318 -0
  48. package/src/execution/__tests__/record-store-intent-api.test.ts +600 -0
  49. package/src/execution/__tests__/record-store-orphan-revive.test.ts +71 -3
  50. package/src/execution/__tests__/record-store.test.ts +72 -54
  51. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +8 -5
  52. package/src/execution/__tests__/round-supervisor-workflow-origin.test.ts +205 -0
  53. package/src/execution/__tests__/run-orchestration-write-lease.test.ts +293 -0
  54. package/src/execution/__tests__/run-orchestration.test.ts +199 -0
  55. package/src/execution/__tests__/session-file-gc.test.ts +35 -4
  56. package/src/execution/__tests__/session-reconstructor.test.ts +78 -0
  57. package/src/execution/__tests__/state-marker.test.ts +288 -0
  58. package/src/execution/__tests__/stream-sink-retirement.test.ts +8 -5
  59. package/src/execution/__tests__/subagent-actions-core.test.ts +67 -16
  60. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +0 -1
  61. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -11
  62. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +17 -11
  63. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +130 -53
  64. package/src/execution/__tests__/subagent-service.test.ts +17 -215
  65. package/src/execution/__tests__/subprocess-agent-runner-no-progress-killall.test.ts +138 -0
  66. package/src/execution/__tests__/subprocess-agent-runner.test.ts +105 -660
  67. package/src/execution/__tests__/sync-collect-recovery.test.ts +23 -16
  68. package/src/execution/__tests__/terminal-write-latency.test.ts +79 -0
  69. package/src/execution/__tests__/workflow-agent-dispatch.test.ts +782 -0
  70. package/src/execution/alive-store.ts +37 -28
  71. package/src/execution/{cold-resurrect.ts → cold-lookup.ts} +48 -45
  72. package/src/execution/config.ts +1 -1
  73. package/src/execution/conversation-continuation.ts +541 -0
  74. package/src/execution/dialog-queue.ts +60 -12
  75. package/src/execution/engine/__tests__/common/capability-gate.test.ts +1 -1
  76. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +16 -1
  77. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +17 -51
  78. package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +0 -5
  79. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +1 -1
  80. package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +5 -9
  81. package/src/execution/engine/__tests__/conformance/registry-fork-filter.test.ts +55 -53
  82. package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +0 -3
  83. package/src/execution/engine/__tests__/engine-discovery.test.ts +0 -3
  84. package/src/execution/engine/__tests__/inflight-snapshot.test.ts +180 -0
  85. package/src/execution/engine/__tests__/model-prompt.test.ts +0 -3
  86. package/src/execution/engine/__tests__/registry.test.ts +0 -1
  87. package/src/execution/engine/__tests__/routing.test.ts +0 -1
  88. package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +0 -3
  89. package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +0 -3
  90. package/src/execution/engine/client/__tests__/engine-client-reap.test.ts +212 -0
  91. package/src/execution/engine/client/__tests__/mirror.test.ts +73 -1
  92. package/src/execution/engine/client/__tests__/protocol-closure.test.ts +2 -9
  93. package/src/execution/engine/client/__tests__/remote-engine.test.ts +46 -12
  94. package/src/execution/engine/client/engine-client.ts +111 -26
  95. package/src/execution/engine/client/mirror.ts +9 -0
  96. package/src/execution/engine/client/remote-engine.ts +52 -34
  97. package/src/execution/engine/client/reverse-router.ts +6 -27
  98. package/src/execution/engine/common/__tests__/run-signals.test.ts +79 -0
  99. package/src/execution/engine/common/capability-gate.ts +27 -0
  100. package/src/execution/engine/common/data-dir.ts +3 -2
  101. package/src/execution/engine/common/errors.ts +1 -1
  102. package/src/execution/engine/common/journal-wiring.ts +18 -9
  103. package/src/execution/engine/common/run-signals.ts +83 -0
  104. package/src/execution/engine/d8-compat.ts +1 -1
  105. package/src/execution/engine/host/__tests__/host-bridge.test.ts +3 -18
  106. package/src/execution/engine/host/host-bridge.ts +26 -41
  107. package/src/execution/engine/host/pi-host-binding.ts +5 -5
  108. package/src/execution/engine/host/spawned-children.ts +1 -1
  109. package/src/execution/engine/inflight-snapshot.ts +92 -0
  110. package/src/execution/engine/port.ts +52 -54
  111. package/src/execution/engine/types.ts +11 -14
  112. package/src/execution/execution-record.ts +15 -47
  113. package/src/execution/finalize-record.ts +94 -197
  114. package/src/execution/idle-gc.ts +9 -3
  115. package/src/execution/lifecycle-manager.ts +25 -361
  116. package/src/execution/lifecycle-predicates.ts +22 -19
  117. package/src/execution/manifest-store.ts +40 -42
  118. package/src/execution/model-config-service.ts +1 -1
  119. package/src/execution/notifier.ts +39 -1
  120. package/src/execution/notify-host.ts +13 -4
  121. package/src/execution/path-encoding.ts +6 -0
  122. package/src/execution/record-entry.ts +20 -2
  123. package/src/execution/record-store.ts +925 -160
  124. package/src/execution/relay-env.ts +8 -3
  125. package/src/execution/round-supervisor/domain.ts +17 -5
  126. package/src/execution/round-supervisor/service-binding.test.ts +89 -53
  127. package/src/execution/round-supervisor/service-binding.ts +71 -21
  128. package/src/execution/round-supervisor/supervisor.ts +23 -9
  129. package/src/execution/service/record-access.ts +547 -0
  130. package/src/execution/service/record-lifecycle.ts +542 -0
  131. package/src/execution/service/run-orchestration.ts +1647 -0
  132. package/src/execution/service/service-bootstrap.ts +111 -0
  133. package/src/execution/service/service-constants.ts +27 -0
  134. package/src/execution/service/session-baselines.ts +401 -0
  135. package/src/execution/service/sync-collect-domain.ts +442 -0
  136. package/src/execution/service/workflow-dispatch.ts +567 -0
  137. package/src/execution/session-file-gc.ts +11 -8
  138. package/src/execution/session-reconstructor.ts +44 -1
  139. package/src/execution/sessions-index.ts +20 -3
  140. package/src/execution/settled-watchdog.ts +86 -43
  141. package/src/execution/state-marker.ts +516 -0
  142. package/src/execution/stream-sink.ts +1 -1
  143. package/src/execution/subagent-actions-core.ts +53 -31
  144. package/src/execution/subagent-service.ts +643 -2955
  145. package/src/execution/subprocess-agent-runner.ts +46 -323
  146. package/src/execution/types.ts +60 -43
  147. package/src/execution/ui-request-handler-factory.ts +5 -0
  148. package/src/execution/worktree-manager.ts +1 -1
  149. package/src/execution/worktree-registry.ts +12 -8
  150. package/src/index.ts +16 -5
  151. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -3
  152. package/src/orchestration/__tests__/agent-call-stream.test.ts +24 -49
  153. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +4 -4
  154. package/src/orchestration/__tests__/file-run-store.test.ts +5 -7
  155. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +2 -3
  156. package/src/orchestration/__tests__/run-snapshot.test.ts +27 -43
  157. package/src/orchestration/__tests__/worker-message-pump-workflow-dispatch.test.ts +274 -0
  158. package/src/orchestration/execute-agent-call.ts +1 -1
  159. package/src/orchestration/file-run-store.ts +1 -1
  160. package/src/orchestration/models/ports.ts +20 -13
  161. package/src/orchestration/models/types.ts +11 -13
  162. package/src/orchestration/run-snapshot.ts +18 -22
  163. package/src/orchestration/worker-message-pump.ts +33 -57
  164. package/src/shared/atomic-write.ts +10 -8
  165. package/dist/chunk-T2SYBW3I.js +0 -24
  166. package/dist/engine-discovery-scan-ocpM8FmI.d.cts +0 -1608
  167. package/dist/engine-discovery-scan-ocpM8FmI.d.ts +0 -1608
  168. package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +0 -255
  169. package/src/execution/__tests__/finalized-marker.test.ts +0 -104
  170. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +0 -211
  171. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +0 -488
  172. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +0 -75
  173. package/src/execution/__tests__/tombstone-store.test.ts +0 -73
  174. package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +0 -230
  175. package/src/execution/finalized-marker.ts +0 -70
  176. package/src/execution/tombstone-store.ts +0 -72
@@ -0,0 +1,782 @@
1
+ // src/execution/__tests__/workflow-agent-dispatch.test.ts
2
+ //
3
+ // [H2 W2] executeWorkflowAgent 统一编排入口单测(设计 subagent-workflow-record-
4
+ // unification.md §3.4 错误规格 / §3.5 终态数据流 / D3 池顺序 / D4 守护 / D6 通知
5
+ // gate / D7 成功收口 / adopt 豁免双点)。
6
+ //
7
+ // 锁六组面:
8
+ // 1. 注册面:record 带 origin:"workflow" + parentRunId;record 级
9
+ // pending:register/unregister 配对(D5:record 级照旧)。
10
+ // 2. 池顺序(D3):路由/预检失败先于池 acquire——零池占用 + 同步抛错 + 零孤儿 record。
11
+ // 3. 守护(D4/M3 复刻):arm 键 = record.id(ctx.taskId 即守护键);双刷新源
12
+ // (journal.onEvent 包装 ∪ stream.onDelta 包装);fire 后失败结果追注恢复指引。
13
+ // 4. D7 收口 CAS 规格:成功 → closed/gc 立即终态化 + archive;close 路径抢先
14
+ // (user-close 已写 closedReason)→ 静默跳过不覆盖(memory closedReason 不分叉);
15
+ // aborted+success 竞态 → 落 cancelled 分支不漂移;origin=tool 负向对照
16
+ // (executeAndAwait 成功仍 SP-5 running-resumable,D7 零外溢)。
17
+ // 5. D6 gate:workflow record 完成/失败/关闭不产生任何回注通知(toNotifyRecord
18
+ // 返回 undefined + pi.sendMessage 零调用)。
19
+ // 6. 引擎死亡(§3.4):engine_crashed → catch 合成 failed result 回脚本(swallow)
20
+ // + record 由失败路径立即终态化(不 adopt);service 分诊两处豁免(runEngineTask
21
+ // catch / finalizeEngineOutcome exitCode===null)对 workflow record 落空即终态化。
22
+ // 7. [H2 W3 must-fix] stream 缺省自构:runWorkflowEngineTask 在 stream 实参缺省时
23
+ // 经 createBackgroundStream 自构(kickOffChatRound 同款策略)——三形态(TUI
24
+ // widget 接通 / GUI+relay 停发私货 / sink 未注入降级 no-op)+ 内构对象的守护
25
+ // 刷新源(bindWorkflowStreamRefresh 包裹 onDelta)仍生效。
26
+ //
27
+ // 替身形态:pi EnginePort = registerFakePiEngine(协议 seam 替身);受限引擎
28
+ // (strict-engine)本地构造(预检命中用);pi = mock(appendEntry 捕获 subagent-record
29
+ // entry、events.emit 捕获 pending 注册/注销、sendMessage 零调用断言)。journal 落盘
30
+ // 经 XYZ_AGENT_DATA_DIR 指到 tmpdir(测试红线:不触真实数据目录);守护窗长经
31
+ // _setMidRoundNoProgressWindowMsForTest 注入(秒级,fake timers 驱动)。
32
+
33
+ import * as fs from "node:fs";
34
+ import * as os from "node:os";
35
+ import * as path from "node:path";
36
+
37
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
38
+
39
+ const { loggerMock } = vi.hoisted(() => ({
40
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
41
+ }));
42
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
43
+
44
+ import { EngineSdkError } from "@zhushanwen/subagent-engine-sdk";
45
+ import { tryTransition } from "../execution-record.ts";
46
+ import { createRecord } from "../execution-record.ts";
47
+ import { createNotifyHost } from "../notify-host.ts";
48
+ import { ModelConfigService } from "../model-config-service.ts";
49
+ import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
50
+ import type { RecordStore } from "../record-store.ts";
51
+ import { SubagentStream } from "../stream-sink.ts";
52
+ import { SubagentService } from "../subagent-service.ts";
53
+ import type { PiLike } from "../subagent-service.ts";
54
+ import type { AgentCallOpts, AgentResult } from "../../orchestration/models/types.ts";
55
+ import type { SubagentRecordEntryData } from "../record-entry.ts";
56
+ import { SUBAGENT_RECORD_CUSTOM_TYPE } from "../record-entry.ts";
57
+ import {
58
+ _resetSettledWatchdogsForTest,
59
+ _setMidRoundNoProgressWindowMsForTest,
60
+ armMidRoundNoProgress,
61
+ getMidRoundNoProgressWindowMs,
62
+ hasSettledWatchdog,
63
+ isSettledWatchdogDisabled,
64
+ SETTLED_MID_ROUND_NO_PROGRESS_MS,
65
+ SETTLED_WATCHDOG_ENV,
66
+ } from "../settled-watchdog.ts";
67
+ import { resetCoreForTests } from "../../core/host-services.ts";
68
+ import { clearEngines, registerEngine } from "../engine/registry.ts";
69
+ import type { EngineCapabilities } from "../engine/types.ts";
70
+ import type { EnginePort } from "../engine/port.ts";
71
+ import type { ExecutionRecord } from "../types.ts";
72
+ import { registerFakePiEngine, type FakePiEnginePort, type FakeRun } from "./helpers/fake-engine-port.ts";
73
+
74
+ // ── 辅助:service 构造(notify-gate / routing 测试同款范式)──
75
+
76
+ function makeEmptyRegistry(): ModelRegistryLike {
77
+ return { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true };
78
+ }
79
+
80
+ const ctxModel: ModelInfo = { id: "m", name: "M", provider: "p", reasoning: false };
81
+
82
+ function makePi() {
83
+ return {
84
+ appendEntry: vi.fn(),
85
+ events: { emit: vi.fn() },
86
+ sendMessage: vi.fn(),
87
+ };
88
+ }
89
+
90
+ type MockPi = ReturnType<typeof makePi>;
91
+
92
+ interface DispatchHarness {
93
+ service: SubagentService;
94
+ store: RecordStore;
95
+ pi: MockPi;
96
+ fake: FakePiEnginePort;
97
+ entries: SubagentRecordEntryData[];
98
+ tmpRoot: string;
99
+ }
100
+
101
+ function makeHarness(opts: {
102
+ /** [H2 W3 must-fix] streamSink 注入(TUI/未激活形态的 widget sink;缺省 null = 无 UI)。 */
103
+ streamSink?: { setWidget: (key: string, lines: string[] | undefined) => void };
104
+ /** [H2 W3 must-fix] 宿主形态(createBackgroundStream 的 GUI 判定输入;缺省 undefined)。 */
105
+ mode?: "tui" | "rpc";
106
+ } = {}): DispatchHarness {
107
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wf-dispatch-it-"));
108
+ process.env.XYZ_AGENT_DATA_DIR = path.join(tmpRoot, "engine-data");
109
+ const agentDir = path.join(tmpRoot, "agent");
110
+ const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
111
+ modelService.initModel({
112
+ modelRegistry: makeEmptyRegistry(),
113
+ sessionId: "wf-dispatch-it",
114
+ ctxModel,
115
+ });
116
+ const service = new SubagentService({ cwd: agentDir, modelService });
117
+ const pi = makePi();
118
+ const entries: SubagentRecordEntryData[] = [];
119
+ pi.appendEntry.mockImplementation((customType: string, data: unknown) => {
120
+ if (customType === SUBAGENT_RECORD_CUSTOM_TYPE) entries.push(data as SubagentRecordEntryData);
121
+ });
122
+ service.initSession({
123
+ pi: pi as unknown as PiLike,
124
+ sessionId: "wf-dispatch-it",
125
+ ...(opts.streamSink !== undefined ? { streamSink: opts.streamSink } : {}),
126
+ ...(opts.mode !== undefined ? { mode: opts.mode } : {}),
127
+ });
128
+ clearEngines();
129
+ const fake = registerFakePiEngine();
130
+ return { service, store: Reflect.get(service, "store") as RecordStore, pi, fake, entries, tmpRoot };
131
+ }
132
+
133
+ function baseOpts(over: Partial<AgentCallOpts> = {}): AgentCallOpts {
134
+ return { prompt: "调研 A", description: "research-a", ...over };
135
+ }
136
+
137
+ /** 微任务冲刷:dispatch 的 acquire/engine.run 链路是 await 链,flush 后 run 才进 fake。 */
138
+ async function flush(): Promise<void> {
139
+ await new Promise<void>((resolve) => setImmediate(resolve));
140
+ }
141
+
142
+ /** 最近一次 run 捕获(多次 dispatch 共用 fake 时取最后一个)。 */
143
+ function lastRun(fake: FakePiEnginePort): FakeRun {
144
+ const run = fake.runs.at(-1);
145
+ expect(run).toBeDefined();
146
+ return run!;
147
+ }
148
+
149
+ /** 唯一在途 run(首次 dispatch 后必须恰好一个)。 */
150
+ function soleRun(fake: FakePiEnginePort): FakeRun {
151
+ expect(fake.runs).toHaveLength(1);
152
+ return fake.runs[0]!;
153
+ }
154
+
155
+ /** 从 store 取内存 running record(getMutable 公共面)。 */
156
+ function runningRecord(store: RecordStore): ExecutionRecord {
157
+ const running = store.listRunning();
158
+ expect(running).toHaveLength(1);
159
+ return store.getMutable(running[0]!.id)!;
160
+ }
161
+
162
+ /** 受限引擎能力位(worktree/conversation/maxTurns/fork 全不支持——预检命中用)。 */
163
+ const STRICT_CAPS: EngineCapabilities = {
164
+ schemaEnforcement: "native",
165
+ steer: "unsupported",
166
+ conversation: "unsupported",
167
+ personaInjection: "flag",
168
+ eventGranularity: "stream",
169
+ sandbox: "none",
170
+ sessionRead: "full",
171
+ resume: "unsupported",
172
+ interrupt: "kill-only",
173
+ permissionMode: "native",
174
+ maxTurns: false,
175
+ };
176
+
177
+ /** 受限引擎注册(池顺序用例:worktree:true 命中 gate)。 */
178
+ function registerStrictEngine(): void {
179
+ const port: EnginePort = {
180
+ id: "strict-engine",
181
+ capabilities: () => STRICT_CAPS,
182
+ probe: async () => ({ ok: true, engineVersion: "fake-strict", checks: [{ name: "invocation", ok: true, detail: "fake" }] }),
183
+ run: () => new Promise(() => {}),
184
+ read: async () => ({ engineId: "strict-engine", turns: [], source: "outcome-only" }),
185
+ };
186
+ registerEngine("strict-engine", () => port);
187
+ }
188
+
189
+ let prevDataDirEnv: string | undefined;
190
+
191
+ beforeEach(() => {
192
+ prevDataDirEnv = process.env["XYZ_AGENT_DATA_DIR"];
193
+ });
194
+
195
+ afterEach(() => {
196
+ _resetSettledWatchdogsForTest();
197
+ vi.restoreAllMocks();
198
+ if (prevDataDirEnv === undefined) delete process.env["XYZ_AGENT_DATA_DIR"];
199
+ else process.env["XYZ_AGENT_DATA_DIR"] = prevDataDirEnv;
200
+ });
201
+
202
+ // ============================================================
203
+ // 1. 注册面(origin/parentRunId + pending:register 配对)
204
+ // ============================================================
205
+
206
+ describe("executeWorkflowAgent 注册面", () => {
207
+ it("record 带 origin=workflow + parentRunId,pending:register/unregister 配对,taskId 即 record id", async () => {
208
+ const { service, store, pi, fake } = makeHarness();
209
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-42");
210
+ await flush();
211
+ const run = soleRun(fake);
212
+ const record = runningRecord(store);
213
+
214
+ expect(record.origin).toBe("workflow");
215
+ expect(record.parentRunId).toBe("run-42");
216
+ // 守护/journal/spawned-children 的键 = record.id(SAR 占位 taskId 消亡)
217
+ expect(run.ctx.taskId).toBe(record.id);
218
+ // record 级 pending:register 照旧(与既有派发路径同款)
219
+ const registered = pi.events.emit.mock.calls.find((c) => c[0] === "pending:register");
220
+ expect(registered?.[1]).toMatchObject({ id: record.id, type: "subagent" });
221
+ // parentRunId 查询面(W1)命中
222
+ expect(service.queries.collectRecordsByParentRunId("run-42", 10).map((r) => r.id)).toEqual([record.id]);
223
+
224
+ run.settle({ content: "done" });
225
+ await pending;
226
+ // 终态化 → record 级注销(D5:record 级配对照旧)
227
+ const unregistered = pi.events.emit.mock.calls.find((c) => c[0] === "pending:unregister");
228
+ expect(unregistered?.[1]).toMatchObject({ id: record.id });
229
+ });
230
+ });
231
+
232
+ // ============================================================
233
+ // 2. 池顺序(D3:路由/预检先于池 acquire——失败零池占用)
234
+ // ============================================================
235
+
236
+ describe("executeWorkflowAgent 池顺序", () => {
237
+ it("预检命中(不支持的 task shape)→ 同步抛错 + 池 acquire 未被调 + 零孤儿 record", async () => {
238
+ const { service, store, pi } = makeHarness();
239
+ registerStrictEngine();
240
+ const pool = Reflect.get(service, "pool") as { acquire: (p: number, m?: number, s?: AbortSignal) => Promise<void> };
241
+ const acquireSpy = vi.spyOn(pool, "acquire");
242
+
243
+ await expect(
244
+ service.executeWorkflowAgent(baseOpts({ engine: "strict-engine", worktree: true }), "run-1"),
245
+ ).rejects.toThrow(/engine_capability_unsupported/);
246
+
247
+ expect(acquireSpy).not.toHaveBeenCalled(); // 零池占用(D3)
248
+ expect(store.listRunning()).toHaveLength(0); // 零孤儿 record
249
+ expect(pi.events.emit).not.toHaveBeenCalledWith("pending:register", expect.anything());
250
+ });
251
+
252
+ it("池排队中被 abort → run 域 cancelled 收口(acquirePoolOrFinalize S1 同款分支)", async () => {
253
+ const { service, store, fake, entries } = makeHarness();
254
+ // 占满共享池(maxConcurrent 槽全占)→ 新派发排队;run 级 signal abort → cancelled 收口
255
+ const pool = Reflect.get(service, "pool") as {
256
+ maxConcurrent: number;
257
+ acquire(p: number, m?: number, s?: AbortSignal): Promise<void>;
258
+ release(): void;
259
+ };
260
+ for (let i = 0; i < pool.maxConcurrent; i++) await pool.acquire(0);
261
+
262
+ const controller = new AbortController();
263
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-2", controller.signal);
264
+ await flush();
265
+ expect(fake.runs).toHaveLength(0); // 仍在排队(未触达 engine.run)
266
+ const record = runningRecord(store);
267
+ expect(record.status).toBe("running");
268
+
269
+ controller.abort();
270
+ const result = await pending;
271
+ expect(result.error).toContain("cancelled"); // 合成 failed result 回脚本
272
+ expect(record.status).toBe("closed");
273
+ expect(record.closedReason).toBe("cancelled"); // run 域 cancelled 收口
274
+ expect(entries.at(-1)).toMatchObject({ id: record.id, closedReason: "cancelled" });
275
+ expect(fake.runs).toHaveLength(0); // 零引擎副作用
276
+ });
277
+ });
278
+
279
+ // ============================================================
280
+ // 3. 守护(D4/M3 复刻:arm 键 = record.id + 双刷新源 + fire 追注)
281
+ // ============================================================
282
+
283
+ describe("executeWorkflowAgent no-progress 守护", () => {
284
+ beforeEach(() => {
285
+ vi.useFakeTimers();
286
+ _setMidRoundNoProgressWindowMsForTest(5000);
287
+ });
288
+ afterEach(() => {
289
+ vi.useRealTimers();
290
+ });
291
+
292
+ it("arm 键 = record.id;协议事件(journal.onEvent 包装)刷新窗口;fire 后失败结果追注恢复指引", async () => {
293
+ const { service, store, fake } = makeHarness();
294
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-1");
295
+ await vi.advanceTimersByTimeAsync(0);
296
+ const run = soleRun(fake);
297
+ const record = runningRecord(store);
298
+ expect(run.ctx.taskId).toBe(record.id); // arm 键 = record.id(非占位 taskId)
299
+
300
+ // 窗内协议事件(经 observedEvent 包装 → refreshFromProtocolEvent(record.id))刷新窗口
301
+ await vi.advanceTimersByTimeAsync(3000);
302
+ run.emitEvent({ type: "message_start" });
303
+ await vi.advanceTimersByTimeAsync(3000); // 越过原始 5000ms 窗(已刷新 → 不 fire)
304
+ expect(run.ctx.signal?.aborted).toBe(false);
305
+
306
+ // 无后续事件 → 新窗口到期 fire → abort 送达 engine ctx.signal
307
+ await vi.advanceTimersByTimeAsync(5000);
308
+ expect(run.ctx.signal?.aborted).toBe(true);
309
+
310
+ // fire 后引擎合成失败 outcome → 失败结果追注恢复指引(fire 追注语义,
311
+ // 原 SAR noteIfNoProgressFired 符号随编排归一消亡——见设计 D4)
312
+ run.settle({ error: "engine: aborted" });
313
+ const result = await pending;
314
+ expect(result.error).toContain("engine: aborted");
315
+ expect(result.error).toContain("workflow no-progress watchdog fired");
316
+ // 恢复指引配对口径(与 message-guard 文案族同款):fire 后 record 必然终态,
317
+ // 只查 running 得空列表——必须 includeFinished:true + includeWorkflow:true 成对
318
+ expect(result.error).toContain("includeFinished:true");
319
+ expect(result.error).toContain("(add includeWorkflow:true to also see workflow-dispatched subagents)");
320
+ });
321
+
322
+ it("stream.onDelta 同为刷新源(双源缺一不可——纯流式产出也保活)", async () => {
323
+ const { service, fake } = makeHarness();
324
+ const stream = new SubagentStream("wf-stream-key", { setWidget: () => {} });
325
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-1", undefined, undefined, stream);
326
+ await vi.advanceTimersByTimeAsync(0);
327
+ const run = soleRun(fake);
328
+
329
+ await vi.advanceTimersByTimeAsync(3000);
330
+ run.emitDelta("chunk"); // stream 反向帧(不经 journal)→ 绑定包装刷新
331
+ await vi.advanceTimersByTimeAsync(3000); // 越过原始窗口(已刷新 → 不 fire)
332
+ expect(run.ctx.signal?.aborted).toBe(false);
333
+
334
+ await vi.advanceTimersByTimeAsync(5000); // 无后续 delta → fire
335
+ expect(run.ctx.signal?.aborted).toBe(true);
336
+ run.settle({ error: "engine: aborted" });
337
+ const result = await pending;
338
+ expect(result.error).toContain("workflow no-progress watchdog fired");
339
+ });
340
+
341
+ it("内构 stream(实参缺省)同样保活:emitDelta 经 bindWorkflowStreamRefresh 刷新守护", async () => {
342
+ // [H2 W3 must-fix] runWorkflowEngineTask 在 stream 实参缺省时自构
343
+ // createBackgroundStream——内构对象的 onDelta 同样被 bindWorkflowStreamRefresh
344
+ // 原地包裹(守护双刷新源之二),且 widget flush 与刷新在同一调用点生效。
345
+ const sink = { setWidget: vi.fn() };
346
+ const { service, fake } = makeHarness({ streamSink: sink });
347
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-stream-self");
348
+ await vi.advanceTimersByTimeAsync(0);
349
+ const run = soleRun(fake);
350
+
351
+ await vi.advanceTimersByTimeAsync(3000);
352
+ run.emitDelta("chunk"); // 反向帧 → 内构 stream.onDelta(已包裹)→ 刷新
353
+ await vi.advanceTimersByTimeAsync(3000); // 越过原始窗口(已刷新 → 不 fire)
354
+ expect(run.ctx.signal?.aborted).toBe(false);
355
+
356
+ run.settle({ content: "done" });
357
+ const result = await pending;
358
+ expect(result.content).toBe("done");
359
+ });
360
+ });
361
+
362
+ // ============================================================
363
+ // 3.5 [H2 W3 must-fix] stream 缺省自构(createBackgroundStream 三形态)
364
+ // ============================================================
365
+
366
+ describe("executeWorkflowAgent stream 自构(设计 D2「service 派发路径既有通道承载」)", () => {
367
+ afterEach(() => {
368
+ // relay 三键注入的清理(防泄漏到同文件其他用例)
369
+ delete process.env["XYZ_SUBAGENT_RELAY_SOCKET"];
370
+ delete process.env["XYZ_SUBAGENT_RELAY_NODE"];
371
+ delete process.env["XYZ_SUBAGENT_RELAY_SCRIPT"];
372
+ });
373
+
374
+ it("TUI 形态:内构 stream 接通 widget——emitDelta 流式可见 + settle 后 dispose 清除", async () => {
375
+ const sink = { setWidget: vi.fn() };
376
+ const { service, store, fake } = makeHarness({ streamSink: sink, mode: "tui" });
377
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-stream-tui");
378
+ await flush();
379
+ const run = soleRun(fake);
380
+ const record = runningRecord(store);
381
+
382
+ run.emitDelta("hello "); // leading edge:首个 delta 立即 flush(无 timer 等待)
383
+ run.emitDelta("world");
384
+ const widgetKey = `subagent-stream-${record.id}`;
385
+ expect(sink.setWidget).toHaveBeenCalledWith(widgetKey, ["hello "]);
386
+ // 第二个 delta 走 trailing 合并窗(100ms timer)——推进后 flush 累积文本
387
+ await new Promise<void>((resolve) => setTimeout(resolve, 150));
388
+ expect(sink.setWidget).toHaveBeenCalledWith(widgetKey, ["hello world"]);
389
+
390
+ run.settle({ content: "done" });
391
+ await pending;
392
+ // dispose 清除 widget(releaseRoundResources → stream.dispose)
393
+ expect(sink.setWidget).toHaveBeenLastCalledWith(widgetKey, undefined);
394
+ });
395
+
396
+ it("GUI + relay 激活:停发私货(createBackgroundStream H1 策略)——sink 零调用", async () => {
397
+ process.env["XYZ_SUBAGENT_RELAY_SOCKET"] = "/tmp/relay.sock";
398
+ process.env["XYZ_SUBAGENT_RELAY_NODE"] = "node-1";
399
+ process.env["XYZ_SUBAGENT_RELAY_SCRIPT"] = "/tmp/relay.mjs";
400
+ const sink = { setWidget: vi.fn() };
401
+ const { service, fake } = makeHarness({ streamSink: sink, mode: "rpc" });
402
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-stream-gui");
403
+ await flush();
404
+ const run = soleRun(fake);
405
+
406
+ run.emitDelta("should-not-ship");
407
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
408
+ expect(sink.setWidget).not.toHaveBeenCalled();
409
+
410
+ run.settle({ content: "done" });
411
+ const result = await pending;
412
+ expect(result.content).toBe("done"); // 停发不影响执行本体
413
+ });
414
+
415
+ it("sink 未注入(无 UI):降级 undefined no-op——dispatch 正常完成不崩", async () => {
416
+ const { service, fake } = makeHarness(); // 无 streamSink
417
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-stream-nosink");
418
+ await flush();
419
+ const run = soleRun(fake);
420
+
421
+ run.emitDelta("no-op"); // ctx.stream undefined → emitDelta 经 fake 的 ctx.stream?. 判空
422
+ run.settle({ content: "done" });
423
+ const result = await pending;
424
+ expect(result.content).toBe("done");
425
+ });
426
+ });
427
+
428
+ // ============================================================
429
+ // 4. D7 收口 CAS 规格
430
+ // ============================================================
431
+
432
+ describe("executeWorkflowAgent D7 成功收口", () => {
433
+ it("成功 → closed/gc 立即终态化 + archive(非 SP-5 running-idle)+ 字段保真回脚本", async () => {
434
+ const { service, store, fake, entries, tmpRoot } = makeHarness();
435
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-7");
436
+ await flush();
437
+ const run = soleRun(fake);
438
+ const record = runningRecord(store);
439
+
440
+ // [H4/U5 适配] sessionFile 必须是真实可写路径(mkdtemp 下)——假路径触发
441
+ // writeFinalizedState ENOENT 重试耗尽 → markFinalized false 不 archive,
442
+ // 旧断言(终态化 + archive)随终态原语同步写权威化而失效。
443
+ fs.mkdirSync(path.join(tmpRoot, "wf"), { recursive: true });
444
+ const sessionFile = path.join(tmpRoot, "wf", "session.jsonl");
445
+ run.settle({
446
+ content: "done",
447
+ sessionFile,
448
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0.1, contextTokens: 15, turns: 2 },
449
+ });
450
+ const result: AgentResult = await pending;
451
+
452
+ // 回脚本字段保真(outcome 直映射:usage/sessionFile 不丢)
453
+ expect(result.content).toBe("done");
454
+ expect(result.sessionFile).toBe(sessionFile);
455
+ expect(result.usage?.turns).toBe(2);
456
+ // D7:成功即终态化 closed/gc + archive 出内存(不再 SP-5 running-resumable)
457
+ expect(record.status).toBe("closed");
458
+ expect(record.closedReason).toBe("gc");
459
+ expect(store.getMutable(record.id)).toBeUndefined();
460
+ // 终态 entry(持久化链)携带 origin/closedReason
461
+ const finalEntry = entries.at(-1);
462
+ expect(finalEntry).toMatchObject({ id: record.id, status: "closed", closedReason: "gc", origin: "workflow" });
463
+ });
464
+
465
+ it("close 路径抢先(user-close 已写 closedReason)→ 静默跳过不覆盖(memory closedReason 不分叉)", async () => {
466
+ const { service, store, fake, entries } = makeHarness();
467
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-8");
468
+ await flush();
469
+ // 基线在 register 之后取(register 自身落一条 entry——record-store 迁移点内置)
470
+ const entriesBefore = entries.length;
471
+ const run = soleRun(fake);
472
+ const record = runningRecord(store);
473
+
474
+ // 模拟 close 路径赢家(closeChatIdle 终态写点的抢先形态:closed + user-close)
475
+ expect(tryTransition(record, "closed", "user-close")).toBe(true);
476
+
477
+ run.settle({ content: "done" });
478
+ await pending;
479
+
480
+ // D7 分支抢锁失败 → 静默跳过:closedReason 不被 "gc" 覆盖,无二次终态 entry
481
+ expect(record.closedReason).toBe("user-close");
482
+ expect(record.status).toBe("closed");
483
+ expect(entries.length).toBe(entriesBefore);
484
+ });
485
+
486
+ it("aborted+success 竞态 → 照旧落现有 cancelled 分支(不漂移为 gc)", async () => {
487
+ const { service, store, fake, entries } = makeHarness();
488
+ const controller = new AbortController();
489
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-9", controller.signal);
490
+ await flush();
491
+ const run = soleRun(fake);
492
+ const record = runningRecord(store);
493
+
494
+ controller.abort(); // 外部 run 级 abort 抢先(run 仍以 success 应答的竞态边缘)
495
+ run.settle({ content: "done" });
496
+ const result = await pending;
497
+
498
+ expect(record.status).toBe("closed");
499
+ expect(record.closedReason).toBe("cancelled"); // 不漂移为 "gc"(D7 条件含 !aborted)
500
+ const finalEntry = entries.at(-1);
501
+ expect(finalEntry).toMatchObject({ id: record.id, closedReason: "cancelled" });
502
+ expect(result.content).toBe("done");
503
+ });
504
+
505
+ it("origin=tool 负向对照:executeAndAwait 成功仍 SP-5 running-resumable(D7 零外溢)", async () => {
506
+ const { service, store, fake } = makeHarness();
507
+ const pending = service.executeAndAwait({ task: "tool one-shot", slug: "tool-shot", ctxModel });
508
+ await flush();
509
+ const run = soleRun(fake);
510
+ run.settle({ content: "ok" });
511
+ const result = await pending;
512
+
513
+ const record = store.getMutable(result.sessionId ?? "");
514
+ expect(record).toBeDefined();
515
+ expect(record!.status).toBe("running"); // SP-5:等 message 升级,不终态化
516
+ expect(record!.resumable).toBe(true);
517
+ expect(record!.origin).toBeUndefined();
518
+ });
519
+ });
520
+
521
+ // ============================================================
522
+ // 4.5 [H2 A3] live usage 喂入(fake engine 事件 → record.totalTokens/turns 保真)
523
+ // ============================================================
524
+
525
+ describe("executeWorkflowAgent live usage 喂入(H2 A3)", () => {
526
+ it("fake engine message_end(usage) 事件 → live record 实时累积,终态 entry 落盘保真", async () => {
527
+ const { service, store, fake, entries } = makeHarness();
528
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-usage");
529
+ await flush();
530
+ const run = soleRun(fake);
531
+
532
+ // 引擎协议事件:message_end 携带 usage(pi-subagent-cli spawn-event-translator
533
+ // accumulateMessageEnd 的协议产物形态),turn_end 闭合 turn。
534
+ run.emitEvent({ type: "message_end", usage: { input: 100, output: 50, cacheRead: 20, cacheWrite: 10, cost: 0.5 } });
535
+ run.emitEvent({ type: "text_delta", delta: "working" });
536
+ run.emitEvent({ type: "message_end", usage: { input: 7, output: 3, cacheRead: 0, cacheWrite: 0 } });
537
+ run.emitEvent({ type: "turn_end" });
538
+
539
+ // live record 实时累积(Gate B A3 现象面:agent 实际消耗 LLM 而 record 恒 0)
540
+ const record = runningRecord(store);
541
+ expect(record.totalTokens).toBe(190); // (100+50+20+10) + (7+3)
542
+ expect(record.turnCount).toBe(1);
543
+
544
+ // 终态 entry(持久化链)保真:list / 重启重建源读到非零 tokens
545
+ run.settle({ content: "done" });
546
+ await pending;
547
+ const finalEntry = entries.at(-1);
548
+ expect(finalEntry).toMatchObject({ id: record.id, status: "closed", totalTokens: 190, turns: 1 });
549
+ });
550
+
551
+ it("error 事件 → lastError 记录(reducer 喂入不破坏失败语义)", async () => {
552
+ const { service, store, fake, entries } = makeHarness();
553
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-usage-err");
554
+ await flush();
555
+ const run = soleRun(fake);
556
+
557
+ run.emitEvent({ type: "error", message: "boom" });
558
+ run.settle({ content: "", error: "boom" });
559
+ await pending;
560
+
561
+ // 失败收口不受喂入影响(终态 entry 正常落盘)
562
+ const finalEntry = entries.at(-1);
563
+ expect(finalEntry).toBeDefined();
564
+ expect(finalEntry!.status).toBe("closed");
565
+ expect(store.getMutable(finalEntry!.id)).toBeUndefined();
566
+ });
567
+ });
568
+
569
+ // ============================================================
570
+ // 5. D6 gate(workflow 回注全静默)
571
+ // ============================================================
572
+
573
+ describe("D6 toNotifyRecord origin gate", () => {
574
+ it("漏斗层单点:workflow record 完成/失败/关闭全返回 undefined(notifyComplete 零投递)", () => {
575
+ const pi = makePi();
576
+ const host = createNotifyHost({
577
+ getPi: () => pi as unknown as PiLike,
578
+ listRunning: () => [],
579
+ getIsIdle: () => undefined,
580
+ });
581
+
582
+ const base = createRecord("sa-d6", {
583
+ agent: "worker",
584
+ model: "m",
585
+ mode: "background",
586
+ task: "t",
587
+ slug: "d6",
588
+ startedAt: Date.now(),
589
+ });
590
+ const closedWf = { ...base, origin: "workflow" as const, status: "closed" as const };
591
+ const resumableWf = { ...base, origin: "workflow" as const, resumable: true };
592
+ expect(host.toNotifyRecord(closedWf)).toBeUndefined();
593
+ expect(host.toNotifyRecord(resumableWf)).toBeUndefined();
594
+ host.notifyComplete(closedWf);
595
+ host.notifyComplete(resumableWf);
596
+ expect(pi.sendMessage).not.toHaveBeenCalled();
597
+
598
+ // 对照:tool 来源 closed record 正常产通知(gate 仅 workflow)
599
+ expect(tryTransition(base, "closed", "gc")).toBe(true);
600
+ expect(host.toNotifyRecord(base)).toBeDefined();
601
+ });
602
+
603
+ it("service 级:executeWorkflowAgent 成功/引擎死亡全程 pi.sendMessage 零调用", async () => {
604
+ const { service, fake, pi } = makeHarness();
605
+ const success = service.executeWorkflowAgent(baseOpts(), "run-10");
606
+ await flush();
607
+ lastRun(fake).settle({ content: "done" });
608
+ await success;
609
+
610
+ const death = service.executeWorkflowAgent(baseOpts(), "run-11");
611
+ await flush();
612
+ lastRun(fake).fail(new EngineSdkError("engine_crashed", "engine process died", "re-dispatch the workflow"));
613
+ const deathResult = await death;
614
+ expect(deathResult.error).toContain("engine_crashed");
615
+
616
+ expect(pi.sendMessage).not.toHaveBeenCalled(); // D6:完成/失败回注全静默
617
+ });
618
+ });
619
+
620
+ // ============================================================
621
+ // 6. 引擎死亡 + adopt 豁免双点(service 分诊)
622
+ // ============================================================
623
+
624
+ describe("引擎死亡与 adopt 豁免(§3.4 + 决策表)", () => {
625
+ it("engine_crashed → 合成 failed result 回脚本(swallow)+ record 立即终态化(不 adopt)", async () => {
626
+ const { service, store, fake, entries } = makeHarness();
627
+ const pending = service.executeWorkflowAgent(baseOpts(), "run-12");
628
+ await flush();
629
+ const run = soleRun(fake);
630
+ const record = runningRecord(store);
631
+
632
+ run.fail(new EngineSdkError("engine_crashed", "engine process died", "re-dispatch the workflow"));
633
+ const result = await pending; // 不 reject——脚本观察到失败结果
634
+
635
+ expect(result.error).toContain("engine_crashed");
636
+ expect(result.content).toBe("");
637
+ // record 由失败路径立即终态化(closed/gc + archive),不保持 resumable 交监督器
638
+ expect(record.status).toBe("closed");
639
+ expect(record.closedReason).toBe("gc");
640
+ expect(store.getMutable(record.id)).toBeUndefined();
641
+ expect(entries.at(-1)).toMatchObject({ id: record.id, status: "closed", closedReason: "gc" });
642
+ const supervisor = Reflect.get(service, "roundSupervisor") as { supervisedIds(): string[] };
643
+ expect(supervisor.supervisedIds()).toEqual([]);
644
+ });
645
+
646
+ it("豁免点一(runEngineTask catch):workflow record 不 adopt → finalizeFailed 立即终态化;tool 对照照常 adopt", async () => {
647
+ const { service, store } = makeHarness();
648
+ const supervisor = Reflect.get(service, "roundSupervisor") as { supervisedIds(): string[] };
649
+ // [R4 深绑改写] runEngineTask 本体已迁 RunOrchestration 聚合(service/
650
+ // run-orchestration.ts)——bracket 路径改经聚合实例(断言对象与强度不变)。
651
+ const runEngineTask = (
652
+ Reflect.get(Reflect.get(service, "runOrchestration"), "runEngineTask") as (
653
+ record: ExecutionRecord,
654
+ opts: { task: string; slug: string },
655
+ engine: EnginePort,
656
+ signal: AbortSignal | undefined,
657
+ ) => Promise<boolean>
658
+ ).bind(Reflect.get(service, "runOrchestration"));
659
+ const deadEngine: EnginePort = {
660
+ id: "pi",
661
+ capabilities: () => ({ ...STRICT_CAPS, conversation: "native", sandbox: "emulated", resume: "native", maxTurns: true }),
662
+ probe: async () => ({ ok: true, engineVersion: "x", checks: [] }),
663
+ run: () => Promise.reject(new EngineSdkError("engine_crashed", "died", "retry")),
664
+ read: async () => ({ engineId: "pi", turns: [], source: "outcome-only" }),
665
+ };
666
+
667
+ const wfRecord: ExecutionRecord = {
668
+ ...createRecord("sa-triage-wf", {
669
+ agent: "worker", model: "m", mode: "background", task: "t", slug: "s", startedAt: Date.now(),
670
+ }),
671
+ origin: "workflow",
672
+ parentRunId: "run-13",
673
+ };
674
+ store.register(wfRecord);
675
+ const adopted = await runEngineTask(wfRecord, { task: "t", slug: "s" }, deadEngine, undefined);
676
+ expect(adopted).toBe(false); // 豁免:不走接管分支
677
+ expect(wfRecord.status).toBe("closed"); // 落空 → finalizeFailed 立即终态化
678
+ expect(wfRecord.closedReason).toBe("gc");
679
+ expect(wfRecord.resumable).toBeUndefined();
680
+ expect(supervisor.supervisedIds()).toEqual([]);
681
+
682
+ // 对照:origin=tool 同形态照常 adopt(保持 resumable 交监督器)
683
+ const toolRecord = createRecord("sa-triage-tool", {
684
+ agent: "worker", model: "m", mode: "background", task: "t", slug: "s", startedAt: Date.now(),
685
+ });
686
+ store.register(toolRecord);
687
+ const adoptedTool = await runEngineTask(toolRecord, { task: "t", slug: "s" }, deadEngine, undefined);
688
+ expect(adoptedTool).toBe(true);
689
+ expect(toolRecord.resumable).toBe(true);
690
+ expect(toolRecord.status).toBe("running");
691
+ expect(supervisor.supervisedIds()).toEqual(["sa-triage-tool"]);
692
+ });
693
+
694
+ it("豁免点二(finalizeEngineOutcome exitCode===null):workflow record 不 adopt → 正常终态化", async () => {
695
+ const { service, store } = makeHarness();
696
+ const supervisor = Reflect.get(service, "roundSupervisor") as { supervisedIds(): string[] };
697
+ // [R4 深绑改写] finalizeEngineOutcome 本体已迁 RunOrchestration 聚合(豁免点二
698
+ // 分诊形态原样随迁)——bracket 路径改经聚合实例。
699
+ const finalizeEngineOutcome = (
700
+ Reflect.get(Reflect.get(service, "runOrchestration"), "finalizeEngineOutcome") as (
701
+ record: ExecutionRecord,
702
+ outcome: { content: string; engineId: string; error: string; exitCode: null },
703
+ ) => Promise<boolean>
704
+ ).bind(Reflect.get(service, "runOrchestration"));
705
+
706
+ const wfRecord: ExecutionRecord = {
707
+ ...createRecord("sa-triage2-wf", {
708
+ agent: "worker", model: "m", mode: "background", task: "t", slug: "s", startedAt: Date.now(),
709
+ }),
710
+ origin: "workflow",
711
+ parentRunId: "run-14",
712
+ };
713
+ store.register(wfRecord);
714
+ const adopted = await finalizeEngineOutcome(wfRecord, {
715
+ content: "",
716
+ engineId: "pi",
717
+ error: "killed by signal",
718
+ exitCode: null,
719
+ });
720
+ expect(adopted).toBe(false); // 豁免:exitCode===null 合成死亡形态不走接管
721
+ expect(wfRecord.status).toBe("closed"); // 落空 → 正常终态化
722
+ expect(wfRecord.closedReason).toBe("gc");
723
+ expect(supervisor.supervisedIds()).toEqual([]);
724
+ });
725
+ });
726
+
727
+ // ── settled-watchdog 原语守护([H2 W4] 自 subprocess-agent-runner-no-progress-
728
+ // full-chain.test.ts 迁移——原语不随 SAR.run 掏空退役,守护唯一 arm 点现为
729
+ // service 派发路径,用例归本文件) ──────────────────────────────
730
+
731
+ describe("settled-watchdog 原语守护(自 SAR full-chain 测试迁移)", () => {
732
+ beforeEach(() => {
733
+ _resetSettledWatchdogsForTest();
734
+ loggerMock.warn.mockClear();
735
+ // 宿主 shell export 隔离:守护开关 env 必须处于「未设」基线(空串 = 未设)。
736
+ vi.stubEnv(SETTLED_WATCHDOG_ENV, "");
737
+ });
738
+
739
+ afterEach(() => {
740
+ vi.unstubAllEnvs();
741
+ _resetSettledWatchdogsForTest();
742
+ resetCoreForTests();
743
+ });
744
+
745
+ it("默认值守护:生产中段窗恒 30min;测试注入不改常量且可复位(防将来重构手滑)", () => {
746
+ expect(SETTLED_MID_ROUND_NO_PROGRESS_MS).toBe(30 * 60 * 1000);
747
+ expect(getMidRoundNoProgressWindowMs()).toBe(SETTLED_MID_ROUND_NO_PROGRESS_MS);
748
+
749
+ _setMidRoundNoProgressWindowMsForTest(1_500);
750
+ expect(getMidRoundNoProgressWindowMs()).toBe(1_500);
751
+ // 注入是覆盖值的读取,不污染常量本体(生产路径读到的默认值不变)。
752
+ expect(SETTLED_MID_ROUND_NO_PROGRESS_MS).toBe(30 * 60 * 1000);
753
+
754
+ _resetSettledWatchdogsForTest();
755
+ expect(getMidRoundNoProgressWindowMs()).toBe(SETTLED_MID_ROUND_NO_PROGRESS_MS);
756
+ });
757
+
758
+ it("U-B3 env ≤0 的 warn 明示 workflow 域 no-progress 熔断连带失效(只改文案,不动开关语义)", () => {
759
+ vi.stubEnv(SETTLED_WATCHDOG_ENV, "0");
760
+ // 惰性首读触发解析 + warn 留痕(本文件 hoisted-mock 了 core/logger——settled-watchdog
761
+ // 的 logger.warn 进 loggerMock,与原 full-chain 文件的 HostServices.log 捕获等价)。
762
+ expect(isSettledWatchdogDisabled()).toBe(true);
763
+ const warn = loggerMock.warn.mock.calls
764
+ .map((args) => String(args[0]))
765
+ .find((message) => message.includes(SETTLED_WATCHDOG_ENV));
766
+ expect(warn).toBeDefined();
767
+ // 文案必须覆盖 M3 复用同一原语带来的 workflow 域连带后果(修复前只提 chat 域);
768
+ // [H2 W5] 挂载点表述随 W4 掏空更新为 service 派发点(原 SAR.run 表述退役)。
769
+ expect(warn).toContain("workflow");
770
+ expect(warn).toContain("no-progress");
771
+ expect(warn).toContain("runWorkflowEngineTask");
772
+
773
+ // 开关语义不动:arm 仍 no-op(本条目只补文案与注释,不新增 env、不改行为)。
774
+ const fired: string[] = [];
775
+ armMidRoundNoProgress("sa-env-off", {
776
+ onMidTimeout: () => fired.push("mid"),
777
+ onSettleTimeout: () => fired.push("settle"),
778
+ });
779
+ expect(hasSettledWatchdog("sa-env-off")).toBe(false);
780
+ expect(fired).toEqual([]);
781
+ });
782
+ });