@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
@@ -28,6 +28,8 @@ import * as path from "node:path";
28
28
  import { getLogger } from "@zhushanwen/pi-extension-logger";
29
29
 
30
30
  import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
31
+ import { writeFinalized } from "./finalized-marker.ts";
32
+ import { toSubagentRecordEntry, SUBAGENT_RECORD_CUSTOM_TYPE } from "./record-entry.ts";
31
33
  import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
32
34
  import { INDEX_WRITE_MIN_INTERVAL_MS, loadIndex, saveIndex } from "./sessions-index.ts";
33
35
  import type { SessionsIndexEntry, SessionsIndexNegativeEntry } from "./sessions-index.ts";
@@ -150,6 +152,124 @@ interface NegativeFileEntry {
150
152
  /** fileCache 值类型:正常条目或负缓存条目。 */
151
153
  type FileCacheValue = FileCacheEntry | NegativeFileEntry;
152
154
 
155
+ /** 孤儿判定的末行读取初始窗口(常规 entry 远小于此,避免全文件读)。 */
156
+ // eslint-disable-next-line no-magic-numbers -- 64KB = 64 * 1024 bytes 字节换算常数
157
+ const LAST_LINE_WINDOW_BYTES = 64 * 1024;
158
+ /** 末行超出初始窗口时的扩窗倍数(64KB → 256KB → 1MB → …,上限=文件头)。
159
+ * [HISTORICAL] 曾断言「64KB 足以容纳任何单行 entry(task 上限 ~62KB 实测)」并被
160
+ * V1 探针推翻:真实库 4822 个子文件中 28 个末行为 65KB-776KB 的完整 entry
161
+ * (subagent-identity 的 task 内嵌大 payload)——固定尾窗会把超长末行从中间切开
162
+ * 误判截断,孤儿恢复错落 error。 */
163
+ const WINDOW_GROWTH_FACTOR = 4;
164
+ /** 窗口内 ≥2 个非空段 = 末行之前存在换行边界(首段可能被窗口切开,末段完整到 EOF)。 */
165
+ const MIN_SEGMENTS_WITH_BOUNDARY = 2;
166
+
167
+ /**
168
+ * 读 JSONL 文件的最后一个非空行(孤儿终态判定用,residual-fixes §5.2)。
169
+ * 读文件尾部窗口起步;窗口内只有单个非空段且未到文件头时,该段可能是被窗口切开的
170
+ * 超长末行,按 WINDOW_GROWTH_FACTOR 扩窗直到看见末行前的换行边界或抵达文件头。
171
+ * 返回 ok=false 表示 IO 错误(open/read 阶段抛出,含权限/磁盘故障——可能是暂时
172
+ * 状态,调用方按保守方向处理)。
173
+ */
174
+ function readLastJsonlLine(sessionFile: string): { ok: true; line: string } | { ok: false } {
175
+ let fd: number | undefined;
176
+ try {
177
+ fd = fs.openSync(sessionFile, "r");
178
+ const size = fs.fstatSync(fd).size;
179
+ let windowBytes = LAST_LINE_WINDOW_BYTES;
180
+ let windowStart = Math.max(0, size - windowBytes);
181
+ let lines: string[] = [];
182
+ while (true) {
183
+ const buf = Buffer.alloc(size - windowStart);
184
+ fs.readSync(fd, buf, 0, buf.length, windowStart);
185
+ lines = buf.toString("utf-8").split("\n").filter((l) => l.length > 0);
186
+ // 末行完整可提取 = 窗口内末行之前还有换行(≥2 个非空段)或已覆盖到文件头。
187
+ if (windowStart === 0 || lines.length >= MIN_SEGMENTS_WITH_BOUNDARY) break;
188
+ windowBytes *= WINDOW_GROWTH_FACTOR;
189
+ const nextStart = Math.max(0, size - windowBytes);
190
+ if (nextStart === windowStart) break;
191
+ windowStart = nextStart;
192
+ }
193
+ // 窗口仍可能从行中间开始(首段被切开)——丢掉第一段;到文件头则首段是完整首行。
194
+ const candidates = windowStart > 0 && lines.length > 0 ? lines.slice(1) : lines;
195
+ const last = candidates.length > 0 ? candidates[candidates.length - 1] : lines[lines.length - 1];
196
+ if (last === undefined) return { ok: true, line: "" }; // 空文件:视为不可判终态的截断形态由 parse 失败兜住
197
+ return { ok: true, line: last };
198
+ } catch {
199
+ return { ok: false };
200
+ } finally {
201
+ if (fd !== undefined) {
202
+ try {
203
+ fs.closeSync(fd);
204
+ } catch (_e) {
205
+ void _e; // 关闭失败不影响已读结果(对齐 finalized-marker best-effort 模式)
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ /** unknown → subagent-record entry 的运行时守卫(taste/no-unsafe-cast:断言前收窄)。 */
212
+ function asSubagentRecordEntry(o: unknown): { id: string; data: Record<string, unknown> } | null {
213
+ if (typeof o !== "object" || o === null) return null;
214
+ const obj = o as Record<string, unknown>;
215
+ if (obj.type !== "custom" || obj.customType !== SUBAGENT_RECORD_CUSTOM_TYPE) return null;
216
+ if (typeof obj.data !== "object" || obj.data === null) return null;
217
+ const data = obj.data as Record<string, unknown>;
218
+ return typeof data.id === "string" ? { id: data.id, data } : null;
219
+ }
220
+
221
+ /** recoverEntryOnlyOrphans 用的 entry 扫描:主 session 全文 → 每 id 末条 record data。 */
222
+ function collectLastRecordEntries(content: string): Map<string, Record<string, unknown>> {
223
+ const lastById = new Map<string, Record<string, unknown>>();
224
+ for (const line of content.split("\n")) {
225
+ if (!line.includes(SUBAGENT_RECORD_CUSTOM_TYPE)) continue; // 快过滤:绝大多数行不是本类型
226
+ let entry: { id: string; data: Record<string, unknown> } | null = null;
227
+ try {
228
+ entry = asSubagentRecordEntry(JSON.parse(line));
229
+ } catch (err) {
230
+ // 截断/异构行跳过(主文件末行可能正被写入)——行级 best-effort,debug 留痕
231
+ logger.debug("[subagents] entry-only orphan scan: skip unparsable line", {
232
+ reason: err instanceof Error ? err.message : String(err),
233
+ });
234
+ }
235
+ if (entry !== null) lastById.set(entry.id, entry.data);
236
+ }
237
+ return lastById;
238
+ }
239
+
240
+ /** entry data 即 SubagentRecord v1 快照——带运行时 guard 重建(taste/no-unsafe-cast)。
241
+ * 损坏 entry(agent/task/startedAt 任一缺失)返回 null,由调用方跳过。 */
242
+ function rebuildEntryRecord(id: string, d: Record<string, unknown>): SubagentRecord | null {
243
+ const str = (k: string): string | undefined => (typeof d[k] === "string" ? (d[k] as string) : undefined);
244
+ const num = (k: string): number | undefined => (typeof d[k] === "number" ? (d[k] as number) : undefined);
245
+ const agent = str("agent");
246
+ const task = str("task");
247
+ const startedAt = num("startedAt");
248
+ if (agent === undefined || task === undefined || startedAt === undefined) return null; // 损坏 entry:跳过
249
+ return {
250
+ id,
251
+ agent,
252
+ task,
253
+ slug: str("slug") ?? "",
254
+ status: "running",
255
+ mode: "background",
256
+ startedAt,
257
+ rootSessionId: str("rootSessionId"),
258
+ parentRecordId: str("parentRecordId"),
259
+ depth: num("depth") ?? 0,
260
+ endedAt: undefined,
261
+ turns: num("turns") ?? 0,
262
+ totalTokens: num("totalTokens") ?? 0,
263
+ model: str("model") ?? "",
264
+ thinkingLevel: str("thinkingLevel"),
265
+ eventLog: [],
266
+ displayItems: [],
267
+ sessionFile: undefined,
268
+ chatMode: d.chatMode === true,
269
+ round: num("round"),
270
+ };
271
+ }
272
+
153
273
  /** stat 戳(不存在 → null)。 */
154
274
  function statStamp(p: string): Stamp | null {
155
275
  try {
@@ -191,6 +311,8 @@ export class RecordStore {
191
311
  private readonly records = new Map<string, ExecutionRecord>();
192
312
  private readonly listeners = new Set<ChangeListener>();
193
313
  private _disposed = false;
314
+ /** 孤儿终态恢复的已判定缓存(residual-fixes):resumable 形态无 sidecar 锚,同进程重复调用跳过。 */
315
+ private orphanJudged = new Set<string>();
194
316
  /** Pi handle(用于 appendEntry 上报损坏 manifest)。构造时可空,setPi() 后续注入。
195
317
  * 显式存为字段而非构造参数 readonly:setPi 需要写权限。 */
196
318
  private pi: RecordStorePi = null;
@@ -236,9 +358,12 @@ export class RecordStore {
236
358
  this.pi = pi ?? null;
237
359
  }
238
360
 
239
- /** 注册新 record。触发 onChange。 */
361
+ /** 注册新 record。触发 onChange。
362
+ * W16 [D4]:record 诞生(→ running)即 append 自描述快照 entry——pi 文件是
363
+ * 扩展数据持久化权威,custom entry 不进 LLM context。 */
240
364
  register(record: ExecutionRecord): void {
241
365
  this.records.set(record.id, record);
366
+ this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
242
367
  this.notifyChange();
243
368
  }
244
369
 
@@ -246,12 +371,28 @@ export class RecordStore {
246
371
  * 归档:record 已被 completeRecord 设置了终态 status。
247
372
  * 立即从内存移除(终态 record 下次读时从 session.jsonl 重建)。
248
373
  * cancelled record 由调用方先写 tombstone(cancel 路径),此处只负责移除。
374
+ *
375
+ * W16 [D4]:终态冻结字段(result/endedAt/closedReason)在 completeRecord 已就绪,
376
+ * 此处 append 的快照即完整终态记录(所有终态路径的必经锚点)。
249
377
  */
250
378
  archive(record: ExecutionRecord): void {
251
379
  this.records.delete(record.id);
380
+ this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
252
381
  this.notifyChange();
253
382
  }
254
383
 
384
+ /**
385
+ * W16 [D4]:类外状态写点上报(record-store 内的迁移点 register/archive 已内置)。
386
+ *
387
+ * 供 service 层直接改 record.status 的恢复写点调用(chatMode 续轮 idle→running
388
+ * 冷路径 resumeRound、轮终 finalizeRoundToIdle 回 running-resumable)——这些
389
+ * 写点绕过 register/archive,若不显式上报,pi 文件缺失该次迁移、重建源滞后。
390
+ * pi 未注入(session_start 前)时可选链静默降级,不阻断主流程。
391
+ */
392
+ reportRecordTransition(record: ExecutionRecord): void {
393
+ this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
394
+ }
395
+
255
396
  /** 按 id 查找。返回可变 record(仅 runtime 内部用)。 */
256
397
  getMutable(id: string): ExecutionRecord | undefined {
257
398
  return this.records.get(id);
@@ -377,6 +518,145 @@ export class RecordStore {
377
518
  .slice(0, limit);
378
519
  }
379
520
 
521
+ // ── 孤儿终态恢复(residual-fixes 设计 §6.1.2)──────────────────
522
+
523
+ /**
524
+ * 重建 SubagentRecord 的自描述 entry 落盘入口(签名适配:reportRecordTransition 收
525
+ * ExecutionRecord,重建孤儿的数据源是 SubagentRecord——直接经 toSubagentRecordEntry
526
+ * 投影 appendEntry,绕过 recordToSubagent)。pi 未注入时可选链静默。
527
+ */
528
+ reportSubagentRecord(record: SubagentRecord): void {
529
+ this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(record));
530
+ }
531
+
532
+ /**
533
+ * 孤儿终态恢复:对重建矩阵分支 4 兜底(running 且无 externalInstance)的 record
534
+ * 判定真实终态并落 entry,消除「父扩展死后再无人写终态 → 侧栏永久 running」。
535
+ *
536
+ * 判定(residual-fixes §5.2 三判据 + chat 分流):
537
+ * - chatMode = true → 不终态化(跨重启可续聊是产品语义,v4 B-1),落 resumable
538
+ * entry 供侧栏 waiting 细分;
539
+ * - 子 JSONL 末行完整 JSON.parse → closed(closedReason=gc,与分支 2 重建映射一致;
540
+ * done/failed 细分由 error 字段经 deriveClosedDisplay 派生)+ 写 .finalized sidecar
541
+ * (防重锚——下次重建走分支 2 不再进判定);
542
+ * - 末行截断 → closed + error(保守,错误方向安全)+ sidecar;
543
+ * - 文件不可读(IO 错误,可能暂时)→ 不判终态,落 resumable entry(防御性路径,
544
+ * IO 恢复后重开可重判)。
545
+ *
546
+ * 防重:orphanJudged 实例级缓存(resumable 形态无 sidecar 锚,同进程重复调用跳过;
547
+ * 终态形态双重防护 = sidecar + 缓存)。调用方:index.ts session_start 恢复段(一次)。
548
+ */
549
+ recoverOrphanRecords(rootSessionFilter?: string): void {
550
+ for (const rec of this.reconstructAll(rootSessionFilter)) {
551
+ // 分支 4 命中集 = running 且无活进程实例(分支 3 带 externalInstance,分支 1/2 已 closed)。
552
+ if (rec.status !== "running" || rec.externalInstance !== undefined) continue;
553
+ if (this.orphanJudged.has(rec.id)) continue;
554
+ this.orphanJudged.add(rec.id);
555
+ this.finalizeOrphanRecord(rec);
556
+ }
557
+ }
558
+
559
+ /**
560
+ * 单孤儿 record 的终态判定与落 entry(residual-fixes §5.2 三判据 + chat 分流)。
561
+ * 防重锚(orphanJudged 标记)已由调用方完成。
562
+ */
563
+ private finalizeOrphanRecord(rec: SubagentRecord): void {
564
+ if (rec.chatMode === true) {
565
+ // chat 会话跨重启等续聊:保留 running(可续聊),仅落执行态信号。
566
+ this.reportSubagentRecord({ ...rec, resumable: true });
567
+ return;
568
+ }
569
+ const sessionFile = rec.sessionFile;
570
+ if (sessionFile === undefined) return; // 无子文件锚(目录扫描源不可达,防御)
571
+ const lastLine = readLastJsonlLine(sessionFile);
572
+ if (!lastLine.ok) {
573
+ // IO 错误可能暂时——判终态不可逆,保守落 resumable 等重开重判。
574
+ this.reportSubagentRecord({ ...rec, resumable: true });
575
+ return;
576
+ }
577
+ let parseOk = false;
578
+ try {
579
+ JSON.parse(lastLine.line);
580
+ parseOk = true;
581
+ } catch {
582
+ parseOk = false;
583
+ }
584
+ // .finalized sidecar:防重锚(同 doFinalizeRecord 终态路径的收尾标记)。
585
+ writeFinalized(sessionFile);
586
+ this.reportSubagentRecord({
587
+ ...rec,
588
+ status: "closed",
589
+ closedReason: "gc",
590
+ endedAt: Date.now(),
591
+ ...(parseOk ? {} : { error: "orphan recovery: subagent session ended abnormally (truncated last line)" }),
592
+ });
593
+ }
594
+
595
+ /**
596
+ * [E2E 实测缺口] entry-born 孤儿恢复:register entry 已落主 session、但子 session 文件
597
+ * 从未创建(父进程死在 spawn 窗口期——register 写点与子进程首笔写入之间的窗口;外部
598
+ * 删除子文件的已知边界同形)。目录扫描(reconstructAll)看不见这类 record(无文件即
599
+ * 无扫描集),recoverOrphanRecords 判不到,侧栏(runtime entry 扫描源)永久 spinner。
600
+ *
601
+ * 判定:读主 session 的 subagent-record entry,取每 id 末条;末条 status=running 且
602
+ * 无子文件锚(不在 reconstructAll 结果中)且不在内存活 record(防误杀刚 register 的
603
+ * 在途 spawn)→ 按无文件判据收敛:chatMode=true → resumable(分流语义一致);否则
604
+ * closed+gc+error(子文件由子进程创建,无文件 = 子进程从未开跑,error 方向安全)。
605
+ * 调用点:initSession 的 recoverOrphanRecords 之后(session_start,内存恒空)。
606
+ */
607
+ recoverEntryOnlyOrphans(mainSessionFile: string | undefined, rootSessionFilter?: string): void {
608
+ if (mainSessionFile === undefined) return;
609
+ let content: string;
610
+ try {
611
+ content = fs.readFileSync(mainSessionFile, "utf-8");
612
+ } catch {
613
+ return; // 主文件不可读(含新 session 未 flush 的 ENOENT):静默跳过(best-effort 恢复)
614
+ }
615
+ const lastById = collectLastRecordEntries(content);
616
+ if (lastById.size === 0) return;
617
+ const anchoredIds = new Set(this.reconstructAll(rootSessionFilter).map((r) => r.id));
618
+ for (const [id, d] of lastById) {
619
+ if (!this.isEntryOrphanCandidate(id, d, rootSessionFilter, anchoredIds)) continue;
620
+ this.orphanJudged.add(id);
621
+ const rec = rebuildEntryRecord(id, d);
622
+ if (rec === null) continue; // 损坏 entry:跳过(orphanJudged 已标记,不重判)
623
+ this.finalizeEntryOnlyOrphan(rec, d.chatMode === true);
624
+ }
625
+ }
626
+
627
+ /**
628
+ * entry-born 孤儿候选判定(recoverEntryOnlyOrphans 的守卫链拆出):末条 running、
629
+ * root session 匹配、无子文件锚、不在内存活 record(防误杀在途 spawn)、未判过。
630
+ */
631
+ private isEntryOrphanCandidate(
632
+ id: string,
633
+ d: Record<string, unknown>,
634
+ rootSessionFilter: string | undefined,
635
+ anchoredIds: Set<string>,
636
+ ): boolean {
637
+ if (d.status !== "running") return false; // 末条已终态/轮终:entry 自洽,无需恢复
638
+ if (rootSessionFilter !== undefined && d.rootSessionId !== rootSessionFilter) return false;
639
+ if (anchoredIds.has(id)) return false; // 有子文件锚:主循环已判(或 sidecar 已终态)
640
+ if (this.records.has(id)) return false; // 内存活 record:在途 spawn,不得误杀
641
+ return !this.orphanJudged.has(id);
642
+ }
643
+
644
+ /** entry-born 孤儿按无文件判据收敛落 entry:chatMode → resumable(分流语义一致);
645
+ * 否则 closed+gc+error(子文件由子进程创建,无文件 = 子进程从未开跑,error 方向安全)。 */
646
+ private finalizeEntryOnlyOrphan(rec: SubagentRecord, chatMode: boolean): void {
647
+ this.reportSubagentRecord({
648
+ ...rec,
649
+ ...(chatMode
650
+ ? { resumable: true }
651
+ : {
652
+ status: "closed" as const,
653
+ closedReason: "gc" as const,
654
+ endedAt: Date.now(),
655
+ error: "orphan recovery: no child session file (spawn interrupted or file removed externally)",
656
+ }),
657
+ });
658
+ }
659
+
380
660
  /** 订阅变更。返回取消订阅函数。 */
381
661
  onChange(listener: ChangeListener): () => void {
382
662
  this.listeners.add(listener);
@@ -402,6 +682,7 @@ export class RecordStore {
402
682
  this.fileCache.clear();
403
683
  this.idToFile.clear();
404
684
  this.dirStamp = null;
685
+ this.orphanJudged.clear();
405
686
  // [perf L-1] 索引状态重置为初始值(「清内存」语义完备)。不取消挂起的 fire-and-forget
406
687
  // 写——in-flight 写的丢失显式接受(revive 后 dirStamp===null 重扫会重新装载/重写)。
407
688
  this.indexEntries = null;
@@ -763,6 +1044,7 @@ export class RecordStore {
763
1044
  result: base.result,
764
1045
  error: base.error,
765
1046
  sessionFile: base.sessionFile,
1047
+ chatMode: base.chatMode,
766
1048
  worktree: base.worktree,
767
1049
  };
768
1050
  } else {
@@ -789,6 +1071,7 @@ export class RecordStore {
789
1071
  result: undefined,
790
1072
  error: undefined,
791
1073
  sessionFile: base.sessionFile,
1074
+ chatMode: base.chatMode,
792
1075
  worktree: base.worktree,
793
1076
  };
794
1077
  }
@@ -904,6 +1187,12 @@ export class RecordStore {
904
1187
  error: r.error,
905
1188
  sessionFile: r.sessionFile,
906
1189
  round: r.round,
1190
+ // [E2E 实测抓漏] 缺这两行时 chatMode/resumable 在 recordToSubagent 处被丢弃,
1191
+ // entry 序列化后无此字段 → renderer isDone(需显式 chatMode===false)恒不成立,
1192
+ // 完成态 one-shot 永远显示 waiting。单测 schema 断言曾因内存对象保留 undefined
1193
+ // 键名而未拦截(真实 JSONL 丢 undefined 值),故 schema 测试改为序列化后断言。
1194
+ chatMode: r.chatMode,
1195
+ resumable: r.resumable,
907
1196
  // [review round2] worktree 隔离标志:内存源有 handle 或跨重启重建带 hadWorktree 均为 true。
908
1197
  worktree: r.worktreeHandle !== undefined || r.hadWorktree === true,
909
1198
  };
@@ -17,10 +17,11 @@ import * as fs from "node:fs";
17
17
 
18
18
  import { countActiveFromEntries } from "@zhushanwen/pi-pending-notifications";
19
19
 
20
- /** 后代刚完成(unregister)后,notify 唤醒父 agent 可能仍在路上(triggerTurn steer
21
- * 经进程内 EventBus 发送,与主进程处理 agent_end 行存在毫秒级竞态——explorer 3 秒完成
22
- * 时实测 unregister 先于 agent_end 判定写入,导致差集 0 误判完成)。此窗口内的
23
- * agent_end kill,等父被唤醒后的下一次 agent_end 再判。 */
20
+ /** 后代刚完成(unregister)后,notify 唤醒父 agent 可能仍在路上(triggerTurn
21
+ * steer/followUp sendMessage agent 队列排空(agent-session.js:1081-1087),
22
+ * 不经 EventBus——EventBus 只用于扩展间 pi.events;与主进程处理 agent_end 行存在
23
+ * 毫秒级竞态——explorer 3 秒完成时实测 unregister 先于 agent_end 判定写入,导致
24
+ * 差集 0 误判完成)。此窗口内的 agent_end 不 kill,等父被唤醒后的下一次 agent_end 再判。 */
24
25
  const RECENT_UNREGISTER_WINDOW_MS = 60_000;
25
26
 
26
27
  /** 判定结果:count > 0 = 有活跃后代(应保持进程等唤醒)。 */