@zhushanwen/pi-subagent-workflow 8.5.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 (92) hide show
  1. package/package.json +7 -6
  2. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  3. package/src/execution/__tests__/chat-engine-routing.test.ts +6 -2
  4. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  5. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  6. package/src/execution/__tests__/execution-record.test.ts +110 -0
  7. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  8. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  9. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  10. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  11. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  12. package/src/execution/__tests__/list-fields.test.ts +45 -14
  13. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  14. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  15. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  16. package/src/execution/__tests__/output-collector.test.ts +299 -2
  17. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  18. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  19. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  20. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  21. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  22. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  23. package/src/execution/__tests__/subprocess-agent-runner.test.ts +94 -1
  24. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  25. package/src/execution/__tests__/tool-action.test.ts +92 -1
  26. package/src/execution/agent-registry.ts +6 -0
  27. package/src/execution/argv-mirror.ts +5 -1
  28. package/src/execution/concurrency-pool.ts +1 -1
  29. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +13 -0
  30. package/src/execution/engine/engines/zcode/zcode-engine.ts +11 -1
  31. package/src/execution/engine/types.ts +6 -1
  32. package/src/execution/execute-options-mapper.ts +8 -7
  33. package/src/execution/execution-record.ts +60 -1
  34. package/src/execution/lifecycle-manager.ts +23 -1
  35. package/src/execution/model-config-service.ts +16 -1
  36. package/src/execution/model-resolver.ts +31 -59
  37. package/src/execution/notifier.ts +105 -35
  38. package/src/execution/notify-ledger.ts +580 -0
  39. package/src/execution/output-collector.ts +143 -3
  40. package/src/execution/session-runner.ts +304 -71
  41. package/src/execution/subagent-service.ts +24 -2
  42. package/src/execution/subprocess-agent-runner.ts +14 -0
  43. package/src/execution/types.ts +68 -5
  44. package/src/execution/ui-request-queue.ts +14 -4
  45. package/src/index.ts +54 -1
  46. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  47. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  48. package/src/interface/bg-notify-render.ts +33 -12
  49. package/src/interface/helpers.ts +2 -2
  50. package/src/interface/subagent-actions.ts +26 -9
  51. package/src/interface/subagent-tool-schema.ts +156 -0
  52. package/src/interface/subagent-tool.ts +56 -125
  53. package/src/interface/subagents.ts +2 -2
  54. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +16 -3
  55. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  56. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  57. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  58. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  59. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  60. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  61. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  62. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  63. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  64. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  65. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  66. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  67. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  68. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +21 -2
  69. package/src/orchestration/agent-opts-resolver.ts +104 -23
  70. package/src/orchestration/error-recovery.ts +189 -33
  71. package/src/orchestration/execute-agent-call.ts +39 -0
  72. package/src/orchestration/jsonl-run-store.ts +121 -7
  73. package/src/orchestration/launcher.ts +60 -15
  74. package/src/orchestration/lifecycle.ts +10 -7
  75. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  76. package/src/orchestration/models/budget.ts +5 -35
  77. package/src/orchestration/models/run-runtime.ts +24 -9
  78. package/src/orchestration/models/types.ts +9 -0
  79. package/src/orchestration/script-lint.ts +1 -1
  80. package/src/orchestration/skill-discovery.ts +31 -8
  81. package/src/orchestration/worker-script-builder.ts +16 -3
  82. package/src/shared/__tests__/model-ref.test.ts +306 -0
  83. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  84. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  85. package/src/shared/model-ref.ts +286 -0
  86. package/src/shared/schema-env.ts +44 -0
  87. package/src/shared/schema-jsonify.ts +6 -4
  88. package/src/shared/timer-delay.ts +54 -0
  89. package/workflows/review-fix-loop-utils.cjs +9 -7
  90. package/workflows/review-fix-loop.js +20 -12
  91. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  92. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -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 });
@@ -152,7 +152,13 @@ export class AgentRegistry {
152
152
  * - ~/ 前缀展开;相对路径/非 .md 引用返回 undefined(引用唯一形态 = 绝对路径)
153
153
  * - 文件不可读/不存在 → 驱逐缓存 + 返回 undefined(调用方给错误指引)
154
154
  * - mtime 未变复用 config 缓存;cache-miss 时 W4 lint 一次
155
+ *
156
+ * require 语义:require:true 时上述两类失败改为 throw(错误文案含
157
+ * <available_subagents> 恢复指引),供「用户显式点名 agent」的调用点使用——
158
+ * 显式 ref 失败是配置错误,必须显式报错而非静默降级(三通道对称审查)。
155
159
  */
160
+ loadByPath(ref: string, require: true): AgentConfig;
161
+ loadByPath(ref: string, require?: boolean): AgentConfig | undefined;
156
162
  loadByPath(ref: string, require?: boolean): AgentConfig | undefined {
157
163
  const filePath = normalizeRef(ref, AGENT_REF_EXT);
158
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);
@@ -200,6 +200,19 @@ describe("run ① prepare 期错误:进程创建前 reject、不产生 handle"
200
200
  /engine_capability_unsupported/,
201
201
  );
202
202
  });
203
+
204
+ it("maxTurns(pi 专属)→ engine_capability_unsupported,不调 launch(U4:静默丢弃会造成假上限)", async () => {
205
+ const fake = makeFakeLaunch({ stdout: ZCODE_GOLDEN_STDOUT });
206
+ const engine = makeEngine({ launch: fake.launch });
207
+ await expect(engine.run(makeTask({ maxTurns: 10 }), makeCtx())).rejects.toThrowError(
208
+ /engine_capability_unsupported/,
209
+ );
210
+ // 恢复指引:去掉 maxTurns 或改用 pi 引擎
211
+ await expect(engine.run(makeTask({ maxTurns: 10 }), makeCtx())).rejects.toThrowError(
212
+ /maxTurns|engine: 'pi'/,
213
+ );
214
+ expect(fake.calls).toHaveLength(0);
215
+ });
203
216
  });
204
217
 
205
218
  describe("run ② 成功路径:golden stdout → outcome/handle/事件合成", () => {
@@ -535,7 +535,10 @@ export class ZcodeEngine implements EnginePort {
535
535
  /**
536
536
  * prepare 期的能力拒绝(进程创建前):fork 是 pi 专属(AgentTaskSpec.fork 契约:
537
537
  * 其他引擎按 capabilities 拒绝);conversation 是 interact 控制面的 task 标志,
538
- * zcode 无此面(A11:同步拒绝 + 可操作建议,无进程创建)。
538
+ * zcode 无此面(A11:同步拒绝 + 可操作建议,无进程创建);maxTurns 是 pi 引擎
539
+ * 专属(turn limiter + spawn watchdog 估算依赖 pi 的 turn_end 事件流)——zcode
540
+ * 无 turn_end 语义,静默丢弃会造成「传了上限却失控」的假象,显式拒绝(U4,
541
+ * 同 fork 模式)。
539
542
  */
540
543
  private rejectUnsupportedTaskShapes(task: AgentTaskSpec): void {
541
544
  if (task.fork === true) {
@@ -551,6 +554,13 @@ export class ZcodeEngine implements EnginePort {
551
554
  "恢复指引:改用单次调用(去掉 conversation),或使用 engine: 'pi'。",
552
555
  );
553
556
  }
557
+ if (task.maxTurns !== undefined) {
558
+ throw new ZcodeTaskShapeError(
559
+ "engine_capability_unsupported",
560
+ "zcode 引擎不支持 maxTurns(pi 引擎专属 turn limiter;zcode 无 turn_end 语义,无法兑现轮数上限)。" +
561
+ "恢复指引:去掉 maxTurns 参数重派,或使用 engine: 'pi'。",
562
+ );
563
+ }
554
564
  }
555
565
 
556
566
  /**
@@ -89,7 +89,12 @@ export interface AgentTaskSpec {
89
89
  * PI_WORKFLOW_SCHEMA env 注入链路按 native 直传,公共仿真层只服务 emulated 引擎。
90
90
  */
91
91
  schema?: Record<string, unknown>;
92
- /** 原样(ExecuteOptions.maxTurns)。 */
92
+ /**
93
+ * 原样(ExecuteOptions.maxTurns)。pi 引擎专属(turn limiter + spawn watchdog
94
+ * 估算依赖 pi 的 turn_end 事件流);其他引擎 prepare 期显式拒绝(U4,同 fork 模式)。
95
+ * 显式 0 压过 SPAWN_WATCHDOG_ENV 兑底(SP-6 参数 > env,U5);undefined 未传才由
96
+ * env 兑底。
97
+ */
93
98
  maxTurns?: number;
94
99
  /** 原样(ExecuteOptions.graceTurns)。 */
95
100
  graceTurns?: number;
@@ -10,13 +10,12 @@ import { HOST_TIMEOUT_ABORT_REASON } from "./engine/common/kill-chain.ts";
10
10
  import type { ModelInfo } from "./model-resolver.ts";
11
11
  import type { ExecuteOptions } from "./types.ts";
12
12
 
13
- /**
14
- * slug 最大长度(字符)。subagent/workflow 创建时 slug 超过此值会被截断。
15
- * subagent/workflow tool schema maxLength 引用此常量(单一真相,勿再硬编码)。
16
- * 历史值 20 偏紧——描述性 slug 如 "audit-structured-output"(23)/"fix-subagent-wf-tools"(21)
17
- * 会撞上限,放宽到 35 兼顾「短到能塞进 TUI 标题行」与「容纳合理描述性 kebab-case 名」。
18
- */
19
- export const SLUG_MAX_LENGTH = 35;
13
+ // SLUG_MAX_LENGTH 定义已迁至 interface/subagent-tool-schema.ts(与 tool schema 的
14
+ // maxLength 同址,跨包契约测试经该零依赖叶子 import)。此处 re-export 保持既有
15
+ // import 路径(tool-workflow / subagent-actions / error-recovery 等)不变。
16
+ import { SLUG_MAX_LENGTH } from "../interface/subagent-tool-schema.ts";
17
+
18
+ export { SLUG_MAX_LENGTH };
20
19
 
21
20
  /**
22
21
  * D-A2: AgentCallOpts → ExecuteOptions 映射。
@@ -36,6 +35,7 @@ export const SLUG_MAX_LENGTH = 35;
36
35
  * skillPath → skillPath
37
36
  * thinkingLevel → thinkingLevel(M1: 否则下游 subagent-service 读到 undefined)
38
37
  * appendSystemPrompt → appendSystemPrompt(内容数组,同名同义透传)
38
+ * maxTurns → maxTurns(turn limiter 上限;undefined = 不限,不挂 turns 估算 watchdog)
39
39
  *
40
40
  * 忽略字段(委托后由 executeAndAwait 内部机制替代):
41
41
  * timeoutMs —— mergeTimeoutSignal 单独处理
@@ -63,6 +63,7 @@ export function mapToExecuteOptions(
63
63
  skillPath: opts.skillPath,
64
64
  thinkingLevel: opts.thinkingLevel,
65
65
  appendSystemPrompt: opts.appendSystemPrompt,
66
+ maxTurns: opts.maxTurns,
66
67
  };
67
68
  }
68
69
 
@@ -23,9 +23,11 @@ import type {
23
23
  ClosedReason,
24
24
  DisplayItem,
25
25
  ExecutionMode,
26
+ ExecutionOutcome,
26
27
  ExecutionRecord,
27
28
  ExecutionStatus,
28
29
  InternalToolCall,
30
+ ProjectedOutcome,
29
31
  RecordSnapshot,
30
32
  SubagentToolDetails,
31
33
  ToolCall,
@@ -690,7 +692,7 @@ export function markReconstructedStatus(
690
692
  }
691
693
 
692
694
  /**
693
- * 唯一完成入口。冻结状态(写 endedAt/agentResult/result/error)。
695
+ * 唯一完成入口。冻结状态(写 endedAt/agentResult/result/error/outcome)。
694
696
  * 不修改 turns/totalTokens——已由 updateFromEvent 累积,completeRecord 只读不重置。
695
697
  *
696
698
  * ⚠ 前置条件:调用方必须先通过 tryTransition 抢到锁(status 已被 CAS 设为 target)。
@@ -706,12 +708,68 @@ export function completeRecord(
706
708
  ): void {
707
709
  record.status = status;
708
710
  record.closedReason = closedReason ?? "gc";
711
+ // U3 C-outcome:outcome 唯一写入点——终态语义在此一次定形(D6),下游消费方
712
+ // (project/list/notify 文案/渲染器)只读 record.outcome,不再各自推导。
713
+ record.outcome = deriveOutcome(record.closedReason, result.error);
709
714
  record.endedAt = Date.now();
710
715
  record.agentResult = result;
711
716
  record.result = result.text;
712
717
  record.error = result.error;
713
718
  }
714
719
 
720
+ // ============================================================
721
+ // 终态 outcome(U3 C-outcome:单一权威派生)
722
+ // ============================================================
723
+
724
+ /**
725
+ * closed 终态 → 三态 outcome 的唯一权威派生(D6 收敛:原 notifier/bg-notify-render/
726
+ * shared deriveClosedDisplay 三处手写同构 switch 的单一实现)。
727
+ *
728
+ * 判定顺序(顺序敏感,勿回退成「error 有值即 failed」的无视取消规则):
729
+ * 1. closedReason === "cancelled" → "cancelled"(取消优先,不参与 error——abort 合成
730
+ * result 可能携带 error,但用户取消语义优先)
731
+ * 2. error 非空(truthy,与旧三处同构的 `record.error &&` 判定逐字对齐——空串 error
732
+ * 不构成失败)→ "failed"
733
+ * 3. 其余 → "completed"
734
+ *
735
+ * [D6 待核项保真] 「failed 优先于 patchFile 提示」:失败轮也会写 patchFile
736
+ * (doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch),消费方必须先按
737
+ * outcome 分流再渲染 patch 提示——failed 分支不展示 patch/result。历史 bug:notifier
738
+ * 的 patchFile 分支曾遮蔽 gc+error 判定,失败终态被 LLM 告知 completed(M1 修复存档)。
739
+ *
740
+ * [D6 显式取舍] parent-shutdown/parent-fork/parent-new 合成关闭(subagent-service
741
+ * disposeAllRecords 合成 result 恒写 error:"closed due to ${reason}")在本映射下落
742
+ * "failed"——语义为「父进程关闭时子 agent 未完成即失败」,选定行为而非疏漏,
743
+ * 勿当 bug 改回 cancelled 造成派生矛盾。
744
+ *
745
+ * 唯一写点 completeRecord 调用本函数冻结 record.outcome;通知 payload(notifier 投影
746
+ * 边界)与无 outcome 字段的存量/重建 record 由 projectOutcome 兜底复用本函数。
747
+ */
748
+ export function deriveOutcome(
749
+ closedReason: ClosedReason | undefined,
750
+ error: string | undefined | null,
751
+ ): ExecutionOutcome {
752
+ if (closedReason === "cancelled") return "cancelled";
753
+ if (error) return "failed";
754
+ return "completed";
755
+ }
756
+
757
+ /**
758
+ * 投影层 outcome 唯一出口:running → undefined(终态语义不适用活跃态);closed →
759
+ * 一等 outcome 字段直读优先,字段缺失(存量/磁盘重建 record——outcome 持久化不在
760
+ * U3 领地内)时回退 deriveOutcome(closedReason, error) 兜底——单一权威函数,
761
+ * 消费方零手写推导。返回值联合含 "closed-legacy" 预留态,消费方必须处理。
762
+ */
763
+ export function projectOutcome(record: {
764
+ status: ExecutionStatus;
765
+ outcome?: ExecutionOutcome;
766
+ closedReason?: ClosedReason;
767
+ error?: string;
768
+ }): ProjectedOutcome | undefined {
769
+ if (record.status !== "closed") return undefined;
770
+ return record.outcome ?? deriveOutcome(record.closedReason, record.error);
771
+ }
772
+
715
773
  // ============================================================
716
774
  // 投影(唯一 → Details / Snapshot / Persisted)
717
775
  // ============================================================
@@ -728,6 +786,7 @@ export function computeElapsedSeconds(record: { startedAt: number; endedAt?: num
728
786
  export function project(record: ExecutionRecord): SubagentToolDetails {
729
787
  return {
730
788
  status: record.status,
789
+ outcome: projectOutcome(record),
731
790
  mode: record.mode,
732
791
  agent: record.agent,
733
792
  model: record.model,
@@ -24,6 +24,8 @@
24
24
  // 设计参考:session-runner 的 MF-3/MF-4 setTimeout→SIGTERM 骨架(复用 timer 形态,
25
25
  // 触发条件重构);spawnedChildren Map 的模块级单例模式。
26
26
 
27
+ import { assertSafeTimerDelay } from "../shared/timer-delay.ts";
28
+
27
29
  // ============================================================
28
30
  // 默认常量
29
31
  // ============================================================
@@ -97,18 +99,38 @@ const idleTimers = new Map<string, IdleTimerEntry>();
97
99
  *
98
100
  * 调用时机(接入时由 session-runner 编排):`agent_settled`(支柱四,真空闲边界)→ arm。
99
101
  *
102
+ * [预算语义对齐] 显式禁用:timeoutMs 传 0/负数 → 不挂 timer 并 disarm 已有的
103
+ * (idle GC 可被显式关闭,旧实现 0 会落成 setTimeout(0) 立即 kill——危险 footgun)。
104
+ * 默认行为不变(资源回收性质,默认值保留):不传 → env XYZ_SUBAGENT_IDLE_TIMEOUT_MS
105
+ * → DEFAULT_IDLE_TIMEOUT_MS(5min)。env 频道不认识禁用值:非法(<=0)回落默认。
106
+ *
100
107
  * @param recordId subagent record id(sa-<uuid>)
101
108
  * @param onTimeout 超时回调(调用方注入:SIGTERM 回收进程)
102
- * @param timeoutMs 可选。SP-6 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > DEFAULT_IDLE_TIMEOUT_MS
109
+ * @param timeoutMs 可选。SP-6 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > DEFAULT_IDLE_TIMEOUT_MS
110
+ * 显式 <=0 表示禁用(不挂 timer)。
111
+ * @throws 解析后的 delay(参数/env/默认任一层)超出 Node setTimeout 上限 2^31-1 ——
112
+ * 溢出值会被 Node 置 1ms 立即触发(「长空闲保活」变「立即回收」),fail-fast 不静默
113
+ * clamp(U1)。
103
114
  */
104
115
  export function armIdleTimer(
105
116
  recordId: string,
106
117
  onTimeout: () => void,
107
118
  timeoutMs?: number,
108
119
  ): void {
120
+ // 显式禁用通道:参数明确传 0/负数 → 关闭该 record 的 idle GC(顺带清已有 timer,
121
+ // 否则早前默认 arm 的 timer 仍在跑,禁用形同虚设)。
122
+ if (timeoutMs !== undefined && timeoutMs <= 0) {
123
+ disarmIdleTimer(recordId);
124
+ return;
125
+ }
126
+
109
127
  // SP-6 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > 默认 300000ms (5min)。
110
128
  const resolved = timeoutMs ?? getEnvIdleTimeoutMs() ?? DEFAULT_IDLE_TIMEOUT_MS;
111
129
 
130
+ // [U1] arm 入口:值流入 setTimeout 前校验安全域(>2^31-1 会变 1ms 立即触发)。
131
+ // 显式 <=0 的禁用通道已在上方 return,不受影响。
132
+ assertSafeTimerDelay(resolved, "idleTimeoutMs");
133
+
112
134
  // 刷新:先清旧 timer,避免同一 record 叠加多个 armed timer。
113
135
  disarmIdleTimer(recordId);
114
136
 
@@ -132,11 +132,26 @@ export class ModelConfigService {
132
132
  return resolveModel(config, this.modelRegistry!, override, ctxModel ?? this._ctxModel);
133
133
  }
134
134
 
135
- /** 查询 agent 配置(SubagentService 内部判定 defaultBackground 用)。 */
135
+ /** 查询 agent 配置(SubagentService 内部判定 defaultBackground 用)。
136
+ * undefined = 合法缺省语义(未点名 / 默认 general-purpose 形态)。 */
136
137
  getAgentConfig(agentRef?: string): AgentConfig | undefined {
137
138
  return agentRef ? this.agentRegistry.loadByPath(agentRef) : undefined;
138
139
  }
139
140
 
141
+ /**
142
+ * 查询 agent 配置——显式 ref 失败即 throw(SubagentService.resolveIdentity 用)。
143
+ *
144
+ * 与 getAgentConfig 的语义分界(「用户显式点名」vs「默认 general-purpose」):
145
+ * 用户显式点名的 agentRef(工具 agent 参数 / workflow agent({agent}) opts)解析
146
+ * 失败 = 配置错误,必须显式报错——错误文案含 <available_subagents> 恢复指引
147
+ * (对齐 workflow name not found 反馈风格),不允许静默降级为无配置
148
+ * general-purpose 形态(systemPrompt/工具白名单全丢且零反馈)。默认形态
149
+ * (不传 agent)走 getAgentConfig:undefined = 合法缺省,走 override → ctxModel 兑底。
150
+ */
151
+ getRequiredAgentConfig(agentRef: string): AgentConfig {
152
+ return this.agentRegistry.loadByPath(agentRef, true);
153
+ }
154
+
140
155
  // ── 配置读取(subagent-service 调)────────────────────────
141
156
 
142
157
  /** 全局配置深拷贝(调用方拿到副本,改不影响 Service 内部)。 */
@@ -8,6 +8,21 @@
8
8
  // 设计:默认与主 agent 同模型(零配置)。只有「有人显式指定 model」时才查
9
9
  // registry 做解析 + 鉴权校验。thinkingLevel 同链路,无指定时 undefined。
10
10
 
11
+ // [U1 ModelRef 全等裁决] THINKING_ORDER / ThinkingLevel / strip / 裁决入口收拢到
12
+ // shared/model-ref.ts(单一权威),此处 re-export 保持既有 import 路径不变
13
+ //(subagent-tool / tool-workflow 的 schema 枚举从本模块派生)。
14
+ import {
15
+ THINKING_ORDER,
16
+ assertCanonicalModelRef,
17
+ modelRefFromVerified,
18
+ } from "../shared/model-ref";
19
+
20
+ export { THINKING_ORDER };
21
+ export type { ThinkingLevel } from "../shared/model-ref";
22
+
23
+ /** 解析失败时错误信息列出的可用模型上限(防超长错误信息)。 */
24
+ const MODEL_LIST_LIMIT = 20;
25
+
11
26
  /**
12
27
  * ModelRegistry 的最小接口(duck-typed,测试可 mock)。
13
28
  * 字段结构与 Pi SDK 的 ctx.modelRegistry 对齐。
@@ -66,13 +81,8 @@ export interface ResolvedModel {
66
81
  // 常量
67
82
  // ============================================================
68
83
 
69
- /** thinking level 支持顺序(低→高),用于 clamp model 可用级别。
70
- * SSOT(单一权威源):新增 thinking 级别只改此处,subagent-tool 的 thinkingLevel 枚举
71
- * 从本常量派生,避免两处硬编码不同步。 */
72
- export const THINKING_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
73
-
74
- /** 解析失败时错误信息列出的可用模型上限(防超长错误信息)。 */
75
- const MODEL_LIST_LIMIT = 20;
84
+ // MODEL_LIST_LIMIT THINKING_ORDER suggestSimilarModels/lookupModel 一并迁入
85
+ // shared/model-ref.ts(U1 裁决单一入口)。
76
86
 
77
87
  // ============================================================
78
88
  // 解析
@@ -129,7 +139,10 @@ export function resolveModel(
129
139
  }
130
140
 
131
141
  // 3. 主 agent model(直接透传)。无显式 thinking → 兜底最高可用档。
142
+ // [U1 D2 豁免口径] ctxModel 是运行时已验证的 ModelInfo 对象,豁免 registry 存在性
143
+ // 复查与 auth 校验;但孪生守卫同等适用(registry 含大小写孪生时拒绝放行,modelRefFromVerified)。
132
144
  if (ctxModel) {
145
+ modelRefFromVerified(ctxModel, modelRegistry);
133
146
  return {
134
147
  model: ctxModel,
135
148
  thinkingLevel: paramOverride?.thinkingLevel ?? agentConfig?.thinkingLevel
@@ -150,9 +163,12 @@ export function resolveModel(
150
163
  /**
151
164
  * lookup + auth 校验 + thinkingLevel clamp。显式指定但失败 → 抛错(不降级)。
152
165
  *
166
+ * [U1 D1] 模型串裁决经 assertCanonicalModelRef 单一入口(strip 后缀 → provider 精确 →
167
+ * 全等匹配 → 孪生守卫;未命中同步抛错并附问句式纠错候选)。放行即与 registry 条目全等。
168
+ *
153
169
  * 错误信息区分两种失败(避免误导排查方向):
154
- * - model 不存在 提示检查拼写 + 列出相近可用 model
155
- * - model 存在但 auth 未配置 → 提示在 models.json 配置鉴权
170
+ * - model 非全等(不存在/大小写不符/孪生歧义)→ assertCanonicalModelRef 的问句式报错
171
+ * - model 全等命中但 auth 未配置 → 提示在 models.json 配置鉴权
156
172
  */
157
173
  function lookupAndResolve(
158
174
  modelStr: string,
@@ -160,11 +176,14 @@ function lookupAndResolve(
160
176
  registry: ModelRegistryLike,
161
177
  source: "paramOverride" | "agentConfig",
162
178
  ): ResolvedModel {
163
- const model = lookupModel(modelStr, registry);
179
+ const ref = assertCanonicalModelRef(modelStr, registry, { source });
180
+ const model = registry.find(ref.provider, ref.id);
164
181
  if (!model) {
182
+ // 裁决已按 getAvailable 全等命中;find 独立实现(duck-typed mock 可能不同源)时的
183
+ // 类型收窄兜底,非预期路径。
165
184
  throw new Error(
166
- `Model "${modelStr}" (${source}) not found in registry. ` +
167
- suggestSimilarModels(modelStr, registry),
185
+ `Model "${modelStr}" (${source}) passed the canonical ref check but registry.find missed it ` +
186
+ `(registry snapshot inconsistent). Retry with an exact entry from the available models list.`,
168
187
  );
169
188
  }
170
189
  if (!registry.hasConfiguredAuth(model)) {
@@ -197,53 +216,6 @@ function maxThinkingForModel(
197
216
  return model.reasoning ? "xhigh" : undefined;
198
217
  }
199
218
 
200
- /**
201
- * 解析 "provider/modelId" 并查 registry。
202
- *
203
- * 容错:剥离尾部 ":thinkingLevel" 后缀(off/minimal/low/medium/high/xhigh)。
204
- * 原因:LLM 常把平台复合标识 "provider/modelId:thinkingLevel"(如
205
- * "deepseek-router/ds-pro:xhigh")整体当 model 参数传入。registry 仅存
206
- * "provider/modelId"(无后缀),不剥离则 modelId="ds-pro:xhigh" 查不到。
207
- * 剥离后 thinkingLevel 仍由独立的 thinkingLevel 参数/resolveThinkingLevel 处理。
208
- *
209
- * modelId 可含 /,按第一个 / 分割 provider 与 modelId。
210
- */
211
- function lookupModel(modelStr: string, registry: ModelRegistryLike): ModelInfo | undefined {
212
- const cleanStr = stripThinkingSuffix(modelStr);
213
- const idx = cleanStr.indexOf("/");
214
- if (idx <= 0) return undefined;
215
- return registry.find(cleanStr.slice(0, idx), cleanStr.slice(idx + 1));
216
- }
217
-
218
- /**
219
- * 剥离模型字符串尾部 ":thinkingLevel" 后缀(如 "ds-pro:xhigh" → "ds-pro")。
220
- * 仅匹配合法 thinking level,避免误剥 "foo:bar" 这类无关冒号。
221
- * 返回去除后缀的字符串;无后缀则原样返回。
222
- */
223
- function stripThinkingSuffix(modelStr: string): string {
224
- // THINKING_ORDER 含 off/minimal/low/medium/high/xhigh,按长度降序拼正则避免短串误匹配
225
- const alt = THINKING_ORDER.slice().sort((a, b) => b.length - a.length).join("|");
226
- return modelStr.replace(new RegExp(`:(${alt})$`), "");
227
- }
228
-
229
- /**
230
- * 为 not-found 错误生成「相近可用 model」建议,辅助定位拼写错误。
231
- * 策略:取 provider/modelId 的末段,与每个可用 model 的末段做小写包含匹配,
232
- * 命中则列出。无命中则列出前 N 个全部可用 model(兜底)。空 registry 不列。
233
- */
234
- function suggestSimilarModels(modelStr: string, registry: ModelRegistryLike): string {
235
- const available = registry.getAvailable();
236
- if (available.length === 0) return "Registry has no available models.";
237
- const target = modelStr.split("/").pop()?.toLowerCase() ?? "";
238
- // ponytail: 末段子串包含足够定位拼写错误,无需编辑距离/相似度库
239
- const similar = available
240
- .filter((m) => m.id.toLowerCase().includes(target) || target.includes(m.id.toLowerCase()))
241
- .map((m) => `${m.provider}/${m.id}`)
242
- .slice(0, MODEL_LIST_LIMIT);
243
- const list = similar.length > 0 ? similar : available.slice(0, MODEL_LIST_LIMIT).map((m) => `${m.provider}/${m.id}`);
244
- return `Check the model string (maybe a typo, or a ":thinkingLevel" suffix that should be passed via the thinkingLevel param instead). Similar available models:\n ${list.join("\n ")}`;
245
- }
246
-
247
219
  /**
248
220
  * 从 model.thinkingLevelMap 提取可用级别,clamp 到最高可用。
249
221
  * model.reasoning === false → undefined(不支持 thinking)