@zhushanwen/pi-subagent-workflow 7.0.1 → 7.2.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.
@@ -145,6 +145,19 @@ export interface SubagentServiceSessionInit {
145
145
  /** background 优先级(保留 priority 排序机制,单一值)。 */
146
146
  const PRIORITY_BACKGROUND = 1000;
147
147
 
148
+ /** 跨进程身份贯穿的 env 名(父进程 spawn 子进程时注入,子进程 initSession 读取)。
149
+ * 仿照 PI_SUBAGENT_FORK_DEPTH 机制,让递归 subagent 的身份(rootSessionId / parentRecordId / depth)
150
+ * 跨进程传递,使主进程 /subagents 能看到完整递归树(设计见 docs/design/recursive-subagent-visibility.md)。
151
+ * 语义:env 描述「子进程自己的身份」,不是父的身份(决策 1)。
152
+ * [MF-3] 第 4 个 env:真 ROOT 的 cwd(PI_SUBAGENT_ROOT_CWD)。worktree 模式下子进程 spawn cwd =
153
+ * checkout 路径,若按各自 cwd 编码落盘目录,深层 record 写到 enc(worktree) 段、ROOT 磁盘重建
154
+ * 扫不到 → 全树可见性深度 ≥ 2 断裂。子进程经本 env 拿 ROOT cwd,sessions 与 records 两套目录
155
+ * 统一编码在 enc(ROOT cwd) 段(与身份贯穿同构,见 session-runner 注入点)。 */
156
+ const ENV_ROOT_SESSION_ID = "PI_SUBAGENT_ROOT_SESSION_ID";
157
+ const ENV_SELF_RECORD_ID = "PI_SUBAGENT_SELF_RECORD_ID";
158
+ const ENV_DEPTH = "PI_SUBAGENT_DEPTH";
159
+ const ENV_ROOT_CWD = "PI_SUBAGENT_ROOT_CWD";
160
+
148
161
  /** 触发 onUpdate 的事件类型(streaming delta 不触发,避免每 token 刷新)。 */
149
162
  const TRIGGERING_EVENT_TYPES = new Set<AgentEvent["type"]>([
150
163
  "tool_start",
@@ -196,8 +209,31 @@ export class SubagentService {
196
209
  /** UI 请求可观测性(sessionMode + handler 缺失告警去重,提取自本类降低行数)。 */
197
210
  private readonly uiObservability = new UiRequestObservability();
198
211
  private pi: PiLike | null = null;
199
- /** 当前 Pi session IDsession 隔离过滤用)。initSession 时注入。 */
212
+ /** 当前 Pi session ID(本进程 pi session,事件路由等用;record 过滤不用它)。initSession 时注入。 */
200
213
  private sessionId: string | null = null;
214
+ /** 所属根 session ID(record 归属过滤用)。根进程 = sessionId(自己是 root);
215
+ * 子进程 = env PI_SUBAGENT_ROOT_SESSION_ID 贯穿的真 ROOT(initSession 读取)。
216
+ * 与 sessionId 正交:sessionId 是本进程 pi session(事件路由等),sessionRootId 是所属根
217
+ * (collectRecords filter 用,与 createRecordForMode 的 rootSessionId 盖章同源——子进程
218
+ * 因此看到整棵 ROOT 树)。设计见 recursive-subagent-visibility.md 决策 3。 */
219
+ private sessionRootId: string | null = null;
220
+ /** 进程级执行上下文基线(不依赖 ALS 贯穿——pi RPC mode 的 stdin JSONL 是事件回调式
221
+ * (attachJsonlLineReader stream.on("data")),每个命令是独立异步链,initSession 里
222
+ * execCtxAls.enterWith 的 store 不会贯穿到后续 tool 调用事件(实测:递归第二层
223
+ * parentRecordId/depth 丢失而 rootSessionId 正确——rootSessionId 是实例字段所以不受影响)。
224
+ * 基线 = 本进程自己的身份(initSession 从 env 读取,与 sessionRootId 同机制):
225
+ * 读 ALS store 失败时兜底,保证「本进程派发的 subagent 都是本进程记录的孩子」
226
+ * 这一跨进程树形关系成立。
227
+ * initSession 设置:有 env PI_SUBAGENT_SELF_RECORD_ID → {recordId: env 值, depth: env DEPTH};
228
+ * 无 env(根进程)→ null(顶层)。 */
229
+ private execCtxBaseline: { recordId: string | undefined; depth: number } | null = null;
230
+ /** fork 深度基线(同 ALS 断裂问题:forkDepthAls.getStore() 兜底用)。根进程=0。 */
231
+ private forkDepthBaseline = 0;
232
+ /** [MF-3] 所属根进程 cwd(sessions/records 落盘目录编码键)。
233
+ * 根进程=自身 cwd(构造时 init.cwd);子进程=env PI_SUBAGENT_ROOT_CWD 贯穿的真 ROOT cwd。
234
+ * worktree 模式下子进程 this.cwd 是 checkout 路径,若按它编码目录,深层 record 落到
235
+ * enc(worktree) 段、ROOT 扫描不到 → 全树可见性深度 ≥ 2 断裂(与 sessionRootId 同构)。 */
236
+ private rootCwd: string;
201
237
  /** UI streaming sink(ctx.ui.setWidget)。workflow 域经 getStreamSink() 取用。 */
202
238
  private streamSink: StreamSink | null = null;
203
239
  /** [竞态修复] 主 agent isIdle 查询(ctx.isIdle)。notifier flush gate 用。
@@ -229,8 +265,15 @@ export class SubagentService {
229
265
  this.uiRequestHandler = init.uiRequestHandler;
230
266
  this.pool = new DefaultConcurrencyPool(this.modelService.getGlobalConfig().maxConcurrent);
231
267
  this.worktreeManager = new WorktreeManager(this.modelService.getAgentDir());
232
- const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), init.cwd);
233
- const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), init.cwd);
268
+ // [MF-3] worktree 隔离下全树落盘目录统一到 ROOT cwd:子进程(spawn cwd = worktree checkout 路径)
269
+ // 若按自身 cwd 编码目录,深层 record 写到 enc(worktree) 段,ROOT 磁盘重建扫不到。
270
+ // 读 env PI_SUBAGENT_ROOT_CWD(根进程无 env → init.cwd)。sessions 与 records 两套目录
271
+ // 必须同源(同一 rootCwd),否则 enc 段不变量断裂(只改其一会让同 record 的
272
+ // session 文件与 manifest 分落两段,GC/重建互相找不到)。
273
+ const envRootCwd = process.env[ENV_ROOT_CWD];
274
+ this.rootCwd = envRootCwd && envRootCwd !== "" ? envRootCwd : init.cwd;
275
+ const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), this.rootCwd);
276
+ const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), this.rootCwd);
234
277
  this.manifestStore = new ManifestStore(recordsDir);
235
278
  this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
236
279
  this.notifier = new BgNotifier(this.piAdapter());
@@ -286,6 +329,32 @@ export class SubagentService {
286
329
  const base = Number.parseInt(envDepth, 10);
287
330
  if (!Number.isNaN(base) && base > 0) {
288
331
  this.forkDepthAls.enterWith(base);
332
+ this.forkDepthBaseline = base;
333
+ }
334
+ }
335
+ // [递归可见性] 跨进程身份贯穿(设计 recursive-subagent-visibility.md)。
336
+ // 父进程 spawn 时注入这 4 个 env 描述「子进程自己的身份」:
337
+ // - rootSessionId:所属根 session(贯穿真 ROOT,子进程不覆盖)
338
+ // - selfRecordId:子进程自己的 record id(孙 subagent 的直接父)
339
+ // - depth:子进程的嵌套深度
340
+ // - rootCwd:真 ROOT 的 cwd([MF-3] 落盘目录编码键,worktree 下与自身 cwd 不同)
341
+ // 子进程读 env 建立基线后,createRecordForMode 读 execCtxAls 自动正确(孙挂到子名下)。
342
+ // 根进程无 env → sessionRootId = init.sessionId(自己是 root),execCtxAls 不 enterWith(顶层)。
343
+ // enterWith 贯穿整个 session 生命周期(与 forkDepthAls 同构,决策 4)。
344
+ const envRoot = process.env[ENV_ROOT_SESSION_ID];
345
+ this.sessionRootId = envRoot ?? init.sessionId;
346
+ const envSelfRecord = process.env[ENV_SELF_RECORD_ID];
347
+ if (envSelfRecord !== undefined && envSelfRecord !== "") {
348
+ const envNestingDepth = Number.parseInt(process.env[ENV_DEPTH] ?? "0", 10);
349
+ const nestingDepth = Number.isNaN(envNestingDepth) ? 0 : envNestingDepth;
350
+ // [ALS 断裂修复] 基线兜底:enterWith 在 pi 事件回调模型下不可靠(见 execCtxBaseline 注释),
351
+ // 基线是 createRecordForMode / 护栏读 ALS store 失败时的权威回退。
352
+ this.execCtxBaseline = { recordId: envSelfRecord, depth: nestingDepth };
353
+ this.execCtxAls.enterWith({ recordId: envSelfRecord, depth: nestingDepth });
354
+ if (process.env.PI_EXT_DEBUG) {
355
+ logger.debug(
356
+ `[subagents] execCtxAls initialized: recordId=${envSelfRecord} depth=${nestingDepth} rootSessionId=${envRoot ?? init.sessionId}`,
357
+ );
289
358
  }
290
359
  }
291
360
  // revive(dispose 的逆操作:/resume /fork /new 后复活)
@@ -413,7 +482,8 @@ export class SubagentService {
413
482
  // 但耗资源且 LLM 易陷入「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
414
483
  // 计数基准:顶层 nestingDepth=0,nestingDepth>MAX 被拒。与 fork 体积护栏(parentForkDepth 检查)
415
484
  // 互补:本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
416
- const parentNesting = this.execCtxAls.getStore();
485
+ // [ALS 断裂修复] getStore() 在 pi 事件回调模型下可能读空(enterWith 不贯穿),基线兜底。
486
+ const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
417
487
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
418
488
  if (nestingDepth > MAX_FORK_DEPTH) {
419
489
  throw new ForkDepthExceededError(
@@ -421,16 +491,6 @@ export class SubagentService {
421
491
  );
422
492
  }
423
493
 
424
- // [MF#7] worktree:true 需要 fork:true——否则下面三个 worktree 分支都不命中,
425
- // worktreeHandle 恒 undefined → 子 agent 零文件隔离且零报错(静默 no-op)。此处在
426
- // 任何副作用(record 创建 / worktree 创建)之前 fail-fast,不吞误用。
427
- if (opts.worktree === true && !opts.fork) {
428
- throw new Error(
429
- "worktree:true requires fork:true (worktree isolation only applies to forked sessions). " +
430
- "Set fork:true together with worktree:true.",
431
- );
432
- }
433
-
434
494
  // mode 固定 background(sync 模式已删除)
435
495
  const mode: ExecutionMode = "background";
436
496
  const ctx = this.buildSessionRunnerContext(opts.cwd);
@@ -451,7 +511,7 @@ export class SubagentService {
451
511
  // 传入的是已创建的 WorktreeHandle
452
512
  worktreeHandle = opts.worktree;
453
513
  } else if (opts.worktree === true) {
454
- // worktree===true(显式要求)——创建新 worktree。MF#7 已保证此处 fork 必为 true。
514
+ // worktree===true(显式要求)——创建新 worktree。与 fork 正交(worktree 文件隔离不依赖 fork 上下文继承)。
455
515
  try {
456
516
  worktreeHandle = this.worktreeManager.create(this.cwd, record.id);
457
517
  record.worktreeHandle = worktreeHandle;
@@ -516,7 +576,8 @@ export class SubagentService {
516
576
  this.assertReady();
517
577
 
518
578
  // ── BC-12 嵌套护栏:复用 execute() 的 execCtxAls 深度检查 ──
519
- const parentNesting = this.execCtxAls.getStore();
579
+ // [ALS 断裂修复] getStore() 可能读空,基线兜底(与 execute 同)。
580
+ const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
520
581
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
521
582
  if (nestingDepth > MAX_FORK_DEPTH) {
522
583
  throw new ForkDepthExceededError(
@@ -524,15 +585,6 @@ export class SubagentService {
524
585
  );
525
586
  }
526
587
 
527
- // [MF#7] worktree:true requires fork:true — symmetric with execute() guard.
528
- // Fails fast before any side effect (record creation / worktree creation).
529
- if (opts.worktree === true && !opts.fork) {
530
- throw new Error(
531
- "worktree:true requires fork:true (worktree isolation only applies to forked sessions). " +
532
- "Set fork:true together with worktree:true.",
533
- );
534
- }
535
-
536
588
  // ── 步骤 1: IDENTITY 解析 ──
537
589
  const identity = await this.resolveIdentity(opts);
538
590
 
@@ -543,8 +595,8 @@ export class SubagentService {
543
595
  // ── 步骤 2.5: worktree creation (only worktree===true; handle injection is execute()'s path) ──
544
596
  // Workflow path receives boolean only (AgentCallOpts.worktree: boolean) — WorktreeHandle is a
545
597
  // main-thread non-serializable object that cannot cross worker postMessage, so no object branch
546
- // here (unlike execute() :445-447 which serves the subagent-tool path). MF#7 guard above ensures
547
- // fork===true when worktree===true. On create failure, finalizeFailed cleans up the record, then
598
+ // here (unlike execute() :445-447 which serves the subagent-tool path).
599
+ // On create failure, finalizeFailed cleans up the record, then
548
600
  // throw lets SAR.run() convert it to an AgentResult.error (not return-handle like execute()).
549
601
  let worktreeHandle: WorktreeHandle | undefined;
550
602
  if (opts.worktree === true) {
@@ -618,9 +670,10 @@ export class SubagentService {
618
670
  }
619
671
 
620
672
  /** 合并内存(running) + 磁盘(session.jsonl 重建) record(/subagents list + tool list 消费)。
621
- * 按 rootSessionId 过滤,只返回当前 session 创建的 recordsession 隔离)。 */
673
+ * 按 rootSessionId 过滤:根进程=本 session(sessionRootId===sessionId);
674
+ * 子进程=env 贯穿的真 ROOT(sessionRootId≠sessionId)→ 看到整棵 ROOT 树(决策 3)。 */
622
675
  collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
623
- return this.store.collectRecords(limit, statusFilter, this.sessionId ?? undefined);
676
+ return this.store.collectRecords(limit, statusFilter, this.sessionRootId ?? this.sessionId ?? undefined);
624
677
  }
625
678
 
626
679
  // ── 执行内部:身份解析 + record 创建 ──────────
@@ -657,7 +710,9 @@ export class SubagentService {
657
710
  // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
658
711
  // B run() 期间包了 execCtxAls,B 内创建 C 时读到 B → C.parentRecordId=B.id, C.depth=B.depth+1。
659
712
  // depth 语义:顶层(无父)=0;有父=父 depth+1。靠 recordId 是否存在区分,不用负数魔数。
660
- const parentCtx = this.execCtxAls.getStore();
713
+ // [ALS 断裂修复] getStore() 在 pi 事件回调模型下可能读空(enterWith 不贯穿),
714
+ // 基线兜底——本进程的身份在 initSession 已确定(env 注入),任何上下文下都能正确挂父链。
715
+ const parentCtx = this.execCtxAls.getStore() ?? this.execCtxBaseline;
661
716
  const parentRecordId = parentCtx?.recordId;
662
717
  const depth = parentCtx ? parentCtx.depth + 1 : 0;
663
718
 
@@ -669,7 +724,7 @@ export class SubagentService {
669
724
  task: opts.task,
670
725
  slug: opts.slug,
671
726
  startedAt: Date.now(),
672
- rootSessionId: this.sessionId ?? undefined,
727
+ rootSessionId: this.sessionRootId ?? undefined,
673
728
  parentRecordId,
674
729
  depth,
675
730
  controller,
@@ -724,7 +779,7 @@ export class SubagentService {
724
779
  worktreeHandle = opts.worktree;
725
780
  }
726
781
  // [MF#4][MF#2] fork 深度护栏:ALS 传递深度(主 session 链无 store→0,fork 推进 +1)。
727
- const parentDepth = this.forkDepthAls.getStore() ?? 0;
782
+ const parentDepth = this.forkDepthAls.getStore() ?? this.forkDepthBaseline;
728
783
  const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
729
784
 
730
785
  let result: AgentResult;
@@ -1001,6 +1056,15 @@ export class SubagentService {
1001
1056
  dialogQueue: this.dialogQueue,
1002
1057
  // 主进程运行模式:session-runner W4 守卫据此决定是否注入 ask_user RPC 提示词。
1003
1058
  mode: this.uiObservability.getMode(),
1059
+ // [递归可见性] 透传所属根 session(runSpawn 注入为子进程 env PI_SUBAGENT_ROOT_SESSION_ID)。
1060
+ // sessionRootId 在 initSession 设定(根进程=sessionId,子进程=env 贯穿的真 ROOT)。
1061
+ // execute/executeAndAwait 调本方法前必经 initSession,此时 sessionRootId 已非空;
1062
+ // ?? 兑底防类型漂移(运行时不可达)。
1063
+ sessionRootId: this.sessionRootId ?? this.sessionId ?? "",
1064
+ // [MF-3] 透传 ROOT cwd(runSpawn 落盘目录编码键 + 注入子进程 env PI_SUBAGENT_ROOT_CWD)。
1065
+ // worktree 模式下 mainCwd = 本进程 checkout 路径,rootCwd 才是真 ROOT——session 文件
1066
+ // 落盘统一用 rootCwd 编码,ROOT 磁盘重建才扫得到深层 record(与 sessionRootId 同构)。
1067
+ rootCwd: this.rootCwd,
1004
1068
  };
1005
1069
  }
1006
1070
  }
@@ -231,7 +231,7 @@ export interface AgentResult {
231
231
  */
232
232
  /**
233
233
  * worktree handle 值对象。仅 worktree:true 时持有——worktree 是独立维度,
234
- * 需显式开启(且要求 fork:true),fork alone 不创建 worktree。
234
+ * 需显式开启,fork alone 不创建 worktree。
235
235
  * Object.freeze 守卫保证不可变。
236
236
  */
237
237
  export interface WorktreeHandle {
@@ -339,7 +339,7 @@ export interface ExecutionRecord {
339
339
  /** session jsonl 文件名。session 创建成功后由 session-runner.run() 回填(窗口期内 undefined)。 */
340
340
  sessionFile?: string;
341
341
 
342
- /** [MF#3] fork+worktree 模式下子 agent 改动的 patch 文件路径(worktree 外,供调用方应用)。 */
342
+ /** [MF#3] worktree 模式下子 agent 改动的 patch 文件路径(worktree 外,供调用方应用)。 */
343
343
  patchFile?: string;
344
344
 
345
345
  /** worktree 隔离时的 handle(仅 worktree:true 时存在;fork alone 无此字段)。 */
@@ -383,7 +383,7 @@ export interface SubagentToolDetails {
383
383
  parsedOutput?: unknown;
384
384
  /** session jsonl 文件名(不含目录)。窗口期内可能 undefined(session 尚未创建成功)。 */
385
385
  sessionFile?: string;
386
- /** [MF#3] fork+worktree 模式下子 agent 改动的 patch 文件路径(worktree 外,供调用方应用)。 */
386
+ /** [MF#3] worktree 模式下子 agent 改动的 patch 文件路径(worktree 外,供调用方应用)。 */
387
387
  patchFile?: string;
388
388
  }
389
389
 
@@ -419,7 +419,7 @@ export interface ExecuteOptions {
419
419
  onComplete?: (record: RecordSnapshot) => void;
420
420
  /** 是否继承父会话上下文(fork 模式,只继承上下文)。 */
421
421
  fork?: boolean;
422
- /** 文件系统隔离:true=创建新 git worktree(要求 fork:true),WorktreeHandle=复用外部已创建的;undefined=不隔离(parent cwd)。 */
422
+ /** 文件系统隔离:true=创建新 git worktreeWorktreeHandle=复用外部已创建的;undefined=不隔离(parent cwd)。 */
423
423
  worktree?: boolean | WorktreeHandle;
424
424
  /** 覆盖执行 cwd(默认 mainCwd)。 */
425
425
  cwd?: string;
@@ -527,7 +527,7 @@ export interface SubagentRecord {
527
527
  result?: string;
528
528
  error?: string;
529
529
  sessionFile?: string;
530
- /** [MF#3] fork+worktree 模式下子 agent 改动的 patch 文件路径(worktree 外,供调用方应用)。 */
530
+ /** [MF#3] worktree 模式下子 agent 改动的 patch 文件路径(worktree 外,供调用方应用)。 */
531
531
  patchFile?: string;
532
532
  /** 外部 Pi 实例(进程隔离模式下由外部启动的子进程)。 */
533
533
  externalInstance?: AliveMarker;
@@ -57,7 +57,7 @@ interface BgNotifyRecord {
57
57
  model?: string;
58
58
  result?: string;
59
59
  error?: string;
60
- /** [MF#1] fork+worktree background 完成通知携带的 patch 文件路径。 */
60
+ /** [MF#1] worktree background 完成通知携带的 patch 文件路径。 */
61
61
  patchFile?: string;
62
62
  }
63
63
 
@@ -275,7 +275,7 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
275
275
  model: typeof d.model === "string" ? d.model : undefined,
276
276
  result: typeof d.result === "string" ? d.result : undefined,
277
277
  error: typeof d.error === "string" ? d.error : undefined,
278
- // [MF#1] 提取 patchFile(fork+worktree background 完成通知携带)。
278
+ // [MF#1] 提取 patchFile(worktree background 完成通知携带)。
279
279
  patchFile: typeof d.patchFile === "string" ? d.patchFile : undefined,
280
280
  };
281
281
  }
@@ -226,7 +226,21 @@ export async function cancelHandler(
226
226
 
227
227
  // step 1: id 不存在(findRecord 只查内存 running record,不从 session.jsonl 重建)
228
228
  const rec = service.findRecord(id);
229
- if (!rec) throw new Error(`No subagent record with id "${id}". It may have finished — use action:'list' with includeFinished:true to verify.`);
229
+ if (!rec) {
230
+ // [S-19] MF-1 全树可见后,list/completion 可能列出其他进程(父/兄弟)的 running record
231
+ //(collectRecords 扫共享 sessionsDir 按 rootSessionId 过滤,跨进程互相可见),而 cancel
232
+ // 只作用于本进程内存 record。区分两种失败,避免「may have finished」误导(该 record 正
233
+ // 被列出且未 finished,只是不属于本进程内存)。仅文案区分,不改 cancel 作用域。
234
+ const treeRec = service.collectRecords(DEFAULT_LIST_LIMIT, "all").find((r) => r.id === id);
235
+ if (treeRec && treeRec.status === "running") {
236
+ throw new Error(
237
+ `Subagent record "${id}" is running but owned by another process in the tree ` +
238
+ `(it was spawned by a different subagent process) — this process cannot cancel it; ` +
239
+ `cancel only works for subagents spawned by the current process.`,
240
+ );
241
+ }
242
+ throw new Error(`No subagent record with id "${id}". It may have finished — use action:'list' with includeFinished:true to verify.`);
243
+ }
230
244
  // step 2: controller 检查(controller 为 undefined 表示 record 已终态或未启动)
231
245
  if (rec.mode !== "background") {
232
246
  throw new Error(`Cannot cancel subagent ${id} (unsupported mode: ${rec.mode})`);
@@ -105,10 +105,10 @@ const SubagentParams = Type.Object({
105
105
  description: "Extra turns allowed after maxTurns is reached before SIGTERM (default 2). Only meaningful when maxTurns is set.",
106
106
  })),
107
107
  fork: Type.Optional(Type.Boolean({
108
- description: "Fork mode: inherit the parent's conversation context. When true, the subagent receives the parent's session file via --fork and builds a branched conversation (it sees prior turns/messages). The subagent still runs in a separate spawned child process (process isolation) — fork is about context inheritance, not process sharing. Use worktree:true (requires fork:true) for file-system isolation.",
108
+ description: "Fork mode: inherit the parent's conversation context. When true, the subagent receives the parent's session file via --fork and builds a branched conversation (it sees prior turns/messages). The subagent still runs in a separate spawned child process (process isolation) — fork is about context inheritance, not process sharing; independent of worktree (file-system isolation, see worktree param). When to use: only when the task extends from the parent and genuinely needs key information from the parent's conversation history that a self-contained task prompt cannot carry — most tasks a plain prompt can describe do NOT need fork, so keep false by default and enable only when the user explicitly asks or the task truly depends on seeing prior turns. Caveat: fork drags in the parent's dispatch records and unrelated task context, polluting the subagent (it cannot tell 'context meant for me' from 'parent dispatching me'); when state lives in an external store the subagent can query (e.g., cw handoff), prefer that over fork.",
109
109
  })),
110
110
  worktree: Type.Optional(Type.Boolean({
111
- description: "Worktree isolation (requires fork:true): run the subagent in a dedicated git worktree, providing file-system level isolation from the parent session. Prevents concurrent file-write conflicts between parent and subagent. Only takes effect when fork:true; passing worktree:true without fork:true throws an error.",
111
+ description: "Worktree isolation: run the subagent in a dedicated git worktree, providing file-system level isolation from the parent session (prevents concurrent file-write conflicts). Independent of fork worktree may be combined with fork:false (file isolation does not require context inheritance). When to use: parallel development scenarios where multiple agents write files concurrently and need isolated working directories (each gets its own checkout; merge later); leave false for single-agent or read-only tasks.",
112
112
  })),
113
113
  cwd: Type.Optional(Type.String({
114
114
  description: 'Override the working directory for the subagent execution. Must be an absolute path. Defaults to the parent session\'s cwd.',
@@ -32,7 +32,7 @@ export function registerSubagentsCommand(pi: ExtensionAPI): void {
32
32
  try {
33
33
  const service = getSubagentService();
34
34
  if (!service) return null;
35
- // collectRecords 合并内存(running) + 磁盘重建 record,按 session 过滤。
35
+ // collectRecords 合并内存(running) + 磁盘重建 record,按 rootSessionId 过滤。
36
36
  // cancel 只对 running 有效,但全部列出便于用户辨认(终态 record 会被 service 拒绝)。
37
37
  const records = service.collectRecords(LIST_LIMIT);
38
38
  if (records.length === 0) return null;
@@ -175,3 +175,55 @@ describe("W2: RunStore.stateFilePath 暴露 run 状态文件路径", () => {
175
175
  expect(result).toBe(path.join(tmpDir, "workflow-state", "run-xyz.jsonl"));
176
176
  });
177
177
  });
178
+
179
+ // W3: save 兜底容错——run 工作目录被并发清理时 mkdir 抛 ENOENT,save 静默返回。
180
+ //
181
+ // 防的 bug(PR #166 CI 回归):review-fix-loop-e2e 等 runAndWait 测试中,
182
+ // handleReturn 的 run.transition("done") 同步改 status 后,runAndWait 轮询发现 done
183
+ // 并 resolve,测试 afterEach 随即 rmSync 删除 sessionDir;此时 handleReturn 内 in-flight
184
+ // 的 await save 尚未完成,mkdir 遇到目录链被并发删除 → ENOENT。原实现 await save 让错误
185
+ // 冒泡为 unhandled promise rejection(worker-host onMessage 无 catch),CI exit 1。
186
+ // 修复:save 仅容错 ENOENT(run 已终态,状态不再变化,持久化无意义也无法完成)→ silent return;
187
+ // 非 ENOENT 错误(EACCES/ENOSPC 等真实磁盘问题)仍重新抛出,不掩盖。
188
+ //
189
+ // 注:真正的 ENOENT 只在 rmSync 与 mkdir 并发时出现(串行 rmSync 后 mkdir {recursive:true}
190
+ // 会重建目录而非抛 ENOENT),故用 mock 直接锁定 save 的容错判定逻辑,不依赖竞态时序复现。
191
+ describe("W3: JsonlRunStore.save 兜底容错(run 工作目录被并发清理时的竞态)", () => {
192
+ let tmpDir: string;
193
+ let store: JsonlRunStore;
194
+
195
+ beforeEach(() => {
196
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-enoent-"));
197
+ store = new JsonlRunStore({ sessionDir: tmpDir });
198
+ });
199
+
200
+ afterEach(() => {
201
+ fs.rmSync(tmpDir, { recursive: true, force: true });
202
+ vi.restoreAllMocks();
203
+ });
204
+
205
+ it("mkdir 抛 ENOENT(sessionDir 被并发清理)→ save 静默返回,不抛 unhandled rejection", async () => {
206
+ const run = makeRunWithDoneCall();
207
+ const spy = vi
208
+ .spyOn(fs.promises, "mkdir")
209
+ .mockRejectedValueOnce(
210
+ Object.assign(new Error("ENOENT: no such file or directory, mkdir"), {
211
+ code: "ENOENT",
212
+ }),
213
+ );
214
+ // run 已终态 + 工作目录消失 → save 放弃持久化,resolve undefined(不抛)
215
+ await expect(store.save(run)).resolves.toBeUndefined();
216
+ expect(spy).toHaveBeenCalled();
217
+ });
218
+
219
+ it("mkdir 抛非 ENOENT 错误(EACCES)→ save 重新抛出,不掩盖真实磁盘问题", async () => {
220
+ const run = makeRunWithDoneCall();
221
+ const spy = vi
222
+ .spyOn(fs.promises, "mkdir")
223
+ .mockRejectedValueOnce(
224
+ Object.assign(new Error("permission denied"), { code: "EACCES" }),
225
+ );
226
+ await expect(store.save(run)).rejects.toThrow("permission denied");
227
+ expect(spy).toHaveBeenCalled();
228
+ });
229
+ });
@@ -241,7 +241,26 @@ export class JsonlRunStore {
241
241
  */
242
242
  async save(run: WorkflowRun): Promise<void> {
243
243
  const filePath = this.filePathFor(run.runId);
244
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
244
+ // 兜底容错:run 工作目录(sessionDir)已被清理时,mkdir ENOENT,save 放弃。
245
+ // 竞态场景(review-fix-loop-e2e 等 runAndWait 测试):handleReturn 内
246
+ // run.transition("done") 同步改 status 后,runAndWait 轮询发现 done 并 resolve,
247
+ // 测试 afterEach 随即 rmSync 删除 sessionDir;此时本方法 in-flight 的 mkdir
248
+ // 遇到目录链已删除 → ENOENT({recursive:true} 在并发 rmSync 下仍可抛 ENOENT)。
249
+ // run 既已终态(状态不再变化),持久化无意义也无法完成 → silent return。
250
+ // 仅容错 ENOENT,重新抛出其他错误(EACCES/ENOSPC 等真实磁盘问题不掩盖)。
251
+ try {
252
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
253
+ } catch (err) {
254
+ if (
255
+ typeof err === "object" &&
256
+ err !== null &&
257
+ "code" in err &&
258
+ (err as { code: unknown }).code === "ENOENT"
259
+ ) {
260
+ return;
261
+ }
262
+ throw err;
263
+ }
245
264
  const snapshot = serializeRun(run);
246
265
  await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
247
266
  if (this.pi) {
@@ -142,9 +142,9 @@ export interface AgentCallOpts {
142
142
  * undefined 时 spawn 继承 workflow 进程的 cwd(向后兼容)。
143
143
  */
144
144
  cwd?: string;
145
- /** Inherit parent session context (fork mode). Required when worktree isolation is enabled. */
145
+ /** Inherit parent session context (fork mode). Independent of worktree (file isolation). */
146
146
  fork?: boolean;
147
- /** Filesystem isolation: when true, creates a new git worktree for the agent (requires fork: true). */
147
+ /** Filesystem isolation: when true, creates a new git worktree for the agent. Independent of fork. */
148
148
  worktree?: boolean;
149
149
  /** When true, agent() resolves {value, sessionFile, worktreePath, error} instead of a bare value.
150
150
  * Worker-layer flag only — not forwarded to ExecuteOptions (mapToExecuteOptions drops it). */