@zhushanwen/pi-subagent-workflow 7.4.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 (166) hide show
  1. package/README.md +15 -0
  2. package/package.json +7 -4
  3. package/scripts/rfl.mjs +308 -0
  4. package/skills/workflow-script-format/SKILL.md +1 -1
  5. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  7. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  8. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  9. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  10. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  11. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  12. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  13. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  14. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  15. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  16. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  17. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  18. package/src/execution/__tests__/execution-record.test.ts +199 -0
  19. package/src/execution/__tests__/finalize-record.test.ts +197 -15
  20. package/src/execution/__tests__/format.test.ts +131 -7
  21. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  22. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
  24. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  26. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  27. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  28. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  29. package/src/execution/__tests__/list-component.test.ts +59 -5
  30. package/src/execution/__tests__/list-fields.test.ts +109 -0
  31. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  32. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  33. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  34. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  35. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  36. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  37. package/src/execution/__tests__/record-store.test.ts +442 -54
  38. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  39. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  40. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  41. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  42. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  43. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  44. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  45. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  46. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  47. package/src/execution/__tests__/session-pending.test.ts +61 -2
  48. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  49. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  50. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  51. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  52. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  53. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  54. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  55. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  56. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  57. package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  59. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  60. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  61. package/src/execution/__tests__/tool-action.test.ts +12 -10
  62. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  63. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  64. package/src/execution/__tests__/worktree-manager.test.ts +300 -90
  65. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
  66. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  67. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  68. package/src/execution/agent-result-mapper.ts +4 -1
  69. package/src/execution/argv-mirror.ts +21 -2
  70. package/src/execution/channel-registry-access.ts +3 -1
  71. package/src/execution/execution-record.ts +126 -9
  72. package/src/execution/finalize-record.ts +99 -13
  73. package/src/execution/idle-gc.ts +47 -0
  74. package/src/execution/lifecycle-manager.ts +491 -0
  75. package/src/execution/lifecycle-predicates.ts +65 -0
  76. package/src/execution/manifest-store.ts +61 -16
  77. package/src/execution/model-resolver.ts +26 -5
  78. package/src/execution/notifier.ts +69 -12
  79. package/src/execution/pi-invocation.ts +21 -1
  80. package/src/execution/record-entry.ts +118 -0
  81. package/src/execution/record-store.ts +844 -108
  82. package/src/execution/session-pending.ts +121 -49
  83. package/src/execution/session-reconstructor.ts +224 -7
  84. package/src/execution/session-runner.ts +713 -316
  85. package/src/execution/sessions-index.ts +304 -0
  86. package/src/execution/stdin-writer.ts +93 -7
  87. package/src/execution/stream-sink.ts +20 -3
  88. package/src/execution/subagent-service.ts +917 -138
  89. package/src/execution/temp-prompt.ts +8 -3
  90. package/src/execution/turn-limiter.ts +14 -0
  91. package/src/execution/types.ts +218 -19
  92. package/src/execution/worktree-manager.ts +449 -59
  93. package/src/execution/worktree-registry.ts +97 -29
  94. package/src/index.ts +318 -20
  95. package/src/injectors/subagent-list-injector.ts +26 -8
  96. package/src/injectors/workflow-list-injector.ts +25 -8
  97. package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
  98. package/src/interface/__tests__/tool-render.test.ts +10 -8
  99. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  100. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  101. package/src/interface/bg-notify-render.ts +32 -8
  102. package/src/interface/command-actions.ts +26 -7
  103. package/src/interface/commands.ts +21 -22
  104. package/src/interface/format.ts +53 -20
  105. package/src/interface/gui-mappers.ts +6 -8
  106. package/src/interface/helpers.ts +170 -10
  107. package/src/interface/list-component.ts +53 -14
  108. package/src/interface/subagent-actions.ts +235 -17
  109. package/src/interface/subagent-tool.ts +81 -16
  110. package/src/interface/subagents.ts +2 -1
  111. package/src/interface/tool-render.ts +21 -27
  112. package/src/interface/tool-workflow-script.ts +29 -33
  113. package/src/interface/tool-workflow.ts +67 -100
  114. package/src/interface/views/WorkflowsView.ts +89 -32
  115. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  116. package/src/interface/views/detail-content.ts +1 -1
  117. package/src/interface/views/format.ts +3 -3
  118. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
  119. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  120. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  121. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  122. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  123. package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
  124. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  128. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  129. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  130. package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
  131. package/src/orchestration/__tests__/test-mocks.ts +197 -0
  132. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  133. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
  134. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  135. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  136. package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
  137. package/src/orchestration/agent-opts-resolver.ts +4 -1
  138. package/src/orchestration/args-validator.ts +2 -2
  139. package/src/orchestration/config-loader.ts +30 -1
  140. package/src/orchestration/error-recovery.ts +137 -30
  141. package/src/orchestration/execute-agent-call.ts +44 -10
  142. package/src/orchestration/jsonl-run-store.ts +397 -71
  143. package/src/orchestration/launcher.ts +7 -1
  144. package/src/orchestration/lifecycle.ts +145 -133
  145. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  146. package/src/orchestration/models/budget.ts +1 -1
  147. package/src/orchestration/models/run-runtime.ts +15 -17
  148. package/src/orchestration/models/run-spec.ts +2 -2
  149. package/src/orchestration/models/run-state.ts +3 -3
  150. package/src/orchestration/models/trace.ts +95 -15
  151. package/src/orchestration/models/types.ts +8 -9
  152. package/src/orchestration/models/workflow-run.ts +50 -71
  153. package/src/orchestration/models/workflow-script.ts +32 -1
  154. package/src/orchestration/skill-discovery.ts +30 -0
  155. package/src/orchestration/worker-handle.ts +1 -1
  156. package/src/orchestration/worker-host.ts +1 -1
  157. package/src/orchestration/worker-script-builder.ts +38 -11
  158. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  159. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  160. package/src/shared/__tests__/resource-discovery.test.ts +79 -0
  161. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  162. package/src/shared/agent-ref.ts +22 -1
  163. package/src/shared/resource-discovery.ts +162 -59
  164. package/src/shared/schema-jsonify.ts +56 -0
  165. package/workflows/review-fix-loop-utils.cjs +542 -32
  166. package/workflows/review-fix-loop.js +462 -109
@@ -7,6 +7,20 @@
7
7
  // - onChange 订阅(TUI widget/list 据此重渲)
8
8
  // - collectRecords:内存(running) + 磁盘(sessions/*.jsonl 重建) 合并
9
9
  // - 提供 snapshot() 只读视图给 TUI(永不返回可变引用)
10
+ //
11
+ // [perf] 两级读写设计(修复 /subagents 打开慢):
12
+ // 1. 列表扫描 = light:只读文件头部 identity(readIdentityHeader,64KB)+ sidecar
13
+ // 状态矩阵,不解析 message entries。列表/补全/hasRunning 只需身份与状态,
14
+ // 586MB 级 sessions 目录的全量 JSON.parse(秒级)从列表路径上消失。
15
+ // 2. 详情 = getFullRecord(id) 懒加载:选中/单独查询时才对该文件全量重建
16
+ // (reconstructFromFile),turns/eventLog/result 等重数据仅按需解析。
17
+ // 3. per-file 缓存 + stat 戳校验(mtime+size):register/archive 等内存事件
18
+ // 不再整体失效缓存;任何磁盘写入(jsonl append / sidecar 覆盖)只重建对应
19
+ // 单文件,其余 N-1 个文件复用缓存(stat 校验毫秒级)。
20
+ // 4. [perf L-1] sessions-index.json(sessionsDir 兄弟位置):identity 探测结果的
21
+ // 磁盘种子——冷启动首扫读一次(惰性装载),dirty 扫描后按 60s 节流落盘
22
+ // (fileCache 投影,fire-and-forget);运行期 L0/L1 语义不变,损坏/低版本
23
+ // 静默回退全扫,高版本忽略不重写。见 sessions-index.ts。
10
24
 
11
25
  import * as fs from "node:fs";
12
26
  import * as path from "node:path";
@@ -14,16 +28,29 @@ import * as path from "node:path";
14
28
  import { getLogger } from "@zhushanwen/pi-extension-logger";
15
29
 
16
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";
17
33
  import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
18
- import { reconstructFromFile } from "./session-reconstructor.ts";
34
+ import { INDEX_WRITE_MIN_INTERVAL_MS, loadIndex, saveIndex } from "./sessions-index.ts";
35
+ import type { SessionsIndexEntry, SessionsIndexNegativeEntry } from "./sessions-index.ts";
36
+ import {
37
+ IDENTITY_HEAD_BYTES,
38
+ type IdentityHeaderRecon,
39
+ type ReconstructedRecord,
40
+ readIdentityAnywhere,
41
+ readIdentityHeader,
42
+ readIdentityTail,
43
+ reconstructFromFile,
44
+ } from "./session-reconstructor.ts";
19
45
  import type {
46
+ AliveMarker,
20
47
  ExecutionRecord,
21
48
  ExecutionStatus,
22
49
  RecordSnapshot,
23
50
  SubagentRecord,
24
51
  } from "./types.ts";
52
+ import type { CancelledTombstone } from "./tombstone-store.ts";
25
53
  import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
26
- import { readFinalized } from "./finalized-marker.ts";
27
54
  import { readCancelledTombstone } from "./tombstone-store.ts";
28
55
 
29
56
  const logger = getLogger("subagents");
@@ -32,13 +59,12 @@ const logger = getLogger("subagents");
32
59
  // 常量
33
60
  // ============================================================
34
61
 
35
- /** status → 排序优先级(值小排前):running < failed < crashed < cancelled < done */
62
+ /** status → 排序优先级(值小排前):running < closed
63
+ * v4 B-1:idle/cancelled 折入 running/closed,两态收敛。closed = 统一终态
64
+ * (done/failed/crashed/cancelled 合并),按 closedReason 派生对外语义。 */
36
65
  const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
37
66
  running: 0,
38
- failed: 1,
39
- crashed: 1,
40
- cancelled: 2,
41
- done: 3,
67
+ closed: 3,
42
68
  };
43
69
 
44
70
  /** .alive sidecar 的 24 小时软超时(超过此时间即使 pid 存活也判 crashed)。 */
@@ -47,19 +73,21 @@ const ALIVE_SOFT_TIMEOUT_MS = 3_600_000; // 1h in ms (reduced from 24h to minimi
47
73
  /**
48
74
  * manifest status → ExecutionStatus 运行时守卫映射。
49
75
  *
50
- * manifest 写 running/completed/failed/cancelled 四态(ManifestRecord.status union),但磁盘
51
- * 文件可能陈旧(含历史 "error" 值、被外部篡改、或意外出现 crashed 值)。越界值返回 null——
76
+ * manifest 写 running/closed/cancelled 三态(ManifestRecord.status union),但磁盘
77
+ * 文件可能陈旧(含历史 "completed"/"failed"/"error" 值、被外部篡改)。越界值返回 null——
52
78
  * manifestToSubagent 据此返回 null,collectRecords 跳过损坏 record 并 console.warn,不因单个
53
- * 坏文件崩溃,也不把损坏 record 错误降级为 failedfailed 会触发重试/告警,是误报)。
79
+ * 坏文件崩溃,也不把损坏 record 错误降级为 closedclosed 触发告警,是误报)。
54
80
  *
55
81
  * 提取为纯函数:同时解决 PR#85 反射问题(三元 + `as ExecutionStatus` cast)。
82
+ * [HISTORICAL] SP-1 重构:旧 "completed" → closed,旧 "failed" → closed(L1 统一终态)。
56
83
  */
57
84
  function mapManifestStatus(s: string): ExecutionStatus | null {
58
- if (s === "completed") return "done";
59
- if (s === "failed") return "failed";
85
+ if (s === "closed") return "closed";
86
+ if (s === "completed") return "closed"; // 向后兼容旧 manifest 数据
87
+ if (s === "failed") return "closed"; // 向后兼容旧 manifest 数据
60
88
  if (s === "running") return "running";
61
- if (s === "cancelled") return "cancelled";
62
- return null; // 越界=数据损坏(含历史 "error"、意外 crashed 值),返回 null 让调用方跳过
89
+ if (s === "cancelled") return "closed"; // v4 B-1: manifest cancelled 折入 closed(closedReason 信息丢失,manifest 仅诊断辅助)
90
+ return null; // 越界=数据损坏(含历史 "error" 值),返回 null 让调用方跳过
63
91
  }
64
92
 
65
93
  /** store 变更监听器(返回取消订阅函数)。 */
@@ -68,6 +96,199 @@ export type ChangeListener = () => void;
68
96
  /** status 过滤模式(collectRecords 的核心能力参数)。 */
69
97
  export type StatusFilter = "running" | "all";
70
98
 
99
+ /** 缓存校验戳(mtime+size 双因子——append-only jsonl 必变 size;sidecar 覆盖写必变 mtime)。 */
100
+ interface Stamp {
101
+ mtimeMs: number;
102
+ size: number;
103
+ }
104
+
105
+ /** sidecar 状态矩阵输入(buildLightRecord / getFullRecord 共享)。 */
106
+ interface SidecarMatrix {
107
+ tomb: CancelledTombstone | undefined;
108
+ finalized: boolean;
109
+ alive: AliveMarker | undefined;
110
+ /** jsonl mtime(light 分支 2 的 endedAt 近似——finalize 后文件不再变化)。 */
111
+ jsonlMtimeMs: number;
112
+ /** 全量重建可得的精确结束时间(最后 entry ts);light 传 undefined 回落 mtime。 */
113
+ fullEndedAt?: number;
114
+ now: number;
115
+ }
116
+
117
+ /**
118
+ * per-file 缓存条目([perf] 两级设计)。
119
+ *
120
+ * light:头部 identity + sidecar 状态矩阵(列表扫描产出,详情字段缺省)
121
+ * full :懒加载的完整重建(getFullRecord 按需补 turns/eventLog/result)。
122
+ * full === light 是哨兵(「已尝试但无详情可补」,如无 assistant message
123
+ * 的文件),避免重复全文重读;stat 戳变化时随 light 一起重置重试。
124
+ *
125
+ * 校验:jsonl + 3 sidecar 的 stat 戳对比。任何写操作至少改变一个戳 →
126
+ * 只重建该文件,其余 N-1 个复用缓存(statSync 毫秒级,取代旧的整体失效重扫)。
127
+ */
128
+ interface FileCacheEntry {
129
+ /** tagged union 判别(负缓存条目为 true)。显式声明 false 供 TS narrowing。 */
130
+ negative?: false;
131
+ light: SubagentRecord;
132
+ full: SubagentRecord | undefined;
133
+ jsonl: Stamp;
134
+ cancelled: Stamp | null;
135
+ finalized: Stamp | null;
136
+ alive: Stamp | null;
137
+ /** 最近一次重建时读到的 sidecar 原始内容(校验命中路径复用,不重读文件)。 */
138
+ tomb: CancelledTombstone | undefined;
139
+ aliveData: AliveMarker | undefined;
140
+ }
141
+
142
+ /** 负缓存条目:确认无 identity 的文件(损坏/异构)。缓存「没有」这一事实,
143
+ * 避免每轮扫描都全文 fallback 重读(全文读是 fallback 的成本主体)。 */
144
+ interface NegativeFileEntry {
145
+ negative: true;
146
+ jsonl: Stamp;
147
+ cancelled: Stamp | null;
148
+ finalized: Stamp | null;
149
+ alive: Stamp | null;
150
+ }
151
+
152
+ /** fileCache 值类型:正常条目或负缓存条目。 */
153
+ type FileCacheValue = FileCacheEntry | NegativeFileEntry;
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
+
273
+ /** stat 戳(不存在 → null)。 */
274
+ function statStamp(p: string): Stamp | null {
275
+ try {
276
+ const s = fs.statSync(p);
277
+ return { mtimeMs: s.mtimeMs, size: s.size };
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ function sameStamp(a: Stamp, b: Stamp): boolean {
284
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
285
+ }
286
+
287
+ function sameNullableStamp(a: Stamp | null, b: Stamp | null): boolean {
288
+ if (a === null || b === null) return a === b;
289
+ return sameStamp(a, b);
290
+ }
291
+
71
292
  /** Pi ExtensionAPI 的最小子集(仅 collectRecords 跳过损坏 manifest 时上报用)。
72
293
  * 解构为局部类型,避免与 subagent-service 的 PiLike 循环依赖。 */
73
294
  export type RecordStorePi = {
@@ -82,21 +303,41 @@ export type RecordStorePi = {
82
303
  * Record 容器。进程单例(随 SubagentService 重建)。
83
304
  *
84
305
  * 内存只留 running record——终态 record 在 archive 时立即移除,collectRecords
85
- * 读时从 sessions/*.jsonl 重建(reconstructFromFile)。重建结果有缓存,在
86
- * notifyChange 时失效(终态 record 不再变化,但新 finalize 触发重扫)。
306
+ * 读时从 sessions/*.jsonl 重建([perf] light 头部扫描 + per-file 缓存)。
87
307
  *
88
- * 任何 mutate → notifyChange()
308
+ * 任何 mutate → notifyChange()(仅通知监听器;磁盘缓存靠 stat 戳自校验,不清空)。
89
309
  */
90
310
  export class RecordStore {
91
311
  private readonly records = new Map<string, ExecutionRecord>();
92
312
  private readonly listeners = new Set<ChangeListener>();
93
313
  private _disposed = false;
314
+ /** 孤儿终态恢复的已判定缓存(residual-fixes):resumable 形态无 sidecar 锚,同进程重复调用跳过。 */
315
+ private orphanJudged = new Set<string>();
94
316
  /** Pi handle(用于 appendEntry 上报损坏 manifest)。构造时可空,setPi() 后续注入。
95
317
  * 显式存为字段而非构造参数 readonly:setPi 需要写权限。 */
96
318
  private pi: RecordStorePi = null;
97
319
 
98
- /** 重建缓存:sessionFile SubagentRecord。notifyChange 时失效。 */
99
- private reconCache: Map<string, SubagentRecord> | undefined;
320
+ /** [perf] per-file 缓存(key = sessionFile 绝对路径)。不再整体失效——stat 戳精准校验。
321
+ * 值含负缓存(确认无 identity 的文件),防每轮全文 fallback 重读。 */
322
+ private readonly fileCache = new Map<string, FileCacheValue>();
323
+ /** record id → sessionFile 索引(getFullRecord 按 id 定位文件)。随 fileCache 同步维护。 */
324
+ private readonly idToFile = new Map<string, string>();
325
+ /** [perf] sessionsDir 最近一次全量扫描的 mtime(快路径判变,见 reconstructAll)。
326
+ * null = 未扫过 / 已 dispose。 */
327
+ private dirStamp: { mtimeMs: number } | null = null;
328
+
329
+ /** [perf L-1] 首扫惰性装载的磁盘索引只读映像(key = jsonl basename)。
330
+ * 扫描尾(flushIndexAfterScan)与 readdir 失败路径释放——运行期索引不再被读(L1 接管)。 */
331
+ private indexEntries: Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> | null = null;
332
+ /** [perf L-1] 本轮起未落盘的探测标志:scanFile 走过探测分支即置位。发起写时消费
333
+ * (置 false)、写失败恢复;未写路径不清位——未落盘的探测成果跨轮携带直至真正写入。 */
334
+ private indexDirty = false;
335
+ /** [perf L-1] 上次成功落盘墙钟(节流基准)。0 = 从未写过 → 首扫 dirty 必写;
336
+ * 仅成功分支推进(写失败不推进节流窗,下轮过窗重试)。 */
337
+ private lastIndexWriteAt = 0;
338
+ /** [perf L-1] loadIndex 高版本标志的进程级持久态:true 时本进程所有后续扫描均不
339
+ * 落盘(防 v1/v2 last-writer-wins 覆盖振荡),直至下次 loadIndex 重新评估。 */
340
+ private indexHigherVersion = false;
100
341
 
101
342
  constructor(
102
343
  private readonly sessionsDir: string,
@@ -117,9 +358,12 @@ export class RecordStore {
117
358
  this.pi = pi ?? null;
118
359
  }
119
360
 
120
- /** 注册新 record。触发 onChange。 */
361
+ /** 注册新 record。触发 onChange。
362
+ * W16 [D4]:record 诞生(→ running)即 append 自描述快照 entry——pi 文件是
363
+ * 扩展数据持久化权威,custom entry 不进 LLM context。 */
121
364
  register(record: ExecutionRecord): void {
122
365
  this.records.set(record.id, record);
366
+ this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
123
367
  this.notifyChange();
124
368
  }
125
369
 
@@ -127,12 +371,28 @@ export class RecordStore {
127
371
  * 归档:record 已被 completeRecord 设置了终态 status。
128
372
  * 立即从内存移除(终态 record 下次读时从 session.jsonl 重建)。
129
373
  * cancelled record 由调用方先写 tombstone(cancel 路径),此处只负责移除。
374
+ *
375
+ * W16 [D4]:终态冻结字段(result/endedAt/closedReason)在 completeRecord 已就绪,
376
+ * 此处 append 的快照即完整终态记录(所有终态路径的必经锚点)。
130
377
  */
131
378
  archive(record: ExecutionRecord): void {
132
379
  this.records.delete(record.id);
380
+ this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
133
381
  this.notifyChange();
134
382
  }
135
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
+
136
396
  /** 按 id 查找。返回可变 record(仅 runtime 内部用)。 */
137
397
  getMutable(id: string): ExecutionRecord | undefined {
138
398
  return this.records.get(id);
@@ -168,12 +428,21 @@ export class RecordStore {
168
428
  .map((r) => toSnapshot(r));
169
429
  }
170
430
 
431
+ /** SP-4: 列出所有活跃 record(running + idle)的可变引用。
432
+ * 供 SubagentService.disposeAllRecords 做级联关闭。 */
433
+ listAllActive(): ExecutionRecord[] {
434
+ return [...this.records.values()]
435
+ .filter((r) => r.status === "running");
436
+ }
437
+
171
438
  /**
172
439
  * 合并内存(running) + 磁盘(sessions/*.jsonl 重建) → SubagentRecord[]。
173
440
  *
174
441
  * ╔══════════════════════════════════════════════════════════════════╗
175
- * ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 reconstructFromFile
176
- * ║ (命中缓存则跳过读文件)。cancelled tombstone override status
442
+ * ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 scanFile([perf] 头部
443
+ * ║ identity 轻量重建 + stat 戳缓存命中零读取)。cancelled
444
+ * ║ tombstone override status。详情字段(eventLog/result/turns) ║
445
+ * ║ 缺省,由 getFullRecord(id) 懒加载 ║
177
446
  * ║ 2. 内存源覆盖(同 id 内存优先——running record 更新鲜) ║
178
447
  * ║ 3. session 过滤:只留 rootSessionId === rootSessionFilter 的 ║
179
448
  * ║ record。rootSessionId 缺失(旧文件)的 record 一律排除 ║
@@ -249,6 +518,145 @@ export class RecordStore {
249
518
  .slice(0, limit);
250
519
  }
251
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
+
252
660
  /** 订阅变更。返回取消订阅函数。 */
253
661
  onChange(listener: ChangeListener): () => void {
254
662
  this.listeners.add(listener);
@@ -257,10 +665,11 @@ export class RecordStore {
257
665
  };
258
666
  }
259
667
 
260
- /** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。同时失效重建缓存。 */
668
+ /** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。
669
+ * [perf] 不清空磁盘缓存:per-file stat 戳自校验(任何磁盘写入改变戳 → 单文件重建),
670
+ * 内存事件(register/archive)不改变磁盘文件——旧实现整体失效是全量重扫的根因。 */
261
671
  notifyChange(): void {
262
672
  if (this._disposed) return;
263
- this.reconCache = undefined; // 失效缓存(新 finalize 可能产出新 session.jsonl)。
264
673
  for (const listener of this.listeners) {
265
674
  listener();
266
675
  }
@@ -270,6 +679,16 @@ export class RecordStore {
270
679
  dispose(): void {
271
680
  this._disposed = true;
272
681
  this.listeners.clear();
682
+ this.fileCache.clear();
683
+ this.idToFile.clear();
684
+ this.dirStamp = null;
685
+ this.orphanJudged.clear();
686
+ // [perf L-1] 索引状态重置为初始值(「清内存」语义完备)。不取消挂起的 fire-and-forget
687
+ // 写——in-flight 写的丢失显式接受(revive 后 dirStamp===null 重扫会重新装载/重写)。
688
+ this.indexEntries = null;
689
+ this.indexDirty = false;
690
+ this.lastIndexWriteAt = 0;
691
+ this.indexHigherVersion = false;
273
692
  }
274
693
 
275
694
  /** /resume /fork /new 后复活(dispose 的逆操作)。 */
@@ -280,117 +699,424 @@ export class RecordStore {
280
699
  // ── 内部 ──────────────────────────────────────────────────
281
700
 
282
701
  /**
283
- * 四分支 sidecar 矩阵重建。
702
+ * 四分支 sidecar 矩阵重建([perf] light 版)。
284
703
  *
285
704
  * 优先级:
286
- * 1. .cancelled → cancelled
287
- * 2. .finalized → done/failed(按 recon.stopReason 推)
288
- * 3. .alive + pid 存活 + 未超 24h → running, externalInstance=true
289
- * 4. 兜底 crashed
705
+ * 1. .cancelled → closed(closedReason=cancelled
706
+ * 2. .finalized → closed(closedReason=gc)
707
+ * 3. .alive + pid 存活 + 未超软超时 → running, externalInstance=true
708
+ * 4. 兜底(无 marker、pid 死、超时)→ running(v4 B-1 可续聊语义)
290
709
  *
291
- * 所有分支经 markReconstructedStatus(不裸 .status=)。
710
+ * [perf]:逐文件 scanFile(stat 戳校验 + 头部 identity 轻量重建)。命中缓存的
711
+ * 文件零文件读取;变化的文件只重建自身,其余 N-1 个复用缓存。
292
712
  *
293
713
  * session 隔离:rootSessionFilter 非空时,只保留 rootSessionId 匹配的 record。
294
714
  * rootSessionId 缺失(旧文件,未带身份字段)一律排除(无法判定归属)。
295
- * 缓存以 undefined 过滤结果为基底,带 filter 时在基底上再筛(避免缓存碎片化)。
296
715
  */
297
716
  private reconstructAll(rootSessionFilter?: string): SubagentRecord[] {
298
- if (this.reconCache) {
299
- const all = [...this.reconCache.values()];
300
- if (rootSessionFilter === undefined) return all;
301
- return all.filter((r) => r.rootSessionId === rootSessionFilter);
717
+ // [perf] 目录 mtime 快路径:sessionsDir mtime 未变 ⇒ 文件集合与 sidecar 集合都未变
718
+ //(任何文件新建/删除/重命名都改目录 mtime),且 jsonl append 不影响 light 态
719
+ //(identity/status 由首行与 sidecar 决定,进度重数据走 getFullRecord 的独立 stat
720
+ //校验)→ 跳过 readdir + N×4 statSync,直接复用缓存 light。/subagents overlay 打开
721
+ //期间 250ms 动画 timer + 120ms debounce 双驱动高频扫描,快路径把 ~N×4 stat 降到
722
+ // 1 次(目录本身)+ 少量 pid 探活(refreshAlive,内存无 IO)。
723
+ // 已知局限(与 mtime 缓存同族):目录 mtime 粒度粗糙的文件系统(NFS/2s FAT)
724
+ // 可能漏判——APFS 微秒级可靠。invalidate 语义由文件写入侧保证,但**仅限 sidecar
725
+ // 新建/删除/重命名**(这些操作必改目录 mtime);覆盖写已存在的 sidecar 不改目录
726
+ // mtime——`.alive` 覆盖写(resume spawn 后 pid 变化重写 marker)后快路径会复用旧
727
+ // aliveData(旧 pid/旧 startedAt),refreshAlive 探活与 1h 软超时判定可能滞后一拍
728
+ //(status 判定不受影响:分支 3 探活失败只清 externalInstance,不改 status)。
729
+ let dirMtimeMs: number;
730
+ try {
731
+ dirMtimeMs = fs.statSync(this.sessionsDir).mtimeMs;
732
+ } catch {
733
+ return [];
734
+ }
735
+ // [perf L-1] 首扫(dirStamp===null)惰性装载磁盘索引。必须位于 statSync 之后:
736
+ // sessionsDir 不存在的 early-return 不装载映像(防解析产物滞留内存);首扫时
737
+ // 下方快路径条件必不成立,插在快路径 if 前后等价。
738
+ if (this.dirStamp === null) {
739
+ const loaded = loadIndex(path.dirname(this.sessionsDir));
740
+ this.indexEntries = loaded.entries;
741
+ this.indexHigherVersion = loaded.higherVersion;
742
+ }
743
+ if (this.dirStamp !== null && this.dirStamp.mtimeMs === dirMtimeMs) {
744
+ const now = Date.now();
745
+ const out: SubagentRecord[] = [];
746
+ for (const entry of this.fileCache.values()) {
747
+ if (entry.negative) continue;
748
+ RecordStore.refreshAlive(entry, now);
749
+ out.push(entry.light);
750
+ }
751
+ return rootSessionFilter === undefined
752
+ ? out
753
+ : out.filter((r) => r.rootSessionId === rootSessionFilter);
302
754
  }
303
755
 
304
- const cache = new Map<string, SubagentRecord>();
305
756
  let files: string[];
306
757
  try {
307
758
  files = fs.readdirSync(this.sessionsDir)
308
759
  .filter((f) => f.endsWith(".jsonl"))
309
760
  .map((f) => path.join(this.sessionsDir, f));
310
761
  } catch {
311
- this.reconCache = cache;
762
+ this.indexEntries = null; // [perf L-1] 该 early-return 路径同样释放映像(内存卫生)
312
763
  return [];
313
764
  }
314
765
 
315
- const now = Date.now();
766
+ // 修剪:磁盘上已消失的文件(GC/手动删)同步移出缓存与索引。删了条目必须置
767
+ // indexDirty——否则纯修剪轮(无其他探测)flush 第一道门 !dirty return,磁盘索引的
768
+ // 陈旧条目永不清除(fileCache 已删仅影响内存投影,落盘快照不会自发跟随)。
769
+ const disk = new Set(files);
770
+ for (const [file, entry] of this.fileCache) {
771
+ if (!disk.has(file)) {
772
+ this.fileCache.delete(file);
773
+ if (!entry.negative) this.idToFile.delete(entry.light.id);
774
+ this.indexDirty = true;
775
+ }
776
+ }
316
777
 
778
+ const now = Date.now();
779
+ const out: SubagentRecord[] = [];
317
780
  for (const file of files) {
318
- const recon = reconstructFromFile(file);
319
- if (!recon) continue; // 文件缺失/损坏/缺 identity → 跳过。
320
-
321
- // 读取三个 sidecar(best-effort,不存在返回 falsy)。
322
- const tomb = readCancelledTombstone(file);
323
- const finalized = readFinalized(file);
324
- const alive = readAliveMarker(file);
325
-
326
- // 构造 base record(status/error/endedAt/externalInstance 后续按分支覆盖)。
327
- const rec: SubagentRecord = {
328
- id: recon.id,
329
- agent: recon.agent,
330
- slug: recon.slug,
331
- status: recon.status, // 临时值,各分支覆盖
332
- mode: recon.mode,
333
- startedAt: recon.startedAt,
334
- rootSessionId: recon.rootSessionId,
335
- parentRecordId: recon.parentRecordId,
336
- depth: recon.depth,
337
- endedAt: undefined,
338
- turns: recon.turnCount,
339
- totalTokens: recon.totalTokens,
340
- model: recon.model,
341
- thinkingLevel: recon.thinkingLevel,
342
- task: recon.task,
343
- // 磁盘重建是离线快照,无实时活动状态。
344
- currentActivity: undefined,
345
- // worktreeHandle 不从磁盘重建(session.jsonl 未持久化路径/分支)。
346
- // 已结束的 worktree record 的 checkout 已被 cleanup 回收,重建句柄无意义。
347
- // forkDepth 从 identity 重建(用于 TUI 深度标记),worktree 信息仅内存 running 时可见。
348
- eventLog: recon.eventLog,
349
- // [STEP3] displayItems 从重建的 turns[] 派生(getDisplayItems 参数放宽为
350
- // { turns },ReconstructedRecord 满足)。终态 record 详情可看完整 text 输出。
351
- displayItems: getDisplayItems(recon),
352
- result: recon.result,
353
- error: recon.error,
354
- sessionFile: recon.sessionFile,
355
- };
781
+ const entry = this.scanFile(file, now);
782
+ if (entry) out.push(entry.light);
783
+ }
784
+ this.dirStamp = { mtimeMs: dirMtimeMs };
785
+ this.flushIndexAfterScan();
786
+ if (rootSessionFilter === undefined) return out;
787
+ return out.filter((r) => r.rootSessionId === rootSessionFilter);
788
+ }
356
789
 
357
- // ── 分支 1: .cancelled ──
358
- if (tomb) {
359
- markReconstructedStatus(rec, "cancelled");
360
- rec.error = "cancelled by user";
361
- rec.endedAt = tomb.endedAt;
362
- }
363
- // ── 分支 2: .finalized ──
364
- else if (finalized) {
365
- // done/failed recon 推导的 stopReason(reconstructFromFile 已映射为 status)。
366
- const status: ExecutionStatus = recon.status === "failed" ? "failed" : "done";
367
- markReconstructedStatus(rec, status);
368
- // 用最后一条 entry 的时间戳作为 endedAt(避免重建后耗时随墙钟无限增长)。
369
- rec.endedAt = recon.endedAt;
790
+ /**
791
+ * 扫描单文件:stat 戳(jsonl + 3 sidecar)校验,全同 → 复用缓存(零文件读取,
792
+ * 含负缓存直接返回 null);否则重建 light。
793
+ * identity 定位两级:头部 64KB(首轮会话)→ 全文 fallback(续聊场景 identity
794
+ * append 在尾部);两级都找不到 → 写负缓存(防每轮全文重读)。
795
+ * 返回 null:文件消失/读失败/无 identity → 跳过。
796
+ */
797
+ private scanFile(file: string, now: number): FileCacheEntry | null {
798
+ const jsonl = statStamp(file);
799
+ if (!jsonl) {
800
+ this.fileCache.delete(file);
801
+ return null;
802
+ }
803
+ const cancelled = statStamp(`${file}.cancelled`);
804
+ const finalized = statStamp(`${file}.finalized`);
805
+ const alive = statStamp(`${file}.alive`);
806
+
807
+ const cached = this.fileCache.get(file);
808
+ if (
809
+ cached !== undefined &&
810
+ sameStamp(cached.jsonl, jsonl) &&
811
+ sameNullableStamp(cached.cancelled, cancelled) &&
812
+ sameNullableStamp(cached.finalized, finalized) &&
813
+ sameNullableStamp(cached.alive, alive)
814
+ ) {
815
+ if (cached.negative) return null; // 负缓存命中:确认无 identity,零读取跳过
816
+ // pid 探活结果不落盘(进程死亡无 IO)——分支 3 的 running 项每扫重查,
817
+ // 保留原语义(旧实现每次 collectRecords 都重新 isProcessAlive)。
818
+ RecordStore.refreshAlive(cached, now);
819
+ return cached;
820
+ }
821
+
822
+ // [perf L-1] 磁盘索引查询(首扫惰性装载,miss/空索引时 get 恒 undefined = 无索引)。
823
+ // 条目戳匹配 jsonl 当前 stat → 零内容读取构造缓存条目。sidecar payload(tombstone/
824
+ // alive)是活态数据,沿用探测分支的每轮重读语义。
825
+ if (this.indexEntries !== null) {
826
+ const hit = this.indexEntries.get(path.basename(file));
827
+ if (hit !== undefined && hit.mtimeMs === jsonl.mtimeMs && hit.size === jsonl.size) {
828
+ if (hit.negative === true) {
829
+ // 负条目命中:「确认无 identity」跨实例持久,零探测跳过(与下方负缓存同款形态)。
830
+ this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
831
+ return null;
832
+ }
833
+ const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
834
+ const aliveData = alive !== null ? readAliveMarker(file) : undefined;
835
+ const entry: FileCacheEntry = {
836
+ light: RecordStore.buildRecord(
837
+ { ...hit, forkDepth: undefined, sessionFile: file },
838
+ { tomb, finalized: finalized !== null, alive: aliveData, jsonlMtimeMs: jsonl.mtimeMs, now },
839
+ ),
840
+ full: undefined,
841
+ jsonl,
842
+ cancelled,
843
+ finalized,
844
+ alive,
845
+ tomb,
846
+ aliveData,
847
+ };
848
+ this.fileCache.set(file, entry);
849
+ this.idToFile.set(hit.id, file);
850
+ return entry;
370
851
  }
371
- // ── 分支 3: .alive + pid 存活 + 未超 24h 软超时 ──
372
- else if (
373
- alive !== undefined &&
374
- isProcessAlive(alive.pid) &&
375
- now - alive.startedAt < ALIVE_SOFT_TIMEOUT_MS
376
- ) {
377
- markReconstructedStatus(rec, "running");
378
- rec.externalInstance = alive;
852
+ }
853
+
854
+ // [perf L-1] 索引 miss/戳不匹配落到原三级探测:本轮探测结果必须进索引(含负探测)。
855
+ // 覆盖两种形态:首扫(映像已装载但 miss/不匹配)与后续轮次(映像已释放,凡进重建分支必是戳变化)。
856
+ this.indexDirty = true;
857
+
858
+ // 重建:identity 三级定位——头部 64KB(首轮会话,~34%)→ 尾部 64KB(续聊场景
859
+ // 最后一轮 session_start 追加,~65%)→ 全文 fallback(~0.2%)。均不解析 message entries。
860
+ // size ≤ 头部读取上限时 head 读到的即全文,tail/anywhere 只会重复读同一份内容——
861
+ // 直接判负(head miss = 全文无 identity),省去同内容两连读。
862
+ const header =
863
+ jsonl.size <= IDENTITY_HEAD_BYTES
864
+ ? readIdentityHeader(file)
865
+ : readIdentityHeader(file) ?? readIdentityTail(file) ?? readIdentityAnywhere(file);
866
+ if (!header) {
867
+ // 负缓存:确认无 identity。后续扫描 stat 命中直接跳过;戳变化(文件补写)自动重试。
868
+ this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
869
+ return null;
870
+ }
871
+ const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
872
+ const aliveData = alive !== null ? readAliveMarker(file) : undefined;
873
+ const entry: FileCacheEntry = {
874
+ light: RecordStore.buildRecord(header, {
875
+ tomb,
876
+ finalized: finalized !== null,
877
+ alive: aliveData,
878
+ jsonlMtimeMs: jsonl.mtimeMs,
879
+ now,
880
+ }),
881
+ full: undefined,
882
+ jsonl,
883
+ cancelled,
884
+ finalized,
885
+ alive,
886
+ tomb,
887
+ aliveData,
888
+ };
889
+ this.fileCache.set(file, entry);
890
+ this.idToFile.set(header.id, file);
891
+ return entry;
892
+ }
893
+
894
+ /**
895
+ * [perf L-1] 扫描尾索引落盘(节流):释放映像 → dirty/高版本/60s 节流窗三重门 →
896
+ * fire-and-forget saveIndex(fileCache 全量投影)。写决策与发起在同步栈(collectRecords
897
+ * 返回后不会再有本轮写);仅写完成的回调(推进节流窗)是异步的。所有 return 路径均
898
+ * 不清 dirty——未落盘的探测成果跨轮携带,直至真正写入。
899
+ *
900
+ * 并发安全:节流基准只在写成功后推进,W1 在途时新一轮过窗扫描可再 dispatch W2(不做
901
+ * 进程内排队——fire-and-forget 语义保持)。安全性由 saveIndex 的 tmp 唯一性
902
+ * (pid+单调序号)保证:交错 rename 的终态必为某一次的完整快照(last-writer-wins,
903
+ * 陈旧快照胜出时下轮戳不匹配自愈),不依赖本方法串行化。
904
+ */
905
+ private flushIndexAfterScan(): void {
906
+ this.indexEntries = null; // 释放映像:运行期索引不再被读(L1 接管)
907
+ if (!this.indexDirty) return; // 纯命中轮零探测,不写
908
+ if (this.indexHigherVersion) return; // 磁盘是更高版本:只忽略不重写(防 v1/v2 互相覆盖)
909
+ if (Date.now() - this.lastIndexWriteAt < INDEX_WRITE_MIN_INTERVAL_MS) return; // 60s 节流窗内
910
+ const entries = this.projectIndexEntries();
911
+ this.indexDirty = false; // 发起时消费(写失败在 .catch 恢复)
912
+ const encDir = path.dirname(this.sessionsDir);
913
+ saveIndex(encDir, { entries })
914
+ .then(() => {
915
+ this.lastIndexWriteAt = Date.now(); // 仅成功分支推进节流窗
916
+ })
917
+ .catch((err: unknown) => {
918
+ this.indexDirty = true; // 失败恢复 dirty,下轮过窗重试
919
+ logger.debug("[subagents] sessions-index write failed", {
920
+ detail: { dir: encDir, error: err instanceof Error ? err.message : String(err) },
921
+ });
922
+ });
923
+ }
924
+
925
+ /**
926
+ * [perf L-1] fileCache 全量投影 → 索引快照(basename → 正/负条目)。
927
+ * 投影式单一 SSOT:不维护第二份可变索引映像(防双轨漂移);fileCache 已被
928
+ * reconstructAll 修剪掉消失文件(修剪时置 indexDirty),下次过窗写时快照清除
929
+ * 磁盘上的陈旧条目。
930
+ */
931
+ private projectIndexEntries(): Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> {
932
+ const entries = new Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>();
933
+ for (const [file, cached] of this.fileCache) {
934
+ const base = path.basename(file);
935
+ if (cached.negative) {
936
+ entries.set(base, { negative: true, mtimeMs: cached.jsonl.mtimeMs, size: cached.jsonl.size });
937
+ } else {
938
+ entries.set(base, {
939
+ mtimeMs: cached.jsonl.mtimeMs,
940
+ size: cached.jsonl.size,
941
+ id: cached.light.id,
942
+ agent: cached.light.agent,
943
+ mode: cached.light.mode,
944
+ task: cached.light.task,
945
+ slug: cached.light.slug,
946
+ startedAt: cached.light.startedAt,
947
+ rootSessionId: cached.light.rootSessionId,
948
+ parentRecordId: cached.light.parentRecordId,
949
+ depth: cached.light.depth,
950
+ model: cached.light.model,
951
+ thinkingLevel: cached.light.thinkingLevel,
952
+ });
379
953
  }
380
- // ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超 24h)──
381
- else {
382
- markReconstructedStatus(rec, "crashed");
383
- // crashed 以最后已知活动时间为准(pid 死亡时间未知,用最后 entry 时间近似)。
384
- rec.endedAt = recon.endedAt;
954
+ }
955
+ return entries;
956
+ }
957
+
958
+ /**
959
+ * [perf] byId 索引直查 light record(单文件 stat 校验,不触发 getFullRecord 的
960
+ * 全量重建)。idToFile 未热(进程重启后尚未扫描过)时返回 undefined,调用方
961
+ * 自行兜底全目录扫描——用于把「跨重启后每条 message 一次 collectRecords 全扫」
962
+ * 降为 O(1) 索引命中。
963
+ */
964
+ findLightById(id: string): SubagentRecord | undefined {
965
+ const file = this.idToFile.get(id);
966
+ if (!file) return undefined;
967
+ return this.scanFile(file, Date.now())?.light;
968
+ }
969
+
970
+ /**
971
+ * [perf] 单 record 详情懒加载:内存 running record 投影全量;磁盘 record 全量重建
972
+ * (reconstructFromFile)并套用同一 sidecar 状态矩阵。结果缓存在 FileCacheEntry.full,
973
+ * stat 戳变化时随 light 一起失效。列表 collectRecords 返回 light(无 eventLog/
974
+ * result/turns 等重数据),详情面板/工具 list 按需调本方法补齐。
975
+ *
976
+ * 返回 undefined:id 不存在(内存与磁盘均无)。reconstructFromFile 失败(无
977
+ * assistant message 等)→ 返回 light(无详情可补,缓存哨兵防重复全文重读)。
978
+ */
979
+ getFullRecord(id: string): SubagentRecord | undefined {
980
+ // 内存 running record 天然全量(recordToSubagent 投影完整活态数据)。
981
+ const mem = this.records.get(id);
982
+ if (mem) return RecordStore.recordToSubagent(mem);
983
+
984
+ const file = this.idToFile.get(id);
985
+ if (!file) return undefined;
986
+ const entry = this.scanFile(file, Date.now());
987
+ if (!entry) return undefined;
988
+ if (entry.full === undefined) {
989
+ const recon = reconstructFromFile(file);
990
+ if (recon) {
991
+ entry.full = RecordStore.buildRecord(recon, {
992
+ tomb: entry.tomb,
993
+ finalized: entry.finalized !== null,
994
+ alive: entry.aliveData,
995
+ jsonlMtimeMs: entry.jsonl.mtimeMs,
996
+ fullEndedAt: recon.endedAt,
997
+ now: Date.now(),
998
+ });
999
+ } else {
1000
+ entry.full = entry.light; // 哨兵:无详情可补,后续直取 light(戳变化时重置重试)
385
1001
  }
1002
+ }
1003
+ return entry.full;
1004
+ }
1005
+
1006
+ /** alive 探活刷新(scanFile 缓存命中与 reconstructAll 快路径共用):
1007
+ * 分支 3 的 running + alive 条目每扫重查 pid(结果不落盘,进程死亡无 IO),
1008
+ * 保留旧实现「每次 collectRecords 重新 isProcessAlive」的语义。 */
1009
+ private static refreshAlive(entry: FileCacheEntry, now: number): void {
1010
+ if (entry.alive === null || entry.light.status !== "running") return;
1011
+ const marker = entry.aliveData;
1012
+ if (!marker) return;
1013
+ const live = isProcessAlive(marker.pid) && now - marker.startedAt < ALIVE_SOFT_TIMEOUT_MS;
1014
+ entry.light.externalInstance = live ? marker : undefined;
1015
+ }
386
1016
 
387
- cache.set(file, rec);
1017
+ /** identity 基底(头部 light 或全量 recon)+ 四分支 sidecar 状态矩阵 → SubagentRecord。 */
1018
+ private static buildRecord(
1019
+ base: IdentityHeaderRecon | ReconstructedRecord,
1020
+ m: SidecarMatrix,
1021
+ ): SubagentRecord {
1022
+ let rec: SubagentRecord;
1023
+ if ("turns" in base) {
1024
+ // 全量:turnCount/totalTokens/model/eventLog/displayItems/result/error 齐全。
1025
+ rec = {
1026
+ id: base.id,
1027
+ agent: base.agent,
1028
+ slug: base.slug,
1029
+ status: "running", // 占位,下方矩阵覆盖
1030
+ mode: base.mode,
1031
+ startedAt: base.startedAt,
1032
+ rootSessionId: base.rootSessionId,
1033
+ parentRecordId: base.parentRecordId,
1034
+ depth: base.depth,
1035
+ endedAt: undefined,
1036
+ turns: base.turnCount,
1037
+ totalTokens: base.totalTokens,
1038
+ model: base.model,
1039
+ thinkingLevel: base.thinkingLevel,
1040
+ task: base.task,
1041
+ currentActivity: undefined,
1042
+ eventLog: base.eventLog,
1043
+ displayItems: getDisplayItems(base),
1044
+ result: base.result,
1045
+ error: base.error,
1046
+ sessionFile: base.sessionFile,
1047
+ chatMode: base.chatMode,
1048
+ worktree: base.worktree,
1049
+ };
1050
+ } else {
1051
+ // light:详情字段缺省(turns=0/eventLog=[]/result=undefined),getFullRecord 懒补。
1052
+ rec = {
1053
+ id: base.id,
1054
+ agent: base.agent,
1055
+ slug: base.slug,
1056
+ status: "running", // 占位,下方矩阵覆盖
1057
+ mode: base.mode,
1058
+ startedAt: base.startedAt,
1059
+ rootSessionId: base.rootSessionId,
1060
+ parentRecordId: base.parentRecordId,
1061
+ depth: base.depth,
1062
+ endedAt: undefined,
1063
+ turns: 0,
1064
+ totalTokens: 0,
1065
+ model: base.model,
1066
+ thinkingLevel: base.thinkingLevel,
1067
+ task: base.task,
1068
+ currentActivity: undefined,
1069
+ eventLog: [],
1070
+ displayItems: [],
1071
+ result: undefined,
1072
+ error: undefined,
1073
+ sessionFile: base.sessionFile,
1074
+ chatMode: base.chatMode,
1075
+ worktree: base.worktree,
1076
+ };
388
1077
  }
389
1078
 
390
- this.reconCache = cache;
391
- // filter 时在缓存上筛(上面已构造全量缓存,便于后续调用复用)。
392
- if (rootSessionFilter === undefined) return [...cache.values()];
393
- return [...cache.values()].filter((r) => r.rootSessionId === rootSessionFilter);
1079
+ // ── 分支 1: .cancelled ──
1080
+ // v4 B-1: cancelled 折入 closed(closedReason='cancelled' 保留 L2 区分)。
1081
+ if (m.tomb) {
1082
+ markReconstructedStatus(rec, "closed");
1083
+ rec.closedReason = "cancelled";
1084
+ rec.error = "cancelled by user";
1085
+ rec.endedAt = m.tomb.endedAt;
1086
+ }
1087
+ // ── 分支 2: .finalized ──
1088
+ else if (m.finalized) {
1089
+ // closed 统一终态:done/failed/crashed 合并为 closed + closedReason=gc。
1090
+ markReconstructedStatus(rec, "closed");
1091
+ rec.closedReason = "gc";
1092
+ // 全量路径用最后 entry ts(精确);light 路径用 jsonl mtime 近似(finalize 后
1093
+ // 文件不再变化,误差 <1s),避免重建后耗时随墙钟无限增长。
1094
+ rec.endedAt = m.fullEndedAt ?? m.jsonlMtimeMs;
1095
+ }
1096
+ // ── 分支 3: .alive + pid 存活 + 未超软超时 ──
1097
+ else if (
1098
+ m.alive !== undefined &&
1099
+ isProcessAlive(m.alive.pid) &&
1100
+ m.now - m.alive.startedAt < ALIVE_SOFT_TIMEOUT_MS
1101
+ ) {
1102
+ markReconstructedStatus(rec, "running");
1103
+ rec.externalInstance = m.alive;
1104
+ }
1105
+ // ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超时)──
1106
+ // v4 B-1:跨重启可续聊态落点 = running。endedAt 保持 undefined(非终态)。
1107
+ else {
1108
+ markReconstructedStatus(rec, "running");
1109
+ }
1110
+ return rec;
1111
+ }
1112
+
1113
+ /** 从缓存与索引移除某文件(文件删除时;负缓存条目无 id,仅删缓存项)。 */
1114
+ private dropFileCache(file: string): void {
1115
+ const entry = this.fileCache.get(file);
1116
+ if (entry) {
1117
+ if (!entry.negative) this.idToFile.delete(entry.light.id);
1118
+ this.fileCache.delete(file);
1119
+ }
394
1120
  }
395
1121
 
396
1122
  /** 排序比较器:status priority(running<failed<cancelled<done)+ startedAt desc。 */
@@ -430,7 +1156,7 @@ export class RecordStore {
430
1156
  eventLog: [],
431
1157
  displayItems: [],
432
1158
  result: undefined,
433
- error: status === "failed" ? "manifest record" : undefined,
1159
+ error: undefined, // closed 统一终态,不再按 status 区分 error 字段
434
1160
  sessionFile: m.sessionFile,
435
1161
  };
436
1162
  }
@@ -441,6 +1167,7 @@ export class RecordStore {
441
1167
  id: r.id,
442
1168
  agent: r.agent,
443
1169
  status: r.status,
1170
+ closedReason: r.closedReason,
444
1171
  mode: r.mode,
445
1172
  slug: r.slug,
446
1173
  startedAt: r.startedAt,
@@ -459,6 +1186,15 @@ export class RecordStore {
459
1186
  result: r.result,
460
1187
  error: r.error,
461
1188
  sessionFile: r.sessionFile,
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,
1196
+ // [review round2] worktree 隔离标志:内存源有 handle 或跨重启重建带 hadWorktree 均为 true。
1197
+ worktree: r.worktreeHandle !== undefined || r.hadWorktree === true,
462
1198
  };
463
1199
  }
464
1200
  }