@zhushanwen/subagent-core 0.2.0 → 0.4.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 (195) hide show
  1. package/README.md +45 -2
  2. package/agents/analyst.md +60 -0
  3. package/agents/coder.md +69 -0
  4. package/agents/debugger.md +66 -0
  5. package/agents/doc-reviewer.md +49 -0
  6. package/agents/explorer.md +63 -0
  7. package/agents/general-purpose.md +32 -0
  8. package/agents/orchestrator.md +61 -0
  9. package/agents/planner.md +53 -0
  10. package/agents/researcher.md +67 -0
  11. package/agents/reviewer.md +73 -0
  12. package/dist/chunk-4KN4TTG7.js +240 -0
  13. package/dist/chunk-APZY4IME.js +27 -0
  14. package/dist/chunk-X7SZ5HLQ.js +66 -0
  15. package/dist/execution/engine/engines/zcode/constants.cjs +65 -0
  16. package/dist/execution/engine/engines/zcode/constants.d.cts +93 -1
  17. package/dist/execution/engine/engines/zcode/constants.d.ts +93 -1
  18. package/dist/execution/engine/engines/zcode/constants.js +43 -1
  19. package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
  20. package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
  21. package/dist/execution/engine/engines/zcode/reader.js +4 -234
  22. package/dist/execution/engine/paths.js +7 -19
  23. package/dist/index.cjs +14709 -100
  24. package/dist/index.d.cts +4053 -200
  25. package/dist/index.d.ts +4053 -200
  26. package/dist/index.js +14358 -102
  27. package/dist/{types-BxyAidGf.d.cts → types-DpUO16pj.d.cts} +464 -1
  28. package/dist/{types-BxyAidGf.d.ts → types-DpUO16pj.d.ts} +464 -1
  29. package/package.json +6 -3
  30. package/src/__tests__/review-fix-loop-script.test.ts +90 -2
  31. package/src/__tests__/review-fix-loop-utils.test.ts +21 -0
  32. package/src/__tests__/smoke.test.ts +9 -2
  33. package/src/execution/__tests__/agent-profile.test.ts +232 -0
  34. package/src/execution/__tests__/agents-assembly.test.ts +219 -0
  35. package/src/execution/__tests__/chat-engine-routing.test.ts +71 -0
  36. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +1 -0
  37. package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
  38. package/src/execution/__tests__/delivery-methods.test.ts +2 -0
  39. package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
  40. package/src/execution/__tests__/dialog-queue.test.ts +199 -1
  41. package/src/execution/__tests__/epipe-fallback.test.ts +2 -0
  42. package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
  43. package/src/execution/__tests__/finalize-record.test.ts +106 -0
  44. package/src/execution/__tests__/gc-timer.test.ts +2 -0
  45. package/src/execution/__tests__/get-record-for-action-restart.test.ts +2 -0
  46. package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
  47. package/src/execution/__tests__/keep-alive-no-progress.test.ts +378 -0
  48. package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
  49. package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
  50. package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
  51. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
  52. package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
  53. package/src/execution/__tests__/notify-ledger.test.ts +294 -0
  54. package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
  55. package/src/execution/__tests__/record-store.test.ts +83 -0
  56. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +4 -0
  57. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +1 -0
  58. package/src/execution/__tests__/run-spawn-edges.test.ts +407 -17
  59. package/src/execution/__tests__/run-spawn-integration.test.ts +1 -1
  60. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +12 -4
  61. package/src/execution/__tests__/service-kill-escalation.test.ts +89 -0
  62. package/src/execution/__tests__/session-pending.test.ts +228 -0
  63. package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
  64. package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
  65. package/src/execution/__tests__/session-runner-epipe.test.ts +1 -0
  66. package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +226 -0
  67. package/src/execution/__tests__/settled-watchdog.test.ts +289 -0
  68. package/src/execution/__tests__/spawned-children.test.ts +153 -10
  69. package/src/execution/__tests__/subagent-actions-core.test.ts +997 -0
  70. package/src/execution/__tests__/subagent-service-message-close.test.ts +71 -10
  71. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
  72. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +266 -0
  73. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +3 -0
  74. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +316 -0
  75. package/src/execution/__tests__/timeout-integration.test.ts +7 -7
  76. package/src/execution/__tests__/ui-request-handler-factory.test.ts +95 -1
  77. package/src/execution/__tests__/ui-request-queue.test.ts +25 -0
  78. package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
  79. package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
  80. package/src/execution/agent-registry.ts +225 -1
  81. package/src/execution/agents-assembly.ts +88 -0
  82. package/src/execution/concurrency-pool.ts +71 -9
  83. package/src/execution/dialog-queue.ts +101 -3
  84. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +49 -2
  85. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +87 -1
  86. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +26 -0
  87. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +83 -1
  88. package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +131 -0
  89. package/src/execution/engine/__tests__/registry.test.ts +90 -1
  90. package/src/execution/engine/engine-discovery.ts +12 -16
  91. package/src/execution/engine/engines/pi/task-spec-mapper.ts +3 -3
  92. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +274 -0
  93. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
  94. package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +472 -0
  95. package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +387 -0
  96. package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +780 -0
  97. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +815 -0
  98. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +462 -0
  99. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +119 -1
  100. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +136 -6
  101. package/src/execution/engine/engines/zcode/appserver-home.ts +442 -0
  102. package/src/execution/engine/engines/zcode/appserver-probe.ts +141 -0
  103. package/src/execution/engine/engines/zcode/connection.ts +585 -0
  104. package/src/execution/engine/engines/zcode/constants.ts +129 -0
  105. package/src/execution/engine/engines/zcode/golden-sample.ts +54 -8
  106. package/src/execution/engine/engines/zcode/preparer.ts +25 -22
  107. package/src/execution/engine/engines/zcode/session-channel.ts +656 -0
  108. package/src/execution/engine/engines/zcode/zcode-engine.ts +909 -41
  109. package/src/execution/engine/host-task-spec.ts +3 -3
  110. package/src/execution/engine/port.ts +41 -1
  111. package/src/execution/engine/registry.ts +53 -0
  112. package/src/execution/finalize-record.ts +63 -5
  113. package/src/execution/get-state-handshake.ts +85 -10
  114. package/src/execution/lifecycle-manager.ts +27 -3
  115. package/src/execution/manifest-store.ts +53 -62
  116. package/src/execution/notifier.ts +10 -10
  117. package/src/execution/notify-ledger.ts +117 -16
  118. package/src/execution/record-entry.ts +8 -2
  119. package/src/execution/record-store.ts +29 -15
  120. package/src/execution/session-pending.ts +213 -62
  121. package/src/execution/session-runner.ts +974 -128
  122. package/src/execution/sessions-index.ts +10 -55
  123. package/src/execution/settled-watchdog.ts +99 -0
  124. package/src/execution/subagent-actions-core.ts +686 -0
  125. package/src/execution/subagent-service.ts +396 -49
  126. package/src/execution/ui-request-handler-factory.ts +27 -4
  127. package/src/execution/worktree-git-ops.ts +397 -0
  128. package/src/execution/worktree-manager.ts +72 -10
  129. package/src/execution/worktree-registry.ts +6 -12
  130. package/src/index.ts +413 -6
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -4
  133. package/src/orchestration/__tests__/args-meta.test.ts +358 -0
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +1 -8
  135. package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
  136. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +382 -0
  137. package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
  138. package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
  139. package/src/orchestration/__tests__/file-run-store.test.ts +389 -0
  140. package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
  141. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
  142. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
  143. package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
  144. package/src/orchestration/__tests__/lifecycle.test.ts +77 -0
  145. package/src/orchestration/__tests__/run-snapshot.test.ts +353 -0
  146. package/src/orchestration/__tests__/script-generate.test.ts +314 -0
  147. package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
  148. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
  149. package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
  150. package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
  151. package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
  152. package/src/orchestration/agent-opts-resolver.ts +7 -7
  153. package/src/orchestration/args-meta.ts +198 -0
  154. package/src/orchestration/error-recovery.ts +416 -146
  155. package/src/orchestration/execute-agent-call.ts +9 -9
  156. package/src/orchestration/file-run-store.ts +327 -0
  157. package/src/orchestration/launcher.ts +35 -26
  158. package/src/orchestration/lifecycle.ts +355 -79
  159. package/src/orchestration/models/agent-call.ts +7 -7
  160. package/src/orchestration/models/budget.ts +5 -5
  161. package/src/orchestration/models/run-runtime.ts +13 -13
  162. package/src/orchestration/models/trace.ts +8 -8
  163. package/src/orchestration/models/workflow-run.ts +27 -27
  164. package/src/orchestration/models/workflow-script.ts +4 -4
  165. package/src/orchestration/run-snapshot.ts +266 -0
  166. package/src/orchestration/script-generate.ts +154 -0
  167. package/src/orchestration/script-lint.ts +33 -33
  168. package/src/orchestration/worker-handle.ts +10 -10
  169. package/src/orchestration/worker-host.ts +10 -10
  170. package/src/orchestration/worker-script-builder.ts +401 -346
  171. package/src/orchestration/workflow-files.ts +37 -11
  172. package/src/orchestration/workflow-run-summary.ts +69 -0
  173. package/src/orchestration/workflow-script-registry-impl.ts +31 -9
  174. package/src/shared/__tests__/agent-ref.test.ts +209 -2
  175. package/src/shared/__tests__/atomic-write.test.ts +267 -0
  176. package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
  177. package/src/shared/__tests__/injection-render.test.ts +518 -0
  178. package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
  179. package/src/shared/__tests__/resource-discovery.test.ts +3 -2
  180. package/src/shared/agent-ref.ts +143 -2
  181. package/src/shared/atomic-write.ts +320 -0
  182. package/src/shared/bounded-serialize.ts +154 -0
  183. package/src/shared/injection-render.ts +279 -0
  184. package/src/shared/meta-parser.ts +41 -1
  185. package/src/shared/resource-discovery.ts +104 -37
  186. package/src/shared/resource-meta.ts +14 -0
  187. package/src/shared/xml-injection.ts +9 -9
  188. package/workflows/README.md +9 -9
  189. package/workflows/chain.js +4 -2
  190. package/workflows/map-reduce.js +5 -3
  191. package/workflows/parallel.js +5 -3
  192. package/workflows/review-fix-loop-utils.cjs +14 -3
  193. package/workflows/review-fix-loop.js +18 -7
  194. package/workflows/scatter-gather.js +4 -2
  195. package/dist/chunk-3VOERJPJ.js +0 -22
@@ -5,21 +5,38 @@
5
5
  // spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
6
6
  // runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
7
7
 
8
- import { type ChildProcess, type ChildProcessWithoutNullStreams, execFile, spawn } from "node:child_process";
8
+ import {
9
+ type ChildProcess,
10
+ type ChildProcessWithoutNullStreams,
11
+ execFile,
12
+ spawn,
13
+ spawnSync,
14
+ } from "node:child_process";
9
15
  import * as fs from "node:fs";
10
16
 
11
17
  import { getLogger } from "../core/logger.ts";
12
18
  import { bestEffort } from "./best-effort.ts";
13
- import { armIdleTimer } from "./lifecycle-manager.ts";
14
- import { readActivePendingFromSessionFile } from "./session-pending.ts";
19
+ import { disposeEngines } from "./engine/registry.ts";
20
+ import { armIdleTimer, DEFAULT_IDLE_TIMEOUT_MS } from "./lifecycle-manager.ts";
21
+ import {
22
+ listActivePendingFromSessionFile,
23
+ prunePendingCursor,
24
+ readActivePendingFromSessionFile,
25
+ type ActivePendingResult,
26
+ } from "./session-pending.ts";
15
27
 
16
28
  import type { ExtensionMode } from "./host-mode.ts";
17
29
 
18
30
  import { type MirrorFlags, mirrorMainProcessFlags } from "./argv-mirror.ts";
19
- import { writeAliveMarker } from "./alive-store.ts";
31
+ import { isProcessAlive, readAliveMarker, writeAliveMarker } from "./alive-store.ts";
20
32
  import { type DialogGlobalQueue, type UiRequestHandler } from "./dialog-queue.ts";
21
33
  import { updateFromEvent } from "./execution-record.ts";
22
- import { type GetStateResult, performGetStateHandshake } from "./get-state-handshake.ts";
34
+ import {
35
+ type AddGetStateResponseListener,
36
+ type GetStateResult,
37
+ performGetStateHandshake,
38
+ requestGetStateOnce,
39
+ } from "./get-state-handshake.ts";
23
40
  import { willRespondToAskUser } from "./host-mode.ts";
24
41
  import type { AgentConfig, ResolvedModel } from "./model-resolver.ts";
25
42
  import { collectResult } from "./output-collector.ts";
@@ -33,6 +50,11 @@ import {
33
50
  schemaEnvByteLength,
34
51
  } from "../shared/schema-env.ts";
35
52
  import { assertSafeTimerDelay } from "../shared/timer-delay.ts";
53
+ import {
54
+ armSettledWatchdog,
55
+ disarmSettledWatchdog,
56
+ SETTLED_WATCHDOG_TIMEOUT_MS,
57
+ } from "./settled-watchdog.ts";
36
58
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
37
59
  import { EPIPE_FAILURE_THRESHOLD, recordEpipeFailure, sendPromptCommand } from "./stdin-writer.ts";
38
60
  import {
@@ -128,7 +150,7 @@ const DEFAULT_GRACE_TURNS = 2;
128
150
  /** watchdog 下限:30 分钟。兜底防止子进程卡死在单个 tool 内(hang 的 bash/网络读),
129
151
  * 导致 turn_end 永不触发、maxTurns limiter 失效、background 槽位/worktree/alive marker 泄漏。
130
152
  * [M-1] 旧实现固定 30 分钟,与 maxTurns 无关——maxTurns=100 的长任务会被误杀。
131
- * 现改为基于 maxTurns 动态计算(见 computeWatchdogMs)。 */
153
+ * 现改为基于 maxTurns 动态计算(见 maxTurnsToWatchdogMs)。 */
132
154
  const WATCHDOG_FLOOR_MINUTES = 30;
133
155
  const SPAWN_WATCHDOG_FLOOR_MS = WATCHDOG_FLOOR_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
134
156
 
@@ -156,28 +178,66 @@ const WATCHDOG_MS_PER_TURN = WATCHDOG_MINUTES_PER_TURN * SECONDS_PER_MINUTE * MS
156
178
  export const WAKEUP_GRACE_MS = 15_000;
157
179
 
158
180
  /**
159
- * [M-1] 基于 maxTurns 动态计算 watchdog 超时。
181
+ * [T2-① / P-T2 降级路径 B] keep-alive 裸缺省无进展检测的连续静默阈值(30min)。
160
182
  *
161
- * 旧实现固定 30 分钟(SPAWN_WATCHDOG_MS),与 maxTurns 无关:maxTurns=100 的长任务
162
- * (全量重构/大规模迁移)正常需数小时,30 分钟到达即被误杀,limiter 机制形同虚设。
183
+ * P-T2 探针裁决(probe/p-t2-report.md):历史 89 样本 96.6% keep-alive 窗口 >30min
184
+ * (P50=24.5min、长尾 95.5h、85/89 由 parent-shutdown 合法收敛)——固定 30min 上限
185
+ * 会大面积误杀,「wave keep-alive 数小时是合法形态」被数据证实。按设计降级路径 B
186
+ * 落地:上界语义从固定时长改为**无进展检测**——keep-alive 期间任何子进程 stdout
187
+ * 活动刷新计时,仅**连续静默**达此阈值才处置(SIGTERM→SIGKILL 升级)。
163
188
  *
164
- * 现按 maxTurns 线性估算:每 turn 5 分钟,下限 30 分钟。
189
+ * 阈值取 30min(对齐 SPAWN_WATCHDOG_FLOOR_MS 的旧 floor 量级):真实 keep-alive
190
+ * 合法性由「仍在活动」定义而非「不超过某时长」。层主 stdout 静默 ≠ 无进展——
191
+ *「直接后代跑 >30min、层主静默」是合法形态(P-T2 数据 85/89 parent-shutdown 即此类,
192
+ * 层主侧 stdout 刷新面看不到后代集合变化),故 fire 时不立即处置:先惰性复核层主是否
193
+ * 有存活活跃后代(pending 差集 + 后代 pid 存活,与 descendant sweep 同源判据),有 →
194
+ * 视为有进展重挂本 timer(固定 30min 复核节奏直到后代死光),无 → 真静默才处置。
195
+ * 挂载面严格限定裸缺省(maxTurns/env 双缺省才挂)——显式 maxTurns<=0(显式不限时)
196
+ * 与 resolveSpawnWatchdogMs fail-fast 降级不挂任何 timer(opt-out 保留)。
197
+ * [export] 测试可观测(keep-alive-no-progress 用例锚定静默阈值、刷新与 fire 复核语义)。
198
+ */
199
+ export const KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS = SPAWN_WATCHDOG_FLOOR_MS;
200
+
201
+ /**
202
+ * [T1/RC-1] agent_end 决策点惰性回补的单次 get_state 超时预算(ms)。
203
+ *
204
+ * P-T1 探针实证(probe/p-t1-report.md):agent_end 时 idle 子进程应答 0.3-0.4ms,
205
+ * 1s 预算有 ~2500 倍余量。回补超时/失败不重试——走既有保守分支(行为不劣化),
206
+ * 不把决策点变成第二个重试循环(与「被否:加长握手重试」的裁决一致)。
207
+ */
208
+ const LAZY_GET_STATE_TIMEOUT_MS = 1000;
209
+
210
+ /**
211
+ * [M-1] maxTurns → watchdog 毫秒换算(纯函数,可导出复用)。
212
+ *
213
+ * **换算语义(floor 文档化)**:`max(30min, maxTurns × 5min)`——按 maxTurns 线性估算,
214
+ * 带 30 分钟下限(floor)。maxTurns 换算结果低于 30 分钟时(含 ≤6 的整数与小数,
215
+ * 如 0.5)一律钳到 30 分钟:单 turn 约 5 分钟是经验值(复杂 tool + 长 LLM 响应约
216
+ * 3-4 分钟,留 1-2 分钟余量),maxTurns 过小时不设 floor 会把 watchdog 紧到误杀。
217
+ * - maxTurns=2 → 30min(floor 生效,非 2×5=10min——zsw 曾自实现无 floor 版本致该
218
+ * 配置被 10min 误杀,见 sink 设计 §2.1 例 1;两宿主统一消费本函数即同语义)
219
+ * - maxTurns=6 → 30min(恰为 floor 临界)
165
220
  * - maxTurns=20 → 100 分钟
166
221
  * - maxTurns=100 → 500 分钟(8 小时+,覆盖全量重构)
167
- *
168
- * [预算语义对齐 2026-08] maxTurns 未传/<=0 → 不挂 watchdog(不限)。旧实现按 10 turns
169
- * 估算出 50min 默认 SIGTERM,违背「默认不限制,显式传参才触发」的项目裁决;且显式
170
- * maxTurns:0 也被落回估算,任何参数都关不掉。用户须知风险:watchdog 防的是 pi 子进程
171
- * hang 泄漏(卡死在单个 tool turn_end 永不触发,limiter 失效),默认关闭意味着
172
- * maxTurns 的 spawn 若 hang 将永不自动回收——须用下方 SPAWN_WATCHDOG_ENV 显式兑底。
173
- *
222
+ *
223
+ * 旧实现固定 30 分钟(SPAWN_WATCHDOG_MS),与 maxTurns 无关:maxTurns=100 的长任务
224
+ * (全量重构/大规模迁移)正常需数小时,30 分钟到达即被误杀,limiter 机制形同虚设。
225
+ *
226
+ * [预算语义对齐 2026-08] maxTurns 未传/<=0 不挂 watchdog(不限)的挂载判定**不归本
227
+ * 函数**——本函数只做换算,挂载判定单一入口是 resolveSpawnWatchdogMs(未传时走
228
+ * SPAWN_WATCHDOG_ENV 兑底,显式 <=0 = 显式不限压过 env)。用户须知风险:watchdog 防
229
+ * 的是 pi 子进程 hang 泄漏(卡死在单个 tool 内 turn_end 永不触发,limiter 失效),
230
+ * 默认关闭意味着无 maxTurns 的 spawn 若 hang 将永不自动回收——须用 SPAWN_WATCHDOG_ENV
231
+ * 显式兑底。
232
+ *
174
233
  * [MF-4] 同时是 agent_end keep-alive 的「有活跃后代」等待超时(不 kill 分支),
175
234
  * 替代旧固定 2h(WAIT_DESCENDANT_TIMEOUT_MS,已删除)——wave 开发 >2h 不被误杀。
176
- * [export] 测试可观测(run-spawn-edges MF-4 用例断言 keep-alive 等待超时 = 动态值)。
177
- *
235
+ * [export] 测试可观测(run-spawn-edges MF-4 用例断言 keep-alive 等待超时 = 动态值;
236
+ * max-turns-to-watchdog-ms.test.ts 锚定 floor/边界换算)。
237
+ *
178
238
  * @param maxTurns 调用方指定的 turn 上限;调用方保证 > 0(否则走 resolveSpawnWatchdogMs)
179
239
  */
180
- export function computeWatchdogMs(maxTurns: number): number {
240
+ export function maxTurnsToWatchdogMs(maxTurns: number): number {
181
241
  return Math.max(SPAWN_WATCHDOG_FLOOR_MS, maxTurns * WATCHDOG_MS_PER_TURN);
182
242
  }
183
243
 
@@ -196,12 +256,32 @@ export function computeWatchdogMs(maxTurns: number): number {
196
256
  */
197
257
  export const SPAWN_WATCHDOG_ENV = "XYZ_SUBAGENT_SPAWN_WATCHDOG_MS";
198
258
 
199
- /** 解析 spawn watchdog 毫秒数;env 未设/非法返回 undefined(调用方不挂 timer)。 */
259
+ /**
260
+ * [LC-9/T7②] stdout invalid 行样本留痕上限:前 N 条逐条 debug,其后仅累计计数
261
+ *(防长尾调试输出刷屏);总数与样本在 close 聚合一次性输出。
262
+ */
263
+ const MAX_INVALID_LINE_SAMPLES = 3;
264
+ /** 单条样本截断长度(pi 调试行可能超长,截断保日志可用)。 */
265
+ const INVALID_LINE_SAMPLE_MAX_LENGTH = 160;
266
+
267
+ /**
268
+ * 解析 spawn watchdog 毫秒数;env 未设返回 undefined(调用方不挂 timer)。
269
+ *
270
+ * [LC-7/T7①] env 已设但非法(非数字/<=0)同样返回 undefined——watchdog 不挂载 =
271
+ * **等价关闭**,但必须 warn 留痕:运维设 `XYZ_SUBAGENT_SPAWN_WATCHDOG_MS="30m"`
272
+ * 本意加兜底,静默失效会造成「以为有兜底、实际裸奔」(设计 §4.3 LC-7),
273
+ * 生效行为必须可见。
274
+ */
200
275
  function getEnvSpawnWatchdogMs(): number | undefined {
201
276
  const raw = process.env[SPAWN_WATCHDOG_ENV];
202
277
  if (!raw) return undefined;
203
278
  const parsed = Number(raw);
204
- if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
279
+ if (!Number.isFinite(parsed) || parsed <= 0) {
280
+ logger.warn(
281
+ `[session-runner] ${SPAWN_WATCHDOG_ENV}="${raw}" is invalid (expected a positive millisecond number) — spawn watchdog NOT armed, equivalent to disabled; set a plain ms value (e.g. 1800000) to enable`,
282
+ );
283
+ return undefined;
284
+ }
205
285
  return parsed;
206
286
  }
207
287
 
@@ -209,7 +289,7 @@ function getEnvSpawnWatchdogMs(): number | undefined {
209
289
  * 解析 spawn watchdog 超时(挂载判定的单一入口)。
210
290
  *
211
291
  * 优先级(SP-6 参数 > env,U5):maxTurns 显式传参时压过 env——有效(>0)按 turns
212
- * 估算(computeWatchdogMs);显式 0/负 = 显式不限(不挂 watchdog);仅 undefined/null
292
+ * 估算(maxTurnsToWatchdogMs);显式 0/负 = 显式不限(不挂 watchdog);仅 undefined/null
213
293
  * (未传)才落 SPAWN_WATCHDOG_ENV 兑底;env 也未设 → undefined(不挂 watchdog,不限)。
214
294
  *
215
295
  * [U1] 返回值(env 兑底或 turns 估算两条路径)流入 setTimeout 前校验安全域:
@@ -234,13 +314,30 @@ export function resolveSpawnWatchdogMs(maxTurns: number | undefined | null): num
234
314
  assertSafeTimerDelay(turns, `maxTurns=${String(maxTurns)}`);
235
315
  }
236
316
  if (turns > 0) {
237
- const estimated = computeWatchdogMs(turns);
238
- assertSafeTimerDelay(estimated, `computeWatchdogMs(maxTurns=${maxTurns})`);
317
+ const estimated = maxTurnsToWatchdogMs(turns);
318
+ assertSafeTimerDelay(estimated, `maxTurnsToWatchdogMs(maxTurns=${maxTurns})`);
239
319
  return estimated;
240
320
  }
241
321
  return undefined;
242
322
  }
243
323
 
324
+ /**
325
+ * [A1-1] keep-alive 无进展 timer 的挂载资格:仅「裸缺省」(maxTurns 未传且 env 未设)。
326
+ *
327
+ * resolveSpawnWatchdogMs 返回 undefined 有三种来源,语义不同:
328
+ * a. 裸缺省(本函数为 true)→ 挂无进展检测上界(T2-① 降级 B);
329
+ * b. 显式 maxTurns<=0(显式不限时,压过 env,U5)→ 不挂任何 timer(opt-out 保留);
330
+ * c. resolveSpawnWatchdogMs throw 的 F-R2 降级 → 不挂任何 timer(fail-fast 不静默
331
+ * 换兜底)。
332
+ * env 存在性用原始 process.env 判(不经 getEnvSpawnWatchdogMs 的 parse)——避免与
333
+ * resolveSpawnWatchdogMs 内部的 invalid-env warn 重复出声。判据是 raw falsy(undefined
334
+ * 或空串都算未设),与 getEnvSpawnWatchdogMs 对 raw 的判定逐字一致;env 已设且非空
335
+ *(含非法值如 "abc")即「显式配置」语义,非裸缺省。
336
+ */
337
+ function isBareDefaultKeepAlive(maxTurns: number | undefined | null): boolean {
338
+ return (maxTurns === undefined || maxTurns === null) && !process.env[SPAWN_WATCHDOG_ENV];
339
+ }
340
+
244
341
  /** stderr 累积上限——按字符截断(.slice 语义),非字节;64K 规模沿自原实现。
245
342
  * 防止失控子进程打满父进程内存。保留尾部便于诊断。 */
246
343
  const STDERR_MAX_CHARS = 65_536;
@@ -334,29 +431,57 @@ export const spawnedChildren = new Map<string, ChildProcess>();
334
431
  /**
335
432
  * kill 所有未退出的 spawned 子进程(dispose 兜底用)。
336
433
  *
337
- * 遍历 spawnedChildren Map values(),对每个未 killed 的子进程发 `child.kill(signal)`。
434
+ * [R1 D6③] 编排扩容:先触发 engine registry 各已实例化引擎的 dispose(常驻资源
435
+ * 归引擎所有,见 EnginePort.dispose / registry.disposeEngines),再杀 per-record
436
+ * children——顺序不可反(D6①:SIGTERM 先发会导致引擎侧 close 帧必丢)。dispose
437
+ * 触发不等待:本函数保持同步契约(宿主调用点零改动,函数签名与导出名不变),
438
+ * 引擎 dispose 的同步面(fire close 帧 + 同步 SIGTERM)由引擎实现保证,异步
439
+ * promise 段(grace→SIGKILL)的 rejection 由 registry 侧 catch 吞掉,防
440
+ * unhandledRejection 崩宿主。
441
+ *
442
+ * [R1 D6 注释契约] spawnedChildren Map 是 per-record 一次性 spawn 模态(一任务一
443
+ * 进程,key=record.id);引擎持有的常驻进程(跨任务共享)**不进本 Map**——其生命
444
+ * 周期完全归引擎 dispose 管理(边界声明见 RunContext.onChildSpawned)。常驻进程的
445
+ * 注册/回收问题在引擎层解决(R4),此处只立 Map 模态契约。
446
+ *
447
+ * 遍历 spawnedChildren Map 的 values(),对每个「未确认死亡」的子进程发信号。
338
448
  * 已退出的子进程在 close/error 事件时已从 Map 移除(按句守卫 removeChildRegistration——
339
449
  * Map 当前值仍是该 child 才删,防误删 resume spawn 的新注册),故 Map 中只剩「活着的」
340
- * 或「已被 kill 但 close 事件尚未回调的」。后者用 `child.killed`
341
- * 跳过——避免对一个已 kill 的子进程重复 kill。
450
+ * 或「已被 kill 但 close 事件尚未回调的」。
451
+ *
452
+ * [T2-⑤ / LC-2] 死亡判定按 exitCode/signalCode 而非 killed 标记——killed=true 只表示
453
+ * 「发过 kill 请求」,不等于「已死」:SIGTERM 可能被无视(卡死在不可中断 native 调用),
454
+ * 旧实现按 killed 跳过会让这类进程脱离最后一次回收窗口。现规则:
455
+ * - 已确认死亡(exitCode/signalCode 任一非 null)→ 跳过(无论 killed 与否);
456
+ * - killed 但未确认死亡(SIGTERM 已发、进程仍在)→ 直接升级 SIGKILL(dispose 是
457
+ * 最后兜底,没有 30s 升级窗口可等——killAllSpawnedChildren 保持快速返回契约);
458
+ * - 未 killed 且未确认死亡 → 发调用方指定 signal。
342
459
  *
343
460
  * 用于 SubagentService.dispose(进程退出路径):覆盖 sync 子进程(controller 为 undefined,
344
461
  * abortRunningControllers 跳过它们)。background 子进程此时已被 abortRunningControllers 经
345
- * controller.abort 路径 kill,本函数对它们的二次 kill 是无害 noop(已 killed)。
462
+ * controller.abort 路径 kill,本函数对它们的再处理是 SIGKILL 升级检查(T2-⑤ 语义),
463
+ * 对已死句柄 child.kill 返回 false 无害。
346
464
  *
347
465
  * 不 await 子进程退出(dispose 要快速返回)。
348
466
  *
349
467
  * @returns 被 kill 的子进程数(诊断用)
350
468
  */
351
469
  export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): number {
470
+ // [R1 D6③] 先全部触发引擎 dispose(含时序与等待策略说明的完整注释见函数 doc),
471
+ // 后遍历杀 per-record children。
472
+ disposeEngines();
352
473
  let n = 0;
353
474
  for (const child of spawnedChildren.values()) {
354
- // 跳过已 kill 的(killed=true 表示已调过 child.kill;已退出的在 close/error 时已从 Set 移除)。
355
- // 不依赖 exitCode/signalCode:close 事件回调可能晚于 dispose,此时它们仍为 null,但子进程
356
- // 可能已被 controller.abort 路径 kill(killed=true)。
357
- if (child.killed) continue;
475
+ // [T2-⑤ / LC-2] killed=发过 kill 请求 ≠ 已死:只有 exitCode/signalCode null
476
+ //(进程已确认死亡)才跳过。close 事件回调可能晚于 dispose 到达,此时两个字段
477
+ // 仍为 null 但进程可能已被 controller.abort 路径 kill(killed=true)——不跳过,
478
+ // 走下方 SIGKILL 升级检查(SIGTERM 可能被无视,不能赌它生效)。
479
+ // 字段缺失(?? null 兜底,如 test double)按「无法确认死亡」保守视为存活,
480
+ // 进入 kill 分支——宁多发一次无害信号,不漏一个未死进程。
481
+ const confirmedDead = (child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null;
482
+ if (confirmedDead) continue;
358
483
  try {
359
- child.kill(signal);
484
+ child.kill(child.killed ? "SIGKILL" : signal);
360
485
  n++;
361
486
  } catch (err) {
362
487
  // best-effort:单个 kill 失败不影响其他子进程(常见于进程刚退出、句柄竞态),
@@ -370,8 +495,8 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
370
495
  }
371
496
  // dispose 全量清理;正常路径的 close/error 事件 delete 保留作 per-child 精细清理,
372
497
  // 这里兑底防 close 事件漏触发的极端累积(主进程崩溃后 close 回调可能不再触发,
373
- // 不 clear 则下次 dispose 会重复向已 kill 的 child 发信号——虽然 killed=true 跳过,
374
- // Set 无限增长泄漏内存)。
498
+ // 不 clear 则下次 dispose 会重复向已 kill 的 child 发信号——虽然已确认死亡者被
499
+ // 跳过,但 Map 无限增长泄漏内存)。
375
500
  spawnedChildren.clear();
376
501
  return n;
377
502
  }
@@ -421,6 +546,205 @@ export function registerSpawnedChildForRecord(recordId: string, child: ChildProc
421
546
  child.once("error", () => removeChildRegistration(recordId, child));
422
547
  }
423
548
 
549
+ // ============================================================
550
+ // [T2-② / P-T2b 主路径] 后代级联补杀 sweep
551
+ // ============================================================
552
+ //
553
+ // 背景(设计 §7.2 T2-② + 探针 probe/p-t2b-report.md):keep-alive 上界 kill 的是层主
554
+ // 进程,其后台化 pi 后代不会随层主 SIGTERM 级联死亡(P-T2b 三次稳定复现 NO-CASCADE,
555
+ // 实装机制层 rpc-mode SIGTERM handler 只 kill tracked detached children,agent_end 后
556
+ // bash 已 untrack)。补杀时序分两步:层主确认死亡(close)后从其 sessionFile 冻结
557
+ // 快照采集活跃后代清单(此刻 pending entries 最完整,避开「kill 前采集」的垂死窗口
558
+ // 漏项),再对清单内每个后代迭代展开至叶(递归读各后代的 pending 差集)逐个
559
+ // escalation kill。
560
+
561
+ /** sweep 内 ps -p <pid> -o command= 探测的超时(处置路径一次性调用,防 ps 挂死拖住收尾)。 */
562
+ const DESCENDANT_CMDLINE_PROBE_TIMEOUT_MS = 3000;
563
+
564
+ /**
565
+ * 读目标 pid 的完整命令行(macOS + Linux 通用的 `ps -p <pid> -o command=`)。
566
+ * 返回 undefined:ps 失败 / 超时 / 空输出(进程刚死等)——调用方按「校验不过」保守跳过。
567
+ */
568
+ function readProcessCmdline(pid: number): string | undefined {
569
+ try {
570
+ const r = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
571
+ encoding: "utf-8",
572
+ timeout: DESCENDANT_CMDLINE_PROBE_TIMEOUT_MS,
573
+ });
574
+ if (r.error || r.status !== 0) return undefined;
575
+ const out = typeof r.stdout === "string" ? r.stdout.trim() : "";
576
+ return out.length > 0 ? out : undefined;
577
+ } catch {
578
+ return undefined;
579
+ }
580
+ }
581
+
582
+ /**
583
+ * cmdline 校验:目标进程须是 pi --mode rpc 形态才允许 kill。
584
+ *
585
+ * [防误杀] 层主死后窗口内 pid 可能被操作系统复用给无关进程——存活校验(isProcessAlive)
586
+ * 只证明「有进程」,cmdline 校验进一步证明「是 pi rpc 子进程」才动手(P-T2b 实测后代
587
+ * 即以 `pi --mode rpc` 形态存活,可命中)。
588
+ *
589
+ * [export] 测试可观测(descendant-sweep 用例断言 pi 形态命中 / 无关进程拒绝)。
590
+ */
591
+ export function looksLikePiRpcProcess(cmdline: string): boolean {
592
+ // pi 词形:裸命令 "pi"、路径段 ".../pi"、带扩展名的入口脚本 ".../pi.js"。
593
+ const hasPi = /(^|[\s/])pi(\.js|\.cjs|\.mjs)?(\s|$)/.test(cmdline);
594
+ // --mode rpc:分离参数("--mode rpc")与连写("--mode=rpc")两形态。
595
+ const hasRpcMode = /(^|\s)--mode[=\s]rpc(\s|$)/.test(cmdline);
596
+ return hasPi && hasRpcMode;
597
+ }
598
+
599
+ /**
600
+ * 对外部 pid(无 ChildProcess 句柄的后代进程)发 SIGTERM 并武装 SIGKILL 升级:
601
+ * PID_KILL_ESCALATION 后仍存活则 SIGKILL。语义对齐 killChildWithEscalation,
602
+ * 但后代是 detached 孤儿(非本进程 spawn 的句柄),只能按 pid 操作。
603
+ */
604
+ function killPidWithEscalation(pid: number, label: string): void {
605
+ try {
606
+ process.kill(pid, "SIGTERM");
607
+ } catch (err) {
608
+ // SIGTERM 发送即失败(进程恰死 / 权限):无需升级,诊断留痕
609
+ logger.debug(
610
+ `[session-runner] ${label}: SIGTERM to pid ${pid} failed (best-effort continue): ${
611
+ err instanceof Error ? err.message : String(err)
612
+ }`,
613
+ );
614
+ return;
615
+ }
616
+ assertSafeTimerDelay(SIGKILL_ESCALATION_MS, `descendant SIGKILL escalation (${label})`);
617
+ const escalation = setTimeout(() => {
618
+ if (isProcessAlive(pid)) {
619
+ logger.warn(
620
+ `[session-runner] ${label}: descendant pid ${pid} still alive ${
621
+ SIGKILL_ESCALATION_MS / MS_PER_SECOND
622
+ }s after SIGTERM, escalating to SIGKILL`,
623
+ );
624
+ try {
625
+ process.kill(pid, "SIGKILL");
626
+ } catch (killErr) {
627
+ // 进程在窗口内自行退出:目标已达成,ESRCH 是预期终态
628
+ void killErr;
629
+ }
630
+ }
631
+ }, SIGKILL_ESCALATION_MS);
632
+ escalation.unref();
633
+ }
634
+
635
+ /** 后代补杀结果(诊断/测试可观测)。 */
636
+ export interface DescendantSweepResult {
637
+ /** 已发 SIGTERM(挂升级)的后代 pid。 */
638
+ killed: number[];
639
+ /** 被守卫拦下的后代(pid 反查失败 / 已死 / 非 pi 形态),reason 面向排查。 */
640
+ skipped: Array<{ sessionId: string; pid?: number; reason: string }>;
641
+ }
642
+
643
+ /**
644
+ * 从层主 sessionFile 出发迭代补杀全部活跃后代(迭代至叶)。
645
+ *
646
+ * 采集:层主 sessionFile 的 pending register−unregister 差集(listActivePendingFromSessionFile,
647
+ * 与 keep-alive 判定的 count 口径共享增量游标)给出活跃后代的 sessionId 清单;每个
648
+ * sessionId 经 findSessionFileByHeaderId 反查后代 sessionFile,其自身差集继续展开
649
+ * (BFS + visited 防环)直至叶。
650
+ *
651
+ * kill 前双校验(防 pid 复用误杀):readAliveMarker 取后代 pid → isProcessAlive 存活
652
+ * 校验 → readProcessCmdline + looksLikePiRpcProcess 形态校验,通过才 escalation kill。
653
+ *
654
+ * 残余窗口(设计如实标注):个别后代 pid 反查失败时归 T5 marker 机制兜底(见下
655
+ * TODO 锚点)——marker 失真时该后代可能被孤儿恢复误终态,本函数不押注 marker 精确性。
656
+ *
657
+ * 同步实现:后代树规模有限、每步有界(ps 探测 3s 超时、文件读取增量游标),调用方
658
+ * (runSpawn 收尾)一次性执行不悬挂。
659
+ */
660
+ export function sweepDescendantsOfSession(
661
+ rootSessionFile: string | undefined,
662
+ sessionDir: string,
663
+ source: string,
664
+ ): DescendantSweepResult {
665
+ const result: DescendantSweepResult = { killed: [], skipped: [] };
666
+ if (!rootSessionFile) return result;
667
+
668
+ const visited = new Set<string>();
669
+ const queue: string[] = [rootSessionFile];
670
+ while (queue.length > 0) {
671
+ const sessionFile = queue.shift() as string;
672
+ if (visited.has(sessionFile)) continue;
673
+ visited.add(sessionFile);
674
+
675
+ const list = listActivePendingFromSessionFile(sessionFile);
676
+ if (list.error) {
677
+ // 差集读不出 = 无法证明有活跃后代:保守跳过该分支(不杀不在清单内的进程)
678
+ logger.debug(
679
+ `[session-runner] descendant sweep (${source}): pending list unreadable for ${sessionFile}: ${list.error}`,
680
+ );
681
+ continue;
682
+ }
683
+
684
+ for (const item of list.items) {
685
+ if (!item.sessionId) {
686
+ // TODO(T5 marker fallback):register entry 缺 sessionId,pid/sessionFile 反查
687
+ // 无门。兜底归 T5 的 marker 机制(后续单元实施:按 pending id 关联 alive
688
+ // marker 反查);当前仅留痕——该后代成为孤儿后由 marker/孤儿恢复收敛,存在
689
+ // 设计已标注的「marker 失真残余窗口」。
690
+ result.skipped.push({
691
+ sessionId: item.id,
692
+ reason: "pending register entry has no sessionId (marker-based fallback pending T5)",
693
+ });
694
+ continue;
695
+ }
696
+ const childFile = findSessionFileByHeaderId(sessionDir, item.sessionId);
697
+ if (!childFile) {
698
+ // TODO(T5 marker fallback):sessionDir 反查失败(session 文件未 flush / 非本
699
+ // store 树)。同上归 T5 marker 机制兜底,当前留痕。
700
+ result.skipped.push({
701
+ sessionId: item.sessionId,
702
+ reason: "session file not found in sessionDir (marker-based fallback pending T5)",
703
+ });
704
+ continue;
705
+ }
706
+ // 迭代至叶:后代自身的 pending 差集在后续轮次继续展开(visited 防环)。
707
+ queue.push(childFile);
708
+
709
+ const marker = readAliveMarker(childFile);
710
+ if (!marker) {
711
+ // 后代 sessionFile 存在但无 .alive sidecar:无法定位 pid。TODO(T5 marker
712
+ // fallback) 同上——marker 机制实施后按 record 关联补齐。
713
+ result.skipped.push({ sessionId: item.sessionId, reason: "no alive marker for session file" });
714
+ continue;
715
+ }
716
+ if (!isProcessAlive(marker.pid)) {
717
+ // 存活校验不过:后代已死(自然完成 / 随层主 cascade),无需补杀。
718
+ result.skipped.push({
719
+ sessionId: item.sessionId,
720
+ pid: marker.pid,
721
+ reason: "pid not alive (already exited)",
722
+ });
723
+ continue;
724
+ }
725
+ const cmdline = readProcessCmdline(marker.pid);
726
+ if (cmdline === undefined || !looksLikePiRpcProcess(cmdline)) {
727
+ // [防误杀] pid 复用守卫:探测失败或非 pi --mode rpc 形态一律不动手。
728
+ result.skipped.push({
729
+ sessionId: item.sessionId,
730
+ pid: marker.pid,
731
+ reason:
732
+ cmdline === undefined
733
+ ? "cmdline probe failed (ps unavailable)"
734
+ : `cmdline is not pi --mode rpc (pid reuse guard): ${cmdline}`,
735
+ });
736
+ continue;
737
+ }
738
+ logger.warn(
739
+ `[session-runner] descendant sweep (${source}): killing orphan descendant pid=${marker.pid} session=${item.sessionId} (${item.id})`,
740
+ );
741
+ killPidWithEscalation(marker.pid, `descendant sweep (${source})`);
742
+ result.killed.push(marker.pid);
743
+ }
744
+ }
745
+ return result;
746
+ }
747
+
424
748
  // ============================================================
425
749
  // 依赖注入容器 + 入参
426
750
  // ============================================================
@@ -601,9 +925,49 @@ export function applySchemaEnvToChildEnv(
601
925
  /** buildEnvBlock 的 git 命令超时(ms)。 */
602
926
  const ENV_GIT_TIMEOUT_MS = 2000;
603
927
 
604
- /** git branch 缓存(key=cwd)——避免每次 session 创建都 spawn git。 */
928
+ /**
929
+ * git branch 缓存的 LRU 上限 [LC-8/T6③]。
930
+ *
931
+ * 缓存 key 是 cwd——worktree 场景每次路径唯一,无上限则条目按 path 永久累积(长寿命
932
+ * orchestrator 内存无界,设计 §4.3 LC-8「实锤·轻微」)。64 对「同 cwd 高频 session
933
+ * 创建」的缓存收益零影响(活跃 worktree 数远小于此),仅封顶最坏形态。
934
+ */
935
+ export const BRANCH_CACHE_MAX_ENTRIES = 64;
936
+
937
+ /** git branch 缓存(key=cwd)——避免每次 session 创建都 spawn git。[LC-8] LRU 有界。 */
605
938
  const branchCache = new Map<string, string>();
606
939
 
940
+ /** [LC-8] get 命中刷新 LRU 序:重插至 Map 尾(Map 迭代序 = 插入序,首元素即最旧)。 */
941
+ function getCachedBranch(cwd: string): string | undefined {
942
+ const branch = branchCache.get(cwd);
943
+ if (branch !== undefined) {
944
+ branchCache.delete(cwd);
945
+ branchCache.set(cwd, branch);
946
+ }
947
+ return branch;
948
+ }
949
+
950
+ /** [LC-8] set 入缓存并淘汰超限的最旧条目(重 set 前先删,已存在时刷新 LRU 序)。 */
951
+ function setCachedBranch(cwd: string, branch: string): void {
952
+ branchCache.delete(cwd);
953
+ branchCache.set(cwd, branch);
954
+ while (branchCache.size > BRANCH_CACHE_MAX_ENTRIES) {
955
+ const oldest = branchCache.keys().next();
956
+ if (oldest.done === true) break; // 防御:空 Map 但 size 判定异常时退出
957
+ branchCache.delete(oldest.value);
958
+ }
959
+ }
960
+
961
+ /** 测试钩子:清空 branchCache(模块级单例状态隔离,对齐 _resetLifecycleState 先例)。 */
962
+ export function _resetBranchCacheForTest(): void {
963
+ branchCache.clear();
964
+ }
965
+
966
+ /** 测试钩子:branchCache 当前条目数(LRU 上界断言的观察点)。 */
967
+ export function _getBranchCacheSizeForTest(): number {
968
+ return branchCache.size;
969
+ }
970
+
607
971
  /**
608
972
  * 构建环境信息块(P7 防注入:环境数据标记为 data,非指令)。
609
973
  * git branch 异步获取(execFile),按 cwd 缓存——缓存命中路径返回已 resolve 值零开销,
@@ -636,7 +1000,7 @@ export async function buildEnvBlock(
636
1000
  if (depth > 0) {
637
1001
  lines.push(`Depth: ${depth}/${MAX_FORK_DEPTH}`);
638
1002
  }
639
- let branch = branchCache.get(cwd);
1003
+ let branch = getCachedBranch(cwd);
640
1004
  if (branch === undefined) {
641
1005
  // catch 兜底一切失败(含 execFile 未被 mock 的测试环境):branch 静默为空,
642
1006
  // env block 省略 Git branch 行——与旧同步版语义一致
@@ -661,7 +1025,7 @@ export async function buildEnvBlock(
661
1025
  );
662
1026
  branch = "";
663
1027
  }
664
- branchCache.set(cwd, branch);
1028
+ setCachedBranch(cwd, branch);
665
1029
  }
666
1030
  if (branch) lines.push(`Git branch: ${branch}`);
667
1031
  lines.push("--- end environment ---");
@@ -745,7 +1109,7 @@ export function buildSpawnArgs(
745
1109
  //
746
1110
  // [单写者不变量·MF-8|第五轮元审查结论] session JSONL 完整性依赖「每 session
747
1111
  // 单写进程」架构不变量:子进程写独立 subagent sessionDir(getSubagentSessionDir
748
- // 编码隔离),主 session 仅本进程单线程写。pi 0.84.1 写入原语(dist/core/
1112
+ // 编码隔离),主 session 仅本进程单线程写。pi 0.84.4 写入原语(dist/core/
749
1113
  // session-manager.js,机制登记 PS-18)只在「唯一写者」前提下原子:_persist
750
1114
  //(:724-753)首写用 wx flag 整体落盘缓冲 entry(:739),此后一律 appendFileSync
751
1115
  // 追加(:730/:751);运行时 compaction 走 appendCompaction(:803-818)→
@@ -822,6 +1186,30 @@ function writeAliveMarkerBestEffort(sessionFile: string, pid: number, id: string
822
1186
  }
823
1187
  }
824
1188
 
1189
+ /**
1190
+ * [T5② / PS-7a] keep-alive 期 agent_end 心跳:覆盖写 .alive marker 刷新软超时基准。
1191
+ *
1192
+ * 1h 软超时(ALIVE_SOFT_TIMEOUT_MS)的隐含假设是「marker 写后进程短命」——被 keep-alive
1193
+ * 打破(MF-4 数小时 keep-alive 是设计内可达态)后,活记录会被异进程孤儿恢复误盖
1194
+ * .finalized sidecar(PS-7a)。keep-alive 的每次 agent_end 重写 marker,把「实例活跃」
1195
+ * 的证明持续推新,软超时只在进程真死后才可能到期。
1196
+ *
1197
+ * [P-T5 探针裁决] 主路径成立(probe/p-t5-report.md):历史 4747 个 subagent session 回溯,
1198
+ * agent_end 密度 P95 ≈10 次/分钟,单次覆盖写 0.0315ms(56 字节)——开销可忽略,不降级
1199
+ * 软超时对齐。
1200
+ *
1201
+ * [语义登记] marker.startedAt 语义由「实例启动时刻」扩展为「最后一次活跃证明时刻」:
1202
+ * 三处软超时消费方(record-store buildRecord 活态分支 ×2 + findForeignLiveInstance)
1203
+ * 判据 `now - startedAt < ALIVE_SOFT_TIMEOUT_MS` 语义统一收紧为「最后心跳后 1h 内算活」,
1204
+ * 活跃实例不再被误判陈旧(方向安全);marker 的 pid/id 字段保持不变(id 取现有 marker
1205
+ * 值,缺失时兜底 record.id,与 finishHandshake 写点兜底一致)。
1206
+ */
1207
+ function touchAliveMarkerForHeartbeat(sessionFile: string | undefined, pid: number | undefined, recordId: string): void {
1208
+ if (!sessionFile || !pid) return;
1209
+ const existingId = readAliveMarker(sessionFile)?.id ?? recordId;
1210
+ writeAliveMarkerBestEffort(sessionFile, pid, existingId);
1211
+ }
1212
+
825
1213
  // ============================================================
826
1214
  // [SPAWN 改造] runSpawn 的阶段拆分(max-lines-per-function 383 > 300)
827
1215
  // ============================================================
@@ -859,6 +1247,25 @@ interface SpawnRunState {
859
1247
  * 是 no-op(Promise 只 resolve 一次)。
860
1248
  */
861
1249
  resolveRun: ((code: number) => void) | undefined;
1250
+ /**
1251
+ * [T2-① / P-T2 降级 B] keep-alive 裸缺省的无进展检测 timer(仅 isBareDefaultKeepAlive
1252
+ * 的 keep-alive 分支挂载):子进程 stdout 活动刷新计时,连续静默
1253
+ * KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS 且 fire 时惰性复核无存活后代才处置。undefined =
1254
+ * 未挂载(显式配置走既有固定时长 state.watchdog / 显式 opt-out 不挂任何 timer)。
1255
+ */
1256
+ keepAliveNoProgressTimer: NodeJS.Timeout | undefined;
1257
+ /**
1258
+ * [T2-② / P-T2b 主路径] keep-alive 上界处置层主后置 true:runSpawn 收尾(层主
1259
+ * close 确认死亡 + sessionFile 冻结为最终快照)时对活跃后代做级联补杀 sweep。
1260
+ */
1261
+ sweepDescendantsOnClose: boolean;
1262
+ /**
1263
+ * [T2-③ / LC-1] chatMode 首轮 settled 等待固定硬上限(timer 句柄记账在
1264
+ * settled-watchdog.ts 的 armedTimers Map,按 recordId arm/disarm;双挂载原语,
1265
+ * 热路径挂载在 subagent-service deliverMessage,u-t2b 接线)。
1266
+ */
1267
+ /** [T2-③] settled watchdog 已触发——runSpawn 收尾据此转 failed + 恢复指引(非正常完成)。 */
1268
+ settledWatchdogFired: boolean;
862
1269
  }
863
1270
 
864
1271
  /**
@@ -910,6 +1317,172 @@ function killChildWithEscalation(state: SpawnRunState, child: ChildProcess, sour
910
1317
  state.escalationTimer = escalation;
911
1318
  }
912
1319
 
1320
+ /**
1321
+ * [T2-④ / LC-2] 服务侧 kill 收敛入口:无 SpawnRunState 的调用方(subagent-service 的
1322
+ * closeChatIdle / closeAfterRoundSettled / cancelBackground / disposeAllRecords)经本函数
1323
+ * 发 SIGTERM + 武装 30s SIGKILL 升级——替代四处裸 `child.kill("SIGTERM")`。
1324
+ *
1325
+ * 背景(LC-2):SIGTERM 可能被无视(子进程卡死在不可中断 native 调用 / SIGTERM handler
1326
+ * 挂死),裸 SIGTERM 后进程不退 → record 已终态归档 → 幽灵进程,且 dispose 兜底
1327
+ * killAllSpawnedChildren 的升级检查也可能不再触达(Map 条目已随 close 移除前的窗口)。
1328
+ * 服务侧 kill 时机都在「record 即将/已经终态化」——此后再无其他回收通道,必须有升级。
1329
+ *
1330
+ * 与 runSpawn 内 killChildWithEscalation 的差异:调用方没有 SpawnRunState(runSpawn 已
1331
+ * 返回 / 从未在本进程跑),升级 timer 记账在模块级 Map(recordId → timer),子进程 exit
1332
+ * 自动清除(对齐 state.escalationTimer 的 exit-clear 语义);同 record 重复调用先清旧
1333
+ * 升级 timer 防叠加。child 不在 spawnedChildren(已 close 移除 / 从未注册)或已发过
1334
+ * kill 请求(killed=true,升级窗口已由先前路径武装)时 no-op——与旧 `child && !child.killed`
1335
+ * 守卫语义逐字对齐,仅补升级。
1336
+ */
1337
+ const serviceEscalationTimers = new Map<string, NodeJS.Timeout>();
1338
+
1339
+ export function killRecordChildWithEscalation(recordId: string, source: string): void {
1340
+ const child = spawnedChildren.get(recordId);
1341
+ if (!child || child.killed) return;
1342
+ child.kill("SIGTERM");
1343
+ assertSafeTimerDelay(SIGKILL_ESCALATION_MS, `SIGKILL escalation (${source})`);
1344
+ if (serviceEscalationTimers.has(recordId)) {
1345
+ clearTimeout(serviceEscalationTimers.get(recordId));
1346
+ }
1347
+ const escalation = setTimeout(
1348
+ () => {
1349
+ if (child.exitCode === null && child.signalCode === null) {
1350
+ logger.warn(
1351
+ `[session-runner] child ${recordId} still alive ${SIGKILL_ESCALATION_MS / MS_PER_SECOND}s after SIGTERM, escalating to SIGKILL (source: ${source})`,
1352
+ );
1353
+ child.kill("SIGKILL");
1354
+ }
1355
+ },
1356
+ SIGKILL_ESCALATION_MS,
1357
+ );
1358
+ escalation.unref();
1359
+ child.once("exit", () => {
1360
+ clearTimeout(escalation);
1361
+ if (serviceEscalationTimers.get(recordId) === escalation) {
1362
+ serviceEscalationTimers.delete(recordId);
1363
+ }
1364
+ });
1365
+ serviceEscalationTimers.set(recordId, escalation);
1366
+ }
1367
+
1368
+ /** [T2-④] 测试隔离:清空服务侧升级 timer 记账(命名对齐 _resetSettledWatchdogsForTest)。 */
1369
+ export function _resetServiceKillStateForTest(): void {
1370
+ for (const timer of serviceEscalationTimers.values()) {
1371
+ clearTimeout(timer);
1372
+ }
1373
+ serviceEscalationTimers.clear();
1374
+ }
1375
+
1376
+ /**
1377
+ * [A1-2] 层主是否有「存活且活跃」的直接后代(no-progress fire 时的惰性复核)。
1378
+ *
1379
+ * 与 descendant sweep(sweepDescendantsOfSession)同源判据:层主 sessionFile 的
1380
+ * pending register−unregister 差集(listActivePendingFromSessionFile)给出活跃后代
1381
+ * 清单,逐个经 sessionId 反查后代 sessionFile → readAliveMarker 取 pid → isProcessAlive
1382
+ * 探活;任一后代 pid 存活即视为有进展。刻意不做 sweep kill 前双校验的另一半
1383
+ *(cmdline pi 形态校验)——本函数只决定「不杀层主、再等一个复核周期」,pid 复用误报
1384
+ * 只延后节奏 30min(方向安全);pid 探不出的后代(register 缺 sessionId / 文件未
1385
+ * flush / 无 marker)不计入存活——与 sweep 同盲区,归 T5 marker 兜底,不以此永久豁免
1386
+ * 层主的无进展上界。
1387
+ *
1388
+ * 复核失败(层主 sessionFile 读不出)→ false(按无后代处置)+ warn 留痕。方向与
1389
+ * readActivePendingFromSessionFile 调用方的既有保守约定(error = 不杀)相反是刻意的:
1390
+ * 此处的「不确定」发生在已坐实的 30min 无进展之后,处置走 killChildWithEscalation
1391
+ * 升级链,外部 signal / dispose 兜底通道仍在,warn 保证行为可见。
1392
+ */
1393
+ function hasLiveActiveDescendant(sessionFile: string | undefined, sessionDir: string): boolean {
1394
+ const list = listActivePendingFromSessionFile(sessionFile);
1395
+ if (list.error) {
1396
+ logger.warn(
1397
+ `[session-runner] keep-alive no-progress re-check failed (treating as no live descendants): ${list.error}`,
1398
+ );
1399
+ return false;
1400
+ }
1401
+ for (const item of list.items) {
1402
+ if (!item.sessionId) continue;
1403
+ const childFile = findSessionFileByHeaderId(sessionDir, item.sessionId);
1404
+ if (!childFile) continue;
1405
+ const marker = readAliveMarker(childFile);
1406
+ if (marker && isProcessAlive(marker.pid)) return true;
1407
+ }
1408
+ return false;
1409
+ }
1410
+
1411
+ /**
1412
+ * [T2-① / P-T2 降级 B] 挂载(或刷新)keep-alive 裸缺省无进展检测 timer。
1413
+ *
1414
+ * 仅在 keep-alive 分支的裸缺省形态(isBareDefaultKeepAlive:无 maxTurns 无 env)挂载。
1415
+ * 刷新机制:stdout pump 的每次 data 事件经 refreshKeepAliveNoProgressTimer 重挂(先清旧)
1416
+ * ——「连续静默」由「每次活动重置计时」实现;重复 arm 不叠加(旧 timer 先 clear)。
1417
+ *
1418
+ * [A1-2] 到期不立即处置:层主 stdout 静默 ≠ 无进展(fire 回调内先复核存活后代,
1419
+ * 见 hasLiveActiveDescendant)。无存活后代才真静默处置:SIGTERM→killChildWithEscalation
1420
+ * + 置 sweepDescendantsOnClose(T2-② 后代级联补杀的两步时序前半)。
1421
+ *
1422
+ * [A1-2 补修] 重挂分支随行心跳(touchAliveMarkerForHeartbeat):本 timer 合法化的
1423
+ * 目标形态「层主静默 + 后代长跑数小时」期间层主无 agent_end,原心跳写点(agent_end
1424
+ * 处置)不再触达——marker.startedAt 停在最后一次 agent_end,超 ALIVE_SOFT_TIMEOUT_MS
1425
+ * (1h)被判陈旧 → findForeignLiveInstance 放行透明重生(活层主被双写)+ record-store
1426
+ * 孤儿恢复误终态活 record(正是 T5②/PS-7a 心跳要防的失效)。复核发现存活后代本身就是
1427
+ * 「层主仍被需要」的活跃证明,每次重挂刷新软超时基准。sessionFile 为空由心跳函数
1428
+ * 自身守卫跳过(与既有写点同语义)。
1429
+ */
1430
+ function armKeepAliveNoProgressTimer(
1431
+ state: SpawnRunState,
1432
+ child: ChildProcess,
1433
+ sessionDir: string,
1434
+ ): void {
1435
+ if (state.keepAliveNoProgressTimer) clearTimeout(state.keepAliveNoProgressTimer);
1436
+ assertSafeTimerDelay(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS, "keep-alive no-progress watchdog");
1437
+ state.keepAliveNoProgressTimer = setTimeout(() => {
1438
+ // [A1-2] fire 惰性复核:有存活活跃后代 = 有进展 → 重挂(固定 30min 再复核,
1439
+ // 直到后代死光才落处置分支)。
1440
+ if (hasLiveActiveDescendant(state.record.sessionFile, sessionDir)) {
1441
+ // [A1-2 补修] 重挂 = 层主仍被需要:随行心跳刷新 marker 软超时基准(详见函数 doc)。
1442
+ touchAliveMarkerForHeartbeat(state.record.sessionFile, child.pid, state.record.id);
1443
+ logger.debug(
1444
+ `[session-runner] keep-alive no-progress re-check: live descendant(s) present for ${state.record.id}, re-arm (cadence ${
1445
+ KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE
1446
+ } min)`,
1447
+ );
1448
+ armKeepAliveNoProgressTimer(state, child, sessionDir);
1449
+ return;
1450
+ }
1451
+ logger.warn(
1452
+ `[session-runner] keep-alive no-progress watchdog fired for ${state.record.id}: no child output for ${
1453
+ KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE
1454
+ } min and no live descendant (bare-default keep-alive without maxTurns/env), terminating`,
1455
+ );
1456
+ // [T2-②] 同 keep-alive watchdog 处置:层主 close 确认死亡后级联补杀活跃后代。
1457
+ state.sweepDescendantsOnClose = true;
1458
+ killChildWithEscalation(state, child, "keep-alive no-progress watchdog");
1459
+ }, KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
1460
+ state.keepAliveNoProgressTimer.unref();
1461
+ }
1462
+
1463
+ /**
1464
+ * [T2-①] 子进程有 stdout 活动 → 刷新静默计时。
1465
+ *
1466
+ * 未挂载(显式 maxTurns/env 的固定时长等待、显式 opt-out、非 keep-alive 阶段)时
1467
+ * no-op——刷新面严格限定在裸缺省上界,显式配置的行为不变(opt-out 语义保留)。
1468
+ */
1469
+ function refreshKeepAliveNoProgressTimer(
1470
+ state: SpawnRunState,
1471
+ child: ChildProcess,
1472
+ sessionDir: string,
1473
+ ): void {
1474
+ if (!state.keepAliveNoProgressTimer) return;
1475
+ armKeepAliveNoProgressTimer(state, child, sessionDir);
1476
+ }
1477
+
1478
+ /** [T2-①] 清除无进展检测 timer(keep-alive 重评估 / 收尾清理;未挂载时 no-op)。 */
1479
+ function disarmKeepAliveNoProgressTimer(state: SpawnRunState): void {
1480
+ if (state.keepAliveNoProgressTimer) {
1481
+ clearTimeout(state.keepAliveNoProgressTimer);
1482
+ state.keepAliveNoProgressTimer = undefined;
1483
+ }
1484
+ }
1485
+
913
1486
  /**
914
1487
  * 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
915
1488
  *
@@ -971,22 +1544,41 @@ function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void
971
1544
  // 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
972
1545
  if (isAgentSettledEvt(raw)) {
973
1546
  if (record.chatMode) {
1547
+ // [T2-③ / LC-1] settled 到达:本轮等待窗口结束,固定硬上限即清(resolveRun
1548
+ // 在本分支同点调用,天然同清)。清除必须先于后续逻辑——idle timer / 回调 /
1549
+ // resolve 抛错时 watchdog 已确保撤下,不会误杀下一个正常轮次。
1550
+ disarmSettledWatchdog(record.id);
974
1551
  // [F-R2] 本闭包经 stdout data 回调同步调用(handleSdkEvent ← attachStdoutPump):
975
1552
  // armIdleTimer → assertSafeTimerDelay fail-fast 的 throw 若逃出回调 = uncaughtException
976
- // 崩宿主。包 try/catch 降级为「不挂 idle timer」(进程回收由外部 signal / dispose 驱动),
977
- // 错误经 bestEffort("error") 可见但不升级为进程崩溃;后续 limiter.reset /
978
- // onRoundSettled / resolveRun 照常执行(本轮完成通知不因 GC timer 故障丢失)。
1553
+ // 崩宿主。包 try/catch 降级,错误经 bestEffort("error") 可见但不升级为进程崩溃;
1554
+ // 后续 limiter.reset / onRoundSettled / resolveRun 照常执行(本轮完成通知不因 GC
1555
+ // timer 故障丢失)。
1556
+ // [T4② / PS-4] 降级语义修正:旧降级「不挂 idle timer」保住了「不崩进程」,却丢掉
1557
+ // timer 承载的两个下游不变量——isIdle 放行门(hasIdleTimer=false → 轮次完成通知被
1558
+ // lifecycle-predicates 吞)与进程回收(进程活着却无 timer 永久泄漏)。现降级改为
1559
+ // 「挂 DEFAULT_IDLE_TIMEOUT_MS + warn 留痕」:非配置替换(配置错误已在 spawn 入口
1560
+ // fail-fast,见 subagent-service),此处是防御性兜底,兜底必须可见且保住不变量。
1561
+ const armIdleTimerOnTimeout = (): void => {
1562
+ // onTimeout 复用现有 kill 路径:child.kill("SIGTERM") 触发 close → close handler
1563
+ // 统一 cleanup(spawnedChildren.delete / get_stateListeners.clear / resolve)。
1564
+ // 与 agent_end handler 现有 SIGTERM 分支一致,不新造 cleanup。
1565
+ // [race-F4] 升级:idle timer SIGTERM 后挂住 → 30s 后 SIGKILL。
1566
+ const child = getChildByRecord(record.id);
1567
+ if (child && !child.killed) killChildWithEscalation(state, child, "idle timer");
1568
+ };
979
1569
  try {
980
- armIdleTimer(record.id, () => {
981
- // onTimeout 复用现有 kill 路径:child.kill("SIGTERM") 触发 close → close handler
982
- // 统一 cleanup(spawnedChildren.delete / get_stateListeners.clear / resolve)。
983
- // 与 agent_end handler 现有 SIGTERM 分支一致,不新造 cleanup。
984
- // [race-F4] 升级:idle timer SIGTERM 后挂住 → 30s 后 SIGKILL。
985
- const child = getChildByRecord(record.id);
986
- if (child && !child.killed) killChildWithEscalation(state, child, "idle timer");
987
- }, record.idleTimeoutMs);
1570
+ armIdleTimer(record.id, armIdleTimerOnTimeout, record.idleTimeoutMs);
988
1571
  } catch (err) {
989
1572
  bestEffort(err, "armIdleTimer (agent_settled chatMode)", "error");
1573
+ try {
1574
+ armIdleTimer(record.id, armIdleTimerOnTimeout, DEFAULT_IDLE_TIMEOUT_MS);
1575
+ logger.warn(
1576
+ `[session-runner] idleTimeoutMs invalid for ${record.id}, fell back to DEFAULT_IDLE_TIMEOUT_MS (${DEFAULT_IDLE_TIMEOUT_MS}ms) — idle GC and round notification gate stay active`,
1577
+ );
1578
+ } catch (fallbackErr) {
1579
+ // 双重失败(理论上不可达:DEFAULT 恒在安全域内)——退回旧「不挂」语义但留痕。
1580
+ bestEffort(fallbackErr, "armIdleTimer fallback (agent_settled chatMode)", "error");
1581
+ }
990
1582
  }
991
1583
  // [SP-9] chatMode 每轮 reset turn-limiter:新一轮开始(续聊)时,
992
1584
  // maxTurns/graceTurns 不跨轮累计(续聊本质是无限轮,累计上限违背 G1)。
@@ -1211,8 +1803,12 @@ function splitRecordModelRef(model: string): SpawnModelRef {
1211
1803
 
1212
1804
  /** attachStdoutPump 返回的共享句柄(waitForChildExit / get_state 握手启动消费)。 */
1213
1805
  interface StdoutPumpHandles {
1214
- /** get_state RPC response 监听器注册(performGetStateHandshake 经此挂 resolver)。 */
1215
- registerGetStateListener(id: string, resolver: (data: unknown) => void): void;
1806
+ /**
1807
+ * get_state RPC response 监听器注册(performGetStateHandshake / requestGetStateOnce 经此挂 resolver)。
1808
+ * 返回注销函数(从监听表移除该 resolver)——requestGetStateOnce 自清理消费;
1809
+ * performGetStateHandshake 忽略返回值(条目由 close 统一清,既有语义)。
1810
+ */
1811
+ registerGetStateListener(id: string, resolver: (data: unknown) => void): () => void;
1216
1812
  /** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
1217
1813
  finishHandshake(r: GetStateResult): void;
1218
1814
  /** 立即放弃握手(close handler 用:子进程已退出,response 不会再来)。 */
@@ -1223,10 +1819,185 @@ interface StdoutPumpHandles {
1223
1819
  readonly handshakeSettled: Promise<void>;
1224
1820
  /** 处理 stdout 末尾残留行(无换行结尾的最后一段,close handler 用)。 */
1225
1821
  processTrailingLine(): void;
1822
+ /** [LC-9/T7②] 本子进程生命周期内 stdout invalid 行累计数(可观测性出口)。 */
1823
+ invalidLineCount(): number;
1226
1824
  /** 清空 get_state 监听器(子进程已退出,无更多 response)。 */
1227
1825
  clearGetStateListeners(): void;
1228
1826
  }
1229
1827
 
1828
+ /**
1829
+ * [T1/RC-1+RC-2] agent_end 处置决策(非 chatMode、willRetry=false)。异步化以支持惰性回补。
1830
+ *
1831
+ * ① 惰性回补:record.sessionFile 缺失(RC-1 形态:RPC mode 的 get_state 握手 7s 预算
1832
+ * 一次性耗尽后永不再试,sessionFile 成为永久缺失)时,现场向 idle 子进程单次
1833
+ * get_state(此刻 turn 已完成,探针 P-T1 实证应答 0.3-0.4ms,预算 1s 量级)。
1834
+ * 回填 record.sessionFile + 写 alive marker + 补 handshakeResult.sessionId(对齐
1835
+ * finishHandshake 的回填面)后走正常三分支——「有后代 keep-alive / 无后代 final
1836
+ * kill / 读不出保守不杀」不再被一次性握手失败劫持进保守分支。
1837
+ * ② 回补失败(超时 / 空 response / stdin 已断同步 throw)不重试:readActivePending 对
1838
+ * undefined 返回 error → 既有保守分支(行为不劣化)。决策点不变成第二个重试循环。
1839
+ *
1840
+ * fire-and-forget 契约:调用点在 stdout 同步回调链内,rejection 无人接 = unhandledRejection。
1841
+ * 内部唯一 await 对象 requestGetStateOnce 按契约永不 reject(同步写失败转空结果);
1842
+ * 同步段不抛(resolveSpawnWatchdogMs 的 fail-fast 已由 [F-R2] try/catch 承接)。
1843
+ *
1844
+ * 竞态边界:决策延迟窗口内 child 可能已被 abort/watchdog kill——回补前按 child.killed
1845
+ * 跳过(killed 后无回补意义);后续 kill 对已死句柄 no-op,timer 收尾统一 clearTimeout。
1846
+ */
1847
+ async function runAgentEndDisposition(
1848
+ state: SpawnRunState,
1849
+ child: ChildProcessWithoutNullStreams,
1850
+ sessionDir: string,
1851
+ registerGetStateListener: AddGetStateResponseListener,
1852
+ ): Promise<void> {
1853
+ const { record } = state;
1854
+
1855
+ if (!record.sessionFile && !child.killed) {
1856
+ await backfillSessionFileViaGetState(state, child, registerGetStateListener);
1857
+ }
1858
+
1859
+ // [A1-3] 回补 await 的异步窗口内 child 可能已死(close / abort / watchdog)。进程已死
1860
+ // 则 close 收尾已完成 timer 清理与句柄移除,三分支不再执行——否则 keep-alive 分支会
1861
+ // re-arm 泄漏 timer、touch marker 向死 pid 写心跳,final kill / warn 在已收尾进程上
1862
+ // 误导排查。存活判据用 exitCode/signalCode 双 null(close 后即非 null),不用
1863
+ // child.killed——它只表示「收到过 kill 请求」,close 之后恒 true,区分不了生死。
1864
+ if (child.exitCode !== null || child.signalCode !== null) return;
1865
+
1866
+ // ── 以下三分支与同步化前逐行一致(仅随函数迁移)──
1867
+ const pending = readActivePendingFromSessionFile(record.sessionFile);
1868
+ if (pending.count > 0 || pending.error) {
1869
+ keepAliveOnAgentEnd(state, child, sessionDir, pending);
1870
+ } else if (pending.recentUnregister) {
1871
+ keepAliveForWakeupGrace(state, child);
1872
+ } else {
1873
+ disarmKeepAliveNoProgressTimer(state);
1874
+ killChildWithEscalation(state, child, "agent_end final kill");
1875
+ }
1876
+ }
1877
+
1878
+ /**
1879
+ * [T1/RC-1+RC-2] 惰性回补:record.sessionFile 缺失时向 idle 子进程单次 get_state,
1880
+ * 回填 sessionFile + 写 alive marker + 补 handshakeResult.sessionId(对齐
1881
+ * finishHandshake 的回填面)。回补失败不重试(决策点不变成第二个重试循环)。
1882
+ */
1883
+ async function backfillSessionFileViaGetState(
1884
+ state: SpawnRunState,
1885
+ child: ChildProcessWithoutNullStreams,
1886
+ registerGetStateListener: AddGetStateResponseListener,
1887
+ ): Promise<void> {
1888
+ const { record } = state;
1889
+ const r = await requestGetStateOnce(child, registerGetStateListener, LAZY_GET_STATE_TIMEOUT_MS);
1890
+ // 仅当本次回补拿到且此前仍缺失时回填(与 finishHandshake 的 !record.sessionFile 守卫一致)。
1891
+ if (r.sessionFile && !record.sessionFile) {
1892
+ record.sessionFile = r.sessionFile;
1893
+ if (child.pid) {
1894
+ writeAliveMarkerBestEffort(r.sessionFile, child.pid, r.sessionId ?? record.id);
1895
+ }
1896
+ logger.warn(
1897
+ `[session-runner] agent_end: sessionFile backfilled via lazy get_state (spawn handshake had failed): ${r.sessionFile}`,
1898
+ );
1899
+ }
1900
+ // sessionId 一并补入 handshakeResult:close 路径 LC-4 兜底查找的 lookupId 来源。
1901
+ if (r.sessionId && !state.handshakeResult?.sessionId) {
1902
+ state.handshakeResult = { ...state.handshakeResult, sessionId: r.sessionId };
1903
+ }
1904
+ }
1905
+
1906
+ /**
1907
+ * keep-alive 分支(有活跃后代 / 读不出保守不杀):心跳 + 清原 watchdog 换等待后代超时。
1908
+ * 空闲等待期间不消耗 turn(每次 agent_end 重新计时)。
1909
+ * [MF-4] 动态超时 = maxTurnsToWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
1910
+ * 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
1911
+ * killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
1912
+ * [A1-1 挂载面三分] keepAliveMs === undefined 的三种来源语义不同,只有裸缺省挂
1913
+ * 无进展上界:显式 maxTurns>0 → 固定时长动态 watchdog(不变);裸缺省(maxTurns
1914
+ * 未传且 env 未设)→ [T2-① / P-T2 降级 B] 挂无进展检测上界;显式 maxTurns<=0(显式
1915
+ * 不限时,压过 env,U5)与 resolveSpawnWatchdogMs fail-fast 降级 → 维持旧「不
1916
+ * re-arm(等待后代不限时)」语义——opt-out 通道保留,无进展 timer 的挂载面严格
1917
+ * 限定裸缺省(isBareDefaultKeepAlive)。
1918
+ * [F-R2] resolveSpawnWatchdogMs → assertSafeTimerDelay fail-fast 的 throw 不升级为
1919
+ * 进程崩溃:包 try/catch 降级为「不 re-arm」(与显式 opt-out 同归「不挂 timer」,
1920
+ * 不落入裸缺省分支),错误经 bestEffort("error") 可见。
1921
+ */
1922
+ function keepAliveOnAgentEnd(
1923
+ state: SpawnRunState,
1924
+ child: ChildProcessWithoutNullStreams,
1925
+ sessionDir: string,
1926
+ pending: ActivePendingResult,
1927
+ ): void {
1928
+ const { record, opts } = state;
1929
+ // [T5② / PS-7a] keep-alive 心跳:决定保活即刷新 .alive marker(软超时基准推新,
1930
+ // 防 keep-alive 数小时的活记录被异进程孤儿恢复误终态;P-T5 探针裁决写盘开销可忽略)。
1931
+ touchAliveMarkerForHeartbeat(record.sessionFile, child.pid, record.id);
1932
+ if (pending.error) {
1933
+ logger.warn(
1934
+ `[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
1935
+ );
1936
+ } else {
1937
+ logger.debug(
1938
+ `[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
1939
+ );
1940
+ }
1941
+ clearTimeout(state.watchdog);
1942
+ disarmKeepAliveNoProgressTimer(state);
1943
+ // 裸缺省判定必须在 try 之前做:env 原始存在性检查(不经 parse),resolveSpawnWatchdogMs
1944
+ // 内部的 invalid-env warn 不因此重复出声。
1945
+ const bareDefaultKeepAlive = isBareDefaultKeepAlive(opts.maxTurns);
1946
+ let keepAliveMs: number | undefined;
1947
+ try {
1948
+ keepAliveMs = resolveSpawnWatchdogMs(opts.maxTurns);
1949
+ } catch (err) {
1950
+ bestEffort(err, "resolveSpawnWatchdogMs (agent_end keep-alive re-arm)", "error");
1951
+ keepAliveMs = undefined;
1952
+ }
1953
+ if (keepAliveMs !== undefined) {
1954
+ state.watchdog = setTimeout(() => {
1955
+ // [T2-② / P-T2b 主路径] keep-alive 上界处置层主的两步时序前半:kill 层主;
1956
+ // close(确认死亡 + sessionFile 冻结为最终快照)后由 runSpawn 收尾 sweep
1957
+ // 活跃后代(后半)。SIGTERM 对后台化 pi 后代无级联(P-T2b NO-CASCADE 三次
1958
+ // 复现),补杀必须显式做,不能押注子进程自行级联。
1959
+ state.sweepDescendantsOnClose = true;
1960
+ killChildWithEscalation(state, child, "keep-alive watchdog");
1961
+ }, keepAliveMs);
1962
+ state.watchdog.unref();
1963
+ } else if (bareDefaultKeepAlive) {
1964
+ // [T2-① / P-T2 降级路径 B] 裸缺省(无 maxTurns 无 env):挂无进展检测上界。
1965
+ // P-T2 探针实证固定 30min 上限会误杀 96.6% 真实 keep-alive(长尾 95.5h 合法),
1966
+ // 上界语义改为「连续静默达 KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS 且复核无存活后代
1967
+ // 才处置」——子进程 stdout 活动刷新计时,后代集合变化由 fire 时惰性复核承接
1968
+ //(hasLiveActiveDescendant),真实 keep-alive 不被时长上限误杀,纯静默且无
1969
+ // 存活后代的 wedged 层主仍有界回收。回收由外部 signal / dispose / 后代自然完成
1970
+ // 驱动的旧兜底通道全部保留。
1971
+ armKeepAliveNoProgressTimer(state, child, sessionDir);
1972
+ }
1973
+ }
1974
+
1975
+ /**
1976
+ * recentUnregister 分支:差集 0 但最近有 unregister——后代刚完成,notify 唤醒可能
1977
+ * 在路上(竞态窗口),保持进程——父被唤醒后的下一次 agent_end 会正常判定。
1978
+ */
1979
+ function keepAliveForWakeupGrace(
1980
+ state: SpawnRunState,
1981
+ child: ChildProcessWithoutNullStreams,
1982
+ ): void {
1983
+ const { record } = state;
1984
+ // [T5② / PS-7a] 保活分支同样心跳(进程仍活,软超时基准应推新)。
1985
+ touchAliveMarkerForHeartbeat(record.sessionFile, child.pid, record.id);
1986
+ // [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
1987
+ // 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
1988
+ // 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
1989
+ logger.debug(
1990
+ "[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
1991
+ );
1992
+ clearTimeout(state.watchdog);
1993
+ disarmKeepAliveNoProgressTimer(state);
1994
+ state.watchdog = setTimeout(
1995
+ () => killChildWithEscalation(state, child, "wakeup grace timer"),
1996
+ WAKEUP_GRACE_MS,
1997
+ );
1998
+ state.watchdog.unref();
1999
+ }
2000
+
1230
2001
  /**
1231
2002
  * stdout pump + get_state 握手状态机(原 runSpawn 内联的 stdout data handler 整体迁入)。
1232
2003
  *
@@ -1249,6 +2020,29 @@ function attachStdoutPump(
1249
2020
  const get_stateListeners = new Map<string, (data: unknown) => void>();
1250
2021
  let stdoutBuffer = "";
1251
2022
 
2023
+ // [LC-9/T7②] stdout invalid 行可见性:per-child 计数 + debug 级前 N 条样本留痕。
2024
+ // 容错原则不变(invalid 行不中断流——stdout 可能有调试输出),但「事件行损坏被
2025
+ // 静默丢弃」曾使 LC-1 形态 (c) 完全不可排查(设计 §4.3 LC-9)。前 N 条逐条 debug,
2026
+ // 之后仅累计(防刷屏);总数与样本在 close 路径(processTrailingLine)聚合输出,
2027
+ // 并经 StdoutPumpHandles.invalidLineCount() 暴露给测试/调用方。
2028
+ let invalidLineCount = 0;
2029
+ const invalidLineSamples: string[] = [];
2030
+ const recordInvalidLine = (line: string, reason: string): void => {
2031
+ invalidLineCount++;
2032
+ const truncated =
2033
+ line.length > INVALID_LINE_SAMPLE_MAX_LENGTH
2034
+ ? `${line.slice(0, INVALID_LINE_SAMPLE_MAX_LENGTH)}…`
2035
+ : line;
2036
+ if (invalidLineSamples.length < MAX_INVALID_LINE_SAMPLES) {
2037
+ invalidLineSamples.push(truncated);
2038
+ }
2039
+ if (invalidLineCount <= MAX_INVALID_LINE_SAMPLES) {
2040
+ logger.debug(
2041
+ `[session-runner] stdout invalid line #${invalidLineCount} dropped (${reason}): ${truncated}`,
2042
+ );
2043
+ }
2044
+ };
2045
+
1252
2046
  // [#18] 握手状态变量在 stdout handler 注册之前定义,消除"handler 闭包依赖同 tick
1253
2047
  // 后续 const 初始化"的隐式顺序假设——handler 现在直接引用已初始化的变量,不靠
1254
2048
  // "data 事件必然在下一 tick 才触发"的运行时不变式兜底。
@@ -1276,6 +2070,14 @@ function attachStdoutPump(
1276
2070
  };
1277
2071
 
1278
2072
  child.stdout.on("data", (data: string) => {
2073
+ // [T2-① / P-T2 降级 B] 子进程有输出 = keep-alive 仍有进展迹象:刷新静默计时。
2074
+ // 任何 stdout 活动(header / 事件行 / invalid 调试行)都算——keep-alive 的合法性
2075
+ // 由「仍在活动」定义(P-T2 探针裁决:真实 keep-alive 96.6% 超 30min,合法性不看
2076
+ // 时长看活动)。[A1-2] stdout 刷新面只覆盖「层主自己有输出」半边:「直接后代跑
2077
+ // >30min、层主静默」的合法形态刷新不到——由 no-progress fire 时的惰性复核承接
2078
+ //(armKeepAliveNoProgressTimer 内 hasLiveActiveDescendant)。未挂载(显式
2079
+ // maxTurns/env / opt-out)时 no-op。
2080
+ refreshKeepAliveNoProgressTimer(state, child, sessionDir);
1279
2081
  stdoutBuffer += data;
1280
2082
  const lines = stdoutBuffer.split("\n");
1281
2083
  stdoutBuffer = lines.pop() ?? ""; // 保留最后未完整行
@@ -1324,74 +2126,24 @@ function attachStdoutPump(
1324
2126
  if (isAgentEndEvt(evt)) {
1325
2127
  if (evt.willRetry) {
1326
2128
  // agent 会重试,不能 kill。
1327
- } else {
2129
+ } else if (record.chatMode) {
1328
2130
  // [V2 决策 1] chatMode:对话模式进程不因轮次死。agent_end 不 kill、不 MF-3/MF-4。
1329
2131
  // 等待 agent_settled(真空闲信号)arm idle timer + notify(onRoundSettled)。
1330
2132
  // 用 continue 而非 return:return 会跳出 stdout data handler 的 for(line) 循环,
1331
2133
  // 丢弃同一 flush 内 agent_end 之后的事件(如紧随的 agent_settled)。continue 只
1332
2134
  // 跳过当前行剩余(handleSdkEvent 对 agent_end 是 no-op),继续处理后续行。
1333
- if (record.chatMode) {
1334
- continue;
1335
- }
2135
+ continue;
2136
+ } else {
1336
2137
  // [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
1337
2138
  // (pending:register − unregister 差集)。有活跃后代(background subagent /
1338
2139
  // workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
1339
2140
  // 无 → 正常完成,kill 触发 close → runSpawn resolve。
1340
- const pending = readActivePendingFromSessionFile(record.sessionFile);
1341
- if (pending.count > 0 || pending.error) {
1342
- if (pending.error) {
1343
- logger.warn(
1344
- `[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
1345
- );
1346
- } else {
1347
- logger.debug(
1348
- `[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
1349
- );
1350
- }
1351
- // 空闲等待期间不消耗 turn:清原 watchdog,换等待后代超时(每次 agent_end 重新计时)。
1352
- // [MF-4] 动态超时 = computeWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
1353
- // 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
1354
- // killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
1355
- // [预算语义对齐 + U5] maxTurns 未传且 env 未设,或显式 <=0(压过 env)→
1356
- // 不 re-arm(等待后代不限时);回收由外部 signal / dispose / 后代自然完成驱动。
1357
- // [F-R2] 本分支在 child.stdout.on("data") 同步回调链内:resolveSpawnWatchdogMs →
1358
- // assertSafeTimerDelay fail-fast 的 throw 若逃出回调 = Node uncaughtException 崩宿主。
1359
- // 此处包 try/catch 降级为「不 re-arm」(与 keepAliveMs === undefined 的既有降级语义
1360
- // 一致——回收由外部 signal / dispose / 后代自然完成驱动):fail-fast 语义保留
1361
- // (错误经 bestEffort("error") 可见、行为明确),但不升级为进程崩溃。
1362
- clearTimeout(state.watchdog);
1363
- let keepAliveMs: number | undefined;
1364
- try {
1365
- keepAliveMs = resolveSpawnWatchdogMs(opts.maxTurns);
1366
- } catch (err) {
1367
- bestEffort(err, "resolveSpawnWatchdogMs (agent_end keep-alive re-arm)", "error");
1368
- keepAliveMs = undefined;
1369
- }
1370
- if (keepAliveMs !== undefined) {
1371
- state.watchdog = setTimeout(
1372
- () => killChildWithEscalation(state, child, "keep-alive watchdog"),
1373
- keepAliveMs,
1374
- );
1375
- state.watchdog.unref();
1376
- }
1377
- } else if (pending.recentUnregister) {
1378
- // 差集 0 但最近有 unregister:后代刚完成,notify 唤醒可能在路上(竞态窗口),
1379
- // 保持进程——父被唤醒后的下一次 agent_end 会正常判定。
1380
- // [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
1381
- // 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
1382
- // 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
1383
- logger.debug(
1384
- "[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
1385
- );
1386
- clearTimeout(state.watchdog);
1387
- state.watchdog = setTimeout(
1388
- () => killChildWithEscalation(state, child, "wakeup grace timer"),
1389
- WAKEUP_GRACE_MS,
1390
- );
1391
- state.watchdog.unref();
1392
- } else {
1393
- killChildWithEscalation(state, child, "agent_end final kill");
1394
- }
2141
+ //
2142
+ // [T1/RC-1] 处置决策异步化(fire-and-forget):sessionFile 缺失(RC-1 握手失败
2143
+ // 形态)时现场惰性 get_state 回补后再判定,见 runAgentEndDisposition。原同步
2144
+ // 三分支逐行迁入该函数;异步化后决策最晚 1s(回补超时预算)落地,期间子进程
2145
+ // 仍在原 watchdog 保护下,kill 延迟无语义影响。
2146
+ void runAgentEndDisposition(state, child, sessionDir, registerGetStateListener);
1395
2147
  }
1396
2148
  }
1397
2149
  if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
@@ -1407,15 +2159,29 @@ function attachStdoutPump(
1407
2159
  } else if (parsed.kind === "extension_ui_request") {
1408
2160
  // W3: 子进程发 UI 请求(ask_user)。入队 FIFO 串行处理,防止并发询问用户。
1409
2161
  enqueueUiRequest(parsed.id, parsed.request);
2162
+ } else {
2163
+ // [LC-9/T7②] invalid 行(非法 JSON / 缺 type 字段):不中断流(stdout 可能有
2164
+ // 调试输出),但不再静默——计数 + debug 样本留痕(防刷屏:前 N 条逐条、
2165
+ // 其后仅累计),close 时聚合输出总数。
2166
+ recordInvalidLine(parsed.raw, parsed.error);
1410
2167
  }
1411
- // invalid 行忽略(stdout 可能有调试输出)
1412
2168
  }
1413
2169
  });
1414
2170
 
2171
+ /** get_state 监听器注册(返回注销函数供 requestGetStateOnce 自清理;见 StdoutPumpHandles)。 */
2172
+ const registerGetStateListener = (
2173
+ id: string,
2174
+ resolver: (data: unknown) => void,
2175
+ ): (() => void) => {
2176
+ get_stateListeners.set(id, resolver);
2177
+ // 按句守卫删除:resolver 已被同 id 覆盖(理论不发生——reqId 是 UUID)时不误删新条目。
2178
+ return () => {
2179
+ if (get_stateListeners.get(id) === resolver) get_stateListeners.delete(id);
2180
+ };
2181
+ };
2182
+
1415
2183
  return {
1416
- registerGetStateListener: (id, resolver) => {
1417
- get_stateListeners.set(id, resolver);
1418
- },
2184
+ registerGetStateListener,
1419
2185
  finishHandshake,
1420
2186
  abandonHandshake: settleHandshakeNow,
1421
2187
  isHandshakePending: () => settleHandshake !== undefined,
@@ -1426,9 +2192,21 @@ function attachStdoutPump(
1426
2192
  const parsed = parseSpawnLine(stdoutBuffer);
1427
2193
  if (parsed?.kind === "event" && isSdkEvent(parsed.event)) {
1428
2194
  handleSdkEvent(parsed.event);
2195
+ } else if (parsed?.kind === "invalid") {
2196
+ // [LC-9/T7②] 残留行同计 invalid 统计(处理行为不变:event 以外仍不分发)。
2197
+ recordInvalidLine(parsed.raw, parsed.error);
1429
2198
  }
1430
2199
  }
2200
+ // [LC-9/T7②] close 聚合:本子进程生命周期的 invalid 行总数在此暴露一次
2201
+ //(processTrailingLine 由 close handler 必经调用),样本随行——LC-1 形态 (c)
2202
+ //「事件行损坏被静默丢弃」的排查入口。
2203
+ if (invalidLineCount > 0) {
2204
+ logger.debug(
2205
+ `[session-runner] stdout had ${invalidLineCount} invalid line(s) dropped in total; sample(s): ${invalidLineSamples.join(" | ")}`,
2206
+ );
2207
+ }
1431
2208
  },
2209
+ invalidLineCount: () => invalidLineCount,
1432
2210
  clearGetStateListeners: () => {
1433
2211
  get_stateListeners.clear();
1434
2212
  },
@@ -1454,6 +2232,9 @@ function waitForChildExit(
1454
2232
  // [C1] 子进程已退出,从 orphan-tracking Map 移除(dispose 兜底无需再 kill 它)。
1455
2233
  // [M4] 按值守卫:close 事件晚于 resume spawn 到达时不误删新 child 注册。
1456
2234
  removeChildRegistration(state.record.id, child);
2235
+ // [T2-③ / LC-1] close:settled 等待窗口必然结束(含首轮 settled watchdog 自身
2236
+ // 触发 kill 后的 close——timer 已触发执行完毕,clear 幂等无害)。
2237
+ disarmSettledWatchdog(state.record.id);
1457
2238
  // FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
1458
2239
  pump.clearGetStateListeners();
1459
2240
  // FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
@@ -1463,6 +2244,14 @@ function waitForChildExit(
1463
2244
  // await 立即返回(上方已 settle):保证 header 加速路径或 get_state response 已
1464
2245
  // 完成的回填结果对后续 identity 写入可见。
1465
2246
  await pump.handshakeSettled;
2247
+ // [LC-6/T6②] 进程 close 回收其 sessionFile 的 pending 增量游标(cursors Map 按
2248
+ // 「进程 close」剪枝的接线点,设计 §7.2 T6②):进程死后该文件不再有 agent_end
2249
+ // 判定,cursor 只会滞留。必须放在 handshakeSettled 之后——sessionFile 回填完成才
2250
+ // 拿得到剪枝键;未回填(快速失败)跳过,此时也不存在 cursor(判定从未发生)。
2251
+ // error 事件路径不重复剪:spawn 失败时 Node 必发 close,此处单点覆盖。
2252
+ if (state.record.sessionFile) {
2253
+ prunePendingCursor(state.record.sessionFile);
2254
+ }
1466
2255
  // 处理 stdout 末尾残留行
1467
2256
  pump.processTrailingLine();
1468
2257
  resolve(code ?? 0);
@@ -1522,6 +2311,9 @@ export async function runSpawn(
1522
2311
  sessionHeader: undefined,
1523
2312
  handshakeResult: undefined,
1524
2313
  resolveRun: undefined,
2314
+ keepAliveNoProgressTimer: undefined,
2315
+ sweepDescendantsOnClose: false,
2316
+ settledWatchdogFired: false,
1525
2317
  };
1526
2318
 
1527
2319
  // a/b. 事件累积器(pendingTools 寄存器 + turnLimiter + handleSdkEvent/agentEvent 闭包)
@@ -1538,7 +2330,7 @@ export async function runSpawn(
1538
2330
  //
1539
2331
  // [单写者不变量·MF-8|第五轮元审查结论] session JSONL 完整性依赖「每 session 单写
1540
2332
  // 进程」架构不变量:本目录是子进程专属 sessionDir,session 文件写入方仅此子进程
1541
- //(单进程单线程);主进程只读(扫描/重建/统计),绝不写。pi 0.84.1 的写入原语
2333
+ //(单进程单线程);主进程只读(扫描/重建/统计),绝不写。pi 0.84.4 的写入原语
1542
2334
  //(session-manager.js,完整锚点见 buildSpawnArgs 注释 / PS-18):_persist
1543
2335
  //(:724-753)首写 wx flag + 后续 appendFileSync 追加;compaction 为 append-only
1544
2336
  // 追加(appendCompaction :803-818),截断重写 _rewriteFile(:693-705)只在加载期
@@ -1652,6 +2444,22 @@ export async function runSpawn(
1652
2444
  // 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
1653
2445
  sendPromptCommand(child, task);
1654
2446
 
2447
+ // [T2-③ / LC-1] chatMode 首轮 settled 等待固定硬上限(双挂载原语之首轮调用点;
2448
+ // 热路径调用点在 subagent-service deliverMessage,u-t2b 接线)。prompt 发出后挂、
2449
+ // settled 到达(handleSdkEvent)/ close(waitForChildExit)/ resolveRun 任一发生
2450
+ // 即清——settled 永不到达(事件行丢失 / 子进程 wedged)时本 timer 是唯一独立
2451
+ // 回收通道。到期处置:kill 层主 + settledWatchdogFired 标记(收尾据此转 failed
2452
+ // + 恢复指引,见下方 success 判定),与被信号终止视为正常完成的既有语义区分。
2453
+ if (record.chatMode) {
2454
+ armSettledWatchdog(record.id, () => {
2455
+ logger.warn(
2456
+ `[session-runner] settled watchdog fired for ${record.id}: no agent_settled within ${SETTLED_WATCHDOG_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE} min of first-round prompt, terminating (LC-1 wedge recovery)`,
2457
+ );
2458
+ state.settledWatchdogFired = true;
2459
+ killChildWithEscalation(state, child, "settled watchdog");
2460
+ });
2461
+ }
2462
+
1655
2463
  // d. signal → proc.kill 监听(一次性,替代 session.abort)
1656
2464
  // [race-F4] 用户取消同样升级:SIGTERM 后挂住 → 30s 后 SIGKILL 兑现取消语义。
1657
2465
  const onAbort = (): void => {
@@ -1664,7 +2472,7 @@ export async function runSpawn(
1664
2472
 
1665
2473
  // e. watchdog:子进程整体超时兜底。卡死在单 tool 内(turn_end 永不触发)时
1666
2474
  // limiter 失效,此 timer 保证最终 SIGTERM,防止 background 槽位/资源泄漏。
1667
- // [M-1] timeout 基于 maxTurns 动态计算(computeWatchdogMs):旧实现固定 30 分钟
2475
+ // [M-1] timeout 基于 maxTurns 动态计算(maxTurnsToWatchdogMs):旧实现固定 30 分钟
1668
2476
  // 误杀长任务,现按 maxTurns 线性估算(每 turn ~5 分钟,下限 30 分钟)。
1669
2477
  // [预算语义对齐] maxTurns 未传/<=0 → 不挂 watchdog(不限,用户明确裁决——旧实现按
1670
2478
  // 10 turns 估出 50min 默认 SIGTERM 且 maxTurns:0 也关不掉,已废);
@@ -1729,28 +2537,66 @@ export async function runSpawn(
1729
2537
  // [race-F4] 兑底清升级 timer(exit 事件自动 clear 的双保险:close 先于升级触发的
1730
2538
  // 竞态窗口内不误杀下一个占用同 state 的子进程)
1731
2539
  clearTimeout(state.escalationTimer);
2540
+ // [T2-①③] 收尾兜底清新增 timer(正常清除点已覆盖;防收尾路径遗漏泄漏)
2541
+ disarmKeepAliveNoProgressTimer(state);
2542
+ disarmSettledWatchdog(record.id);
1732
2543
 
1733
2544
  // [持久化 A] sessionFile 兜底校验。
1734
2545
  // identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
1735
2546
  // 父进程不再 fs 补写——fs 补写的 entry 缺 id/parentId 污染 pi _buildIndex。
1736
2547
  // 此处仅保留 sessionFile 路径兜底(deriveSessionFilePath/握手路径可能不准)。
1737
- if (record.sessionFile) {
1738
- // 兜底:deriveSessionFilePath 推导或握手返回的路径可能不存在(pi 命名规则变化),
1739
- // sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
1740
- // sessionId 来源:header(json mode)优先,其次握手结果(RPC mode)。
1741
- if (!fs.existsSync(record.sessionFile)) {
1742
- const lookupId = state.sessionHeader?.id ?? state.handshakeResult?.sessionId;
1743
- if (lookupId) {
1744
- const actual = findSessionFileByHeaderId(sessionDir, lookupId);
1745
- if (actual) record.sessionFile = actual;
2548
+ //
2549
+ // [T1/LC-4] findSessionFileByHeaderId 兜底查找移出 `if (record.sessionFile)` 守卫:
2550
+ // 原守卫条件恰等于它要兜底的缺失本身——RC-1 握手失败留下的「sessionId 有、
2551
+ // sessionFile 无」形态(两字段独立采集)永远不可达本兜底,下游 finalize marker /
2552
+ // alive marker / identity 写入全部失去依据(PS-9 放大)。现在两种形态都反查:
2553
+ // sessionFile 有但路径不存在(推导错 / pi 命名变化);sessionFile 无而 sessionId
2554
+ // (header 或握手部分成功,含 agent_end 惰性回补补入的 handshakeResult.sessionId)。
2555
+ {
2556
+ const lookupId = state.sessionHeader?.id ?? state.handshakeResult?.sessionId;
2557
+ const needsLookup = record.sessionFile
2558
+ ? !fs.existsSync(record.sessionFile)
2559
+ : lookupId !== undefined;
2560
+ if (lookupId && needsLookup) {
2561
+ const actual = findSessionFileByHeaderId(sessionDir, lookupId);
2562
+ if (actual && actual !== record.sessionFile) record.sessionFile = actual;
2563
+ }
2564
+ }
2565
+
2566
+ // [T2-② / P-T2b 主路径] keep-alive 上界处置层主的两步时序后半:此刻 close 已发生
2567
+ //(waitForChildExit 已 resolve = 层主确认死亡),且 sessionFile 已完成 LC-4 反查
2568
+ // = 冻结为最终快照(pending entries 最完整,避开「kill 前采集」的垂死窗口漏项)。
2569
+ // 从层主 sessionFile 差集采集活跃后代清单,对清单内每个后代迭代展开至叶(递归读
2570
+ // 各后代的 pending 差集),kill 前做存活 + cmdline(pi/--mode rpc)校验防 pid 复用
2571
+ // 误杀,逐个 escalation kill(SIGTERM→SIGKILL)。同步执行:后代树规模有限 + 每
2572
+ // 步有界(ps 探测 3s 超时),不阻塞 runSpawn 收尾的可感时长。
2573
+ if (state.sweepDescendantsOnClose) {
2574
+ try {
2575
+ const sweep = sweepDescendantsOfSession(record.sessionFile, sessionDir, "keep-alive watchdog");
2576
+ if (sweep.killed.length > 0 || sweep.skipped.length > 0) {
2577
+ logger.warn(
2578
+ `[session-runner] descendant sweep (keep-alive watchdog): killed=[${sweep.killed.join(", ")}] skipped=${JSON.stringify(sweep.skipped)}`,
2579
+ );
1746
2580
  }
2581
+ } catch (err) {
2582
+ // sweep 整体失败不掩盖层主自身的收尾结果(best-effort 可见)
2583
+ bestEffort(err, "descendant sweep (keep-alive watchdog)", "error");
1747
2584
  }
1748
2585
  }
1749
2586
 
1750
2587
  // 判定成功/失败(三来源:exitCode + record.lastError + abort 原因)
1751
2588
  let success: boolean;
1752
2589
  let error: string | undefined;
1753
- if (record.lastError) {
2590
+ if (state.settledWatchdogFired) {
2591
+ // [T2-③ / LC-1] settled 硬上限到期回收 ≠ 正常完成:被信号终止视为正常完成的
2592
+ // 既有语义(maxTurns 达限 kill)不适用于本形态——runSpawn 以错误返回(设计
2593
+ // §6.2),错误消息含恢复指引(S-B 验收判据)。closedReason 的 "watchdog" 映射
2594
+ // 由 finalize 侧按 error 标记承接(ClosedReason 枚举封闭,不在此擅自扩枚举)。
2595
+ success = false;
2596
+ error =
2597
+ `subagent did not reach agent_settled within ${SETTLED_WATCHDOG_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE} min (settled watchdog); the process was terminated to bound the wait. ` +
2598
+ `Recovery: check state with subagents action:'list', then re-send your message to continue.`;
2599
+ } else if (record.lastError) {
1754
2600
  // LLM/provider error 或 abort error 已收口进 record.lastError
1755
2601
  success = false;
1756
2602
  error = record.lastError;