@zhushanwen/pi-subagent-workflow 8.10.1 → 8.11.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.
|
|
3
|
+
"version": "8.11.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.
|
|
57
|
+
"@xyz-agent/extension-protocol": "0.9.0",
|
|
58
58
|
"@xyz-agent/session-delivery": "0.3.1",
|
|
59
|
-
"@zhushanwen/pi-ext-guards": "0.
|
|
60
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
61
|
-
"@zhushanwen/subagent-core": "0.
|
|
59
|
+
"@zhushanwen/pi-ext-guards": "0.3.0",
|
|
60
|
+
"@zhushanwen/pi-extension-logger": "0.6.0",
|
|
61
|
+
"@zhushanwen/subagent-core": "0.8.0",
|
|
62
62
|
"@zhushanwen/pi-subagent-cli": "0.1.2",
|
|
63
|
-
"@zhushanwen/zcode-subagent-cli": "0.
|
|
63
|
+
"@zhushanwen/zcode-subagent-cli": "0.2.0"
|
|
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.
|
|
71
|
-
"@zhushanwen/pi-structured-output": "5.1.
|
|
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
|
@@ -20,7 +20,10 @@ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
|
20
20
|
// ═══ core 宿主端口接线(subagent-core 包抽离 u0-wire;实现见 src/host/pi-host.ts) ═══
|
|
21
21
|
import { configureCore } from "@zhushanwen/subagent-core";
|
|
22
22
|
import { configureNotifyDomain } from "@zhushanwen/subagent-core";
|
|
23
|
+
import { setInFlightListener } from "@zhushanwen/subagent-core";
|
|
23
24
|
import { createPiHostServices, createPiNotifyDomainPorts } from "./host/pi-host.ts";
|
|
25
|
+
// [u7a D5] 壳层在途上报出口:core 状态迁移 → 本出口 → select 通道(marker 帧)→ runtime。
|
|
26
|
+
import { createInFlightReporter } from "./host/inflight-reporter.ts";
|
|
24
27
|
|
|
25
28
|
import { bestEffort } from "@zhushanwen/subagent-core";
|
|
26
29
|
// ═══ execution/ 层(subagents 核心 + 运行时) ═══
|
|
@@ -146,6 +149,13 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
146
149
|
// [P3 引擎接线] 登记 'zcode'(幂等同上)。D8 薄壳:vendored 定位 cli descriptor。
|
|
147
150
|
registerZcodeEngine();
|
|
148
151
|
|
|
152
|
+
// [u7a D5] 在途聚合上报接线:core 状态迁移(spawn/close/arm/disarm)→ 出口回调 →
|
|
153
|
+
// 本 reporter 经 select 通道推绝对计数帧。出口为进程级单监听(在途记账本身是 pi
|
|
154
|
+
// 进程级模块状态),后注册覆盖先注册(jiti 重载幂等);回调同步 void,不进任何
|
|
155
|
+
// 生命周期 await 链(D5 接线约束①)。ctx 由 session_start 注入(factory 阶段无 ui)。
|
|
156
|
+
const inflightReporter = createInFlightReporter();
|
|
157
|
+
setInFlightListener(inflightReporter.onInFlightChanged);
|
|
158
|
+
|
|
149
159
|
// [U7b] 引擎列表在 extension 模块加载时即同步 engines.json(不等 session_start——
|
|
150
160
|
// 用户体验拍板 2026-08-25:xyz-agent 打开后激活任意 session 的第一时间(含 TUI 等价
|
|
151
161
|
// 场景)GUI 引擎选择器就该有数据;session_start 处保留幂等重写兜底 jiti 双路径/
|
|
@@ -288,6 +298,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
288
298
|
// ════════════════════════════════════════════════════════════
|
|
289
299
|
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
290
300
|
lsRef.lastSessionId = ctx.sessionManager.getSessionId();
|
|
301
|
+
// [u7a D5] 初始上报(count=当下绝对计数):触发时点 = extension 加载完成 / session
|
|
302
|
+
// 就绪(factory 无 ctx/ui,session_start 是最早带 ctx 的钩子——plugin-bridge 同款
|
|
303
|
+
// 事实)。fire-and-forget 在 await 装配链之前发起,不阻塞也不被阻塞。
|
|
304
|
+
inflightReporter.attachSession(ctx);
|
|
291
305
|
const result = await setupSessionLifecycle(pi, ctx, makeLifecycleDeps());
|
|
292
306
|
sessionState.set(result.sessionId, result);
|
|
293
307
|
});
|
|
@@ -412,6 +426,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
412
426
|
// ── subagents 域:dispose SubagentService ──
|
|
413
427
|
getSubagentService()?.dispose();
|
|
414
428
|
|
|
429
|
+
// [u7a D5] 在途上报通道随 session 终结:摘 ctx + 停重试(session 已死,重试直至
|
|
430
|
+
// 成功的语义只对活 session 成立;进程级出口监听保留——后续 /new 重新 attach)。
|
|
431
|
+
inflightReporter.detachSession();
|
|
432
|
+
|
|
415
433
|
// ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
|
|
416
434
|
// H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——
|
|
417
435
|
// 防御 session 切换但 session_tree 未先触发导致 lastSessionId 指向已删除 session 的情况)。
|
package/src/interface/helpers.ts
CHANGED
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { guardStaleCtx, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
13
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
12
14
|
|
|
13
15
|
// bounded JSON pretty 序列化(IF13/#19,TC5/ES5)已下沉 core shared
|
|
14
16
|
// (u-core-atomic 逐字平移,输出与原本地实现字节一致;本地实现已删)。
|
|
15
17
|
import { boundedPrettySerialize } from "@zhushanwen/subagent-core";
|
|
16
18
|
|
|
19
|
+
// 模块级 logger(与 session-lifecycle.ts / index.ts 同 component 名)
|
|
20
|
+
const logger = getLogger("subagents");
|
|
21
|
+
|
|
17
22
|
import type { WorkflowRun } from "@zhushanwen/subagent-core";
|
|
18
23
|
import {
|
|
19
24
|
guiComponent,
|
|
@@ -147,14 +152,31 @@ export function notifyDone(
|
|
|
147
152
|
);
|
|
148
153
|
}
|
|
149
154
|
|
|
150
|
-
|
|
155
|
+
// stale ctx 防御(crash-resilience D1 / ext-guards 审计 §7 blockers#1 收口):
|
|
156
|
+
// notifyDone 经 index.ts onRunDone 在 workflow 完成链路异步触发,不在 pi emit() 的
|
|
157
|
+
// try/catch 内、无自有 try/catch——session 替换窗口触碰 stale pi 命中 assertActive
|
|
158
|
+
// (PS-30)即无人接 rejection 崩 pi(E1 同机制)。stale 静默降级(完成通知不投递,
|
|
159
|
+
// 用户可从 session 历史 / 工具结果看到 workflow 结果,判定见 stale-ctx-audit.md §4),
|
|
160
|
+
// 非 stale 错误原样上抛(守卫不吞真实 bug)。
|
|
161
|
+
guardStaleCtx(
|
|
162
|
+
() =>
|
|
163
|
+
pi.sendMessage(
|
|
164
|
+
{
|
|
165
|
+
customType: "workflow-result",
|
|
166
|
+
content,
|
|
167
|
+
display: true,
|
|
168
|
+
details,
|
|
169
|
+
},
|
|
170
|
+
{ triggerTurn: true, deliverAs: "steer" }, // g4-allow: 存量待迁移——workflow 完成通知属结果语义,迁移切片复用 U2 账本设施(附录 B 待办)
|
|
171
|
+
),
|
|
151
172
|
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
173
|
+
label: "subagent-workflow:notifyDone",
|
|
174
|
+
onStale: (error) =>
|
|
175
|
+
logger.warn("workflow completion notice delivery skipped (stale ctx)", {
|
|
176
|
+
runId,
|
|
177
|
+
error: toErrorMessage(error),
|
|
178
|
+
}),
|
|
156
179
|
},
|
|
157
|
-
{ triggerTurn: true, deliverAs: "steer" }, // g4-allow: 存量待迁移——workflow 完成通知属结果语义,迁移切片复用 U2 账本设施(附录 B 待办)
|
|
158
180
|
);
|
|
159
181
|
}
|
|
160
182
|
|
package/src/session-lifecycle.ts
CHANGED
|
@@ -22,7 +22,7 @@ import * as path from "node:path";
|
|
|
22
22
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
25
|
-
import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
25
|
+
import { guardStaleCtx, oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
26
26
|
|
|
27
27
|
// ═══ core 宿主端口消费(随迁块的依赖;production 默认实现住本文件) ═══
|
|
28
28
|
import { getOrCreateChannelRegistry } from "@zhushanwen/subagent-core";
|
|
@@ -323,8 +323,12 @@ function appendSubagentIdentityEntry(pi: ExtensionAPI): void {
|
|
|
323
323
|
* 随文件域隔离)。compaction 存活情况归 session_compact handler 的条件降级(P-B4
|
|
324
324
|
* 探针阶段 5 实测,见 notify-ledger.ts compactionCheck)。装配失败不阻断
|
|
325
325
|
* session_start(通知退回 notifier 的内核路径)。
|
|
326
|
+
*
|
|
327
|
+
* export + 返回装配的 NotifyLedgerHost = 测试直入 seam(可直调 sendDelivery 验证
|
|
328
|
+
* 守卫分诊);生产调用方(setupSessionLifecycle)忽略返回值,行为不变。装配失败
|
|
329
|
+
* 返回 undefined。
|
|
326
330
|
*/
|
|
327
|
-
function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext):
|
|
331
|
+
export function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): NotifyLedgerHost | undefined {
|
|
328
332
|
try {
|
|
329
333
|
const ledgerHost: NotifyLedgerHost = {
|
|
330
334
|
appendLedgerEntry: (customType, data) => {
|
|
@@ -338,16 +342,31 @@ function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): void
|
|
|
338
342
|
sendDelivery: (message) => {
|
|
339
343
|
// D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
|
|
340
344
|
// courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
|
|
341
|
-
|
|
345
|
+
// stale ctx 防御(crash-resilience D1 / ext-guards 审计 §7 blockers#1 收口):
|
|
346
|
+
// sendDelivery 经 settled 边沿 / 看门狗 / 恢复重放异步触发——session 替换窗口
|
|
347
|
+
// 触碰 stale pi 命中 assertActive(PS-30)即无人接 rejection(E1 同机制)。
|
|
348
|
+
// stale 静默降级(本条通知不投递,attemptDeliver 按已受理标 sentAt——session
|
|
349
|
+
// 替换后通知对旧 session 已无意义,与守卫前「留 pending 反复撞 stale 直到账本
|
|
350
|
+
// 重绑」终局一致),非 stale 错误原样上抛(attemptDeliver 既有 catch 走
|
|
351
|
+
// settleRejected 留账重试语义不变)。
|
|
352
|
+
guardStaleCtx(() => pi.sendMessage(message, { triggerTurn: true }), {
|
|
353
|
+
label: "subagent-workflow:sendDelivery",
|
|
354
|
+
onStale: (error) =>
|
|
355
|
+
logger.warn("notify delivery skipped (stale ctx)", {
|
|
356
|
+
error: toErrorMessage(error),
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
342
359
|
},
|
|
343
360
|
};
|
|
344
361
|
// U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
|
|
345
362
|
// 通道落盘),此处不再重复打日志。
|
|
346
363
|
bindNotifyLedgerHost(ledgerHost).recoverFromSession();
|
|
364
|
+
return ledgerHost;
|
|
347
365
|
} catch (err) {
|
|
348
366
|
logger.warn("[subagents] notify ledger bind failed", {
|
|
349
367
|
reason: toErrorMessage(err),
|
|
350
368
|
});
|
|
369
|
+
return undefined;
|
|
351
370
|
}
|
|
352
371
|
}
|
|
353
372
|
|