@zhushanwen/pi-subagent-workflow 9.2.1 → 9.4.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 +16 -16
- package/src/host/__tests__/inflight-reporter.test.ts +44 -56
- package/src/host/__tests__/journal-reporter.test.ts +230 -0
- package/src/host/inflight-reporter.ts +29 -82
- package/src/host/journal-reporter.ts +204 -0
- package/src/index.ts +5 -2
- package/src/interface/command/__tests__/commands-resume.test.ts +53 -8
- package/src/interface/command/__tests__/subagent-model.test.ts +242 -0
- package/src/interface/command/command-actions.ts +6 -5
- package/src/interface/command/commands.ts +47 -7
- package/src/interface/command/subagent-model.ts +268 -0
- package/src/interface/command/subagents.ts +2 -2
- package/src/interface/tool/__tests__/tool-workflow-resume.test.ts +20 -0
- package/src/interface/tool/subagent-tool.ts +6 -5
- package/src/interface/tool/tool-workflow-script.ts +13 -9
- package/src/interface/tool/tool-workflow.ts +33 -4
- package/src/jsonl-run-store.ts +13 -3
- package/src/session-lifecycle.ts +4 -6
- package/src/workflow-events.ts +66 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.4.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.",
|
|
@@ -58,22 +58,22 @@
|
|
|
58
58
|
]
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@zhushanwen/extension-protocol": "0.
|
|
62
|
-
"@zhushanwen/session-delivery": "1.
|
|
63
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
64
|
-
"@zhushanwen/pi-extension-logger": "0.6.
|
|
65
|
-
"@zhushanwen/pi-notify-ledger-host": "0.
|
|
66
|
-
"@zhushanwen/subagent-
|
|
67
|
-
"@zhushanwen/
|
|
68
|
-
"@zhushanwen/
|
|
61
|
+
"@zhushanwen/extension-protocol": "0.19.0",
|
|
62
|
+
"@zhushanwen/session-delivery": "1.1.0",
|
|
63
|
+
"@zhushanwen/pi-ext-guards": "0.4.3",
|
|
64
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
65
|
+
"@zhushanwen/pi-notify-ledger-host": "0.3.1",
|
|
66
|
+
"@zhushanwen/pi-subagent-cli": "0.7.0",
|
|
67
|
+
"@zhushanwen/zcode-subagent-cli": "0.6.0",
|
|
68
|
+
"@zhushanwen/subagent-core": "1.4.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@earendil-works/pi-ai": "^0.
|
|
72
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
73
|
-
"@earendil-works/pi-tui": "^0.
|
|
74
|
-
"typebox": "
|
|
75
|
-
"@zhushanwen/pi-
|
|
76
|
-
"@zhushanwen/pi-
|
|
71
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
72
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
73
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
74
|
+
"typebox": "1.3.27",
|
|
75
|
+
"@zhushanwen/pi-structured-output": "6.1.0",
|
|
76
|
+
"@zhushanwen/pi-pending-notifications": "0.8.3"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"@types/node": "^24.0.0",
|
|
88
88
|
"@vitest/coverage-v8": "^4.1.9",
|
|
89
89
|
"vitest": "^4.1.8",
|
|
90
|
-
"@zhushanwen/subagent-engine-sdk": "0.
|
|
90
|
+
"@zhushanwen/subagent-engine-sdk": "0.10.0"
|
|
91
91
|
},
|
|
92
92
|
"scripts": {
|
|
93
93
|
"typecheck": "npx tsc --noEmit",
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// 三视角:
|
|
4
4
|
// ①使用者(runtime event-adapter 视角)——帧形状:title=SUBAGENT_INFLIGHT_MARKER、
|
|
5
5
|
// options=[JSON 帧](inFlight/sessionId/emittedAt)、控制面级 timeout 在场;
|
|
6
|
-
// ②构建者——初始上报(attachSession 触发,无需任何 subagent
|
|
7
|
-
//
|
|
6
|
+
// ②构建者——初始上报(attachSession 触发,无需任何 subagent 调用);失败折叠 +
|
|
7
|
+
// 置脏(无 timer 自动重试,ADR-0122);推送在途期间多次迁移合并为单帧且携带
|
|
8
|
+
// 最新绝对计数;
|
|
8
9
|
// ③观察者——onInFlightChanged 同步返回(不 await select,不进生命周期链);
|
|
9
|
-
// detachSession
|
|
10
|
+
// detachSession 后通道静默,session 死后不推帧。
|
|
10
11
|
//
|
|
11
12
|
// [HISTORICAL] 行为于 9f914b749 变更:kind='initial'|'delta' 帧字段与 initialAcked
|
|
12
13
|
// 状态机作为死面删除(消费方 event-adapter 从不读 kind,绝对计数语义下帧间等价;
|
|
@@ -35,7 +36,6 @@ import { INFLIGHT_REPORT_ACK, SUBAGENT_INFLIGHT_MARKER } from "@zhushanwen/exten
|
|
|
35
36
|
|
|
36
37
|
import { createInFlightReporter } from "../inflight-reporter.ts";
|
|
37
38
|
|
|
38
|
-
const RETRY_MS = 50;
|
|
39
39
|
const SELECT_TIMEOUT_MS = 1_000;
|
|
40
40
|
|
|
41
41
|
type SelectCall = { title: string; payload: string; timeout: number | undefined };
|
|
@@ -93,7 +93,7 @@ async function advance(ms: number): Promise<void> {
|
|
|
93
93
|
describe("初始上报(D5:触发时点 = extension 加载完成 / session 就绪)", () => {
|
|
94
94
|
it("attachSession 即发初始帧(count=当下快照,无需任何 subagent 调用)", async () => {
|
|
95
95
|
const channel = makeSelectChannel();
|
|
96
|
-
const reporter = createInFlightReporter({
|
|
96
|
+
const reporter = createInFlightReporter({ selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
97
97
|
|
|
98
98
|
reporter.attachSession(makeCtx(channel));
|
|
99
99
|
await advance(0);
|
|
@@ -113,7 +113,7 @@ describe("初始上报(D5:触发时点 = extension 加载完成 / session
|
|
|
113
113
|
|
|
114
114
|
it("初始未送达前发生的迁移不产生第二帧(合并为单帧,ack 后补推最新快照)", async () => {
|
|
115
115
|
const channel = makeSelectChannel();
|
|
116
|
-
const reporter = createInFlightReporter(
|
|
116
|
+
const reporter = createInFlightReporter();
|
|
117
117
|
reporter.attachSession(makeCtx(channel));
|
|
118
118
|
await advance(0);
|
|
119
119
|
|
|
@@ -135,7 +135,7 @@ describe("初始上报(D5:触发时点 = extension 加载完成 / session
|
|
|
135
135
|
describe("绝对计数语义(每帧携带当下值,非增量)", () => {
|
|
136
136
|
it("连续迁移各帧均为整值快照(2 → 0,不做加减)", async () => {
|
|
137
137
|
const channel = makeSelectChannel();
|
|
138
|
-
const reporter = createInFlightReporter(
|
|
138
|
+
const reporter = createInFlightReporter();
|
|
139
139
|
reporter.attachSession(makeCtx(channel));
|
|
140
140
|
await advance(0);
|
|
141
141
|
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
@@ -158,27 +158,32 @@ describe("绝对计数语义(每帧携带当下值,非增量)", () => {
|
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
describe("
|
|
162
|
-
it("select resolve undefined(超时/旧版 runtime)→
|
|
161
|
+
describe("失败折叠(ADR-0122:无 timer 自动重试,事件驱动重推)", () => {
|
|
162
|
+
it("select resolve undefined(超时/旧版 runtime)→ 置脏不重试;下次迁移事件重推最新快照", async () => {
|
|
163
163
|
const channel = makeSelectChannel();
|
|
164
|
-
const reporter = createInFlightReporter({
|
|
164
|
+
const reporter = createInFlightReporter({ selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
165
165
|
reporter.attachSession(makeCtx(channel));
|
|
166
166
|
await advance(0);
|
|
167
167
|
expect(channel.calls).toHaveLength(1);
|
|
168
168
|
|
|
169
|
-
// 首帧无人 ack,select 以 undefined 落定(超时形态)→
|
|
169
|
+
// 首帧无人 ack,select 以 undefined 落定(超时形态)→ 折叠置脏
|
|
170
170
|
channel.settle(0, undefined);
|
|
171
|
-
await advance(
|
|
172
|
-
|
|
173
|
-
await advance(
|
|
171
|
+
await advance(0);
|
|
172
|
+
// 推进远超原重试周期的时间:无 timer 自动重试(ADR-0122),零新帧
|
|
173
|
+
await advance(60_000);
|
|
174
|
+
expect(channel.calls).toHaveLength(1);
|
|
175
|
+
|
|
176
|
+
// 下一次 in-flight 变化事件驱动重推(携带最新绝对计数快照)
|
|
177
|
+
reporter.onInFlightChanged();
|
|
178
|
+
await advance(0);
|
|
174
179
|
expect(channel.calls).toHaveLength(2);
|
|
175
180
|
const retryFrame = parseFrame(channel.calls[1]);
|
|
176
|
-
expect(retryFrame.kind).toBeUndefined(); // kind 已删(9f914b749
|
|
177
|
-
expect(retryFrame.inFlight).toBe(0); //
|
|
181
|
+
expect(retryFrame.kind).toBeUndefined(); // kind 已删(9f914b749):重推帧与普通帧等价
|
|
182
|
+
expect(retryFrame.inFlight).toBe(0); // 重推帧携带完整绝对计数快照
|
|
178
183
|
|
|
179
|
-
//
|
|
184
|
+
// 重推帧得到 ack → 通道恢复;随后迁移照常送达
|
|
180
185
|
channel.settle(1, INFLIGHT_REPORT_ACK);
|
|
181
|
-
await advance(
|
|
186
|
+
await advance(60_000);
|
|
182
187
|
expect(channel.calls).toHaveLength(2);
|
|
183
188
|
|
|
184
189
|
reporter.onInFlightChanged();
|
|
@@ -188,81 +193,64 @@ describe("失败折叠 + 延迟重试(有界:累计 MAX_REPORT_ATTEMPTS 次
|
|
|
188
193
|
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
189
194
|
});
|
|
190
195
|
|
|
191
|
-
it("select
|
|
196
|
+
it("select 通道抛错同样折叠置脏,由迁移事件重推", async () => {
|
|
192
197
|
const channel = makeSelectChannel();
|
|
193
|
-
const reporter = createInFlightReporter(
|
|
198
|
+
const reporter = createInFlightReporter();
|
|
194
199
|
reporter.attachSession(makeCtx(channel));
|
|
195
200
|
await advance(0);
|
|
196
201
|
channel.settle(0, new Error("channel blew up"));
|
|
197
|
-
await advance(RETRY_MS);
|
|
198
|
-
expect(channel.calls.length).toBeGreaterThanOrEqual(2);
|
|
199
|
-
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
200
202
|
await advance(0);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const channel = makeSelectChannel();
|
|
205
|
-
const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
|
|
206
|
-
reporter.attachSession(makeCtx(channel));
|
|
203
|
+
await advance(60_000); // 无 timer 重试
|
|
204
|
+
expect(channel.calls).toHaveLength(1);
|
|
205
|
+
reporter.onInFlightChanged();
|
|
207
206
|
await advance(0);
|
|
208
|
-
channel.settle(0, '{"ok":1}');
|
|
209
|
-
await advance(RETRY_MS);
|
|
210
207
|
expect(channel.calls).toHaveLength(2);
|
|
211
208
|
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
212
209
|
await advance(0);
|
|
213
210
|
});
|
|
214
211
|
|
|
215
|
-
it("
|
|
212
|
+
it("非确认回包(旧版 runtime 的任意字符串)不算送达,置脏等事件重推", async () => {
|
|
216
213
|
const channel = makeSelectChannel();
|
|
217
|
-
|
|
218
|
-
const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
214
|
+
const reporter = createInFlightReporter();
|
|
219
215
|
reporter.attachSession(makeCtx(channel));
|
|
220
216
|
await advance(0);
|
|
217
|
+
channel.settle(0, '{"ok":1}');
|
|
218
|
+
await advance(0);
|
|
219
|
+
await advance(60_000); // 无 timer 重试
|
|
221
220
|
expect(channel.calls).toHaveLength(1);
|
|
222
|
-
|
|
223
|
-
// 三帧全部失败落定:首帧 + 2 次退避重试;第 3 次失败触顶
|
|
224
|
-
channel.settle(0, undefined);
|
|
225
|
-
await advance(RETRY_MS);
|
|
226
|
-
expect(channel.calls).toHaveLength(2);
|
|
227
|
-
channel.settle(1, undefined);
|
|
228
|
-
await advance(RETRY_MS);
|
|
229
|
-
expect(channel.calls).toHaveLength(3);
|
|
230
|
-
channel.settle(2, undefined);
|
|
231
|
-
|
|
232
|
-
// 达到上限:放弃为 session 内终态——时间推进与后续迁移都不再发帧
|
|
233
|
-
await advance(RETRY_MS * 10);
|
|
234
|
-
expect(channel.calls).toHaveLength(3);
|
|
235
221
|
reporter.onInFlightChanged();
|
|
236
|
-
await advance(
|
|
237
|
-
expect(channel.calls).toHaveLength(
|
|
222
|
+
await advance(0);
|
|
223
|
+
expect(channel.calls).toHaveLength(2);
|
|
224
|
+
channel.settleAll(INFLIGHT_REPORT_ACK);
|
|
225
|
+
await advance(0);
|
|
238
226
|
});
|
|
239
227
|
});
|
|
240
228
|
|
|
241
229
|
describe("不阻塞生命周期主链(D5 接线约束①)", () => {
|
|
242
230
|
it("onInFlightChanged 同步返回:select 永不落定也不挂调用方(detach 可丢弃在途帧)", async () => {
|
|
243
231
|
const channel = makeSelectChannel();
|
|
244
|
-
const reporter = createInFlightReporter(
|
|
232
|
+
const reporter = createInFlightReporter();
|
|
245
233
|
reporter.attachSession(makeCtx(channel));
|
|
246
234
|
await advance(0);
|
|
247
235
|
|
|
248
236
|
// 第一帧挂起(模拟 runtime 无响应)——迁移调用仍同步返回
|
|
249
237
|
expect(() => reporter.onInFlightChanged()).not.toThrow();
|
|
250
238
|
|
|
251
|
-
// session 死后 detach
|
|
239
|
+
// session 死后 detach:挂起帧被丢弃,通道静止(时间推进零新调用)
|
|
252
240
|
reporter.detachSession();
|
|
253
241
|
channel.settleAll(undefined);
|
|
254
|
-
await advance(
|
|
242
|
+
await advance(60_000);
|
|
255
243
|
expect(channel.calls).toHaveLength(1);
|
|
256
244
|
});
|
|
257
245
|
|
|
258
|
-
it("detachSession
|
|
246
|
+
it("detachSession 后失败落定不再发帧(ctx 已空,置脏无效果)", async () => {
|
|
259
247
|
const channel = makeSelectChannel();
|
|
260
|
-
const reporter = createInFlightReporter(
|
|
248
|
+
const reporter = createInFlightReporter();
|
|
261
249
|
reporter.attachSession(makeCtx(channel));
|
|
262
250
|
await advance(0);
|
|
263
251
|
reporter.detachSession();
|
|
264
|
-
channel.settleAll(undefined); // 在途帧失败落定——ctx
|
|
265
|
-
await advance(
|
|
252
|
+
channel.settleAll(undefined); // 在途帧失败落定——ctx 已空,不排重推
|
|
253
|
+
await advance(60_000);
|
|
266
254
|
expect(channel.calls).toHaveLength(1);
|
|
267
255
|
});
|
|
268
256
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// journal-reporter.test.ts —— 壳层 journal 事件推送出口(event-push-channel W-P1)。
|
|
2
|
+
//
|
|
3
|
+
// 三视角:
|
|
4
|
+
// ①使用者(runtime event-adapter 视角)——帧形状:title=SUBAGENT_JOURNAL_MARKER、
|
|
5
|
+
// options=[JSON 报告](domain/fileKey/events/sessionId/emittedAt)、控制面级
|
|
6
|
+
// timeout 在场;
|
|
7
|
+
// ②构建者——单帧单文件(fileKey 单数);一次推送尝试在途时新事件并入待推缓冲,
|
|
8
|
+
// 成功后逐组继续;失败(无 ack)→ 待推缓冲整体丢弃不重推(设计 D5,与 inflight
|
|
9
|
+
// 的 dirty 重推刻意分叉——消费方有 seq 水位可判缺口);
|
|
10
|
+
// ③观察者——onJournalAppended 同步返回(不 await select,不进落盘主链);
|
|
11
|
+
// 非 rpc 模式 no-op(不设 ctx,全程零帧);detach 后通道静默。
|
|
12
|
+
//
|
|
13
|
+
// 与 inflight-reporter 的差异锚:无初始帧(报告语义是增量事件,空 session 无事件
|
|
14
|
+
// 可报,缺席语义归消费方冷读——设计 §3.3)。
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
// ── hoisted mocks(依赖收窄:logger 防文件落盘) ──
|
|
19
|
+
|
|
20
|
+
const extensionLoggerMock = vi.hoisted(() => ({
|
|
21
|
+
getLogger: vi.fn(() => ({
|
|
22
|
+
debug: vi.fn(),
|
|
23
|
+
warn: vi.fn(),
|
|
24
|
+
error: vi.fn(),
|
|
25
|
+
})),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => extensionLoggerMock);
|
|
29
|
+
|
|
30
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { JOURNAL_REPORT_ACK, SUBAGENT_JOURNAL_MARKER } from "@zhushanwen/extension-protocol";
|
|
32
|
+
|
|
33
|
+
import { createJournalReporter } from "../journal-reporter.ts";
|
|
34
|
+
|
|
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-journal"): 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
|
+
|
|
72
|
+
function makeNonRpcCtx(channel: ReturnType<typeof makeSelectChannel>): ExtensionContext {
|
|
73
|
+
return {
|
|
74
|
+
cwd: "/w",
|
|
75
|
+
mode: "tui",
|
|
76
|
+
sessionManager: { getSessionId: () => "sess-journal" },
|
|
77
|
+
ui: { select: channel.select },
|
|
78
|
+
} as unknown as ExtensionContext;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFrame(call: SelectCall): Record<string, unknown> {
|
|
82
|
+
return JSON.parse(call.payload) as Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.useRealTimers();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** 推进 fake 时间并排空微任务(attempt 的 await 链走完)。 */
|
|
94
|
+
async function advance(ms: number): Promise<void> {
|
|
95
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("帧形状与单文件单帧(①使用者契约)", () => {
|
|
99
|
+
it("onJournalAppended → 单帧报告(title/marker、domain/fileKey/events/sessionId、控制面 timeout)", async () => {
|
|
100
|
+
const channel = makeSelectChannel();
|
|
101
|
+
const reporter = createJournalReporter({ selectTimeoutMs: SELECT_TIMEOUT_MS });
|
|
102
|
+
reporter.attachSession(makeCtx(channel));
|
|
103
|
+
|
|
104
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
105
|
+
await advance(0);
|
|
106
|
+
|
|
107
|
+
expect(channel.calls).toHaveLength(1);
|
|
108
|
+
expect(channel.calls[0].title).toBe(SUBAGENT_JOURNAL_MARKER);
|
|
109
|
+
expect(channel.calls[0].timeout).toBe(SELECT_TIMEOUT_MS);
|
|
110
|
+
const frame = parseFrame(channel.calls[0]);
|
|
111
|
+
expect(frame.domain).toBe("run");
|
|
112
|
+
expect(frame.fileKey).toBe("wf-1");
|
|
113
|
+
expect(frame.events).toEqual([{ type: "run-created", ts: 1, seq: 1 }]);
|
|
114
|
+
expect(frame.sessionId).toBe("sess-journal");
|
|
115
|
+
expect(typeof frame.emittedAt).toBe("number");
|
|
116
|
+
|
|
117
|
+
channel.settleAll(JOURNAL_REPORT_ACK);
|
|
118
|
+
await advance(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("在途期间同文件新事件并入待推缓冲(成功后作为下一帧);不同文件逐帧发送(fileKey 单数契约)", async () => {
|
|
122
|
+
const channel = makeSelectChannel();
|
|
123
|
+
const reporter = createJournalReporter();
|
|
124
|
+
reporter.attachSession(makeCtx(channel));
|
|
125
|
+
|
|
126
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
127
|
+
// 第一帧在途:同文件第二事件并入待推缓冲组;另一文件组队
|
|
128
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "agent-started", ts: 2, seq: 2 }]);
|
|
129
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "agent-settled", ts: 3, seq: 3 }]);
|
|
130
|
+
reporter.onJournalAppended("record", "sa-1", [{ type: "record-created", ts: 4, seq: 1 }]);
|
|
131
|
+
await advance(0);
|
|
132
|
+
expect(channel.calls).toHaveLength(1); // 在途串行化:不并发发帧
|
|
133
|
+
|
|
134
|
+
// 帧 0(run-created)ack 落定 → 缓冲组 wf-1(两事件已合并为一组)先发
|
|
135
|
+
channel.settle(0, JOURNAL_REPORT_ACK);
|
|
136
|
+
await advance(0);
|
|
137
|
+
expect(channel.calls).toHaveLength(2);
|
|
138
|
+
expect(parseFrame(channel.calls[1]).fileKey).toBe("wf-1");
|
|
139
|
+
expect(parseFrame(channel.calls[1]).events).toEqual([
|
|
140
|
+
{ type: "agent-started", ts: 2, seq: 2 },
|
|
141
|
+
{ type: "agent-settled", ts: 3, seq: 3 },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// 组间 FIFO:record 组随后
|
|
145
|
+
channel.settle(1, JOURNAL_REPORT_ACK);
|
|
146
|
+
await advance(0);
|
|
147
|
+
expect(channel.calls).toHaveLength(3);
|
|
148
|
+
expect(parseFrame(channel.calls[2]).fileKey).toBe("sa-1");
|
|
149
|
+
expect(parseFrame(channel.calls[2]).domain).toBe("record");
|
|
150
|
+
channel.settle(2, JOURNAL_REPORT_ACK);
|
|
151
|
+
await advance(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("失败折叠(设计 D5:缓冲即弃不重推,完整性归消费方缺口补读)", () => {
|
|
156
|
+
it("无 ack(超时形态)→ 待推缓冲整体丢弃;时间推进零重推,新事件重新起推", async () => {
|
|
157
|
+
const channel = makeSelectChannel();
|
|
158
|
+
const reporter = createJournalReporter();
|
|
159
|
+
reporter.attachSession(makeCtx(channel));
|
|
160
|
+
|
|
161
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
162
|
+
await advance(0);
|
|
163
|
+
channel.settle(0, undefined); // 首帧失败落定(超时形态)
|
|
164
|
+
await advance(0);
|
|
165
|
+
// 在途期间并入的事件随失败一并丢弃(缓冲即弃)
|
|
166
|
+
expect(channel.calls).toHaveLength(1);
|
|
167
|
+
|
|
168
|
+
await advance(60_000); // 无 timer 自动重试
|
|
169
|
+
expect(channel.calls).toHaveLength(1);
|
|
170
|
+
|
|
171
|
+
// 新的落盘事件重新起推(缓冲已清空,新事件是唯一载荷——不补发丢失事件)
|
|
172
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "agent-started", ts: 2, seq: 2 }]);
|
|
173
|
+
await advance(0);
|
|
174
|
+
expect(channel.calls).toHaveLength(2);
|
|
175
|
+
expect((parseFrame(channel.calls[1]).events as unknown[]).length).toBe(1);
|
|
176
|
+
channel.settleAll(JOURNAL_REPORT_ACK);
|
|
177
|
+
await advance(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("非确认回包(任意字符串)不算送达,同款缓冲即弃", async () => {
|
|
181
|
+
const channel = makeSelectChannel();
|
|
182
|
+
const reporter = createJournalReporter();
|
|
183
|
+
reporter.attachSession(makeCtx(channel));
|
|
184
|
+
reporter.onJournalAppended("record", "sa-1", [{ type: "record-created", ts: 1, seq: 1 }]);
|
|
185
|
+
await advance(0);
|
|
186
|
+
channel.settle(0, '{"ok":1}');
|
|
187
|
+
await advance(0);
|
|
188
|
+
await advance(60_000);
|
|
189
|
+
expect(channel.calls).toHaveLength(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("生命周期与环境门控(③观察者)", () => {
|
|
194
|
+
it("onJournalAppended 同步返回:select 永不落定也不挂调用方(detach 可丢弃在途帧与缓冲)", async () => {
|
|
195
|
+
const channel = makeSelectChannel();
|
|
196
|
+
const reporter = createJournalReporter();
|
|
197
|
+
reporter.attachSession(makeCtx(channel));
|
|
198
|
+
|
|
199
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
200
|
+
await advance(0);
|
|
201
|
+
// 第一帧挂起(模拟 runtime 无响应)——落盘出口调用仍同步返回
|
|
202
|
+
expect(() => reporter.onJournalAppended("run", "wf-1", [{ type: "agent-started", ts: 2, seq: 2 }])).not.toThrow();
|
|
203
|
+
|
|
204
|
+
// session 死后 detach:挂起帧被丢弃、缓冲清空,通道静止
|
|
205
|
+
reporter.detachSession();
|
|
206
|
+
channel.settleAll(undefined);
|
|
207
|
+
await advance(60_000);
|
|
208
|
+
expect(channel.calls).toHaveLength(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("非 rpc 模式 no-op:不设 ctx,落盘事件零帧(裸 pi TUI 下 marker select 会弹真框)", async () => {
|
|
212
|
+
const channel = makeSelectChannel();
|
|
213
|
+
const reporter = createJournalReporter();
|
|
214
|
+
reporter.attachSession(makeNonRpcCtx(channel));
|
|
215
|
+
|
|
216
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
217
|
+
await advance(60_000);
|
|
218
|
+
expect(channel.calls).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("attach 前的落盘事件不进缓冲(无 ctx 直返——factory 阶段落盘点早于 session_start 的窗口)", async () => {
|
|
222
|
+
const channel = makeSelectChannel();
|
|
223
|
+
const reporter = createJournalReporter();
|
|
224
|
+
reporter.onJournalAppended("run", "wf-1", [{ type: "run-created", ts: 1, seq: 1 }]);
|
|
225
|
+
await advance(0);
|
|
226
|
+
reporter.attachSession(makeCtx(channel));
|
|
227
|
+
await advance(0);
|
|
228
|
+
expect(channel.calls).toHaveLength(0); // 丢失面归消费方冷读收敛
|
|
229
|
+
});
|
|
230
|
+
});
|