@zhushanwen/pi-subagent-workflow 9.2.0 → 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 +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/__tests__/staged-discovery.test.ts +147 -0
- package/src/host/inflight-reporter.ts +29 -82
- package/src/host/journal-reporter.ts +204 -0
- package/src/host/pi-host.ts +54 -6
- package/src/interface/command/subagents.ts +2 -2
- package/src/interface/tool/subagent-tool.ts +6 -5
- package/src/interface/tool/tool-workflow-script.ts +13 -9
- package/src/session-lifecycle.ts +4 -6
- package/src/workflow-events.ts +30 -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/host/pi-host.ts
CHANGED
|
@@ -62,20 +62,34 @@ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
|
62
62
|
* realpath 去重吸收)。
|
|
63
63
|
*
|
|
64
64
|
* 每次调用现解析(不 memo):发现调用点稀疏(session_start + 缓存 miss),解析
|
|
65
|
-
*
|
|
66
|
-
*
|
|
65
|
+
* 成本可忽略;两种形态各有通路,都不阻断发现主链:
|
|
66
|
+
* - **dev / npm 形态**:`require.resolve` 沿本包依赖解析链命中 core 包;
|
|
67
|
+
* - **bundle / staged 打包形态**(2026-10-03 B′ 修复):esbuild 自包含 bundle 把
|
|
68
|
+
* core inline、staged 布局无 node_modules ⇒ resolve 必败(实证:pi-host 日志全天
|
|
69
|
+
* 202 条 'Cannot find module … workflows/README.md',内置 6 workflow + 10 agent
|
|
70
|
+
* 整体不可发现、`subagents` 批量工具报 'Built-in workflow fan-out is not available')。
|
|
71
|
+
* 此时回退到 staged scope 根(见 stagedScopeRootFromModuleUrl)——仅当回退也不
|
|
72
|
+
* 成立(异常布局/解析器不可用)才降级为不注入并 warn。
|
|
67
73
|
*/
|
|
68
|
-
function corePackageNpmRoot(): string | undefined {
|
|
74
|
+
export function corePackageNpmRoot(moduleUrl: string = import.meta.url): string | undefined {
|
|
69
75
|
try {
|
|
70
76
|
// createRequire 锚定本模块(jiti 加载器下 import.meta.url 可用;不可用则随
|
|
71
77
|
// catch 降级)。require.resolve 沿 pi-sw 自身的依赖解析链——workspace 与发布态
|
|
72
78
|
// 都从本包出发命中 core。
|
|
73
|
-
const require = createRequire(
|
|
79
|
+
const require = createRequire(moduleUrl);
|
|
74
80
|
const anchor = require.resolve("@zhushanwen/subagent-core/workflows/README.md");
|
|
75
81
|
return dirname(dirname(dirname(anchor)));
|
|
76
82
|
} catch (err) {
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
const stagedRoot = stagedScopeRootFromModuleUrl(moduleUrl);
|
|
84
|
+
if (stagedRoot !== undefined) {
|
|
85
|
+
// getLogger 惰性调用(冷路径:测试环境对 pi-extension-logger 的 module-level
|
|
86
|
+
// mock 可能返回 undefined,模块级持有会在 import 期踩 undefined)
|
|
87
|
+
getLogger("pi-host").debug(
|
|
88
|
+
"[pi-host] core 包经 staged scope 根注入(bundle 形态,无 node_modules 解析面)",
|
|
89
|
+
{ root: stagedRoot },
|
|
90
|
+
);
|
|
91
|
+
return stagedRoot;
|
|
92
|
+
}
|
|
79
93
|
getLogger("pi-host").warn(
|
|
80
94
|
"[pi-host] core 包 agents/ 注入根解析失败——10 内置角色可能不可发现",
|
|
81
95
|
{ reason: toErrorMessage(err) },
|
|
@@ -84,6 +98,40 @@ function corePackageNpmRoot(): string | undefined {
|
|
|
84
98
|
}
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
/**
|
|
102
|
+
* bundle / staged 形态的 core 资产注入根(2026-10-03 B′)。
|
|
103
|
+
*
|
|
104
|
+
* 打包态扩展被 esbuild 打成**自包含 index.js**(core 被 inline),staged 布局内无
|
|
105
|
+
* node_modules ⇒ `require.resolve` 锚点必败。但此时模块位置是
|
|
106
|
+
* `<stagedScope>/<pkg>/index.js`(如
|
|
107
|
+
* `<Resources>/extensions/@zhushanwen/pi-subagent-workflow/index.js`),其**父目录**
|
|
108
|
+
* 正是 `@zhushanwen` scope 目录——npm 槽语义("一级子项 = 包目录")恰好成立,
|
|
109
|
+
* 于是可直接当 npm 槽根用:约定目录扫描即命中
|
|
110
|
+
* `pi-subagent-workflow/{workflows,agents}`(两目录由 `scripts/bundle-extensions.mjs`
|
|
111
|
+
* 随包拷贝;`pi.agents`/`pi.workflows` manifest 约定及其"声明路径不存在 → 整包失败
|
|
112
|
+
* 占位"语义是 subagent-core resource-discovery 的 taiji 自建规则,权威源 =
|
|
113
|
+
* resource-discovery.ts processPackage——pi 上游 manifest 只有
|
|
114
|
+
* extensions/skills/prompts/themes 四字段,无 agents/workflows 概念。已核仓内无任何
|
|
115
|
+
* 包声明该字段,故扫描面内所有包都走约定目录分支,不进 manifest 失败分支)。
|
|
116
|
+
*
|
|
117
|
+
* 与 dev/npm 形态的既有语义一致:npm 槽根扫的就是 scope 下全部包目录(因此本包自带
|
|
118
|
+
* 的 workflows/agents 在 dev/npm 形态同样入发现面),非新增语义。
|
|
119
|
+
*
|
|
120
|
+
* 形态判据 = 父目录名以 `@` 开头:dev(模块在 `extensions/universal/...`)与 npm
|
|
121
|
+
* 安装形态都先走 resolve 成功分支,本回退不可达;仅 bundle/staged 形态命中。
|
|
122
|
+
*/
|
|
123
|
+
export function stagedScopeRootFromModuleUrl(moduleUrl: string): string | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const pkgDir = dirname(fileURLToPath(moduleUrl));
|
|
126
|
+
const scopeDir = dirname(pkgDir);
|
|
127
|
+
if (scopeDir === pkgDir) return undefined;
|
|
128
|
+
const scopeName = scopeDir.split(/[\\/]/).pop() ?? "";
|
|
129
|
+
return scopeName.startsWith("@") ? scopeDir : undefined;
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
/** agents/workflows 共享的 agentDir 派生根(末级目录名由 kind 决定)。
|
|
88
136
|
* 顺序与 source 标签逐项对齐 resource-discovery.ts buildScanTargets 的
|
|
89
137
|
* user-pi → npm → npm-dev 段(根列表按优先级低→高排列,D2 语义边界)。
|
|
@@ -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
|
|
40
|
-
* 实装(agent-session.js
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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
|
-
//
|
|
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
|
|
207
|
-
//
|
|
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
|
|
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
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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 {
|
package/src/session-lifecycle.ts
CHANGED
|
@@ -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
|
-
//
|
|
528
|
-
//
|
|
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
|
-
//
|
|
536
|
+
// 缺席,settled 边沿仍投新通知)——不得共用 "bind failed" 文案误报。
|
|
539
537
|
let ledger: ReturnType<typeof bindNotifyLedgerHost>;
|
|
540
538
|
try {
|
|
541
539
|
ledger = bindNotifyLedgerHost(ledgerHost);
|