@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
@@ -0,0 +1,304 @@
1
+ // src/execution/sessions-index.ts
2
+ //
3
+ // [perf L-1] sessions-index.json —— identity 探测结果的磁盘种子。
4
+ //
5
+ // 背景:RecordStore 冷启动首扫需逐个探测 sessions/*.jsonl 的 identity(头部 64KB →
6
+ // 尾部 64KB → 全文 fallback)。session 数量大时该探测是冷扫描的主要成本。本模块把
7
+ // 「本目录的探测结论」(per-file identity 或负标记)持久化到 sessionsDir 的兄弟位置
8
+ // (<enc> 段内),下次进程/实例首扫直接用索引条目构造缓存条目(零内容读取)。
9
+ //
10
+ // 语义:
11
+ // - 读侧(loadIndex)永不抛:文件缺失/JSON 损坏/结构不符/版本低 → 空索引(= 今天的
12
+ // 全量行为,下轮 dirty 重写自愈);版本高于自身 → 空索引 + higherVersion 标志
13
+ // (整体忽略不消费,RecordStore 据此抑制本轮及后续落盘,防 v1/v2 last-writer-wins
14
+ // 覆盖振荡)。
15
+ // - 写侧(saveIndex):tmp(pid+seq)+fsync+rename+目录 fsync 原子写(逐环复刻
16
+ // ManifestStore.writeManifest 的生产模式)。失败本身向上抛;fire-and-forget 的
17
+ // .catch 兜底在 RecordStore 侧。
18
+ // - 损坏/降级走 logger.debug(PI_EXT_DEBUG=1 可见,默认 no-op),不 console.error
19
+ // ——索引是纯性能缓存,降级自愈不应告警。
20
+ //
21
+ // 无状态纯函数模块:不依赖 RecordStore 任何内部状态。
22
+
23
+ import * as fs from "node:fs";
24
+ import * as fsPromises from "node:fs/promises";
25
+ import * as path from "node:path";
26
+
27
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
28
+
29
+ import { bestEffort } from "./best-effort.ts";
30
+ import type { ExecutionMode } from "./types.ts";
31
+
32
+ const logger = getLogger("subagents");
33
+
34
+ // ============================================================
35
+ // 常量(测试锚点 SSOT:RecordStore 与测试从同一来源引用,禁止内联字面量)
36
+ // ============================================================
37
+
38
+ /** 索引文件名(落 sessionsDir 兄弟位置——写索引不改 sessionsDir 目录 mtime,不击穿 L0 快路径)。 */
39
+ export const INDEX_FILENAME = "sessions-index.json";
40
+
41
+ /** 索引格式版本。schema 变更必须递增:低版本文件整体丢弃(空索引,下轮 dirty 重写自愈);高版本整体忽略(higherVersion,不重写)。 */
42
+ export const INDEX_VERSION = 1;
43
+
44
+ /** 两次成功落盘的最小墙钟间隔(节流:overlay 打开期间的高频扫描不放大磁盘写)。 */
45
+ export const INDEX_WRITE_MIN_INTERVAL_MS = 60_000;
46
+
47
+ /** tmp 文件单调计数:同一进程内并发的 saveIndex 各用独立 tmp。节流基准只在写成功后
48
+ * 推进——W1 在途时新一轮过窗扫描可再 dispatch W2,共用同一 tmp 会交错(W2 truncate
49
+ * 落在 W1 write 与 rename 之间 → 半成品被 rename / rename 后失败 → 假失败日志)。
50
+ * pid+seq 双后缀保证 tmp 唯一:交错 rename 的终态必为某次完整快照(last-writer-wins,
51
+ * 与跨进程 pid 隔离同语义;陈旧快照胜出时下轮戳不匹配自愈)。 */
52
+ let tmpSeq = 0;
53
+
54
+ // ============================================================
55
+ // 类型(DM1 磁盘顶层 + DM2 条目)
56
+ // ============================================================
57
+
58
+ /**
59
+ * 正索引条目:「该文件有 identity」的身份字段 + stat 戳。
60
+ *
61
+ * 不含 sessionFile(加载侧由 sessionsDir + basename 重构,绝对路径因 agentDir 迁移
62
+ * 整体失效);不含 forkDepth/chatMode(投影源 SubagentRecord 无此二字段,buildRecord
63
+ * 构造 light 时不读——存了也无消费方;未来需要时升 INDEX_VERSION)。
64
+ */
65
+ export interface SessionsIndexEntry {
66
+ /** tagged union 判别(负条目为 true)。显式声明 false 供 TS narrowing(与 record-store.ts FileCacheEntry 同款)。 */
67
+ negative?: false;
68
+ /** jsonl stat 戳(mtime+size)——不匹配 = 内容变化,调用方回退重探测。 */
69
+ mtimeMs: number;
70
+ size: number;
71
+ id: string;
72
+ agent: string;
73
+ /** 执行模式。运行时校验宽容历史 "sync" 值(白名单 sync|background,镜像 isIdentityData);类型层由 ExecutionMode 收窄。 */
74
+ mode: ExecutionMode;
75
+ task: string;
76
+ slug: string;
77
+ startedAt: number;
78
+ /** undefined 表示缺失(旧文件/顶层)。JSON 序列化时被丢弃,往返保持 undefined。 */
79
+ rootSessionId: string | undefined;
80
+ parentRecordId: string | undefined;
81
+ depth: number;
82
+ /** 空串合法:尾部探测(readIdentityTail)拿不到 model 时的合法结果,不当损坏。 */
83
+ model: string;
84
+ thinkingLevel: string | undefined;
85
+ }
86
+
87
+ /**
88
+ * 负索引条目:「该文件确认无 identity」(junk/异构文件)。与内存负缓存
89
+ * NegativeFileEntry 语义对齐——把「没有」这一事实持久化,跨实例零重探测。
90
+ */
91
+ export interface SessionsIndexNegativeEntry {
92
+ negative: true;
93
+ mtimeMs: number;
94
+ size: number;
95
+ }
96
+
97
+ /** 磁盘 JSON 顶层结构(key = jsonl basename 不含路径)。 */
98
+ export interface SessionsIndexFile {
99
+ version: 1;
100
+ pid: number;
101
+ entries: Record<string, SessionsIndexEntry | SessionsIndexNegativeEntry>;
102
+ }
103
+
104
+ /** saveIndex 输入(内存形态)。 */
105
+ export interface SessionsIndexData {
106
+ entries: Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>;
107
+ }
108
+
109
+ /** loadIndex 输出。恒返回该形态对象(不抛、不返回 null)。 */
110
+ export interface LoadedSessionsIndex {
111
+ entries: Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>;
112
+ /** true = 磁盘版本高于自身:条目整体不消费,且本进程及后续不得写盘(防 v1/v2 互相覆盖振荡)。 */
113
+ higherVersion: boolean;
114
+ }
115
+
116
+ // ============================================================
117
+ // 条目级校验(ES3:单条目损坏仅丢弃该条目,不放大为整体失效)
118
+ // ============================================================
119
+
120
+ /** 正条目类型谓词:镜像 isIdentityData(session-reconstructor.ts:244-253)的字段检查 + 索引特有戳/形态字段。 */
121
+ function isPositiveIndexEntry(raw: unknown): raw is SessionsIndexEntry {
122
+ if (typeof raw !== "object" || raw === null) return false;
123
+ const v = raw as Record<string, unknown>;
124
+ return (
125
+ typeof v.mtimeMs === "number" &&
126
+ typeof v.size === "number" &&
127
+ typeof v.id === "string" &&
128
+ typeof v.agent === "string" &&
129
+ // 宽容历史 "sync" 值(ExecutionMode 现值仅 "background","sync" 是旧数据合法值,放行不丢弃)
130
+ (v.mode === "sync" || v.mode === "background") &&
131
+ typeof v.task === "string" &&
132
+ typeof v.slug === "string" &&
133
+ typeof v.startedAt === "number" &&
134
+ (v.rootSessionId === undefined || typeof v.rootSessionId === "string") &&
135
+ (v.parentRecordId === undefined || typeof v.parentRecordId === "string") &&
136
+ typeof v.depth === "number" &&
137
+ typeof v.model === "string" && // 空串合法(DS4)
138
+ (v.thinkingLevel === undefined || typeof v.thinkingLevel === "string")
139
+ );
140
+ }
141
+
142
+ /** 负条目类型谓词(tagged union 形态检查)。 */
143
+ function isNegativeIndexEntry(raw: unknown): raw is SessionsIndexNegativeEntry {
144
+ if (typeof raw !== "object" || raw === null) return false;
145
+ const v = raw as Record<string, unknown>;
146
+ return v.negative === true && typeof v.mtimeMs === "number" && typeof v.size === "number";
147
+ }
148
+
149
+ /**
150
+ * 校验单条索引条目。返回 undefined = 条目损坏,调用方仅丢弃该条目
151
+ * (该文件本轮回退探测,其余条目正常保留/命中)。
152
+ */
153
+ export function validateIndexEntry(
154
+ raw: unknown,
155
+ ): SessionsIndexEntry | SessionsIndexNegativeEntry | undefined {
156
+ if (!isPositiveIndexEntry(raw) && !isNegativeIndexEntry(raw)) return undefined;
157
+ return raw;
158
+ }
159
+
160
+ // ============================================================
161
+ // 读侧(IF1:永不抛)
162
+ // ============================================================
163
+
164
+ /**
165
+ * 读取指定目录(<enc> 段)的 sessions-index.json。永不抛:
166
+ * - 文件不存在/读失败/JSON.parse 失败/顶层结构不符/版本低于自身
167
+ * → { entries: 空 Map, higherVersion: false }(空索引 = 今天的全量行为)
168
+ * - 版本高于自身 → { entries: 空 Map, higherVersion: true }
169
+ * (整体忽略——即使 entries 合法也不消费,防陈旧 schema 灌入;RecordStore 据此抑制落盘)
170
+ * - 版本匹配 → 逐条 validateIndexEntry(坏条目丢弃,其余保留)
171
+ *
172
+ * 只读精确路径 INDEX_FILENAME:.tmp. 残留文件不匹配文件名,天然被忽略。
173
+ */
174
+ export function loadIndex(encDir: string): LoadedSessionsIndex {
175
+ const empty: LoadedSessionsIndex = { entries: new Map(), higherVersion: false };
176
+ const indexPath = path.join(encDir, INDEX_FILENAME);
177
+
178
+ let raw: string;
179
+ try {
180
+ raw = fs.readFileSync(indexPath, "utf-8");
181
+ } catch (err) {
182
+ // ENOENT = 正常首跑,保持静默;其余读失败(EACCES 等长期权限异常)留 debug 线索
183
+ // ——空索引回退本身可自愈,但权限类异常不会自己消失,需可诊断。
184
+ const code = err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : undefined;
185
+ if (code !== "ENOENT") {
186
+ logger.debug("[subagents] sessions-index read failed, fallback to empty", {
187
+ detail: { dir: encDir, code },
188
+ });
189
+ }
190
+ return empty;
191
+ }
192
+
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(raw);
196
+ } catch (err) {
197
+ // 损坏(截断/外部编辑)走 debug:可降级自愈场景,不 console.error
198
+ logger.debug("[subagents] sessions-index corrupted JSON, fallback to empty", {
199
+ detail: { path: indexPath, error: err instanceof Error ? err.message : String(err) },
200
+ });
201
+ return empty;
202
+ }
203
+
204
+ if (typeof parsed !== "object" || parsed === null) {
205
+ logger.debug("[subagents] sessions-index invalid top-level shape, fallback to empty", {
206
+ detail: { path: indexPath },
207
+ });
208
+ return empty;
209
+ }
210
+ const top = parsed as Record<string, unknown>;
211
+ if (
212
+ typeof top.version !== "number" ||
213
+ typeof top.entries !== "object" ||
214
+ top.entries === null ||
215
+ Array.isArray(top.entries)
216
+ ) {
217
+ logger.debug("[subagents] sessions-index invalid header fields, fallback to empty", {
218
+ detail: { path: indexPath },
219
+ });
220
+ return empty;
221
+ }
222
+
223
+ if (top.version > INDEX_VERSION) {
224
+ // 高版本:整体忽略(entries 即使合法也不消费);调用方据 higherVersion 抑制写盘
225
+ return { entries: new Map(), higherVersion: true };
226
+ }
227
+ if (top.version < INDEX_VERSION) {
228
+ // 低版本:整体丢弃 → 空索引;本轮全扫 dirty 后重写自愈
229
+ logger.debug("[subagents] sessions-index stale version, discarded", {
230
+ detail: { path: indexPath, version: top.version, expected: INDEX_VERSION },
231
+ });
232
+ return empty;
233
+ }
234
+
235
+ const entries = new Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>();
236
+ for (const [key, value] of Object.entries(top.entries)) {
237
+ const entry = validateIndexEntry(value);
238
+ if (entry !== undefined) entries.set(key, entry);
239
+ }
240
+ return { entries, higherVersion: false };
241
+ }
242
+
243
+ // ============================================================
244
+ // 写侧(IF2:原子写;失败向上抛——fire-and-forget 的 .catch 兜底在调用方)
245
+ // ============================================================
246
+
247
+ /**
248
+ * 原子写索引(tmp(pid+seq) → fsync → rename → fsync 目录;逐环复刻
249
+ * ManifestStore.writeManifest 的生产模式)。tmp 带 pid+单调序号双后缀:pid 防两进程
250
+ * 共用同一 tmp,seq 防同进程内并发 saveIndex(节流基准在写成功后才推进,W1 在途时
251
+ * 新一轮过窗扫描可 dispatch W2)共用同一 tmp;rename 原子性保证读侧看到旧版或完整
252
+ * 新版,绝无半成品。
253
+ *
254
+ * 失败向上抛——RecordStore 的 flushIndexAfterScan 以 fire-and-forget .catch 消费
255
+ * (写失败不影响任何扫描结果,恢复 dirty 待下轮过窗重试)。
256
+ */
257
+ export async function saveIndex(encDir: string, data: SessionsIndexData): Promise<void> {
258
+ const filePath = path.join(encDir, INDEX_FILENAME);
259
+ const tmpPath = `${filePath}.tmp.${process.pid}.${++tmpSeq}`;
260
+ const file: SessionsIndexFile = {
261
+ version: INDEX_VERSION,
262
+ pid: process.pid,
263
+ entries: Object.fromEntries(data.entries),
264
+ };
265
+ const content = JSON.stringify(file);
266
+
267
+ let renamed = false;
268
+ try {
269
+ // 1. 写 tmp → fsync 文件
270
+ const fh = await fsPromises.open(tmpPath, "w");
271
+ try {
272
+ await fh.writeFile(content, "utf-8");
273
+ await fh.sync();
274
+ } finally {
275
+ await fh.close();
276
+ }
277
+
278
+ // 2. rename tmp → final(放在 try 内:失败时 catch 清理 tmp)
279
+ await fsPromises.rename(tmpPath, filePath);
280
+ renamed = true;
281
+
282
+ // 3. fsync 目录(best-effort:POSIX 不要求,失败不否定已成功的 rename)
283
+ try {
284
+ const dirFh = await fsPromises.open(encDir, "r");
285
+ try {
286
+ await dirFh.sync();
287
+ } finally {
288
+ await dirFh.close();
289
+ }
290
+ } catch (dirSyncErr) {
291
+ bestEffort(dirSyncErr, "fsync dir (saveIndex)");
292
+ }
293
+ } catch (err) {
294
+ // rename 未成功 → 清理残留 tmp(best-effort,不掩盖原错误)
295
+ if (!renamed) {
296
+ try {
297
+ await fsPromises.unlink(tmpPath);
298
+ } catch (cleanupErr) {
299
+ bestEffort(cleanupErr, "unlink tmp (saveIndex)");
300
+ }
301
+ }
302
+ throw err;
303
+ }
304
+ }
@@ -16,6 +16,43 @@ import type { UiResponse } from "./dialog-queue.ts";
16
16
 
17
17
  const logger = getLogger("subagents");
18
18
 
19
+ /**
20
+ * EPIPE 连续失败计数器(record.id → 连续 EPIPE 次数)。
21
+ *
22
+ * [v4 A-1] 计数器从 subagent-service.ts 迁移到本模块(stdin 错误域)。迁移原因:
23
+ * session-runner.ts 已被 subagent-service.ts import(runSpawn 等),若 session-runner
24
+ * 反向 import subagent-service 的 helper 会循环依赖。stdin-writer 不 import 两者,
25
+ * 是 stdin 域中立模块,epipe 计数属 stdin 错误域,职责合理。
26
+ *
27
+ * 错误处理两半面共用本计数器(合并计数,防 spawn→EPIPE→resume 死循环):
28
+ * ① 同步 write 抛错(writeStdinLine throw → deliverMessage catch 递增)
29
+ * ② 异步 stream 'error' event(session-runner.ts child.stdin.on('error') 递增)
30
+ */
31
+ const epipeConsecutiveFailures = new Map<string, number>();
32
+
33
+ /** 连续 EPIPE 失败阈值:达到即不再尝试 resume(避免无限 spawn → EPIPE → resume 循环)。 */
34
+ export const EPIPE_FAILURE_THRESHOLD = 2;
35
+
36
+ /**
37
+ * 递增 recordId 的 EPIPE 连续失败计数,返回递增后的新计数。
38
+ * 同步 writeStdinLine throw 与异步 child.stdin 'error' event 共用此入口(合并计数)。
39
+ */
40
+ export function recordEpipeFailure(recordId: string): number {
41
+ const count = (epipeConsecutiveFailures.get(recordId) ?? 0) + 1;
42
+ epipeConsecutiveFailures.set(recordId, count);
43
+ return count;
44
+ }
45
+
46
+ /** 成功写入时清零某 record 的 EPIPE 连续失败计数(热路径成功 → 重置,允许后续重新计数)。 */
47
+ export function clearEpipeFailure(recordId: string): void {
48
+ epipeConsecutiveFailures.delete(recordId);
49
+ }
50
+
51
+ /** dispose 时清空所有 EPIPE 计数(防跨 session 泄漏)。 */
52
+ export function resetAllEpipeFailures(): void {
53
+ epipeConsecutiveFailures.clear();
54
+ }
55
+
19
56
  /**
20
57
  * 按 UiResponse 形状构造 Pi 原生 extension_ui_response 并写 stdin。
21
58
  *
@@ -62,19 +99,40 @@ export function respond(child: ChildProcess, id: string, out: UiResponse, signal
62
99
  * pi 在 await rebindSession() 后才挂 stdin reader(rpc-mode.ts:778-781),
63
100
  * reader 处理 prompt 时 session 已就绪。
64
101
  *
102
+ * [V2 决策 3] chatMode 续聊热路径用 prompt + streamingBehavior 统一投递(替代 steer/followUp
103
+ * 命令),pi 权威裁决 busy/idle(设计 F3/F4):busy 时 followUp 入队/steer 抢占;idle 时
104
+ * streamingBehavior 被忽略、直接开新 turn。不传 streamingBehavior(首帧 prompt / 旧调用方)
105
+ * 行为完全不变——向后兼容。
106
+ *
65
107
  * @param child 子进程(stdin 写入 prompt 命令)
66
108
  * @param task 完整 task 文本(含 schema 指令)
109
+ * @param options.streamingBehavior V2 统一投递语义:`"followUp"`(排队,当前轮后处理)/ `"steer"`(抢占,立即中断 streaming)。
110
+ * 省略时不写入该字段(首帧 prompt / 非 chatMode 调用方,行为不变)。
67
111
  */
68
- export function sendPromptCommand(child: ChildProcess, task: string): void {
112
+ export function sendPromptCommand(
113
+ child: ChildProcess,
114
+ task: string,
115
+ options?: { streamingBehavior?: "followUp" | "steer" },
116
+ ): void {
69
117
  if (!child.stdin || child.stdin.destroyed) return;
70
- const command = JSON.stringify({
118
+ const payload: Record<string, unknown> = {
71
119
  id: crypto.randomUUID(),
72
120
  type: "prompt",
73
121
  message: task,
74
- });
75
- writeStdinLine(child, command, "prompt command");
122
+ };
123
+ if (options?.streamingBehavior) {
124
+ payload.streamingBehavior = options.streamingBehavior;
125
+ }
126
+ writeStdinLine(child, JSON.stringify(payload), "prompt command");
76
127
  }
77
128
 
129
+ /**
130
+ * [review 修复 round2] 已删除 sendFollowUpCommand / sendSteerCommand——deliverToRunning
131
+ * (busy 投递死机制,S2 删除)的唯一消费方移除后两函数成零生产调用方死 export。
132
+ * busy 投递语义由 prompt + streamingBehavior(sendPromptCommand 的
133
+ * options.streamingBehavior: "followUp" | "steer",pi 权威裁决)承担。
134
+ */
135
+
78
136
  /**
79
137
  * 向 rpc 子进程 stdin 写 get_state 命令,查询 sessionFile/sessionId。
80
138
  *
@@ -96,17 +154,45 @@ export function sendGetStateCommand(child: ChildProcess): string {
96
154
  }
97
155
 
98
156
  /**
99
- * 向子进程 stdin 写一行(自动补换行),带背压检查。
157
+ * 向子进程 stdin 写一行(自动补换行),带背压检查 + EPIPE 检测。
100
158
  *
101
159
  * [R1] write 返回 false 时记 warn(不阻塞,内核缓冲会随后排空)。
160
+ * [R3] write 抛 EPIPE / ERR_STREAM_DESTROYED 时 throw 含 EPIPE 关键词的 Error,
161
+ * 让上层(deliverMessage)能捕获并自动转冷路径 resume。
102
162
  * stdin 已关闭/销毁时跳过——respond 已检查 signal,sendPromptCommand 已检查 destroyed。
103
163
  *
104
164
  * @param child 子进程
105
165
  * @param line JSON 行(不含换行)
106
166
  * @param warnTag warn 日志的语义标记
167
+ * @throws Error 含 "EPIPE" 关键词——stdin 管道已断(子进程已退出 / stdin 被销毁)
107
168
  */
108
169
  function writeStdinLine(child: ChildProcess, line: string, warnTag: string): void {
109
170
  if (!child.stdin || child.stdin.destroyed) return;
110
- const ok = child.stdin.write(line + "\n");
111
- if (!ok) logger.warn(`[subagents] stdin backpressure on ${warnTag}`);
171
+ try {
172
+ const ok = child.stdin.write(line + "\n");
173
+ if (!ok) logger.warn(`[subagents] stdin backpressure on ${warnTag}`);
174
+ } catch (err) {
175
+ // [R3] EPIPE / ERR_STREAM_DESTROYED:stdin 管道已断,子进程已退出或 stdin 被销毁。
176
+ // [v4 A-1] 错误处理两半面:① 同步 write 抛错(本 catch)——上层 deliverMessage 捕获后
177
+ // 调 recordEpipeFailure 递增计数;② 异步 stream 'error' event(session-runner.ts 的
178
+ // child.stdin.on('error') listener)。两者共用本模块 export 的 recordEpipeFailure
179
+ // helper 合并计数(防 spawn→EPIPE→resume 死循环)。
180
+ // throw 让上层(deliverMessage)捕获并自动转冷路径 resume + 消息重放。
181
+ if (
182
+ err !== null &&
183
+ typeof err === "object" &&
184
+ "code" in err &&
185
+ ((err as NodeJS.ErrnoException).code === "EPIPE" ||
186
+ (err as NodeJS.ErrnoException).code === "ERR_STREAM_DESTROYED")
187
+ ) {
188
+ throw new Error(
189
+ `[subagents] EPIPE on stdin write (${warnTag}): pipe broken, child process likely exited. ` +
190
+ `Recovery: treat as dead process and resume via cold path.`,
191
+ );
192
+ }
193
+ // 非 EPIPE 错误(不应发生,但兜底降级为 warn 不崩溃)
194
+ logger.warn(`[subagents] unexpected stdin write error on ${warnTag}`, {
195
+ detail: err instanceof Error ? err.message : String(err),
196
+ });
197
+ }
112
198
  }
@@ -12,7 +12,7 @@
12
12
  * 设计要点:
13
13
  * - leading edge:第一个 delta 立即 flush(前端尽快看到开始)
14
14
  * - trailing edge:后续 delta 追加 buffer,timer 到期后 flush
15
- * - 每次 flush 把 buffer 的累积全文 split("\n") 传给 setWidget
15
+ * - 每次 flush 把 buffer 的累积文本 split("\n") 截尾 MAX_WIDGET_LINES 行传给 setWidget
16
16
  * - dispose 清除 widget + 清 timer
17
17
  */
18
18
 
@@ -25,9 +25,17 @@ export interface StreamSink {
25
25
  setWidget(key: string, lines: string[] | undefined): void;
26
26
  }
27
27
 
28
- /** delta 合并窗口时间(ms)。与 onEventThrottled 的节流间隔对齐。 */
28
+ /** delta 合并窗口时间(ms)。 */
29
29
  const STREAM_FLUSH_MS = 100;
30
30
 
31
+ /** widget 发送的最大行数上限。
32
+ *
33
+ * flush 把 buffer 累积全文 split 后经 setWidget RPC 通道整包发送——长输出 subagent
34
+ * (数百 KB)下每 100ms 一次 O(当前总量) 的序列化+传输,累计 O(n²)。widget 是实时
35
+ * 预览(完整输出在 record/eventLog 与 session 文件),只保留尾部行即可;超限时以
36
+ * 一行 truncated 提示替代被裁掉的头部。 */
37
+ const MAX_WIDGET_LINES = 200;
38
+
31
39
  /**
32
40
  * subagent text_delta streaming 生命周期对象。
33
41
  *
@@ -78,6 +86,15 @@ export class SubagentStream {
78
86
  private flush(): void {
79
87
  this.timer = undefined;
80
88
  if (this.buffer.length === 0 || this.disposed) return;
81
- this.sink.setWidget(this.widgetKey, this.buffer.split("\n"));
89
+ const lines = this.buffer.split("\n");
90
+ if (lines.length <= MAX_WIDGET_LINES) {
91
+ this.sink.setWidget(this.widgetKey, lines);
92
+ return;
93
+ }
94
+ const dropped = lines.length - MAX_WIDGET_LINES;
95
+ this.sink.setWidget(this.widgetKey, [
96
+ `(... ${dropped} earlier lines truncated, full output in /subagents detail)`,
97
+ ...lines.slice(dropped),
98
+ ]);
82
99
  }
83
100
  }