@zhushanwen/pi-subagent-workflow 8.8.0 → 8.9.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.
- package/package.json +9 -8
- package/skills/subagent-ext-config/SKILL.md +21 -1
- package/src/host/pi-host.ts +5 -4
- package/src/index.ts +145 -464
- package/src/injectors/__tests__/engine-awareness.test.ts +4 -2
- package/src/injectors/__tests__/engine-section-stability.test.ts +42 -29
- package/src/injectors/engine-awareness.ts +80 -4
- package/src/injectors/resource-list-injector.ts +209 -0
- package/src/injectors/subagent-list-injector.ts +30 -143
- package/src/injectors/workflow-list-injector.ts +23 -150
- package/src/interface/__tests__/detectors.test.ts +1 -1
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +15 -6
- package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +1 -1
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +1 -1
- package/src/interface/bg-notify-render.ts +4 -4
- package/src/interface/commands.ts +115 -75
- package/src/interface/format.ts +339 -241
- package/src/interface/helpers.ts +2 -2
- package/src/interface/list-component.ts +163 -116
- package/src/interface/list-shared.ts +2 -2
- package/src/interface/list-view.ts +129 -72
- package/src/interface/subagent-actions.ts +5 -5
- package/src/interface/subagent-tool-schema.ts +21 -6
- package/src/interface/subagent-tool.ts +31 -29
- package/src/interface/subagents.ts +8 -7
- package/src/interface/tool-render.ts +2 -2
- package/src/interface/tool-workflow-script.ts +15 -14
- package/src/interface/tool-workflow.ts +14 -11
- package/src/interface/tui-kit.ts +305 -0
- package/src/interface/views/WorkflowsView.ts +267 -196
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +3 -3
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +7 -4
- package/src/interface/views/detail-content.ts +16 -14
- package/src/interface/views/view-constants.ts +19 -0
- package/src/jsonl-run-store.ts +11 -7
- package/src/session-lifecycle.ts +561 -0
- package/src/interface/views/format.ts +0 -281
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-lifecycle — 会话生命周期装配 seam(bootstrap seam)。
|
|
3
|
+
*
|
|
4
|
+
* 设计锚点:docs/design/subagent-post-convergence-architecture.md §3.1(D1/D2/D8)。
|
|
5
|
+
* 随迁内容 = 原组合根 index.ts session_start handler(:336-613)的六职责,原样搬移
|
|
6
|
+
* (D2 纪律:本文件不改行为;行为变更点——守卫合一 / lazyDeps getter 化(10 成员
|
|
7
|
+
* 守卫触发对象,偏差 #10)——留在 index.ts,各自独立成条):
|
|
8
|
+
* 1. identity env→appendEntry 重建(类型 13 字段含 1 个 @deprecated,写入 12)
|
|
9
|
+
* 2. notify ledger host 装配 + 重启恢复
|
|
10
|
+
* 3. 双 Service 装配 + initSession(createOrReuseServices 封装,单例语义 D8)
|
|
11
|
+
* 4. GC / manifest tmp / worktree 恢复
|
|
12
|
+
* 5. per-session run store + kill-9 恢复循环 + evictDoneRunsBeyondCap
|
|
13
|
+
* 6. SAR + engine 基线(经 SessionLifecycleResult 返回,sessionState 写入留在组合根)
|
|
14
|
+
*
|
|
15
|
+
* 测试入口:deps(SessionLifecycleDeps)注入 fake 即可验证装配行为,无需挂载整个
|
|
16
|
+
* index.ts 整类打桩(设计 §3.1 使用者视角样例)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
25
|
+
import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
26
|
+
|
|
27
|
+
// ═══ core 宿主端口消费(随迁块的依赖;production 默认实现住本文件) ═══
|
|
28
|
+
import { getOrCreateChannelRegistry } from "@zhushanwen/subagent-core";
|
|
29
|
+
import { DialogGlobalQueue } from "@zhushanwen/subagent-core";
|
|
30
|
+
import { syncEnginesFile } from "@zhushanwen/subagent-core";
|
|
31
|
+
import { createUiRequestHandlerForMode } from "@zhushanwen/subagent-core";
|
|
32
|
+
import {
|
|
33
|
+
getModelConfigService,
|
|
34
|
+
ModelConfigService,
|
|
35
|
+
setModelConfigService,
|
|
36
|
+
} from "@zhushanwen/subagent-core";
|
|
37
|
+
import { bindNotifyLedgerHost, type NotifyLedgerHost } from "@zhushanwen/subagent-core";
|
|
38
|
+
import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "@zhushanwen/subagent-core";
|
|
39
|
+
import type { ExecutionMode } from "@zhushanwen/subagent-core";
|
|
40
|
+
import { maybeCleanupExpiredSessionFiles } from "@zhushanwen/subagent-core";
|
|
41
|
+
import {
|
|
42
|
+
getSubagentService,
|
|
43
|
+
setSubagentService,
|
|
44
|
+
SubagentService,
|
|
45
|
+
} from "@zhushanwen/subagent-core";
|
|
46
|
+
import { SubprocessAgentRunner } from "@zhushanwen/subagent-core";
|
|
47
|
+
import { WorktreeManager } from "@zhushanwen/subagent-core";
|
|
48
|
+
import { clearSkillPathCache } from "@zhushanwen/subagent-core";
|
|
49
|
+
// kill-9 崩溃恢复四步(loadAll → failed → save → evict)收口 core(D8:宿主各写
|
|
50
|
+
// 一遍正是 failure-mode-B);dev 侧 u-audit-fix 的 6 个 oncePerProcess 守卫随迁块
|
|
51
|
+
// 从 index.ts 原样迁入(f23fbcc3c)。
|
|
52
|
+
import { recoverCrashedRuns } from "@zhushanwen/subagent-core";
|
|
53
|
+
import type { WorkflowRun } from "@zhushanwen/subagent-core";
|
|
54
|
+
// [engine-awareness D1b] lastEngine 基线归一(随迁块 6 消费)——normalizeEngineId
|
|
55
|
+
// 单一权威源在 core,直连 barrel(engine-awareness 的历史再导出面已删,dev 侧
|
|
56
|
+
// 「导入面折叠直连」语义)
|
|
57
|
+
import { normalizeEngineId } from "@zhushanwen/subagent-core";
|
|
58
|
+
import { JsonlRunStore } from "./jsonl-run-store.ts";
|
|
59
|
+
|
|
60
|
+
// 模块级 logger(与原 index.ts 同 component 名;setPiHandle 注入后自动走 appendEntry)
|
|
61
|
+
const logger = getLogger("subagents");
|
|
62
|
+
|
|
63
|
+
// ── 主 session 文件解析(随迁为 module 私有,唯一消费方是随迁块) ─────────────────
|
|
64
|
+
|
|
65
|
+
// 模块级缓存:主 session 的 sessionFile(fork source 解析用)。
|
|
66
|
+
// [搬移注] 原为 index.ts factory 闭包状态,随 createOrReuseServices 域整体随迁为
|
|
67
|
+
// module 级:生产形态 extension factory 每进程实例化一次,闭包与 module 级等价;
|
|
68
|
+
// 每次 session_start 都无条件刷新此缓存(见下方搬移块),/resume /fork 复用实例时
|
|
69
|
+
// getter 读到的恒为最新值(SR-3 语义不变)。
|
|
70
|
+
let cachedMainSessionFile: string | undefined;
|
|
71
|
+
|
|
72
|
+
function getCachedMainSessionFile(): string | undefined {
|
|
73
|
+
return cachedMainSessionFile;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 按 sessionId 解析主 session 文件路径(文件名约定 `<ISO 时间戳>_<sessionId>.jsonl`)。
|
|
78
|
+
* [E2E 实测] attach 场景下 ctx.sessionManager.getSessionFile() 会返回前一 session 的
|
|
79
|
+
* 文件(session_start(root=01a01bf5) 时仍返回刚新建 session 的路径)——恢复逻辑
|
|
80
|
+
* 读错文件会整段漏判。此处按 id 从 sessions 目录解析为准;新 session 文件未 flush
|
|
81
|
+
* 时(AGENTS.md 规则 6:首条 assistant 消息前可能不存在)返回 undefined,调用方
|
|
82
|
+
* (fork 解析 / 孤儿恢复)对该场景本就无 entry 可读。
|
|
83
|
+
*
|
|
84
|
+
* [pi 锚点 ADR-0063 I4] getSessionFile attach 语义:返回 `this.sessionFile` 字段
|
|
85
|
+
* (pi-mono coding-agent/src/core/session-manager.ts :1011-1013),该字段仅在
|
|
86
|
+
* _setSessionFile(:884/:895-896,constructor 显式路径或 setSessionFile)与
|
|
87
|
+
* newSession(:953 生成 `<ts>_<sessionId>.jsonl`)时写入——session_start 事件时点
|
|
88
|
+
* extension ctx 持有的 sessionManager 若尚未重绑到 root session,getSessionFile
|
|
89
|
+
* 仍回旧值(与 E2E 实测一致)。clone v0.84.2 核对,实装 0.84.4。
|
|
90
|
+
*/
|
|
91
|
+
function resolveMainSessionFileById(sessionId: string): string | undefined {
|
|
92
|
+
const sessionsDir = path.join(getAgentDir(), "..", "sessions");
|
|
93
|
+
try {
|
|
94
|
+
const match = fs.readdirSync(sessionsDir).find((f) => f.endsWith(`_${sessionId}.jsonl`));
|
|
95
|
+
return match === undefined ? undefined : path.join(sessionsDir, match);
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** workflow 域 per-session state 目录探测(随迁为 module 私有,唯一消费方是随迁块)。 */
|
|
102
|
+
function resolveSessionDir(): string {
|
|
103
|
+
const defaultDir = getAgentDir();
|
|
104
|
+
const sessionSlug = `--${process.cwd().replace(/^\//, "").replace(/\//g, "-")}--`;
|
|
105
|
+
// F2:根改 getAgentDir() 派生(实例隔离);保留 sessionScopedDir 存在则用之的探测语义
|
|
106
|
+
const sessionScopedDir = path.join(getAgentDir(), "sessions", sessionSlug);
|
|
107
|
+
return fs.existsSync(sessionScopedDir) ? sessionScopedDir : defaultDir;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── 进程级单例(dialog queue;原 index.ts module 级随域搬移) ────────────────────
|
|
111
|
+
//
|
|
112
|
+
// channel registry 经 channel-registry-access.ts 公开访问(跨扩展 API),不在此列。
|
|
113
|
+
// dialog queue 仍为本模块私有单例——消费方 = 本文件 createOrReuseServices +
|
|
114
|
+
// index.ts session_shutdown(flush L2 pending dialog)。
|
|
115
|
+
|
|
116
|
+
const DIALOG_QUEUE_KEY = Symbol.for("@zhushanwen/pi-subagents.dialogQueue");
|
|
117
|
+
|
|
118
|
+
/** 获取或创建进程级 dialog queue 单例。
|
|
119
|
+
* L2 跨子进程串行队列——所有子进程的 dialog 类请求共享同一队列实例。 */
|
|
120
|
+
export function getOrCreateDialogQueue(): DialogGlobalQueue {
|
|
121
|
+
let queue = Reflect.get(globalThis, DIALOG_QUEUE_KEY) as DialogGlobalQueue | undefined;
|
|
122
|
+
if (!queue) {
|
|
123
|
+
queue = new DialogGlobalQueue();
|
|
124
|
+
Reflect.set(globalThis, DIALOG_QUEUE_KEY, queue);
|
|
125
|
+
}
|
|
126
|
+
return queue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── deps 契约(SessionLifecycleDeps)与装配结果 ─────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** createOrReuseServices 返回值。reused 标志仅供测试断言与日志——禁止据其跳过
|
|
132
|
+
* initModel/initSession(D8:reused=true 跳过 init 会让上一 session 的
|
|
133
|
+
* uiRequestHandler/sessionId 残留)。 */
|
|
134
|
+
export interface ServicesBundle {
|
|
135
|
+
service: SubagentService;
|
|
136
|
+
modelService: ModelConfigService;
|
|
137
|
+
reused: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 组合根可注入的装配依赖。全部可选:undefined 时走生产默认实现(住本文件);
|
|
141
|
+
* 测试传 fake 逐项覆盖。 */
|
|
142
|
+
export interface SessionLifecycleDeps {
|
|
143
|
+
/**
|
|
144
|
+
* 双 Service 装配工厂(随迁块 3)。默认 = createOrReuseServices:完整保留
|
|
145
|
+
* existing-??-new + 仅 !existing 时 set 的单例语义(D8——jiti 多实例分裂靠
|
|
146
|
+
* globalThis Symbol 单例防护,/resume /fork 复用既有实例)。裸 new 禁止绕过
|
|
147
|
+
* 单例检查出现在默认实现里(否则每个 session_start 新建实例,GC timer 翻倍、
|
|
148
|
+
* record store 状态分裂)。
|
|
149
|
+
*/
|
|
150
|
+
createServices?: (pi: ExtensionAPI, ctx: ExtensionContext) => ServicesBundle;
|
|
151
|
+
/**
|
|
152
|
+
* worktree reaper(随迁块 4 的 ADR-035 启动恢复扫描)。默认 = 每次扫描新建
|
|
153
|
+
* WorktreeManager(现状语义:无状态扫描器,无单例诉求)。测试注入 fake
|
|
154
|
+
* (如 scanCalls 计数)观察扫描行为,见设计 §3.1 使用者视角样例。
|
|
155
|
+
*/
|
|
156
|
+
worktreeManager?: Pick<WorktreeManager, "scan">;
|
|
157
|
+
/**
|
|
158
|
+
* per-session run store 工厂(随迁块 5)。默认 = new JsonlRunStore({ sessionDir, pi, ctx })
|
|
159
|
+
* (per-session 新建为现状设计:store 生命周期与 session 等同,D-008/F-4)。
|
|
160
|
+
* 测试注入 fake 以控制 loadAll 行为(kill-9 恢复分支)。
|
|
161
|
+
*/
|
|
162
|
+
createRunStore?: (sessionDir: string, pi: ExtensionAPI, ctx: ExtensionContext) => JsonlRunStore;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** setupSessionLifecycle 装配结果——组合根据此写入 per-session sessionState。 */
|
|
166
|
+
export interface SessionLifecycleResult {
|
|
167
|
+
sessionId: string;
|
|
168
|
+
store: JsonlRunStore;
|
|
169
|
+
runs: Map<string, WorkflowRun>;
|
|
170
|
+
sessionDir: string;
|
|
171
|
+
/** D-008 per-session SAR(需要 ctxModel + subagentService 委托目标) */
|
|
172
|
+
runner: SubprocessAgentRunner;
|
|
173
|
+
/** session 上下文(notifyDone 需要 GuiContext) */
|
|
174
|
+
ctx: ExtensionContext;
|
|
175
|
+
/** MF-1: store 健康度。session_start 时 store.loadAll 失败则 false,
|
|
176
|
+
* workflow 域启动时 fail-fast,避免后续 store.save 再次失败导致 run 状态不落地。
|
|
177
|
+
* subagent 域不依赖 store,不受此标志影响。 */
|
|
178
|
+
storeHealthy: boolean;
|
|
179
|
+
/** [engine-awareness D1b] 上一次已知默认引擎(session_start 初始化,per-turn 检测
|
|
180
|
+
* diff 基准)。undefined = 初始化时 config 读失败——首 turn 检测遇 undefined
|
|
181
|
+
* 静默基线化,不算变更、不发通知(防首 turn 伪通知)。 */
|
|
182
|
+
lastEngine?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 双 Service 装配(随迁块 3 封装;D8 单例语义关键不变量) ───────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 双 Service 装配:getSubagentService() ?? new + 仅 !existing 时 set 的整段原样保留
|
|
189
|
+
* (jiti 多实例分裂靠 globalThis Symbol 单例防护,/resume /fork 复用既有实例——
|
|
190
|
+
* SR-3/SR-4)。initModel/initSession 对 new 与 reused 均无条件执行:
|
|
191
|
+
* - SR-3:/resume /fork 复用实例时注入 handler 覆盖旧值、更新 sessionId;
|
|
192
|
+
* - SR-4:dialogQueue 注入(session-runner child close 时清 L2 pending dialog)。
|
|
193
|
+
* reused 返回标志仅供测试断言与日志,不存在任何「reused=true 跳过 init」分支。
|
|
194
|
+
*/
|
|
195
|
+
function createOrReuseServices(pi: ExtensionAPI, ctx: ExtensionContext): ServicesBundle {
|
|
196
|
+
const agentDir = getAgentDir();
|
|
197
|
+
const cwd = ctx.cwd;
|
|
198
|
+
const existingService = getSubagentService();
|
|
199
|
+
const existingModelService = getModelConfigService();
|
|
200
|
+
const modelService = existingModelService ?? new ModelConfigService({ agentDir, cwd });
|
|
201
|
+
const service = existingService ?? new SubagentService({ cwd, modelService, getMainSessionFile: getCachedMainSessionFile });
|
|
202
|
+
|
|
203
|
+
modelService.initModel({
|
|
204
|
+
modelRegistry: ctx.modelRegistry,
|
|
205
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
206
|
+
ctxModel: ctx.model ?? undefined,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── W3: handler 注入链路接通 ──
|
|
210
|
+
// 进程级单例:channel registry + dialog queue 跨 session 复用
|
|
211
|
+
//(与 SubagentService 单例模式一致,globalThis Symbol 持有避免 jiti 多实例分裂)。
|
|
212
|
+
const channelRegistry = getOrCreateChannelRegistry();
|
|
213
|
+
const dialogQueue = getOrCreateDialogQueue();
|
|
214
|
+
const uiRequestHandler = createUiRequestHandlerForMode(ctx, channelRegistry, dialogQueue);
|
|
215
|
+
|
|
216
|
+
// 主 session 文件:按 sessionId 解析(getSessionFile() 在 attach 场景会返回前一
|
|
217
|
+
// session 的文件,E2E 实测),未 flush 的新 session 回退 getSessionFile()。
|
|
218
|
+
// 值直传 initSession——jiti 多实例分裂下闭包缓存(cachedMainSessionFile)不跨
|
|
219
|
+
// 实例共享,恢复逻辑经缓存读会拿到滞后一个事件的值(E2E 实测 ENOENT 漏判);
|
|
220
|
+
// 缓存本身保留给既有 getter 消费者(fork source 解析)。
|
|
221
|
+
cachedMainSessionFile =
|
|
222
|
+
resolveMainSessionFileById(ctx.sessionManager.getSessionId()) ??
|
|
223
|
+
ctx.sessionManager.getSessionFile() ??
|
|
224
|
+
undefined;
|
|
225
|
+
|
|
226
|
+
service.initSession({
|
|
227
|
+
pi,
|
|
228
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
229
|
+
mainSessionFile: cachedMainSessionFile,
|
|
230
|
+
// 注入 ctx.ui.setWidget 作为 streaming sink(只绑方法,不持有整个 ctx)。
|
|
231
|
+
// background subagent 执行期间,text_delta 经 SubagentStream 合并后由此通道转发。
|
|
232
|
+
// [W1 修复] ctx.mode === 'rpc' 守卫:TUI/json/print 下 streamSink = undefined(无 widget 噪音),
|
|
233
|
+
// rpc mode(GUI/xyz-agent)下保持原行为(ctx.ui.setWidget → sidecar → chatStore)。
|
|
234
|
+
// streamSink API 不变(SubagentStream.onDelta 仍可调,只是 TUI 下 stream 不会被创建)。
|
|
235
|
+
streamSink: ctx.mode === "rpc"
|
|
236
|
+
? { setWidget: (key, lines) => ctx.ui.setWidget(key, lines) }
|
|
237
|
+
: undefined,
|
|
238
|
+
// [#24][D4-④] uiRequestHandler 单一注入入口 = initSession 参数(原
|
|
239
|
+
// setUiRequestHandler 方法已随 D4 拆分删除)。SR-3 语义保留:无论 new 还是
|
|
240
|
+
// existing(/resume /fork 复用),session_start 都注入 handler 覆盖旧值;
|
|
241
|
+
// headless 下工厂返回 undefined → 传 null(显式清空语义,防上一个 session 的
|
|
242
|
+
// handler 残留——与原 setUiRequestHandler(undefined) 行为等价)。
|
|
243
|
+
uiRequestHandler: uiRequestHandler ?? null,
|
|
244
|
+
mode: ctx.mode,
|
|
245
|
+
// SR-4:注入 L2 dialog 队列——session-runner child close 时调 rejectChildDialogs
|
|
246
|
+
// 清理该 child 在 L2 的 pending dialog,防全局死锁(C1 修复:清理路径接通)。
|
|
247
|
+
dialogQueue,
|
|
248
|
+
// [竞态修复] 注入 ctx.isIdle:notifier flush 在主 agent busy 时退避,idle 后再
|
|
249
|
+
// sendMessage(triggerTurn),规避 agent_end→finishRun 窗口里走 steer 分支丢失通知。
|
|
250
|
+
isIdle: () => ctx.isIdle(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const reused = existingService !== null;
|
|
254
|
+
if (!existingService) {
|
|
255
|
+
setModelConfigService(modelService);
|
|
256
|
+
setSubagentService(service);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// S-2: 启动 idle record GC 定时器(30 天 TTL,每小时检查一次)。
|
|
260
|
+
// 注册 setInterval 属进程级副作用——oncePerProcess 守卫防双跑(u-audit-fix)。
|
|
261
|
+
oncePerProcess("subagent-workflow:start-gc-timer", () => service.startGcTimer());
|
|
262
|
+
|
|
263
|
+
return { service, modelService, reused };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── 装配分组 helper(复杂度消减提取;调用序 = 原内联序,行为不变) ────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* [M4] identity 子进程写入(V2 决策 5)。
|
|
270
|
+
*
|
|
271
|
+
* 子进程经 env(PI_SUBAGENT_*)接收自己的 identity,在 session_start 用 pi.appendEntry
|
|
272
|
+
* 写 subagent-identity custom entry。pi 自动生成 id/parentId → message tree 连续。
|
|
273
|
+
* 旧实现父进程 fs.appendFileSync 补写的 custom entry 缺 id/parentId → 污染 _buildIndex
|
|
274
|
+
* leafId 指针 → message tree 断成两棵 → 多轮对话丢上下文(bug 根因)。
|
|
275
|
+
* 主/子进程判定:PI_SUBAGENT_SELF_RECORD_ID 仅 session-runner spawn 子进程时注入,
|
|
276
|
+
* 主进程无此 env → 跳过(identity 只在子进程写一次)。失败记日志不阻断(设计 §3.4)。
|
|
277
|
+
*/
|
|
278
|
+
function appendSubagentIdentityEntry(pi: ExtensionAPI): void {
|
|
279
|
+
const selfRecordId = process.env.PI_SUBAGENT_SELF_RECORD_ID;
|
|
280
|
+
if (!selfRecordId) return;
|
|
281
|
+
try {
|
|
282
|
+
const modeEnv = process.env.PI_SUBAGENT_MODE;
|
|
283
|
+
// ExecutionMode 联合窄化:父进程经 env 注入(record.mode 恒为 "background"),
|
|
284
|
+
// 运行时校验合法值,非法兜底 background(避免裸 cast,符合 taste/no-unsafe-cast)。
|
|
285
|
+
const mode: ExecutionMode = modeEnv === "background" ? modeEnv : "background";
|
|
286
|
+
const identity: SubagentIdentityData = {
|
|
287
|
+
id: selfRecordId,
|
|
288
|
+
agent: process.env.PI_SUBAGENT_AGENT ?? "",
|
|
289
|
+
mode,
|
|
290
|
+
task: process.env.PI_SUBAGENT_TASK ?? "",
|
|
291
|
+
slug: process.env.PI_SUBAGENT_SLUG,
|
|
292
|
+
startedAt: Number(process.env.PI_SUBAGENT_STARTED_AT ?? Date.now()),
|
|
293
|
+
rootSessionId: process.env.PI_SUBAGENT_ROOT_SESSION_ID,
|
|
294
|
+
parentRecordId: process.env.PI_SUBAGENT_PARENT_RECORD_ID,
|
|
295
|
+
depth:
|
|
296
|
+
process.env.PI_SUBAGENT_DEPTH !== undefined
|
|
297
|
+
? Number(process.env.PI_SUBAGENT_DEPTH)
|
|
298
|
+
: undefined,
|
|
299
|
+
forkDepth:
|
|
300
|
+
process.env.PI_SUBAGENT_FORK_DEPTH !== undefined
|
|
301
|
+
? Number(process.env.PI_SUBAGENT_FORK_DEPTH)
|
|
302
|
+
: undefined,
|
|
303
|
+
chatMode: process.env.PI_SUBAGENT_CHAT_MODE === "true",
|
|
304
|
+
// [review round2] worktree 隔离标志(session-runner 注入):跨重启重建路径据此
|
|
305
|
+
// 拒绝续聊(handle 不可序列化,reattach 不可行)。
|
|
306
|
+
worktree: process.env.PI_SUBAGENT_WORKTREE === "true",
|
|
307
|
+
};
|
|
308
|
+
pi.appendEntry(IDENTITY_CUSTOM_TYPE, identity);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
logger.warn("[subagents] identity appendEntry failed in session_start", {
|
|
311
|
+
reason: toErrorMessage(err),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* [U2] 通知账本装配 + 重启恢复(设计 D4:存在性 / 可达性分离)。
|
|
318
|
+
*
|
|
319
|
+
* bind 先于 service.initSession(notifier.revive 在其内——notify() 经
|
|
320
|
+
* getBoundNotifyLedger 消费账本)。recoverFromSession 扫 ledger/ack 两列 entry
|
|
321
|
+
* 差集:未销账号重放投递(已销账零重发,notifyId 幂等);fork 继承未销账
|
|
322
|
+
* pending 属可接受语义(D4 归属规则——扫描域 = 单 session 文件,幂等键作用域
|
|
323
|
+
* 随文件域隔离)。compaction 存活情况归 session_compact handler 的条件降级(P-B4
|
|
324
|
+
* 探针阶段 5 实测,见 notify-ledger.ts compactionCheck)。装配失败不阻断
|
|
325
|
+
* session_start(通知退回 notifier 的内核路径)。
|
|
326
|
+
*/
|
|
327
|
+
function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
328
|
+
try {
|
|
329
|
+
const ledgerHost: NotifyLedgerHost = {
|
|
330
|
+
appendLedgerEntry: (customType, data) => {
|
|
331
|
+
pi.appendEntry(customType, data);
|
|
332
|
+
},
|
|
333
|
+
readSessionEntries: () => ctx.sessionManager.getEntries(),
|
|
334
|
+
isIdle: () => ctx.isIdle(),
|
|
335
|
+
onAgentSettled: (handler) => {
|
|
336
|
+
pi.on("agent_settled", handler);
|
|
337
|
+
},
|
|
338
|
+
sendDelivery: (message) => {
|
|
339
|
+
// D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
|
|
340
|
+
// courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
|
|
341
|
+
pi.sendMessage(message, { triggerTurn: true });
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
// U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
|
|
345
|
+
// 通道落盘),此处不再重复打日志。
|
|
346
|
+
bindNotifyLedgerHost(ledgerHost).recoverFromSession();
|
|
347
|
+
} catch (err) {
|
|
348
|
+
logger.warn("[subagents] notify ledger bind failed", {
|
|
349
|
+
reason: toErrorMessage(err),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 随迁块 4 的进程级维护三连(各 try-catch「失败记日志不阻断」,设计 §3.4):
|
|
356
|
+
* 过期 session 文件清理 / ADR-035 manifest tmp 恢复 / ADR-035 worktree reaper 扫描。
|
|
357
|
+
* 另含 [E1] sync 批崩溃恢复(per-session 域,不用 oncePerProcess——见块内注释)。
|
|
358
|
+
*/
|
|
359
|
+
async function runProcessLevelMaintenance(
|
|
360
|
+
agentDir: string,
|
|
361
|
+
ctx: ExtensionContext,
|
|
362
|
+
service: SubagentService,
|
|
363
|
+
deps: SessionLifecycleDeps,
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
// [E1] sync 批崩溃恢复(subagent-sync-collect 设计 §3.1.5 E1,U5 接线):扫描主
|
|
366
|
+
// session 末条 entry 重建批缓冲;全员终态未投递 → notifyBatch 补发 + 统一补
|
|
367
|
+
// batchFinalized 标记(账本同 hash 幂等拒绝也算已投递)。须晚于 initSession(孤儿
|
|
368
|
+
// 终态恢复先行收敛 running 成员,createOrReuseServices 内部同步完成)与 ledger
|
|
369
|
+
// bind(补发走 notifyBatch 写账链,见上方 bindNotifyLedgerHost)。per-session 域
|
|
370
|
+
// (主 session 文件),不用 oncePerProcess;best-effort 不阻断 session_start。
|
|
371
|
+
// 时序约束在调用点 setupSessionLifecycle 已满足(bindLedgerHostAndRecover 与
|
|
372
|
+
// createOrReuseServices 均先于本 helper 调用)。
|
|
373
|
+
try {
|
|
374
|
+
service.recoverSyncCollectBatch();
|
|
375
|
+
} catch (err) {
|
|
376
|
+
logger.warn("[subagents] sync collect batch recovery failed", {
|
|
377
|
+
reason: toErrorMessage(err),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
// 递归扫描 <agentDir>/subagents + unlink 超 TTL 跨 session 文件属进程级维护
|
|
383
|
+
// ——oncePerProcess 守卫防双跑(u-audit-fix)。
|
|
384
|
+
oncePerProcess("subagent-workflow:cleanup-expired-session-files", () =>
|
|
385
|
+
maybeCleanupExpiredSessionFiles(agentDir, ctx.cwd));
|
|
386
|
+
} catch (err) {
|
|
387
|
+
logger.warn("[subagents] expired session file cleanup failed", {
|
|
388
|
+
reason: toErrorMessage(err),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ADR-035 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下,promote/unlink)。
|
|
393
|
+
// 扫描属进程级维护——oncePerProcess 守卫防双跑(u-audit-fix);第二派发重放首次
|
|
394
|
+
// Promise(结果缓存语义),recovered 计数日志可能重打,无文件副作用。
|
|
395
|
+
try {
|
|
396
|
+
const recovered = await oncePerProcess("subagent-workflow:recover-manifest-tmp-files", () =>
|
|
397
|
+
service.recoverManifestTmpFiles());
|
|
398
|
+
if (recovered.recovered > 0 || recovered.deleted > 0) {
|
|
399
|
+
logger.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
logger.warn("[subagents] manifest tmp recovery failed", {
|
|
403
|
+
reason: toErrorMessage(err),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
// ADR-035:worktree reaper 扫描(git/rm 进程操作 + 注册表/目录扫描)属进程级
|
|
409
|
+
// 维护——oncePerProcess 守卫防双跑(u-audit-fix)。默认每次扫描新建
|
|
410
|
+
// WorktreeManager(现状语义);deps.worktreeManager 供测试注入 fake(scanCalls 计数)。
|
|
411
|
+
await oncePerProcess("subagent-workflow:worktree-scan", async () => {
|
|
412
|
+
const wtm = deps.worktreeManager ?? new WorktreeManager(agentDir);
|
|
413
|
+
await wtm.scan();
|
|
414
|
+
});
|
|
415
|
+
} catch (err) {
|
|
416
|
+
logger.warn("[subagents] worktree reaper scan failed", {
|
|
417
|
+
reason: toErrorMessage(err),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** createSessionRunState 返回值(随迁块 5 的装配产物)。 */
|
|
423
|
+
interface SessionRunState {
|
|
424
|
+
store: JsonlRunStore;
|
|
425
|
+
runs: Map<string, WorkflowRun>;
|
|
426
|
+
/** MF-1: loadAll 失败则 false,workflow 域启动时 fail-fast */
|
|
427
|
+
storeHealthy: boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 随迁块 5:per-session run store + runs + kill-9 崩溃恢复循环 + storeHealthy 跟踪。
|
|
432
|
+
*
|
|
433
|
+
* MF-1: store 健康度跟踪。loadAll 失败 → storeHealthy=false,workflow 域启动时 fail-fast。
|
|
434
|
+
* 崩溃恢复四步(loadAll → failed → save → evict)收口到 core recoverCrashedRuns(D8:
|
|
435
|
+
* 宿主各写一遍正是 failure-mode-B);pending:unregister 经 hooks 外置发射(位置在
|
|
436
|
+
* transition 后、save 前,对齐原内联实现);save 走 store 冷路径(done 绕过去抖)——
|
|
437
|
+
* 冷路径语义在 JsonlRunStore.save 内,不随循环归属转移。loadAll 失败的 fail-fast
|
|
438
|
+
* (storeHealthy=false 停初始化)是宿主职责,core 原样上抛、这里 catch 兜住。
|
|
439
|
+
*/
|
|
440
|
+
async function createSessionRunState(
|
|
441
|
+
sessionDir: string,
|
|
442
|
+
pi: ExtensionAPI,
|
|
443
|
+
ctx: ExtensionContext,
|
|
444
|
+
deps: SessionLifecycleDeps,
|
|
445
|
+
): Promise<SessionRunState> {
|
|
446
|
+
const store = deps.createRunStore
|
|
447
|
+
? deps.createRunStore(sessionDir, pi, ctx)
|
|
448
|
+
: new JsonlRunStore({
|
|
449
|
+
sessionDir,
|
|
450
|
+
pi,
|
|
451
|
+
ctx,
|
|
452
|
+
});
|
|
453
|
+
const runs = new Map<string, WorkflowRun>();
|
|
454
|
+
|
|
455
|
+
// F-4/D-003: agent 发现走 shared/resource-discovery(ADR-031),modelService
|
|
456
|
+
// 自持 AgentRegistry(subagents/workflow 两域共用同一发现结果)。
|
|
457
|
+
// M2 修正:workflow 域 resolveAgentOpts 不再消费 agentRegistry(agent ref 交
|
|
458
|
+
// resolveIdentity),无需经 state 透传——modelService 是唯一 registry 源。
|
|
459
|
+
let storeHealthy = true;
|
|
460
|
+
try {
|
|
461
|
+
// 崩溃恢复 loadAll 扫 cwd 共享 sessionDir(同 cwd 跨 session 共享)并把 running run
|
|
462
|
+
// 转 failed 落盘——写非本 session 的 run state 文件属跨 session 副作用,oncePerProcess
|
|
463
|
+
// 守卫防双跑(u-audit-fix)。第二派发重放首次 Promise:不再落盘、不再 emit。
|
|
464
|
+
await oncePerProcess(
|
|
465
|
+
"subagent-workflow:recover-crashed-runs",
|
|
466
|
+
() =>
|
|
467
|
+
recoverCrashedRuns(
|
|
468
|
+
store,
|
|
469
|
+
runs,
|
|
470
|
+
"Process killed (kill-9 or crash recovery)",
|
|
471
|
+
{
|
|
472
|
+
onRunRecovered: (payload) => {
|
|
473
|
+
pi.events.emit("pending:unregister", payload);
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
// QMF-4 fix: store.loadAll 失败是关键路径错误,workflow 域将未初始化
|
|
480
|
+
logger.error("[subagent-workflow] store.loadAll failed, workflow domain uninitialized", {
|
|
481
|
+
reason: toErrorMessage(err),
|
|
482
|
+
});
|
|
483
|
+
storeHealthy = false;
|
|
484
|
+
}
|
|
485
|
+
return { store, runs, storeHealthy };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── 单一装配入口 ─────────────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 会话生命周期装配单一入口(bootstrap seam,D1)。index.ts 的 session_start 退为
|
|
492
|
+
* `await setupSessionLifecycle(pi, ctx, makeLifecycleDeps())`。
|
|
493
|
+
*
|
|
494
|
+
* 错误处理语义原样保留(设计 §3.4):identity/ledger/cleanup 各 try-catch
|
|
495
|
+
* 「失败记日志不阻断」;kill-9 恢复 save 失败 error 日志不阻断其余 run(下次
|
|
496
|
+
* session_start 幂等重试)。
|
|
497
|
+
*/
|
|
498
|
+
export async function setupSessionLifecycle(
|
|
499
|
+
pi: ExtensionAPI,
|
|
500
|
+
ctx: ExtensionContext,
|
|
501
|
+
deps: SessionLifecycleDeps,
|
|
502
|
+
): Promise<SessionLifecycleResult> {
|
|
503
|
+
const agentDir = getAgentDir();
|
|
504
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
505
|
+
|
|
506
|
+
// [U7] 引擎列表同步 engines.json(幂等零写 + fail-safe;组合根注册已在
|
|
507
|
+
// extension 工厂体完成,此处 registry 已含全部引擎)。写 agentDir 全局文件属
|
|
508
|
+
// 跨 session 副作用——oncePerProcess 守卫防 factory 二调/handler 累积双跑(u-audit-fix)。
|
|
509
|
+
oncePerProcess("subagent-workflow:sync-engines-file", () => syncEnginesFile(agentDir));
|
|
510
|
+
|
|
511
|
+
// skill 路径两级缓存 session 级失效:pi 同进程可能有多个 session(TUI /new、/fork),
|
|
512
|
+
// 运行中安装的 skill 需对新 session 可见(含曾 miss 缓存的 undefined 条目与 npm 新装
|
|
513
|
+
// 包的候选目录)。session 内复用收益不变(IF8/DM3 消重发生在同 session 的重复调用)。
|
|
514
|
+
clearSkillPathCache();
|
|
515
|
+
|
|
516
|
+
// ── [M4] identity 子进程写入(随迁块 1)──
|
|
517
|
+
appendSubagentIdentityEntry(pi);
|
|
518
|
+
|
|
519
|
+
// ── [U2] 通知账本装配 + 重启恢复(随迁块 2)──
|
|
520
|
+
bindLedgerHostAndRecover(pi, ctx);
|
|
521
|
+
|
|
522
|
+
// ── subagents 域:双 Service 装配(随迁块 3,经 deps 可注入)──
|
|
523
|
+
const { service, modelService } = deps.createServices
|
|
524
|
+
? deps.createServices(pi, ctx)
|
|
525
|
+
: createOrReuseServices(pi, ctx);
|
|
526
|
+
|
|
527
|
+
// ── GC / manifest tmp / worktree 恢复(随迁块 4)──
|
|
528
|
+
await runProcessLevelMaintenance(agentDir, ctx, service, deps);
|
|
529
|
+
|
|
530
|
+
// ── workflow 域:per-session store + runs + kill-9 恢复(随迁块 5)──
|
|
531
|
+
const sessionDir = resolveSessionDir();
|
|
532
|
+
const { store, runs, storeHealthy } = await createSessionRunState(sessionDir, pi, ctx, deps);
|
|
533
|
+
|
|
534
|
+
// D-008: per-session SAR(需要 ctxModel 填底 + subagentService 委托目标)。
|
|
535
|
+
// old: const runner = new SubprocessAgentRunner()(module-level singleton,无 deps)
|
|
536
|
+
// new: per-session session_start 时创建,经 SessionLifecycleResult 传给组合根。
|
|
537
|
+
const runner = new SubprocessAgentRunner({
|
|
538
|
+
subagentService: service,
|
|
539
|
+
ctxModel: ctx.model ?? undefined,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// [engine-awareness D1b] lastEngine 初始化:构造性同源——单次 reloadGlobalConfig
|
|
543
|
+
// 读取同时刷新 Service 路由缓存与 lastEngine 基准,消灭 initModel 与本处两次独立
|
|
544
|
+
// 读取间的分叉窗口(两读值不一致时检测走 unchanged 分支不 reload,状态段/路由
|
|
545
|
+
// 永停旧值且永不通知)。ok/absent → 归一后的当前引擎;failed → undefined(首 turn
|
|
546
|
+
// 检测静默基线化兜底,此时缓存亦保持不动)。/resume、/fork 同样走 session_start
|
|
547
|
+
// (SR-3),基线天然覆盖。
|
|
548
|
+
const engineRead = modelService.reloadGlobalConfig();
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
sessionId,
|
|
552
|
+
store,
|
|
553
|
+
runs,
|
|
554
|
+
sessionDir,
|
|
555
|
+
runner,
|
|
556
|
+
ctx,
|
|
557
|
+
storeHealthy,
|
|
558
|
+
lastEngine:
|
|
559
|
+
engineRead.status === "failed" ? undefined : normalizeEngineId(engineRead.config.defaultEngine),
|
|
560
|
+
};
|
|
561
|
+
}
|