@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.1

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 (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +14 -9
  3. package/relay/relay.mjs +45 -2
  4. package/skills/workflow-script-format/SKILL.md +2 -56
  5. package/src/host/__tests__/inflight-reporter.test.ts +26 -1
  6. package/src/host/__tests__/pi-host.test.ts +418 -4
  7. package/src/host/inflight-reporter.ts +13 -10
  8. package/src/host/pi-host.ts +205 -17
  9. package/src/index.ts +21 -72
  10. package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
  11. package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
  12. package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
  13. package/src/injectors/resource-list-injector.ts +103 -32
  14. package/src/injectors/subagent-list-injector.ts +4 -28
  15. package/src/injectors/workflow-list-injector.ts +8 -4
  16. package/src/interface/__tests__/capture-tool.ts +56 -0
  17. package/src/interface/__tests__/commands-resume.test.ts +103 -0
  18. package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
  19. package/src/interface/__tests__/tool-render.test.ts +105 -2
  20. package/src/interface/__tests__/tool-subagents.test.ts +24 -26
  21. package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
  22. package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
  23. package/src/interface/__tests__/tool-workflow.test.ts +884 -0
  24. package/src/interface/bg-notify-render.ts +18 -9
  25. package/src/interface/command-actions.ts +22 -17
  26. package/src/interface/commands.ts +58 -12
  27. package/src/interface/format.ts +50 -16
  28. package/src/interface/id-preview.ts +4 -4
  29. package/src/interface/subagent-actions.ts +18 -50
  30. package/src/interface/subagent-tool.ts +26 -18
  31. package/src/interface/subagents.ts +5 -11
  32. package/src/interface/tool-render.ts +14 -30
  33. package/src/interface/tool-shared.ts +55 -12
  34. package/src/interface/tool-subagents.ts +21 -11
  35. package/src/interface/tool-workflow-script.ts +52 -72
  36. package/src/interface/tool-workflow.ts +345 -139
  37. package/src/interface/views/WorkflowsView.ts +10 -7
  38. package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
  39. package/src/interface/views/detail-content.ts +3 -5
  40. package/src/jsonl-run-store.ts +656 -544
  41. package/src/model-events.ts +35 -0
  42. package/src/session-lifecycle.ts +349 -86
  43. package/src/subagents-events.ts +56 -0
  44. package/src/workflow-events.ts +285 -200
  45. package/src/workflow-notify.ts +456 -0
  46. package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
  47. package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
  48. package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
  49. package/src/interface/__tests__/detectors.test.ts +0 -161
  50. package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
  51. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
  52. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
  53. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
  54. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
  55. package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
  56. package/src/interface/helpers.ts +0 -272
  57. package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
  58. package/src/interface/views/__tests__/record-progress.test.ts +0 -235
@@ -0,0 +1,35 @@
1
+ /**
2
+ * model-events — model 域事件 handler(主 agent 模型切换的缓存刷新)。
3
+ *
4
+ * 原内联在 workflow-events.ts 装配 seam(跨域 handler 迁出,原样搬移——行为与
5
+ * 日志文案逐字保留);ModelConfigService 缓存是 model 域状态,与 workflow 域
6
+ * 装配零数据耦合。由 setupWorkflowDomain 在原注册位置调用 setupModelEvents
7
+ * (pi.on 注册顺序逐位不变,workflow-events.test.ts 锁定)。
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
12
+ import { getModelConfigService } from "@zhushanwen/subagent-core";
13
+
14
+ // 模块级 logger(与 index.ts 同 component 名;setPiHandle 注入后自动走 appendEntry)
15
+ const logger = getLogger("subagents");
16
+
17
+ /**
18
+ * 注册 model 域事件:用户切换 model 时刷新 ModelConfigService 缓存。
19
+ */
20
+ export function setupModelEvents(pi: ExtensionAPI): void {
21
+ pi.on("model_select", (event) => {
22
+ const service = getModelConfigService();
23
+ if (service && typeof service.setCtxModel === "function") {
24
+ service.setCtxModel(event.model);
25
+ } else {
26
+ // [C2] 不再静默:service 缺席(session_start 装配链失败)时模型切换缓存未
27
+ // 刷新,后续 resolveModel 会用旧模型——warn 留痕接通「现象 → 根因」链路。
28
+ // model_select 是低频用户操作,不会刷屏。
29
+ logger.warn(
30
+ "[subagent-workflow] model_select ignored: model config service not initialized (session_start assembly failed) — model switch will not take effect for new subagents until session reload",
31
+ { model: event.model },
32
+ );
33
+ }
34
+ });
35
+ }
@@ -7,7 +7,7 @@
7
7
  * 1. identity env→appendEntry 重建(类型 13 字段含 1 个 @deprecated,写入 12)
8
8
  * 2. notify ledger host 装配 + 重启恢复
9
9
  * 3. 双 Service 装配 + initSession(createOrReuseServices 封装,单例语义 D8)
10
- * 4. GC / manifest tmp 清扫([U4c/D6] promote 退役)/ 索引重建([U4c/G1] boot 全量腿)/ worktree 恢复
10
+ * 4. GC / manifest tmp 清扫([U4c/D6] promote 退役)/ 索引重建([U4c/G1] boot 全量腿)/ worktree 恢复 / 保留维护轮兜底([W1/D5] session_start 触发)
11
11
  * 5. per-session run store + kill-9 恢复循环 + evictDoneRunsBeyondCap
12
12
  * 6. SAR + engine 基线(经 SessionLifecycleResult 返回,sessionState 写入留在组合根)
13
13
  *
@@ -21,7 +21,13 @@ import * as path from "node:path";
21
21
  import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
22
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
23
  import { getLogger } from "@zhushanwen/pi-extension-logger";
24
- import { guardStaleCtx, oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
24
+ import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
25
+ // notify ledger host 装配工厂(五端口接线 + 送达 stale 防御的共享单点实现,
26
+ // 与 session-manager notify-ledger.ts 同一装配机制收敛于此)
27
+ import { createPiNotifyLedgerHost } from "@zhushanwen/pi-notify-ledger-host";
28
+ // [W2/V4 D6] 恢复链注销直落消费的 protocol SSOT(customType + status 映射单点——
29
+ // 与 finalizeRun 直落 / reconcile-sweep 补注销同一函数,零新增定义点)。
30
+ import { mapReasonToStatus, PENDING_UNREGISTER_ENTRY_TYPE } from "@zhushanwen/extension-protocol";
25
31
 
26
32
  // ═══ core 宿主端口消费(随迁块的依赖;production 默认实现住本文件) ═══
27
33
  import { getOrCreateChannelRegistry } from "@zhushanwen/subagent-core";
@@ -37,6 +43,20 @@ import { bindNotifyLedgerHost, type NotifyLedgerHost } from "@zhushanwen/subagen
37
43
  import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "@zhushanwen/subagent-core";
38
44
  import type { ExecutionMode } from "@zhushanwen/subagent-core";
39
45
  import { maybeCleanupExpiredSessionFiles } from "@zhushanwen/subagent-core";
46
+ import { resolvePiSessionScopedDir } from "@zhushanwen/subagent-core";
47
+ // [W1 / D5] session_start 兜底触发点的统一保留维护轮:入口 + record 域目录锚
48
+ // (getSubagentRecordsDir 布局 + ENV_ROOT_CWD 贯穿 env 名单源)+ state 目录分量
49
+ // 单源 STATE_DIR_NAME——全部经 barrel 消费(壳生产消费纪律)。
50
+ import { reapOrphanRuns, runRetentionMaintenanceRound } from "@zhushanwen/subagent-core";
51
+ import { getSubagentRecordsDir } from "@zhushanwen/subagent-core";
52
+ import { ENV_ROOT_CWD } from "@zhushanwen/subagent-core";
53
+ import { STATE_DIR_NAME } from "@zhushanwen/subagent-core";
54
+ // workflow 族 customType 词表单源(workflow-record 现役 + workflow-state-link legacy
55
+ // 指针):引用集三代解析的判别字面量经 barrel 消费,勿手抄。
56
+ import {
57
+ WORKFLOW_RECORD_CUSTOM_TYPE,
58
+ WORKFLOW_STATE_LINK_CUSTOM_TYPE,
59
+ } from "@zhushanwen/subagent-core";
40
60
  import {
41
61
  getSubagentService,
42
62
  setSubagentService,
@@ -80,6 +100,13 @@ function getCachedMainSessionFile(): string | undefined {
80
100
  * 时(AGENTS.md 规则 6:首条 assistant 消息前可能不存在)返回 undefined,调用方
81
101
  * (fork 解析 / 孤儿恢复)对该场景本就无 entry 可读。
82
102
  *
103
+ * 布局对齐 pi 实装 getSessionsDir(config.js):session 文件落
104
+ * `<agentDir>/sessions/<encoded-cwd>/` 子目录(session-manager.js——`--<cwd 编码>--`
105
+ * 目录内 `<ts>_<sessionId>.jsonl`;与 collectAliveWorkflowRunReferences 同一布局
106
+ * 锚点)。子目录枚举 + 文件名后缀段 `<ts>_<sessionId>.jsonl` 匹配,slug 目录名不
107
+ * 解析(pi 编码规则演进不影响按 id 命中)。任一层 readdir 失败(sessions 根不存在
108
+ * 等)返回 undefined,调用方回落 getSessionFile()。
109
+ *
83
110
  * [pi 锚点 ADR-0063 I4] getSessionFile attach 语义:返回 `this.sessionFile` 字段
84
111
  * (pi-mono coding-agent/src/core/session-manager.ts :1011-1013),该字段仅在
85
112
  * _setSessionFile(:884/:895-896,constructor 显式路径或 setSessionFile)与
@@ -87,23 +114,172 @@ function getCachedMainSessionFile(): string | undefined {
87
114
  * extension ctx 持有的 sessionManager 若尚未重绑到 root session,getSessionFile
88
115
  * 仍回旧值(与 E2E 实测一致)。clone v0.84.2 核对,实装 0.84.4。
89
116
  */
117
+ /** sessions 根下的 encoded-cwd slug 子目录绝对路径列表(布局锚点 = `<agentDir>/sessions`
118
+ * 对齐 pi 实装 getSessionsDir,详注见 resolveMainSessionFileById 与
119
+ * collectAliveWorkflowRunReferences)。readdir 失败原样上抛——错误分通道
120
+ * (catch-all 回落 / ENOENT 空态分流)归各调用方。 */
121
+ function listSessionSlugDirs(agentDir: string): string[] {
122
+ const sessionsRoot = path.join(agentDir, "sessions");
123
+ return fs
124
+ .readdirSync(sessionsRoot, { withFileTypes: true })
125
+ .filter((ent) => ent.isDirectory())
126
+ .map((ent) => path.join(sessionsRoot, ent.name));
127
+ }
128
+
90
129
  function resolveMainSessionFileById(sessionId: string): string | undefined {
91
- const sessionsDir = path.join(getAgentDir(), "..", "sessions");
130
+ let slugDirs: string[];
92
131
  try {
93
- const match = fs.readdirSync(sessionsDir).find((f) => f.endsWith(`_${sessionId}.jsonl`));
94
- return match === undefined ? undefined : path.join(sessionsDir, match);
132
+ slugDirs = listSessionSlugDirs(getAgentDir());
95
133
  } catch {
96
134
  return undefined;
97
135
  }
136
+ const suffix = `_${sessionId}.jsonl`;
137
+ for (const dir of slugDirs) {
138
+ try {
139
+ const match = fs.readdirSync(dir).find((f) => f.endsWith(suffix));
140
+ if (match !== undefined) return path.join(dir, match);
141
+ } catch {
142
+ continue; // 单子目录读失败继续其余(宁回落 getSessionFile,不中断装配)
143
+ }
144
+ }
145
+ return undefined;
98
146
  }
99
147
 
100
- /** workflow 域 per-session state 目录探测(随迁为 module 私有,唯一消费方是随迁块)。 */
148
+ /**
149
+ * workflow 域 per-session state 目录探测(随迁为 module 私有,唯一消费方是随迁块)。
150
+ *
151
+ * [已知限制·登记] slug 锚 process.cwd()(进程 cwd)而非 session cwd:pi CLI 在
152
+ * 目录 B resume cwd 为 A 的 session 时,新 run 的 record 事件流落 B 的 slug
153
+ * 目录、旧 run 的在 A——GC/retention sweep(core 同源推导)读不到旧 run 的磁盘
154
+ * 足迹,兜底失效。主数据不受影响(权威 entry 在 session 文件里,跨 cwd 可重建)。
155
+ * 布局单源在 core resolvePiSessionScopedDir(workflow-state-root.ts)——修复改锚
156
+ * 只动 core 单点,本薄消费自动跟随。taiji 桌面场景(spawn cwd 恒等于 session
157
+ * cwd)不触发,仅裸 pi CLI 的跨目录 resume 触发,故登记不改。
158
+ */
101
159
  function resolveSessionDir(): string {
102
- const defaultDir = getAgentDir();
103
- const sessionSlug = `--${process.cwd().replace(/^\//, "").replace(/\//g, "-")}--`;
104
- // F2:根改 getAgentDir() 派生(实例隔离);保留 sessionScopedDir 存在则用之的探测语义
105
- const sessionScopedDir = path.join(getAgentDir(), "sessions", sessionSlug);
106
- return fs.existsSync(sessionScopedDir) ? sessionScopedDir : defaultDir;
160
+ // F2:agentDir 走 pi SDK 活源注入(实例隔离);slug + 探测布局单源在 core。
161
+ return resolvePiSessionScopedDir({ agentDir: getAgentDir() });
162
+ }
163
+
164
+ // ── [裁决点 7] 存活 session 引用集采集(壳侧注入面,core 不 import pi SDK)──────
165
+ //
166
+ // 全池 session 文件流式扫描(sessions 根 + 全 slug 子目录的 *.jsonl),逐文件
167
+ // 提 workflow run 的注册引用并集——三代形态都解析(v2 注册条目 / v1 全量快照
168
+ // 条目 / pre-W17 link 指针):
169
+ // - v2:workflow-record custom entry,data.kind === "registered" → data.runId;
170
+ // - v1:data.v === 1 且 data.snapshot.runId 为 string → snapshot.runId;
171
+ // - link:workflow-state-link custom entry → data.runId。
172
+ // 只认 v2 会把存量 run 首轮误判无主并不可逆删除(设计裁决点 7「引用集三代形态」)。
173
+ //
174
+ // 行预过滤(customType 子串)+ 逐行 JSON.parse 的宽容扫描:单文件解析失败跳过
175
+ // (坏文件不阻断整轮——引用集缺侧 = 宁保留方向:少采集到的 run 引用会使其
176
+ // 进观察期而非直接删除,宽限窗兜底)。成本量级 = 全池文件流式扫描,秒级~
177
+ // 十秒级(设计「实现落点」登记的实测预期——每 session_start 一次,oncePerProcess
178
+ // 守卫下进程内单跑)。
179
+
180
+ /** Node fs 错误 code 判定(ENOENT = 路径不存在;core shared/fs-error 的 errorCodeOf
181
+ * 未进 barrel——本地等价 helper,判据与 pi-host-run-store 的读错分通道同款)。 */
182
+ function isEnoent(err: unknown): boolean {
183
+ return typeof err === "object" && err !== null && (err as NodeJS.ErrnoException).code === "ENOENT";
184
+ }
185
+
186
+ /** workflow custom entry 的最小消费视图(宽容扫描用):customType 保持 unknown
187
+ * (值域分流在调用方),data 已过对象校验(字段级读取仍走 unknown 判型)。 */
188
+ interface WorkflowCustomEntryView {
189
+ customType: unknown;
190
+ data: Record<string, unknown>;
191
+ }
192
+
193
+ /** [extractRunReferencesFromLine 守卫] 是否为 data 载荷成形的 workflow custom
194
+ * entry(type=custom 且 data 为对象;customType 免判型——分流在调用方)。 */
195
+ function isWorkflowCustomEntry(v: unknown): v is WorkflowCustomEntryView {
196
+ if (typeof v !== "object" || v === null) return false;
197
+ const rec = v as Record<string, unknown>;
198
+ return rec.type === "custom" && typeof rec.data === "object" && rec.data !== null;
199
+ }
200
+
201
+ function extractRunReferencesFromLine(line: string, out: Set<string>): void {
202
+ const trimmed = line.trim();
203
+ if (trimmed === "" || !trimmed.includes("workflow")) return; // 行级预过滤
204
+ let parsed: unknown;
205
+ try {
206
+ parsed = JSON.parse(trimmed);
207
+ } catch {
208
+ return;
209
+ }
210
+ if (!isWorkflowCustomEntry(parsed)) return;
211
+ const d: Record<string, unknown> = parsed.data;
212
+ if (parsed.customType === WORKFLOW_RECORD_CUSTOM_TYPE) {
213
+ extractWorkflowRecordRunReference(d, out);
214
+ } else if (parsed.customType === WORKFLOW_STATE_LINK_CUSTOM_TYPE) {
215
+ if (typeof d.runId === "string" && d.runId !== "") out.add(d.runId);
216
+ }
217
+ }
218
+
219
+ /** [extractRunReferencesFromLine 拆分] workflow-record 条目两代引用提取
220
+ * (v2 registered 的 runId / v1 快照内 runId)。 */
221
+ function extractWorkflowRecordRunReference(
222
+ d: Record<string, unknown>,
223
+ out: Set<string>,
224
+ ): void {
225
+ if (d.kind === "registered" && typeof d.runId === "string" && d.runId !== "") {
226
+ out.add(d.runId);
227
+ } else if (d.v === 1 && typeof d.snapshot === "object" && d.snapshot !== null) {
228
+ const snapRunId: unknown = (d.snapshot as Record<string, unknown>).runId;
229
+ if (typeof snapRunId === "string" && snapRunId !== "") out.add(snapRunId);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * [裁决点 7] 采集全部存活 session 的 workflow run 引用并集(agentDir 活源)。
235
+ *
236
+ * sessions 布局对齐 pi 实装 getSessionsDir(config.js):`<agentDir>/sessions`
237
+ * 下的 encoded-cwd 子目录(session-manager.js——`--<cwd 编码>--` 目录内
238
+ * `<ts>_<sessionId>.jsonl`)。指向错误层级会静默扫出空集,裁决点 7 的引用
239
+ * 保护整体落空(活跃 run 误判无主)——布局锚点以 pi dist 实装为准。
240
+ */
241
+ async function collectAliveWorkflowRunReferences(
242
+ agentDir: string,
243
+ ): Promise<ReadonlySet<string>> {
244
+ const refs = new Set<string>();
245
+ let slugDirs: string[];
246
+ try {
247
+ slugDirs = listSessionSlugDirs(agentDir);
248
+ } catch (err) {
249
+ // 读错分通道(对齐 core pi-host-run-store 同款纪律):ENOENT = 从未落盘的
250
+ // 正常空态,空集返回(core reapOrphanRuns 按「无引用」正常判定);非 ENOENT
251
+ // (EACCES/EIO 等真 IO 故障)上抛——空集会把「引用状态不可知」折叠成「无任何
252
+ // 引用」的成功返回,core 的「采集失败 = 整轮跳过(宁保留)」防御(reapOrphan
253
+ // Runs 的 collectAliveRunReferences catch)在生产路径将不可达,持续 IO 权限
254
+ // 故障下被存活 session 引用的 run 会在宽限窗后被不可逆删除。
255
+ if (isEnoent(err)) return refs;
256
+ throw err;
257
+ }
258
+ const readOpts = { encoding: "utf8" as const };
259
+ for (const dir of slugDirs) {
260
+ let files: string[];
261
+ try {
262
+ files = fs.readdirSync(dir);
263
+ } catch (err) {
264
+ // 同款分通道:ENOENT = 该 slug 目录被并发清走(会话删除竞态),跳过;真 IO
265
+ // 故障上抛整轮跳过(该目录下 session 的引用集缺席只延后删除——但「缺席的
266
+ // 原因不可知」时按失败处置,不冒充空集)。
267
+ if (!isEnoent(err)) throw err;
268
+ continue;
269
+ }
270
+ for (const file of files) {
271
+ if (!file.endsWith(".jsonl")) continue;
272
+ try {
273
+ const content = fs.readFileSync(path.join(dir, file), readOpts);
274
+ for (const line of content.split("\n")) {
275
+ extractRunReferencesFromLine(line, refs);
276
+ }
277
+ } catch {
278
+ continue; // 单文件读失败跳过(宁保留——该文件的引用缺席只延后删除)
279
+ }
280
+ }
281
+ }
282
+ return refs;
107
283
  }
108
284
 
109
285
  // ── 进程级单例(dialog queue;原 index.ts module 级随域搬移) ────────────────────
@@ -190,7 +366,7 @@ export interface SessionLifecycleResult {
190
366
  store: JsonlRunStore;
191
367
  runs: Map<string, WorkflowRun>;
192
368
  sessionDir: string;
193
- /** D-008 per-session SAR(需要 ctxModel + subagentService 委托目标) */
369
+ /** D-008 per-session SAR(subagentService 委托目标) */
194
370
  runner: SubprocessAgentRunner;
195
371
  /** session 上下文(notifyDone 需要 GuiContext) */
196
372
  ctx: ExtensionContext;
@@ -278,10 +454,6 @@ function createOrReuseServices(pi: ExtensionAPI, ctx: ExtensionContext): Service
278
454
  setSubagentService(service);
279
455
  }
280
456
 
281
- // S-2: 启动 idle record GC 定时器(30 天 TTL,每小时检查一次)。
282
- // 注册 setInterval 属进程级副作用——oncePerProcess 守卫防双跑(u-audit-fix)。
283
- oncePerProcess("subagent-workflow:start-gc-timer", () => service.startGcTimer());
284
-
285
457
  return { service, modelService, reused };
286
458
  }
287
459
 
@@ -350,45 +522,38 @@ function appendSubagentIdentityEntry(pi: ExtensionAPI): void {
350
522
  * 返回 undefined。
351
523
  */
352
524
  export function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): NotifyLedgerHost | undefined {
525
+ // host 五端口接线 + 送达 stale 防御(D5 单通道 {triggerTurn:true} / stale 静默降级 /
526
+ // abandon 补显形 T4③)收敛在 @zhushanwen/pi-notify-ledger-host 工厂,权威注释随迁
527
+ // 工厂内;component/logger 参数保持本包 label 前缀与 warn 通道归因不变,
528
+ // sendDisplayMessage = 生产 bind 恒实现(NotifyLedgerHost 可选端口的本侧既有语义)。
529
+ const ledgerHost = createPiNotifyLedgerHost(pi, ctx, {
530
+ component: "subagent-workflow",
531
+ logger,
532
+ sendDisplayMessage: true,
533
+ });
534
+ // bind 与 recover 拆独立 try(失败归因不同):
535
+ // - bind 失败:槽上无 ledger,消费方(getBoundNotifyLedger)退回内核直发路径;
536
+ // - recover 失败:bind 已成功、槽上 ledger 仍在,消费方照常走账本路径(重启重放
537
+ // 缺席,边沿/看门狗仍投新通知)——不得共用 "bind failed" 文案误报。
538
+ let ledger: ReturnType<typeof bindNotifyLedgerHost>;
353
539
  try {
354
- const ledgerHost: NotifyLedgerHost = {
355
- appendLedgerEntry: (customType, data) => {
356
- pi.appendEntry(customType, data);
357
- },
358
- readSessionEntries: () => ctx.sessionManager.getEntries(),
359
- isIdle: () => ctx.isIdle(),
360
- onAgentSettled: (handler) => {
361
- pi.on("agent_settled", handler);
362
- },
363
- sendDelivery: (message) => {
364
- // D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
365
- // courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
366
- // stale ctx 防御(crash-resilience D1 / ext-guards 审计 §7 blockers#1 收口):
367
- // sendDelivery 经 settled 边沿 / 看门狗 / 恢复重放异步触发——session 替换窗口
368
- // 触碰 stale pi 命中 assertActive(PS-30)即无人接 rejection(E1 同机制)。
369
- // stale 静默降级(本条通知不投递,attemptDeliver 按已受理标 sentAt——session
370
- // 替换后通知对旧 session 已无意义,与守卫前「留 pending 反复撞 stale 直到账本
371
- // 重绑」终局一致),非 stale 错误原样上抛(attemptDeliver 既有 catch 走
372
- // settleRejected 留账重试语义不变)。
373
- guardStaleCtx(() => pi.sendMessage(message, { triggerTurn: true }), {
374
- label: "subagent-workflow:sendDelivery",
375
- onStale: (error) =>
376
- logger.warn("notify delivery skipped (stale ctx)", {
377
- error: toErrorMessage(error),
378
- }),
379
- });
380
- },
381
- };
382
- // U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
383
- // 通道落盘),此处不再重复打日志。
384
- bindNotifyLedgerHost(ledgerHost).recoverFromSession();
385
- return ledgerHost;
540
+ ledger = bindNotifyLedgerHost(ledgerHost);
386
541
  } catch (err) {
387
542
  logger.warn("[subagents] notify ledger bind failed", {
388
543
  reason: toErrorMessage(err),
389
544
  });
390
545
  return undefined;
391
546
  }
547
+ try {
548
+ // U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
549
+ // 通道落盘),此处不再重复打日志。
550
+ ledger.recoverFromSession();
551
+ } catch (err) {
552
+ logger.warn("[subagents] notify ledger recoverFromSession failed (ledger stays bound)", {
553
+ reason: toErrorMessage(err),
554
+ });
555
+ }
556
+ return ledgerHost;
392
557
  }
393
558
 
394
559
  /**
@@ -458,6 +623,85 @@ async function runProcessLevelMaintenance(
458
623
  reason: toErrorMessage(err),
459
624
  });
460
625
  }
626
+
627
+ // [W1 / D5] session_start 兜底触发:统一保留维护轮(冷启动即清——进程首启
628
+ // 无任何新写时的清理机会,覆盖「崩溃后重开的会话」历史遗留)。run 域目录锚
629
+ // = resolveSessionDir 同源(与 JsonlRunStore 落盘同布局);record 域目录锚 =
630
+ // getSubagentRecordsDir(agentDir, rootCwd),rootCwd 推导与 SubagentService 构造点
631
+ // (SessionBaselines)同式:env 贯穿 ?? ctx.cwd 兜底,env 名单源 ENV_ROOT_CWD——
632
+ // enc 段不漂移(sessions 与 records 两目录同源不变量)。进程级维护幂等,
633
+ // oncePerProcess 守卫防双跑;/resume /fork 同进程复用时跳过(record-only 会话
634
+ // 的持续累积由 record 首写触发点 RecordStore.register 覆盖)。
635
+ try {
636
+ await oncePerProcess("subagent-workflow:retention-maintenance-round", async () => {
637
+ const envRootCwd = process.env[ENV_ROOT_CWD];
638
+ const rootCwd = envRootCwd && envRootCwd !== "" ? envRootCwd : ctx.cwd;
639
+ await runRetentionMaintenanceRound(
640
+ {
641
+ stateDir: path.join(resolveSessionDir(), STATE_DIR_NAME),
642
+ recordsDir: getSubagentRecordsDir(agentDir, rootCwd),
643
+ },
644
+ {},
645
+ {
646
+ warn: (msg) => logger.warn(`[subagent-workflow] ${msg}`),
647
+ debug: (msg) => logger.debug(`[subagent-workflow] ${msg}`),
648
+ toMsg: (err: unknown) => toErrorMessage(err),
649
+ },
650
+ );
651
+ // [裁决点 7](workflow-run-resume-revision)孤儿 run 对账清理:引用集采集 =
652
+ // 壳侧注入面(core 不 import pi SDK——全池 session 文件流式扫描,v2 注册
653
+ // 条目 ∪ v1 快照条目 ∪ pre-W17 link 指针三代解析的并集)。同维护轮触发点、
654
+ // 同 oncePerProcess 守卫(幂等整轮);宽限窗缺省 7 天(env 可调)。
655
+ // 触发面 = 全部 state 目录(对齐 core pi-host-run-store 枚举口径:agentDir
656
+ // 根回退 + sessions/<slug>/workflow-state 全目录 + 本 session 锚定目录)——
657
+ // 「引用 session 全删的目录」的残留 run 对本 session 维护轮结构性可达,判定
658
+ // 与删除判据归 core reapOrphanRuns,本处只做目录枚举。引用集预采集一次复用
659
+ // 全部目录(同轮同快照,逐目录重复全池扫描无增益);登记文件
660
+ // (orphan-run-reap.json)落各自 stateDir,多目录天然隔离。
661
+ const stateDirs = new Set<string>([
662
+ path.join(resolveSessionDir(), STATE_DIR_NAME),
663
+ path.join(agentDir, STATE_DIR_NAME),
664
+ ]);
665
+ try {
666
+ for (const ent of fs.readdirSync(path.join(agentDir, "sessions"), { withFileTypes: true })) {
667
+ if (ent.isDirectory()) {
668
+ stateDirs.add(path.join(agentDir, "sessions", ent.name, STATE_DIR_NAME));
669
+ }
670
+ }
671
+ } catch (err) {
672
+ // sessions 根不可枚举:ENOENT = 从未落盘(首次运行形态),仅保留上方两
673
+ // 目录锚;非 ENOENT(EACCES/EIO 等真 IO 故障)debug 留痕不放大——枚举
674
+ // 缺侧 = 少扫目录(宁保留方向,宽限窗兜底),对齐读错分通道纪律。
675
+ if (!isEnoent(err)) {
676
+ logger.debug(
677
+ `[subagent-workflow] sessions 根目录枚举失败,state 目录触发面缺侧:${toErrorMessage(err)}`,
678
+ );
679
+ }
680
+ }
681
+ // 引用集惰性单次采集(同轮同快照,逐目录重复全池扫描无增益):promise 在
682
+ // core reapOrphanRuns 的 await 表达式内才创建——采集器上抛(真 IO 故障,
683
+ // 读错分通道)时拒绝被该 await 的 try/catch 即时接住,不产生提前创建导致的
684
+ // 未处理拒绝告警;登记文件(orphan-run-reap.json)落各自 stateDir,多目录天然隔离。
685
+ let aliveRefsPromise: Promise<ReadonlySet<string>> | undefined;
686
+ const collectAliveRefsOnce = (): Promise<ReadonlySet<string>> => {
687
+ aliveRefsPromise ??= collectAliveWorkflowRunReferences(agentDir);
688
+ return aliveRefsPromise;
689
+ };
690
+ const reapDeps = {
691
+ collectAliveRunReferences: collectAliveRefsOnce,
692
+ warn: (msg: string) => logger.warn(`[subagent-workflow] ${msg}`),
693
+ debug: (msg: string) => logger.debug(`[subagent-workflow] ${msg}`),
694
+ toMsg: (err: unknown) => toErrorMessage(err),
695
+ };
696
+ for (const dir of stateDirs) {
697
+ await reapOrphanRuns({ stateDir: dir }, reapDeps);
698
+ }
699
+ });
700
+ } catch (err) {
701
+ logger.warn("[subagents] retention maintenance round failed", {
702
+ reason: toErrorMessage(err),
703
+ });
704
+ }
461
705
  }
462
706
 
463
707
  /** createSessionRunState 返回值(随迁块 5 的装配产物)。 */
@@ -473,7 +717,8 @@ interface SessionRunState {
473
717
  *
474
718
  * MF-1: store 健康度跟踪。loadAll 失败 → storeHealthy=false,workflow 域启动时 fail-fast。
475
719
  * 崩溃恢复四步(loadAll → failed → save → evict)收口到 core recoverCrashedRuns(D8:
476
- * 宿主各写一遍正是 failure-mode-B);pending:unregister 经 hooks 外置发射(位置在
720
+ * 宿主各写一遍正是 failure-mode-B);pending:unregister 经 hooks 外置回调在本面
721
+ * appendEntry 直落([W2/V4 D6] 与 reload-closeout D4 定案对齐——不经 emit;位置在
477
722
  * transition 后、save 前,对齐原内联实现);save 走 store 冷路径(done 绕过去抖)——
478
723
  * 冷路径语义在 JsonlRunStore.save 内,不随循环归属转移。loadAll 失败的 fail-fast
479
724
  * (storeHealthy=false 停初始化)是宿主职责,core 原样上抛、这里 catch 兜住。
@@ -501,32 +746,55 @@ async function createSessionRunState(
501
746
  let storeHealthy = true;
502
747
  // [skill-reload D4] 恢复门控:session_start(reason==='reload') 全程不跑
503
748
  // recoverCrashedRuns(无论条目有无)。暗礁(设计 §2.4):recoverCrashedRuns 判
504
- // 「crashed」只看磁盘快照 status=running、内存活 run 不参与判定且会被重建对象
505
- // 覆盖——契约前提是「拥有这些 run 的进程已死」,而 reload 恰恰证明进程没死,
506
- // 跑恢复即误杀窗口内存活的 run。条目缺失场景同理门控:磁盘可能有本 session 的
507
- // running entry(前一轮 adoption 未完成又 reload 的窗口),由下一次**非 reload**
508
- // 的 session_start(真重启/切换)按既有 kill-9 语义收编。跳过 loadAll 时无从
509
- // 证伪健康度:storeHealthy 保持 true(workflow 域可用,可派发新 run)。门控放
510
- // 调用点先于条目判断(setupSessionLifecycle 分流处),不进 oncePerProcess 守卫
511
- // 内——守卫 Map 是模块级状态 reload 后归零(D9 不提权),靠守卫判 reason 形同虚设。
749
+ // 「crashed」只看 record fold 出的 running 态(loadAll 产物;runs Map 内存活
750
+ // run 不参与判定)——loadAll 折叠重建出 running 态即经 interruptRun(终局编排
751
+ // 单一入口)收编,事件流不含进程存活信息、窗口内存活 run 的流同样是 running
752
+ // 形态——契约前提是「拥有这些 run 的进程已死」,而 reload 恰恰证明进程没死,
753
+ // 跑恢复即误杀窗口内存活的 run。条目缺失场景同理
754
+ // 门控:磁盘可能有本 session 的 running entry(前一轮 adoption 未完成又
755
+ // reload 的窗口),由下一次**非 reload** 的 session_start(真重启/切换)按既有
756
+ // kill-9 语义收编。跳过 loadAll 时无从证伪健康度:storeHealthy 保持 true
757
+ // (workflow 域可用,可派发新 run)。
512
758
  if (!opts.skipRecovery) {
513
759
  try {
514
- // 崩溃恢复 loadAll 扫 cwd 共享 sessionDir(同 cwd 跨 session 共享)并把 running run
515
- // 转 failed 落盘——写非本 session 的 run state 文件属跨 session 副作用,oncePerProcess
516
- // 守卫防双跑(u-audit-fix)。第二派发重放首次 Promise:不再落盘、不再 emit。
517
- await oncePerProcess(
518
- "subagent-workflow:recover-crashed-runs",
519
- () =>
520
- recoverCrashedRuns(
521
- store,
522
- runs,
523
- "Process killed (kill-9 or crash recovery)",
524
- {
525
- onRunRecovered: (payload) => {
526
- pi.events.emit("pending:unregister", payload);
527
- },
528
- },
529
- ),
760
+ // [B1 修复] 恢复不挂 oncePerProcess:loadAll 只认本 session 的 v2 注册
761
+ // 条目(record 流折叠重建,W17 定界的现行形态)、收编即向本 run 的 record
762
+ // 流追加 run-interrupted 转移事件(不覆盖任何文件,天然幂等)——恢复是
763
+ // session 级幂等操作,挂进程级守卫会让同进程的后续 session_start(如 /new
764
+ // 后 /resume 一个上次崩溃退出的 session)重放首次 Promise、跳过 loadAll,
765
+ // 该 session 的 running 残留既不收编也不进 run 列表。reload 的防误杀由上方
766
+ // opts.skipRecovery(分流处按 reason 判定)承担,无需进程级守卫。
767
+ const { loaded, recovered } = await recoverCrashedRuns(
768
+ store,
769
+ runs,
770
+ "Process killed (kill-9 or crash recovery)",
771
+ {
772
+ onRunRecovered: (payload) => {
773
+ // [W2/V4 D6] 注销直落权威面:appendEntry 直接落盘,不经 emit。
774
+ // [reload-closeout D4] 定案「emit 链在 reload 转换窗失效、appendEntry
775
+ // 是唯一可靠通道」在崩溃恢复链同样适用(session_start 装配窗可能仍在
776
+ // reload 转换内);pending 域消费方全部从持久化 entries 现算,直落对其
777
+ // 即时生效。status 经 protocol mapReasonToStatus 单点(与 finalizeRun
778
+ // 直落 / reconcile-sweep 补注销同一函数);抛错由 core 侧 hook 围栏
779
+ // warn 留痕后恢复循环继续,差集残留交 reconcile-sweep 下次收口。
780
+ pi.appendEntry(PENDING_UNREGISTER_ENTRY_TYPE, {
781
+ id: payload.id,
782
+ reason: payload.reason,
783
+ status: mapReasonToStatus(payload.reason),
784
+ });
785
+ },
786
+ // [W1 / D4] 收编终态条目补写(恢复链收编的条目半边):core 在 journal
787
+ // run-settled 落账后回调本面,v2 终态条目经当前 pi appendEntry 落主
788
+ // session(注册 + 终态两条 v2 小条目的收编补齐;本 store loadAll 对
789
+ // 「journal 已终局而条目缺失」形态的幂等补写与此同构)。围栏在 core 消费
790
+ // 侧(同步抛错 warn 后恢复循环继续,lifecycle.ts hooks 契约)。
791
+ appendSettledEntry: (customType, data) => {
792
+ pi.appendEntry(customType, data);
793
+ },
794
+ },
795
+ );
796
+ logger.debug(
797
+ `[subagent-workflow] recoverCrashedRuns: loaded=${loaded} recovered=${recovered}`,
530
798
  );
531
799
  } catch (err) {
532
800
  // QMF-4 fix: store.loadAll 失败是关键路径错误,workflow 域将未初始化
@@ -544,7 +812,7 @@ async function createSessionRunState(
544
812
  /**
545
813
  * adoption 主体:接管既有条目(同引用原地改写)。成功返回原 SessionLifecycleResult
546
814
  * (sessionState.get(sid) 与 reload 前同一引用——探针红线,store/runs 不换实例);
547
- * 失败(健康检查不过 / rebind / 快照重发抛错)走 {@link failAdoption} 后返回
815
+ * 失败(健康检查不过 / rebind 抛错)走 {@link failAdoption} 后返回
548
816
  * undefined(调用方落到全量装配)。
549
817
  */
550
818
  async function tryAdoptExistingSession(
@@ -561,22 +829,19 @@ async function tryAdoptExistingSession(
561
829
  return undefined;
562
830
  }
563
831
  try {
564
- // D3 rebind:在飞去抖批与串行 flush 链持有 store(this),原地改写 .pi/.ctx
565
- // 对后续 flush 天然可见;换入的 appendEntry 源带 stale guard(D5)。
832
+ // D3 rebind:store 实例跨 reload 存活(this 引用不变),原地改写 .pi/.ctx;
833
+ // 换入的 appendEntry 源带 stale guard(D5)。[D1] record 单源后 store 无投影
834
+ // 物化面(原 D4 快照重发随 state 快照删除而退役)——接管动作收敛为 rebind,
835
+ // 后续 v2 终态条目补写自动走新 pi。
566
836
  existing.store.rebind(pi, ctx);
567
- // D4 快照重发:经 store 既有 per-runId 串行 flush 链重发当前快照权威 entry
568
- //(设计红线:禁止绕链直接 pi.appendEntry——物理乱序会让 last-ways 读回
569
- // running → 崩溃恢复误判)。任何 IO 失败上抛 → 失败处置。
570
- await existing.store.resendSnapshots(existing.runs);
571
837
  } catch (err) {
572
838
  await failAdoption(pi, ctx, deps, existing, toErrorMessage(err));
573
839
  return undefined;
574
840
  }
575
841
  // 接管:同引用原地改写(ctx 换新——旧 ctx 已被 invalidate;lastEngine 按当前
576
- // config 重置基线)+ runner 刷新 ctxModel。跳过 store/runner 重建(幂等 last-wins)。
842
+ // config 重置基线)。跳过 store/runner 重建(幂等 last-wins)。
577
843
  existing.ctx = ctx;
578
844
  existing.lastEngine = lastEngine;
579
- existing.runner.updateCtxModel(ctx.model ?? undefined);
580
845
  logger.debug(
581
846
  `[subagent-workflow] adoption ok (sessionId=${existing.sessionId}, runs=${existing.runs.size})`,
582
847
  );
@@ -703,12 +968,10 @@ export async function setupSessionLifecycle(
703
968
  skipRecovery: isReload,
704
969
  });
705
970
 
706
- // D-008: per-session SAR(需要 ctxModel 填底 + subagentService 委托目标)。
707
- // old: const runner = new SubprocessAgentRunner()(module-level singleton,无 deps)
708
- // new: per-session session_start 时创建,经 SessionLifecycleResult 传给组合根。
971
+ // D-008: per-session SAR(subagentService 委托目标)——per-session session_start
972
+ // 时创建,经 SessionLifecycleResult 传给组合根。
709
973
  const runner = new SubprocessAgentRunner({
710
974
  subagentService: service,
711
- ctxModel: ctx.model ?? undefined,
712
975
  });
713
976
 
714
977
  return {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * subagents-events — subagents 域父级联事件 handler(/fork /new 级联关闭)。
3
+ *
4
+ * 原内联在 workflow-events.ts 装配 seam(跨域 handler 迁出,原样搬移——行为与
5
+ * 日志文案逐字保留);级联关闭的对象是 SubagentService 的 record 池,与 workflow
6
+ * 域装配零数据耦合。由 setupWorkflowDomain 在原注册位置调用
7
+ * setupSubagentsCascadeEvents(pi.on 注册顺序逐位不变,
8
+ * workflow-events.test.ts 锁定)。
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
13
+ import { getSubagentService } from "@zhushanwen/subagent-core";
14
+
15
+ // 模块级 logger(与 index.ts 同 component 名;setPiHandle 注入后自动走 appendEntry)
16
+ const logger = getLogger("subagents");
17
+
18
+ /**
19
+ * SP-4: 注册父级联关闭事件(/fork 与 /new)。
20
+ *
21
+ * 主 session /fork 或 /new 时,清理旧 record(disposeAllRecords:CAS 转终态 +
22
+ * archive + worktree 清理)。before 事件在 session 替换前触发,确保旧 session 的
23
+ * subagent 在新 session 创建前被清理(随后的 session_shutdown → dispose 收割子进程)。
24
+ *
25
+ * [M2 修复] 旧实现把 /new 级联挂在 session_before_tree 上——SDK 中该事件只由
26
+ * AgentSession.navigateTree()(/tree 同 session 分支切换)触发,/new 走
27
+ * session_before_switch(reason:"new") + session_shutdown(reason:"new"),从不触发
28
+ * before_tree。后果双向:/new 级联是死代码;普通 /tree 分支导航反而误杀全部活跃
29
+ * subagent。现 /new 改挂 session_before_switch(reason==="new"),before_tree handler
30
+ * 移除(/tree 是同 session 内导航,record/子进程归属不变,无级联关闭诉求)。
31
+ */
32
+ export function setupSubagentsCascadeEvents(pi: ExtensionAPI): void {
33
+ pi.on("session_before_fork", (_event, _ctx) => {
34
+ const service = getSubagentService();
35
+ if (service) {
36
+ const count = service.onParentFork();
37
+ if (count > 0) {
38
+ logger.warn(`[subagents] /fork 级联关闭 ${count} 个 subagent`);
39
+ }
40
+ }
41
+ });
42
+
43
+ pi.on("session_before_switch", (event, _ctx) => {
44
+ // /new(reason:"new")创建全新 session → 级联关闭旧 record。
45
+ // reason:"resume"(/resume /import 回到已有 session)不级联:record 按 rootSessionId
46
+ // 归属隔离,跨 session 读写由 store 过滤守卫,无需销毁。
47
+ if (event.reason !== "new") return;
48
+ const service = getSubagentService();
49
+ if (service) {
50
+ const count = service.onParentNew();
51
+ if (count > 0) {
52
+ logger.warn(`[subagents] /new 级联关闭 ${count} 个 subagent`);
53
+ }
54
+ }
55
+ });
56
+ }