@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.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 (55) hide show
  1. package/package.json +7 -4
  2. package/scripts/rfl.mjs +308 -0
  3. package/src/execution/__tests__/finalize-record.test.ts +33 -7
  4. package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
  5. package/src/execution/__tests__/record-store.test.ts +284 -2
  6. package/src/execution/__tests__/spawn-args.test.ts +5 -5
  7. package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
  8. package/src/execution/__tests__/worktree-manager.test.ts +9 -2
  9. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
  10. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  11. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  12. package/src/execution/agent-result-mapper.ts +4 -1
  13. package/src/execution/argv-mirror.ts +1 -1
  14. package/src/execution/channel-registry-access.ts +3 -1
  15. package/src/execution/finalize-record.ts +16 -7
  16. package/src/execution/idle-gc.ts +47 -0
  17. package/src/execution/lifecycle-manager.ts +9 -2
  18. package/src/execution/record-entry.ts +118 -0
  19. package/src/execution/record-store.ts +290 -1
  20. package/src/execution/session-pending.ts +5 -4
  21. package/src/execution/session-runner.ts +484 -289
  22. package/src/execution/subagent-service.ts +87 -37
  23. package/src/execution/temp-prompt.ts +8 -3
  24. package/src/execution/types.ts +20 -2
  25. package/src/execution/worktree-manager.ts +325 -14
  26. package/src/execution/worktree-registry.ts +90 -33
  27. package/src/index.ts +42 -2
  28. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  29. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  30. package/src/interface/format.ts +10 -4
  31. package/src/interface/tool-render.ts +10 -3
  32. package/src/interface/tool-workflow-script.ts +29 -33
  33. package/src/interface/tool-workflow.ts +47 -65
  34. package/src/interface/views/detail-content.ts +1 -1
  35. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
  36. package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
  37. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  38. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
  39. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  40. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  41. package/src/orchestration/__tests__/test-mocks.ts +9 -3
  42. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  43. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
  44. package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
  45. package/src/orchestration/error-recovery.ts +4 -1
  46. package/src/orchestration/execute-agent-call.ts +13 -3
  47. package/src/orchestration/jsonl-run-store.ts +139 -60
  48. package/src/orchestration/lifecycle.ts +10 -1
  49. package/src/orchestration/worker-script-builder.ts +9 -1
  50. package/src/shared/__tests__/resource-discovery.test.ts +24 -0
  51. package/src/shared/agent-ref.ts +6 -1
  52. package/src/shared/resource-discovery.ts +15 -1
  53. package/src/shared/schema-jsonify.ts +4 -1
  54. package/workflows/review-fix-loop-utils.cjs +542 -32
  55. package/workflows/review-fix-loop.js +462 -109
@@ -40,6 +40,7 @@ import { RecordStore } from "./record-store.ts";
40
40
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
41
41
  import { getChildByRecord, killAllSpawnedChildren, runSpawn, spawnedChildren, type SessionRunnerContext, type SpawnResumeOpts } from "./session-runner.ts";
42
42
  import { isIdle, isResumable, hasLiveProcessHandle } from "./lifecycle-predicates.ts";
43
+ import { startIdleGc } from "./idle-gc.ts";
43
44
  import {
44
45
  clearEpipeFailure,
45
46
  EPIPE_FAILURE_THRESHOLD,
@@ -69,6 +70,9 @@ import { WorktreeManager } from "./worktree-manager.ts";
69
70
 
70
71
  const logger = getLogger("subagents");
71
72
 
73
+ /** SP-2 冷路径按 id 查 record 的 collectRecords 扫描上限(全扫兜底的容量 cap)。 */
74
+ const COLD_LOOKUP_SCAN_LIMIT = 1000;
75
+
72
76
  // [v4 A-1] EPIPE 连续失败计数器已迁移到 stdin-writer.ts(stdin 错误域,避免 session-runner
73
77
  // 反向 import 本文件 helper 产生循环依赖)。同步路径(deliverMessage)与异步路径
74
78
  //(session-runner child.stdin.on('error'))共用 stdin-writer 的同一计数器。
@@ -145,6 +149,11 @@ export interface SubagentServiceInit {
145
149
  export interface SubagentServiceSessionInit {
146
150
  pi: PiLike;
147
151
  sessionId: string;
152
+ /** 主 session 文件路径(session_start 解析后直传)。
153
+ * [E2E 实测] 不能经闭包缓存(getCachedMainSessionFile)读:jiti 多实例分裂下闭包
154
+ * 变量不跨实例共享,恢复逻辑读到的是滞后一个事件的值(读到未 flush 的新 session
155
+ * ENOENT 路径,entry-born 孤儿整段漏判)。 */
156
+ mainSessionFile?: string;
148
157
  /** UI streaming sink(ctx.ui.setWidget),用于 background text_delta 转发。 */
149
158
  streamSink?: StreamSink;
150
159
  /** 主进程运行模式(W4 守卫:headless 不注入 ask_user RPC 提示词)。
@@ -213,6 +222,8 @@ export class SubagentService {
213
222
  private pi: PiLike | null = null;
214
223
  /** 当前 Pi session ID(本进程 pi session,事件路由等用;record 过滤不用它)。initSession 时注入。 */
215
224
  private sessionId: string | null = null;
225
+ /** 主 session 文件(initSession 按值直传——jiti 多实例下闭包缓存不可靠,见 SessionInit 注释)。 */
226
+ private mainSessionFile: string | undefined;
216
227
  /** 所属根 session ID(record 归属过滤用)。根进程 = sessionId(自己是 root);
217
228
  * 子进程 = env PI_SUBAGENT_ROOT_SESSION_ID 贯穿的真 ROOT(initSession 读取)。
218
229
  * 与 sessionId 正交:sessionId 是本进程 pi session(事件路由等),sessionRootId 是所属根
@@ -319,6 +330,8 @@ export class SubagentService {
319
330
  // 上报通道永远是 no-op,事故排查依然静默。
320
331
  this.store.setPi(this.pi);
321
332
  this.sessionId = init.sessionId;
333
+ // 主 session 文件按值直传(jiti 多实例下闭包缓存不可靠,见接口注释)。
334
+ this.mainSessionFile = init.mainSessionFile;
322
335
  this.streamSink = init.streamSink ?? null;
323
336
  this.isIdleFn = init.isIdle;
324
337
  // 读取 mode(W4 守卫透传给 session-runner)+ session 级 handler 覆盖。
@@ -373,6 +386,32 @@ export class SubagentService {
373
386
  this._disposed = false;
374
387
  this.store.revive();
375
388
  this.notifier.revive();
389
+ // 孤儿终态恢复(residual-fixes):session_start 主动触发一次——父扩展死后再无人写
390
+ // 终态 entry 的 record 在此判定落盘(否则侧栏永久 running)。放 initSession 末尾:
391
+ // setPi 已注入(appendEntry 可用),sessionRootId 已建立(过滤当前根的 record)。
392
+ // 幂等不 throw,失败不阻断 session_start。
393
+ this.recoverOrphanRecords();
394
+ }
395
+
396
+ /** 孤儿终态恢复委托(RecordStore.recoverOrphanRecords 的唯一公开入口,维持 store
397
+ * private 封装——与 recoverManifestTmpFiles 同模式)。判定语义见 store 侧注释。
398
+ * 随后跑 entry-born 孤儿恢复(无子文件锚的 register-only record,spawn 窗口期死亡,
399
+ * E2E 实测缺口)——主 session 文件经 getMainSessionFile 注入(构造期可空)。 */
400
+ recoverOrphanRecords(): void {
401
+ try {
402
+ this.store.recoverOrphanRecords(this.sessionRootId ?? undefined);
403
+ } catch (err) {
404
+ logger.warn("[subagents] orphan recovery failed", {
405
+ reason: err instanceof Error ? err.message : String(err),
406
+ });
407
+ }
408
+ try {
409
+ this.store.recoverEntryOnlyOrphans(this.mainSessionFile, this.sessionRootId ?? undefined);
410
+ } catch (err) {
411
+ logger.warn("[subagents] entry-only orphan recovery failed", {
412
+ reason: err instanceof Error ? err.message : String(err),
413
+ });
414
+ }
376
415
  }
377
416
 
378
417
  /** 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下的 *.json.tmp.<pid>),
@@ -446,40 +485,19 @@ export class SubagentService {
446
485
  return this.disposeAllRecords("parent-new");
447
486
  }
448
487
 
449
- /** SP-4: idle record GC 定时器(30 天 TTL)。防止 idle record 永久驻留内存。
450
- * session_start 时启动,session_shutdown 时清理。每小时检查一次。 */
451
- private gcTimer: NodeJS.Timeout | undefined;
488
+ /** SP-4: idle record GC30 天 TTL,实现抽至 idle-gc.ts)。stop 函数(dispose 调)。 */
489
+ private stopIdleGc: (() => void) | undefined;
452
490
 
453
- /** 启动 idle record GC 定时器(session_start 调用)。 */
491
+ /** 启动 idle record GC 定时器(session_start 调用,幂等)。 */
454
492
  startGcTimer(): void {
455
- if (this.gcTimer) return;
456
- const GC_INTERVAL_MS = 60 * 60 * 1000; // 1 小时
457
- const IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天
458
- this.gcTimer = setInterval(() => {
459
- const now = Date.now();
460
- for (const record of this.store.listAllActive()) {
461
- if (isResumable(record) && record.idleSince) {
462
- const age = now - record.idleSince;
463
- if (age > IDLE_TTL_MS) {
464
- logger.warn(`[subagents] GC: archiving idle record ${record.id} (idle for ${Math.round(age / 86400000)}d)`);
465
- try {
466
- this.store.archive(record);
467
- } catch (err) {
468
- bestEffort(err, `GC archive record ${record.id}`);
469
- }
470
- }
471
- }
472
- }
473
- }, GC_INTERVAL_MS);
474
- this.gcTimer.unref?.();
493
+ if (this.stopIdleGc) return;
494
+ this.stopIdleGc = startIdleGc(this.store);
475
495
  }
476
496
 
477
497
  /** 停止 idle record GC 定时器(dispose 调用)。 */
478
498
  private stopGcTimer(): void {
479
- if (this.gcTimer) {
480
- clearInterval(this.gcTimer);
481
- this.gcTimer = undefined;
482
- }
499
+ this.stopIdleGc?.();
500
+ this.stopIdleGc = undefined;
483
501
  }
484
502
 
485
503
  /** session 结束清理(清定时器,丢弃 pending 通知)。幂等。
@@ -538,11 +556,16 @@ export class SubagentService {
538
556
  * closeSubagent 的 status 分流守卫(closed 后幂等 no-op)/ CAS 抢锁保证只执行一次,
539
557
  * 本方法自身不重复发送;迟到的 kickOffBackground.then 通知与轮次通知同 key=`id:round`,
540
558
  * 60s 窗内仍被吞,不构成第三条。 */
541
- private notifyClosed(record: ExecutionRecord): void {
559
+ /** @param emptyBody true = 终态通知正文置空串(D2 路径②)。W16 P-1 修复后
560
+ * closeChatIdle 的 doneResult.text 改用 record.result 保真(close 终态
561
+ * subagent-record entry 的 result 不抹空轮终真实值),「正文空」不再由合成空
562
+ * text 的副作用承载,改为显式参数——持久化 result 与通知正文两个关注点解耦。 */
563
+ private notifyClosed(record: ExecutionRecord, emptyBody = false): void {
542
564
  if (!record.chatMode) return;
543
565
  const notify = this.toNotifyRecord(record);
544
566
  if (!notify) return;
545
567
  notify.round = undefined;
568
+ if (emptyBody) notify.result = "";
546
569
  if (record.round != null) notify.totalRounds = record.round;
547
570
  this.notifier.notify(notify);
548
571
  }
@@ -804,6 +827,13 @@ export class SubagentService {
804
827
 
805
828
  // 手动设回 running(M2-A 边界:绕过 tryTransition,idle→running 恢复非终态 CAS)。
806
829
  record.status = "running";
830
+ // 执行态信号清除(residual-fixes U3 补全):新一轮开跑 = 无轮终信号——resumable
831
+ // 与上一轮 result 都要清(§5.4 isStreaming 公式要求 result undefined 才显示
832
+ // streaming,不清则续轮流仍显示 waiting、spinner 无法恢复)。
833
+ record.resumable = undefined;
834
+ record.result = undefined;
835
+ // W16 [D4]:冷路径续轮是类外状态写点(不走 register/archive),显式上报迁移。
836
+ this.store.reportRecordTransition(record);
807
837
 
808
838
  // resume 参数从 record identity 读(防漂移,P-10)。
809
839
  const resume: SpawnResumeOpts = {
@@ -882,6 +912,14 @@ export class SubagentService {
882
912
  sendPromptCommand(child, text, { streamingBehavior: interrupt ? "steer" : "followUp" });
883
913
  // 热路径成功,清零 EPIPE 连续失败计数([v4 A-1] 计数器已迁移到 stdin-writer)
884
914
  clearEpipeFailure(record.id);
915
+ // 轮始执行态信号清除 + 迁移上报(residual-fixes U3 补全,与冷路径 resumeRound
916
+ // 对称):新一轮开跑 = 无轮终信号——清上一轮 result(§5.4 isStreaming 公式要求
917
+ // result undefined 才显示 streaming)与 resumable,appendEntry 让 runtime/W18
918
+ // 派生缓存失效、GUI 侧从 waiting 切回 spinner。仅在投递成功后清(失败保留
919
+ // 上一轮信号,EPIPE 兜底走 resumeRound 时由其再清)。
920
+ record.result = undefined;
921
+ record.resumable = undefined;
922
+ this.store.reportRecordTransition(record);
885
923
  } catch (err) {
886
924
  // EPIPE 兜底:stdin 管道已断,进程实际已死但 close 事件尚未到达。
887
925
  // 检测 EPIPE 关键词 → 进程按 dead 处理 → 自动转冷路径 resume + 消息重放。
@@ -942,7 +980,7 @@ export class SubagentService {
942
980
  *
943
981
  * 同进程内 running + idle record 都在内存(getMutable);终态 record 已 archive。
944
982
  * 跨重启(SP-2)内存空时,从磁盘 collectRecords 重建 idle record 并 register 进内存。
945
- * reconstructAll 已将跨重启 record(无 sidecar marker + pid 死)标记为 idle,
983
+ * reconstructAll 已将跨重启 record(无 sidecar marker + pid 死)标记为 running(v4 B-1 跨重启可续聊语义,record-store buildRecord 分支 4),
946
984
  * collectRecords 返回的 SubagentRecord 可直接转为可变 ExecutionRecord 供续操作。
947
985
  *
948
986
  * @param id subagent record id
@@ -953,7 +991,7 @@ export class SubagentService {
953
991
  this.assertReady();
954
992
  let record = this.store.getMutable(id);
955
993
  // SP-2 跨重启恢复:内存未命中时,从磁盘 collectRecords 重建 idle record。
956
- // reconstructAll 已将跨重启 record(无 sidecar + pid 死)标记为 idle(非 crashed),
994
+ // reconstructAll 已将跨重启 record(无 sidecar + pid 死)标记为 running(v4 B-1 可续聊语义,非 crashed),
957
995
  // 直接转为可变 ExecutionRecord register 进内存,供 message/close action 续操作。
958
996
  if (!record) {
959
997
  // [perf] SP-2 冷路径:先走 idToFile 索引直查(单文件 stat 校验),未命中
@@ -963,7 +1001,7 @@ export class SubagentService {
963
1001
  const found =
964
1002
  (direct?.status === "running" ? direct : undefined) ??
965
1003
  this.store
966
- .collectRecords(1000, "all", undefined)
1004
+ .collectRecords(COLD_LOOKUP_SCAN_LIMIT, "all", undefined)
967
1005
  .find((r) => r.id === id && r.status === "running");
968
1006
  if (found) {
969
1007
  record = createRecord(id, {
@@ -1077,9 +1115,13 @@ export class SubagentService {
1077
1115
  disarmIdleTimer(record.id);
1078
1116
  const child = getChildByRecord(record.id);
1079
1117
  if (child && !child.killed) child.kill("SIGTERM");
1080
- // 合成 closed result(无在途 AgentResult,对齐 cancelBackground cancelledResult)
1118
+ // 合成 closed result(无在途 AgentResult,对齐 closeAfterRoundSettled
1119
+ // `record.result ?? ""` 模式)。[W16 P-1 修复] text 必须沿用轮终真实 result:
1120
+ // completeRecord 会执行 record.result = result.text,合成空串会把轮终真实值抹空,
1121
+ // archive 落的 close 终态 subagent-record entry(D4 重建源)随之失真——重开
1122
+ // session 后 result 回退空串。
1081
1123
  const doneResult: AgentResult = {
1082
- text: "",
1124
+ text: record.result ?? "",
1083
1125
  turns: record.turnCount,
1084
1126
  durationMs: Date.now() - record.startedAt,
1085
1127
  success: true,
@@ -1100,11 +1142,12 @@ export class SubagentService {
1100
1142
  "closed",
1101
1143
  "user-close", // close action 主动关闭
1102
1144
  );
1103
- // [C-1] 终态通知(设计 D2 路径②):doneResult.text 恒空串 终态通知正文空 +
1104
- // sessionFile 指针行(idle 下末轮增量已由该轮轮次通知送达,终态再发属重复)。
1145
+ // [C-1] 终态通知(设计 D2 路径②):正文空串占位 + sessionFile 指针行(idle
1146
+ // 末轮增量已由该轮轮次通知送达,终态再发属重复)。doneResult.text 已改保真
1147
+ // (P-1 修复),正文空由 notifyClosed 的 emptyBody 参数显式表达。
1105
1148
  // dedup 身份独立于轮次通知(notifyClosed 置 round=undefined),60s 窗内不被吞。
1106
1149
  // 防重入:closeSubagent 对 closed record 幂等 no-op,本路径不会被二次进入。
1107
- this.notifyClosed(record);
1150
+ this.notifyClosed(record, true);
1108
1151
  }
1109
1152
 
1110
1153
  /**
@@ -1786,6 +1829,13 @@ export class SubagentService {
1786
1829
  // [增量] base 推进(notify 之后):下一轮增量从本轮边界起。滞后空 turn 不计入边界
1787
1830
  //(防御分支,留在下一轮增量内防丢文本——nextRoundBaseTurnIndex 注释)。
1788
1831
  record.roundBaseTurnIndex = nextRoundBaseTurnIndex(record);
1832
+ // 轮终迁移持久化(residual-fixes U3 补全):热路径轮终不经 doFinalizeRoundToIdle
1833
+ //(agent_settled 恒 arm idle timer → runAndFinalize 恒 early return,MF-2 原写点
1834
+ // 不可达)——不 appendEntry 则 runtime/W18 派生缓存不失效,renderer 停留在
1835
+ // register 快照(无 result),chat 等续聊的 waiting 形态显示不出来、spinner
1836
+ // 卡死。显式上报:entry 携带本轮 result/round/chatMode(§5.4:result 有值 +
1837
+ // chatMode=true → waiting)。closeAfterRound 的终态 entry 在此后追加,序不变。
1838
+ this.store.reportRecordTransition(record);
1789
1839
  // [M5] closeAfterRound 消费点:chatMode 每轮完成的统一汇聚点(热路径轮不经
1790
1840
  // runAndFinalize CAS 分支——agent_settled 恒 arm idle timer → runAndFinalize 恒
1791
1841
  // early return,旧消费点对 chatMode 不可达,标志置了无人消费、tool 谎报 closed:true)。
@@ -1,7 +1,8 @@
1
1
  // src/core/temp-prompt.ts
2
2
  //
3
3
  // 将 agent systemPrompt 写入临时文件,供 pi CLI --append-system-prompt 使用。
4
- // Core 叶子原语(仅依赖 node 内置)。
4
+ // Core 叶子原语(node 内置 + 同目录 bestEffort 清理 helper)。注意 bestEffort
5
+ // 经 extension-logger 有到 pi 的传递依赖(仅日志通路,无 pi 运行时调用)。
5
6
  //
6
7
  // pi CLI 的 --append-system-prompt 接受文件路径(非内联字符串),故 spawn 前
7
8
  // 需把 systemPrompt 落盘。每次调用创建唯一临时目录,用完由 runSpawn 清理。
@@ -13,6 +14,8 @@ import * as fs from "node:fs";
13
14
  import * as os from "node:os";
14
15
  import * as path from "node:path";
15
16
 
17
+ import { bestEffort } from "./best-effort.ts";
18
+
16
19
  /** 临时 prompt 文件创建结果。dir 供调用方清理。 */
17
20
  export interface TempPromptFile {
18
21
  /** 临时目录绝对路径(mkdtemp 创建,调用方负责删除)。 */
@@ -51,7 +54,9 @@ export async function writePromptToTempFile(
51
54
  export async function cleanupTempPrompt(file: TempPromptFile): Promise<void> {
52
55
  try {
53
56
  await fs.promises.rm(file.dir, { recursive: true, force: true });
54
- } catch {
55
- // best-effort:临时文件泄漏不影响功能,OS tmpdir 清理机制兜底
57
+ } catch (err) {
58
+ // best-effort:临时文件泄漏不影响功能,OS tmpdir 清理机制兜底;失败经
59
+ // bestEffort 记 debug 日志(对齐 idle-gc / finalize-record 的清理惯例)
60
+ bestEffort(err, `cleanup temp prompt dir ${file.dir}`);
56
61
  }
57
62
  }
@@ -371,6 +371,13 @@ export interface ExecutionRecord {
371
371
  * 向后兼容:旧 record / 旧 session 文件无此字段,按一次性模式处理。
372
372
  */
373
373
  readonly chatMode?: boolean;
374
+ /**
375
+ * 执行态信号(residual-fixes 设计):true = 该 record 无活进程驱动(轮终 idle /
376
+ * 重建孤儿兜底),处于「可续聊/等续聊」态——不是后台真在跑。轮终迁移
377
+ * (doFinalizeRoundToIdle)置 true,冷路径续轮(进程启动)清除;GUI 侧
378
+ * streaming/waiting 细分与 hasRunning 判据消费。缺省 falsy = 有进程或旧数据。
379
+ */
380
+ resumable?: boolean;
374
381
  /**
375
382
  * 空闲超时毫秒数(仅 chatMode 有意义)。覆盖默认 5min idle timeout。
376
383
  * 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > 默认 300000ms。
@@ -415,8 +422,8 @@ export interface ExecutionRecord {
415
422
  * 范围静默丢失。
416
423
  * - D4 不持久化:磁盘重建走 createRecord(turns 仅为初始 [emptyTurn()]),base=0 对空 turn
417
424
  * 的增量派生等价为空、天然产出仅新轮增量,持久化是死数据。故不写 manifest、不参与重建。
418
- * - pi 内部序锚定依据(R1 mitigation):@earendil-works/pi-agent-core 0.84.0
419
- * dist/agent-loop.js :106-113(error/aborted stopReason 也先 emit turn_end 再 agent_end)
425
+ * - pi 内部序锚定依据(R1 mitigation):@earendil-works/pi-agent-core 0.84.2
426
+ * dist/agent-loop.js :108-111(error/aborted stopReason 也先 emit turn_end 再 agent_end)
420
427
  * 与 :131(正常路径 turn_end 收尾);agent_settled 在 agent_end 之后 emit,故未闭合
421
428
  * turn 只可能来自滞后事件。pi 升级若改变 turn_end/agent_end 时序,onRoundSettled 推进前
422
429
  * 的观测哨(末 turn 未闭合且 text 非空 → logger.warn)会留痕。
@@ -709,6 +716,17 @@ export interface SubagentRecord {
709
716
  * ExecutionRecord.round 投影。
710
717
  */
711
718
  round?: number;
719
+ /**
720
+ * 对话模式标志(与 ExecutionRecord.chatMode 同义;投影给 GUI 侧 streaming/waiting/done
721
+ * 细分判据——one-shot 轮终(chatMode 非 true + result 有值)显示完成态,chat 轮终显示
722
+ * 等续聊。内存源由 recordToSubagent 投影,磁盘源经 subagent-record entry 重建)。
723
+ */
724
+ chatMode?: boolean;
725
+ /**
726
+ * 执行态信号(与 ExecutionRecord.resumable 同义):true = 无活进程驱动的 running
727
+ * (轮终 idle / 重建孤儿兜底),GUI 侧据此排除「真在跑」判定。
728
+ */
729
+ resumable?: boolean;
712
730
  /** 外部 Pi 实例(进程隔离模式下由外部启动的子进程)。 */
713
731
  externalInstance?: AliveMarker;
714
732
  /** fork 模式下的 worktree handle。 */