@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
|
@@ -26,15 +26,14 @@
|
|
|
26
26
|
// (session_start 是 pi 启动序列里最早带 ctx 的钩子 = 「session 就绪」,即设计所指
|
|
27
27
|
// 加载完成时点;不挂任何懒触发(无 subagent 的 session 也必上报)。
|
|
28
28
|
//
|
|
29
|
-
// 失败语义(D5
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
/**
|
|
88
|
+
/** 有待推帧(推送在途或上次失败期间置位,下次 onInFlightChanged 事件驱动重推)。 */
|
|
107
89
|
let dirty = false;
|
|
108
|
-
/**
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
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
|
-
//
|
|
174
|
-
//
|
|
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
|
-
|
|
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});
|
|
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
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* subagent-workflow Extension — Factory(extension 装配点)
|
|
3
3
|
*
|
|
4
4
|
* 合并 @zhushanwen/pi-subagents + @zhushanwen/pi-workflow 为统一包。
|
|
5
|
-
* 注册项:4 tool(subagent + subagents + workflow + workflow-script)+
|
|
5
|
+
* 注册项:4 tool(subagent + subagents + workflow + workflow-script)+ 3 command(subagents + workflows + subagent-model)
|
|
6
6
|
* + messageRenderer(subagent-bg-notify)+ session 事件。
|
|
7
7
|
*
|
|
8
8
|
* 包内结构(执行运行时已迁 packages/subagent-core,本包只留注册面与宿主适配):
|
|
@@ -52,6 +52,8 @@ import { registerWorkflowsCommand } from "./interface/command/commands.ts";
|
|
|
52
52
|
import { registerSubagentTool } from "./interface/tool/subagent-tool.ts";
|
|
53
53
|
// ═══ interface/ 层(tools/commands/tui 合并) ═══
|
|
54
54
|
import { registerSubagentsCommand } from "./interface/command/subagents.ts";
|
|
55
|
+
// [subagent-model-switch U6] 模型切换 RPC 通道消费端(§7.1.1,runtime 出站点 prompt 触达)
|
|
56
|
+
import { registerSubagentModelCommand } from "./interface/command/subagent-model.ts";
|
|
55
57
|
import { registerSubagentsTool } from "./interface/tool/tool-subagents.ts";
|
|
56
58
|
import { registerWorkflowTool } from "./interface/tool/tool-workflow.ts";
|
|
57
59
|
import { registerWorkflowScriptTool } from "./interface/tool/tool-workflow-script.ts";
|
|
@@ -143,6 +145,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
143
145
|
// ════════════════════════════════════════════════════════════
|
|
144
146
|
registerSubagentTool(pi);
|
|
145
147
|
registerSubagentsCommand(pi);
|
|
148
|
+
registerSubagentModelCommand(pi);
|
|
146
149
|
pi.registerMessageRenderer(SUBAGENT_BG_NOTIFY_CUSTOM_TYPE, renderBgNotifyMessage);
|
|
147
150
|
|
|
148
151
|
// ════════════════════════════════════════════════════════════
|
|
@@ -226,7 +229,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
226
229
|
process.on("beforeExit", reapSpawnedChildrenOnShutdown);
|
|
227
230
|
|
|
228
231
|
// ════════════════════════════════════════════════════════════
|
|
229
|
-
// Tools(3 个)+ Commands(
|
|
232
|
+
// Tools(3 个)+ Commands(3 个)—— 注册面
|
|
230
233
|
//
|
|
231
234
|
// lazyDeps / isScriptRunning / registry 由 workflow-events.ts 装配结果提供。
|
|
232
235
|
// guard:workflow tool 与 subagents(批量派发入口)共用同一 guard——两者是同一条
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /workflows 命令的 resume 通道契约(workflow-run-resume-revision U3
|
|
2
|
+
* /workflows 命令的 resume 通道契约(workflow-run-resume-revision U3 + F1-26 后续项)。
|
|
3
3
|
*
|
|
4
|
-
* 覆盖面(TUI 模式):/workflows resume <runId> → 执行断点续跑后 notify
|
|
5
|
-
*
|
|
4
|
+
* 覆盖面(TUI 模式):/workflows resume <runId> [model] → 执行断点续跑后 notify,
|
|
5
|
+
* 不打开面板;缺 runId → Usage 提示(含可选 [model]);resumeRun 拒绝 → warning
|
|
6
|
+
* notify 透出恢复指引文案;显式 model 的 handler 透传(第三参 { model } / 无 model
|
|
7
|
+
* 不落键)与目录预检拒单(与 tool 通道同款 D8 语义)。
|
|
6
8
|
*
|
|
7
|
-
* RPC 模式(taiji GUI)的 resume verb
|
|
8
|
-
* command-
|
|
9
|
-
* dispatch 断言由 src/__tests__/rpc-command-handling.test.ts 原样锁定——词表
|
|
10
|
-
* 适配归编排层(见 commands.ts handleRpcMode 注释)。workflow tool 通道
|
|
9
|
+
* RPC 模式(taiji GUI)的 resume verb 接线与 model 透传由
|
|
10
|
+
* src/__tests__/rpc-command-handling.test.ts 覆盖;workflow tool 通道
|
|
11
11
|
* (action:"resume",含 D14 args 校验)由 tool-workflow-resume.test.ts 覆盖。
|
|
12
12
|
*
|
|
13
13
|
* mock 策略:resume-run 深路径 stub(对齐 tool-workflow-resume.test.ts);命令
|
|
@@ -19,6 +19,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
19
19
|
|
|
20
20
|
// 被 mock 的模块——import 路径与被测源文件的等值实例(对齐 tool-workflow-resume.test.ts)
|
|
21
21
|
import { resumeRun } from "@zhushanwen/subagent-core/orchestration/resume-run.ts";
|
|
22
|
+
import { setModelConfigService, GLOBAL_SLOT_KEYS } from "@zhushanwen/subagent-core";
|
|
22
23
|
|
|
23
24
|
import { registerWorkflowsCommand } from "../commands.ts";
|
|
24
25
|
|
|
@@ -66,9 +67,26 @@ function makeCtx(mode: "rpc" | "tui"): { ctx: ExtensionCommandContext; notifies:
|
|
|
66
67
|
return { ctx: ctx as never, notifies };
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/** 重置进程级 ModelConfigService 单例槽(model catalog 预检用例的注入/清理,
|
|
71
|
+
* Symbol 直写——key 与生产 getModelServiceSlot 的 MODEL_SERVICE_SLOT_KEY 一致)。 */
|
|
72
|
+
function resetModelServiceSlot(): void {
|
|
73
|
+
const slot = Reflect.get(globalThis, Symbol.for(GLOBAL_SLOT_KEYS.modelService)) as
|
|
74
|
+
| { current: unknown }
|
|
75
|
+
| undefined;
|
|
76
|
+
if (slot) slot.current = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 注入目录为 entries 的最小 ModelConfigService fake(duck-typed getAvailable)。 */
|
|
80
|
+
function injectModelServiceWith(entries: Array<{ provider: string; id: string }>): void {
|
|
81
|
+
setModelConfigService({
|
|
82
|
+
getModelRegistry: () => ({ getAvailable: () => entries }),
|
|
83
|
+
} as never);
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
beforeEach(() => {
|
|
70
87
|
vi.mocked(resumeRun).mockReset();
|
|
71
88
|
vi.mocked(resumeRun).mockResolvedValue("wf-x");
|
|
89
|
+
resetModelServiceSlot();
|
|
72
90
|
});
|
|
73
91
|
|
|
74
92
|
// ── TUI 模式 ─────────────────────────────────────────────────
|
|
@@ -79,17 +97,44 @@ describe("/workflows TUI resume", () => {
|
|
|
79
97
|
const { ctx, notifies } = makeCtx("tui");
|
|
80
98
|
await cmd.handler("resume wf-x", ctx);
|
|
81
99
|
expect(vi.mocked(resumeRun)).toHaveBeenCalledWith("wf-x", expect.anything());
|
|
100
|
+
// 无 model 不落键:调用恰为 (runId, deps) 两参(spread 条件反写不会静默通过)
|
|
101
|
+
expect(vi.mocked(resumeRun).mock.calls[0]).toHaveLength(2);
|
|
82
102
|
expect(notifies).toHaveLength(1);
|
|
83
103
|
expect(notifies[0]!.msg).toContain("resuming");
|
|
84
104
|
expect(notifies[0]!.level).toBe("info");
|
|
85
105
|
});
|
|
86
106
|
|
|
107
|
+
it("resume <runId> <model> → resumeRun 第三参 { model } 透传 + notify 带模型", async () => {
|
|
108
|
+
injectModelServiceWith([{ provider: "p2", id: "m2" }]);
|
|
109
|
+
const cmd = captureCommand();
|
|
110
|
+
const { ctx, notifies } = makeCtx("tui");
|
|
111
|
+
await cmd.handler("resume wf-x p2/m2:high", ctx);
|
|
112
|
+
expect(vi.mocked(resumeRun)).toHaveBeenCalledWith("wf-x", expect.anything(), {
|
|
113
|
+
model: "p2/m2:high",
|
|
114
|
+
});
|
|
115
|
+
expect(notifies[0]!.msg).toContain("resuming with model p2/m2:high");
|
|
116
|
+
expect(notifies[0]!.level).toBe("info");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("resume 显式 model 目录查无 → 同步拒单 warning(tool 通道同款 D8 语义,不落覆盖记账)", async () => {
|
|
120
|
+
injectModelServiceWith([{ provider: "p1", id: "m1" }]);
|
|
121
|
+
const cmd = captureCommand();
|
|
122
|
+
const { ctx, notifies } = makeCtx("tui");
|
|
123
|
+
await cmd.handler("resume wf-x pX/nope", ctx);
|
|
124
|
+
// 目录查无在 resumeRun 之前拒绝——不产生假成功 notify
|
|
125
|
+
expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
|
|
126
|
+
expect(notifies).toHaveLength(1);
|
|
127
|
+
expect(notifies[0]!.msg).toContain("Failed to resume workflow wf-x:");
|
|
128
|
+
expect(notifies[0]!.msg).toContain("pX/nope");
|
|
129
|
+
expect(notifies[0]!.level).toBe("warning");
|
|
130
|
+
});
|
|
131
|
+
|
|
87
132
|
it("resume 缺 runId → Usage warning", async () => {
|
|
88
133
|
const cmd = captureCommand();
|
|
89
134
|
const { ctx, notifies } = makeCtx("tui");
|
|
90
135
|
await cmd.handler("resume", ctx);
|
|
91
136
|
expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
|
|
92
|
-
expect(notifies[0]).toMatchObject({ msg: "Usage: /workflows resume <runId>", level: "warning" });
|
|
137
|
+
expect(notifies[0]).toMatchObject({ msg: "Usage: /workflows resume <runId> [model]", level: "warning" });
|
|
93
138
|
});
|
|
94
139
|
|
|
95
140
|
it("resumeRun 拒绝 → warning notify(TUI 同款透出)", async () => {
|