@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
@@ -25,18 +25,47 @@ const RECENT_UNREGISTER_WINDOW_MS = 60_000;
25
25
 
26
26
  /** 判定结果:count > 0 = 有活跃后代(应保持进程等唤醒)。 */
27
27
  export interface ActivePendingResult {
28
- count: number;
29
- /** 最近窗口内(60s)有 pending:unregister——后代刚完成,唤醒通知可能在路上。 */
30
- recentUnregister: boolean;
31
- /** 读取/解析失败的原因(undefined = 成功)。调用方对 error 采取保守策略(不 kill)。 */
32
- error?: string;
28
+ count: number;
29
+ /** 最近窗口内(60s)有 pending:unregister——后代刚完成,唤醒通知可能在路上。 */
30
+ recentUnregister: boolean;
31
+ /** 读取/解析失败的原因(undefined = 成功)。调用方对 error 采取保守策略(不 kill)。 */
32
+ error?: string;
33
33
  }
34
34
 
35
35
  /**
36
- * session 文件计算活跃 pending 数。
36
+ * [perf] per-file 增量游标:session 文件 append-only,同文件重复判定(层主被多个
37
+ * 后代唤醒 N 次 → N 次 agent_end)只需读上次 offset 之后的新增行,累计 entries
38
+ * 差集语义与全量读完全一致。fork 继承的大 session(数十 MB)从「每次整读」降为
39
+ * 「首读全量 + 后续增量」。
40
+ * truncate/重建防御:size < offset → 重置全读。EOF 半行(写入竞态)不入账,
41
+ * offset 只推进到完整行边界,下次补读。
42
+ * 内存量级:每 sessionFile 一条(offset + 后代 entries,均有限);测试隔离用
43
+ * clearPendingCursors()。
44
+ */
45
+ interface PendingReadCursor {
46
+ offset: number;
47
+ entries: unknown[];
48
+ latestUnregisterMs: number;
49
+ }
50
+
51
+ const cursors = new Map<string, PendingReadCursor>();
52
+
53
+ /** 清空增量游标缓存(测试隔离用)。 */
54
+ export function clearPendingCursors(): void {
55
+ cursors.clear();
56
+ }
57
+
58
+ /** [taste/no-unsafe-cast] pending 行的最小结构守卫:非 null object 即可(字段访问
59
+ * 侧均做了 undefined 检查,全可选类型断言无校验意义,改守卫带运行时检查)。 */
60
+ function isPendingLineLike(v: unknown): v is { customType?: string; timestamp?: string } {
61
+ return typeof v === "object" && v !== null;
62
+ }
63
+
64
+ /**
65
+ * 读 session 文件计算活跃 pending 数(增量读,见 PendingReadCursor)。
37
66
  *
38
67
  * 快速路径:行内含 pending 值(`"pending:register"` / `"pending:unregister"`)才解析,
39
- * 大文件(fork 继承主 session)只付 includes 扫描跳过大量 message 行的 JSON.parse。
68
+ * 大量 message 行只付 includes 扫描跳过 JSON.parse。
40
69
  * [S-4] 按值匹配而非 `"customType":"pending:` 序列化格式——后者耦合 pi 的 JSON 序列化
41
70
  * 空格习惯(冒号后无空格),pi 改 pretty-print 会导致全过滤 → count=0 → keep-alive
42
71
  * 静默失效 → recursive tree 被杀、steer 丢失。值字符串本身不受序列化空格影响。
@@ -45,45 +74,87 @@ export interface ActivePendingResult {
45
74
  * 坏行跳过(append 中途崩溃的截断行)。
46
75
  */
47
76
  export function readActivePendingFromSessionFile(
48
- sessionFile: string | undefined,
77
+ sessionFile: string | undefined,
49
78
  ): ActivePendingResult {
50
- if (!sessionFile) {
51
- return { count: 0, recentUnregister: false, error: "no sessionFile (handshake not settled)" };
52
- }
53
- let raw: string;
54
- try {
55
- raw = fs.readFileSync(sessionFile, "utf-8");
56
- } catch (err) {
57
- return {
58
- count: 0,
59
- recentUnregister: false,
60
- error: `session file unreadable: ${err instanceof Error ? err.message : String(err)}`,
61
- };
62
- }
79
+ if (!sessionFile) {
80
+ return { count: 0, recentUnregister: false, error: "no sessionFile (handshake not settled)" };
81
+ }
82
+
83
+ let size: number;
84
+ try {
85
+ size = fs.statSync(sessionFile).size;
86
+ } catch (err) {
87
+ return {
88
+ count: 0,
89
+ recentUnregister: false,
90
+ error: `session file unreadable: ${err instanceof Error ? err.message : String(err)}`,
91
+ };
92
+ }
93
+
94
+ let cursor = cursors.get(sessionFile);
95
+ if (cursor === undefined || size < cursor.offset) {
96
+ // 首次(全量)或文件被 truncate/重建(offset 越界)→ 重置从头读
97
+ cursor = { offset: 0, entries: [], latestUnregisterMs: 0 };
98
+ }
99
+
100
+ let chunk: string;
101
+ try {
102
+ if (cursor.offset === 0) {
103
+ chunk = fs.readFileSync(sessionFile, "utf-8");
104
+ } else {
105
+ const fd = fs.openSync(sessionFile, "r");
106
+ try {
107
+ const len = size - cursor.offset;
108
+ const buf = Buffer.alloc(len);
109
+ let total = 0;
110
+ while (total < len) {
111
+ const n = fs.readSync(fd, buf, total, len - total, cursor.offset + total);
112
+ if (n <= 0) break;
113
+ total += n;
114
+ }
115
+ chunk = buf.toString("utf-8", 0, total);
116
+ } finally {
117
+ fs.closeSync(fd);
118
+ }
119
+ }
120
+ } catch (err) {
121
+ return {
122
+ count: 0,
123
+ recentUnregister: false,
124
+ error: `session file unreadable: ${err instanceof Error ? err.message : String(err)}`,
125
+ };
126
+ }
127
+
128
+ // 只消费到最后一个完整行:EOF 半行(append 写入竞态)不入账,offset 不推进,
129
+ // 下次从该行起点重读(补全后正常入账)。
130
+ const lastNl = chunk.lastIndexOf("\n");
131
+ const complete = lastNl === -1 ? "" : chunk.slice(0, lastNl);
132
+ cursor.offset += Buffer.byteLength(complete, "utf-8");
133
+ cursors.set(sessionFile, cursor);
63
134
 
64
- const entries: unknown[] = [];
65
- let latestUnregisterMs = 0;
66
- for (const line of raw.split("\n")) {
67
- // [S-4] 按值匹配:countActiveFromEntries 只消费 register/unregister 两种 customType,
68
- // 故只需检测这两个值字符串;冒号前后空格变化不影响(值始终是连续子串)。
69
- if (!line.includes('"pending:register"') && !line.includes('"pending:unregister"')) continue;
70
- try {
71
- const entry = JSON.parse(line) as { customType?: string; timestamp?: string };
72
- entries.push(entry);
73
- if (entry.customType === "pending:unregister" && entry.timestamp) {
74
- const ts = Date.parse(entry.timestamp);
75
- if (Number.isFinite(ts) && ts > latestUnregisterMs) latestUnregisterMs = ts;
76
- }
77
- } catch {
78
- // 截断行/坏行跳过——不影响其余 entry 的差集判定(罕见:append 中途崩溃)
79
- console.debug("[session-pending] skipped malformed pending line in", sessionFile);
80
- }
81
- }
135
+ for (const line of complete.split("\n")) {
136
+ // [S-4] 按值匹配:countActiveFromEntries 只消费 register/unregister 两种 customType,
137
+ // 故只需检测这两个值字符串;冒号前后空格变化不影响(值始终是连续子串)。
138
+ if (!line.includes('"pending:register"') && !line.includes('"pending:unregister"')) continue;
139
+ try {
140
+ const entry: unknown = JSON.parse(line);
141
+ if (!isPendingLineLike(entry)) continue;
142
+ cursor.entries.push(entry);
143
+ if (entry.customType === "pending:unregister" && entry.timestamp) {
144
+ const ts = Date.parse(entry.timestamp);
145
+ if (Number.isFinite(ts) && ts > cursor.latestUnregisterMs) cursor.latestUnregisterMs = ts;
146
+ }
147
+ } catch {
148
+ // 截断行/坏行跳过——不影响其余 entry 的差集判定(罕见:append 中途崩溃)
149
+ console.debug("[session-pending] skipped malformed pending line in", sessionFile);
150
+ }
151
+ }
82
152
 
83
- const active = countActiveFromEntries(entries);
84
- return {
85
- count: active.count,
86
- recentUnregister:
87
- latestUnregisterMs > 0 && Date.now() - latestUnregisterMs < RECENT_UNREGISTER_WINDOW_MS,
88
- };
153
+ const active = countActiveFromEntries(cursor.entries);
154
+ return {
155
+ count: active.count,
156
+ recentUnregister:
157
+ cursor.latestUnregisterMs > 0 &&
158
+ Date.now() - cursor.latestUnregisterMs < RECENT_UNREGISTER_WINDOW_MS,
159
+ };
89
160
  }
@@ -98,6 +98,17 @@ export interface SubagentIdentityData {
98
98
  depth?: number;
99
99
  /** [MF#4] 本 session 的 fork 深度(session-runner 写入 parentForkDepth+1)。旧文件可能缺失。 */
100
100
  forkDepth?: number;
101
+ /**
102
+ * 对话模式标志(可持续对话 subagent)。旧文件缺失 → undefined(按一次性模式处理)。
103
+ * session-runner 写入;reconstructFromFile 经 ...identity 展开自动透传到 ReconstructedRecord。
104
+ */
105
+ chatMode?: boolean;
106
+ /**
107
+ * [review round2] worktree 隔离标志(该 subagent 创建时 worktree:true)。旧文件缺失 →
108
+ * undefined。仅供跨重启重建路径判定「worktree 绑定已丢失」——WorktreeHandle 本身不可
109
+ * 序列化,跨重启后无法 reattach,续聊须拒绝(防 resume cwd 静默回落主 repo 破坏隔离)。
110
+ */
111
+ worktree?: boolean;
101
112
  /** @deprecated 兼容旧文件:旧 identity entry 写的是 parentSessionId,读取时 fallback 到 rootSessionId。 */
102
113
  parentSessionId?: string;
103
114
  }
@@ -142,9 +153,21 @@ export interface ReconstructedRecord {
142
153
  depth: number;
143
154
  /** [MF#4] 本 session 的 fork 深度(来自 identity custom entry;旧文件为 undefined)。 */
144
155
  forkDepth: number | undefined;
156
+ /** 对话模式标志(来自 identity custom entry;旧文件为 undefined)。 */
157
+ chatMode?: boolean;
158
+ /** [review round2] worktree 隔离标志(见 SubagentIdentityData.worktree)。 */
159
+ worktree?: boolean;
160
+ /**
161
+ * 对话轮次计数(非 identity entry 字段,reconstructFromFile 不填)。
162
+ * V2 idle record 的 round 只在内存维护(doFinalizeRoundToIdle 递增),磁盘重建不恢复。
163
+ */
164
+ round?: number;
145
165
  sessionFile: string;
146
166
  // ── 可变状态(来自 message entries)──
147
167
  status: ExecutionStatus;
168
+ /** L2 关闭原因子枚举(仅 status="closed" 时有意义)。SP-1 新增。
169
+ * 从 stopReason 推导:error/aborted → gc;其余 → gc。 */
170
+ closedReason?: import("./types.ts").ClosedReason;
148
171
  turns: Turn[];
149
172
  turnCount: number;
150
173
  totalTokens: number;
@@ -276,6 +299,9 @@ interface PendingToolCall {
276
299
  *
277
300
  * status 由最后一条 assistant message 的 stopReason 推导(error/aborted → failed,
278
301
  * 其余 → done)。cancelled 由 tombstone override(record-store 层),本函数不感知。
302
+ *
303
+ * [perf] 本函数是重路径(全文读 + 全 entry 解析)。列表扫描用 readIdentityHeader
304
+ * (只读头部 identity),详情才走本函数(RecordStore.getFullRecord 懒加载)。
279
305
  */
280
306
  export function reconstructFromFile(sessionFile: string): ReconstructedRecord | undefined {
281
307
  // 读文件 + 逐行 JSON.parse(session.jsonl = newline-delimited JSON)。
@@ -338,8 +364,6 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
338
364
  const pending: PendingToolCall[] = [];
339
365
  let lastError: string | undefined;
340
366
  let totalTokens = 0;
341
- /** 最后一条 assistant message 的 stopReason(推导终态 status)。 */
342
- let lastStopReason: string | undefined;
343
367
  /** 最后一条 entry 的时间戳(ms),推导 endedAt(避免重建 record 耗时随墙钟无限增长)。 */
344
368
  let lastEntryTsMs: number | undefined;
345
369
 
@@ -386,7 +410,7 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
386
410
  // stopReason 驱动 lastError(与 updateFromEvent 一致):
387
411
  // error/aborted → 设 lastError;stop(正常结束)→ 清 lastError(镜像 turn_end 的清除语义,
388
412
  // 即前序 turn 的瞬态 error 在后续成功 turn 后恢复,不误判 success=false)。
389
- lastStopReason = msg.stopReason;
413
+ // [v4 B-1] status 恒 closed 后 lastStopReason 不再参与推导,死变量已删。
390
414
  if (msg.stopReason === "error" || msg.stopReason === "aborted") {
391
415
  lastError = msg.errorMessage ?? msg.stopReason;
392
416
  } else if (msg.stopReason === "stop") {
@@ -429,10 +453,10 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
429
453
  const eventLog = deriveEventLog(turns, lastError, identity.startedAt);
430
454
 
431
455
  // 终态 status:最后一条 assistant message 的 stopReason 推导(与 finalizeRecord 的判定一致)。
432
- // error/aborted failed;其余(stop/toolUse/length)→ done。
433
- // cancelled tombstone override(record-store 层),本函数不感知。
434
- const status: ExecutionStatus =
435
- lastStopReason === "error" || lastStopReason === "aborted" ? "failed" : "done";
456
+ // SP-1:done/failed 合并为 closed,cancelled 由 tombstone override(record-store 层),本函数不感知。
457
+ const status: ExecutionStatus = "closed";
458
+ // closedReason gc(通用完成/失败)。error/aborted 的区分由 error 字段保留。
459
+ const closedReason: import("./types.ts").ClosedReason = "gc";
436
460
 
437
461
  // rootSessionId 归一化:新文件读 rootSessionId,旧文件 fallback parentSessionId。
438
462
  const rootSessionId = identity.rootSessionId ?? identity.parentSessionId;
@@ -447,6 +471,7 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
447
471
  forkDepth: identity.forkDepth,
448
472
  sessionFile,
449
473
  status,
474
+ closedReason,
450
475
  turns,
451
476
  turnCount,
452
477
  totalTokens,
@@ -459,3 +484,195 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
459
484
  eventLog,
460
485
  };
461
486
  }
487
+
488
+ // ============================================================
489
+ // 轻量头部读取(列表扫描用)
490
+ // ============================================================
491
+
492
+ /** 头部读取字节数。首轮会话 identity 在第 2-3 行;但续聊(resume)场景 session_start
493
+ * 再次触发,identity append 到文件尾部附近(实测真实目录 1186/1744 文件的 identity
494
+ * 不在头 64KB)。头部 miss 时由 RecordStore.scanFile 调 readIdentityAnywhere 全文 fallback。 */
495
+ export const IDENTITY_HEAD_BYTES = 65536;
496
+
497
+ /** 轻量重建产出:仅身份字段 + 头部可见的 model/thinkingLevel(详情字段一律缺省)。 */
498
+ export interface IdentityHeaderRecon {
499
+ id: string;
500
+ agent: string;
501
+ mode: ExecutionMode;
502
+ task: string;
503
+ slug: string;
504
+ startedAt: number;
505
+ rootSessionId: string | undefined;
506
+ parentRecordId: string | undefined;
507
+ depth: number;
508
+ forkDepth: number | undefined;
509
+ chatMode?: boolean;
510
+ /** [review round2] worktree 隔离标志(见 SubagentIdentityData.worktree)。 */
511
+ worktree?: boolean;
512
+ model: string;
513
+ thinkingLevel: string | undefined;
514
+ sessionFile: string;
515
+ }
516
+
517
+ /**
518
+ * 只读文件头部,解析 identity custom entry(+途经的 model/thinking_level change)。
519
+ *
520
+ * 列表扫描专用:单次 readSync 读头部 IDENTITY_HEAD_BYTES 字节,逐行解析到 identity
521
+ * 即停。不解析 message entries(turns/eventLog/result 等重数据由
522
+ * RecordStore.getFullRecord 懒加载 reconstructFromFile 补齐)。
523
+ *
524
+ * 与 reconstructFromFile 的行为差异(有意):本函数只要头部有 identity 就返回,
525
+ * 不要求存在 assistant message——pi 延迟写入策略下无 assistant 的文件几乎不存在,
526
+ * 若存在(崩溃前 flush)也按分支 4 running 呈现(v4 B-1 可续聊语义),
527
+ * 而非静默不可见。
528
+ *
529
+ * 返回 undefined:文件缺失/读失败/头部无 identity。不抛。
530
+ * 头部无 identity ≠ 文件无 identity(续聊场景 identity 在尾部附近)——需要覆盖全文件
531
+ * 时用 readIdentityAnywhere。
532
+ */
533
+ export function readIdentityHeader(sessionFile: string): IdentityHeaderRecon | undefined {
534
+ let text: string;
535
+ try {
536
+ const fd = fs.openSync(sessionFile, "r");
537
+ try {
538
+ const buf = Buffer.alloc(IDENTITY_HEAD_BYTES);
539
+ let total = 0;
540
+ while (total < buf.length) {
541
+ const n = fs.readSync(fd, buf, total, buf.length - total, total);
542
+ if (n <= 0) break;
543
+ total += n;
544
+ }
545
+ text = buf.toString("utf-8", 0, total);
546
+ } finally {
547
+ fs.closeSync(fd);
548
+ }
549
+ } catch {
550
+ return undefined; // 文件缺失/不可读 → 降级跳过
551
+ }
552
+ return parseIdentityFromText(text, sessionFile);
553
+ }
554
+
555
+ /**
556
+ * 全文件找 identity:头部/尾部都 miss 后的最后 fallback(真实数据仅 ~0.2% 走到)。
557
+ *
558
+ * 全量 readFileSync + indexOf 预筛(只有含特征串的行才 JSON.parse),从后往前找
559
+ * (续聊轮次的 identity 靠尾部,最后一次的 identity 最新鲜)。读全文是成本主体
560
+ * (预筛/parse 可忽略)——调用方(RecordStore)应缓存结果避免重复读。
561
+ */
562
+ export function readIdentityAnywhere(sessionFile: string): IdentityHeaderRecon | undefined {
563
+ let text: string;
564
+ try {
565
+ text = fs.readFileSync(sessionFile, "utf-8");
566
+ } catch {
567
+ return undefined;
568
+ }
569
+ const lines = text.split("\n");
570
+ for (let i = lines.length - 1; i >= 0; i--) {
571
+ const s = lines[i];
572
+ if (!s.includes(IDENTITY_CUSTOM_TYPE)) continue; // 预筛:identity 行必含特征串
573
+ const recon = parseIdentityFromText(s, sessionFile);
574
+ if (recon) return recon;
575
+ }
576
+ return undefined;
577
+ }
578
+
579
+ /**
580
+ * 只读文件尾部找 identity(续聊场景主力命中点:最后一轮 session_start 追加的
581
+ * identity 离文件尾近,实测真实目录 65% 文件 identity 在尾 64KB)。
582
+ *
583
+ * 尾部块首行可能残缺(从行中间开始)——JSON.parse 失败自然跳过,不影响后续行。
584
+ * model/thinking_level 在尾部块中拿不到 identity 之前的途经 entry,返回空/undefined
585
+ * (best-effort,详情场景由全量重建补齐)。
586
+ */
587
+ export function readIdentityTail(sessionFile: string): IdentityHeaderRecon | undefined {
588
+ let text: string;
589
+ try {
590
+ const { size } = fs.statSync(sessionFile);
591
+ const start = Math.max(0, size - IDENTITY_HEAD_BYTES);
592
+ const fd = fs.openSync(sessionFile, "r");
593
+ try {
594
+ const buf = Buffer.alloc(size - start);
595
+ let total = 0;
596
+ while (total < buf.length) {
597
+ const n = fs.readSync(fd, buf, total, buf.length - total, start + total);
598
+ if (n <= 0) break;
599
+ total += n;
600
+ }
601
+ text = buf.toString("utf-8", 0, total);
602
+ } finally {
603
+ fs.closeSync(fd);
604
+ }
605
+ } catch {
606
+ return undefined;
607
+ }
608
+ const lines = text.split("\n");
609
+ for (let i = lines.length - 1; i >= 0; i--) {
610
+ const s = lines[i];
611
+ if (!s.includes(IDENTITY_CUSTOM_TYPE)) continue;
612
+ const recon = parseIdentityFromText(s, sessionFile);
613
+ if (recon) return recon;
614
+ }
615
+ return undefined;
616
+ }
617
+
618
+ /** 从文本片段解析 identity(头部/全文两入口共享)。解析到 identity 即停(返回);
619
+ * 找不到返回 undefined。model/thinking_level 仅在 identity 之前的途经 entry 中
620
+ * best-effort 提取,详情场景由全量重建补齐。 */
621
+ function parseIdentityFromText(text: string, sessionFile: string): IdentityHeaderRecon | undefined {
622
+ let identity: SubagentIdentityData | undefined;
623
+ let model = "";
624
+ let thinkingLevel: string | undefined;
625
+ for (const line of text.split("\n")) {
626
+ const s = line.trim();
627
+ if (!s) continue;
628
+ // 预筛([S-4] 同款值匹配):只有含三种目标 entry 值串的行才值得 JSON.parse——
629
+ // head 64KB 内大 toolResult 行(数十 KB/行)占多数,全量 parse 是冷扫描 CPU 大头。
630
+ // 值字符串在任意序列化格式下连续出现,不耦合 pi 的空格习惯。
631
+ if (
632
+ !s.includes(IDENTITY_CUSTOM_TYPE) &&
633
+ !s.includes('"model_change"') &&
634
+ !s.includes('"thinking_level_change"')
635
+ ) {
636
+ continue;
637
+ }
638
+ let entry: JsonlEntry;
639
+ try {
640
+ entry = JSON.parse(s) as JsonlEntry;
641
+ } catch {
642
+ continue; // 损坏行/截断行(头部读取边界)跳过
643
+ }
644
+ if (entry.type === "custom" && entry.customType === IDENTITY_CUSTOM_TYPE) {
645
+ if (isIdentityData(entry.data)) {
646
+ identity = entry.data;
647
+ break; // 找到即停(后续行不再解析)
648
+ }
649
+ } else if (entry.type === "model_change") {
650
+ if (typeof entry.provider === "string" && typeof entry.modelId === "string") {
651
+ model = `${entry.provider}/${entry.modelId}`;
652
+ }
653
+ } else if (entry.type === "thinking_level_change") {
654
+ if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
655
+ }
656
+ }
657
+ if (!identity) return undefined;
658
+
659
+ // 归一化与全量 recon 同源:rootSessionId fallback 旧字段、slug 兜底空串。
660
+ const rootSessionId = identity.rootSessionId ?? identity.parentSessionId;
661
+ return {
662
+ id: identity.id,
663
+ agent: identity.agent,
664
+ mode: identity.mode,
665
+ task: identity.task,
666
+ slug: identity.slug ?? "",
667
+ startedAt: identity.startedAt,
668
+ rootSessionId,
669
+ parentRecordId: identity.parentRecordId,
670
+ depth: identity.depth ?? 0,
671
+ forkDepth: identity.forkDepth,
672
+ chatMode: identity.chatMode,
673
+ worktree: identity.worktree,
674
+ model,
675
+ thinkingLevel,
676
+ sessionFile,
677
+ };
678
+ }