@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
@@ -31,6 +31,7 @@ import type { AgentOutcome, EngineHandle } from "./engine/types.ts";
31
31
  import { mapToExecuteOptions, mergeTimeoutSignal } from "./execute-options-mapper.ts";
32
32
  import { getModelConfigService } from "./model-config-service.ts";
33
33
  import type { ModelInfo } from "./model-resolver.ts";
34
+ import { modelRefFromVerified } from "../shared/model-ref";
34
35
  import { registerSpawnedChildForRecord } from "./session-runner.ts";
35
36
  import type { SubagentStream } from "./stream-sink.ts";
36
37
  import type { SubagentService } from "./subagent-service.ts";
@@ -185,6 +186,19 @@ export class SubprocessAgentRunner implements AgentRunner {
185
186
  };
186
187
 
187
188
  try {
189
+ // ── [U1 D2] RunContext.modelRef 接入:ctxModel 继承路径的孪生守卫 ──
190
+ // ctxModel 是运行时已验证的 ModelInfo(豁免 registry 存在性复查),但继承产出的
191
+ // canonical 串与显式入参走同一个 pi pattern 引擎,孪生守卫同等适用(modelRefFromVerified)。
192
+ // 守卫在 engine.run 之前同步拒绝:含孪生 registry 下不产生任何 record/spawn,
193
+ // 失败走下方 catch → errorResult(错误文案含恢复指引)。
194
+ // RunContext 类型本身定义在 engine/port.ts(跨模块 port,不在本单元领地),
195
+ // 故接入点为构造 runCtx 前的守卫调用;pi 链路下游 resolveModel 的 ctxModel 分支
196
+ // 有同一守卫(model-resolver.ts),两处共用同一入口函数。
197
+ if (this.ctxModel) {
198
+ const modelService = getModelConfigService();
199
+ if (modelService) modelRefFromVerified(this.ctxModel, modelService.getModelRegistry());
200
+ }
201
+
188
202
  // ── D-A9: timeoutMs 合并 signal(超时 abort 带 HOST_TIMEOUT_ABORT_REASON 标记)──
189
203
  const mergedSignal = mergeTimeoutSignal(signal, opts.timeoutMs);
190
204
 
@@ -61,6 +61,37 @@ export type ExecutionStatus = "running" | "closed";
61
61
  */
62
62
  export type ClosedReason = 'parent-shutdown' | 'parent-fork' | 'parent-new' | 'user-close' | 'cancelled' | 'gc';
63
63
 
64
+ /** ClosedReason 全枚举值(运行时守卫用——防御性解析外部输入时校验成员资格)。 */
65
+ export const CLOSED_REASONS: readonly ClosedReason[] = [
66
+ 'parent-shutdown',
67
+ 'parent-fork',
68
+ 'parent-new',
69
+ 'user-close',
70
+ 'cancelled',
71
+ 'gc',
72
+ ];
73
+
74
+ /**
75
+ * 终态三态对外语义(U3 C-outcome 一等披露)。
76
+ *
77
+ * 由 completeRecord 唯一写入点按 deriveOutcome 一次计算(判定顺序:cancelled 优先
78
+ * → error 非空 → completed),消费方(project/list/notify 文案/渲染器)只读本字段,
79
+ * 不再各自手写成败推导 switch(三处同构 switch 已随 U3 收敛删除)。
80
+ *
81
+ * [D6 显式取舍] parent-shutdown/parent-fork/parent-new 合成关闭(subagent-service
82
+ * disposeAllRecords 合成 result 恒写 error:"closed due to ...")落 "failed"——语义为
83
+ * 「父进程关闭时子 agent 未完成即失败」,选定行为而非疏漏,勿当 bug 改回 cancelled。
84
+ */
85
+ export type ExecutionOutcome = "completed" | "failed" | "cancelled";
86
+
87
+ /**
88
+ * 对外投影的 outcome 联合:含历史 record(outcome 字段诞生前的存量数据)兼容态。
89
+ * 投影层(projectOutcome 唯一出口)对无 outcome 字段的 closed record 按
90
+ * deriveOutcome(closedReason, error) 兜底派生;"closed-legacy" 预留给连派生输入都
91
+ * 不足以判读的存量形态,消费方必须处理该成员(不得因未知值崩溃)。
92
+ */
93
+ export type ProjectedOutcome = ExecutionOutcome | "closed-legacy";
94
+
64
95
  /**
65
96
  * 对外四态(设计决策 10 细则 3):内部 ExecutionStatus(v4 B-1 两态)收敛为 agent
66
97
  * 可理解的状态语义。真实映射只有两条:
@@ -417,6 +448,12 @@ export interface ExecutionRecord {
417
448
  * 由 tryTransition(record, "closed", reason) 写入;投影层按需派生对外语义。
418
449
  * 向后兼容:旧 record 无此字段,按 gc 处理(通用完成/失败)。 */
419
450
  closedReason?: ClosedReason;
451
+ /**
452
+ * 终态三态对外语义(U3 C-outcome)。completeRecord 唯一写入点按 deriveOutcome
453
+ * 一次计算,消费方只读本字段不再自行推导。向后兼容:旧 record / 磁盘重建
454
+ * record 无此字段,投影层按 projectOutcome 兜底(closed-legacy 语义)。
455
+ */
456
+ outcome?: ExecutionOutcome;
420
457
  /** 完整执行内容,按 turn 组织。createRecord 初始化为 [空 turn]。 */
421
458
  turns: Turn[];
422
459
  /** turn 计数(= turns.filter(closed).length,冗余存储供投影直接读)。 */
@@ -520,6 +557,11 @@ export interface ExecutionRecord {
520
557
  */
521
558
  export interface SubagentToolDetails {
522
559
  status: ExecutionStatus;
560
+ /**
561
+ * 终态三态对外语义(U3 C-outcome,projectOutcome 唯一出口)。running → undefined;
562
+ * 历史数据无 outcome 字段时兜底派生(见 ProjectedOutcome)。
563
+ */
564
+ outcome?: ProjectedOutcome;
523
565
  mode: ExecutionMode;
524
566
  agent: string;
525
567
  model: string;
@@ -564,6 +606,10 @@ export interface ExecuteOptions {
564
606
  schema?: Record<string, unknown>;
565
607
  /** D-A6 bridge: workflow schemaEnv 经 ExecuteOptions 透传到 runSpawn childEnv。 */
566
608
  schemaEnv?: string;
609
+ /**
610
+ * Turn 上限 limiter。显式 0/负 = 显式不限:压过 SPAWN_WATCHDOG_ENV 兑底不挂
611
+ * watchdog(SP-6 参数 > env,U5);undefined 未传才由 env 兑底。
612
+ */
567
613
  maxTurns?: number;
568
614
  graceTurns?: number;
569
615
  /** sync 模式来自 Pi tool 框架;background 模式 hub 忽略,自建 controller。 */
@@ -587,6 +633,7 @@ export interface ExecuteOptions {
587
633
  /**
588
634
  * 空闲超时毫秒数(仅 conversation 模式有意义)。覆盖默认 5min idle timeout。
589
635
  * 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > 默认 300000ms。
636
+ * 显式传 0/负数 = 禁用 idle GC(不挂 timer;旧实现 0 会落成 setTimeout(0) 立即 kill)。
590
637
  */
591
638
  idleTimeoutMs?: number;
592
639
  /**
@@ -643,10 +690,13 @@ export interface SubagentListItem {
643
690
  /** 可冷路径 resume(running 且无活进程句柄)。[v4 A-6] B-1「可续聊」对外表达,
644
691
  * agent 据 list 判断哪些 running subagent 实际可续聊(vs 正在忙)。 */
645
692
  resumable?: boolean;
646
- /** L2 关闭原因子枚举(仅 status="closed" 时有意义)。[v4 A-6] SP-4 级联关闭告知
647
- * 替代——砍 before_agent_start 注入通道后,被级联关闭的 record 经 list
648
- * (includeFinished:true)可查,closedReason 显示 'parent-fork'/'parent-new' 等。 */
649
- closedReason?: ClosedReason;
693
+ /**
694
+ * 终态三态对外语义(U3 C-outcome 一等披露,projectOutcome 唯一出口):
695
+ * completed / failed / cancelled,历史 record 无 outcome 字段时兜底派生,
696
+ * 不可判读的存量形态为 "closed-legacy"。GUI pane / agent 据此判读成败,
697
+ * 无需翻 error 字段原文(S5)。
698
+ */
699
+ outcome?: ProjectedOutcome;
650
700
  }
651
701
 
652
702
  /** background 启动的内层响应(挂在 SubagentToolResult.bgResponse)。 */
@@ -655,6 +705,19 @@ export interface BgResponse {
655
705
  mode: "background";
656
706
  /** 启动提示文案("detached, will notify on completion")。 */
657
707
  message: string;
708
+ /**
709
+ * 终态三态语义(U3 C-outcome 对外 JSON 契约完备位)。start 时点 record 尚未终态,
710
+ * 恒 undefined(JSON.stringify 落键省略);终态成败语义经 list items[].outcome
711
+ * 披露。旧字段 status/mode/message 原样保留(向后兼容)。
712
+ */
713
+ outcome?: ProjectedOutcome;
714
+ /**
715
+ * 通知投递契约回显位(U1 预置,U2 通知账本的契约声明)。恒值
716
+ * "ledger+at-least-once":主 agent 在当前 run 结束或有限延迟内收到完成通知,
717
+ * 送达保证为 at-least-once + notifyId 幂等可识别。字段与填充由 U1 负责,
718
+ * 值语义由 U2(execution/notify-ledger.ts)兑现。
719
+ */
720
+ notifyContract: "ledger+at-least-once";
658
721
  }
659
722
 
660
723
  /** list 的内层响应(挂在 SubagentToolResult.listResponse)。 */
@@ -697,7 +760,7 @@ export interface CloseResponse {
697
760
  * - close → closeResponse(subagentId 有值;sessionFile 无意义,可为 null)
698
761
  */
699
762
  export type SubagentToolResult =
700
- | { action: "start"; subagentId: string; sessionFile: string | null; slug: string; bgResponse: BgResponse; __gui__?: GuiRenderResult }
763
+ | { action: "start"; subagentId: string; sessionFile: string | null; slug: string; /** registry 全等回显(U1):放行即与 registry 条目全等,"provider/id" 形态。 */ model: string; bgResponse: BgResponse; __gui__?: GuiRenderResult }
701
764
  | { action: "list"; subagentId: null; sessionFile: null; listResponse: ListResponse; __gui__?: GuiRenderResult }
702
765
  | { action: "cancel"; subagentId: string; sessionFile: null; cancelResponse: CancelResponse; __gui__?: GuiRenderResult }
703
766
  | { action: "message"; subagentId: string; sessionFile: null; messageResponse: MessageResponse; __gui__?: GuiRenderResult }
@@ -55,10 +55,20 @@ export function createUiRequestQueue(
55
55
  if (processing || queue.length === 0 || closed) return;
56
56
  processing = true;
57
57
  const { id, request, signal } = queue.shift()!;
58
- handleUiRequest(child, id, request, ctx, signal).finally(() => {
59
- processing = false;
60
- processNext();
61
- });
58
+ // [F2] .catch 在 .finally 之前:handleUiRequest 是 async 函数,任何同步异常(parseChannel
59
+ // 解析 throw / respond → writeStdinLine 的 EPIPE 同步 throw / catch 分支内 respond 再次
60
+ // throw)都会变成 rejection。旧链只有 .finally——rejection 穿透后无人接 →
61
+ // unhandledRejection(Node 15+ 默认 mode=throw)可崩父进程。记 error 后吞掉,
62
+ // .finally 照常释放 processing 推进队列(单个请求失败不阻塞后续 UI 请求)。
63
+ handleUiRequest(child, id, request, ctx, signal)
64
+ .catch((err: unknown) => {
65
+ const m = err instanceof Error ? err.message : String(err);
66
+ logger.error(`[subagents] ui request ${id} (${request.method}) failed unexpectedly: ${m}`);
67
+ })
68
+ .finally(() => {
69
+ processing = false;
70
+ processNext();
71
+ });
62
72
  }
63
73
 
64
74
  // [R3] 子进程退出时 abort 所有 pending handler,队列不再阻塞
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
 
19
- import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
19
+ import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
20
20
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
21
21
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
22
22
 
@@ -39,6 +39,7 @@ import {
39
39
  ModelConfigService,
40
40
  setModelConfigService,
41
41
  } from "./execution/model-config-service.ts";
42
+ import { bindNotifyLedgerHost, getBoundNotifyLedger, type NotifyLedgerHost } from "./execution/notify-ledger.ts";
42
43
  import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./execution/session-reconstructor.ts";
43
44
  import type { ExecutionMode, SubagentRecord } from "./execution/types.ts";
44
45
  import { maybeCleanupExpiredSessionFiles } from "./execution/session-file-gc.ts";
@@ -410,6 +411,39 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
410
411
  }
411
412
  }
412
413
 
414
+ // ── [U2] 通知账本装配 + 重启恢复(设计 D4:存在性 / 可达性分离)──
415
+ // bind 先于 service.initSession(notifier.revive 在其内——notify() 经
416
+ // getBoundNotifyLedger 消费账本)。recoverFromSession 扫 ledger/ack 两列 entry
417
+ // 差集:未销账号重放投递(已销账零重发,notifyId 幂等);fork 继承未销账
418
+ // pending 属可接受语义(D4 归属规则——扫描域 = 单 session 文件,幂等键作用域
419
+ // 随文件域隔离)。compaction 存活情况归 session_compact handler 的条件降级(P-B4
420
+ // 探针阶段 5 实测,见 notify-ledger.ts compactionCheck)。
421
+ try {
422
+ const ledgerHost: NotifyLedgerHost = {
423
+ appendLedgerEntry: (customType, data) => {
424
+ pi.appendEntry(customType, data);
425
+ },
426
+ readSessionEntries: () => ctx.sessionManager.getEntries(),
427
+ isIdle: () => ctx.isIdle(),
428
+ onAgentSettled: (handler) => {
429
+ pi.on("agent_settled", handler);
430
+ },
431
+ sendDelivery: (message) => {
432
+ // D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
433
+ // courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
434
+ pi.sendMessage(message, { triggerTurn: true });
435
+ },
436
+ };
437
+ // U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
438
+ // 通道落盘),此处不再重复打日志。
439
+ bindNotifyLedgerHost(ledgerHost).recoverFromSession();
440
+ } catch (err) {
441
+ // 账本装配失败不阻断 session_start(通知退回 notifier 的内核路径)
442
+ logger.warn("[subagents] notify ledger bind failed", {
443
+ reason: err instanceof Error ? err.message : String(err),
444
+ });
445
+ }
446
+
413
447
  // ── subagents 域:双 Service 装配 ──
414
448
  const existingService = getSubagentService();
415
449
  const existingModelService = getModelConfigService();
@@ -588,6 +622,25 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
588
622
  });
589
623
  });
590
624
 
625
+ // ════════════════════════════════════════════════════════════
626
+ // [U2 P-B4 降级] session_compact:compaction 对 custom entry 保留行为实装未
627
+ // 验证——检测 ledger/ack entry 被 compaction 清除时按内存态补写(notify-ledger
628
+ // compactionCheck;未清除则 no-op)。内存态在 compaction 后仍活着,作为补写源;
629
+ // 重启后的权威仍是两列 entry 差集(内存不承担销账职责)。
630
+ // ═══════════════════════════════════════════════════════
631
+ pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
632
+ try {
633
+ const rewritten = getBoundNotifyLedger()?.compactionCheck() ?? 0;
634
+ if (rewritten > 0) {
635
+ logger.warn(`[subagents] notify ledger entries lost to compaction; rewrote ${rewritten} from memory`);
636
+ }
637
+ } catch (err) {
638
+ logger.warn("[subagents] notify ledger compactionCheck failed", {
639
+ reason: err instanceof Error ? err.message : String(err),
640
+ });
641
+ }
642
+ });
643
+
591
644
  // ════════════════════════════════════════════════════════════
592
645
  // [U7] before_agent_start:引擎模型段注入(defaultEngine 非 pi 且引擎实现
593
646
  // listModels 时追加 <available_<engine>_models>——每 turn 重判 config,改配置
@@ -0,0 +1,157 @@
1
+ // subagent tool 路径类参数守卫测试(三通道对称审查修复 + MF-13)。
2
+ //
3
+ // 修复背景:subagent tool 的 skillPath 参数此前零校验直传 session-runner 拼
4
+ // `--skill <path>`;cwd 仅 description 声明 "Must be an absolute path" 无运行时
5
+ // 闸。schema pattern(^/)经 pi agent-loop 运行时强校验已是强制(PS-20:
6
+ // agent-loop.js:403-404 → validation.js:247-273),工具层守卫定位 = defense-in-depth
7
+ // + schema 表达力缺口——`..` 穿越语义超出 pattern 能力(^/ 放行 "/a/../b"),
8
+ // 守卫在 executeSubagent start 分支 immediate throw(与 action 枚举守卫同风格)。
9
+ //
10
+ // 本文件锁住:
11
+ // 1. skillPath / cwd 含 `..` 穿越段 → 同步 reject,service.execute 零触达
12
+ // 2. skillPath / cwd 相对路径 → 同步 reject
13
+ // 3. 合法绝对路径放行 → 参数原样透传 service.execute(守卫零误伤对照)
14
+ //
15
+ // harness 复用 sdk-contract.test.ts 的 mock 链(pi-ai/typebox/subagent-service),
16
+ // registerSubagentTool 后捕获 execute 回调直接调用。
17
+
18
+ import { describe, expect, it, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@earendil-works/pi-ai", () => ({
21
+ StringEnum: (values: string[]) => ({ type: "string", enum: values }),
22
+ }));
23
+ vi.mock("typebox", () => ({
24
+ Type: {
25
+ Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
26
+ Optional: (schema: unknown) => ({ ...(schema as object), optional: true }),
27
+ String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
28
+ Boolean: () => ({ type: "boolean" }),
29
+ Number: (opts?: Record<string, unknown>) => ({ type: "number", ...opts }),
30
+ Array: (items: unknown) => ({ type: "array", items }),
31
+ Record: (key: unknown, value: unknown) => ({ type: "object", additionalProperties: value, key }),
32
+ Unknown: () => ({ type: "unknown" }),
33
+ Union: (members: unknown[]) => ({ type: "union", members }),
34
+ Literal: (value: unknown) => ({ type: "literal", value }),
35
+ },
36
+ }));
37
+
38
+ // Mock getSubagentService:断言「守卫拒绝时 service.execute 零触达」的承重证据。
39
+ const { mockServiceExecute } = vi.hoisted(() => ({
40
+ mockServiceExecute: vi.fn(),
41
+ }));
42
+ vi.mock("../../execution/subagent-service.ts", () => ({
43
+ getSubagentService: () => ({ execute: mockServiceExecute }),
44
+ }));
45
+
46
+ import { registerSubagentTool } from "../../interface/subagent-tool.ts";
47
+ import { mockExtensionApi } from "../../execution/__tests__/helpers/mock-extension-api.ts";
48
+
49
+ type ExecuteCb = (...args: unknown[]) => Promise<unknown>;
50
+
51
+ /** 注册 tool 并捕获 execute 回调(sdk-contract 同款)。 */
52
+ function captureExecute(): ExecuteCb {
53
+ let captured: ExecuteCb | undefined;
54
+ const pi = mockExtensionApi({
55
+ registerTool: (tool: unknown) => {
56
+ captured = (tool as { execute: ExecuteCb }).execute;
57
+ },
58
+ });
59
+ registerSubagentTool(pi);
60
+ if (!captured) throw new Error("subagent tool not registered");
61
+ return captured;
62
+ }
63
+
64
+ /** 合法 start 入参基线(守卫测试只变动 skillPath/cwd 字段)。 */
65
+ function baseParams(over: Record<string, unknown> = {}): Record<string, unknown> {
66
+ return { action: "start", task: "guard task", slug: "path-guard", ...over };
67
+ }
68
+
69
+ /** 合法 execute 返回值 stub(放行路径 adapter 包装用)。 */
70
+ function stubHandle() {
71
+ return {
72
+ mode: "background",
73
+ subagentId: "sa-path-guard",
74
+ sessionFile: "/tmp/session.jsonl",
75
+ details: { slug: "path-guard", model: "test/model" },
76
+ };
77
+ }
78
+
79
+ describe("subagent tool 路径守卫(skillPath/cwd:绝对路径 + 禁 .. 穿越)", () => {
80
+ let execute: ExecuteCb;
81
+
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ mockServiceExecute.mockResolvedValue(stubHandle());
85
+ execute = captureExecute();
86
+ });
87
+
88
+ // ── `..` 穿越段拒绝 ──
89
+
90
+ it("skillPath 含 .. 穿越段 → immediate reject,service.execute 零触达", async () => {
91
+ await expect(
92
+ execute("call-1", baseParams({ skillPath: "../../etc/passwd" }), undefined, undefined, undefined),
93
+ ).rejects.toThrow(/skillPath must not contain '\.\.' path segments/);
94
+
95
+ expect(mockServiceExecute).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("cwd 含 .. 穿越段 → immediate reject,service.execute 零触达", async () => {
99
+ await expect(
100
+ execute("call-2", baseParams({ cwd: "/safe/root/../../unsafe" }), undefined, undefined, undefined),
101
+ ).rejects.toThrow(/cwd must not contain '\.\.' path segments/);
102
+
103
+ expect(mockServiceExecute).not.toHaveBeenCalled();
104
+ });
105
+
106
+ // ── 相对路径拒绝(`~` 缩写不是绝对路径,一并拒)──
107
+
108
+ it("skillPath 相对路径 → immediate reject(报错指引展开为绝对路径)", async () => {
109
+ await expect(
110
+ execute("call-3", baseParams({ skillPath: ".agents/skills/my-skill" }), undefined, undefined, undefined),
111
+ ).rejects.toThrow(/skillPath must be an absolute path.*Expand '~' yourself/s);
112
+
113
+ expect(mockServiceExecute).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("cwd 相对路径 → immediate reject", async () => {
117
+ await expect(
118
+ execute("call-4", baseParams({ cwd: "relative/dir" }), undefined, undefined, undefined),
119
+ ).rejects.toThrow(/cwd must be an absolute path/);
120
+
121
+ expect(mockServiceExecute).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("cwd `~` 缩写 → immediate reject(下游 spawn cwd 不展开 ~)", async () => {
125
+ await expect(
126
+ execute("call-5", baseParams({ cwd: "~/project" }), undefined, undefined, undefined),
127
+ ).rejects.toThrow(/cwd must be an absolute path/);
128
+
129
+ expect(mockServiceExecute).not.toHaveBeenCalled();
130
+ });
131
+
132
+ // ── 放行对照(守卫零误伤)──
133
+
134
+ it("合法绝对路径 skillPath/cwd 放行,参数原样透传 service.execute", async () => {
135
+ await execute(
136
+ "call-ok",
137
+ baseParams({ skillPath: "/work/project/.agents/skills/my-skill", cwd: "/work/project" }),
138
+ undefined,
139
+ undefined,
140
+ undefined,
141
+ );
142
+
143
+ expect(mockServiceExecute).toHaveBeenCalledTimes(1);
144
+ expect(mockServiceExecute).toHaveBeenCalledWith(
145
+ expect.objectContaining({
146
+ skillPath: "/work/project/.agents/skills/my-skill",
147
+ cwd: "/work/project",
148
+ }),
149
+ );
150
+ });
151
+
152
+ it("不传 skillPath/cwd → 守卫不介入(undefined 合法缺省)", async () => {
153
+ await execute("call-none", baseParams(), undefined, undefined, undefined);
154
+
155
+ expect(mockServiceExecute).toHaveBeenCalledTimes(1);
156
+ });
157
+ });
@@ -114,6 +114,18 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
114
114
  expect(DESCRIPTION).not.toContain('"sa_');
115
115
  });
116
116
 
117
+ it("Examples 示例 agent 值必须是绝对路径 .md 形态(显式 ref 硬守卫拒绝裸名)", () => {
118
+ // 显式 agent ref 走硬守卫:getRequiredAgentConfig → loadByPath(ref, true),
119
+ // 裸名(normalizeRef 非绝对路径 → null)同步 throw `Invalid agent ref` +
120
+ // <available_subagents> 恢复指引(explicit-agent-ref-guard.test.ts 锁定拒绝路径)。
121
+ // 示例若教裸名(历史漂移:"agent":"coder"),弱模型照抄即必败调用——浪费一轮
122
+ // 并系统性教唆反模式。与 subagentId 格式测试同风格:示例必须与实际契约一致。
123
+ // ① 零裸名:任何 "agent":"<非/>" 形态都禁止
124
+ expect(DESCRIPTION).not.toMatch(/"agent":"(?!\/)[^"]*"/);
125
+ // ② 正例在位且为绝对路径 .md 形态(<available_subagents> 注入的 <location> 形态)
126
+ expect(DESCRIPTION).toMatch(/"agent":"\/[^"]*\.md"/);
127
+ });
128
+
117
129
  it("agent 字段 description 不写死枚举,指向 <available_subagents>(通用化防漂移)", () => {
118
130
  // 原实现把 9 个内置 agent 名写死在 schema field description(防漏),
119
131
  // 但枚举与包内 agents/*.md 存在漂移风险(新增/删除 agent 要手改两处)。
@@ -23,6 +23,9 @@ import type { Component } from "@earendil-works/pi-tui";
23
23
  import type { Theme } from "@earendil-works/pi-coding-agent";
24
24
 
25
25
  import { displayAgentName } from "../shared/agent-ref.ts";
26
+ import { deriveOutcome } from "../execution/execution-record.ts";
27
+ import { CLOSED_REASONS } from "../execution/types.ts";
28
+ import type { ClosedReason, ExecutionOutcome } from "../execution/types.ts";
26
29
  import {
27
30
  firstLine,
28
31
  padToVisible,
@@ -55,8 +58,10 @@ interface BgNotifyRecord {
55
58
  id: string;
56
59
  /** v4 B-1: closed(终态,含 cancelled)或 running(对话模式轮次完成,旧 idle)。 */
57
60
  status: "running" | "closed";
58
- /** L2 关闭原因子枚举(仅 status="closed" 时有意义)。 */
59
- closedReason?: string;
61
+ /** L2 关闭原因子枚举(内部诊断 + outcome 兑底派生输入;经 toClosedReason 防御性收窄)。 */
62
+ closedReason?: ClosedReason;
63
+ /** 终态三态对外语义(U3 C-outcome)。缺失(升级前旧消息重放)时按 deriveOutcome 兑底。 */
64
+ outcome?: ExecutionOutcome;
60
65
  agent: string;
61
66
  model?: string;
62
67
  result?: string;
@@ -218,12 +223,15 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
218
223
  const modelPart = record.model
219
224
  ? ` ${t.fg("dim", "·")} ${t.fg("accent", truncLine(record.model, MODEL_MAX_WIDTH))}`
220
225
  : "";
221
- // v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生文案。
222
- const reason = record.closedReason ?? "gc";
226
+ // U3 C-outcome:verb 与正文分流只读 outcome——单一权威派生(升级前旧消息重放等
227
+ // details 缺 outcome 的存量形态经 deriveOutcome(closedReason, error) 兑底,非同构
228
+ // 重写)。判定先于 patchFile:failed 分支不展示 patch/result(失败轮也会写
229
+ // patchFile,历史 bug 存档见 deriveOutcome 注释)。
230
+ const outcome = record.outcome ?? deriveOutcome(record.closedReason, record.error);
223
231
  let verb: string;
224
- if (reason === "cancelled") {
232
+ if (outcome === "cancelled") {
225
233
  verb = "cancelled";
226
- } else if (reason === "gc" && record.error) {
234
+ } else if (outcome === "failed") {
227
235
  verb = "failed";
228
236
  } else {
229
237
  verb = "finished";
@@ -233,13 +241,12 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
233
241
 
234
242
  switch (record.status) {
235
243
  case "closed": {
236
- // v4 B-1: closed 统一终态(含 cancelled)。cancelled 无正文;失败显示错误;否则结果/patch。
237
- const r = record.closedReason ?? "gc";
238
- if (r === "cancelled") {
244
+ // U3 C-outcome:cancelled 无正文;failed 显示错误;否则结果/patch(同上,分流只读 outcome)。
245
+ if (outcome === "cancelled") {
239
246
  return [head];
240
247
  }
241
- if (r === "gc" && record.error) {
242
- return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error)}`, BODY_MAX_WIDTH))];
248
+ if (outcome === "failed") {
249
+ return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error ?? "")}`, BODY_MAX_WIDTH))];
243
250
  }
244
251
  if (!record.result && !record.patchFile) return [head];
245
252
  const lines: string[] = [];
@@ -275,6 +282,19 @@ function extractBatch(details: unknown): BgNotifyRecord[] | undefined {
275
282
  return records.length > 0 ? records : undefined;
276
283
  }
277
284
 
285
+ /**
286
+ * details.closedReason 防御性收窄:任意字符串 → ClosedReason | undefined。
287
+ * 旧数据/外部构造的非法值按缺失处理,交由 deriveOutcome 兑底(消费方不崩溃)。
288
+ */
289
+ function toClosedReason(value: unknown): ClosedReason | undefined {
290
+ return CLOSED_REASONS.find((reason) => reason === value);
291
+ }
292
+
293
+ /** details.outcome 防御性收窄:仅接受三态枚举值,其余按缺失处理。 */
294
+ function toOutcome(value: unknown): ExecutionOutcome | undefined {
295
+ return value === "completed" || value === "failed" || value === "cancelled" ? value : undefined;
296
+ }
297
+
278
298
  /**
279
299
  * 从 message.details 防御性提取 BgNotifyRecord。
280
300
  * 结构不全(缺 status / agent)返回 undefined。
@@ -297,7 +317,8 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
297
317
  model: typeof d.model === "string" ? d.model : undefined,
298
318
  result: typeof d.result === "string" ? d.result : undefined,
299
319
  error: typeof d.error === "string" ? d.error : undefined,
300
- closedReason: typeof d.closedReason === "string" ? d.closedReason : undefined,
320
+ closedReason: toClosedReason(d.closedReason),
321
+ outcome: toOutcome(d.outcome),
301
322
  round: typeof d.round === "number" ? d.round : undefined,
302
323
  // [MF#1] 提取 patchFile(worktree background 完成通知携带)。
303
324
  patchFile: typeof d.patchFile === "string" ? d.patchFile : undefined,
@@ -238,7 +238,7 @@ export function notifyDone(
238
238
 
239
239
  const content = parts.join("\n");
240
240
 
241
- // deliverAs:"steer" + triggerTurn:true —— workflow 完成作为 steering 消息注入
241
+ // deliverAs:"steer" + triggerTurn:true —— workflow 完成作为 steering 消息注入(g4-allow: 存量待迁移——结果语义通知,账本化迁移登记 pi-boundary-reliability 附录 B 待办)
242
242
  // 并立即唤醒 parent agent 处理结果(与 subagent 的 followUp+triggerTurn 对称)
243
243
  const details: WorkflowNotifyDetails = {
244
244
  runId,
@@ -275,7 +275,7 @@ export function notifyDone(
275
275
  display: true,
276
276
  details,
277
277
  },
278
- { triggerTurn: true, deliverAs: "steer" },
278
+ { triggerTurn: true, deliverAs: "steer" }, // g4-allow: 存量待迁移——workflow 完成通知属结果语义,迁移切片复用 U2 账本设施(附录 B 待办)
279
279
  );
280
280
  }
281
281