@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -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";
@@ -15,15 +29,26 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
15
29
 
16
30
  import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
17
31
  import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
18
- import { reconstructFromFile } from "./session-reconstructor.ts";
32
+ import { INDEX_WRITE_MIN_INTERVAL_MS, loadIndex, saveIndex } from "./sessions-index.ts";
33
+ import type { SessionsIndexEntry, SessionsIndexNegativeEntry } from "./sessions-index.ts";
34
+ import {
35
+ IDENTITY_HEAD_BYTES,
36
+ type IdentityHeaderRecon,
37
+ type ReconstructedRecord,
38
+ readIdentityAnywhere,
39
+ readIdentityHeader,
40
+ readIdentityTail,
41
+ reconstructFromFile,
42
+ } from "./session-reconstructor.ts";
19
43
  import type {
44
+ AliveMarker,
20
45
  ExecutionRecord,
21
46
  ExecutionStatus,
22
47
  RecordSnapshot,
23
48
  SubagentRecord,
24
49
  } from "./types.ts";
50
+ import type { CancelledTombstone } from "./tombstone-store.ts";
25
51
  import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
26
- import { readFinalized } from "./finalized-marker.ts";
27
52
  import { readCancelledTombstone } from "./tombstone-store.ts";
28
53
 
29
54
  const logger = getLogger("subagents");
@@ -32,13 +57,12 @@ const logger = getLogger("subagents");
32
57
  // 常量
33
58
  // ============================================================
34
59
 
35
- /** status → 排序优先级(值小排前):running < failed < crashed < cancelled < done */
60
+ /** status → 排序优先级(值小排前):running < closed
61
+ * v4 B-1:idle/cancelled 折入 running/closed,两态收敛。closed = 统一终态
62
+ * (done/failed/crashed/cancelled 合并),按 closedReason 派生对外语义。 */
36
63
  const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
37
64
  running: 0,
38
- failed: 1,
39
- crashed: 1,
40
- cancelled: 2,
41
- done: 3,
65
+ closed: 3,
42
66
  };
43
67
 
44
68
  /** .alive sidecar 的 24 小时软超时(超过此时间即使 pid 存活也判 crashed)。 */
@@ -47,19 +71,21 @@ const ALIVE_SOFT_TIMEOUT_MS = 3_600_000; // 1h in ms (reduced from 24h to minimi
47
71
  /**
48
72
  * manifest status → ExecutionStatus 运行时守卫映射。
49
73
  *
50
- * manifest 写 running/completed/failed/cancelled 四态(ManifestRecord.status union),但磁盘
51
- * 文件可能陈旧(含历史 "error" 值、被外部篡改、或意外出现 crashed 值)。越界值返回 null——
74
+ * manifest 写 running/closed/cancelled 三态(ManifestRecord.status union),但磁盘
75
+ * 文件可能陈旧(含历史 "completed"/"failed"/"error" 值、被外部篡改)。越界值返回 null——
52
76
  * manifestToSubagent 据此返回 null,collectRecords 跳过损坏 record 并 console.warn,不因单个
53
- * 坏文件崩溃,也不把损坏 record 错误降级为 failedfailed 会触发重试/告警,是误报)。
77
+ * 坏文件崩溃,也不把损坏 record 错误降级为 closedclosed 触发告警,是误报)。
54
78
  *
55
79
  * 提取为纯函数:同时解决 PR#85 反射问题(三元 + `as ExecutionStatus` cast)。
80
+ * [HISTORICAL] SP-1 重构:旧 "completed" → closed,旧 "failed" → closed(L1 统一终态)。
56
81
  */
57
82
  function mapManifestStatus(s: string): ExecutionStatus | null {
58
- if (s === "completed") return "done";
59
- if (s === "failed") return "failed";
83
+ if (s === "closed") return "closed";
84
+ if (s === "completed") return "closed"; // 向后兼容旧 manifest 数据
85
+ if (s === "failed") return "closed"; // 向后兼容旧 manifest 数据
60
86
  if (s === "running") return "running";
61
- if (s === "cancelled") return "cancelled";
62
- return null; // 越界=数据损坏(含历史 "error"、意外 crashed 值),返回 null 让调用方跳过
87
+ if (s === "cancelled") return "closed"; // v4 B-1: manifest cancelled 折入 closed(closedReason 信息丢失,manifest 仅诊断辅助)
88
+ return null; // 越界=数据损坏(含历史 "error" 值),返回 null 让调用方跳过
63
89
  }
64
90
 
65
91
  /** store 变更监听器(返回取消订阅函数)。 */
@@ -68,6 +94,81 @@ export type ChangeListener = () => void;
68
94
  /** status 过滤模式(collectRecords 的核心能力参数)。 */
69
95
  export type StatusFilter = "running" | "all";
70
96
 
97
+ /** 缓存校验戳(mtime+size 双因子——append-only jsonl 必变 size;sidecar 覆盖写必变 mtime)。 */
98
+ interface Stamp {
99
+ mtimeMs: number;
100
+ size: number;
101
+ }
102
+
103
+ /** sidecar 状态矩阵输入(buildLightRecord / getFullRecord 共享)。 */
104
+ interface SidecarMatrix {
105
+ tomb: CancelledTombstone | undefined;
106
+ finalized: boolean;
107
+ alive: AliveMarker | undefined;
108
+ /** jsonl mtime(light 分支 2 的 endedAt 近似——finalize 后文件不再变化)。 */
109
+ jsonlMtimeMs: number;
110
+ /** 全量重建可得的精确结束时间(最后 entry ts);light 传 undefined 回落 mtime。 */
111
+ fullEndedAt?: number;
112
+ now: number;
113
+ }
114
+
115
+ /**
116
+ * per-file 缓存条目([perf] 两级设计)。
117
+ *
118
+ * light:头部 identity + sidecar 状态矩阵(列表扫描产出,详情字段缺省)
119
+ * full :懒加载的完整重建(getFullRecord 按需补 turns/eventLog/result)。
120
+ * full === light 是哨兵(「已尝试但无详情可补」,如无 assistant message
121
+ * 的文件),避免重复全文重读;stat 戳变化时随 light 一起重置重试。
122
+ *
123
+ * 校验:jsonl + 3 sidecar 的 stat 戳对比。任何写操作至少改变一个戳 →
124
+ * 只重建该文件,其余 N-1 个复用缓存(statSync 毫秒级,取代旧的整体失效重扫)。
125
+ */
126
+ interface FileCacheEntry {
127
+ /** tagged union 判别(负缓存条目为 true)。显式声明 false 供 TS narrowing。 */
128
+ negative?: false;
129
+ light: SubagentRecord;
130
+ full: SubagentRecord | undefined;
131
+ jsonl: Stamp;
132
+ cancelled: Stamp | null;
133
+ finalized: Stamp | null;
134
+ alive: Stamp | null;
135
+ /** 最近一次重建时读到的 sidecar 原始内容(校验命中路径复用,不重读文件)。 */
136
+ tomb: CancelledTombstone | undefined;
137
+ aliveData: AliveMarker | undefined;
138
+ }
139
+
140
+ /** 负缓存条目:确认无 identity 的文件(损坏/异构)。缓存「没有」这一事实,
141
+ * 避免每轮扫描都全文 fallback 重读(全文读是 fallback 的成本主体)。 */
142
+ interface NegativeFileEntry {
143
+ negative: true;
144
+ jsonl: Stamp;
145
+ cancelled: Stamp | null;
146
+ finalized: Stamp | null;
147
+ alive: Stamp | null;
148
+ }
149
+
150
+ /** fileCache 值类型:正常条目或负缓存条目。 */
151
+ type FileCacheValue = FileCacheEntry | NegativeFileEntry;
152
+
153
+ /** stat 戳(不存在 → null)。 */
154
+ function statStamp(p: string): Stamp | null {
155
+ try {
156
+ const s = fs.statSync(p);
157
+ return { mtimeMs: s.mtimeMs, size: s.size };
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function sameStamp(a: Stamp, b: Stamp): boolean {
164
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
165
+ }
166
+
167
+ function sameNullableStamp(a: Stamp | null, b: Stamp | null): boolean {
168
+ if (a === null || b === null) return a === b;
169
+ return sameStamp(a, b);
170
+ }
171
+
71
172
  /** Pi ExtensionAPI 的最小子集(仅 collectRecords 跳过损坏 manifest 时上报用)。
72
173
  * 解构为局部类型,避免与 subagent-service 的 PiLike 循环依赖。 */
73
174
  export type RecordStorePi = {
@@ -82,10 +183,9 @@ export type RecordStorePi = {
82
183
  * Record 容器。进程单例(随 SubagentService 重建)。
83
184
  *
84
185
  * 内存只留 running record——终态 record 在 archive 时立即移除,collectRecords
85
- * 读时从 sessions/*.jsonl 重建(reconstructFromFile)。重建结果有缓存,在
86
- * notifyChange 时失效(终态 record 不再变化,但新 finalize 触发重扫)。
186
+ * 读时从 sessions/*.jsonl 重建([perf] light 头部扫描 + per-file 缓存)。
87
187
  *
88
- * 任何 mutate → notifyChange()
188
+ * 任何 mutate → notifyChange()(仅通知监听器;磁盘缓存靠 stat 戳自校验,不清空)。
89
189
  */
90
190
  export class RecordStore {
91
191
  private readonly records = new Map<string, ExecutionRecord>();
@@ -95,8 +195,27 @@ export class RecordStore {
95
195
  * 显式存为字段而非构造参数 readonly:setPi 需要写权限。 */
96
196
  private pi: RecordStorePi = null;
97
197
 
98
- /** 重建缓存:sessionFile SubagentRecord。notifyChange 时失效。 */
99
- private reconCache: Map<string, SubagentRecord> | undefined;
198
+ /** [perf] per-file 缓存(key = sessionFile 绝对路径)。不再整体失效——stat 戳精准校验。
199
+ * 值含负缓存(确认无 identity 的文件),防每轮全文 fallback 重读。 */
200
+ private readonly fileCache = new Map<string, FileCacheValue>();
201
+ /** record id → sessionFile 索引(getFullRecord 按 id 定位文件)。随 fileCache 同步维护。 */
202
+ private readonly idToFile = new Map<string, string>();
203
+ /** [perf] sessionsDir 最近一次全量扫描的 mtime(快路径判变,见 reconstructAll)。
204
+ * null = 未扫过 / 已 dispose。 */
205
+ private dirStamp: { mtimeMs: number } | null = null;
206
+
207
+ /** [perf L-1] 首扫惰性装载的磁盘索引只读映像(key = jsonl basename)。
208
+ * 扫描尾(flushIndexAfterScan)与 readdir 失败路径释放——运行期索引不再被读(L1 接管)。 */
209
+ private indexEntries: Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> | null = null;
210
+ /** [perf L-1] 本轮起未落盘的探测标志:scanFile 走过探测分支即置位。发起写时消费
211
+ * (置 false)、写失败恢复;未写路径不清位——未落盘的探测成果跨轮携带直至真正写入。 */
212
+ private indexDirty = false;
213
+ /** [perf L-1] 上次成功落盘墙钟(节流基准)。0 = 从未写过 → 首扫 dirty 必写;
214
+ * 仅成功分支推进(写失败不推进节流窗,下轮过窗重试)。 */
215
+ private lastIndexWriteAt = 0;
216
+ /** [perf L-1] loadIndex 高版本标志的进程级持久态:true 时本进程所有后续扫描均不
217
+ * 落盘(防 v1/v2 last-writer-wins 覆盖振荡),直至下次 loadIndex 重新评估。 */
218
+ private indexHigherVersion = false;
100
219
 
101
220
  constructor(
102
221
  private readonly sessionsDir: string,
@@ -168,12 +287,21 @@ export class RecordStore {
168
287
  .map((r) => toSnapshot(r));
169
288
  }
170
289
 
290
+ /** SP-4: 列出所有活跃 record(running + idle)的可变引用。
291
+ * 供 SubagentService.disposeAllRecords 做级联关闭。 */
292
+ listAllActive(): ExecutionRecord[] {
293
+ return [...this.records.values()]
294
+ .filter((r) => r.status === "running");
295
+ }
296
+
171
297
  /**
172
298
  * 合并内存(running) + 磁盘(sessions/*.jsonl 重建) → SubagentRecord[]。
173
299
  *
174
300
  * ╔══════════════════════════════════════════════════════════════════╗
175
- * ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 reconstructFromFile
176
- * ║ (命中缓存则跳过读文件)。cancelled tombstone override status
301
+ * ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 scanFile([perf] 头部
302
+ * ║ identity 轻量重建 + stat 戳缓存命中零读取)。cancelled
303
+ * ║ tombstone override status。详情字段(eventLog/result/turns) ║
304
+ * ║ 缺省,由 getFullRecord(id) 懒加载 ║
177
305
  * ║ 2. 内存源覆盖(同 id 内存优先——running record 更新鲜) ║
178
306
  * ║ 3. session 过滤:只留 rootSessionId === rootSessionFilter 的 ║
179
307
  * ║ record。rootSessionId 缺失(旧文件)的 record 一律排除 ║
@@ -257,10 +385,11 @@ export class RecordStore {
257
385
  };
258
386
  }
259
387
 
260
- /** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。同时失效重建缓存。 */
388
+ /** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。
389
+ * [perf] 不清空磁盘缓存:per-file stat 戳自校验(任何磁盘写入改变戳 → 单文件重建),
390
+ * 内存事件(register/archive)不改变磁盘文件——旧实现整体失效是全量重扫的根因。 */
261
391
  notifyChange(): void {
262
392
  if (this._disposed) return;
263
- this.reconCache = undefined; // 失效缓存(新 finalize 可能产出新 session.jsonl)。
264
393
  for (const listener of this.listeners) {
265
394
  listener();
266
395
  }
@@ -270,6 +399,15 @@ export class RecordStore {
270
399
  dispose(): void {
271
400
  this._disposed = true;
272
401
  this.listeners.clear();
402
+ this.fileCache.clear();
403
+ this.idToFile.clear();
404
+ this.dirStamp = null;
405
+ // [perf L-1] 索引状态重置为初始值(「清内存」语义完备)。不取消挂起的 fire-and-forget
406
+ // 写——in-flight 写的丢失显式接受(revive 后 dirStamp===null 重扫会重新装载/重写)。
407
+ this.indexEntries = null;
408
+ this.indexDirty = false;
409
+ this.lastIndexWriteAt = 0;
410
+ this.indexHigherVersion = false;
273
411
  }
274
412
 
275
413
  /** /resume /fork /new 后复活(dispose 的逆操作)。 */
@@ -280,117 +418,422 @@ export class RecordStore {
280
418
  // ── 内部 ──────────────────────────────────────────────────
281
419
 
282
420
  /**
283
- * 四分支 sidecar 矩阵重建。
421
+ * 四分支 sidecar 矩阵重建([perf] light 版)。
284
422
  *
285
423
  * 优先级:
286
- * 1. .cancelled → cancelled
287
- * 2. .finalized → done/failed(按 recon.stopReason 推)
288
- * 3. .alive + pid 存活 + 未超 24h → running, externalInstance=true
289
- * 4. 兜底 crashed
424
+ * 1. .cancelled → closed(closedReason=cancelled
425
+ * 2. .finalized → closed(closedReason=gc)
426
+ * 3. .alive + pid 存活 + 未超软超时 → running, externalInstance=true
427
+ * 4. 兜底(无 marker、pid 死、超时)→ running(v4 B-1 可续聊语义)
290
428
  *
291
- * 所有分支经 markReconstructedStatus(不裸 .status=)。
429
+ * [perf]:逐文件 scanFile(stat 戳校验 + 头部 identity 轻量重建)。命中缓存的
430
+ * 文件零文件读取;变化的文件只重建自身,其余 N-1 个复用缓存。
292
431
  *
293
432
  * session 隔离:rootSessionFilter 非空时,只保留 rootSessionId 匹配的 record。
294
433
  * rootSessionId 缺失(旧文件,未带身份字段)一律排除(无法判定归属)。
295
- * 缓存以 undefined 过滤结果为基底,带 filter 时在基底上再筛(避免缓存碎片化)。
296
434
  */
297
435
  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);
436
+ // [perf] 目录 mtime 快路径:sessionsDir mtime 未变 ⇒ 文件集合与 sidecar 集合都未变
437
+ //(任何文件新建/删除/重命名都改目录 mtime),且 jsonl append 不影响 light 态
438
+ //(identity/status 由首行与 sidecar 决定,进度重数据走 getFullRecord 的独立 stat
439
+ //校验)→ 跳过 readdir + N×4 statSync,直接复用缓存 light。/subagents overlay 打开
440
+ //期间 250ms 动画 timer + 120ms debounce 双驱动高频扫描,快路径把 ~N×4 stat 降到
441
+ // 1 次(目录本身)+ 少量 pid 探活(refreshAlive,内存无 IO)。
442
+ // 已知局限(与 mtime 缓存同族):目录 mtime 粒度粗糙的文件系统(NFS/2s FAT)
443
+ // 可能漏判——APFS 微秒级可靠。invalidate 语义由文件写入侧保证,但**仅限 sidecar
444
+ // 新建/删除/重命名**(这些操作必改目录 mtime);覆盖写已存在的 sidecar 不改目录
445
+ // mtime——`.alive` 覆盖写(resume spawn 后 pid 变化重写 marker)后快路径会复用旧
446
+ // aliveData(旧 pid/旧 startedAt),refreshAlive 探活与 1h 软超时判定可能滞后一拍
447
+ //(status 判定不受影响:分支 3 探活失败只清 externalInstance,不改 status)。
448
+ let dirMtimeMs: number;
449
+ try {
450
+ dirMtimeMs = fs.statSync(this.sessionsDir).mtimeMs;
451
+ } catch {
452
+ return [];
453
+ }
454
+ // [perf L-1] 首扫(dirStamp===null)惰性装载磁盘索引。必须位于 statSync 之后:
455
+ // sessionsDir 不存在的 early-return 不装载映像(防解析产物滞留内存);首扫时
456
+ // 下方快路径条件必不成立,插在快路径 if 前后等价。
457
+ if (this.dirStamp === null) {
458
+ const loaded = loadIndex(path.dirname(this.sessionsDir));
459
+ this.indexEntries = loaded.entries;
460
+ this.indexHigherVersion = loaded.higherVersion;
461
+ }
462
+ if (this.dirStamp !== null && this.dirStamp.mtimeMs === dirMtimeMs) {
463
+ const now = Date.now();
464
+ const out: SubagentRecord[] = [];
465
+ for (const entry of this.fileCache.values()) {
466
+ if (entry.negative) continue;
467
+ RecordStore.refreshAlive(entry, now);
468
+ out.push(entry.light);
469
+ }
470
+ return rootSessionFilter === undefined
471
+ ? out
472
+ : out.filter((r) => r.rootSessionId === rootSessionFilter);
302
473
  }
303
474
 
304
- const cache = new Map<string, SubagentRecord>();
305
475
  let files: string[];
306
476
  try {
307
477
  files = fs.readdirSync(this.sessionsDir)
308
478
  .filter((f) => f.endsWith(".jsonl"))
309
479
  .map((f) => path.join(this.sessionsDir, f));
310
480
  } catch {
311
- this.reconCache = cache;
481
+ this.indexEntries = null; // [perf L-1] 该 early-return 路径同样释放映像(内存卫生)
312
482
  return [];
313
483
  }
314
484
 
315
- const now = Date.now();
485
+ // 修剪:磁盘上已消失的文件(GC/手动删)同步移出缓存与索引。删了条目必须置
486
+ // indexDirty——否则纯修剪轮(无其他探测)flush 第一道门 !dirty return,磁盘索引的
487
+ // 陈旧条目永不清除(fileCache 已删仅影响内存投影,落盘快照不会自发跟随)。
488
+ const disk = new Set(files);
489
+ for (const [file, entry] of this.fileCache) {
490
+ if (!disk.has(file)) {
491
+ this.fileCache.delete(file);
492
+ if (!entry.negative) this.idToFile.delete(entry.light.id);
493
+ this.indexDirty = true;
494
+ }
495
+ }
316
496
 
497
+ const now = Date.now();
498
+ const out: SubagentRecord[] = [];
317
499
  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
- };
500
+ const entry = this.scanFile(file, now);
501
+ if (entry) out.push(entry.light);
502
+ }
503
+ this.dirStamp = { mtimeMs: dirMtimeMs };
504
+ this.flushIndexAfterScan();
505
+ if (rootSessionFilter === undefined) return out;
506
+ return out.filter((r) => r.rootSessionId === rootSessionFilter);
507
+ }
356
508
 
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;
509
+ /**
510
+ * 扫描单文件:stat 戳(jsonl + 3 sidecar)校验,全同 → 复用缓存(零文件读取,
511
+ * 含负缓存直接返回 null);否则重建 light。
512
+ * identity 定位两级:头部 64KB(首轮会话)→ 全文 fallback(续聊场景 identity
513
+ * append 在尾部);两级都找不到 → 写负缓存(防每轮全文重读)。
514
+ * 返回 null:文件消失/读失败/无 identity → 跳过。
515
+ */
516
+ private scanFile(file: string, now: number): FileCacheEntry | null {
517
+ const jsonl = statStamp(file);
518
+ if (!jsonl) {
519
+ this.fileCache.delete(file);
520
+ return null;
521
+ }
522
+ const cancelled = statStamp(`${file}.cancelled`);
523
+ const finalized = statStamp(`${file}.finalized`);
524
+ const alive = statStamp(`${file}.alive`);
525
+
526
+ const cached = this.fileCache.get(file);
527
+ if (
528
+ cached !== undefined &&
529
+ sameStamp(cached.jsonl, jsonl) &&
530
+ sameNullableStamp(cached.cancelled, cancelled) &&
531
+ sameNullableStamp(cached.finalized, finalized) &&
532
+ sameNullableStamp(cached.alive, alive)
533
+ ) {
534
+ if (cached.negative) return null; // 负缓存命中:确认无 identity,零读取跳过
535
+ // pid 探活结果不落盘(进程死亡无 IO)——分支 3 的 running 项每扫重查,
536
+ // 保留原语义(旧实现每次 collectRecords 都重新 isProcessAlive)。
537
+ RecordStore.refreshAlive(cached, now);
538
+ return cached;
539
+ }
540
+
541
+ // [perf L-1] 磁盘索引查询(首扫惰性装载,miss/空索引时 get 恒 undefined = 无索引)。
542
+ // 条目戳匹配 jsonl 当前 stat → 零内容读取构造缓存条目。sidecar payload(tombstone/
543
+ // alive)是活态数据,沿用探测分支的每轮重读语义。
544
+ if (this.indexEntries !== null) {
545
+ const hit = this.indexEntries.get(path.basename(file));
546
+ if (hit !== undefined && hit.mtimeMs === jsonl.mtimeMs && hit.size === jsonl.size) {
547
+ if (hit.negative === true) {
548
+ // 负条目命中:「确认无 identity」跨实例持久,零探测跳过(与下方负缓存同款形态)。
549
+ this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
550
+ return null;
551
+ }
552
+ const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
553
+ const aliveData = alive !== null ? readAliveMarker(file) : undefined;
554
+ const entry: FileCacheEntry = {
555
+ light: RecordStore.buildRecord(
556
+ { ...hit, forkDepth: undefined, sessionFile: file },
557
+ { tomb, finalized: finalized !== null, alive: aliveData, jsonlMtimeMs: jsonl.mtimeMs, now },
558
+ ),
559
+ full: undefined,
560
+ jsonl,
561
+ cancelled,
562
+ finalized,
563
+ alive,
564
+ tomb,
565
+ aliveData,
566
+ };
567
+ this.fileCache.set(file, entry);
568
+ this.idToFile.set(hit.id, file);
569
+ return entry;
370
570
  }
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;
571
+ }
572
+
573
+ // [perf L-1] 索引 miss/戳不匹配落到原三级探测:本轮探测结果必须进索引(含负探测)。
574
+ // 覆盖两种形态:首扫(映像已装载但 miss/不匹配)与后续轮次(映像已释放,凡进重建分支必是戳变化)。
575
+ this.indexDirty = true;
576
+
577
+ // 重建:identity 三级定位——头部 64KB(首轮会话,~34%)→ 尾部 64KB(续聊场景
578
+ // 最后一轮 session_start 追加,~65%)→ 全文 fallback(~0.2%)。均不解析 message entries。
579
+ // size ≤ 头部读取上限时 head 读到的即全文,tail/anywhere 只会重复读同一份内容——
580
+ // 直接判负(head miss = 全文无 identity),省去同内容两连读。
581
+ const header =
582
+ jsonl.size <= IDENTITY_HEAD_BYTES
583
+ ? readIdentityHeader(file)
584
+ : readIdentityHeader(file) ?? readIdentityTail(file) ?? readIdentityAnywhere(file);
585
+ if (!header) {
586
+ // 负缓存:确认无 identity。后续扫描 stat 命中直接跳过;戳变化(文件补写)自动重试。
587
+ this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
588
+ return null;
589
+ }
590
+ const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
591
+ const aliveData = alive !== null ? readAliveMarker(file) : undefined;
592
+ const entry: FileCacheEntry = {
593
+ light: RecordStore.buildRecord(header, {
594
+ tomb,
595
+ finalized: finalized !== null,
596
+ alive: aliveData,
597
+ jsonlMtimeMs: jsonl.mtimeMs,
598
+ now,
599
+ }),
600
+ full: undefined,
601
+ jsonl,
602
+ cancelled,
603
+ finalized,
604
+ alive,
605
+ tomb,
606
+ aliveData,
607
+ };
608
+ this.fileCache.set(file, entry);
609
+ this.idToFile.set(header.id, file);
610
+ return entry;
611
+ }
612
+
613
+ /**
614
+ * [perf L-1] 扫描尾索引落盘(节流):释放映像 → dirty/高版本/60s 节流窗三重门 →
615
+ * fire-and-forget saveIndex(fileCache 全量投影)。写决策与发起在同步栈(collectRecords
616
+ * 返回后不会再有本轮写);仅写完成的回调(推进节流窗)是异步的。所有 return 路径均
617
+ * 不清 dirty——未落盘的探测成果跨轮携带,直至真正写入。
618
+ *
619
+ * 并发安全:节流基准只在写成功后推进,W1 在途时新一轮过窗扫描可再 dispatch W2(不做
620
+ * 进程内排队——fire-and-forget 语义保持)。安全性由 saveIndex 的 tmp 唯一性
621
+ * (pid+单调序号)保证:交错 rename 的终态必为某一次的完整快照(last-writer-wins,
622
+ * 陈旧快照胜出时下轮戳不匹配自愈),不依赖本方法串行化。
623
+ */
624
+ private flushIndexAfterScan(): void {
625
+ this.indexEntries = null; // 释放映像:运行期索引不再被读(L1 接管)
626
+ if (!this.indexDirty) return; // 纯命中轮零探测,不写
627
+ if (this.indexHigherVersion) return; // 磁盘是更高版本:只忽略不重写(防 v1/v2 互相覆盖)
628
+ if (Date.now() - this.lastIndexWriteAt < INDEX_WRITE_MIN_INTERVAL_MS) return; // 60s 节流窗内
629
+ const entries = this.projectIndexEntries();
630
+ this.indexDirty = false; // 发起时消费(写失败在 .catch 恢复)
631
+ const encDir = path.dirname(this.sessionsDir);
632
+ saveIndex(encDir, { entries })
633
+ .then(() => {
634
+ this.lastIndexWriteAt = Date.now(); // 仅成功分支推进节流窗
635
+ })
636
+ .catch((err: unknown) => {
637
+ this.indexDirty = true; // 失败恢复 dirty,下轮过窗重试
638
+ logger.debug("[subagents] sessions-index write failed", {
639
+ detail: { dir: encDir, error: err instanceof Error ? err.message : String(err) },
640
+ });
641
+ });
642
+ }
643
+
644
+ /**
645
+ * [perf L-1] fileCache 全量投影 → 索引快照(basename → 正/负条目)。
646
+ * 投影式单一 SSOT:不维护第二份可变索引映像(防双轨漂移);fileCache 已被
647
+ * reconstructAll 修剪掉消失文件(修剪时置 indexDirty),下次过窗写时快照清除
648
+ * 磁盘上的陈旧条目。
649
+ */
650
+ private projectIndexEntries(): Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> {
651
+ const entries = new Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>();
652
+ for (const [file, cached] of this.fileCache) {
653
+ const base = path.basename(file);
654
+ if (cached.negative) {
655
+ entries.set(base, { negative: true, mtimeMs: cached.jsonl.mtimeMs, size: cached.jsonl.size });
656
+ } else {
657
+ entries.set(base, {
658
+ mtimeMs: cached.jsonl.mtimeMs,
659
+ size: cached.jsonl.size,
660
+ id: cached.light.id,
661
+ agent: cached.light.agent,
662
+ mode: cached.light.mode,
663
+ task: cached.light.task,
664
+ slug: cached.light.slug,
665
+ startedAt: cached.light.startedAt,
666
+ rootSessionId: cached.light.rootSessionId,
667
+ parentRecordId: cached.light.parentRecordId,
668
+ depth: cached.light.depth,
669
+ model: cached.light.model,
670
+ thinkingLevel: cached.light.thinkingLevel,
671
+ });
379
672
  }
380
- // ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超 24h)──
381
- else {
382
- markReconstructedStatus(rec, "crashed");
383
- // crashed 以最后已知活动时间为准(pid 死亡时间未知,用最后 entry 时间近似)。
384
- rec.endedAt = recon.endedAt;
673
+ }
674
+ return entries;
675
+ }
676
+
677
+ /**
678
+ * [perf] byId 索引直查 light record(单文件 stat 校验,不触发 getFullRecord 的
679
+ * 全量重建)。idToFile 未热(进程重启后尚未扫描过)时返回 undefined,调用方
680
+ * 自行兜底全目录扫描——用于把「跨重启后每条 message 一次 collectRecords 全扫」
681
+ * 降为 O(1) 索引命中。
682
+ */
683
+ findLightById(id: string): SubagentRecord | undefined {
684
+ const file = this.idToFile.get(id);
685
+ if (!file) return undefined;
686
+ return this.scanFile(file, Date.now())?.light;
687
+ }
688
+
689
+ /**
690
+ * [perf] 单 record 详情懒加载:内存 running record 投影全量;磁盘 record 全量重建
691
+ * (reconstructFromFile)并套用同一 sidecar 状态矩阵。结果缓存在 FileCacheEntry.full,
692
+ * stat 戳变化时随 light 一起失效。列表 collectRecords 返回 light(无 eventLog/
693
+ * result/turns 等重数据),详情面板/工具 list 按需调本方法补齐。
694
+ *
695
+ * 返回 undefined:id 不存在(内存与磁盘均无)。reconstructFromFile 失败(无
696
+ * assistant message 等)→ 返回 light(无详情可补,缓存哨兵防重复全文重读)。
697
+ */
698
+ getFullRecord(id: string): SubagentRecord | undefined {
699
+ // 内存 running record 天然全量(recordToSubagent 投影完整活态数据)。
700
+ const mem = this.records.get(id);
701
+ if (mem) return RecordStore.recordToSubagent(mem);
702
+
703
+ const file = this.idToFile.get(id);
704
+ if (!file) return undefined;
705
+ const entry = this.scanFile(file, Date.now());
706
+ if (!entry) return undefined;
707
+ if (entry.full === undefined) {
708
+ const recon = reconstructFromFile(file);
709
+ if (recon) {
710
+ entry.full = RecordStore.buildRecord(recon, {
711
+ tomb: entry.tomb,
712
+ finalized: entry.finalized !== null,
713
+ alive: entry.aliveData,
714
+ jsonlMtimeMs: entry.jsonl.mtimeMs,
715
+ fullEndedAt: recon.endedAt,
716
+ now: Date.now(),
717
+ });
718
+ } else {
719
+ entry.full = entry.light; // 哨兵:无详情可补,后续直取 light(戳变化时重置重试)
385
720
  }
721
+ }
722
+ return entry.full;
723
+ }
724
+
725
+ /** alive 探活刷新(scanFile 缓存命中与 reconstructAll 快路径共用):
726
+ * 分支 3 的 running + alive 条目每扫重查 pid(结果不落盘,进程死亡无 IO),
727
+ * 保留旧实现「每次 collectRecords 重新 isProcessAlive」的语义。 */
728
+ private static refreshAlive(entry: FileCacheEntry, now: number): void {
729
+ if (entry.alive === null || entry.light.status !== "running") return;
730
+ const marker = entry.aliveData;
731
+ if (!marker) return;
732
+ const live = isProcessAlive(marker.pid) && now - marker.startedAt < ALIVE_SOFT_TIMEOUT_MS;
733
+ entry.light.externalInstance = live ? marker : undefined;
734
+ }
735
+
736
+ /** identity 基底(头部 light 或全量 recon)+ 四分支 sidecar 状态矩阵 → SubagentRecord。 */
737
+ private static buildRecord(
738
+ base: IdentityHeaderRecon | ReconstructedRecord,
739
+ m: SidecarMatrix,
740
+ ): SubagentRecord {
741
+ let rec: SubagentRecord;
742
+ if ("turns" in base) {
743
+ // 全量:turnCount/totalTokens/model/eventLog/displayItems/result/error 齐全。
744
+ rec = {
745
+ id: base.id,
746
+ agent: base.agent,
747
+ slug: base.slug,
748
+ status: "running", // 占位,下方矩阵覆盖
749
+ mode: base.mode,
750
+ startedAt: base.startedAt,
751
+ rootSessionId: base.rootSessionId,
752
+ parentRecordId: base.parentRecordId,
753
+ depth: base.depth,
754
+ endedAt: undefined,
755
+ turns: base.turnCount,
756
+ totalTokens: base.totalTokens,
757
+ model: base.model,
758
+ thinkingLevel: base.thinkingLevel,
759
+ task: base.task,
760
+ currentActivity: undefined,
761
+ eventLog: base.eventLog,
762
+ displayItems: getDisplayItems(base),
763
+ result: base.result,
764
+ error: base.error,
765
+ sessionFile: base.sessionFile,
766
+ worktree: base.worktree,
767
+ };
768
+ } else {
769
+ // light:详情字段缺省(turns=0/eventLog=[]/result=undefined),getFullRecord 懒补。
770
+ rec = {
771
+ id: base.id,
772
+ agent: base.agent,
773
+ slug: base.slug,
774
+ status: "running", // 占位,下方矩阵覆盖
775
+ mode: base.mode,
776
+ startedAt: base.startedAt,
777
+ rootSessionId: base.rootSessionId,
778
+ parentRecordId: base.parentRecordId,
779
+ depth: base.depth,
780
+ endedAt: undefined,
781
+ turns: 0,
782
+ totalTokens: 0,
783
+ model: base.model,
784
+ thinkingLevel: base.thinkingLevel,
785
+ task: base.task,
786
+ currentActivity: undefined,
787
+ eventLog: [],
788
+ displayItems: [],
789
+ result: undefined,
790
+ error: undefined,
791
+ sessionFile: base.sessionFile,
792
+ worktree: base.worktree,
793
+ };
794
+ }
386
795
 
387
- cache.set(file, rec);
796
+ // ── 分支 1: .cancelled ──
797
+ // v4 B-1: cancelled 折入 closed(closedReason='cancelled' 保留 L2 区分)。
798
+ if (m.tomb) {
799
+ markReconstructedStatus(rec, "closed");
800
+ rec.closedReason = "cancelled";
801
+ rec.error = "cancelled by user";
802
+ rec.endedAt = m.tomb.endedAt;
388
803
  }
804
+ // ── 分支 2: .finalized ──
805
+ else if (m.finalized) {
806
+ // closed 统一终态:done/failed/crashed 合并为 closed + closedReason=gc。
807
+ markReconstructedStatus(rec, "closed");
808
+ rec.closedReason = "gc";
809
+ // 全量路径用最后 entry ts(精确);light 路径用 jsonl mtime 近似(finalize 后
810
+ // 文件不再变化,误差 <1s),避免重建后耗时随墙钟无限增长。
811
+ rec.endedAt = m.fullEndedAt ?? m.jsonlMtimeMs;
812
+ }
813
+ // ── 分支 3: .alive + pid 存活 + 未超软超时 ──
814
+ else if (
815
+ m.alive !== undefined &&
816
+ isProcessAlive(m.alive.pid) &&
817
+ m.now - m.alive.startedAt < ALIVE_SOFT_TIMEOUT_MS
818
+ ) {
819
+ markReconstructedStatus(rec, "running");
820
+ rec.externalInstance = m.alive;
821
+ }
822
+ // ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超时)──
823
+ // v4 B-1:跨重启可续聊态落点 = running。endedAt 保持 undefined(非终态)。
824
+ else {
825
+ markReconstructedStatus(rec, "running");
826
+ }
827
+ return rec;
828
+ }
389
829
 
390
- this.reconCache = cache;
391
- // filter 时在缓存上筛(上面已构造全量缓存,便于后续调用复用)。
392
- if (rootSessionFilter === undefined) return [...cache.values()];
393
- return [...cache.values()].filter((r) => r.rootSessionId === rootSessionFilter);
830
+ /** 从缓存与索引移除某文件(文件删除时;负缓存条目无 id,仅删缓存项)。 */
831
+ private dropFileCache(file: string): void {
832
+ const entry = this.fileCache.get(file);
833
+ if (entry) {
834
+ if (!entry.negative) this.idToFile.delete(entry.light.id);
835
+ this.fileCache.delete(file);
836
+ }
394
837
  }
395
838
 
396
839
  /** 排序比较器:status priority(running<failed<cancelled<done)+ startedAt desc。 */
@@ -430,7 +873,7 @@ export class RecordStore {
430
873
  eventLog: [],
431
874
  displayItems: [],
432
875
  result: undefined,
433
- error: status === "failed" ? "manifest record" : undefined,
876
+ error: undefined, // closed 统一终态,不再按 status 区分 error 字段
434
877
  sessionFile: m.sessionFile,
435
878
  };
436
879
  }
@@ -441,6 +884,7 @@ export class RecordStore {
441
884
  id: r.id,
442
885
  agent: r.agent,
443
886
  status: r.status,
887
+ closedReason: r.closedReason,
444
888
  mode: r.mode,
445
889
  slug: r.slug,
446
890
  startedAt: r.startedAt,
@@ -459,6 +903,9 @@ export class RecordStore {
459
903
  result: r.result,
460
904
  error: r.error,
461
905
  sessionFile: r.sessionFile,
906
+ round: r.round,
907
+ // [review round2] worktree 隔离标志:内存源有 handle 或跨重启重建带 hadWorktree 均为 true。
908
+ worktree: r.worktreeHandle !== undefined || r.hadWorktree === true,
462
909
  };
463
910
  }
464
911
  }