@zhushanwen/pi-subagent-workflow 8.1.0 → 8.2.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 +12 -8
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +1 -1
- package/src/execution/__tests__/dialog-queue.test.ts +1 -1
- package/src/execution/__tests__/host-mode.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +193 -7
- package/src/execution/__tests__/notifier-golden-snapshot.test.ts +261 -0
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +1 -1
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-channels.test.ts +1 -1
- package/src/execution/__tests__/ui-interaction-model.test.ts +1 -1
- package/src/execution/__tests__/ui-request-observability.test.ts +1 -1
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-runner.ts +1 -1
- package/src/execution/subagent-service.ts +11 -4
- package/src/index.ts +3 -0
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +104 -0
- package/src/injectors/__tests__/model-list-injector.test.ts +139 -0
- package/src/injectors/__tests__/subagent-list-injector.test.ts +59 -69
- package/src/injectors/__tests__/workflow-list-injector.test.ts +57 -67
- package/src/injectors/model-list-injector.ts +138 -0
- package/src/injectors/subagent-list-injector.ts +33 -24
- package/src/injectors/workflow-list-injector.ts +16 -25
- package/src/orchestration/__tests__/workflows-e2e.test.ts +2 -2
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/resource-discovery.ts +38 -0
- package/src/shared/xml-injection.ts +35 -0
- package/workflows/review-fix-loop-utils.cjs +1 -1
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { describe, expect, it } from "vitest";
|
|
16
16
|
|
|
17
17
|
import { completeRecord, createRecord, tryTransition } from "../execution-record.ts";
|
|
18
|
-
import {
|
|
19
|
-
import type { BgNotifyRecord, NotifierHost } from "../notifier.ts";
|
|
18
|
+
import { createNotifier } from "../notifier.ts";
|
|
19
|
+
import type { BgNotifyRecord, BgNotifier, NotifierHost } from "../notifier.ts";
|
|
20
20
|
import { mapExternalState } from "../../interface/subagent-actions.ts";
|
|
21
21
|
import { statusGlyph } from "../../interface/format.ts";
|
|
22
22
|
import type { ClosedReason, ExecutionRecord, ExecutionStatus } from "../types.ts";
|
|
@@ -205,7 +205,7 @@ describe("BgNotifier dedupKey", () => {
|
|
|
205
205
|
|
|
206
206
|
it("同 id 不同 round 不被去重(round 参与 dedup key)", () => {
|
|
207
207
|
const host = createMockHost(false);
|
|
208
|
-
const notifier =
|
|
208
|
+
const notifier = createNotifier(host);
|
|
209
209
|
const sent: unknown[] = [];
|
|
210
210
|
// 重写 sendMessage 捕获
|
|
211
211
|
(host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
|
|
@@ -238,7 +238,7 @@ describe("BgNotifier dedupKey", () => {
|
|
|
238
238
|
|
|
239
239
|
it("不同 id 不被去重", () => {
|
|
240
240
|
const host = createMockHost(false);
|
|
241
|
-
const notifier =
|
|
241
|
+
const notifier = createNotifier(host);
|
|
242
242
|
const sent: unknown[] = [];
|
|
243
243
|
(host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
|
|
244
244
|
|
|
@@ -267,7 +267,7 @@ describe("BgNotifier dedupKey", () => {
|
|
|
267
267
|
|
|
268
268
|
it("closed status 被正确入队", () => {
|
|
269
269
|
const host = createMockHost(false);
|
|
270
|
-
const notifier =
|
|
270
|
+
const notifier = createNotifier(host);
|
|
271
271
|
const sent: unknown[] = [];
|
|
272
272
|
(host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
|
|
273
273
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// W1 红灯测试:ui-channels.ts — channel 提取(marker 解析)+ channel 注册表。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/ui-channels.ts(新建)
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/ui-channels.ts(新建)
|
|
6
6
|
// 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 2:channel 注册表」
|
|
7
7
|
//
|
|
8
8
|
// parseChannel(req): ExtensionUiRequest → { channel?, channelPayload? }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// W1 红灯测试:ui-interaction-model.ts — method 交互模型分类。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
|
|
6
6
|
// 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 1:透传判定规则」
|
|
7
7
|
//
|
|
8
8
|
// isDialogMethod(method):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// M4 测试:ui-request-observability.ts — UiRequestObservability 纯逻辑类。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/ui-request-observability.ts
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/ui-request-observability.ts
|
|
6
6
|
// 契约来源:类注释(per-session 去重 + resetMissingHandlerWarnings 清洗 + setMode/getMode 往返)
|
|
7
7
|
//
|
|
8
8
|
// UiRequestObservability 职责:
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Background 完成回注主对话。sync 不用(调用方还在 await,结果直接返回)。
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// -
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
//
|
|
5
|
+
// 职责(迁移后,U3):
|
|
6
|
+
// - buildLlmContent:格式化通知文案(本文件唯一逻辑职责)
|
|
7
|
+
// - createNotifier:薄工厂,装配 @xyz-agent/session-delivery 内核
|
|
8
|
+
// - 合并窗口 / 去重 / 退避 / flush 全部委托内核
|
|
9
|
+
//
|
|
10
|
+
// 投递通道:内核 → pi.sendMessage({ deliverAs:"steer", triggerTurn:true }) 注入——
|
|
11
|
+
// 当前 turn 结束后唤醒父 agent 处理结果(steer 不打断 streaming、不锁滚动)
|
|
12
|
+
|
|
13
|
+
import { createDelivery, type DeliveryHandle, type DeliveryPort } from "@xyz-agent/session-delivery";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* 一条待发送的完成通知记录。
|
|
@@ -50,251 +54,207 @@ export interface BgNotifyRecord {
|
|
|
50
54
|
totalRounds?: number;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
/** notifier
|
|
57
|
+
/** notifier 依赖的宿主最小接口(解耦,便于测试)。
|
|
58
|
+
* 迁移后:仅用于构造 DeliveryPort 的底层依赖。 */
|
|
54
59
|
export interface NotifierHost {
|
|
55
|
-
/** 注入消息到主对话。
|
|
56
|
-
* triggerTurn:true + deliverAs:"steer" → 空闲时立即 prompt 新 turn;streaming 时
|
|
57
|
-
* 进 steer 队列等下个 turn 边界 drain。
|
|
58
|
-
*
|
|
59
|
-
* ⚠️ 竞态注意:triggerTurn 分支只在主 agent isStreaming===false 时生效。若调用时
|
|
60
|
-
* 主 agent 处于 agent_end → finishRun 的窄窗口(isStreaming 仍 true),消息会被
|
|
61
|
-
* 错误走 steer 分支入队,而 runLoop 已结束无人 drain → 通知静默丢失。flushPendingNotifications
|
|
62
|
-
* 通过 isIdle() 退避保证在 idle 后同步送达,规避此窗口。 */
|
|
60
|
+
/** 注入消息到主对话。 */
|
|
63
61
|
sendMessage(
|
|
64
62
|
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
65
63
|
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
66
64
|
): void;
|
|
67
65
|
/** 是否还有 running 的 background 任务(用于滑动窗口立即 flush 判断)。 */
|
|
68
66
|
hasRunningBackground(): boolean;
|
|
69
|
-
/** 主 agent 是否空闲(非 streaming
|
|
70
|
-
* 竞态窗口里 sendMessage 走错分支(steer 入队无人 drain)。
|
|
71
|
-
* 可选:未注入(旧测试 host)时 flush 不 gate,保持原行为。 */
|
|
67
|
+
/** 主 agent 是否空闲(非 streaming)。可选:未注入时 flush 不 gate。 */
|
|
72
68
|
isIdle?: () => boolean;
|
|
69
|
+
/**
|
|
70
|
+
* 原生 settled 事件订阅能力(D8):注册 handler 监听主 agent settled 边沿。
|
|
71
|
+
* 无退订语义(pi 0.84.1 的 `pi.on(...)` 全部重载返回 void、无 off——实装锚点:
|
|
72
|
+
* node_modules `@earendil-works/pi-coding-agent` dist/core/extensions/types.d.ts
|
|
73
|
+
* `on()` 系列重载,0.84.1 实测)——退订由本模块的 disposed
|
|
74
|
+
* 标志包装兑现(见下方 port 装配)。可选:未注入时内核退化退避轮询(busy 消息
|
|
75
|
+
* 靠退避达上限强发,不走 settled 边沿驱动)。
|
|
76
|
+
*/
|
|
77
|
+
onAgentSettled?(handler: () => void): void;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
/** 合并窗口(ms)。窗口内多个完成合并为一条消息。 */
|
|
76
|
-
const MERGE_WINDOW_MS = 60_000;
|
|
77
|
-
/** 去重 TTL(ms)。同 id 在此窗口内不重复通知。 */
|
|
78
|
-
const DEDUP_TTL_MS = 60_000;
|
|
79
|
-
/** [竞态修复] flush 时若主 agent 仍 streaming,短退避重试间隔(ms)。
|
|
80
|
-
* 场景:subagent 完成的 detached microtask 与主 agent agent_end→finishRun 竞态,
|
|
81
|
-
* isIdle()=false 时退避,等 idle 后再 sendMessage(triggerTurn),避免走 steer 分支丢失。 */
|
|
82
|
-
const FLUSH_BACKOFF_MS = 100;
|
|
83
|
-
/** [竞态修复] flush 退避上限次数。防止主 agent 永久 busy 时无限重试——
|
|
84
|
-
* 达上限后强制发送(fallthrough 到 pi 的 steer/triggerTurn 分支,至少不丢消息)。 */
|
|
85
|
-
const FLUSH_BACKOFF_MAX = 50; // 50 × 100ms = 5s
|
|
86
|
-
|
|
87
80
|
/** 发送给主对话的 customType(bg-notify-render 消费)。 */
|
|
88
81
|
const NOTIFY_CUSTOM_TYPE = "subagent-bg-notify";
|
|
89
82
|
|
|
90
83
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* notify(record):
|
|
94
|
-
* 1. dedup TTL 检查:同 id 在 TTL 内 → 跳过
|
|
95
|
-
* 2. 入 pending 队列
|
|
96
|
-
* 3. 清除旧 timer(滑动窗口重置)
|
|
97
|
-
* 4. 已无 running background → 立即 flush(最后一批)
|
|
98
|
-
* 5. 否则重启 MERGE_WINDOW_MS timer(等后续完成合并)
|
|
84
|
+
* 将 BgNotifyRecord 格式化为 LLM 可读的 notification content。
|
|
99
85
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* 2. doSend:取出 pending 全部 record
|
|
103
|
-
* 3. 合并为一条消息(多条时列 bullet list)
|
|
104
|
-
* 4. sendMessage({ customType:"subagent-bg-notify",
|
|
105
|
-
* content, display:true,
|
|
106
|
-
* triggerTurn:true, deliverAs:"steer" })
|
|
107
|
-
* 5. 清空 pending + timer
|
|
108
|
-
*
|
|
109
|
-
* 滑动窗口:每次有新完成都重置 60s 计时器,密集完成的任务尽量合并到一条通知。
|
|
110
|
-
* 无 running 时立即 flush——避免最后一条等满窗口。
|
|
86
|
+
* 模块内唯一消费方是下方 createNotifier 的 notify()(预格式化后传 delivery.send,
|
|
87
|
+
* 内核只做拼接——多条以 "\n\n---\n\n" join)。
|
|
111
88
|
*/
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const now = Date.now();
|
|
130
|
-
// sweep 过期 dedup 条目(防 Map 无限增长)
|
|
131
|
-
if (this.dedup.size > 0) {
|
|
132
|
-
for (const [id, ts] of this.dedup) {
|
|
133
|
-
if (now - ts >= DEDUP_TTL_MS) this.dedup.delete(id);
|
|
89
|
+
function buildLlmContent(record: BgNotifyRecord): string {
|
|
90
|
+
const agent = record.agent;
|
|
91
|
+
const id = record.id;
|
|
92
|
+
// [wave2 指针行] 增量语义下轮次通知只携带本轮增量,异步 flush 窗口丢失时不可重发
|
|
93
|
+
//(见 subagent-service.ts onRoundSettled 注释),恢复通道是 session 文件全文。
|
|
94
|
+
// 仅 chatMode 透传 sessionFile,one-shot 通知不含该行;cancelled/gc-failed 不追加
|
|
95
|
+
//(无成功结果可读,指针无意义);缺失时省略整行(追加空串 = 输出逐字节不变)。
|
|
96
|
+
const transcriptPointer = record.sessionFile
|
|
97
|
+
? `\n\nFull transcript: ${record.sessionFile}`
|
|
98
|
+
: "";
|
|
99
|
+
switch (record.status) {
|
|
100
|
+
case "closed": {
|
|
101
|
+
// v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生通知文案。
|
|
102
|
+
const reason = record.closedReason ?? "gc";
|
|
103
|
+
if (reason === "cancelled") {
|
|
104
|
+
return `Subagent "${agent}" (${id}) cancelled.`;
|
|
134
105
|
}
|
|
106
|
+
// 失败场景(gc + 有 error):展示错误。判定必须先于 patchFile——失败轮也会写
|
|
107
|
+
// patchFile(doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch),若
|
|
108
|
+
// patch 分支在前,gc 失败 + worktree 并存时 LLM 被告知 completed。顺序与三处
|
|
109
|
+
// 同构契约的另外两处一致(shared/subagent.ts deriveClosedDisplay、
|
|
110
|
+
// bg-notify-render.ts renderRecordLines:cancelled → gc+error → patch/result)。
|
|
111
|
+
if (record.error && reason === "gc") {
|
|
112
|
+
return `Subagent "${agent}" (${id}) failed: ${record.error}`;
|
|
113
|
+
}
|
|
114
|
+
// 成功完成(user-close)或通用结束(gc/parent-shutdown 等):展示结果。
|
|
115
|
+
// [C-2] chatMode close 终态通知附轮次统计(设计 D2 路径①"completed after N rounds")。
|
|
116
|
+
// totalRounds 仅 close 语义携带(notifyClosed);one-shot 完成通知不设置(round 无轮次
|
|
117
|
+
// 语义),文案保持 "completed. Result:" 逐字节(G4 硬约束,one-shot 字节锁测试锚定)。
|
|
118
|
+
const roundsSuffix =
|
|
119
|
+
record.totalRounds != null && record.totalRounds > 0
|
|
120
|
+
? ` after ${record.totalRounds} round${record.totalRounds === 1 ? "" : "s"}`
|
|
121
|
+
: "";
|
|
122
|
+
const base = `Subagent "${agent}" (${id}) completed${roundsSuffix}. Result:\n${record.result ?? "(empty)"}`;
|
|
123
|
+
if (record.patchFile) {
|
|
124
|
+
// [wave2 review] 长 return 拆行:模板串内不可直接换行(会改变输出内容),提取 patchHint 中转变量
|
|
125
|
+
const patchHint = `\n\nThis subagent ran in an isolated worktree; its file changes were captured as a patch:\n ${record.patchFile}\nTo bring these changes into the current repo, run: \`git apply ${record.patchFile}\``;
|
|
126
|
+
return `${base}${patchHint}${transcriptPointer}`;
|
|
127
|
+
}
|
|
128
|
+
return `${base}${transcriptPointer}`;
|
|
135
129
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const lastSeen = this.dedup.get(dedupKey);
|
|
140
|
-
if (lastSeen !== undefined && now - lastSeen < DEDUP_TTL_MS) return;
|
|
141
|
-
this.dedup.set(dedupKey, now);
|
|
142
|
-
|
|
143
|
-
this.pending.push(record);
|
|
144
|
-
|
|
145
|
-
if (this.timer !== undefined) {
|
|
146
|
-
clearTimeout(this.timer);
|
|
147
|
-
this.timer = undefined;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (!this.host.hasRunningBackground()) {
|
|
151
|
-
this.flushPendingNotifications();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
this.timer = setTimeout(() => this.flushPendingNotifications(), MERGE_WINDOW_MS);
|
|
130
|
+
case "running":
|
|
131
|
+
// v4 B-1: 对话模式轮次完成(旧 idle 折入 running):携带本轮结果送回主 agent,等待下一轮 message。
|
|
132
|
+
return `Subagent "${agent}" (${id}) finished a round. Reply:\n${record.result ?? "(empty)"}${transcriptPointer}`;
|
|
156
133
|
}
|
|
134
|
+
}
|
|
157
135
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Background 完成通知器接口(迁移后由内核实现)。
|
|
138
|
+
*
|
|
139
|
+
* 保持与旧 BgNotifier 类相同的公共 API,避免消费方(subagent-service)改动面过大。
|
|
140
|
+
*/
|
|
141
|
+
export interface BgNotifier {
|
|
142
|
+
/** 入队一条完成通知(去重 + 合批窗口合并)。dispose 后短路。 */
|
|
143
|
+
notify(record: BgNotifyRecord): void;
|
|
144
|
+
/** 立即 flush(session_shutdown 调用,防丢失)。 */
|
|
145
|
+
flushPendingNotifications(): void;
|
|
146
|
+
/** session 结束:清队列,dispose 内核 handle。 */
|
|
147
|
+
dispose(): void;
|
|
148
|
+
/** /resume /fork /new 后复活。 */
|
|
149
|
+
revive(): void;
|
|
150
|
+
}
|
|
164
151
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
* 退避上限:FLUSH_BACKOFF_MAX 次后强制发送——防主 agent 永久 busy(长 turn)时通知饿死,
|
|
177
|
-
* fallthrough 到 pi 的 steer/triggerTurn 分支,至少不丢消息(busy 时走 steer 会在该
|
|
178
|
-
* turn 结束后由 _handlePostAgentRun drain)。
|
|
179
|
-
*/
|
|
180
|
-
private scheduleFlush(attempt: number): void {
|
|
181
|
-
if (this._disposed) return;
|
|
182
|
-
if (this.pending.length === 0) return;
|
|
152
|
+
/**
|
|
153
|
+
* 创建 Background 完成通知器(薄工厂,装配 @xyz-agent/session-delivery 内核)。
|
|
154
|
+
*
|
|
155
|
+
* 内核接管:gate(isIdle 退避)/ 合批窗口(滑动 60s)/ dedup(按 id:round)/ flush / shutdown flush。
|
|
156
|
+
* 本函数职责:将 BgNotifyRecord → DeliveryMessage 映射 + buildLlmContent 预格式化。
|
|
157
|
+
*
|
|
158
|
+
* @param host 宿主能力注入(pi.sendMessage + hasRunningBackground + isIdle)
|
|
159
|
+
* @returns BgNotifier 接口(与旧类同形)
|
|
160
|
+
*/
|
|
161
|
+
export function createNotifier(host: NotifierHost): BgNotifier {
|
|
162
|
+
let disposed = false;
|
|
183
163
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// isIdle 内部 assertActive 可能抛(session 已关闭)——视为不可发送,丢弃。
|
|
191
|
-
// dispose 后本函数首行已短路,此处 catch 兜底极端时序。
|
|
192
|
-
this.pending.length = 0;
|
|
193
|
-
this.flushAttempts = 0;
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
if (!idle && attempt < FLUSH_BACKOFF_MAX) {
|
|
197
|
-
this.timer = setTimeout(() => this.scheduleFlush(attempt + 1), FLUSH_BACKOFF_MS);
|
|
198
|
-
return;
|
|
164
|
+
// 构造 DeliveryPort:intent → pi 参数翻译在适配器内(D3)
|
|
165
|
+
const port: DeliveryPort = {
|
|
166
|
+
supportedPayloads: ["custom"],
|
|
167
|
+
isIdle: () => {
|
|
168
|
+
if (host.isIdle) {
|
|
169
|
+
return host.isIdle();
|
|
199
170
|
}
|
|
200
|
-
//
|
|
201
|
-
|
|
171
|
+
// 未注入 isIdle → 视为 idle(不 gate,向后兼容旧 host)
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
hasPendingMessages: () => false, // notifier 不关心 hasPendingMessages
|
|
175
|
+
// D8(must-fix #4):settled 边沿驱动装配——内核 busy 入队后由 settled 事件唤醒
|
|
176
|
+
// flush(watch-dog 兜底事件丢失),替代无订阅时的退避轮询。host 只注入原生订阅
|
|
177
|
+
// 能力;disposed 标志包装(兑现退订语义——pi.on 返回 void 且无 off)在此完成。
|
|
178
|
+
subscribeSettled:
|
|
179
|
+
host.onAgentSettled === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: (cb) => {
|
|
182
|
+
let disposed = false;
|
|
183
|
+
host.onAgentSettled?.(() => {
|
|
184
|
+
if (!disposed) cb();
|
|
185
|
+
});
|
|
186
|
+
return () => {
|
|
187
|
+
disposed = true;
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
send: (msg, intent) => {
|
|
191
|
+
host.sendMessage(
|
|
192
|
+
{
|
|
193
|
+
customType: NOTIFY_CUSTOM_TYPE,
|
|
194
|
+
content: msg.payload.content,
|
|
195
|
+
display: true,
|
|
196
|
+
details: msg.payload.kind === "custom" ? msg.payload.details : undefined,
|
|
197
|
+
},
|
|
198
|
+
intent === "interrupt-at-turn-boundary"
|
|
199
|
+
? { triggerTurn: true, deliverAs: "steer" }
|
|
200
|
+
: { triggerTurn: true, deliverAs: "followUp" },
|
|
201
|
+
);
|
|
202
|
+
},
|
|
203
|
+
};
|
|
202
204
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
// #5(must-fix):内核 handle 的 disposed 不可逆——dispose 后 revive 必须重建 handle,
|
|
206
|
+
// 否则 revive 后所有 notify() 被内核静默吞(外层标志复位救不回已销毁的内核)。
|
|
207
|
+
// revive = 新生命周期:合批窗口 / 在途批次 / dedup LRU 随重建自然复位(可接受)。
|
|
208
|
+
const createHandle = (): DeliveryHandle =>
|
|
209
|
+
createDelivery(port, {
|
|
210
|
+
intent: "interrupt-at-turn-boundary", // D3:turn 边界抢占(F1 教训内化)
|
|
211
|
+
mergeWindowMs: 60_000, // 滑动窗口合批(继承 MERGE_WINDOW_MS=60s)
|
|
212
|
+
mergeHoldActive: () => host.hasRunningBackground(), // D4 must-fix #1:禁止用 isIdle 代替
|
|
213
|
+
busyPolicy: "retry-force", // settled 边沿驱动 + 退避达上限强发
|
|
214
|
+
backoff: { ms: 100, max: 50 }, // 继承 FLUSH_BACKOFF_MS/MAX
|
|
215
|
+
// dedup LRU:语义与旧 DEDUP_TTL_MS=60s **不同**——按 key 永久去重(仅 LRU 逐出后
|
|
216
|
+
// 同 key 可再入)。当前 key 空间(id / id:round,id 每 spawn 唯一)无实际差异;
|
|
217
|
+
// 后续复用方勿按「60s 内不重复」假设接入(同 key 通知会被永久吞)。
|
|
218
|
+
dedupe: { maxKeys: 1000 },
|
|
219
|
+
});
|
|
220
|
+
let handle: DeliveryHandle = createHandle();
|
|
205
221
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
clearTimeout(this.timer);
|
|
210
|
-
this.timer = undefined;
|
|
211
|
-
}
|
|
212
|
-
if (this.pending.length === 0) return;
|
|
213
|
-
this.flushAttempts = 0;
|
|
222
|
+
return {
|
|
223
|
+
notify(record: BgNotifyRecord): void {
|
|
224
|
+
if (disposed) return;
|
|
214
225
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
?
|
|
218
|
-
: records.map((r) => this.buildLlmContent(r)).join("\n\n---\n\n");
|
|
219
|
-
const details = records.length === 1
|
|
220
|
-
? records[0]
|
|
221
|
-
: { batch: true, items: records };
|
|
226
|
+
// dedup key:idle(对话模式每轮完成)按 id:round 去重——不同轮次不互相掩蔽;
|
|
227
|
+
// 非 idle(closed/cancelled)round 恒定 undefined,key 同旧行为不变。
|
|
228
|
+
const dedupeKey = record.round != null ? `${record.id}:${record.round}` : record.id;
|
|
222
229
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}, { triggerTurn: true, deliverAs: "steer" });
|
|
235
|
-
}
|
|
230
|
+
handle.send({
|
|
231
|
+
payload: {
|
|
232
|
+
kind: "custom",
|
|
233
|
+
customType: NOTIFY_CUSTOM_TYPE,
|
|
234
|
+
content: buildLlmContent(record),
|
|
235
|
+
display: true,
|
|
236
|
+
details: record,
|
|
237
|
+
},
|
|
238
|
+
dedupeKey,
|
|
239
|
+
});
|
|
240
|
+
},
|
|
236
241
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// [wave2 指针行] 增量语义下轮次通知只携带本轮增量,异步 flush 窗口丢失时不可重发
|
|
241
|
-
//(见 subagent-service.ts onRoundSettled 注释),恢复通道是 session 文件全文。
|
|
242
|
-
// 仅 chatMode 透传 sessionFile,one-shot 通知不含该行;cancelled/gc-failed 不追加
|
|
243
|
-
//(无成功结果可读,指针无意义);缺失时省略整行(追加空串 = 输出逐字节不变)。
|
|
244
|
-
const transcriptPointer = record.sessionFile
|
|
245
|
-
? `\n\nFull transcript: ${record.sessionFile}`
|
|
246
|
-
: "";
|
|
247
|
-
switch (record.status) {
|
|
248
|
-
case "closed": {
|
|
249
|
-
// v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生通知文案。
|
|
250
|
-
const reason = record.closedReason ?? "gc";
|
|
251
|
-
if (reason === "cancelled") {
|
|
252
|
-
return `Subagent "${agent}" (${id}) cancelled.`;
|
|
253
|
-
}
|
|
254
|
-
// 失败场景(gc + 有 error):展示错误。判定必须先于 patchFile——失败轮也会写
|
|
255
|
-
// patchFile(doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch),若
|
|
256
|
-
// patch 分支在前,gc 失败 + worktree 并存时 LLM 被告知 completed。顺序与三处
|
|
257
|
-
// 同构契约的另外两处一致(shared/subagent.ts deriveClosedDisplay、
|
|
258
|
-
// bg-notify-render.ts renderRecordLines:cancelled → gc+error → patch/result)。
|
|
259
|
-
if (record.error && reason === "gc") {
|
|
260
|
-
return `Subagent "${agent}" (${id}) failed: ${record.error}`;
|
|
261
|
-
}
|
|
262
|
-
// 成功完成(user-close)或通用结束(gc/parent-shutdown 等):展示结果。
|
|
263
|
-
// [C-2] chatMode close 终态通知附轮次统计(设计 D2 路径①"completed after N rounds")。
|
|
264
|
-
// totalRounds 仅 close 语义携带(notifyClosed);one-shot 完成通知不设置(round 无轮次
|
|
265
|
-
// 语义),文案保持 "completed. Result:" 逐字节(G4 硬约束,one-shot 字节锁测试锚定)。
|
|
266
|
-
const roundsSuffix =
|
|
267
|
-
record.totalRounds != null && record.totalRounds > 0
|
|
268
|
-
? ` after ${record.totalRounds} round${record.totalRounds === 1 ? "" : "s"}`
|
|
269
|
-
: "";
|
|
270
|
-
const base = `Subagent "${agent}" (${id}) completed${roundsSuffix}. Result:\n${record.result ?? "(empty)"}`;
|
|
271
|
-
if (record.patchFile) {
|
|
272
|
-
// [wave2 review] 长 return 拆行:模板串内不可直接换行(会改变输出内容),提取 patchHint 中转变量
|
|
273
|
-
const patchHint = `\n\nThis subagent ran in an isolated worktree; its file changes were captured as a patch:\n ${record.patchFile}\nTo bring these changes into the current repo, run: \`git apply ${record.patchFile}\``;
|
|
274
|
-
return `${base}${patchHint}${transcriptPointer}`;
|
|
275
|
-
}
|
|
276
|
-
return `${base}${transcriptPointer}`;
|
|
277
|
-
}
|
|
278
|
-
case "running":
|
|
279
|
-
// v4 B-1: 对话模式轮次完成(旧 idle 折入 running):携带本轮结果送回主 agent,等待下一轮 message。
|
|
280
|
-
return `Subagent "${agent}" (${id}) finished a round. Reply:\n${record.result ?? "(empty)"}${transcriptPointer}`;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
242
|
+
flushPendingNotifications(): void {
|
|
243
|
+
handle.flush();
|
|
244
|
+
},
|
|
283
245
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
clearTimeout(this.timer);
|
|
289
|
-
this.timer = undefined;
|
|
290
|
-
}
|
|
291
|
-
this.pending.length = 0;
|
|
292
|
-
this.dedup.clear();
|
|
293
|
-
this.flushAttempts = 0;
|
|
294
|
-
}
|
|
246
|
+
dispose(): void {
|
|
247
|
+
disposed = true;
|
|
248
|
+
handle.dispose();
|
|
249
|
+
},
|
|
295
250
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
251
|
+
revive(): void {
|
|
252
|
+
disposed = false;
|
|
253
|
+
// #5:内核 disposed 不可逆——revive 必须重建 handle(/resume /fork /new 后的
|
|
254
|
+
// session_start 时序:disposeAll → initSession → revive)。旧 handle 若仍存活
|
|
255
|
+
// (首次 session_start 的 revive)dispose 为幂等清理,无副作用。
|
|
256
|
+
handle.dispose();
|
|
257
|
+
handle = createHandle();
|
|
258
|
+
},
|
|
259
|
+
};
|
|
300
260
|
}
|
|
@@ -181,7 +181,7 @@ const STDERR_MAX_CHARS = 65_536;
|
|
|
181
181
|
*
|
|
182
182
|
* [跨包契约 SSOT] 此字面量是两个独立 npm 包(@zhushanwen/pi-subagent-workflow 与
|
|
183
183
|
* @zhushanwen/pi-structured-output)之间的隐式 env 契约。structured-output 包内同名常量为
|
|
184
|
-
* `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/structured-output/src/index.ts)。
|
|
184
|
+
* `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/universal/structured-output/src/index.ts)。
|
|
185
185
|
* 两包是独立 npm 包不能直接 import,故各自保留常量但显式标注此契约关系——
|
|
186
186
|
* 任一端改名必须同步另一端,否则权威 schema 注入会静默断桥(子进程不注册 tool/hook)。
|
|
187
187
|
*/
|
|
@@ -32,8 +32,8 @@ import { doFinalizeRecord, doFinalizeRoundToIdle } from "./finalize-record.ts";
|
|
|
32
32
|
import { ManifestStore } from "./manifest-store.ts";
|
|
33
33
|
import type { ModelConfigService } from "./model-config-service.ts";
|
|
34
34
|
import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
|
|
35
|
-
import type { BgNotifyRecord, NotifierHost } from "./notifier.ts";
|
|
36
|
-
import {
|
|
35
|
+
import type { BgNotifyRecord, BgNotifier, NotifierHost } from "./notifier.ts";
|
|
36
|
+
import { createNotifier } from "./notifier.ts";
|
|
37
37
|
import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
|
|
38
38
|
import type { StatusFilter } from "./record-store.ts";
|
|
39
39
|
import { RecordStore } from "./record-store.ts";
|
|
@@ -104,6 +104,10 @@ interface PiLike {
|
|
|
104
104
|
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
105
105
|
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
106
106
|
): void;
|
|
107
|
+
/** 订阅 pi 事件(D8:notifier 的 settled 边沿订阅用 'agent_settled')。
|
|
108
|
+
* pi 0.84.1 的 on 返回 void 且无 off——退订语义由调用侧 disposed 标志包装兑现。
|
|
109
|
+
* 可选:旧测试 mock pi 可能未实现 on,缺省时 notifier 退化为内核退避路径。 */
|
|
110
|
+
on?(event: "agent_settled", handler: () => void): void;
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
/** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
|
|
@@ -299,7 +303,7 @@ export class SubagentService {
|
|
|
299
303
|
const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), this.rootCwd);
|
|
300
304
|
this.manifestStore = new ManifestStore(recordsDir);
|
|
301
305
|
this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
|
|
302
|
-
this.notifier =
|
|
306
|
+
this.notifier = createNotifier(this.piAdapter());
|
|
303
307
|
// #11:注册进程级 observability 单例——ui-request-queue.handleUiRequest 经
|
|
304
308
|
// globalThis 桥接(notifyMissingHandlerGlobal)调到同一实例,共享
|
|
305
309
|
// warnedMissingHandlerSessions 去重集合。未注册时 queue 走 fallback warn(不去重)。
|
|
@@ -586,7 +590,10 @@ export class SubagentService {
|
|
|
586
590
|
(r) => r.mode === "background" && hasLiveProcessHandle(r.id) && !hasIdleTimer(r.id),
|
|
587
591
|
);
|
|
588
592
|
},
|
|
589
|
-
isIdle: this.isIdleFn,
|
|
593
|
+
isIdle: () => this.isIdleFn?.() ?? true,
|
|
594
|
+
// [must-fix #4 / D8] settled 边沿订阅,与 isIdle 同源(session_start 注入的 pi)。
|
|
595
|
+
// 只注入原生订阅能力;disposed 标志包装(退订语义)在 notifier 的 port 装配完成。
|
|
596
|
+
onAgentSettled: (handler) => { this.pi?.on?.("agent_settled", handler); },
|
|
590
597
|
};
|
|
591
598
|
}
|
|
592
599
|
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { killAllSpawnedChildren } from "./execution/session-runner.ts";
|
|
42
42
|
import { SubprocessAgentRunner } from "./execution/subprocess-agent-runner.ts";
|
|
43
43
|
import { WorktreeManager } from "./execution/worktree-manager.ts";
|
|
44
|
+
import { setupModelListInjector } from "./injectors/model-list-injector.ts";
|
|
44
45
|
import { setupSubagentListInjector } from "./injectors/subagent-list-injector.ts";
|
|
45
46
|
import { setupWorkflowListInjector } from "./injectors/workflow-list-injector.ts";
|
|
46
47
|
import { renderBgNotifyMessage } from "./interface/bg-notify-render.ts";
|
|
@@ -168,6 +169,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
168
169
|
|
|
169
170
|
// ════════════════════════════════════════════════════════════
|
|
170
171
|
// injectors:before_agent_start 注入 <available_subagents> + <available_workflows>
|
|
172
|
+
// + <available_provider_models>(模型列表供派发时指定 model 参数;与 subagent/workflow 清单对称)
|
|
171
173
|
//
|
|
172
174
|
// 归位自 unified-hooks(subagent-list-injector)+ 新增 workflow-list-injector。
|
|
173
175
|
// injector 是 subagent-workflow 的内聚功能(让 LLM 知道有哪些 agent/workflow
|
|
@@ -176,6 +178,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
176
178
|
// ════════════════════════════════════════════════════════════
|
|
177
179
|
setupSubagentListInjector(pi);
|
|
178
180
|
setupWorkflowListInjector(pi);
|
|
181
|
+
setupModelListInjector(pi);
|
|
179
182
|
|
|
180
183
|
// 模块级缓存:主 session 的 sessionFile(fork source 解析用)。
|
|
181
184
|
let cachedMainSessionFile: string | undefined;
|