@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.
@@ -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
+ }
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)+ 2 command(subagents + workflows)
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(2 个)—— 注册面
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
- * 面板;缺 runId → Usage 提示;resumeRun 拒绝 → warning notify 透出恢复指引文案。
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 接线未随 U3 落地:解析词表在
8
- * command-actions.ts(领地外,resume verb 现仍解析为 lifecycle-removed),其
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 () => {