@zhushanwen/pi-subagent-workflow 8.4.0 → 8.6.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 (171) hide show
  1. package/package.json +22 -7
  2. package/relay/relay.mjs +390 -0
  3. package/skills/subagent-ext-config/SKILL.md +80 -0
  4. package/src/execution/__tests__/agent-registry.test.ts +110 -0
  5. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  6. package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
  7. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  8. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  9. package/src/execution/__tests__/execution-record.test.ts +237 -1
  10. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  11. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  12. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  13. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  14. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  15. package/src/execution/__tests__/list-fields.test.ts +45 -14
  16. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  17. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  18. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  19. package/src/execution/__tests__/output-collector.test.ts +299 -2
  20. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  21. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  22. package/src/execution/__tests__/relay-env.test.ts +42 -0
  23. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  24. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  25. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  26. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  27. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  28. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  29. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  30. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  31. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  32. package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
  33. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  34. package/src/execution/__tests__/tool-action.test.ts +92 -1
  35. package/src/execution/agent-registry.ts +16 -0
  36. package/src/execution/argv-mirror.ts +5 -1
  37. package/src/execution/concurrency-pool.ts +1 -1
  38. package/src/execution/config.ts +25 -2
  39. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  40. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  41. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  42. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  43. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  44. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  45. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  46. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  47. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  48. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  49. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  50. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  51. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  52. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  53. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  54. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  55. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  56. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  57. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  58. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  59. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  60. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  61. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  62. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  63. package/src/execution/engine/common/data-dir.ts +62 -0
  64. package/src/execution/engine/common/errors.ts +183 -0
  65. package/src/execution/engine/common/event-journal.ts +254 -0
  66. package/src/execution/engine/common/journal-replay.ts +62 -0
  67. package/src/execution/engine/common/kill-chain.ts +221 -0
  68. package/src/execution/engine/common/nesting-guard.ts +50 -0
  69. package/src/execution/engine/common/persona-router.ts +108 -0
  70. package/src/execution/engine/common/pool-manager.ts +226 -0
  71. package/src/execution/engine/common/schema-emulation.ts +189 -0
  72. package/src/execution/engine/common/session-view-projection.ts +51 -0
  73. package/src/execution/engine/engine-discovery.ts +65 -0
  74. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  75. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  76. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  77. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  78. package/src/execution/engine/engines/pi/reader.ts +48 -0
  79. package/src/execution/engine/engines/pi/registration.ts +35 -0
  80. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  81. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  82. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  83. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  84. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  85. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  86. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  87. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  88. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
  89. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  90. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  91. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  92. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  93. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  94. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  95. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  96. package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
  97. package/src/execution/engine/host-task-spec.ts +47 -0
  98. package/src/execution/engine/model-prompt.ts +59 -0
  99. package/src/execution/engine/paths.ts +42 -0
  100. package/src/execution/engine/port.ts +153 -0
  101. package/src/execution/engine/registry.ts +123 -0
  102. package/src/execution/engine/routing.ts +218 -0
  103. package/src/execution/engine/types.ts +309 -0
  104. package/src/execution/execute-options-mapper.ts +13 -8
  105. package/src/execution/execution-record.ts +66 -1
  106. package/src/execution/lifecycle-manager.ts +23 -1
  107. package/src/execution/model-config-service.ts +16 -1
  108. package/src/execution/model-resolver.ts +37 -59
  109. package/src/execution/notifier.ts +105 -35
  110. package/src/execution/notify-ledger.ts +580 -0
  111. package/src/execution/output-collector.ts +143 -3
  112. package/src/execution/pi-invocation.ts +32 -2
  113. package/src/execution/record-entry.ts +14 -0
  114. package/src/execution/record-store.ts +34 -0
  115. package/src/execution/relay-env.ts +37 -0
  116. package/src/execution/session-runner.ts +328 -71
  117. package/src/execution/stream-sink.ts +26 -0
  118. package/src/execution/subagent-service.ts +273 -13
  119. package/src/execution/subprocess-agent-runner.ts +210 -14
  120. package/src/execution/types.ts +124 -5
  121. package/src/execution/ui-request-queue.ts +14 -4
  122. package/src/index.ts +99 -1
  123. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  124. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  125. package/src/interface/bg-notify-render.ts +33 -12
  126. package/src/interface/helpers.ts +2 -2
  127. package/src/interface/subagent-actions.ts +29 -9
  128. package/src/interface/subagent-tool-schema.ts +156 -0
  129. package/src/interface/subagent-tool.ts +56 -119
  130. package/src/interface/subagents.ts +2 -2
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  133. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  135. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  136. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  137. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  138. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  139. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  140. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  141. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  142. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  143. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  144. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  145. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +22 -3
  146. package/src/orchestration/agent-opts-resolver.ts +104 -23
  147. package/src/orchestration/error-recovery.ts +189 -33
  148. package/src/orchestration/execute-agent-call.ts +39 -0
  149. package/src/orchestration/jsonl-run-store.ts +121 -7
  150. package/src/orchestration/launcher.ts +60 -15
  151. package/src/orchestration/lifecycle.ts +10 -7
  152. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  153. package/src/orchestration/models/budget.ts +5 -35
  154. package/src/orchestration/models/run-runtime.ts +24 -9
  155. package/src/orchestration/models/types.ts +16 -0
  156. package/src/orchestration/script-lint.ts +1 -1
  157. package/src/orchestration/skill-discovery.ts +31 -8
  158. package/src/orchestration/worker-script-builder.ts +19 -3
  159. package/src/shared/__tests__/model-ref.test.ts +306 -0
  160. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  161. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  162. package/src/shared/meta-parser.ts +5 -1
  163. package/src/shared/model-ref.ts +286 -0
  164. package/src/shared/resource-meta.ts +5 -0
  165. package/src/shared/schema-env.ts +44 -0
  166. package/src/shared/schema-jsonify.ts +6 -4
  167. package/src/shared/timer-delay.ts +54 -0
  168. package/workflows/review-fix-loop-utils.cjs +9 -7
  169. package/workflows/review-fix-loop.js +20 -12
  170. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  171. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -13,9 +13,16 @@
13
13
  // T3.18 (NFR): dispose 兜底覆盖(delegate 后子进程进 spawnedChildren)
14
14
  // T3.19 (NFR): AgentCallOpts→ExecuteOptions 映射保真
15
15
 
16
- import { describe, expect, it, vi } from "vitest";
16
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
21
 
18
22
  import type { AgentCallOpts, AgentResult } from "../../orchestration/models/types.ts";
23
+ import { ModelConfigService, setModelConfigService } from "../model-config-service.ts";
24
+ import type { ModelRegistryLike } from "../model-resolver.ts";
25
+ import { replayJournal } from "../engine/common/event-journal.ts";
19
26
  import type { SubprocessAgentRunnerDeps } from "../subprocess-agent-runner.ts";
20
27
  import { SubprocessAgentRunner } from "../subprocess-agent-runner.ts";
21
28
 
@@ -182,6 +189,97 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
182
189
  });
183
190
  });
184
191
 
192
+ // ────────────────────────────────────────────────
193
+ // [U1 D2] RunContext.modelRef 接入:ctxModel 继承路径孪生守卫
194
+ // ────────────────────────────────────────────────
195
+ describe("U1 ctxModel twin guard (RunContext.modelRef 接入)", () => {
196
+ const prevDataDir = process.env["XYZ_AGENT_DATA_DIR"];
197
+ let tmpRoot = "";
198
+
199
+ /** 装配含指定快照的 ModelConfigService 单例(initModel 注入 registry)。 */
200
+ function installModelService(registry: ModelRegistryLike): void {
201
+ tmpRoot = mkdtempSync(join(tmpdir(), "sar-model-ref-"));
202
+ process.env["XYZ_AGENT_DATA_DIR"] = join(tmpRoot, "engine-data");
203
+ const svc = new ModelConfigService({ agentDir: join(tmpRoot, "agent"), cwd: tmpRoot });
204
+ svc.initModel({ modelRegistry: registry, sessionId: "sar-guard" });
205
+ setModelConfigService(svc);
206
+ }
207
+
208
+ afterEach(() => {
209
+ // 单例 slot 无 reset API,重装一个空注册服务隔离后续用例(同文件后续
210
+ // describe 不消费 ctxModel 守卫,getGlobalConfig 对空目录返回默认值)
211
+ if (tmpRoot) {
212
+ const svc = new ModelConfigService({ agentDir: join(tmpRoot, "agent"), cwd: tmpRoot });
213
+ svc.initModel({ modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => false }, sessionId: "cleanup" });
214
+ setModelConfigService(svc);
215
+ rmSync(tmpRoot, { recursive: true, force: true });
216
+ tmpRoot = "";
217
+ }
218
+ if (prevDataDir === undefined) delete process.env["XYZ_AGENT_DATA_DIR"];
219
+ else process.env["XYZ_AGENT_DATA_DIR"] = prevDataDir;
220
+ });
221
+
222
+ it("P-A2 拒单路径:ctxModel 命中但 registry 含孪生 → run 返回 error(ambiguous),executeAndAwait 未被调", async () => {
223
+ const models = [
224
+ { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
225
+ { id: "glm-5.3-flash", name: "flash lower", provider: "zai-coding-cn", reasoning: false },
226
+ ];
227
+ installModelService({
228
+ getAvailable: () => models,
229
+ find: (p, id) => models.find((m) => m.provider === p && m.id === id),
230
+ hasConfiguredAuth: () => true,
231
+ });
232
+ const mockService = createMockService();
233
+ const sar = new SubprocessAgentRunner({
234
+ subagentService: mockService as unknown as SubprocessAgentRunnerDeps["subagentService"],
235
+ ctxModel: { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
236
+ });
237
+
238
+ const result = await sar.run(makeBaseOpts(), new AbortController().signal);
239
+
240
+ expect(result.error).toMatch(/ambiguous case variants/);
241
+ expect(result.content).toBe("");
242
+ // 守卫在 engine.run 之前:不产生任何 record/spawn(委托零发生)
243
+ expect(mockService.executeAndAwait).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("P-A2 放行路径:无孪生 registry 下同 ctxModel 正常委托(守卫零误伤)", async () => {
247
+ const models = [
248
+ { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
249
+ ];
250
+ installModelService({
251
+ getAvailable: () => models,
252
+ find: (p, id) => models.find((m) => m.provider === p && m.id === id),
253
+ hasConfiguredAuth: () => true,
254
+ });
255
+ const mockService = createMockService(
256
+ vi.fn().mockResolvedValue(makeMockResult({ content: "ok" })),
257
+ );
258
+ const sar = new SubprocessAgentRunner({
259
+ subagentService: mockService as unknown as SubprocessAgentRunnerDeps["subagentService"],
260
+ ctxModel: { id: "GLM-5.3-Flash", name: "flash", provider: "zai-coding-cn", reasoning: false },
261
+ });
262
+
263
+ const result = await sar.run(makeBaseOpts(), new AbortController().signal);
264
+
265
+ expect(result.error).toBeUndefined();
266
+ expect(result.content).toBe("ok");
267
+ expect(mockService.executeAndAwait).toHaveBeenCalledTimes(1);
268
+ });
269
+
270
+ it("ctxModel 未指定时守卫跳过(无单例也不阻断)", async () => {
271
+ const mockService = createMockService(
272
+ vi.fn().mockResolvedValue(makeMockResult({ content: "ok" })),
273
+ );
274
+ const sar = new SubprocessAgentRunner({
275
+ subagentService: mockService as unknown as SubprocessAgentRunnerDeps["subagentService"],
276
+ });
277
+ const result = await sar.run(makeBaseOpts(), new AbortController().signal);
278
+ expect(result.content).toBe("ok");
279
+ expect(mockService.executeAndAwait).toHaveBeenCalledTimes(1);
280
+ });
281
+ });
282
+
185
283
  // ────────────────────────────────────────────────
186
284
  // H1: SAR ctxModel 刷新(model_select 后不再 stale)
187
285
  // ────────────────────────────────────────────────
@@ -284,12 +382,12 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
284
382
  });
285
383
  });
286
384
 
287
- it("无 workflow onEvent → 不传 bridgedOnEvent", async () => {
288
- let bridgedOnEvent: ((e: Record<string, unknown>) => void) | undefined;
385
+ it("无 workflow onEvent → 引擎仍收到包装 onEvent(P2 journal 通道恒开)", async () => {
386
+ let engineOnEvent: ((e: Record<string, unknown>) => void) | undefined;
289
387
  const mockService = createMockService(
290
388
  vi.fn().mockImplementation(
291
389
  (_opts: Record<string, unknown>, _signal?: AbortSignal, onEvent?: (e: Record<string, unknown>) => void) => {
292
- bridgedOnEvent = onEvent;
390
+ engineOnEvent = onEvent;
293
391
  return Promise.resolve(makeMockResult());
294
392
  },
295
393
  ),
@@ -299,8 +397,51 @@ describe("SubprocessAgentRunner (wave-4 delegate)", () => {
299
397
 
300
398
  await sar.run(makeBaseOpts(), new AbortController().signal);
301
399
 
302
- // 不传 workflow onEvent bridgedOnEvent 应为 undefined
303
- expect(bridgedOnEvent).toBeUndefined();
400
+ // P2 接线(设计 D6 第②级):SAR 包装 onEvent journal 后转发——原 onEvent
401
+ // 未传时也恒传包装版(全引擎免费获得 journal;P1「undefined 透传」细节已被
402
+ // 有意变更,本用例锁定新语义:引擎侧 onEvent 通道恒开)
403
+ expect(typeof engineOnEvent).toBe("function");
404
+ // 包装版不向外抛(journal append 是同步入队)
405
+ expect(() => engineOnEvent?.({ type: "turn_end" })).not.toThrow();
406
+ });
407
+
408
+ it("P2 journal 接线:run 期间事件落盘 journal-<taskId>.jsonl(中立格式,可重放)", async () => {
409
+ vi.useFakeTimers({ toFake: [] }); // 真实 timers:journal 写盘走真实 fs
410
+ const dataDir = mkdtempSync(join(tmpdir(), "sar-journal-test-"));
411
+ const prevEnv = process.env.XYZ_AGENT_DATA_DIR;
412
+ process.env.XYZ_AGENT_DATA_DIR = dataDir;
413
+ try {
414
+ const mockService = createMockService(
415
+ vi.fn().mockImplementation(
416
+ (_opts: Record<string, unknown>, _signal?: AbortSignal, onEvent?: (e: Record<string, unknown>) => void) => {
417
+ onEvent?.({ type: "tool_start", toolName: "bash", args: { cmd: "ls" } });
418
+ onEvent?.({ type: "turn_end" });
419
+ return Promise.resolve(makeMockResult());
420
+ },
421
+ ),
422
+ );
423
+ const deps: SubprocessAgentRunnerDeps = { subagentService: mockService };
424
+ const sar = new SubprocessAgentRunner(deps);
425
+
426
+ const workflowOnEvent = vi.fn();
427
+ await sar.run(makeBaseOpts(), new AbortController().signal, workflowOnEvent);
428
+
429
+ // journal 落在 <dataDir>/engines/pi/shared/journal-<taskId>.jsonl
430
+ const journalFile = join(dataDir, "engines", "pi", "shared");
431
+ const files = readdirSync(journalFile).filter((f) => f.startsWith("journal-") && f.endsWith(".jsonl"));
432
+ expect(files.length).toBe(1);
433
+ const replayed = replayJournal(join(journalFile, files[0] ?? ""));
434
+ expect(replayed).toEqual([
435
+ { type: "tool_start", toolName: "bash", args: { cmd: "ls" } },
436
+ { type: "turn_end" },
437
+ ]);
438
+ // workflow onEvent 照常透传(journal 包装不吞事件)
439
+ expect(workflowOnEvent).toHaveBeenCalledTimes(2);
440
+ } finally {
441
+ if (prevEnv === undefined) delete process.env.XYZ_AGENT_DATA_DIR;
442
+ else process.env.XYZ_AGENT_DATA_DIR = prevEnv;
443
+ rmSync(dataDir, { recursive: true, force: true });
444
+ }
304
445
  });
305
446
  });
306
447
 
@@ -40,6 +40,10 @@ vi.mock("node:child_process", async () => {
40
40
  stderr = new PassThrough();
41
41
  killed = false;
42
42
  killSignal: string | undefined;
43
+ // [race-F4] SIGKILL 升级判定读 exitCode/signalCode(真 ChildProcess 未退出时均 null),
44
+ // FakeChild 同形提供才能驱动升级路径测试(undefined === null 为 false 会误判已退出)。
45
+ exitCode: number | null = null;
46
+ signalCode: string | null = null;
43
47
  kill(sig?: string): boolean {
44
48
  this.killed = true;
45
49
  this.killSignal = sig;
@@ -97,7 +101,14 @@ import { spawn } from "node:child_process";
97
101
  import * as fs from "node:fs";
98
102
 
99
103
  import { createRecord } from "../execution-record.ts";
100
- import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
104
+ import {
105
+ computeWatchdogMs,
106
+ type RunOptions,
107
+ resolveSpawnWatchdogMs,
108
+ runSpawn,
109
+ SPAWN_WATCHDOG_ENV,
110
+ type SessionRunnerContext,
111
+ } from "../session-runner.ts";
101
112
 
102
113
  const mockSpawn = vi.mocked(spawn);
103
114
  const mockExistsSync = vi.mocked(fs.existsSync);
@@ -244,10 +255,17 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
244
255
  beforeEach(() => {
245
256
  vi.clearAllMocks();
246
257
  mockExistsSync.mockReturnValue(false);
258
+ // [F-4 假红源修复] env 隔离:多数用例默认 maxTurns=undefined →
259
+ // resolveSpawnWatchdogMs 走 env 兑底分支,宿主 export SPAWN_WATCHDOG 即假红
260
+ //(极端值 3e9 还会在 assertSafeTimerDelay fail-fast 直接炸掉 runSpawn)。
261
+ // 用例内显式 stubEnv 的值照常叠加生效(inner describe 的 afterEach
262
+ // unstubAllEnvs 与本处兼容)。
263
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
247
264
  });
248
265
 
249
266
  afterEach(() => {
250
267
  vi.restoreAllMocks();
268
+ vi.unstubAllEnvs();
251
269
  });
252
270
 
253
271
  // ── 1. watchdog 到期 → child.kill ──
@@ -291,9 +309,209 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
291
309
  // 信号终止(>=128)视为正常完成
292
310
  expect(result.success).toBe(true);
293
311
  });
312
+
313
+ // [race-F4] SIGKILL 升级:watchdog SIGTERM 后子进程无视信号(exitCode/signalCode
314
+ // 保持 null = 未退出)→ 30s 后升级 SIGKILL。挂住子进程永不回收的兑底防线。
315
+ it("[race-F4] watchdog SIGTERM 后 30s 未 exit → 升级 SIGKILL", async () => {
316
+ const record = makeRecord();
317
+ const promise = runSpawn(record, "Task: hang ignores SIGTERM", makeOpts({ maxTurns: 6 }), makeCtx());
318
+
319
+ const child = await waitForSpawnFake();
320
+
321
+ // watchdog 到期 → SIGTERM(升级路径入口)
322
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
323
+ expect(child.killSignal).toBe("SIGTERM");
324
+
325
+ // 子进程无视 SIGTERM:不 emit exit/close,exitCode/signalCode 保持 null。
326
+ // 推进升级窗口:非贴边断言(waitForSpawnFake 每步 10ms 推进,升级 timer 挂载
327
+ // 时刻有 ≤100ms 抖动)——先推 29s(余量 > 抖动)断言未升级,再推 2s 越过窗口。
328
+ await vi.advanceTimersByTimeAsync(29 * 1000);
329
+ expect(child.killSignal).toBe("SIGTERM"); // 尚未升级
330
+ await vi.advanceTimersByTimeAsync(2 * 1000);
331
+ expect(child.killSignal).toBe("SIGKILL"); // 已升级
332
+
333
+ // 收尾:SIGKILL 后子进程退出 → runSpawn resolve(信号终止视为完成)
334
+ emitStdoutLine(child, sessionHeader());
335
+ child.stdout.end();
336
+ child.emit("close", 137); // SIGKILL = 128+9
337
+ const result = await promise;
338
+ expect(result.success).toBe(true);
339
+ });
340
+
341
+ // [race-F4] 升级 timer 的 exit 自动 clear:SIGTERM 后子进程正常退出(exit 事件到
342
+ // 达),升级 timer 被清,不误发 SIGKILL(killSignal 停留在 SIGTERM)。
343
+ it("[race-F4] SIGTERM 后子进程 exit → 升级 timer 被 clear,不误发 SIGKILL", async () => {
344
+ const record = makeRecord();
345
+ const promise = runSpawn(record, "Task: dies on SIGTERM", makeOpts({ maxTurns: 6 }), makeCtx());
346
+
347
+ const child = await waitForSpawnFake();
348
+
349
+ // watchdog 到期 → SIGTERM
350
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
351
+ expect(child.killSignal).toBe("SIGTERM");
352
+
353
+ // 子进程响应 SIGTERM 退出:exitCode 143(SIGTERM 终止形态)+ exit + close。
354
+ // exit 事件触发升级 timer clear(killChildWithEscalation 内 once("exit") 挂钩)。
355
+ child.exitCode = 143;
356
+ child.emit("exit", 143);
357
+ child.stdout.end();
358
+ child.emit("close", 143);
359
+ const result = await promise;
360
+
361
+ // 越过升级窗口:无 SIGKILL(timer 已被 exit clear),killSignal 停留在 SIGTERM
362
+ await vi.advanceTimersByTimeAsync(60 * 1000);
363
+ expect(child.killSignal).toBe("SIGTERM");
364
+ expect(result.success).toBe(true);
365
+ });
366
+ });
367
+
368
+ // ── 2. watchdog 默认不挂载 + env 兑底(预算语义对齐 2026-08)──
369
+ //
370
+ // 语义:maxTurns 未传 → env 兑底;显式 0/负 → 压过 env 不挂(U5,SP-6 参数 > env,
371
+ // 旧实现 maxTurns:0 落到 env 兑底,参数显式关不掉 watchdog,已废);
372
+ // env XYZ_SUBAGENT_SPAWN_WATCHDOG_MS 设置时按绝对时限兑底挂载(hang 泄漏防线
373
+ // opt-in)。resolveSpawnWatchdogMs 是挂载判定唯一入口。
374
+ describe("watchdog 默认不挂载 + env 兑底(预算语义对齐)", () => {
375
+ beforeEach(() => {
376
+ vi.useFakeTimers();
377
+ });
378
+
379
+ afterEach(() => {
380
+ vi.useRealTimers();
381
+ vi.unstubAllEnvs();
382
+ });
383
+
384
+ it("resolveSpawnWatchdogMs:maxTurns 未传/0/负数且 env 未设 → undefined(不挂)", () => {
385
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, ""); // 空串 = 未设(raw falsy)
386
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
387
+ expect(resolveSpawnWatchdogMs(null)).toBeUndefined();
388
+ expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
389
+ expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
390
+ });
391
+
392
+ it("resolveSpawnWatchdogMs:env 设置 → 绝对时限;非法值 → undefined;maxTurns 有效优先", () => {
393
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
394
+ expect(resolveSpawnWatchdogMs(undefined)).toBe(60_000);
395
+ // [U5] SP-6 参数 > env:显式 0 压过 env 兑底 → 不挂(旧实现落成 60000)
396
+ expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
397
+ expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
398
+ // maxTurns 有效(>0)→ turns 估算优先于 env 兑底
399
+ expect(resolveSpawnWatchdogMs(6)).toBe(computeWatchdogMs(6));
400
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "abc");
401
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
402
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "-1");
403
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
404
+ });
405
+
406
+ // [U1] setTimeout 2^31-1 溢出 fail-fast:溢出 delay 被 Node 置 1ms 立即触发
407
+ //(watchdog 变「启动即杀」),两路径(env 兑底 / turns 估算)都必须在入口拦截。
408
+ it("resolveSpawnWatchdogMs:env 值溢出(>2^31-1)→ fail-fast throw(不静默 clamp/不挂)", () => {
409
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "3000000000");
410
+ expect(() => resolveSpawnWatchdogMs(undefined)).toThrowError(/2147483647/);
411
+ expect(() => resolveSpawnWatchdogMs(undefined)).toThrowError(/Recovery/);
412
+ });
413
+
414
+ it("resolveSpawnWatchdogMs:maxTurns 估算值溢出 → fail-fast throw", () => {
415
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
416
+ // maxTurns=1e6 → 估算 5e9 ms > 2^31-1,入口拦截
417
+ expect(() => resolveSpawnWatchdogMs(1_000_000)).toThrowError(/2147483647/);
418
+ });
419
+
420
+ // [F-R3] maxTurns 垃圾值网格:NaN/""/null/0/负数(含 ±Infinity)。
421
+ // 旧实现 NaN/"" 绕过 Number.isFinite 且压过 env 兑底成「显式不限」。
422
+ it("resolveSpawnWatchdogMs:maxTurns NaN → fail-fast throw(压过 env);\"\"→Number(\"\")=0 显式不限;null/0/负数网格(F-R3)", () => {
423
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
424
+ // NaN:非有限数 fail-fast(与 timer-delay U1 同语义),不再压过 env 兑底成「显式不限」
425
+ expect(() => resolveSpawnWatchdogMs(NaN)).toThrowError(/not a finite number/);
426
+ expect(() => resolveSpawnWatchdogMs(NaN)).toThrowError(/Recovery/);
427
+ // ±Infinity:同非有限数 fail-fast
428
+ expect(() => resolveSpawnWatchdogMs(Infinity)).toThrowError(/not a finite number/);
429
+ // "":Number("") === 0 → 与显式 0 同路径(显式不限,压过 env,U5 语义不回归)
430
+ expect(resolveSpawnWatchdogMs("" as unknown as number)).toBeUndefined();
431
+ // null → 未传语义,env 兑底生效
432
+ expect(resolveSpawnWatchdogMs(null)).toBe(60_000);
433
+ // 0 / 负数 → 显式不限,压过 env(既有 U5 网格不回归)
434
+ expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
435
+ expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
436
+ });
437
+
438
+ it("maxTurns 未传 → 推进 56min 不 kill(旧实现 50min 估算会误杀)", async () => {
439
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
440
+ const record = makeRecord();
441
+ const promise = runSpawn(record, "Task: no-turns", makeOpts({ maxTurns: undefined }), makeCtx());
442
+
443
+ const child = await waitForSpawnFake();
444
+ expect(child.killed).toBe(false);
445
+
446
+ // 旧实现的估算默认(10 turns → 50min)已过,仍不 kill = watchdog 未挂载
447
+ await vi.advanceTimersByTimeAsync(56 * 60 * 1000);
448
+ expect(child.killed).toBe(false);
449
+
450
+ emitStdoutLine(child, sessionHeader());
451
+ child.stdout.end();
452
+ child.emit("close", 0);
453
+ const result = await promise;
454
+ expect(result.success).toBe(true);
455
+ });
456
+
457
+ it("maxTurns: 0(显式关不掉的旧默认已废)→ 同样不挂 watchdog", async () => {
458
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
459
+ const record = makeRecord();
460
+ const promise = runSpawn(record, "Task: zero-turns", makeOpts({ maxTurns: 0 }), makeCtx());
461
+
462
+ const child = await waitForSpawnFake();
463
+ await vi.advanceTimersByTimeAsync(56 * 60 * 1000);
464
+ expect(child.killed).toBe(false);
465
+
466
+ emitStdoutLine(child, sessionHeader());
467
+ child.stdout.end();
468
+ child.emit("close", 0);
469
+ const result = await promise;
470
+ expect(result.success).toBe(true);
471
+ });
472
+
473
+ // [U5] SP-6 参数 > env:显式 maxTurns:0 压过 SPAWN_WATCHDOG_ENV 兑底——
474
+ // 旧实现落成 env 时限 kill,参数显式关不掉 watchdog。
475
+ it("maxTurns: 0 + env=60000 → 显式 0 压过 env,61s 不 kill(U5)", async () => {
476
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
477
+ const record = makeRecord();
478
+ const promise = runSpawn(record, "Task: zero-turns-override", makeOpts({ maxTurns: 0 }), makeCtx());
479
+
480
+ const child = await waitForSpawnFake();
481
+ // 推进越过 env 兑底时限(60s):若 maxTurns:0 未压过 env,此处已被 kill
482
+ await vi.advanceTimersByTimeAsync(61 * 1000);
483
+ expect(child.killed).toBe(false);
484
+
485
+ emitStdoutLine(child, sessionHeader());
486
+ child.stdout.end();
487
+ child.emit("close", 0);
488
+ const result = await promise;
489
+ expect(result.success).toBe(true);
490
+ });
491
+
492
+ it("env SPAWN_WATCHDOG_MS=60000 + maxTurns 未传 → 1min 兑底 kill", async () => {
493
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "60000");
494
+ const record = makeRecord();
495
+ const promise = runSpawn(record, "Task: env-watchdog", makeOpts(), makeCtx());
496
+
497
+ const child = await waitForSpawnFake();
498
+ // 非贴边断言:waitForSpawnFake 每步 10ms 推进,watchdog 挂载时刻有 ≤10ms 抖动,
499
+ // 用 55s / 61s 两个窗口避开 60s 贴边比较
500
+ await vi.advanceTimersByTimeAsync(55_000);
501
+ expect(child.killed).toBe(false);
502
+ await vi.advanceTimersByTimeAsync(6_000);
503
+ expect(child.killed).toBe(true);
504
+ expect(child.killSignal).toBe("SIGTERM");
505
+
506
+ emitStdoutLine(child, sessionHeader());
507
+ child.stdout.end();
508
+ child.emit("close", 143);
509
+ const result = await promise;
510
+ expect(result.success).toBe(true);
511
+ });
294
512
  });
295
513
 
296
- // ── 2. watchdog 到期前正常完成 → clearTimeout 生效,不 kill ──
514
+ // ── 3. watchdog 到期前正常完成 → clearTimeout 生效,不 kill ──
297
515
  describe("watchdog 到期前正常完成 → 不 kill", () => {
298
516
  beforeEach(() => {
299
517
  vi.useFakeTimers();
@@ -103,7 +103,7 @@ describe("startHandler", () => {
103
103
  ).rejects.toThrow(/≤35 chars/);
104
104
  });
105
105
 
106
- it("background 启动 → kind=bg + bgResponse.message 含 detached", async () => {
106
+ it("background 启动 → kind=bg + bgResponse.message 含 detached + notifyContract 恒值", async () => {
107
107
  const svc = makeService({
108
108
  execute: vi.fn(async (): Promise<ExecutionHandle> => ({
109
109
  mode: "background",
@@ -117,6 +117,50 @@ describe("startHandler", () => {
117
117
  if (r.kind !== "bg") return;
118
118
  expect(r.subagentId).toBe("bg-1-123");
119
119
  expect(r.response.message).toMatch(/detached/);
120
+ // [U1] 通知投递契约回显位(U2 账本兑现)
121
+ expect(r.response.notifyContract).toBe("ledger+at-least-once");
122
+ });
123
+
124
+ it("[U1] start 返回值 model 为 registry 全等回显(透传 handle.details.model)", async () => {
125
+ const svc = makeService({
126
+ execute: vi.fn(async (): Promise<ExecutionHandle> => ({
127
+ mode: "background",
128
+ subagentId: "bg-2-456",
129
+ sessionFile: undefined,
130
+ // record.model = resolved(裁决放行条目)拼接的 "provider/id",保留 registry 大小写
131
+ details: makeDetails({ status: "running", mode: "background", model: "zai-coding-cn/GLM-5.3-Flash" }),
132
+ })),
133
+ });
134
+ const r = await startHandler(svc, { task: "t", slug: "s" }, undefined);
135
+ expect(r.model).toBe("zai-coding-cn/GLM-5.3-Flash");
136
+ // adapter 外层 result 同源回显
137
+ const toolResult = adapter({ action: "start", domain: r });
138
+ const result = toolResult.details;
139
+ if (result.action !== "start") throw new Error("expected start variant");
140
+ expect(result.model).toBe("zai-coding-cn/GLM-5.3-Flash");
141
+ // LLM content JSON 同源(与 details 一致)
142
+ const contentJson = JSON.parse(toolResult.content[0]!.type === "text" ? toolResult.content[0].text : "{}");
143
+ expect(contentJson.model).toBe("zai-coding-cn/GLM-5.3-Flash");
144
+ expect(contentJson.bgResponse.notifyContract).toBe("ledger+at-least-once");
145
+ });
146
+
147
+ it("[U1] model 非全等 → 裁决错误向上传播(无 bgResponse 产出,execute 不重试)", async () => {
148
+ // 裁决发生在 service.execute 内部步骤 1(IDENTITY 解析 → resolveModel),在 record
149
+ // 创建 / runSpawn 之前——错误直接向上传播为 tool isError。resolveModel 层的拒单
150
+ // 行为(含 P-A2 双路径)由 model-ref.test.ts + model-resolver.test.ts 锁定;本用例
151
+ // 锁定 handler 层传播语义:异常穿越 startHandler,不产出任何受理响应。
152
+ const svc = makeService({
153
+ execute: vi.fn(async () => {
154
+ throw new Error(
155
+ 'Model "zai-coding-cn/glm-5.3-flash" (paramOverride) is not a registry entry. ' +
156
+ "Did you mean one of these?\n zai-coding-cn/GLM-5.3-Flash",
157
+ );
158
+ }),
159
+ });
160
+ await expect(
161
+ startHandler(svc, { task: "t", slug: "s", model: "zai-coding-cn/glm-5.3-flash" }, undefined),
162
+ ).rejects.toThrow(/is not a registry entry.*Did you mean/s);
163
+ expect(svc.execute).toHaveBeenCalledTimes(1);
120
164
  });
121
165
  });
122
166
 
@@ -377,6 +421,53 @@ describe("adapter", () => {
377
421
  expect(r.details.listResponse).toEqual({ running: 0, items: [] });
378
422
  });
379
423
 
424
+ // [U3 C-outcome] 对外 JSON 契约:list items 携带 outcome 且旧字段保留(验收③)。
425
+ it("[U3] list JSON:items[].outcome 在位(failed 值)且 status/mode/state 旧字段保留", () => {
426
+ const r = adapter({
427
+ action: "list",
428
+ domain: {
429
+ response: {
430
+ running: 0,
431
+ items: [
432
+ {
433
+ subagentId: "bg-u3", agent: "w", slug: "s", state: "ended", status: "closed",
434
+ mode: "background", duration: 1, model: "m", totalTokens: 0,
435
+ outcome: "failed",
436
+ },
437
+ ],
438
+ },
439
+ },
440
+ });
441
+ const parsed = JSON.parse(r.content[0]!.text) as {
442
+ listResponse: { items: Array<Record<string, unknown>> };
443
+ };
444
+ const item = parsed.listResponse.items[0]!;
445
+ expect(item.outcome).toBe("failed");
446
+ // 旧字段保留(向后兼容)
447
+ expect(item.status).toBe("closed");
448
+ expect(item.state).toBe("ended");
449
+ expect(item.mode).toBe("background");
450
+ // closedReason 退出对外 JSON
451
+ expect("closedReason" in item).toBe(false);
452
+ });
453
+
454
+ // [U3 C-outcome] start bgResponse:旧字段保留;outcome 为契约完备位,start 时点
455
+ // record 未终态恒 undefined(JSON.stringify 落键省略),终态成败经 list items 披露。
456
+ it("[U3] start bgResponse JSON:status/mode/message 旧字段保留,outcome 起点为 undefined", () => {
457
+ const r = adapter({
458
+ action: "start",
459
+ domain: {
460
+ kind: "bg", subagentId: "bg-u3-2", sessionFile: undefined, slug: "s",
461
+ response: { status: "running", mode: "background", message: "detached" },
462
+ },
463
+ });
464
+ const parsed = JSON.parse(r.content[0]!.text) as { bgResponse: Record<string, unknown> };
465
+ expect(parsed.bgResponse.status).toBe("running");
466
+ expect(parsed.bgResponse.mode).toBe("background");
467
+ expect(parsed.bgResponse.message).toBe("detached");
468
+ expect(parsed.bgResponse.outcome).toBeUndefined();
469
+ });
470
+
380
471
  it("cancel → cancelResponse.cancelled:true 字面量", () => {
381
472
  const r = adapter({ action: "cancel", domain: { subagentId: "bg-1", response: { cancelled: true } } });
382
473
  expect(r.details.cancelResponse).toEqual({ cancelled: true });
@@ -20,6 +20,7 @@ import { parseResourceMeta } from "../shared/meta-parser.ts";
20
20
  import { lintAgentMeta } from "../orchestration/script-lint.ts";
21
21
  import type { AgentMeta } from "../shared/resource-meta.ts";
22
22
  import type { AgentConfig } from "./model-resolver.ts";
23
+ import { hasEngine, listEngines, EngineNotFoundError } from "./engine/registry.ts";
23
24
 
24
25
  const logger = getLogger("subagents");
25
26
 
@@ -100,6 +101,14 @@ export function parseAgentWithMeta(
100
101
  );
101
102
  }
102
103
  const defaultBackgroundRaw = extractYamlField(yamlBlock, "defaultBackground");
104
+ // engine 字段(D9):结构化优先(IF1),legacy fallback 与 model/tools 同判——
105
+ // agentMeta 未通过 IF1 时配置不丢
106
+ const engine = agentMeta?.engine ?? extractYamlField(yamlBlock, "engine");
107
+ // 解析期校验(D9:未注册 id 前置暴露,不留到运行时神秘失败)。为什么在解析期而非
108
+ // 路由期:配置错误的根源在 .md 文件,越早报错定位越准(错误含文件路径 + 注册清单)
109
+ if (engine !== undefined && !hasEngine(engine)) {
110
+ throw new EngineNotFoundError(engine, listEngines(), filePath);
111
+ }
103
112
 
104
113
  return {
105
114
  config: {
@@ -107,6 +116,7 @@ export function parseAgentWithMeta(
107
116
  systemPrompt: body,
108
117
  model: agentMeta?.model ?? modelFallback ?? undefined,
109
118
  thinkingLevel: extractYamlField(yamlBlock, "thinkingLevel") ?? undefined,
119
+ ...(engine !== undefined ? { engine } : {}),
110
120
  tools: agentMeta?.tools && agentMeta.tools.length > 0
111
121
  ? agentMeta.tools
112
122
  : (toolsFallback && toolsFallback.length > 0 ? toolsFallback : undefined),
@@ -142,7 +152,13 @@ export class AgentRegistry {
142
152
  * - ~/ 前缀展开;相对路径/非 .md 引用返回 undefined(引用唯一形态 = 绝对路径)
143
153
  * - 文件不可读/不存在 → 驱逐缓存 + 返回 undefined(调用方给错误指引)
144
154
  * - mtime 未变复用 config 缓存;cache-miss 时 W4 lint 一次
155
+ *
156
+ * require 语义:require:true 时上述两类失败改为 throw(错误文案含
157
+ * <available_subagents> 恢复指引),供「用户显式点名 agent」的调用点使用——
158
+ * 显式 ref 失败是配置错误,必须显式报错而非静默降级(三通道对称审查)。
145
159
  */
160
+ loadByPath(ref: string, require: true): AgentConfig;
161
+ loadByPath(ref: string, require?: boolean): AgentConfig | undefined;
146
162
  loadByPath(ref: string, require?: boolean): AgentConfig | undefined {
147
163
  const filePath = normalizeRef(ref, AGENT_REF_EXT);
148
164
  if (filePath === null) {
@@ -86,9 +86,13 @@ export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
86
86
  continue;
87
87
  }
88
88
  // 空格形式 --extension <path> / -e <path>:值在下一个 token
89
+ // [MF-7a] flag 判定收窄为 startsWith("--"):守卫原为 !startsWith("-"),把以单个
90
+ // - 开头的合法路径(如相对路径 -weird-dir/x.md)误判为 flag 跳过——extension 路径
91
+ // 静默丢失。pi CLI 的多字符长 flag 一律 -- 前缀,单 - 只接单字符短 flag(-e 本身),
92
+ // 故「-- 开头才算 flag」不误吃真 flag 且放行 - 开头路径。
89
93
  if (tok === "--extension" || tok === "-e") {
90
94
  const next = flagArgs[i + 1];
91
- if (next !== undefined && !next.startsWith("-") && next.length > 0) {
95
+ if (next !== undefined && !next.startsWith("--") && next.length > 0) {
92
96
  extensionPaths.push(next);
93
97
  i++; // 跳过值
94
98
  }
@@ -63,7 +63,7 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
63
63
  // H2: abort 时 reject 排队条目并从 queue 移除,防止永久挂起
64
64
  if (signal) {
65
65
  if (signal.aborted) {
66
- // S1: abort reject 需带 name="AbortError",对齐 concurrency-gate.ts AbortError 语义
66
+ // S1: abort reject 需带 name="AbortError",对齐包内 AbortError 错误语义约定(消费方按 err.name 判别)
67
67
  const err = new Error("acquire aborted");
68
68
  err.name = "AbortError";
69
69
  reject(err);