@zhushanwen/pi-subagent-workflow 9.2.1 → 9.3.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": "9.2.1",
3
+ "version": "9.3.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.17.0",
62
- "@zhushanwen/session-delivery": "1.0.0",
63
- "@zhushanwen/pi-ext-guards": "0.4.2",
64
- "@zhushanwen/pi-extension-logger": "0.6.1",
65
- "@zhushanwen/pi-notify-ledger-host": "0.2.4",
66
- "@zhushanwen/subagent-core": "1.2.1",
67
- "@zhushanwen/pi-subagent-cli": "0.5.0",
68
- "@zhushanwen/zcode-subagent-cli": "0.4.4"
61
+ "@zhushanwen/extension-protocol": "0.18.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.0",
66
+ "@zhushanwen/subagent-core": "1.3.0",
67
+ "@zhushanwen/pi-subagent-cli": "0.6.0",
68
+ "@zhushanwen/zcode-subagent-cli": "0.5.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "@earendil-works/pi-ai": "^0.84.4",
72
- "@earendil-works/pi-coding-agent": "^0.84.4",
73
- "@earendil-works/pi-tui": "^0.84.4",
74
- "typebox": "*",
75
- "@zhushanwen/pi-pending-notifications": "0.8.1",
76
- "@zhushanwen/pi-structured-output": "6.0.0"
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-pending-notifications": "0.8.2",
76
+ "@zhushanwen/pi-structured-output": "6.1.0"
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.8.0"
90
+ "@zhushanwen/subagent-engine-sdk": "0.9.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 调用)→ ack 清失败
7
- // 计数;失败折叠 + 延迟重试;推送在途期间多次迁移合并为单帧且携带最新绝对计数;
6
+ // ②构建者——初始上报(attachSession 触发,无需任何 subagent 调用);失败折叠 +
7
+ // 置脏(无 timer 自动重试,ADR-0122);推送在途期间多次迁移合并为单帧且携带
8
+ // 最新绝对计数;
8
9
  // ③观察者——onInFlightChanged 同步返回(不 await select,不进生命周期链);
9
- // detachSession 停重试,session 死后通道静默。
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({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
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({ retryDelayMs: RETRY_MS });
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({ retryDelayMs: RETRY_MS });
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("失败折叠 + 延迟重试(有界:累计 MAX_REPORT_ATTEMPTS 次放弃,D5 缺席语义②)", () => {
162
- it("select resolve undefined(超时/旧版 runtime)→ 折叠重试;重试帧携带完整快照;ack 后停", async () => {
161
+ describe("失败折叠(ADR-0122:无 timer 自动重试,事件驱动重推)", () => {
162
+ it("select resolve undefined(超时/旧版 runtime)→ 置脏不重试;下次迁移事件重推最新快照", async () => {
163
163
  const channel = makeSelectChannel();
164
- const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS, selectTimeoutMs: SELECT_TIMEOUT_MS });
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(RETRY_MS - 1);
172
- expect(channel.calls).toHaveLength(1); // 未到退避点不重试
173
- await advance(1);
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
- // 重试帧得到 ack → 重试停止;随后迁移照常送达
184
+ // 重推帧得到 ack → 通道恢复;随后迁移照常送达
180
185
  channel.settle(1, INFLIGHT_REPORT_ACK);
181
- await advance(RETRY_MS * 10);
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 通道抛错同样折叠进重试路径", async () => {
196
+ it("select 通道抛错同样折叠置脏,由迁移事件重推", async () => {
192
197
  const channel = makeSelectChannel();
193
- const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
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
- it("非确认回包(旧版 runtime 的任意字符串)不算送达,继续重试", async () => {
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("累计失败达默认上限(3 次)即放弃:不再重试,后续迁移也不再发帧", async () => {
212
+ it("非确认回包(旧版 runtime 的任意字符串)不算送达,置脏等事件重推", async () => {
216
213
  const channel = makeSelectChannel();
217
- // 未注入 maxAttempts:锁默认上限 MAX_REPORT_ATTEMPTS = 3(增强面降级语义)。
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(RETRY_MS * 10);
237
- expect(channel.calls).toHaveLength(3);
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({ retryDelayMs: RETRY_MS });
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(RETRY_MS * 5);
242
+ await advance(60_000);
255
243
  expect(channel.calls).toHaveLength(1);
256
244
  });
257
245
 
258
- it("detachSession 清掉待发重试定时器(session 已死,重试语义随之终结)", async () => {
246
+ it("detachSession 后失败落定不再发帧(ctx 已空,置脏无效果)", async () => {
259
247
  const channel = makeSelectChannel();
260
- const reporter = createInFlightReporter({ retryDelayMs: RETRY_MS });
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(RETRY_MS * 10);
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
+ });
@@ -26,15 +26,14 @@
26
26
  // (session_start 是 pi 启动序列里最早带 ctx 的钩子 = 「session 就绪」,即设计所指
27
27
  // 加载完成时点;不挂任何懒触发(无 subagent 的 session 也必上报)。
28
28
  //
29
- // 失败语义(D5 缺席语义②):select 失败(超时/通道异常/非确认回包)折叠后延迟重试,
30
- // **累计 MAX_REPORT_ATTEMPTS(3)次放弃**。上限刻意取小(增强面降级):GUI live
31
- // 在途镜像短暂滞后可接受,重试 3 次封顶后放弃,不做长尾可达保证——子任务完成通知
32
- // 另有账本必达通道,不依赖此镜像。session_start 首帧早于 runtime adapter attach 的
33
- // 竞态(R2 实证)在 3×2s=6s 窗口内自愈,错过窗口即放弃。送达判据 = runtime resolve
34
- // 的确认回包
35
- // (INFLIGHT_REPORT_ACK)——fire-and-forget 下 resolve(undefined) 与超时不可区分,
36
- // 靠显式 ack 区分「已送达」与「无路由」;放弃后镜像按 absent-report 走 errs 推迟
37
- // (30min 有界),不丢 errs-safe 兜底。
29
+ // 失败语义(ADR-0122 收紧,原 D5 缺席语义②的 timer 重试已删):select 失败
30
+ // (超时/通道异常/非确认回包)→ 首败 warn 显式留痕 + 置脏——**不设 setTimeout
31
+ // 自动重试**,等下一个 onInFlightChanged 事件驱动重推(绝对计数语义下任何一帧
32
+ // 成功即整镜恢复)。session_start 首帧早于 runtime adapter attach 的竞态(R2 实证)
33
+ // 由此由后续 in-flight 变化事件自然补推;无后续事件的 session 镜像按 absent-report
34
+ // 走 errs 推迟(30min 有界),完成通知另有账本必达通道,不依赖此镜像。
35
+ // 送达判据 = runtime resolve 的确认回包(INFLIGHT_REPORT_ACK)——fire-and-forget 下
36
+ // resolve(undefined) 与超时不可区分,靠显式 ack 区分「已送达」与「无路由」。
38
37
 
39
38
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
40
39
  import { SUBAGENT_INFLIGHT_MARKER, callMarkerRpc, isInFlightReportAck } from "@zhushanwen/extension-protocol";
@@ -42,21 +41,10 @@ import { getInFlightSnapshot } from "@zhushanwen/subagent-core";
42
41
  import { getLogger } from "@zhushanwen/pi-extension-logger";
43
42
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
44
43
 
45
- /** select 通道级超时(控制面单请求,秒级校准——超时默认原则规则 19)。取值对齐
46
- * plugin-bridge 启动 sync 的 2s 自愈闸:session_start 首帧可能早于 runtime adapter
47
- * attach(R2 实证),超时折叠后靠有限次重试覆盖 attach 竞态窗口(约 6s,见
48
- * MAX_REPORT_ATTEMPTS);fire-and-forget 帧不留 pending 挂死面。 */
44
+ /** select 通道级超时(控制面单请求,秒级校准——超时默认原则规则 19);fire-and-forget
45
+ * 帧不留 pending 挂死面。失败不重试(ADR-0122),置脏等下一个事件。 */
49
46
  const SELECT_TIMEOUT_MS = 2_000;
50
47
 
51
- /** 失败重试退避(对齐 plugin-bridge SYNC_RETRY_MS 控制面节奏)。 */
52
- const RETRY_DELAY_MS = 2_000;
53
-
54
- /** 累计失败放弃上限(刻意取小——增强面降级):GUI live 在途镜像短暂滞后可接受,
55
- * 3 次封顶(约 6s)后放弃,不做长尾可达保证;完成通知另有账本必达通道,不依赖
56
- * 此镜像。重试窗口同时覆盖 session_start 首帧早于 runtime adapter attach 的竞态
57
- * (R2 实证),并有界防 rpc-but-非-taiji orchestrator 场景的永久空转。 */
58
- const MAX_REPORT_ATTEMPTS = 3;
59
-
60
48
  /** 在途上报器(组合根 index.ts 持有;per-factory 实例,session_start/shutdown 驱动)。 */
61
49
  export interface InFlightReporter {
62
50
  /**
@@ -65,7 +53,7 @@ export interface InFlightReporter {
65
53
  * (ctx.mode !== 'rpc')下为 no-op:无 runtime 拦截方,上报通道不该启动。
66
54
  */
67
55
  attachSession(ctx: ExtensionContext): void;
68
- /** session_shutdown 摘除 ctx 并停止重试(session 已死,上报通道随之终结)。 */
56
+ /** session_shutdown 摘除 ctx(session 已死,上报通道随之终结)。 */
69
57
  detachSession(): void;
70
58
  /** core 出口回调(notifyInFlightChanged 直连):同步返回,内部合并 + void 推送。 */
71
59
  onInFlightChanged(): void;
@@ -74,16 +62,12 @@ export interface InFlightReporter {
74
62
  export interface InFlightReporterOpts {
75
63
  /** 测试注入:select 超时(ms)。缺省 SELECT_TIMEOUT_MS。 */
76
64
  selectTimeoutMs?: number;
77
- /** 测试注入:重试退避(ms)。缺省 RETRY_DELAY_MS。 */
78
- retryDelayMs?: number;
79
- /** 测试注入:累计失败放弃上限。缺省 MAX_REPORT_ATTEMPTS。 */
80
- maxAttempts?: number;
81
65
  }
82
66
 
83
67
  /**
84
- * sessionId 从 ctx 取(plugin-bridge getSessionId 同款防御):pi session 文件延迟写入
85
- * 窗口内取失败不阻断上报——sessionId 缺席时 runtime 按无法归属丢弃整帧(契约
86
- * SubagentInFlightReport.sessionId 可选语义),不视为协议错误。
68
+ * sessionId 从 ctx 取:pi session 文件延迟写入窗口内取失败不阻断上报——sessionId
69
+ * 缺席时 runtime 按无法归属丢弃整帧(契约 SubagentInFlightReport.sessionId 可选语义),
70
+ * 不视为协议错误。
87
71
  */
88
72
  function getSessionId(ctx: ExtensionContext): string | undefined {
89
73
  try {
@@ -95,35 +79,20 @@ function getSessionId(ctx: ExtensionContext): string | undefined {
95
79
 
96
80
  export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFlightReporter {
97
81
  const selectTimeoutMs = opts.selectTimeoutMs ?? SELECT_TIMEOUT_MS;
98
- const retryDelayMs = opts.retryDelayMs ?? RETRY_DELAY_MS;
99
- const maxAttempts = opts.maxAttempts ?? MAX_REPORT_ATTEMPTS;
100
82
  const logger = getLogger("subagents");
101
83
 
102
84
  // 闭包状态(per-factory 实例;禁模块级 let——同进程多 factory 实例会串台)。
103
85
  let ctx: ExtensionContext | null = null;
104
86
  /** 一次推送尝试在途(串行化——绝对计数语义下中间值可安全合并丢弃)。 */
105
87
  let attemptInFlight = false;
106
- /** 有待推帧(onInFlightChanged 在推送在途期间置位,成功后立即补推最新值)。 */
88
+ /** 有待推帧(推送在途或上次失败期间置位,下次 onInFlightChanged 事件驱动重推)。 */
107
89
  let dirty = false;
108
- /** 重试定时器句柄(单飞;成功/dispose 即清)。 */
109
- let retryTimer: ReturnType<typeof setTimeout> | null = null;
110
- /** 累计失败次数(成功清零;达 maxAttempts 放弃——本 session 不再重试)。 */
111
- let failureCount = 0;
112
- /** 已放弃(累计到顶;session 内终态,成功路径永不触及)。 */
113
- let givenUp = false;
114
- /** 首次失败已 warn 留痕(重试循环不刷屏)。 */
90
+ /** 首次失败已 warn 留痕(后续失败不刷屏)。 */
115
91
  let firstFailureLogged = false;
116
92
 
117
- function clearRetryTimer(): void {
118
- if (retryTimer !== null) {
119
- clearTimeout(retryTimer);
120
- retryTimer = null;
121
- }
122
- }
123
-
124
- /** 推送在途、已放弃或无 ctx 时仅置脏;否则发起一次尝试(void,不阻塞调用方)。 */
93
+ /** 推送在途或无 ctx 时仅置脏;否则发起一次尝试(void,不阻塞调用方)。 */
125
94
  function kick(): void {
126
- if (attemptInFlight || givenUp || ctx === null) {
95
+ if (attemptInFlight || ctx === null) {
127
96
  dirty = true;
128
97
  return;
129
98
  }
@@ -148,7 +117,7 @@ export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFligh
148
117
  });
149
118
  // 发送+折叠半边走 protocol 的 callMarkerRpc 原语(D8,fire-and-forget:void 发起
150
119
  // 不变):ok:false 四态(cancelled/timeout/channel-error/non-json)统一折叠进下方
151
- // 延迟重试路径;送达判据 = ack 全等匹配(不是 JSON 消费),留在本侧。原语的失败
120
+ // 失败留痕路径;送达判据 = ack 全等匹配(不是 JSON 消费),留在本侧。原语的失败
152
121
  // 留痕经注入的 log 承载本侧「首败 warn / 后续 debug」防刷屏策略。
153
122
  // guiCtx = ExtensionContext 的 GuiContext 最小子集(ask-user runRpcInteraction 同款
154
123
  // 先例:ui.custom 泛型签名静态不兼容,callMarkerRpc 只读 ui.select)。
@@ -163,37 +132,19 @@ export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFligh
163
132
  });
164
133
  attemptInFlight = false;
165
134
  if (result.ok && isInFlightReportAck(result.value)) {
166
- // 送达确认:清重试与失败计数,补推积压脏帧。
167
- failureCount = 0;
168
- clearRetryTimer();
135
+ // 送达确认:补推积压脏帧。
169
136
  if (dirty && ctx !== null) kick();
170
137
  return;
171
138
  }
172
- // 失败折叠(resolve undefined = 超时/取消/无路由 / 回包非 ack / 通道异常)→ 延迟
173
- // 重试,累计到顶放弃(放弃后镜像按 absent-report 走 errs 推迟,30min 有界,
174
- // errs-safe 兜底不丢)。
139
+ // 失败折叠(resolve undefined = 超时/取消/无路由 / 回包非 ack / 通道异常):
140
+ // 显式留痕 + 置脏(ADR-0122:无 timer 自动重试)——等下一个 onInFlightChanged
141
+ // 事件驱动重推;attach 竞态丢失的首帧由后续事件自然补推,无后续事件的 session
142
+ // 镜像按 absent-report 走 errs 推迟(30min 有界,errs-safe 兜底不丢)。
175
143
  logFailure(
176
144
  result.ok ? "no ack (non-ack response)" : `no ack (${result.reason})`,
177
145
  result.ok ? result.value : undefined,
178
146
  );
179
- failureCount += 1;
180
- if (failureCount >= maxAttempts) {
181
- givenUp = true;
182
- clearRetryTimer();
183
- logger.warn(
184
- `[subagent-inflight] in-flight report gave up after ${failureCount} attempts; ` +
185
- `mirror will treat this session as absent-report (errs-deferred, bounded)`,
186
- );
187
- return;
188
- }
189
- if (ctx !== null && retryTimer === null) {
190
- retryTimer = setTimeout(() => {
191
- retryTimer = null;
192
- kick();
193
- }, retryDelayMs);
194
- // unref:不阻塞进程退出(退出收割由既有 process hook 负责,与 plugin-bridge 同款)。
195
- retryTimer.unref?.();
196
- }
147
+ dirty = true;
197
148
  }
198
149
 
199
150
  /** 原语留痕注入(D8):msg/detail 由 callMarkerRpc 产出;防刷屏策略(首败 warn /
@@ -205,12 +156,12 @@ export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFligh
205
156
  function logFailure(reason: string, detail: unknown): void {
206
157
  if (!firstFailureLogged) {
207
158
  firstFailureLogged = true;
208
- logger.warn(`[subagent-inflight] in-flight report failed (${reason}); retrying every ${retryDelayMs}ms (bounded at ${maxAttempts} attempts)`, {
159
+ logger.warn(`[subagent-inflight] in-flight report failed (${reason}); will retry on next in-flight change (event-driven, no timer)`, {
209
160
  detail: toErrorMessage(detail),
210
161
  });
211
162
  return;
212
163
  }
213
- logger.debug(`[subagent-inflight] in-flight report retry failed (${reason})`);
164
+ logger.debug(`[subagent-inflight] in-flight report failed again (${reason})`);
214
165
  }
215
166
 
216
167
  return {
@@ -219,13 +170,10 @@ export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFligh
219
170
  // select 会弹真框(2026-09-12 裸 TUI 无限闪框事故根因)——不设 ctx,本 session
220
171
  // 全程 no-op。ask-user 的 ctx.mode === "rpc" 二值判定同款先例。
221
172
  if (target.mode !== "rpc") return;
222
- // attach = 新 reporting epoch(新 session / respawn 后重载):放弃态与失败计数
223
- // 随旧 session 终结,重置重试资格(同一 session 内放弃不恢复——absent-report 兜底)。
224
- givenUp = false;
225
- failureCount = 0;
173
+ // attach = 新 reporting epoch(新 session / respawn 后重载):首败留痕标记随旧
174
+ // session 终结重置(新 session 的首败仍 warn 显式上报)。
226
175
  firstFailureLogged = false;
227
176
  ctx = target;
228
- clearRetryTimer();
229
177
  // 初始上报(count=当下绝对计数;session 就绪时点恒为 0——子进程只会在后续
230
178
  // subagent 调用里出现)。fire-and-forget:不阻塞 session_start 装配链。
231
179
  kick();
@@ -234,7 +182,6 @@ export function createInFlightReporter(opts: InFlightReporterOpts = {}): InFligh
234
182
  detachSession(): void {
235
183
  ctx = null;
236
184
  dirty = false;
237
- clearRetryTimer();
238
185
  },
239
186
 
240
187
  onInFlightChanged(): void {
@@ -0,0 +1,204 @@
1
+ // src/host/journal-reporter.ts
2
+ //
3
+ // 壳层 journal 事件推送出口(event-push-channel W-P1,设计权威源
4
+ // .tmp/tech-design/event-push-channel.md §3.1/§3.3/§3.4)。本文件属壳侧(shell),
5
+ // 对 pi SDK(ExtensionContext 的 ctx.ui.select 通道)的消费收敛在 host/ 层——
6
+ // core 闭包红线只约束 core(出口回调由 core 的 journal-notify 注入,见组合根
7
+ // workflow-events.ts 的 setJournalAppendListener 接线)。
8
+ //
9
+ // 链路:core journal 落盘提交点(run 域 terminal-actions / record 域 record-store
10
+ // 族)→ notifyJournalAppended(同步回调)→ 本 reporter(事件按 (domain, fileKey)
11
+ // 分组合并 → select 通道帧,title = SUBAGENT_JOURNAL_MARKER)→ runtime event-adapter
12
+ // marker 路由 → session-records 派生视图(applyJournalReport)。
13
+ //
14
+ // 语义(设计 §3.3):单帧报告覆盖一个文件(fileKey 单数);事件 = 刚落盘的行对象
15
+ // (seq 已分配),不做水位协商——去重与缺口判定归消费方(fold seq 单调守卫构造性
16
+ // 幂等)。串行化合并:一次推送尝试在途时,后续事件并入待推缓冲;尝试成功后缓冲
17
+ // 剩余组继续逐组推送(事件驱动,无 timer)。
18
+ //
19
+ // 失败语义(设计 D5,与 inflight reporter 的 dirty 重推刻意分叉):select 失败
20
+ // (超时/通道异常/非确认回包)→ 首败 warn 留证 + 待推缓冲整体丢弃——**不重试**
21
+ // (本通道消费方有 seq 水位可判缺口,丢失由缺口补读收敛,缓冲重推是重复保险)。
22
+ // 无后续事件的丢失场景由终局条目补读触发兜底(设计 §3.3 终局一致性)。
23
+ // 送达判据 = runtime resolve 的确认回包(JOURNAL_REPORT_ACK)——回执 = 「已应用」
24
+ // (runtime 同步完成 fold 后 resolve)。
25
+ //
26
+ // 环境门控:上报的消费方是 taiji runtime 的 event-adapter marker 路由,runtime
27
+ // spawn pi 恒为 --mode rpc——ctx.mode !== 'rpc' 即无拦截方(裸 pi TUI 下 marker
28
+ // select 会弹真框),此时不启动推送(inflight-reporter 环境门控同款)。
29
+
30
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
31
+ import {
32
+ SUBAGENT_JOURNAL_MARKER,
33
+ callMarkerRpc,
34
+ isJournalReportAck,
35
+ type SubagentJournalDomain,
36
+ type SubagentJournalEvent,
37
+ } from "@zhushanwen/extension-protocol";
38
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
39
+
40
+ /** select 通道级超时(控制面单请求,秒级校准——超时默认原则规则 19;与 inflight
41
+ * reporter 同量级。失败不重试,缺口归消费方补读收敛)。 */
42
+ const SELECT_TIMEOUT_MS = 2_000;
43
+
44
+ /** 单帧报告字节数的观测阈值(设计 W-P4 ④:只观测不拦截——超限 warn 留痕,供
45
+ * worker-log 高频行合并量级评估;不设硬上限,截断会破坏报告内 seq 连续性判定)。 */
46
+ const REPORT_BYTES_WARN_THRESHOLD = 100_000;
47
+
48
+ /** 单文件待推事件组(报告的逐文件分组形态)。 */
49
+ type PendingFileGroup = {
50
+ domain: SubagentJournalDomain;
51
+ fileKey: string;
52
+ events: SubagentJournalEvent[];
53
+ };
54
+
55
+ /** journal 事件推送器(workflow-events 装配点持有;per-factory 实例,session_start/shutdown 驱动)。 */
56
+ export interface JournalReporter { // oe-exempt:20261003:framework:host 层对装配点的 reporter 端口契约(attach/detach/onJournalAppended 消费缝先立)
57
+ /**
58
+ * session_start 注入当前 ctx(非 rpc 模式下为 no-op:无 runtime 拦截方,推送
59
+ * 通道不该启动)。journal 推送是纯事件驱动(落盘提交点触发),无初始帧——
60
+ * 与 inflight 的「加载完成初始上报」刻意不同:报告语义是增量事件,空 session
61
+ * 无事件可报,缺席语义归消费方冷读。
62
+ */
63
+ attachSession(ctx: ExtensionContext): void;
64
+ /** session_shutdown 摘除 ctx 并丢弃待推缓冲(session 已死,推送通道随之终结;
65
+ * 未推事件由消费方冷读/缺口补读收敛)。 */
66
+ detachSession(): void;
67
+ /** core 出口回调(notifyJournalAppended 直连):同步返回,内部分组合并 + void 推送。 */
68
+ onJournalAppended(domain: SubagentJournalDomain, fileKey: string, events: readonly unknown[]): void;
69
+ }
70
+
71
+ export interface JournalReporterOpts { // oe-exempt:20261003:framework:工厂参数契约(测试注入缝,消费方 workflow-events 以不同 opts 实例化)
72
+ /** 测试注入:select 超时(ms)。缺省 SELECT_TIMEOUT_MS。 */
73
+ selectTimeoutMs?: number;
74
+ /** 测试注入:报告字节数观测阈值。缺省 REPORT_BYTES_WARN_THRESHOLD。 */
75
+ reportBytesWarnThreshold?: number;
76
+ }
77
+
78
+ /**
79
+ * sessionId 从 ctx 取:pi session 文件延迟写入窗口内取失败不阻断推送——sessionId
80
+ * 缺席时 runtime 按无法归属丢弃整帧(契约 SubagentJournalReport.sessionId 可选语义),
81
+ * 不视为协议错误。
82
+ */
83
+ function getSessionId(ctx: ExtensionContext): string | undefined {
84
+ try {
85
+ return ctx.sessionManager.getSessionId();
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ export function createJournalReporter(opts: JournalReporterOpts = {}): JournalReporter {
92
+ const selectTimeoutMs = opts.selectTimeoutMs ?? SELECT_TIMEOUT_MS;
93
+ const reportBytesWarnThreshold = opts.reportBytesWarnThreshold ?? REPORT_BYTES_WARN_THRESHOLD;
94
+ const logger = getLogger("subagents");
95
+
96
+ // 闭包状态(per-factory 实例;禁模块级 let——同进程多 factory 实例会串台)。
97
+ let ctx: ExtensionContext | null = null;
98
+ /** 待推缓冲((domain,fileKey) → 事件组;推送在途期间新事件并入,尝试启动时取走)。 */
99
+ const pending = new Map<string, PendingFileGroup>();
100
+ /** 一次推送尝试在途(串行化——组间逐帧发送,避免并发 select 帧交叉)。 */
101
+ let attemptInFlight = false;
102
+ /** 首次失败已 warn 留痕(后续失败不刷屏;随 attach 重置——新 session 的首败仍显式上报)。 */
103
+ let firstFailureLogged = false;
104
+
105
+ function groupKeyOf(domain: SubagentJournalDomain, fileKey: string): string {
106
+ return `${domain}:${fileKey}`;
107
+ }
108
+
109
+ /** core 出口回调直连:事件并入待推缓冲 + 事件驱动 kick(同步返回,不阻塞落盘主链)。 */
110
+ function onJournalAppended(
111
+ domain: SubagentJournalDomain,
112
+ fileKey: string,
113
+ events: readonly unknown[],
114
+ ): void {
115
+ if (ctx === null || events.length === 0) return;
116
+ const key = groupKeyOf(domain, fileKey);
117
+ const group = pending.get(key);
118
+ if (group !== undefined) {
119
+ group.events.push(...(events as SubagentJournalEvent[]));
120
+ } else {
121
+ pending.set(key, { domain, fileKey, events: [...(events as SubagentJournalEvent[])] });
122
+ }
123
+ kick();
124
+ }
125
+
126
+ /** 推送在途时不重入(缓冲已承接);否则逐组发起推送(void,不阻塞调用方)。 */
127
+ function kick(): void {
128
+ if (attemptInFlight || ctx === null) return;
129
+ attemptInFlight = true;
130
+ void attempt();
131
+ }
132
+
133
+ async function attempt(): Promise<void> {
134
+ try {
135
+ let active = ctx;
136
+ while (active !== null && pending.size > 0) {
137
+ // 取首组发送(FIFO——Map 插入序 = 事件到达序);组在发送前移出缓冲:
138
+ // 失败时该组与其余待推组一起丢弃(D5 缓冲即弃),成功时继续下一组。
139
+ const [key, group] = pending.entries().next().value as [string, PendingFileGroup];
140
+ pending.delete(key);
141
+ const payload = JSON.stringify({
142
+ domain: group.domain,
143
+ fileKey: group.fileKey,
144
+ events: group.events,
145
+ sessionId: getSessionId(active),
146
+ emittedAt: Date.now(),
147
+ });
148
+ if (payload.length > reportBytesWarnThreshold) {
149
+ logger.warn(`[subagent-journal] report size exceeds observation threshold (${payload.length} bytes, file=${group.fileKey}, events=${group.events.length}) — observation only, no rejection`);
150
+ }
151
+ const guiCtx = {
152
+ mode: active.mode,
153
+ hasUI: active.hasUI,
154
+ ui: { select: active.ui.select.bind(active.ui) },
155
+ };
156
+ const result = await callMarkerRpc(guiCtx, SUBAGENT_JOURNAL_MARKER, payload, {
157
+ timeout: selectTimeoutMs,
158
+ log: primitiveLog,
159
+ });
160
+ if (!(result.ok && isJournalReportAck(result.value))) {
161
+ logFailure(result.ok ? "no ack (non-ack response)" : `no ack (${result.reason})`);
162
+ pending.clear(); // D5:失败即弃缓冲(不重推)——完整性归消费方 seq 缺口补读
163
+ return;
164
+ }
165
+ active = ctx; // 尝试期间可能 detach/attach——每轮现读
166
+ }
167
+ } finally {
168
+ attemptInFlight = false;
169
+ }
170
+ // 成功路径:detach 后 ctx 为 null(while 退出)——缓冲随 detach 清空,无残留。
171
+ }
172
+
173
+ /** 原语留痕注入:msg/detail 由 callMarkerRpc 产出;防刷屏策略留本侧 logFailure。 */
174
+ function primitiveLog(msg: string, detail?: object): void {
175
+ logFailure(detail === undefined ? msg : `${msg} ${JSON.stringify(detail)}`);
176
+ }
177
+
178
+ function logFailure(reason: string): void {
179
+ if (!firstFailureLogged) {
180
+ firstFailureLogged = true;
181
+ logger.warn(`[subagent-journal] journal report failed (${reason}); buffer dropped, convergence via consumer seq-gap backfill (no retry)`);
182
+ return;
183
+ }
184
+ logger.debug(`[subagent-journal] journal report failed again (${reason})`);
185
+ }
186
+
187
+ return {
188
+ attachSession(target: ExtensionContext): void {
189
+ // 环境门控:非 rpc 模式(裸 pi TUI / json / print)无 runtime 拦截方,marker
190
+ // select 会弹真框——不设 ctx,本 session 全程 no-op(inflight 同款先例)。
191
+ if (target.mode !== "rpc") return;
192
+ // attach = 新 reporting epoch:首败留痕标记随旧 session 终结重置。
193
+ firstFailureLogged = false;
194
+ ctx = target;
195
+ },
196
+
197
+ detachSession(): void {
198
+ ctx = null;
199
+ pending.clear();
200
+ },
201
+
202
+ onJournalAppended,
203
+ };
204
+ }
@@ -36,8 +36,8 @@ export interface SubagentDirectiveDetails {
36
36
  /**
37
37
  * 定向消息留痕:向主 session 落 subagent-directive custom_message entry。
38
38
  *
39
- * 按主 agent streaming 状态分流 sendMessage options。pi 0.84.4 sendCustomMessage
40
- * 实装(agent-session.js):isStreaming 且无 deliverAs 时默认 agent.steer()——会把
39
+ * 按主 agent streaming 状态分流 sendMessage options。pi sendCustomMessage
40
+ * 实装(agent-session.js,1.0.0 复核同构):isStreaming 且无 deliverAs 时默认 agent.steer()——会把
41
41
  * 定向消息注入正在运行的主 agent LLM turn,违反「不经主 agent LLM 直达 subagent」。
42
42
  * 故按调用时刻的权威 streaming 状态(ctx.isIdle(),与 sendCustomMessage 内部
43
43
  * isStreaming 判据精确互补,含 agent_end 后 retry/continuation 窗口)分流:
@@ -92,11 +92,12 @@ function isModelOverrideObj(a: unknown): a is { model?: unknown; thinkingLevel?:
92
92
  /**
93
93
  * start 路径类参数(skillPath / cwd)运行时守卫:绝对路径 + 禁 `..` 穿越。
94
94
  *
95
- * 校验链事实(pi 0.84.4 实装,登记 PS-20):pi agent-loop 对注册 typebox schema
96
- * 有运行时强校验——agent-loop.js:403-404 在 beforeToolCall / execute 之前调
97
- * validateToolArguments(pi-ai validation.js:247:Value.Convert :249 + Compile :210
98
- * + Check :265,失败 throw `Validation failed for tool` :272-273)→ catch 走
99
- * immediate error(agent-loop.js:445-451),execute 不被调用。schema 的
95
+ * 校验链事实(语义登记 PS-20,verifiedWith 以 pi-semantics.json 为准,逐 claim 行号见该条目
96
+ * piAnchor):pi agent-loop 对注册 typebox schema
97
+ * 有运行时强校验——在 beforeToolCall / execute 之前调
98
+ * validateToolArguments(pi-ai validation.js:Value.Convert + Compile
99
+ * + Check,失败 throw `Validation failed for tool`)→ catch 走
100
+ * immediate error,execute 不被调用。schema 的
100
101
  * pattern(skillPath/cwd `^/`)/ maxLength(slug 35)是运行时强制而非仅模型可见
101
102
  * 契约;tool-definition-wrapper.js:11 只原样透传 params,校验发生在上游 agent-loop 层。
102
103
  *
@@ -13,8 +13,9 @@
13
13
  * C5②(convergence D-6):generate 五道闸校验管线 + tmp 写盘下沉 core
14
14
  * generateWorkflowScript(报错文案逐字平移,CA2 前提);save/delete 改调 core
15
15
  * barrel(第三可选目录参数缺省即 pi 布局 .pi/workflows——pi 现两参调用形态零变化)。
16
- * 结构化 {ok}|{error} → pi 的 execute-throw 契约转换在本层(pi 只对 execute throw
17
- * 置 isError:true);AbortSignal 的 aborted 检查在 execute 入口统一拦截(与
16
+ * 结构化 {ok}|{error} → pi 的 execute-throw 契约转换在本层(pi 1.0.0:execute 抛出经
17
+ * pi-agent-core agent-loop.js executePreparedToolCall 外层 catch 统一转 isError:true);
18
+ * AbortSignal 的 aborted 检查在 execute 入口统一拦截(与
18
19
  * workflow / subagents 两 tool 同源,tool-shared assertNotAborted;C4 偏差 #4)。
19
20
  *
20
21
  * 层归属:Interface。依赖 Pi SDK + engine script-lint + infra workflow-files。
@@ -183,7 +184,8 @@ export function registerWorkflowScriptTool(
183
184
  ): Promise<WorkflowScriptExecuteResult> {
184
185
  // P1-2 abort 前置(三 tool 对称):5 个 action 统一在 execute 入口拦截——
185
186
  // 原先只在 generate 内检查,lint/save/delete/list 四路径漏拦。throw(W4b):
186
- // pi 只对 execute throw 置 isError:true,返回值里的 isError 被 agent-loop 丢弃。
187
+ // 错误路径统一 throw(单一错误路径纪律;pi 1.0.0 agent-loop 对抛出统一转
188
+ // isError:true error result)。
187
189
  assertNotAborted(signal);
188
190
  let result: WorkflowScriptExecuteResult;
189
191
  switch (params.action) {
@@ -203,8 +205,9 @@ export function registerWorkflowScriptTool(
203
205
  result = await actionList(registry);
204
206
  break;
205
207
  default:
206
- // 防御性(schema StringEnum 先拦):throw(W4b)——pi 只对 execute throw 置
207
- // isError:true,返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483)。
208
+ // 防御性(schema StringEnum 先拦):throw(W4b)——错误路径统一 throw
209
+ // (pi 1.0.0 pi-agent-core agent-loop.js executePreparedToolCall :579-588:
210
+ // 抛出经外层 catch 转 isError:true error result)。
208
211
  throw new Error(`Unknown action: ${String(params.action)}`);
209
212
  }
210
213
  // GUI 协议:RPC 模式下附加 __gui__ 到 details(attach 单点在 tool-shared)
@@ -229,7 +232,8 @@ export function registerWorkflowScriptTool(
229
232
  // ── generate action ──────────────────────────────────────────
230
233
 
231
234
  export function actionGenerate(params: ScriptParams): WorkflowScriptExecuteResult {
232
- // throw(W4b):pi 只对 execute throw 置 isError:true(返回值 isError 被丢弃)。
235
+ // throw(W4b):错误路径统一 throw(单一错误路径纪律;pi 1.0.0 agent-loop 对抛出
236
+ // 统一转 isError:true error result)。
233
237
  // abort 前置在 execute 入口统一拦截(三 tool 同源,tool-shared assertNotAborted),
234
238
  // 本函数不再自带 signal 检查。
235
239
  const name = params.name ?? "";
@@ -386,9 +390,9 @@ async function actionList(registry: WorkflowScriptRegistry): Promise<WorkflowScr
386
390
  // ── helper ───────────────────────────────────────────────────
387
391
 
388
392
  /**
389
- * 构造纯文本非错误结果(W4b:isError 参数已删除——pi 只对 execute throw 置
390
- * isError:true,返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483),
391
- * 错误一律 throw,编译器兜底防回潮)。
393
+ * 构造纯文本非错误结果(W4b:isError 参数已删除——异常错误路径一律 throw(单一
394
+ * 错误路径纪律;pi 1.0.0 pi-agent-core agent-loop.js :579-588 对抛出统一转
395
+ * isError:true error result),编译器兜底防回潮)。
392
396
  */
393
397
  function textResult(text: string): WorkflowScriptExecuteResult {
394
398
  return {
@@ -523,19 +523,17 @@ function appendSubagentIdentityEntry(pi: ExtensionAPI): void {
523
523
  * 返回 undefined。
524
524
  */
525
525
  export function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): NotifyLedgerHost | undefined {
526
- // host 五端口接线 + 送达 stale 防御(D5 单通道 {triggerTurn:true} / stale 静默降级 /
527
- // abandon 补显形 T4③)收敛在 @zhushanwen/pi-notify-ledger-host 工厂,权威注释随迁
528
- // 工厂内;component/logger 参数保持本包 label 前缀与 warn 通道归因不变,
529
- // sendDisplayMessage = 生产 bind 恒实现(NotifyLedgerHost 可选端口的本侧既有语义)。
526
+ // host 五端口接线 + 送达 stale 防御(D5 单通道 {triggerTurn:true} / stale 静默降级)
527
+ // 收敛在 @zhushanwen/pi-notify-ledger-host 工厂,权威注释随迁工厂内;
528
+ // component/logger 参数保持本包 label 前缀与 warn 通道归因不变。
530
529
  const ledgerHost = createPiNotifyLedgerHost(pi, ctx, {
531
530
  component: "subagent-workflow",
532
531
  logger,
533
- sendDisplayMessage: true,
534
532
  });
535
533
  // bind 与 recover 拆独立 try(失败归因不同):
536
534
  // - bind 失败:槽上无 ledger,消费方(getBoundNotifyLedger)退回内核直发路径;
537
535
  // - recover 失败:bind 已成功、槽上 ledger 仍在,消费方照常走账本路径(重启重放
538
- // 缺席,边沿/看门狗仍投新通知)——不得共用 "bind failed" 文案误报。
536
+ // 缺席,settled 边沿仍投新通知)——不得共用 "bind failed" 文案误报。
539
537
  let ledger: ReturnType<typeof bindNotifyLedgerHost>;
540
538
  try {
541
539
  ledger = bindNotifyLedgerHost(ledgerHost);
@@ -52,6 +52,9 @@ import { getSubagentService } from "@zhushanwen/subagent-core";
52
52
  import type { LauncherDeps } from "@zhushanwen/subagent-core";
53
53
  import { terminateRunningRuns } from "@zhushanwen/subagent-core";
54
54
  import { setInFlightListener } from "@zhushanwen/subagent-core";
55
+ // [event-push-channel W-P1] journal 落盘事件出口接线:core 两域落盘提交点 → 出口
56
+ // 回调 → 壳层 journal reporter(select marker 通道带回执推送 runtime 派生视图)。
57
+ import { setJournalAppendListener } from "@zhushanwen/subagent-core";
55
58
  import {
56
59
  evictDoneRunsBeyondCap,
57
60
  MAX_RETAINED_DONE_RUNS,
@@ -65,6 +68,10 @@ import { WorkflowScriptRegistryImpl } from "@zhushanwen/subagent-core";
65
68
  // [u7a D5] 在途上报出口:实例由本 seam 创建并接线 setInFlightListener(组合根零
66
69
  // 管道),session_start / session_shutdown 驱动 attach/detach;测试可注入 fake。
67
70
  import { createInFlightReporter, type InFlightReporter } from "./host/inflight-reporter.ts";
71
+ // [event-push-channel W-P1] journal 推送出口:实例由本 seam 创建并接线
72
+ // setJournalAppendListener(与 inflight reporter 同 seam 同款),session_start /
73
+ // session_shutdown 驱动 attach/detach;测试可注入 fake。
74
+ import { createJournalReporter, type JournalReporter } from "./host/journal-reporter.ts";
68
75
  // [W2/V1 D1 第 8 行] 判活类消费面(isScriptRunning)经 core 投影单点
69
76
  //(runSummary.status 三态投影——终局判定源 = 终局记录注册表)。
70
77
  import { type RunSettlementRecord } from "./jsonl-run-store.ts";
@@ -274,7 +281,7 @@ export interface WorkflowDomainHandle {
274
281
  */
275
282
  export function setupWorkflowDomain(
276
283
  pi: ExtensionAPI,
277
- wiring: { inflightReporter?: InFlightReporter } = {},
284
+ wiring: { inflightReporter?: InFlightReporter; journalReporter?: JournalReporter } = {},
278
285
  ): WorkflowDomainHandle {
279
286
  // [u7a D5] 在途聚合上报接线(自组合根 index.ts 收编):core 状态迁移
280
287
  // (spawn/close/arm/disarm)→ 出口回调 → reporter 经 select 通道推绝对计数帧。
@@ -285,6 +292,14 @@ export function setupWorkflowDomain(
285
292
  // 组合根对实例零知识(纯管道参数已删)。
286
293
  const inflightReporter = wiring.inflightReporter ?? createInFlightReporter();
287
294
  setInFlightListener(inflightReporter.onInFlightChanged);
295
+ // [event-push-channel W-P1] journal 推送出口接线(与 inflight reporter 同 seam 同款):
296
+ // core 两域落盘提交点 → 出口回调 → reporter 按 (domain,fileKey) 分组合并 → select
297
+ // marker 通道带回执推送 runtime。出口为进程级单监听,后注册覆盖先注册(jiti 重载
298
+ // 幂等);回调同步 void,不进任何落盘 await 链。ctx 由 session_start 注入
299
+ // (factory 阶段无 ui)。wiring.journalReporter 是测试注入面(fake reporter 观察
300
+ // attach/detach 驱动时点);生产路径缺省真实创建——组合根对实例零知识。
301
+ const journalReporter = wiring.journalReporter ?? createJournalReporter();
302
+ setJournalAppendListener(journalReporter.onJournalAppended);
288
303
  // [skill-reload D2] handle.state 即槽对象本身(不做解构重包装——否则容器每次
289
304
  // factory 重跑新建,调用方拿不到「同一 domain state 引用」的接管前提)。
290
305
  const domainState = getOrCreateWorkflowDomainState();
@@ -436,16 +451,20 @@ export function setupWorkflowDomain(
436
451
  // 开头一致)+ 装配结果写入 per-session sessionState。
437
452
  // ════════════════════════════════════════════════════════════
438
453
  pi.on("session_start", async (event: SessionStartEvent, ctx: ExtensionContext) => {
439
- // 装配链异常不向 pi 事件分发逃逸(pi 0.84.4 extension handler 未捕获的 rejection
440
- // 直接炸进程——E1 同机制):围栏 error 留痕(含 sessionId)后保持 handler 不抛。
454
+ // 装配链异常不向 pi 事件分发逃逸(0.84.4 的 extension handler 未捕获 rejection
455
+ // 直接炸进程——E1 同机制;1.0.0 起 runner.emit 以 try/catch 吸收转 emitError 上报,
456
+ // 围栏仍保留——handler 不依赖 pi 侧异常处理语义):error 留痕(含 sessionId)后保持 handler 不抛。
441
457
  // lsRef 在 try 内先行赋值:getSessionId 自身抛错时 catch 里拿到的是上一 session
442
458
  // 的 id(留痕仍可检索)。
443
459
  try {
444
460
  lsRef.lastSessionId = ctx.sessionManager.getSessionId();
445
461
  // [u7a D5] 初始上报(count=当下绝对计数):触发时点 = extension 加载完成 / session
446
- // 就绪(factory 无 ctx/ui,session_start 是最早带 ctx 的钩子——plugin-bridge 同款
447
- // 事实)。fire-and-forget 在 await 装配链之前发起,不阻塞也不被阻塞。
462
+ // 就绪(factory 无 ctx/ui,session_start 是最早带 ctx 的钩子)。fire-and-forget
463
+ // 在 await 装配链之前发起,不阻塞也不被阻塞。
448
464
  inflightReporter.attachSession(ctx);
465
+ // [event-push-channel W-P1] journal 推送出口 attach(事件驱动,无初始帧——
466
+ // 报告语义是增量事件,缺席语义归消费方冷读)。
467
+ journalReporter.attachSession(ctx);
449
468
  // [skill-reload D4] adoption 入参接线:reason 是 handler 独占信息(event 参数),
450
469
  // existing 是 sessionState(domainState 闭包)里的既有条目——两者都是
451
470
  // session-lifecycle seam 的 adoption 分流判据,经 SessionStartOptions 传入。
@@ -546,6 +565,9 @@ export function setupWorkflowDomain(
546
565
  getSubagentService()?.invalidatePiBinding("session replacement (reload)");
547
566
  // b 动作保留(理由见上方 D1 分支说明)。
548
567
  inflightReporter.detachSession();
568
+ // [event-push-channel W-P1] journal 推送通道随 reload 同步摘 ctx(旧 ctx 的
569
+ // session 已被 pi 替换,推送帧无法归属;新 session_start 重新 attach)。
570
+ journalReporter.detachSession();
549
571
 
550
572
  // [skill-reload D8] reload 分支归因日志:preserved 计数取自跳过清理时的存活
551
573
  // 对象真实统计(runs = 各 session 内存 run 聚合根数;records = run.state.calls
@@ -582,6 +604,9 @@ export function setupWorkflowDomain(
582
604
  // [u7a D5] 在途上报通道随 session 终结:摘 ctx + 停重试(session 已死,重试直至
583
605
  // 成功的语义只对活 session 成立;进程级出口监听保留——后续 /new 重新 attach)。
584
606
  inflightReporter.detachSession();
607
+ // [event-push-channel W-P1] journal 推送通道随 session 终结:摘 ctx + 丢弃待推
608
+ // 缓冲(未推事件由消费方冷读/缺口补读收敛,不重推——设计 D5)。
609
+ journalReporter.detachSession();
585
610
 
586
611
  // ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
587
612
  // H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——