@zhushanwen/pi-subagent-workflow 8.10.1 → 8.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "8.10.1",
3
+ "version": "8.12.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -54,21 +54,21 @@
54
54
  ]
55
55
  },
56
56
  "dependencies": {
57
- "@xyz-agent/extension-protocol": "0.8.2",
57
+ "@xyz-agent/extension-protocol": "0.9.0",
58
58
  "@xyz-agent/session-delivery": "0.3.1",
59
- "@zhushanwen/pi-ext-guards": "0.2.1",
60
- "@zhushanwen/pi-extension-logger": "0.4.1",
61
- "@zhushanwen/subagent-core": "0.7.1",
62
- "@zhushanwen/pi-subagent-cli": "0.1.2",
63
- "@zhushanwen/zcode-subagent-cli": "0.1.2"
59
+ "@zhushanwen/pi-ext-guards": "0.3.0",
60
+ "@zhushanwen/pi-extension-logger": "0.6.0",
61
+ "@zhushanwen/subagent-core": "0.9.0",
62
+ "@zhushanwen/pi-subagent-cli": "0.2.0",
63
+ "@zhushanwen/zcode-subagent-cli": "0.2.1"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@earendil-works/pi-ai": "^0.84.4",
67
67
  "@earendil-works/pi-coding-agent": "^0.84.4",
68
68
  "@earendil-works/pi-tui": "^0.84.4",
69
69
  "typebox": "*",
70
- "@zhushanwen/pi-pending-notifications": "0.6.0",
71
- "@zhushanwen/pi-structured-output": "5.1.4"
70
+ "@zhushanwen/pi-pending-notifications": "0.7.0",
71
+ "@zhushanwen/pi-structured-output": "5.1.5"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@earendil-works/pi-coding-agent": {
@@ -0,0 +1,237 @@
1
+ // inflight-reporter.test.ts —— 壳层在途上报出口(u7a,设计 §3.3 D5)。
2
+ //
3
+ // 三视角:
4
+ // ①使用者(runtime event-adapter 视角)——帧形状:title=SUBAGENT_INFLIGHT_MARKER、
5
+ // options=[JSON 帧](kind/inFlight/sessionId/emittedAt)、控制面级 timeout 在场;
6
+ // ②构建者——初始上报(attachSession 触发,kind='initial',无需任何 subagent 调用)
7
+ // → ack 成功一次后转 'delta';失败折叠 + 延迟重试直至成功一次;推送在途期间
8
+ // 多次迁移合并为单帧且携带最新绝对计数;
9
+ // ③观察者——onInFlightChanged 同步返回(不 await select,不进生命周期链);
10
+ // detachSession 停重试,session 死后通道静默。
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ // ── hoisted mocks(依赖收窄:logger 防文件落盘;core barrel 只留快照函数可控点) ──
15
+
16
+ const extensionLoggerMock = vi.hoisted(() => ({
17
+ getLogger: vi.fn(() => ({
18
+ debug: vi.fn(),
19
+ warn: vi.fn(),
20
+ error: vi.fn(),
21
+ })),
22
+ }));
23
+
24
+ const mockSnapshot = vi.hoisted(() => vi.fn((): { inFlight: number } => ({ inFlight: 0 })));
25
+
26
+ vi.mock("@zhushanwen/pi-extension-logger", () => extensionLoggerMock);
27
+ vi.mock("@zhushanwen/subagent-core", () => ({ getInFlightSnapshot: mockSnapshot }));
28
+
29
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
30
+ import { INFLIGHT_REPORT_ACK, SUBAGENT_INFLIGHT_MARKER } from "@xyz-agent/extension-protocol";
31
+
32
+ import { createInFlightReporter } from "../inflight-reporter.ts";
33
+
34
+ const RETRY_MS = 50;
35
+ const SELECT_TIMEOUT_MS = 1_000;
36
+
37
+ type SelectCall = { title: string; payload: string; timeout: number | undefined };
38
+
39
+ /** 可控 select 通道:每次调用的应答由用例逐帧裁决(ack / undefined / 抛错 / 挂起)。 */
40
+ function makeSelectChannel() {
41
+ const calls: SelectCall[] = [];
42
+ const pending: Array<(v: unknown) => void> = [];
43
+ const select = vi.fn(
44
+ (title: string, options: string[], opts?: { timeout?: number }): Promise<unknown> => {
45
+ calls.push({ title, payload: options[0] ?? "", timeout: opts?.timeout });
46
+ return new Promise((resolve) => pending.push(resolve));
47
+ },
48
+ );
49
+ return {
50
+ select,
51
+ calls,
52
+ /** resolve 第 N 帧(0 起)。 */
53
+ settle(index: number, value: unknown): void {
54
+ pending[index]?.(value);
55
+ },
56
+ settleAll(value: unknown): void {
57
+ while (pending.length > 0) pending.shift()?.(value);
58
+ },
59
+ };
60
+ }
61
+
62
+ function makeCtx(channel: ReturnType<typeof makeSelectChannel>, sessionId = "sess-u7a"): ExtensionContext {
63
+ return {
64
+ cwd: "/w",
65
+ mode: "rpc",
66
+ sessionManager: { getSessionId: () => sessionId },
67
+ ui: { select: channel.select },
68
+ } as unknown as ExtensionContext;
69
+ }
70
+
71
+ function parseFrame(call: SelectCall): Record<string, unknown> {
72
+ return JSON.parse(call.payload) as Record<string, unknown>;
73
+ }
74
+
75
+ beforeEach(() => {
76
+ mockSnapshot.mockImplementation(() => ({ inFlight: 0 }));
77
+ vi.useFakeTimers();
78
+ });
79
+
80
+ afterEach(() => {
81
+ vi.useRealTimers();
82
+ });
83
+
84
+ /** 推进 fake 时间并排空微任务(attempt 的 await 链走完)。 */
85
+ async function advance(ms: number): Promise<void> {
86
+ await vi.advanceTimersByTimeAsync(ms);
87
+ }
88
+
89
+ describe("初始上报(D5:触发时点 = extension 加载完成 / session 就绪)", () => {
90
+ it("attachSession 即发 kind='initial' 帧(count=当下快照,无需任何 subagent 调用)", async () => {
91
+ const channel = makeSelectChannel();
92
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
93
+
94
+ reporter.attachSession(makeCtx(channel));
95
+ await advance(0);
96
+
97
+ expect(channel.calls).toHaveLength(1);
98
+ expect(channel.calls[0].title).toBe(SUBAGENT_INFLIGHT_MARKER);
99
+ expect(channel.calls[0].timeout).toBe(SELECT_TIMEOUT_MS);
100
+ const frame = parseFrame(channel.calls[0]);
101
+ expect(frame.kind).toBe("initial");
102
+ expect(frame.inFlight).toBe(0);
103
+ expect(frame.sessionId).toBe("sess-u7a");
104
+ expect(typeof frame.emittedAt).toBe("number");
105
+
106
+ channel.settleAll(INFLIGHT_REPORT_ACK);
107
+ await advance(0);
108
+ });
109
+
110
+ it("初始未送达前发生的迁移不产生第二帧(合并进 initial,送达后转 delta)", async () => {
111
+ const channel = makeSelectChannel();
112
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
113
+ reporter.attachSession(makeCtx(channel));
114
+ await advance(0);
115
+
116
+ mockSnapshot.mockImplementation(() => ({ inFlight: 2 }));
117
+ reporter.onInFlightChanged();
118
+ reporter.onInFlightChanged();
119
+ await advance(0);
120
+ expect(channel.calls).toHaveLength(1); // 推送在途 → 只置脏,无第二帧
121
+
122
+ channel.settleAll(INFLIGHT_REPORT_ACK);
123
+ await advance(0);
124
+ expect(channel.calls).toHaveLength(2);
125
+ const delta = parseFrame(channel.calls[1]);
126
+ expect(delta.kind).toBe("delta");
127
+ expect(delta.inFlight).toBe(2); // 补推帧携带最新绝对计数
128
+ });
129
+ });
130
+
131
+ describe("绝对计数语义(每帧携带当下值,非增量)", () => {
132
+ it("连续迁移各帧均为整值快照(2 → 0,不做加减)", async () => {
133
+ const channel = makeSelectChannel();
134
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
135
+ reporter.attachSession(makeCtx(channel));
136
+ await advance(0);
137
+ channel.settleAll(INFLIGHT_REPORT_ACK);
138
+ await advance(0);
139
+
140
+ mockSnapshot.mockImplementation(() => ({ inFlight: 2 }));
141
+ reporter.onInFlightChanged();
142
+ await advance(0);
143
+ mockSnapshot.mockImplementation(() => ({ inFlight: 0 }));
144
+ reporter.onInFlightChanged(); // attempt(count=2) 仍在途 → 置脏合并
145
+ expect(channel.calls).toHaveLength(2);
146
+
147
+ // 前帧 ack 落定后,脏标记触发补推(携带此刻快照 0)
148
+ channel.settleAll(INFLIGHT_REPORT_ACK);
149
+ await advance(0);
150
+ expect(channel.calls).toHaveLength(3);
151
+ expect(parseFrame(channel.calls[1]).inFlight).toBe(2);
152
+ expect(parseFrame(channel.calls[2]).inFlight).toBe(0);
153
+ channel.settleAll(INFLIGHT_REPORT_ACK);
154
+ });
155
+ });
156
+
157
+ describe("失败折叠 + 延迟重试直至成功一次(D5 缺席语义②)", () => {
158
+ it("select resolve undefined(超时/旧版 runtime)→ 折叠重试;重试帧仍 kind='initial';ack 后停", async () => {
159
+ const channel = makeSelectChannel();
160
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
161
+ reporter.attachSession(makeCtx(channel));
162
+ await advance(0);
163
+ expect(channel.calls).toHaveLength(1);
164
+
165
+ // 首帧无人 ack,select 以 undefined 落定(超时形态)→ 折叠
166
+ channel.settle(0, undefined);
167
+ await advance(RETRY_MS - 1);
168
+ expect(channel.calls).toHaveLength(1); // 未到退避点不重试
169
+ await advance(1);
170
+ expect(channel.calls).toHaveLength(2);
171
+ expect(parseFrame(channel.calls[1]).kind).toBe("initial"); // 初始「成功一次」未达成
172
+
173
+ // 重试帧得到 ack → 重试停止;随后迁移以 delta 送达
174
+ channel.settle(1, INFLIGHT_REPORT_ACK);
175
+ await advance(RETRY_MS * 10);
176
+ expect(channel.calls).toHaveLength(2);
177
+
178
+ reporter.onInFlightChanged();
179
+ await advance(0);
180
+ expect(channel.calls).toHaveLength(3);
181
+ expect(parseFrame(channel.calls[2]).kind).toBe("delta");
182
+ channel.settleAll(INFLIGHT_REPORT_ACK);
183
+ });
184
+
185
+ it("select 通道抛错同样折叠进重试路径", async () => {
186
+ const channel = makeSelectChannel();
187
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
188
+ reporter.attachSession(makeCtx(channel));
189
+ await advance(0);
190
+ channel.settle(0, new Error("channel blew up"));
191
+ await advance(RETRY_MS);
192
+ expect(channel.calls.length).toBeGreaterThanOrEqual(2);
193
+ channel.settleAll(INFLIGHT_REPORT_ACK);
194
+ await advance(0);
195
+ });
196
+
197
+ it("非确认回包(旧版 runtime 的任意字符串)不算送达,继续重试", async () => {
198
+ const channel = makeSelectChannel();
199
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
200
+ reporter.attachSession(makeCtx(channel));
201
+ await advance(0);
202
+ channel.settle(0, '{"ok":1}');
203
+ await advance(RETRY_MS);
204
+ expect(channel.calls).toHaveLength(2);
205
+ channel.settleAll(INFLIGHT_REPORT_ACK);
206
+ await advance(0);
207
+ });
208
+ });
209
+
210
+ describe("不阻塞生命周期主链(D5 接线约束①)", () => {
211
+ it("onInFlightChanged 同步返回:select 永不落定也不挂调用方(detach 可丢弃在途帧)", async () => {
212
+ const channel = makeSelectChannel();
213
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
214
+ reporter.attachSession(makeCtx(channel));
215
+ await advance(0);
216
+
217
+ // 第一帧挂起(模拟 runtime 无响应)——迁移调用仍同步返回
218
+ expect(() => reporter.onInFlightChanged()).not.toThrow();
219
+
220
+ // session 死后 detach:挂起帧被丢弃,重试链静止(时间推进零新调用)
221
+ reporter.detachSession();
222
+ channel.settleAll(undefined);
223
+ await advance(RETRY_MS * 5);
224
+ expect(channel.calls).toHaveLength(1);
225
+ });
226
+
227
+ it("detachSession 清掉待发重试定时器(session 已死,重试语义随之终结)", async () => {
228
+ const channel = makeSelectChannel();
229
+ const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
230
+ reporter.attachSession(makeCtx(channel));
231
+ await advance(0);
232
+ reporter.detachSession();
233
+ channel.settleAll(undefined); // 在途帧失败落定——ctx 已空,不排新重试
234
+ await advance(RETRY_MS * 10);
235
+ expect(channel.calls).toHaveLength(1);
236
+ });
237
+ });
@@ -0,0 +1,193 @@
1
+ // src/host/inflight-reporter.ts
2
+ //
3
+ // 壳层在途上报出口(u7a,设计权威源:docs/design/crash-forensics-and-watchdog.md
4
+ // §3.3 D5「extension 聚合上报」)。本文件属壳侧(shell),对 pi SDK(ExtensionContext
5
+ // 的 ctx.ui.select 通道)的消费收敛在 host/ 层——core 闭包红线只约束 core(出口回调
6
+ // 由 core 的 inflight-snapshot 注入,见组合根 index.ts 的 setInFlightListener 接线)。
7
+ //
8
+ // 链路:core 状态迁移点(session-runner 子进程注册/移除、idle timer arm/disarm)
9
+ // → notifyInFlightChanged(同步回调)→ 本 reporter(绝对计数快照 → select 通道帧,
10
+ // title = SUBAGENT_INFLIGHT_MARKER)→ runtime event-adapter(u7b)。
11
+ //
12
+ // D5 接线三约束的落点:
13
+ // ① 不阻塞生命周期主链——onInFlightChanged 同步返回,推送全部 void fire-and-forget,
14
+ // 绝不进 agent_settled handler await 链;
15
+ // ② 事件产生点在 core、上报出口在壳层——本文件即出口(core 零 pi SDK);
16
+ // ③ marker 路由不广播前端是 u7b(runtime 侧)的约束,本文件不涉及。
17
+ //
18
+ // 语义(D5):每帧携带**绝对计数**(getInFlightSnapshot,谓词与 core 内
19
+ // hasRunningBackground 同源),非增量;初始上报(initial)触发时点 = extension 加载
20
+ // 完成——factory 阶段拿不到 ctx/ui(pi 0.84.4 实装:dist/core/extensions/loader.js:463
21
+ // await factory(load.api) 只收静态注册面 API(registerTool/events.on…,
22
+ // createExtensionAPI :209 函数体无 ui 字段);per-session ctx 由
23
+ // dist/core/extensions/runner.js createContext() :503(get ui :508)在事件派发时点
24
+ // 构造,emit :624 每次现场建 ctx)——session_start 是 pi 启动序列里最早带 ctx 的钩子
25
+ // (dist/core/agent-session.js:1919 _extensionRunner.emit(_sessionStartEvent),默认
26
+ // 事件 :152)= 「session 就绪」,即设计所指加载完成时点;不挂任何懒触发(无 subagent
27
+ // 的 session 也必上报)。
28
+ //
29
+ // 失败语义(D5 缺席语义②):select 失败(超时/通道异常/非确认回包)折叠后**延迟重试
30
+ // 直至成功一次**。送达判据 = runtime resolve 的确认回包(INFLIGHT_REPORT_ACK)——
31
+ // fire-and-forget 下 resolve(undefined) 与超时不可区分,必须靠显式 ack 区分「已送达」
32
+ // 与「旧版 runtime 无路由」,否则 errs 判别(「从未收到上报」只覆盖缺席/旧版)失效。
33
+
34
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import { SUBAGENT_INFLIGHT_MARKER, isInFlightReportAck } from "@xyz-agent/extension-protocol";
36
+ import { getInFlightSnapshot } from "@zhushanwen/subagent-core";
37
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
38
+
39
+ /** select 通道级超时(控制面单请求,秒级校准——超时默认原则规则 19)。取值对齐
40
+ * plugin-bridge 启动 sync 的 2s 自愈闸:session_start 首帧可能早于 runtime adapter
41
+ * attach(R2 实证),超时折叠后重试必然自愈;fire-and-forget 帧不留 pending 挂死面。 */
42
+ const SELECT_TIMEOUT_MS = 2_000;
43
+
44
+ /** 失败重试退避(对齐 plugin-bridge SYNC_RETRY_MS 控制面节奏)。 */
45
+ const RETRY_DELAY_MS = 2_000;
46
+
47
+ /** 在途上报器(组合根 index.ts 持有;per-factory 实例,session_start/shutdown 驱动)。 */
48
+ export interface InFlightReporter {
49
+ /**
50
+ * session_start 注入当前 ctx 并发起初始上报(fire-and-forget——本方法同步返回,
51
+ * 不阻塞 session_start 装配链;初始帧 kind='initial',count 为当下绝对计数)。
52
+ */
53
+ attachSession(ctx: ExtensionContext): void;
54
+ /** session_shutdown 摘除 ctx 并停止重试(session 已死,上报通道随之终结)。 */
55
+ detachSession(): void;
56
+ /** core 出口回调(notifyInFlightChanged 直连):同步返回,内部合并 + void 推送。 */
57
+ onInFlightChanged(): void;
58
+ }
59
+
60
+ export interface InFlightReporterOpts {
61
+ /** 测试注入:select 超时(ms)。缺省 SELECT_TIMEOUT_MS。 */
62
+ selectTimeoutMs?: number;
63
+ /** 测试注入:重试退避(ms)。缺省 RETRY_DELAY_MS。 */
64
+ retryDelayMs?: number;
65
+ }
66
+
67
+ /**
68
+ * sessionId 从 ctx 取(plugin-bridge getSessionId 同款防御):pi session 文件延迟写入
69
+ * 窗口内取失败不阻断上报——sessionId 缺席时 runtime 按无法归属丢弃整帧(契约
70
+ * SubagentInFlightReport.sessionId 可选语义),不视为协议错误。
71
+ */
72
+ function getSessionId(ctx: ExtensionContext): string | undefined {
73
+ try {
74
+ return ctx.sessionManager.getSessionId();
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFlightReporter {
81
+ const selectTimeoutMs = opts.selectTimeoutMs ?? SELECT_TIMEOUT_MS;
82
+ const retryDelayMs = opts.retryDelayMs ?? RETRY_DELAY_MS;
83
+ const logger = getLogger("subagents");
84
+
85
+ // 闭包状态(per-factory 实例;禁模块级 let——同进程多 factory 实例会串台)。
86
+ let ctx: ExtensionContext | null = null;
87
+ /** 初始上报是否已送达(送达后所有帧 kind='delta')。 */
88
+ let initialAcked = false;
89
+ /** 一次推送尝试在途(串行化——绝对计数语义下中间值可安全合并丢弃)。 */
90
+ let attemptInFlight = false;
91
+ /** 有待推帧(onInFlightChanged 在推送在途期间置位,成功后立即补推最新值)。 */
92
+ let dirty = false;
93
+ /** 重试定时器句柄(单飞;成功/dispose 即清)。 */
94
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
95
+ /** 首次失败已 warn 留痕(重试循环不刷屏——旧版 runtime 场景会长期重试)。 */
96
+ let firstFailureLogged = false;
97
+
98
+ function clearRetryTimer(): void {
99
+ if (retryTimer !== null) {
100
+ clearTimeout(retryTimer);
101
+ retryTimer = null;
102
+ }
103
+ }
104
+
105
+ /** 推送在途或无 ctx 时仅置脏;否则发起一次尝试(void,不阻塞调用方)。 */
106
+ function kick(): void {
107
+ if (attemptInFlight || ctx === null) {
108
+ dirty = true;
109
+ return;
110
+ }
111
+ dirty = false;
112
+ attemptInFlight = true;
113
+ void attempt();
114
+ }
115
+
116
+ async function attempt(): Promise<void> {
117
+ const active = ctx;
118
+ if (active === null) {
119
+ attemptInFlight = false;
120
+ return;
121
+ }
122
+ // 帧内容在发送时刻现取:绝对计数 = 此刻快照(「当前非 idle 句柄数」)、
123
+ // 产生时点 = 此刻、kind 按初始上报是否已送达裁决(初始未送达前,脏帧也以
124
+ // initial 语义送达——对 errs 判别等价:runtime 收到任何帧即「在场」)。
125
+ const snapshot = getInFlightSnapshot();
126
+ const payload = JSON.stringify({
127
+ kind: initialAcked ? "delta" : "initial",
128
+ inFlight: snapshot.inFlight,
129
+ sessionId: getSessionId(active),
130
+ emittedAt: Date.now(),
131
+ });
132
+ let value: unknown;
133
+ try {
134
+ value = await active.ui.select(SUBAGENT_INFLIGHT_MARKER, [payload], { timeout: selectTimeoutMs });
135
+ } catch (err) {
136
+ // 通道异常折叠(plugin-bridge callBridge 同款:不静默吞,但只首败 warn)。
137
+ value = undefined;
138
+ logFailure("select channel threw", err);
139
+ }
140
+ attemptInFlight = false;
141
+ if (typeof value === "string" && isInFlightReportAck(value)) {
142
+ // 送达确认:清重试;初始上报「成功一次」达成后,后续帧一律 delta。
143
+ initialAcked = true;
144
+ clearRetryTimer();
145
+ if (dirty && ctx !== null) kick();
146
+ return;
147
+ }
148
+ // 失败折叠(resolve undefined = 超时/取消/旧版无路由)→ 延迟重试直至成功一次
149
+ //(D5 缺席语义②:使「从未收到」只覆盖缺席/旧版形态,不覆盖一次性丢包)。
150
+ logFailure("no ack (timeout, cancelled, or runtime without marker routing)", value);
151
+ if (ctx !== null && retryTimer === null) {
152
+ retryTimer = setTimeout(() => {
153
+ retryTimer = null;
154
+ kick();
155
+ }, retryDelayMs);
156
+ // unref:不阻塞进程退出(退出收割由既有 process hook 负责,与 plugin-bridge 同款)。
157
+ retryTimer.unref?.();
158
+ }
159
+ }
160
+
161
+ function logFailure(reason: string, detail: unknown): void {
162
+ if (!firstFailureLogged) {
163
+ firstFailureLogged = true;
164
+ logger.warn(`[subagent-inflight] in-flight report failed (${reason}); retrying every ${retryDelayMs}ms until acked`, {
165
+ detail: detail instanceof Error ? detail.message : String(detail),
166
+ });
167
+ return;
168
+ }
169
+ logger.debug(`[subagent-inflight] in-flight report retry failed (${reason})`);
170
+ }
171
+
172
+ return {
173
+ attachSession(target: ExtensionContext): void {
174
+ ctx = target;
175
+ clearRetryTimer();
176
+ // 初始上报(count=当下绝对计数;session 就绪时点恒为 0——子进程只会在后续
177
+ // subagent 调用里出现)。fire-and-forget:不阻塞 session_start 装配链。
178
+ kick();
179
+ },
180
+
181
+ detachSession(): void {
182
+ ctx = null;
183
+ dirty = false;
184
+ clearRetryTimer();
185
+ },
186
+
187
+ onInFlightChanged(): void {
188
+ // 同步返回(D5 约束①):core 迁移点直连本方法,任何 await 都会进
189
+ // agent_settled 等生命周期链——这里只置脏 + void 发起。
190
+ kick();
191
+ },
192
+ };
193
+ }
package/src/index.ts CHANGED
@@ -5,10 +5,13 @@
5
5
  * 注册项:3 tool(subagent + workflow + workflow-script)+ 2 command(subagents + workflows)
6
6
  * + messageRenderer(subagent-bg-notify)+ pi.__workflowRun + session 事件。
7
7
  *
8
- * 三层架构:
9
- * interface/ → 注册胶水(tools/commands/tui
10
- * orchestration/ workflow engine(launcher/lifecycle/error-recovery)
11
- * execution/ → subagents 执行运行时(SubagentService/session-runner/concurrency-pool)
8
+ * 包内结构(执行运行时已迁 packages/subagent-core,本包只留注册面与宿主适配):
9
+ * interface/ → 注册胶水(tools / commands / TUI 渲染 / GUI mappers
10
+ * host/ pi 宿主端口实现(HostServices / NotifyDomain 的 pi 侧兑现)
11
+ * injectors/ → 提示注入器(engine-awareness / model-list / resource-list …)
12
+ * session-lifecycle.ts → 会话生命周期装配 seam(测试可注入 fake 依赖)
13
+ *
14
+ * 架构导航见 docs/extensions/subagents/architecture.md。
12
15
  *
13
16
  * 设计基线:D-004(旧包不动)/ ADR-025(进程内执行)/ D-8(pi.__workflowRun 签名)。
14
17
  */
@@ -20,10 +23,13 @@ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
20
23
  // ═══ core 宿主端口接线(subagent-core 包抽离 u0-wire;实现见 src/host/pi-host.ts) ═══
21
24
  import { configureCore } from "@zhushanwen/subagent-core";
22
25
  import { configureNotifyDomain } from "@zhushanwen/subagent-core";
26
+ import { setInFlightListener } from "@zhushanwen/subagent-core";
23
27
  import { createPiHostServices, createPiNotifyDomainPorts } from "./host/pi-host.ts";
28
+ // [u7a D5] 壳层在途上报出口:core 状态迁移 → 本出口 → select 通道(marker 帧)→ runtime。
29
+ import { createInFlightReporter } from "./host/inflight-reporter.ts";
24
30
 
25
31
  import { bestEffort } from "@zhushanwen/subagent-core";
26
- // ═══ execution/ 层(subagents 核心 + 运行时) ═══
32
+ // ═══ core barrel 消费执行域(执行运行时住 packages/subagent-core) ═══
27
33
  // [U7] 引擎列表状态文件(registry → engines.json,GUI 引擎选择器数据源)
28
34
  import { syncEnginesFile } from "@zhushanwen/subagent-core";
29
35
  // [W11/DoD#5] registerPiEngine(inproc 'pi' 注册)已随内建引擎删除:registry 'pi'
@@ -50,7 +56,7 @@ import { registerSubagentTool } from "./interface/subagent-tool.ts";
50
56
  import { registerSubagentsCommand } from "./interface/subagents.ts";
51
57
  import { registerWorkflowTool } from "./interface/tool-workflow.ts";
52
58
  import { registerWorkflowScriptTool } from "./interface/tool-workflow-script.ts";
53
- // ═══ orchestration/ 层(workflow engine + infra) ═══
59
+ // ═══ core barrel 消费 workflow 域(引擎与 worker 住 packages/subagent-core) ═══
54
60
  import type { LauncherDeps } from "@zhushanwen/subagent-core";
55
61
  import { executeNestedWorkflow, runAndWait, type WorkflowRunResult } from "@zhushanwen/subagent-core";
56
62
  import {
@@ -146,6 +152,13 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
146
152
  // [P3 引擎接线] 登记 'zcode'(幂等同上)。D8 薄壳:vendored 定位 cli descriptor。
147
153
  registerZcodeEngine();
148
154
 
155
+ // [u7a D5] 在途聚合上报接线:core 状态迁移(spawn/close/arm/disarm)→ 出口回调 →
156
+ // 本 reporter 经 select 通道推绝对计数帧。出口为进程级单监听(在途记账本身是 pi
157
+ // 进程级模块状态),后注册覆盖先注册(jiti 重载幂等);回调同步 void,不进任何
158
+ // 生命周期 await 链(D5 接线约束①)。ctx 由 session_start 注入(factory 阶段无 ui)。
159
+ const inflightReporter = createInFlightReporter();
160
+ setInFlightListener(inflightReporter.onInFlightChanged);
161
+
149
162
  // [U7b] 引擎列表在 extension 模块加载时即同步 engines.json(不等 session_start——
150
163
  // 用户体验拍板 2026-08-25:xyz-agent 打开后激活任意 session 的第一时间(含 TUI 等价
151
164
  // 场景)GUI 引擎选择器就该有数据;session_start 处保留幂等重写兜底 jiti 双路径/
@@ -263,7 +276,18 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
263
276
  scheduleTimeBudget(runId, deps, budgetTimeMs),
264
277
  onWorkflowCall: (name: string, args: Record<string, unknown>, parentRun: WorkflowRun) =>
265
278
  executeNestedWorkflow(name, args, parentRun, deps),
266
- streamSink: getSubagentService()?.getStreamSink() ?? undefined,
279
+ // [H2 W3] workflow agent() 统一派发入口(设计 §3.5):pump 侧 dispatchAgentCall
280
+ // 经此转调 SubagentService.executeWorkflowAgent——真实 record(origin:"workflow"
281
+ // + parentRunId)进 store、共享池/守护/journal 归 service 编排;parentRunId 由
282
+ // pump 补 run.runId。service 单例在 session_start 后必在(run 只能于 session 内
283
+ // 派发);null 时抛错由 pump 的 dispatchCall catch 兜底回发 failed result。
284
+ workflowAgentDispatch: (opts, parentRunId, signal) => {
285
+ const service = getSubagentService();
286
+ if (!service) {
287
+ throw new Error("workflow agent dispatch unavailable: subagent service not initialized");
288
+ }
289
+ return service.executeWorkflowAgent(opts, parentRunId, signal);
290
+ },
267
291
  log,
268
292
  };
269
293
  return deps;
@@ -288,6 +312,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
288
312
  // ════════════════════════════════════════════════════════════
289
313
  pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
290
314
  lsRef.lastSessionId = ctx.sessionManager.getSessionId();
315
+ // [u7a D5] 初始上报(count=当下绝对计数):触发时点 = extension 加载完成 / session
316
+ // 就绪(factory 无 ctx/ui,session_start 是最早带 ctx 的钩子——plugin-bridge 同款
317
+ // 事实)。fire-and-forget 在 await 装配链之前发起,不阻塞也不被阻塞。
318
+ inflightReporter.attachSession(ctx);
291
319
  const result = await setupSessionLifecycle(pi, ctx, makeLifecycleDeps());
292
320
  sessionState.set(result.sessionId, result);
293
321
  });
@@ -412,6 +440,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
412
440
  // ── subagents 域:dispose SubagentService ──
413
441
  getSubagentService()?.dispose();
414
442
 
443
+ // [u7a D5] 在途上报通道随 session 终结:摘 ctx + 停重试(session 已死,重试直至
444
+ // 成功的语义只对活 session 成立;进程级出口监听保留——后续 /new 重新 attach)。
445
+ inflightReporter.detachSession();
446
+
415
447
  // ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
416
448
  // H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——
417
449
  // 防御 session 切换但 session_tree 未先触发导致 lastSessionId 指向已删除 session 的情况)。
@@ -468,9 +500,11 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
468
500
  // - beforeExit 是退出前最后事件,不 exit(自然退出)。
469
501
  // - idempotent guard(reapSpawnedChildrenOnShutdown 内)防多信号叠加重复 kill。
470
502
  //
471
- // 防线 iii(activate 互斥)已接入:subagent-service.ts 冷路径 resume 调
472
- // acquireActivateLock(含 30s 超时兜底,见 lifecycle-manager.ts ACTIVATE_LOCK_TIMEOUT_MS)。
473
- // 防线 ii(启动 scanOrphanProcesses)骨架就位,启动时接入待实现。
503
+ // 防线 iii(activate 互斥):未接线——acquireActivateLock 机制已随简化清扫删除
504
+ // (历史接线点随协议化重构消失,仅余自持单测)。当前的双写者防护由
505
+ // subagent-service resumesInFlight 集合守卫承担。
506
+ // 防线 ii(启动孤儿扫描):未接线,骨架已随 L2 死代码清扫删除(当前 piped stdio
507
+ // 下 stdin-EOF 自灭链覆盖崩溃路径,见 docs/design/v2-defense-ii-iii-resolution.md)。
474
508
  // ════════════════════════════════════════════════════════════
475
509
  process.on("SIGTERM", () => {
476
510
  reapSpawnedChildrenOnShutdown();
@@ -110,9 +110,6 @@ function fakeEngine(id: string, models: Array<{ id: string; name?: string }>): E
110
110
  run: async () => {
111
111
  throw new Error("not used in this test");
112
112
  },
113
- interact: async () => {
114
- throw new Error("not used in this test");
115
- },
116
113
  read: async () => ({ engineId: id, turns: [], source: "outcome-only" }),
117
114
  // 每次调用返回新数组实例(模拟 registry 每 turn 现值)——渲染必须与实例无关
118
115
  listModels: () => models.map((m) => ({ ...m })),
@@ -22,11 +22,12 @@
22
22
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
23
23
 
24
24
  import type { LauncherDeps } from "@zhushanwen/subagent-core";
25
- import { abortRun } from "@zhushanwen/subagent-core";
25
+ import { abortRun, getSubagentService } from "@zhushanwen/subagent-core";
26
26
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
27
27
  import { parseWorkflowRpcCommand, type WorkflowRpcAction } from "./command-actions.ts";
28
28
  import { createWorkflowsView, type ViewActions } from "./views/WorkflowsView.ts";
29
29
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
30
+ import { LIST_LIMIT } from "./list-shared.ts";
30
31
 
31
32
  /** runId 截断长度(显示用)。 */
32
33
  const RUNID_SHORT = 8;
@@ -248,6 +249,12 @@ function sortedRuns(runs: Map<string, WorkflowRun>): WorkflowRun[] {
248
249
  *
249
250
  * ViewActions 通过 deps 调 lifecycle(abort),与 view 解耦——
250
251
  * view 单测可注入 mock actions(见 views/__tests__/WorkflowsView-signature.test.ts)。
252
+ *
253
+ * [H2 W3] live 进度数据源(设计 D2 进度源切换):view 经 store 查询
254
+ * collectRecordsByParentRunId(run.runId)(内存 ∪ 磁盘重建 ∪ manifest,LIST_LIMIT
255
+ * 口径)配对 running trace node 渲染实时进度。service 单例未就绪(理论上 run 只能
256
+ * 于 session 内存在,session_start 后必在;防御分支)时不注入——view 走终态
257
+ * result 渲染路径。
251
258
  */
252
259
  async function openView(
253
260
  run: WorkflowRun,
@@ -256,7 +263,11 @@ async function openView(
256
263
  deps: LauncherDeps,
257
264
  ): Promise<void> {
258
265
  const actions: ViewActions = {
259
- abort: (runId: string) => abortRun(runId, deps),
266
+ abort: (runId) => abortRun(runId, deps),
260
267
  };
261
- await createWorkflowsView(run, theme, ctx, actions, deps.store.stateFilePath(run.runId));
268
+ const service = getSubagentService();
269
+ const liveRecords = service
270
+ ? () => service.queries.collectRecordsByParentRunId(run.runId, LIST_LIMIT)
271
+ : undefined;
272
+ await createWorkflowsView(run, theme, ctx, actions, deps.store.stateFilePath(run.runId), liveRecords);
262
273
  }
@@ -494,6 +494,22 @@ export function formatElapsed(startedAt?: string, now: number = Date.now()): str
494
494
  return formatElapsedSeconds(Math.max(0, secs));
495
495
  }
496
496
 
497
+ /**
498
+ * Run status 行的 elapsed([H2 A2] done 后冻结)。
499
+ *
500
+ * 与 formatElapsed 的差异仅在 now 基准:completedAt 有值(run 已终态)时以
501
+ * completedAt 为基准 → elapsed 恒等于 completedAt - startedAt,不再随墙钟增长;
502
+ * running(completedAt 缺省)沿用 Date.now() 缺省实时跳动。时钟异常
503
+ * (completedAt < startedAt)由 formatElapsed 钳 0 兜底("0s")。
504
+ */
505
+ export function formatRunStatusElapsed(
506
+ startedAt?: string,
507
+ completedAt?: string,
508
+ now: number = Date.now(),
509
+ ): string {
510
+ return formatElapsed(startedAt, completedAt ? new Date(completedAt).getTime() : now);
511
+ }
512
+
497
513
  /**
498
514
  * Format a live eventLog entry(live 路径 Activity 区用)。
499
515
  *