@zhushanwen/pi-subagent-workflow 7.4.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +15 -0
  2. package/package.json +7 -4
  3. package/scripts/rfl.mjs +308 -0
  4. package/skills/workflow-script-format/SKILL.md +1 -1
  5. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  7. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  8. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  9. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  10. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  11. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  12. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  13. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  14. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  15. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  16. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  17. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  18. package/src/execution/__tests__/execution-record.test.ts +199 -0
  19. package/src/execution/__tests__/finalize-record.test.ts +197 -15
  20. package/src/execution/__tests__/format.test.ts +131 -7
  21. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  22. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
  24. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  26. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  27. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  28. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  29. package/src/execution/__tests__/list-component.test.ts +59 -5
  30. package/src/execution/__tests__/list-fields.test.ts +109 -0
  31. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  32. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  33. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  34. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  35. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  36. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  37. package/src/execution/__tests__/record-store.test.ts +442 -54
  38. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  39. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  40. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  41. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  42. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  43. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  44. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  45. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  46. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  47. package/src/execution/__tests__/session-pending.test.ts +61 -2
  48. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  49. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  50. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  51. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  52. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  53. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  54. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  55. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  56. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  57. package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  59. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  60. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  61. package/src/execution/__tests__/tool-action.test.ts +12 -10
  62. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  63. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  64. package/src/execution/__tests__/worktree-manager.test.ts +300 -90
  65. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
  66. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  67. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  68. package/src/execution/agent-result-mapper.ts +4 -1
  69. package/src/execution/argv-mirror.ts +21 -2
  70. package/src/execution/channel-registry-access.ts +3 -1
  71. package/src/execution/execution-record.ts +126 -9
  72. package/src/execution/finalize-record.ts +99 -13
  73. package/src/execution/idle-gc.ts +47 -0
  74. package/src/execution/lifecycle-manager.ts +491 -0
  75. package/src/execution/lifecycle-predicates.ts +65 -0
  76. package/src/execution/manifest-store.ts +61 -16
  77. package/src/execution/model-resolver.ts +26 -5
  78. package/src/execution/notifier.ts +69 -12
  79. package/src/execution/pi-invocation.ts +21 -1
  80. package/src/execution/record-entry.ts +118 -0
  81. package/src/execution/record-store.ts +844 -108
  82. package/src/execution/session-pending.ts +121 -49
  83. package/src/execution/session-reconstructor.ts +224 -7
  84. package/src/execution/session-runner.ts +713 -316
  85. package/src/execution/sessions-index.ts +304 -0
  86. package/src/execution/stdin-writer.ts +93 -7
  87. package/src/execution/stream-sink.ts +20 -3
  88. package/src/execution/subagent-service.ts +917 -138
  89. package/src/execution/temp-prompt.ts +8 -3
  90. package/src/execution/turn-limiter.ts +14 -0
  91. package/src/execution/types.ts +218 -19
  92. package/src/execution/worktree-manager.ts +449 -59
  93. package/src/execution/worktree-registry.ts +97 -29
  94. package/src/index.ts +318 -20
  95. package/src/injectors/subagent-list-injector.ts +26 -8
  96. package/src/injectors/workflow-list-injector.ts +25 -8
  97. package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
  98. package/src/interface/__tests__/tool-render.test.ts +10 -8
  99. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  100. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  101. package/src/interface/bg-notify-render.ts +32 -8
  102. package/src/interface/command-actions.ts +26 -7
  103. package/src/interface/commands.ts +21 -22
  104. package/src/interface/format.ts +53 -20
  105. package/src/interface/gui-mappers.ts +6 -8
  106. package/src/interface/helpers.ts +170 -10
  107. package/src/interface/list-component.ts +53 -14
  108. package/src/interface/subagent-actions.ts +235 -17
  109. package/src/interface/subagent-tool.ts +81 -16
  110. package/src/interface/subagents.ts +2 -1
  111. package/src/interface/tool-render.ts +21 -27
  112. package/src/interface/tool-workflow-script.ts +29 -33
  113. package/src/interface/tool-workflow.ts +67 -100
  114. package/src/interface/views/WorkflowsView.ts +89 -32
  115. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  116. package/src/interface/views/detail-content.ts +1 -1
  117. package/src/interface/views/format.ts +3 -3
  118. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
  119. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  120. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  121. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  122. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  123. package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
  124. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  128. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  129. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  130. package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
  131. package/src/orchestration/__tests__/test-mocks.ts +197 -0
  132. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  133. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
  134. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  135. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  136. package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
  137. package/src/orchestration/agent-opts-resolver.ts +4 -1
  138. package/src/orchestration/args-validator.ts +2 -2
  139. package/src/orchestration/config-loader.ts +30 -1
  140. package/src/orchestration/error-recovery.ts +137 -30
  141. package/src/orchestration/execute-agent-call.ts +44 -10
  142. package/src/orchestration/jsonl-run-store.ts +397 -71
  143. package/src/orchestration/launcher.ts +7 -1
  144. package/src/orchestration/lifecycle.ts +145 -133
  145. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  146. package/src/orchestration/models/budget.ts +1 -1
  147. package/src/orchestration/models/run-runtime.ts +15 -17
  148. package/src/orchestration/models/run-spec.ts +2 -2
  149. package/src/orchestration/models/run-state.ts +3 -3
  150. package/src/orchestration/models/trace.ts +95 -15
  151. package/src/orchestration/models/types.ts +8 -9
  152. package/src/orchestration/models/workflow-run.ts +50 -71
  153. package/src/orchestration/models/workflow-script.ts +32 -1
  154. package/src/orchestration/skill-discovery.ts +30 -0
  155. package/src/orchestration/worker-handle.ts +1 -1
  156. package/src/orchestration/worker-host.ts +1 -1
  157. package/src/orchestration/worker-script-builder.ts +38 -11
  158. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  159. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  160. package/src/shared/__tests__/resource-discovery.test.ts +79 -0
  161. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  162. package/src/shared/agent-ref.ts +22 -1
  163. package/src/shared/resource-discovery.ts +162 -59
  164. package/src/shared/schema-jsonify.ts +56 -0
  165. package/workflows/review-fix-loop-utils.cjs +542 -32
  166. package/workflows/review-fix-loop.js +462 -109
@@ -11,18 +11,23 @@
11
11
  // 无人写终态 → 孤儿永久泄漏。→ 新判据:pid 死活一条判到底。
12
12
  //
13
13
  // 并发模型:
14
- // - 同步 IO(readFileSync/writeFileSync)。Node 单线程保证 sync read-modify-write
15
- // 在一个 event loop turn 内原子完成,进程内无需 mutex。
16
- // - WorktreeManager 实例(reaper + service)共享同一文件,sync 操作天然串行。
17
- // - 跨进程(用户开两个 pi):last-write-wins,丢失条目靠 OS tmpdir + 分支对账兜底。
14
+ // - 跨进程互斥(D5a):add/updatePid/remove load→mutate→save 全程持
15
+ // proper-lockfile 异步锁(<worktrees.json>.lock,协议登记 data-source-registry.md §6)。
16
+ // 锁不可用(重试耗尽)时降级为无锁 RMW + warn——注册表是 best-effort 数据,
17
+ // 降级不比锁前更差,条目丢失由 reaper 对账兜底(worktree-manager scan
18
+ // 双向 diff,见 reconcileWithPhysical)。
19
+ // - 同步 IO(readFileSync/writeFileSync)持锁执行:锁内临界区毫秒级,
20
+ // async 锁 + sync IO 组合在单线程 event loop 内无 interleaving。
18
21
  // - 原子写:写 .tmp → rename,防写一半崩溃产生损坏 JSON。
19
22
 
20
23
  import * as fs from "node:fs";
21
24
  import * as path from "node:path";
22
25
 
23
- import { bestEffort } from "./best-effort.ts";
26
+ import { withFileLock } from "@zhushanwen/pi-file-lock";
24
27
  import { getLogger } from "@zhushanwen/pi-extension-logger";
25
28
 
29
+ import { bestEffort } from "./best-effort.ts";
30
+
26
31
  const logger = getLogger("subagents");
27
32
 
28
33
  /** create→spawn 宽限期(ms):pid=0 条目超过此阈值判 create 后崩溃。 */
@@ -31,6 +36,11 @@ export const SPAWN_GRACE_MS = 60_000;
31
36
  /** JSON 缩进空格数(可读性 + diff 友好)。 */
32
37
  const JSON_INDENT = 2;
33
38
 
39
+ /** tmp 随机段:36 进制取 8 字符(跳过 "0." 前缀),与 pid 组合保证并发唯一。 */
40
+ const TMP_RANDOM_BASE = 36;
41
+ const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
42
+ const TMP_RANDOM_SLICE_END = 10;
43
+
34
44
  /** 注册表 JSON 顶层结构的运行时类型守卫。 */
35
45
  function isRegistryData(value: unknown): value is { entries: WorktreeEntry[] } {
36
46
  return (
@@ -56,6 +66,12 @@ export interface WorktreeEntry {
56
66
  readonly pid: number;
57
67
  /** 创建时间戳(ms,SPAWN_GRACE 判据 + 调试用)。 */
58
68
  readonly createdAt: number;
69
+ /**
70
+ * 对应 subagent session jsonl 文件全路径(诊断/兼容字段,reaper 据 pid 死活判孤儿)。
71
+ * session-runner first header 拿到 pid 时补全(create 时 record.sessionFile 尚未确定)。
72
+ * 可选:旧 worktrees.json / 非 worktree 模式无此字段,向后兼容(undefined 时 reaper 走原 pid 判据)。
73
+ */
74
+ readonly sessionFile?: string;
59
75
  }
60
76
 
61
77
  /**
@@ -74,40 +90,91 @@ export class WorktreeRegistry {
74
90
  /**
75
91
  * 新增条目(create 成功后调,pid=0 占位)。
76
92
  * 同 branch 已存在则覆盖(防残留覆盖)。
93
+ * 跨进程锁内 RMW(D5a);锁降级路径见 mutate。
77
94
  */
78
- add(entry: WorktreeEntry): void {
79
- const entries = this.load();
80
- const idx = entries.findIndex((e) => e.branch === entry.branch);
81
- if (idx >= 0) {
82
- entries[idx] = entry;
83
- } else {
84
- entries.push(entry);
85
- }
86
- this.save(entries);
95
+ async add(entry: WorktreeEntry): Promise<void> {
96
+ await this.mutate((entries) => {
97
+ const idx = entries.findIndex((e) => e.branch === entry.branch);
98
+ if (idx >= 0) {
99
+ entries[idx] = entry;
100
+ } else {
101
+ entries.push(entry);
102
+ }
103
+ });
87
104
  }
88
105
 
89
106
  /**
90
107
  * 更新 pid(runSpawn spawn() 返回后同步调)。
91
108
  * branch 不存在则忽略(create 后崩溃 + reaper 已清的竞态)。
109
+ * sessionFile 可选补全:传入时填入 entry(reaper 据 pid 死活判孤儿,不读本字段)。
92
110
  */
93
- updatePid(branch: string, pid: number): void {
94
- const entries = this.load();
95
- const idx = entries.findIndex((e) => e.branch === branch);
96
- if (idx >= 0) {
97
- entries[idx] = { ...entries[idx], pid };
98
- this.save(entries, { branch, pid });
99
- }
111
+ async updatePid(branch: string, pid: number, sessionFile?: string): Promise<void> {
112
+ await this.mutate(
113
+ (entries) => {
114
+ const idx = entries.findIndex((e) => e.branch === branch);
115
+ if (idx >= 0) {
116
+ entries[idx] = {
117
+ ...entries[idx],
118
+ pid,
119
+ ...(sessionFile !== undefined ? { sessionFile } : {}),
120
+ };
121
+ }
122
+ },
123
+ { branch, pid },
124
+ );
100
125
  }
101
126
 
102
127
  /**
103
128
  * 移除条目(cleanup/reaper 清理后调)。
104
129
  * branch 不存在则忽略(幂等)。
105
130
  */
106
- remove(branch: string): void {
107
- const entries = this.load();
108
- const filtered = entries.filter((e) => e.branch !== branch);
109
- if (filtered.length !== entries.length) {
110
- this.save(filtered);
131
+ async remove(branch: string): Promise<void> {
132
+ await this.mutate(
133
+ (entries) => {
134
+ const filtered = entries.filter((e) => e.branch !== branch);
135
+ if (filtered.length !== entries.length) {
136
+ entries.length = 0;
137
+ entries.push(...filtered);
138
+ }
139
+ },
140
+ { branch },
141
+ );
142
+ }
143
+
144
+ /**
145
+ * 锁内 RMW 统一入口:withLock(load → mutate → save)。
146
+ *
147
+ * 降级语义(对齐本类既有 best-effort 约定——注册表写失败不阻断 create/cleanup
148
+ * 主流程):锁获取失败(重试耗尽 ELOCKED 等)→ warn + 无锁执行同一段 RMW
149
+ * (= D5a 之前的 last-write-wins 行为,条目丢失由 reaper 对账兜底),
150
+ * 不抛错、永不 reject(调用方含 session-runner 的 fire-and-forget 回调)。
151
+ */
152
+ private async mutate(
153
+ mutate: (entries: WorktreeEntry[]) => void,
154
+ context?: { branch?: string; pid?: number },
155
+ ): Promise<void> {
156
+ const run = (): void => {
157
+ const entries = this.load();
158
+ mutate(entries);
159
+ this.save(entries, context);
160
+ };
161
+ try {
162
+ await withFileLock(this.filePath, () => {
163
+ run();
164
+ return Promise.resolve();
165
+ });
166
+ } catch (lockErr) {
167
+ // 锁不可用(ELOCKED 重试耗尽 / 锁目录损坏等):降级无锁 RMW。
168
+ // 竞争窗口内可能丢条目(旧缺陷形态),由 reaper 对账(scan 双向 diff)收敛。
169
+ logger.warn("[worktree] registry lock unavailable, degraded to lock-free RMW", {
170
+ ...(context ?? {}),
171
+ err: lockErr instanceof Error ? lockErr.message : String(lockErr),
172
+ });
173
+ try {
174
+ run();
175
+ } catch (err) {
176
+ bestEffort(err, "worktree registry degraded RMW");
177
+ }
111
178
  }
112
179
  }
113
180
 
@@ -133,12 +200,13 @@ export class WorktreeRegistry {
133
200
  * 原子写入全部条目。
134
201
  * best-effort:写入失败不阻断主流程(create/cleanup 的 git 操作已执行,
135
202
  * 注册表与 git 状态的短暂不一致靠下次 reaper 对账收敛)。
136
- * 写盘失败时 warn 日志(updatePid 路径带 branch/pid,补全失败可观测闭环)。
203
+ * 写盘失败时 warn 日志(带 branch/pid 上下文,补全失败可观测闭环)。
137
204
  */
138
- private save(entries: WorktreeEntry[], context?: { branch: string; pid: number }): void {
205
+ private save(entries: WorktreeEntry[], context?: { branch?: string; pid?: number }): void {
139
206
  try {
140
207
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
141
- const tmp = `${this.filePath}.tmp`;
208
+ // tmp 名带 pid + 随机段:锁降级(无锁并发 RMW)时多进程 tmp 互不覆盖
209
+ const tmp = `${this.filePath}.tmp_${process.pid}_${Math.random().toString(TMP_RANDOM_BASE).slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
142
210
  fs.writeFileSync(tmp, JSON.stringify({ entries }, null, JSON_INDENT), "utf-8");
143
211
  fs.renameSync(tmp, this.filePath);
144
212
  } catch (err) {
package/src/index.ts CHANGED
@@ -30,12 +30,15 @@ import {
30
30
  ModelConfigService,
31
31
  setModelConfigService,
32
32
  } from "./execution/model-config-service.ts";
33
+ import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./execution/session-reconstructor.ts";
34
+ import type { ExecutionMode, SubagentRecord } from "./execution/types.ts";
33
35
  import { maybeCleanupExpiredSessionFiles } from "./execution/session-file-gc.ts";
34
36
  import {
35
37
  getSubagentService,
36
38
  setSubagentService,
37
39
  SubagentService,
38
40
  } from "./execution/subagent-service.ts";
41
+ import { killAllSpawnedChildren } from "./execution/session-runner.ts";
39
42
  import { SubprocessAgentRunner } from "./execution/subprocess-agent-runner.ts";
40
43
  import { WorktreeManager } from "./execution/worktree-manager.ts";
41
44
  import { setupSubagentListInjector } from "./injectors/subagent-list-injector.ts";
@@ -43,17 +46,23 @@ import { setupWorkflowListInjector } from "./injectors/workflow-list-injector.ts
43
46
  import { renderBgNotifyMessage } from "./interface/bg-notify-render.ts";
44
47
  import { registerWorkflowsCommand } from "./interface/commands.ts";
45
48
  import { toGuiCtx } from "./interface/gui-mappers.ts";
46
- import { notifyDone } from "./interface/helpers.ts";
49
+ import { notifyDone, trackNotifiedRunId } from "./interface/helpers.ts";
47
50
  import { registerSubagentTool } from "./interface/subagent-tool.ts";
48
51
  // ═══ interface/ 层(tools/commands/tui 合并) ═══
49
52
  import { registerSubagentsCommand } from "./interface/subagents.ts";
50
53
  import { registerWorkflowTool } from "./interface/tool-workflow.ts";
51
54
  import { registerWorkflowScriptTool } from "./interface/tool-workflow-script.ts";
52
55
  import { JsonlRunStore } from "./orchestration/jsonl-run-store.ts";
56
+ import { clearSkillPathCache } from "./orchestration/skill-discovery.ts";
53
57
  // ═══ orchestration/ 层(workflow engine + infra) ═══
54
58
  import type { LauncherDeps } from "./orchestration/launcher.ts";
55
59
  import { executeNestedWorkflow, runAndWait, type WorkflowRunResult } from "./orchestration/launcher.ts";
56
- import { pauseRun, scheduleTimeBudget } from "./orchestration/lifecycle.ts";
60
+ import {
61
+ evictDoneRunsBeyondCap,
62
+ MAX_RETAINED_DONE_RUNS,
63
+ scheduleTimeBudget,
64
+ terminateRunningRuns,
65
+ } from "./orchestration/lifecycle.ts";
57
66
  import type { WorkflowRun } from "./orchestration/models/workflow-run.ts";
58
67
  import { WorkerHostImpl } from "./orchestration/worker-host.ts";
59
68
  import { WorkflowScriptRegistryImpl } from "./orchestration/workflow-script-registry-impl.ts";
@@ -76,6 +85,75 @@ declare module "@earendil-works/pi-coding-agent" {
76
85
  // 模块级 logger(setPiHandle 注入后自动走 appendEntry)
77
86
  const logger = getLogger("subagents");
78
87
 
88
+ // ── subagent 状态快照格式化 ──
89
+ //
90
+ // [v4 A-6] before_agent_start 注入 hook 已删(活跃 subagent 清单改由 agent 按需调
91
+ // action:'list' 拉取,消除每 loop 注入的上下文税与盲点)。本函数保留为纯格式化工具:
92
+ // before-agent-start-injection / parent-child-matrix 测试覆盖其正确性,未来 list
93
+ // 视图或其他注入点可复用。
94
+
95
+ /** 活跃 subagent 数量上限(超过截断显示)。 */
96
+ const MAX_STATUS_INJECTION = 10;
97
+
98
+ /**
99
+ * 将活跃 subagent record 格式化为一行一条的快照文本。
100
+ *
101
+ * 格式:
102
+ * [subagent-status] N active subagents:
103
+ * - sa-xxx (slug): running, rounds 0
104
+ * - sa-yyy (slug): idle, rounds 3
105
+ * +2 more, use action:'list'
106
+ *
107
+ * @param records 已筛选的活跃 record(running + idle)
108
+ */
109
+ export function formatSubagentStatusSnapshot(records: SubagentRecord[]): string {
110
+ const lines = [`[subagent-status] ${records.length} active subagent${records.length === 1 ? "" : "s"}:`];
111
+ const shown = records.slice(0, MAX_STATUS_INJECTION);
112
+ for (const r of shown) {
113
+ const slug = r.slug || r.agent;
114
+ const roundPart = r.round !== undefined && r.round > 0 ? `, rounds ${r.round}` : "";
115
+ lines.push(`- ${r.id} (${slug}): ${r.status}${roundPart}`);
116
+ }
117
+ const remaining = records.length - MAX_STATUS_INJECTION;
118
+ if (remaining > 0) {
119
+ lines.push(`+${remaining} more, use action:'list'`);
120
+ }
121
+ return lines.join("\n");
122
+ }
123
+
124
+ // ═══ [V2 决策 7 防线 i] process 级 shutdown hook ═══
125
+ //
126
+ // session_shutdown 是 pi 的 async hook,进程被 SIGTERM/SIGINT 强杀或崩溃时来不及
127
+ // 触发;sync 子进程(controller 为 undefined,abortRunningControllers 跳过它们)会
128
+ // 泄漏为孤儿。process.on 兜底显式 killAllSpawnedChildren 收割全部活子进程。
129
+ // guard 防多信号叠加(如 SIGINT 后又 beforeExit)重复 kill。
130
+ let processShutdownHookFired = false;
131
+
132
+ function reapSpawnedChildrenOnShutdown(): void {
133
+ if (processShutdownHookFired) return;
134
+ processShutdownHookFired = true;
135
+ try {
136
+ killAllSpawnedChildren("SIGTERM");
137
+ } catch (err) {
138
+ // best-effort:收割失败不阻断退出流程——debug 留痕(孤儿子进程排查线索),
139
+ // 不静默吞错,对齐「错误必须可操作」。
140
+ logger.debug(
141
+ "[subagents] process shutdown reap best-effort failed (killAllSpawnedChildren SIGTERM)",
142
+ { reason: err instanceof Error ? err.message : String(err) },
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * 测试钩子:重置 module 级 shutdown guard。
149
+ *
150
+ * 对齐 lifecycle-manager._resetLifecycleState 模式——processShutdownHookFired 是
151
+ * module 级单例状态,跨 test 持久,单测需显式重置以验证 idempotent 行为。
152
+ */
153
+ export function _resetProcessShutdownGuardForTest(): void {
154
+ processShutdownHookFired = false;
155
+ }
156
+
79
157
  export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
80
158
  // 注入 pi handle 给全局 extension-logger,让深层代码(best-effort / error-recovery)
81
159
  // 的 getLogger("subagents") 也能走 appendEntry。
@@ -105,6 +183,24 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
105
183
  return cachedMainSessionFile;
106
184
  }
107
185
 
186
+ /**
187
+ * 按 sessionId 解析主 session 文件路径(文件名约定 `<ISO 时间戳>_<sessionId>.jsonl`)。
188
+ * [E2E 实测] attach 场景下 ctx.sessionManager.getSessionFile() 会返回前一 session 的
189
+ * 文件(session_start(root=01a01bf5) 时仍返回刚新建 session 的路径)——恢复逻辑
190
+ * 读错文件会整段漏判。此处按 id 从 sessions 目录解析为准;新 session 文件未 flush
191
+ * 时(AGENTS.md 规则 6:首条 assistant 消息前可能不存在)返回 undefined,调用方
192
+ * (fork 解析 / 孤儿恢复)对该场景本就无 entry 可读。
193
+ */
194
+ function resolveMainSessionFileById(sessionId: string): string | undefined {
195
+ const sessionsDir = path.join(getAgentDir(), "..", "sessions");
196
+ try {
197
+ const match = fs.readdirSync(sessionsDir).find((f) => f.endsWith(`_${sessionId}.jsonl`));
198
+ return match === undefined ? undefined : path.join(sessionsDir, match);
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
108
204
  // resources_discover:不再注入额外 skill 目录(ADR-031 废弃 discovery.json)。
109
205
  // pi 核心 auto-discovery 已覆盖 .agents/skills 等标准目录,子 session 的
110
206
  // --skill 由 agent({skill}) 调用方显式传入,无需 extension 额外补充。
@@ -187,7 +283,23 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
187
283
  runner: state.runner,
188
284
  runs: state.runs,
189
285
  registry,
190
- onRunDone: (run: WorkflowRun) => notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx)),
286
+ // onRunDone 是全部 done 路径的单点汇聚(abortRun + error-recovery),顺序固化为
287
+ // notify → track → evict:notifyDone 先发完整聚合通知(淘汰后聚合根仍在闭包参数
288
+ // run 引用上不受影响),trackNotifiedRunId 有界化去重窗口,最后裁剪 done run 内存。
289
+ // 本轮 run 的 completedAt 在 transition("done") 时同步设为当前时刻=全局最新,
290
+ // 恒在保留端——结构性保证其不被自身触发的裁剪淘汰,无需 protectRunId。
291
+ onRunDone: (run: WorkflowRun) => {
292
+ notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx));
293
+ trackNotifiedRunId(notifiedRunIds, run.runId);
294
+ const evicted = evictDoneRunsBeyondCap(state.runs, MAX_RETAINED_DONE_RUNS);
295
+ if (evicted > 0) {
296
+ logger.debug("[subagent-workflow] evicted done runs beyond cap", {
297
+ evicted,
298
+ keep: MAX_RETAINED_DONE_RUNS,
299
+ sessionId: lsRef.lastSessionId,
300
+ });
301
+ }
302
+ },
191
303
  eventBus: pi.events,
192
304
  scheduleTimeBudget: (runId: string, budgetTimeMs: number) =>
193
305
  scheduleTimeBudget(runId, deps, budgetTimeMs),
@@ -217,6 +329,55 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
217
329
  const sessionId = ctx.sessionManager.getSessionId();
218
330
  lsRef.lastSessionId = sessionId;
219
331
 
332
+ // skill 路径两级缓存 session 级失效:pi 同进程可能有多个 session(TUI /new、/fork),
333
+ // 运行中安装的 skill 需对新 session 可见(含曾 miss 缓存的 undefined 条目与 npm 新装
334
+ // 包的候选目录)。session 内复用收益不变(IF8/DM3 消重发生在同 session 的重复调用)。
335
+ clearSkillPathCache();
336
+
337
+ // ── [M4] identity 子进程写入(V2 决策 5)──
338
+ // 子进程经 env(PI_SUBAGENT_*)接收自己的 identity,在 session_start 用 pi.appendEntry
339
+ // 写 subagent-identity custom entry。pi 自动生成 id/parentId → message tree 连续。
340
+ // 旧实现父进程 fs.appendFileSync 补写的 custom entry 缺 id/parentId → 污染 _buildIndex
341
+ // leafId 指针 → message tree 断成两棵 → 多轮对话丢上下文(bug 根因)。
342
+ // 主/子进程判定:PI_SUBAGENT_SELF_RECORD_ID 仅 session-runner spawn 子进程时注入,
343
+ // 主进程无此 env → 跳过(identity 只在子进程写一次)。
344
+ const selfRecordId = process.env.PI_SUBAGENT_SELF_RECORD_ID;
345
+ if (selfRecordId) {
346
+ try {
347
+ const modeEnv = process.env.PI_SUBAGENT_MODE;
348
+ // ExecutionMode 联合窄化:父进程经 env 注入(record.mode 恒为 "background"),
349
+ // 运行时校验合法值,非法兜底 background(避免裸 cast,符合 taste/no-unsafe-cast)。
350
+ const mode: ExecutionMode = modeEnv === "background" ? modeEnv : "background";
351
+ const identity: SubagentIdentityData = {
352
+ id: selfRecordId,
353
+ agent: process.env.PI_SUBAGENT_AGENT ?? "",
354
+ mode,
355
+ task: process.env.PI_SUBAGENT_TASK ?? "",
356
+ slug: process.env.PI_SUBAGENT_SLUG,
357
+ startedAt: Number(process.env.PI_SUBAGENT_STARTED_AT ?? Date.now()),
358
+ rootSessionId: process.env.PI_SUBAGENT_ROOT_SESSION_ID,
359
+ parentRecordId: process.env.PI_SUBAGENT_PARENT_RECORD_ID,
360
+ depth:
361
+ process.env.PI_SUBAGENT_DEPTH !== undefined
362
+ ? Number(process.env.PI_SUBAGENT_DEPTH)
363
+ : undefined,
364
+ forkDepth:
365
+ process.env.PI_SUBAGENT_FORK_DEPTH !== undefined
366
+ ? Number(process.env.PI_SUBAGENT_FORK_DEPTH)
367
+ : undefined,
368
+ chatMode: process.env.PI_SUBAGENT_CHAT_MODE === "true",
369
+ // [review round2] worktree 隔离标志(session-runner 注入):跨重启重建路径据此
370
+ // 拒绝续聊(handle 不可序列化,reattach 不可行)。
371
+ worktree: process.env.PI_SUBAGENT_WORKTREE === "true",
372
+ };
373
+ pi.appendEntry(IDENTITY_CUSTOM_TYPE, identity);
374
+ } catch (err) {
375
+ logger.warn("[subagents] identity appendEntry failed in session_start", {
376
+ reason: err instanceof Error ? err.message : String(err),
377
+ });
378
+ }
379
+ }
380
+
220
381
  // ── subagents 域:双 Service 装配 ──
221
382
  const existingService = getSubagentService();
222
383
  const existingModelService = getModelConfigService();
@@ -238,9 +399,20 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
238
399
  // SR-3: 无论 new 还是 existing(/resume /fork 复用),session_start 都必须注入 handler
239
400
  service.setUiRequestHandler(uiRequestHandler);
240
401
 
402
+ // 主 session 文件:按 sessionId 解析(getSessionFile() 在 attach 场景会返回前一
403
+ // session 的文件,E2E 实测),未 flush 的新 session 回退 getSessionFile()。
404
+ // 值直传 initSession——jiti 多实例分裂下闭包缓存(cachedMainSessionFile)不跨
405
+ // 实例共享,恢复逻辑经缓存读会拿到滞后一个事件的值(E2E 实测 ENOENT 漏判);
406
+ // 缓存本身保留给既有 getter 消费者(fork source 解析)。
407
+ cachedMainSessionFile =
408
+ resolveMainSessionFileById(ctx.sessionManager.getSessionId()) ??
409
+ ctx.sessionManager.getSessionFile() ??
410
+ undefined;
411
+
241
412
  service.initSession({
242
413
  pi,
243
414
  sessionId: ctx.sessionManager.getSessionId(),
415
+ mainSessionFile: cachedMainSessionFile,
244
416
  // 注入 ctx.ui.setWidget 作为 streaming sink(只绑方法,不持有整个 ctx)。
245
417
  // background subagent 执行期间,text_delta 经 SubagentStream 合并后由此通道转发。
246
418
  // [W1 修复] ctx.mode === 'rpc' 守卫:TUI/json/print 下 streamSink = undefined(无 widget 噪音),
@@ -267,7 +439,8 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
267
439
  setSubagentService(service);
268
440
  }
269
441
 
270
- cachedMainSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
442
+ // S-2: 启动 idle record GC 定时器(30 天 TTL,每小时检查一次)
443
+ service.startGcTimer();
271
444
 
272
445
  try {
273
446
  maybeCleanupExpiredSessionFiles(agentDir, cwd);
@@ -292,7 +465,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
292
465
 
293
466
  try {
294
467
  const wtm = new WorktreeManager(agentDir);
295
- wtm.scan();
468
+ await wtm.scan();
296
469
  } catch (err) {
297
470
  logger.warn("[subagents] worktree reaper scan failed", {
298
471
  reason: err instanceof Error ? err.message : String(err),
@@ -325,9 +498,38 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
325
498
  id: run.runId,
326
499
  reason: "failed",
327
500
  });
501
+ // 恢复终态必须落盘:save 走冷路径(done 绕过去抖)同步写 state 文件 +
502
+ // append 终态 workflow-record entry——entry_appended 事件驱动 runtime 派生
503
+ // 缓存失效重拉(无 triggerTurn 副作用)。不 save 则 entry/state 双双停留
504
+ // running,侧栏永久卡 running。失败仅记日志不阻断其余 run 的恢复(下次
505
+ // session_start 重开重试,恢复循环天然幂等)。
506
+ try {
507
+ await store.save(run);
508
+ } catch (err) {
509
+ logger.error("[subagent-workflow] kill-9 recovery store.save failed", {
510
+ runId: run.runId,
511
+ reason: err instanceof Error ? err.message : String(err),
512
+ });
513
+ }
328
514
  }
329
515
  runs.set(run.runId, run);
330
516
  }
517
+ // done run 内存有界性:loadAll 全量重水合后立即裁剪到 K。kill-9 恢复(上方
518
+ // running → transition("done","failed"))的 run completedAt 为 transition 时刻
519
+ // (当前时间=全局最新)参与排序且必在保留端;多条恢复 run 同 ms completedAt →
520
+ // tie 稳定排序。淘汰只 delete runs Map 条目——磁盘 state 文件与
521
+ // workflow-state-link 指针条目均不动(历史审计保留);下次 session_start loadAll
522
+ // 从指针全量重水合后再次裁剪,该循环每次 session 启动重复且可接受:内存峰值只在
523
+ // 启动期,常驻 O(K + 活跃 run)。消除启动峰值需指针 compaction,属 append+replay
524
+ // 长期方案问题域,非本范围。
525
+ const evicted = evictDoneRunsBeyondCap(runs, MAX_RETAINED_DONE_RUNS);
526
+ if (evicted > 0) {
527
+ logger.debug("[subagent-workflow] evicted done runs beyond cap after loadAll", {
528
+ evicted,
529
+ keep: MAX_RETAINED_DONE_RUNS,
530
+ sessionId,
531
+ });
532
+ }
331
533
  } catch (err) {
332
534
  // QMF-4 fix: store.loadAll 失败是关键路径错误,workflow 域将未初始化
333
535
  logger.error("[subagent-workflow] store.loadAll failed, workflow domain uninitialized", {
@@ -371,7 +573,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
371
573
  });
372
574
 
373
575
  // ════════════════════════════════════════════════════════════
374
- // session_tree:切分支前 pause 所有 running run
576
+ // session_tree:切分支前终止所有 running run(一次性生命周期——切走即作废)
375
577
  // ════════════════════════════════════════════════════════════
376
578
  pi.on("session_tree", async (_event: SessionTreeEvent, ctx: ExtensionContext) => {
377
579
  const sessionId = ctx.sessionManager.getSessionId();
@@ -379,33 +581,90 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
379
581
 
380
582
  const state = sessionState.get(sessionId);
381
583
  if (state) {
382
- for (const run of state.runs.values()) {
383
- if (run.state.status === "running") {
384
- try {
385
- await pauseRun(run.runId, makeDeps(state, ctx));
386
- } catch (err) {
387
- bestEffort(err, "pauseRun (session_tree handler)");
388
- }
389
- }
584
+ // 一次性生命周期(D-2):running run done,failed 落盘(helper 内部自过滤
585
+ // running,单 run 失败不中断其余)。此处不再挂起待恢复。
586
+ try {
587
+ await terminateRunningRuns(makeDeps(state, ctx), "Session switched: run terminated");
588
+ } catch (err) {
589
+ bestEffort(err, "terminateRunningRuns (session_tree handler)");
590
+ }
591
+ }
592
+ });
593
+
594
+ // ════════════════════════════════════════════════════════════
595
+ // SP-4: session_before_fork(/fork)/ session_before_switch(/new)级联关闭
596
+ // ════════════════════════════════════════════════════════════
597
+ // 主 session /fork 或 /new 时,清理旧 record(disposeAllRecords:CAS 转终态 +
598
+ // archive + worktree 清理)。before 事件在 session 替换前触发,确保旧 session 的
599
+ // subagent 在新 session 创建前被清理(随后的 session_shutdown → dispose 收割子进程)。
600
+ //
601
+ // [M2 修复] 旧实现把 /new 级联挂在 session_before_tree 上——SDK 中该事件只由
602
+ // AgentSession.navigateTree()(/tree 同 session 分支切换)触发,/new 走
603
+ // session_before_switch(reason:"new") + session_shutdown(reason:"new"),从不触发
604
+ // before_tree。后果双向:/new 级联是死代码;普通 /tree 分支导航反而误杀全部活跃
605
+ // subagent。现 /new 改挂 session_before_switch(reason==="new"),before_tree handler
606
+ // 移除(/tree 是同 session 内导航,record/子进程归属不变,无级联关闭诉求)。
607
+ pi.on("session_before_fork", (_event, _ctx) => {
608
+ const service = getSubagentService();
609
+ if (service) {
610
+ const count = service.onParentFork();
611
+ if (count > 0) {
612
+ logger.warn(`[subagents] /fork 级联关闭 ${count} 个 subagent`);
613
+ }
614
+ }
615
+ });
616
+
617
+ pi.on("session_before_switch", (event, _ctx) => {
618
+ // /new(reason:"new")创建全新 session → 级联关闭旧 record。
619
+ // reason:"resume"(/resume /import 回到已有 session)不级联:record 按 rootSessionId
620
+ // 归属隔离,跨 session 读写由 store 过滤守卫,无需销毁。
621
+ if (event.reason !== "new") return;
622
+ const service = getSubagentService();
623
+ if (service) {
624
+ const count = service.onParentNew();
625
+ if (count > 0) {
626
+ logger.warn(`[subagents] /new 级联关闭 ${count} 个 subagent`);
390
627
  }
391
628
  }
392
629
  });
393
630
 
394
631
  // ════════════════════════════════════════════════════════════
395
- // session_shutdown:dispose subagents + pause workflows + cleanup
632
+ // session_shutdown:dispose subagents + terminate workflows + store 收尾 + cleanup
633
+ //
634
+ // store 收尾:每 session 的 JsonlRunStore 在 terminateRunningRuns 之后 dispose(刷
635
+ // pending 去抖批 + await in-flight 链,见 W2C5)。R3 声明:SIGTERM/SIGINT 走下方
636
+ // process handler 不触发本路径,pending 去抖丢失等价崩溃链(重启后 kill-9 恢复
637
+ // 收编 running 残留——终态/创建均冷路径已落盘,丢的只有 ≤saveDebounceMs 的
638
+ // running 尾巴,ES1 已接受);不做 best-effort SIGTERM dispose(需同步 IO 改造,
639
+ // 超出 wave 边界)。
396
640
  // ════════════════════════════════════════════════════════════
397
641
  pi.on("session_shutdown", async (_event: SessionShutdownEvent, _ctx: ExtensionContext) => {
398
642
  // ── subagents 域:dispose SubagentService ──
399
643
  getSubagentService()?.dispose();
400
644
 
401
- // ── workflow 域:pause 所有 running run + 清理 temp files ──
645
+ // ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
402
646
  // H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——
403
647
  // 防御 session 切换但 session_tree 未先触发导致 lastSessionId 指向已删除 session 的情况)。
404
648
  for (const [sessionId, state] of sessionState) {
405
- const running = Array.from(state.runs.values()).filter((r) => r.state.status === "running");
406
- await Promise.allSettled(
407
- running.map((run) => pauseRun(run.runId, makeDeps(state, _ctx))),
408
- );
649
+ // 编排顺序(W2C5):terminate(await,failed 落盘——重启后 kill-9 恢复不误判)
650
+ // → store.dispose(await,刷 pending 去抖批 + await in-flight 链,关「shutdown
651
+ // 时刻 pending 去抖写丢失」窗口)→ delete。terminate 的 running 过滤在 helper
652
+ // 内部(单 run 失败不中断其余);外层 try/catch 兜底防单 session 异常中断后续
653
+ // session 条目的 dispose + delete(对齐原 allSettled 的不中断语义)。
654
+ try {
655
+ await terminateRunningRuns(makeDeps(state, _ctx), "Session shutdown: run terminated");
656
+ } catch (err) {
657
+ bestEffort(err, "terminateRunningRuns (session_shutdown handler)");
658
+ }
659
+ // dispose 自身恒 resolve,catch 兜底防御——handler 内抛错会中断后续 session
660
+ // 条目清理。不留静默吞错(错误必须可操作):debug 留痕带 sessionId/sessionDir,
661
+ // 排查「shutdown 后 run 状态不落盘」类问题时有迹可循。
662
+ await state.store.dispose().catch((err: unknown) => {
663
+ logger.debug(
664
+ `[subagent-workflow] session_shutdown store.dispose failed (sessionId=${sessionId}, sessionDir=${state.sessionDir})`,
665
+ { reason: err instanceof Error ? err.message : String(err) },
666
+ );
667
+ });
409
668
  sessionState.delete(sessionId);
410
669
  }
411
670
 
@@ -422,6 +681,45 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
422
681
  dialogQueue.rejectAll();
423
682
  });
424
683
 
684
+ // ════════════════════════════════════════════════════════════
685
+ // [V2 决策 7 防线 i] process 级 shutdown hook(显式收割三道防线之一)
686
+ //
687
+ // 上方 session_shutdown(pi async hook)在进程被 SIGTERM/SIGINT 强杀或崩溃时
688
+ // 不触发,此处 process.on 兜底确保 sync 子进程被收割(防线 i:shutdown 时显式
689
+ // SIGTERM 全部 activation)。
690
+ //
691
+ // - SIGTERM:pi 各 mode(rpc/interactive/print)自带 SIGTERM handler 负责退出编排,
692
+ // 本 extension 的 handler 只做收割 + 设 exitCode(不 re-raise、不抢 pi 的退出语义;
693
+ // xyz-agent 桌面 supervisor 用 SIGTERM 杀 pi 走这条路)。
694
+ // - SIGINT:pi 本体不注册常规 SIGINT handler(interactive/print/rpc 均 SIGTERM only),
695
+ // 依赖 Node 默认终止。本 extension 注册 listener 即取消默认终止——若只设 exitCode,
696
+ // 本地 pi CLI 的 Ctrl-C 杀不死进程(TUI/stdin/agent loop 仍在事件循环)。故收割
697
+ // 完成后 re-raise 恢复默认终止,见下方 sigintHandler。
698
+ // - beforeExit 是退出前最后事件,不 exit(自然退出)。
699
+ // - idempotent guard(reapSpawnedChildrenOnShutdown 内)防多信号叠加重复 kill。
700
+ //
701
+ // 防线 iii(activate 互斥)已接入:subagent-service.ts 冷路径 resume 调
702
+ // acquireActivateLock(含 30s 超时兜底,见 lifecycle-manager.ts ACTIVATE_LOCK_TIMEOUT_MS)。
703
+ // 防线 ii(启动 scanOrphanProcesses)骨架就位,启动时接入待实现。
704
+ // ════════════════════════════════════════════════════════════
705
+ process.on("SIGTERM", () => {
706
+ reapSpawnedChildrenOnShutdown();
707
+ // S-3: 改用 process.exitCode 而非 process.exit(0),让子进程 cleanup 完成后再自然退出。
708
+ // process.exit(0) 会立即终止,可能在 reapSpawnedChildrenOnShutdown 完成前截断。
709
+ // 退出编排归 pi 自身的 SIGTERM handler(rpc-mode 会主动退出)。
710
+ process.exitCode = 0;
711
+ });
712
+ // [review 修复] SIGINT re-raise:收割同步完成后,先移除自身 listener 再向自身重发
713
+ // SIGINT,恢复 Node 默认终止。不 removeListener 直接 kill(process.pid) 会再次进入
714
+ // 本 handler 递归;移除后无其他 SIGINT listener(pi 不注册)→ 默认行为终止进程。
715
+ const sigintHandler = (): void => {
716
+ reapSpawnedChildrenOnShutdown();
717
+ process.removeListener("SIGINT", sigintHandler);
718
+ process.kill(process.pid, "SIGINT");
719
+ };
720
+ process.on("SIGINT", sigintHandler);
721
+ process.on("beforeExit", reapSpawnedChildrenOnShutdown);
722
+
425
723
  // ════════════════════════════════════════════════════════════
426
724
  // pi.__workflowRun(D-8 签名)
427
725
  // ════════════════════════════════════════════════════════════