@zhushanwen/pi-subagent-workflow 8.14.2 → 8.14.4

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.
@@ -0,0 +1,576 @@
1
+ /**
2
+ * workflow-events — workflow 域事件族装配 seam。
3
+ *
4
+ * 随迁内容 = 原组合根 index.ts 的 workflow 域闭包状态与事件 handler(原样搬移,
5
+ * D2 纪律:本文件不改行为;lazyDeps 10 个同构 getter 收敛为 createLazy 单一原语
6
+ * ——守卫/throw/转发语义与错误消息逐字保留)。原闭包变量收编为显式
7
+ * WorkflowDomainState([skill-reload D2] 提权为 globalThis Symbol 槽 get-or-create
8
+ * ——pi reload 时 jiti(moduleCache:false) 重求值模块图、模块级状态归零,槽跨
9
+ * reload 存活,factory 重跑拿到同一 domain state,post-reload adoption 据此接管
10
+ * 在飞 run):
11
+ * 1. per-factory 域状态(lsRef / notifiedRunIds / workerHost / registry / sessionState)
12
+ * 2. log(pi.appendEntry 包装)+ makeLifecycleDeps + makeDeps(LauncherDeps 生产装配)
13
+ * 3. 7 个 pi.on handler(session_start / session_compact / model_select /
14
+ * session_tree / session_before_fork / session_before_switch / session_shutdown,
15
+ * 注册相对顺序原样保留)
16
+ * 4. getWorkflowDeps 守卫(单一出口,discriminated union)+ lazyDeps(tool lazy 注入源)
17
+ *
18
+ * 组合根消费面:setupWorkflowDomain(pi, { inflightReporter }) 返回
19
+ * WorkflowDomainHandle(state / lazyDeps / getWorkflowDeps / isScriptRunning),
20
+ * tool + command 注册与 pi.__workflowRun 仍留 index.ts。
21
+ *
22
+ * [skill-reload D3] makeDeps 的三个 volatile 成员(eventBus / log / onRunDone 的
23
+ * pi 与 GuiContext)不做闭包快照:pi 从槽上 currentPi 现读(factory 重跑时覆盖
24
+ * 登记),ctx 从 sessionState 条目的 state.ctx 现读(adoption rebind 换新后自动
25
+ * 跟进)——在飞 pump 持有的旧 deps 对象经属性访问自动路由到新绑定,无需遍历重绑。
26
+ *
27
+ * 测试入口:既有 index 挂载类测试(index-session-start / process-shutdown-hook /
28
+ * wave0-package-structure 等)经 factory 间接覆盖;mock 锚点是模块解析路径
29
+ * (jsonl-run-store / interface/* / subagent-core 深路径),随迁不改写。
30
+ *
31
+ * 架构导航见 docs/extensions/subagents/architecture.md §2.1。
32
+ */
33
+
34
+ import type {
35
+ ExtensionAPI,
36
+ ExtensionContext,
37
+ SessionCompactEvent,
38
+ SessionShutdownEvent,
39
+ SessionStartEvent,
40
+ SessionTreeEvent,
41
+ } from "@earendil-works/pi-coding-agent";
42
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
43
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
44
+ // ═══ 经 core barrel 消费 workflow 域(引擎与 worker 住 packages/subagent-core) ═══
45
+ import { bestEffort } from "@zhushanwen/subagent-core";
46
+ import { getBoundNotifyLedger } from "@zhushanwen/subagent-core";
47
+ import { getModelConfigService } from "@zhushanwen/subagent-core";
48
+ import { getSubagentService } from "@zhushanwen/subagent-core";
49
+ import type { LauncherDeps } from "@zhushanwen/subagent-core";
50
+ import { executeNestedWorkflow, terminateRunningRuns } from "@zhushanwen/subagent-core";
51
+ import {
52
+ evictDoneRunsBeyondCap,
53
+ MAX_RETAINED_DONE_RUNS,
54
+ scheduleTimeBudget,
55
+ } from "@zhushanwen/subagent-core";
56
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
57
+ import { WorkerHostImpl } from "@zhushanwen/subagent-core";
58
+ import { WorkflowScriptRegistryImpl } from "@zhushanwen/subagent-core";
59
+ // [u7a D5] 在途上报出口类型(实例由组合根创建并接线 setInFlightListener,
60
+ // 本模块只在 session_start / session_shutdown 驱动 attach/detach)。
61
+ import type { InFlightReporter } from "./host/inflight-reporter.ts";
62
+ import { toGuiCtx } from "./interface/gui-mappers.ts";
63
+ import { notifyDone, trackNotifiedRunId } from "./interface/helpers.ts";
64
+ // ═══ session 生命周期装配 seam(bootstrap seam,设计 §3.1/D1) ═══
65
+ import {
66
+ getOrCreateDialogQueue,
67
+ setupSessionLifecycle,
68
+ type SessionLifecycleDeps,
69
+ type SessionLifecycleResult,
70
+ } from "./session-lifecycle.ts";
71
+
72
+ // 模块级 logger(与 index.ts 同 component 名;setPiHandle 注入后自动走 appendEntry)
73
+ const logger = getLogger("subagents");
74
+
75
+ // ── per-factory 域状态(原 index.ts factory 闭包变量的显式收编) ────────────────
76
+
77
+ export interface WorkflowDomainState {
78
+ /** 单值假设(M-2):Pi 当前保证单 session 串行,lastSessionId 即当前活跃 session。 */
79
+ lsRef: { lastSessionId: string };
80
+ /** notifyDone 完成通知的去重窗口(trackNotifiedRunId 有界化维护)。 */
81
+ notifiedRunIds: Set<string>;
82
+ /** Infra 实例(per-factory 单例,跨 session 复用)。 */
83
+ workerHost: WorkerHostImpl;
84
+ /** workflow 脚本仓库(workflow-script tool 与 makeDeps 共享同一实例)。 */
85
+ registry: WorkflowScriptRegistryImpl;
86
+ /** per-session 状态(session_start 时重建)。value = SessionLifecycleResult
87
+ * (setupSessionLifecycle 装配结果,ctx 必有)。 */
88
+ sessionState: Map<string, SessionLifecycleResult>;
89
+ /** [skill-reload D3] current pi 的 volatile 登记点:setupWorkflowDomain 每次
90
+ * factory 重跑开头覆盖(reload 后新 factory 持新 pi)。makeDeps 的 eventBus /
91
+ * log / onRunDone 三 volatile 成员经属性访问从本成员现读——在飞 pump 持有的
92
+ * 旧 deps 对象因此自动解析到新 pi,无需遍历重绑。undefined = setup 未跑过
93
+ * (makeDeps 只能经 setup 之后的事件链创建,命中即时序异常,fail-fast)。 */
94
+ currentPi: ExtensionAPI | undefined;
95
+ }
96
+
97
+ function createWorkflowDomainState(): WorkflowDomainState {
98
+ return {
99
+ lsRef: { lastSessionId: "" },
100
+ notifiedRunIds: new Set<string>(),
101
+ workerHost: new WorkerHostImpl(),
102
+ registry: new WorkflowScriptRegistryImpl(),
103
+ sessionState: new Map<string, SessionLifecycleResult>(),
104
+ currentPi: undefined,
105
+ };
106
+ }
107
+
108
+ // [skill-reload D2] WorkflowDomainState 进程槽:与 SERVICE_SLOT_KEY /
109
+ // DIALOG_QUEUE_KEY 同一防线形态(globalThis[Symbol.for],跨 jiti 多实例与
110
+ // pi reload 模块重求值存活)。get-or-create 整对象一槽——禁止逐字段筛选提权
111
+ // (sessionState/workerHost/registry/notifiedRunIds/lsRef 是一张对象图,漏提
112
+ // 一项即新旧实例并存、闭包引用分裂,设计被否项)。reload 后 factory 重跑经
113
+ // 此槽拿回同一 domain state,session_shutdown(reload) 跳过清理(D1)保住的
114
+ // 在飞 run / store 由 post-reload session_start(reason=reload) 的 adoption
115
+ // 接管(D4,session-lifecycle.ts)。
116
+ const WORKFLOW_DOMAIN_SLOT_KEY = Symbol.for("@zhushanwen/pi-subagents.workflow-domain-state");
117
+
118
+ function getOrCreateWorkflowDomainState(): WorkflowDomainState {
119
+ let state = Reflect.get(globalThis, WORKFLOW_DOMAIN_SLOT_KEY) as WorkflowDomainState | undefined;
120
+ if (!state) {
121
+ state = createWorkflowDomainState();
122
+ Reflect.set(globalThis, WORKFLOW_DOMAIN_SLOT_KEY, state);
123
+ }
124
+ return state;
125
+ }
126
+
127
+ // [skill-reload D3] makeDeps volatile 成员的 pi 现读源。只读槽不创建——本函数被调
128
+ // 时 domainState 必已存在(makeDeps 只能经 setupWorkflowDomain 之后的事件链创建)。
129
+ // 窗口语义:reload 的 ②invalidate 到新 factory 重跑覆盖登记之间,这里读到的仍是
130
+ // 旧 pi(设计 D5 声明的窗口内 stale 形态,降级守卫在消费侧——notifyDone 的
131
+ // guardStaleCtx / store appendEntry 的 stale guard,本单元不处理)。缺失 = 时序
132
+ // 异常(槽被外力删除或 deps 逃逸到 setup 之前使用),fail-fast 带恢复指向。
133
+ function resolveCurrentPi(): ExtensionAPI {
134
+ const state = Reflect.get(globalThis, WORKFLOW_DOMAIN_SLOT_KEY) as WorkflowDomainState | undefined;
135
+ const pi = state?.currentPi;
136
+ if (!pi) {
137
+ throw new Error(
138
+ "workflow deps current pi binding unset: setupWorkflowDomain has not run " +
139
+ "(slot @zhushanwen/pi-subagents.workflow-domain-state); re-run extension factory to re-register",
140
+ );
141
+ }
142
+ return pi;
143
+ }
144
+
145
+ /** 组合根侧生产 deps 工厂:SessionLifecycleDeps 全部成员有生产默认实现(住
146
+ * session-lifecycle.ts——createOrReuseServices 单例语义 / WorktreeManager 每次
147
+ * 扫描新建 / JsonlRunStore per-session 新建),此处无本地构造可注入;工厂形态
148
+ * 保留为组合根侧注入点(测试或后续演进可在此覆盖)。
149
+ * 测试注入路径:不挂载 index.ts,直接调 setupSessionLifecycle(pi, ctx, fakeDeps)。
150
+ * [skill-reload D4] 唯一本地构造 onAdoptionFailed 依赖 setupWorkflowDomain 闭包
151
+ * (makeDeps / sessionState),其定义随迁函数体内部(见 makeDeps 之后)。 */
152
+
153
+ // ── workflow deps 守卫(单一出口) ─────────────────────────────────────────────
154
+ //
155
+ // [守卫合一] 原 pi.__workflowRun 内联守卫 + getDeps 守卫两份重复(state 缺失 /
156
+ // storeHealthy=false),合并为单一 getWorkflowDeps:返回 discriminated union,
157
+ // 两个消费点各自决定失败形态——pi.__workflowRun(D-8 API)返回错误对象(不
158
+ // throw,保住调用方 Promise 契约),getDeps(3 个 tool 的 lazy deps 源)throw
159
+ // (pi tool 框架将其转译为 tool 错误结果)。错误消息逐字保留(crash-recovery
160
+ // 测试锁 "store unavailable" / "loadAll failed" 子串)。
161
+ export type WorkflowDepsResolution =
162
+ | { ok: true; deps: LauncherDeps }
163
+ | { ok: false; reason: string };
164
+
165
+ // ── lazyDeps 样板收敛 ──────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * lazyDeps 单成员转发原语:属性访问触发 getWorkflowDeps 守卫求值,失败 throw
169
+ * (与 __workflowRun 的 return 错误对象对齐——同源同消息),成功转发 deps[key]。
170
+ * 收敛原 index.ts 10 个同构 4 行 getter;每属性独立求值语义不变。
171
+ */
172
+ function createLazy<K extends keyof LauncherDeps>(
173
+ resolve: () => WorkflowDepsResolution,
174
+ key: K,
175
+ ): LauncherDeps[K] {
176
+ const resolved = resolve();
177
+ if (!resolved.ok) throw new Error(resolved.reason);
178
+ return resolved.deps[key];
179
+ }
180
+
181
+ // ── 组合根消费面 ───────────────────────────────────────────────────────────────
182
+
183
+ export interface WorkflowDomainHandle {
184
+ /** workflow 域状态(组合根只读消费:engine-awareness lastEngine 存取器 /
185
+ * workflows command runs getter / pi.__workflowRun lastSessionId)。 */
186
+ state: WorkflowDomainState;
187
+ /** 3 个 tool + workflows command 的 lazy deps 注入源。 */
188
+ lazyDeps: LauncherDeps;
189
+ /** 守卫单一出口(pi.__workflowRun 消费点;lazyDeps 内部走同一函数)。 */
190
+ getWorkflowDeps(sessionId: string): WorkflowDepsResolution;
191
+ /** workflow-script tool 的重入检查(跨 session 全局视角)。 */
192
+ isScriptRunning(name: string): boolean;
193
+ }
194
+
195
+ /**
196
+ * workflow 域事件族装配单一入口(事件族 seam,与 session-lifecycle.ts 同构)。
197
+ * index.ts 的 workflow 域退为本调用 + 装配结果消费。
198
+ *
199
+ * 7 个 pi.on handler 的注册相对顺序原样保留(session_start → session_compact →
200
+ * model_select → session_tree → session_before_fork → session_before_switch →
201
+ * session_shutdown);engine-awareness(before_agent_start 链尾)仍由组合根在
202
+ * 本调用之后注册(before_agent_start 链序不变,跨事件通道无注册时序语义)。
203
+ */
204
+ export function setupWorkflowDomain(
205
+ pi: ExtensionAPI,
206
+ wiring: { inflightReporter: InFlightReporter },
207
+ ): WorkflowDomainHandle {
208
+ const { inflightReporter } = wiring;
209
+ // [skill-reload D2] handle.state 即槽对象本身(不做解构重包装——否则容器每次
210
+ // factory 重跑新建,调用方拿不到「同一 domain state 引用」的接管前提)。
211
+ const domainState = getOrCreateWorkflowDomainState();
212
+ // [skill-reload D3] current pi 登记点:factory 每次重跑(reload 后新 factory 持新
213
+ // pi)在此覆盖槽上 volatile 绑定——旧 deps 对象的现读成员(见 makeDeps)自动
214
+ // 路由到新 pi,这是「不做遍历重绑」的登记侧前提。
215
+ domainState.currentPi = pi;
216
+ const { lsRef, notifiedRunIds, sessionState, workerHost, registry } = domainState;
217
+
218
+ // [skill-reload D3] log 不闭包捕获 factory 期 pi:每次调用从槽现读 current pi
219
+ // ——reload 后旧 deps.log 的 workflow:log entry 落进新 pi 的权威 session JSONL
220
+ // (旧 pi 的 session 已随 reload invalidate,写旧 pi 命中 assertActive 即丢日志)。
221
+ function log(
222
+ level: "debug" | "info" | "warn" | "error",
223
+ component: string,
224
+ message: string,
225
+ data?: unknown,
226
+ ): void {
227
+ try {
228
+ resolveCurrentPi().appendEntry("workflow:log", {
229
+ timestamp: Date.now(),
230
+ level,
231
+ component,
232
+ message,
233
+ data,
234
+ });
235
+ } catch (err) {
236
+ void err;
237
+ }
238
+ }
239
+
240
+ function makeDeps(
241
+ state: Pick<SessionLifecycleResult, "store" | "runs" | "sessionDir" | "runner" | "ctx">,
242
+ ) {
243
+ const deps: LauncherDeps = {
244
+ store: state.store,
245
+ workerHost,
246
+ runner: state.runner,
247
+ runs: state.runs,
248
+ registry,
249
+ // [skill-reload D3] 三个 volatile 成员(eventBus / log / onRunDone 的 pi 与
250
+ // GuiContext)现读不快照:pi 从槽 currentPi 解析(factory 重跑覆盖登记),ctx
251
+ // 从 state.ctx 解析(槽上 sessionState 条目字段,adoption rebind 换新后自动
252
+ // 跟进)。在飞 pump 持有的旧 deps 对象经属性访问自动路由到新 pi/ctx。eventBus
253
+ // 是值成员必须 getter;log/onRunDone 是函数成员,现读在函数体内达成(函数引用
254
+ // 稳定,调用方缓存引用也无 stale 面)。
255
+ //
256
+ // onRunDone 是全部 done 路径的单点汇聚(abortRun + error-recovery),顺序固化为
257
+ // notify → track → evict:notifyDone 先发完整聚合通知(淘汰后聚合根仍在闭包参数
258
+ // run 引用上不受影响),trackNotifiedRunId 有界化去重窗口,最后裁剪 done run 内存。
259
+ // 本轮 run 的 completedAt 在 transition("done") 时同步设为当前时刻=全局最新,
260
+ // 恒在保留端——结构性保证其不被自身触发的裁剪淘汰,无需 protectRunId。
261
+ onRunDone: (run: WorkflowRun) => {
262
+ notifyDone(resolveCurrentPi(), run.runId, run, notifiedRunIds, toGuiCtx(state.ctx));
263
+ trackNotifiedRunId(notifiedRunIds, run.runId);
264
+ const evicted = evictDoneRunsBeyondCap(state.runs, MAX_RETAINED_DONE_RUNS);
265
+ if (evicted > 0) {
266
+ logger.debug("[subagent-workflow] evicted done runs beyond cap", {
267
+ evicted,
268
+ keep: MAX_RETAINED_DONE_RUNS,
269
+ sessionId: lsRef.lastSessionId,
270
+ });
271
+ }
272
+ },
273
+ get eventBus() {
274
+ return resolveCurrentPi().events;
275
+ },
276
+ scheduleTimeBudget: (runId: string, budgetTimeMs: number) =>
277
+ scheduleTimeBudget(runId, deps, budgetTimeMs),
278
+ onWorkflowCall: (name: string, args: Record<string, unknown>, parentRun: WorkflowRun) =>
279
+ executeNestedWorkflow(name, args, parentRun, deps),
280
+ // [H2 W3] workflow agent() 统一派发入口(设计 §3.5):pump 侧 dispatchAgentCall
281
+ // 经此转调 SubagentService.executeWorkflowAgent——真实 record(origin:"workflow"
282
+ // + parentRunId)进 store、共享池/守护/journal 归 service 编排;parentRunId 由
283
+ // pump 补 run.runId。service 单例在 session_start 后必在(run 只能于 session 内
284
+ // 派发);null 时抛错由 pump 的 dispatchCall catch 兜底回发 failed result。
285
+ workflowAgentDispatch: (opts, parentRunId, signal) => {
286
+ const service = getSubagentService();
287
+ if (!service) {
288
+ throw new Error("workflow agent dispatch unavailable: subagent service not initialized");
289
+ }
290
+ return service.executeWorkflowAgent(opts, parentRunId, signal);
291
+ },
292
+ log,
293
+ };
294
+ return deps;
295
+ }
296
+
297
+ function isScriptRunning(name: string): boolean {
298
+ for (const state of sessionState.values()) {
299
+ for (const run of state.runs.values()) {
300
+ if (run.spec.scriptName === name && run.state.status === "running") return true;
301
+ }
302
+ }
303
+ return false;
304
+ }
305
+
306
+ /** [skill-reload D4] onAdoptionFailed 依赖 setupWorkflowDomain 闭包(makeDeps 的
307
+ * LauncherDeps 完整形态——workerHost / onRunDone 通知链经 D3 现读自动路由到新
308
+ * pi;sessionState 移除依赖 domain state Map),归 workflow 域、session-lifecycle
309
+ * seam 无访问通道,经 SessionLifecycleDeps 注入。 */
310
+ function makeLifecycleDeps(): SessionLifecycleDeps {
311
+ return {
312
+ onAdoptionFailed: async (existing, reason) => {
313
+ // notifyDone: true(D4/r4)——session 仍在(reload 是同会话原地重建),run
314
+ // 终止对用户必须可见(G3 不静默);terminate 前的 rebind-first 已在
315
+ // failAdoption 完成,终态 flush 走新 pi 落权威 JSONL。
316
+ await terminateRunningRuns(
317
+ makeDeps(existing),
318
+ `skill reload adoption failed: ${reason}`,
319
+ { notifyDone: true },
320
+ );
321
+ sessionState.delete(existing.sessionId);
322
+ },
323
+ };
324
+ }
325
+
326
+ // ════════════════════════════════════════════════════════════
327
+ // session_start:初始化 subagents + workflow 两域
328
+ //
329
+ // 六职责编排(identity 重建 / ledger 装配 / 双 Service 装配 / GC+恢复 / kill-9
330
+ // 循环 / SAR+engine 基线)已随迁 session-lifecycle.ts(bootstrap seam,设计
331
+ // §3.1/D1/D2 原样搬移)。此处仅接线:lastSessionId 先行赋值(时序与原 handler
332
+ // 开头一致)+ 装配结果写入 per-session sessionState。
333
+ // ════════════════════════════════════════════════════════════
334
+ pi.on("session_start", async (event: SessionStartEvent, ctx: ExtensionContext) => {
335
+ lsRef.lastSessionId = ctx.sessionManager.getSessionId();
336
+ // [u7a D5] 初始上报(count=当下绝对计数):触发时点 = extension 加载完成 / session
337
+ // 就绪(factory 无 ctx/ui,session_start 是最早带 ctx 的钩子——plugin-bridge 同款
338
+ // 事实)。fire-and-forget 在 await 装配链之前发起,不阻塞也不被阻塞。
339
+ inflightReporter.attachSession(ctx);
340
+ // [skill-reload D4] adoption 入参接线:reason 是 handler 独占信息(event 参数),
341
+ // existing 是 sessionState(domainState 闭包)里的既有条目——两者都是
342
+ // session-lifecycle seam 的 adoption 分流判据,经 SessionStartOptions 传入。
343
+ // 非 reload 的 session_start 不取 existing(quit 族 session_shutdown 已删条目,
344
+ // 此处恒 undefined;显式不传也防误接管)。
345
+ const existing =
346
+ event.reason === "reload" ? sessionState.get(ctx.sessionManager.getSessionId()) : undefined;
347
+ const result = await setupSessionLifecycle(pi, ctx, makeLifecycleDeps(), {
348
+ reason: event.reason,
349
+ existing,
350
+ });
351
+ sessionState.set(result.sessionId, result);
352
+ });
353
+
354
+ // ════════════════════════════════════════════════════════════
355
+ // [U2 P-B4 降级] session_compact:compaction 对 custom entry 保留行为实装未
356
+ // 验证——检测 ledger/ack entry 被 compaction 清除时按内存态补写(notify-ledger
357
+ // compactionCheck;未清除则 no-op)。内存态在 compaction 后仍活着,作为补写源;
358
+ // 重启后的权威仍是两列 entry 差集(内存不承担销账职责)。
359
+ // ═══════════════════════════════════════════════════════════
360
+ pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
361
+ try {
362
+ const rewritten = getBoundNotifyLedger()?.compactionCheck() ?? 0;
363
+ if (rewritten > 0) {
364
+ logger.warn(`[subagents] notify ledger entries lost to compaction; rewrote ${rewritten} from memory`);
365
+ }
366
+ } catch (err) {
367
+ logger.warn("[subagents] notify ledger compactionCheck failed", {
368
+ reason: toErrorMessage(err),
369
+ });
370
+ }
371
+ });
372
+
373
+ // ════════════════════════════════════════════════════════════
374
+ // model_select:用户切换 model 时刷新缓存
375
+ // ════════════════════════════════════════════════════════════
376
+ pi.on("model_select", (event, ctx: ExtensionContext) => {
377
+ const service = getModelConfigService();
378
+ if (service && typeof service.setCtxModel === "function") {
379
+ service.setCtxModel(event.model);
380
+ }
381
+ // H1: 同步刷新所有 session 的 SAR ctxModel(旧实现只在 session_start 固化)
382
+ const sid = ctx.sessionManager.getSessionId();
383
+ const state = sessionState.get(sid);
384
+ if (state) {
385
+ state.runner.updateCtxModel(event.model);
386
+ }
387
+ });
388
+
389
+ // ════════════════════════════════════════════════════════════
390
+ // session_tree:切分支前终止所有 running run(一次性生命周期——切走即作废)
391
+ // ════════════════════════════════════════════════════════════
392
+ pi.on("session_tree", async (_event: SessionTreeEvent, ctx: ExtensionContext) => {
393
+ const sessionId = ctx.sessionManager.getSessionId();
394
+ lsRef.lastSessionId = sessionId;
395
+
396
+ const state = sessionState.get(sessionId);
397
+ if (state) {
398
+ // 一次性生命周期(D-2):running run 转 done,failed 落盘(helper 内部自过滤
399
+ // running,单 run 失败不中断其余)。此处不再挂起待恢复。
400
+ try {
401
+ await terminateRunningRuns(makeDeps(state), "Session switched: run terminated");
402
+ } catch (err) {
403
+ bestEffort(err, "terminateRunningRuns (session_tree handler)");
404
+ }
405
+ }
406
+ });
407
+
408
+ // ════════════════════════════════════════════════════════════
409
+ // SP-4: session_before_fork(/fork)/ session_before_switch(/new)级联关闭
410
+ // ════════════════════════════════════════════════════════════
411
+ // 主 session /fork 或 /new 时,清理旧 record(disposeAllRecords:CAS 转终态 +
412
+ // archive + worktree 清理)。before 事件在 session 替换前触发,确保旧 session 的
413
+ // subagent 在新 session 创建前被清理(随后的 session_shutdown → dispose 收割子进程)。
414
+ //
415
+ // [M2 修复] 旧实现把 /new 级联挂在 session_before_tree 上——SDK 中该事件只由
416
+ // AgentSession.navigateTree()(/tree 同 session 分支切换)触发,/new 走
417
+ // session_before_switch(reason:"new") + session_shutdown(reason:"new"),从不触发
418
+ // before_tree。后果双向:/new 级联是死代码;普通 /tree 分支导航反而误杀全部活跃
419
+ // subagent。现 /new 改挂 session_before_switch(reason==="new"),before_tree handler
420
+ // 移除(/tree 是同 session 内导航,record/子进程归属不变,无级联关闭诉求)。
421
+ pi.on("session_before_fork", (_event, _ctx) => {
422
+ const service = getSubagentService();
423
+ if (service) {
424
+ const count = service.onParentFork();
425
+ if (count > 0) {
426
+ logger.warn(`[subagents] /fork 级联关闭 ${count} 个 subagent`);
427
+ }
428
+ }
429
+ });
430
+
431
+ pi.on("session_before_switch", (event, _ctx) => {
432
+ // /new(reason:"new")创建全新 session → 级联关闭旧 record。
433
+ // reason:"resume"(/resume /import 回到已有 session)不级联:record 按 rootSessionId
434
+ // 归属隔离,跨 session 读写由 store 过滤守卫,无需销毁。
435
+ if (event.reason !== "new") return;
436
+ const service = getSubagentService();
437
+ if (service) {
438
+ const count = service.onParentNew();
439
+ if (count > 0) {
440
+ logger.warn(`[subagents] /new 级联关闭 ${count} 个 subagent`);
441
+ }
442
+ }
443
+ });
444
+
445
+ // ════════════════════════════════════════════════════════════
446
+ // session_shutdown:dispose subagents + terminate workflows + store 收尾 + cleanup
447
+ //
448
+ // [skill-reload D1] 按 reason 分支:pi 的 SessionShutdownReason 枚举
449
+ // (quit|reload|new|resume|fork,SDK types.d.ts)里 reload 是唯一「同会话原地
450
+ // 重建」成员——进程不死、session 不离开,仅 extension 模块图重求值。reload 分支
451
+ // 跳过全部破坏性动作(a dispose / c terminate / d store.dispose / e sessionState
452
+ // 清除 / f dialogQueue.rejectAll),只执行 b(inflightReporter.detachSession,
453
+ // detach 不是破坏——旧 reporter 是 per-factory 实例,不 detach 会在重试循环里持
454
+ // stale ctx 反复 attempt;新 factory 建新 reporter 并覆盖进程级单监听)。存活的
455
+ // 在飞 run / store 经 D2 槽由 post-reload session_start(reason=reload) 的
456
+ // adoption 接管(D4,session-lifecycle.ts)。quit/new/resume/fork = 会话真离开,
457
+ // 六动作现状全保持;session_tree / session_before_switch 的 terminate 路径不受
458
+ // 影响(各自独立 handler,真语义)。
459
+ //
460
+ // store 收尾:每 session 的 JsonlRunStore 在 terminateRunningRuns 之后 dispose(刷
461
+ // pending 去抖批 + await in-flight 链,见 W2C5)。R3 声明:SIGTERM/SIGINT 走
462
+ // 组合根 process handler 不触发本路径,pending 去抖丢失等价崩溃链(重启后 kill-9
463
+ // 恢复收编 running 残留——终态/创建均冷路径已落盘,丢的只有 ≤saveDebounceMs 的
464
+ // running 尾巴,ES1 已接受);不做 best-effort SIGTERM dispose(需同步 IO 改造,
465
+ // 超出 wave 边界)。
466
+ // ════════════════════════════════════════════════════════════
467
+ pi.on("session_shutdown", async (event: SessionShutdownEvent, _ctx: ExtensionContext) => {
468
+ if (event.reason === "reload") {
469
+ // b 动作保留(理由见上方 D1 分支说明)。
470
+ inflightReporter.detachSession();
471
+
472
+ // [skill-reload D8] reload 分支归因日志:preserved 计数取自跳过清理时的存活
473
+ // 对象真实统计(runs = 各 session 内存 run 聚合根数;records = run.state.calls
474
+ // 的 agent 调用数——每 call 对应一条 subagent record;stores = sessionState
475
+ // 条目数即 store 实例数),供归因演练(S4)与 orchestrator 段日志对账——
476
+ // 「reload 存活了什么」必须能从这一行直接读出,不靠时间戳猜。
477
+ let preservedRuns = 0;
478
+ let preservedRecords = 0;
479
+ for (const state of sessionState.values()) {
480
+ preservedRuns += state.runs.size;
481
+ for (const run of state.runs.values()) {
482
+ preservedRecords += run.state.calls.size;
483
+ }
484
+ }
485
+ logger.debug(
486
+ `[workflow-events] session_shutdown reason=reload preserved={runs:${preservedRuns}, records:${preservedRecords}, stores:${sessionState.size}}`,
487
+ );
488
+ return;
489
+ }
490
+
491
+ // ── subagents 域:dispose SubagentService ──
492
+ getSubagentService()?.dispose();
493
+
494
+ // [u7a D5] 在途上报通道随 session 终结:摘 ctx + 停重试(session 已死,重试直至
495
+ // 成功的语义只对活 session 成立;进程级出口监听保留——后续 /new 重新 attach)。
496
+ inflightReporter.detachSession();
497
+
498
+ // ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
499
+ // H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——
500
+ // 防御 session 切换但 session_tree 未先触发导致 lastSessionId 指向已删除 session 的情况)。
501
+ for (const [sessionId, state] of sessionState) {
502
+ // 编排顺序(W2C5):terminate(await,failed 落盘——重启后 kill-9 恢复不误判)
503
+ // → store.dispose(await,刷 pending 去抖批 + await in-flight 链,关「shutdown
504
+ // 时刻 pending 去抖写丢失」窗口)→ delete。terminate 的 running 过滤在 helper
505
+ // 内部(单 run 失败不中断其余);外层 try/catch 兜底防单 session 异常中断后续
506
+ // session 条目的 dispose + delete(对齐原 allSettled 的不中断语义)。
507
+ try {
508
+ await terminateRunningRuns(makeDeps(state), "Session shutdown: run terminated");
509
+ } catch (err) {
510
+ bestEffort(err, "terminateRunningRuns (session_shutdown handler)");
511
+ }
512
+ // dispose 自身恒 resolve,catch 兜底防御——handler 内抛错会中断后续 session
513
+ // 条目清理。不留静默吞错(错误必须可操作):debug 留痕带 sessionId/sessionDir,
514
+ // 排查「shutdown 后 run 状态不落盘」类问题时有迹可循。
515
+ await state.store.dispose().catch((err: unknown) => {
516
+ logger.debug(
517
+ `[subagent-workflow] session_shutdown store.dispose failed (sessionId=${sessionId}, sessionDir=${state.sessionDir})`,
518
+ { reason: toErrorMessage(err) },
519
+ );
520
+ });
521
+ sessionState.delete(sessionId);
522
+ }
523
+
524
+ // M2: 清理 dialog queue 运行时状态(queue/current/processing)。
525
+ // [#10] rejectAll() settle 所有 pending dialog Promise(防闭包泄漏:未 settle 的
526
+ // Promise 持有 resolve/reject 闭包及 handler 上下文,session 退出后仍挂在全球队列上),
527
+ // 并内部重置 queue/current/processing(原子操作,无 footgun)。
528
+ // 单 session 假设(M-2,同 lastSessionId):rejectAll() 清空进程级单例的所有 pending,
529
+ // 依赖 Pi 单进程单 session 串行保证——不会误清其他 session。多 session 并发的迁移策略
530
+ // 见 DialogGlobalQueue 类注释(rejectAllForSession)。
531
+ // channel registry 不清:跨 session 持久是有意设计(ask-user 扩展注册的 channel handler
532
+ // 在 /new /resume /fork 时不丢失注册)。
533
+ const dialogQueue = getOrCreateDialogQueue();
534
+ dialogQueue.rejectAll();
535
+ });
536
+
537
+ const getWorkflowDeps = (sessionId: string): WorkflowDepsResolution => {
538
+ const state = sessionState.get(sessionId);
539
+ if (!state) {
540
+ return { ok: false, reason: "Session not initialized" };
541
+ }
542
+ // MF-1: store 不健康时 fail-fast,避免 store.save 再次失败导致 run 状态不落地。
543
+ if (!state.storeHealthy) {
544
+ return { ok: false, reason: "Workflow store unavailable (loadAll failed in session_start)" };
545
+ }
546
+ return { ok: true, deps: makeDeps(state) };
547
+ };
548
+
549
+ // ════════════════════════════════════════════════════════════
550
+ // lazyDeps(3 个 tool + workflows command 的 lazy deps 注入源)
551
+ //
552
+ // 属性访问触发 getWorkflowDeps 守卫 + makeDeps 求值(每属性独立,createLazy
553
+ // 原语转发)。守卫合一后 getWorkflowDeps 返回 discriminated union,getter 内
554
+ // 消费时 throw(与 __workflowRun 的 return 错误对象对齐——同源同消息,
555
+ // session-lifecycle.test.ts 锁定)。
556
+ // ════════════════════════════════════════════════════════════
557
+ const resolveDeps = (): WorkflowDepsResolution => getWorkflowDeps(lsRef.lastSessionId);
558
+ const lazyDeps: LauncherDeps = {
559
+ get store() { return createLazy(resolveDeps, "store"); },
560
+ get runs() { return createLazy(resolveDeps, "runs"); },
561
+ get registry() { return createLazy(resolveDeps, "registry"); },
562
+ get onRunDone() { return createLazy(resolveDeps, "onRunDone"); },
563
+ get eventBus() { return createLazy(resolveDeps, "eventBus"); },
564
+ get workerHost() { return createLazy(resolveDeps, "workerHost"); },
565
+ get runner() { return createLazy(resolveDeps, "runner"); },
566
+ // scheduleTimeBudget / onWorkflowCall 不可缺席(ports.ts D-12 regression fix):
567
+ // rebuildRuntime 重排 run 级墙钟预算计时器、worker 脚本嵌套 workflow() 调用都经
568
+ // 这两个成员消费——lazyDeps 缺席会让消费点拿到 undefined(可选属性静默放行),
569
+ // 带时间预算的 run 命中一次错误重试后计时器静默失效。转发形态与其余成员一致。
570
+ get scheduleTimeBudget() { return createLazy(resolveDeps, "scheduleTimeBudget"); },
571
+ get onWorkflowCall() { return createLazy(resolveDeps, "onWorkflowCall"); },
572
+ get log() { return createLazy(resolveDeps, "log"); },
573
+ };
574
+
575
+ return { state: domainState, lazyDeps, getWorkflowDeps, isScriptRunning };
576
+ }