@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.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 +7 -4
- package/scripts/rfl.mjs +308 -0
- package/src/execution/__tests__/finalize-record.test.ts +33 -7
- package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
- package/src/execution/__tests__/record-store.test.ts +284 -2
- package/src/execution/__tests__/spawn-args.test.ts +5 -5
- package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
- package/src/execution/__tests__/worktree-manager.test.ts +9 -2
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +1 -1
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/finalize-record.ts +16 -7
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +9 -2
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +290 -1
- package/src/execution/session-pending.ts +5 -4
- package/src/execution/session-runner.ts +484 -289
- package/src/execution/subagent-service.ts +87 -37
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/types.ts +20 -2
- package/src/execution/worktree-manager.ts +325 -14
- package/src/execution/worktree-registry.ts +90 -33
- package/src/index.ts +42 -2
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/format.ts +10 -4
- package/src/interface/tool-render.ts +10 -3
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +47 -65
- package/src/interface/views/detail-content.ts +1 -1
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
- package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/test-mocks.ts +9 -3
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
- package/src/orchestration/error-recovery.ts +4 -1
- package/src/orchestration/execute-agent-call.ts +13 -3
- package/src/orchestration/jsonl-run-store.ts +139 -60
- package/src/orchestration/lifecycle.ts +10 -1
- package/src/orchestration/worker-script-builder.ts +9 -1
- package/src/shared/__tests__/resource-discovery.test.ts +24 -0
- package/src/shared/agent-ref.ts +6 -1
- package/src/shared/resource-discovery.ts +15 -1
- package/src/shared/schema-jsonify.ts +4 -1
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
* 设计:
|
|
12
12
|
* - JsonlRunStore implements RunStore(而非散落的 persist/reconstruct 自由函数)。
|
|
13
13
|
* - **D-5: 不向后兼容**——reconstruct 时检查 snapshotVersion,无版本号或版本不匹配
|
|
14
|
-
*
|
|
14
|
+
* 的 session 返回空数组(spec 决策:旧 run 历史价值低,不尝试兼容迁移)。
|
|
15
15
|
* - rewrite mode(writeFile 覆盖,文件始终是最新单行快照)。
|
|
16
|
-
* - workflow-
|
|
16
|
+
* - **W17 [D4] workflow-record 自描述 entry**:每次成功 flush 同步 append 一条完整
|
|
17
|
+
* 快照 entry(pi.appendEntry)——pi 文件(session JSONL)是 workflow 数据持久化
|
|
18
|
+
* 权威,state 文件降级为纯性能缓存(读序 = entry > state 文件 > 空,写路径保留)。
|
|
19
|
+
* 旧 `workflow-state-link` 指针 entry 退役(loadAll 保留兼容读,存量 run 不丢)。
|
|
17
20
|
*
|
|
18
21
|
* save 去抖语义(cw swf-perf wave2):
|
|
19
22
|
* - **热路径**(running 中间态,本实例已写过):per-runId pending 批合并——窗口内
|
|
@@ -21,11 +24,12 @@
|
|
|
21
24
|
* 固定窗口不重置 timer(批创建时定时一次),保证 flush 延迟有界 ≤saveDebounceMs;
|
|
22
25
|
* agent-call 间隔秒级下 trailing 重置无合并增益反可无限推迟。
|
|
23
26
|
* - **冷路径**(本实例对该 runId 首写,或 status !== "running" 即 done):
|
|
24
|
-
* 同步挂链 flush 绕过 timer——首写立即可见(跨 session 重启后 loadAll
|
|
25
|
-
*
|
|
27
|
+
* 同步挂链 flush 绕过 timer——首写立即可见(跨 session 重启后 loadAll 从 entry
|
|
28
|
+
* 发现 run)、done 立即落盘(终态优先持久化:transition("done") 后的 save
|
|
26
29
|
* 不进去抖批,去抖窗口内的崩溃不吞终态)。
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
30
|
+
* - workflow-record entry 每次成功 flush 都 append(含热路径中间态 flush)——
|
|
31
|
+
* entry 流 = 落盘历史(最后一条 = 最后一次成功 flush,崩溃丢失边界语义与
|
|
32
|
+
* state 文件路径一致);去抖已把 flush 频率控制在与 agent-call 周期同量级。
|
|
29
33
|
* - per-runId 串行 flush 链:同 runId 的 flush 排队顺序执行(不跳过、永不并发
|
|
30
34
|
* writeFile),链尾吞错防断链——错误只经各 save() Promise 的 settlers 传播。
|
|
31
35
|
* - dispose():幂等(缓存自身 Promise);刷全部 pending 批 + await 全部 in-flight
|
|
@@ -42,7 +46,7 @@
|
|
|
42
46
|
import * as fs from "node:fs";
|
|
43
47
|
import * as path from "node:path";
|
|
44
48
|
|
|
45
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
49
|
+
import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
46
50
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
47
51
|
|
|
48
52
|
import { AgentCall } from "./models/agent-call.ts";
|
|
@@ -114,6 +118,39 @@ interface RunSnapshot {
|
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// ── Workflow-record self-describing entry (W17, D4) ─────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 自描述 workflow record entry 的 customType(W17 [D4])。命名对齐 W16 的
|
|
125
|
+
* `subagent-record`(连字符风格)。写点字面量与本常量的等值由
|
|
126
|
+
* __tests__/jsonl-run-store-session-file.test.ts 断言钉住(消费方引用本常量,勿用裸字符串)。
|
|
127
|
+
*/
|
|
128
|
+
export const WORKFLOW_RECORD_CUSTOM_TYPE = "workflow-record";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `workflow-record` entry 的 data schema(v1)。
|
|
132
|
+
*
|
|
133
|
+
* = 完整 RunSnapshot 快照(runId/status/calls/trace 等全部重建需要的字段)+ 版本号。
|
|
134
|
+
* 读取方无需逆向解析 state 文件或指针(D4 自描述原则);snapshot 内部自带 D-5
|
|
135
|
+
* snapshotVersion guard(deserializeRun 检查),entry 层 v 与 snapshot 层 v 是两级
|
|
136
|
+
* 独立版本(entry schema 演化 vs 快照格式演化)。
|
|
137
|
+
*/
|
|
138
|
+
// 模块内类型(不导出:无外部消费方,fallow unused_types/private_type_leaks 双轨判定;
|
|
139
|
+
// 运行侧 workflow-extractor 的同形结构独立定义,见其注释)。
|
|
140
|
+
interface WorkflowRecordEntryData {
|
|
141
|
+
/** schema 版本(W17 起 v1)。消费方按 v 判别解析,不认识的版本跳过而非猜测。 */
|
|
142
|
+
v: 1;
|
|
143
|
+
/** 完整 RunSnapshot(与同次 flush 写入 state 文件的内容是同一份,不二次序列化)。 */
|
|
144
|
+
snapshot: RunSnapshot;
|
|
145
|
+
/** append 时刻 ISO 时间(诊断用;重建不依赖)。 */
|
|
146
|
+
updatedAt: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 已序列化快照 → 自描述 entry data(doFlush 消费同一 snapshot,保证 entry 与 state 文件一致)。 */
|
|
150
|
+
function toWorkflowRecordEntryData(snapshot: RunSnapshot): WorkflowRecordEntryData {
|
|
151
|
+
return { v: 1, snapshot, updatedAt: new Date().toISOString() };
|
|
152
|
+
}
|
|
153
|
+
|
|
117
154
|
// ── Serialization ────────────────────────────────────────────
|
|
118
155
|
|
|
119
156
|
function serializeRun(run: WorkflowRun): RunSnapshot {
|
|
@@ -218,6 +255,63 @@ function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
|
|
|
218
255
|
return WorkflowRun.reconstruct(snapshot.runId, snapshot.spec, state, meta);
|
|
219
256
|
}
|
|
220
257
|
|
|
258
|
+
/** workflow-record entry → 重建 run 写入 recordRuns(v1 entry guard + D-5 版本不匹配
|
|
259
|
+
* 跳过;同 runId 后写覆盖 = 最后一条 entry 胜出)。返回 entry 是否命中该类型。 */
|
|
260
|
+
function collectRecordRun(entry: CustomEntry, recordRuns: Map<string, WorkflowRun>): boolean {
|
|
261
|
+
if (entry.customType !== WORKFLOW_RECORD_CUSTOM_TYPE) return false;
|
|
262
|
+
// v1 entry guard:schema 版本不认识 → 跳过(不猜测解析)
|
|
263
|
+
const data = entry.data as WorkflowRecordEntryData | undefined;
|
|
264
|
+
if (data?.v !== 1 || !data.snapshot) return true;
|
|
265
|
+
const run = deserializeRun(data.snapshot);
|
|
266
|
+
// D-5: null = old snapshot format / version mismatch — skip silently
|
|
267
|
+
if (run) recordRuns.set(run.runId, run); // 后写覆盖 = 最后一条 entry 胜出
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 旧 workflow-state-link 指针 entry → 写入 pointers(仅 state 文件发现通道,W17 前形态)。
|
|
272
|
+
* 返回 entry 是否命中该类型。 */
|
|
273
|
+
function collectStateLinkPointer(entry: CustomEntry, pointers: Map<string, { path: string }>): boolean {
|
|
274
|
+
if (entry.customType !== "workflow-state-link") return false;
|
|
275
|
+
const data = entry.data as { runId?: string; path?: string } | undefined;
|
|
276
|
+
if (data?.runId && data?.path) {
|
|
277
|
+
pointers.set(data.runId, { path: data.path });
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** loadAll 的 entry 扫描:主 session entries → 自描述 record 快照(每 runId 末条胜出)
|
|
283
|
+
* + 旧 workflow-state-link 指针(仅 state 文件发现通道)。 */
|
|
284
|
+
function collectEntrySources(entries: SessionEntry[]): {
|
|
285
|
+
recordRuns: Map<string, WorkflowRun>;
|
|
286
|
+
pointers: Map<string, { path: string }>;
|
|
287
|
+
} {
|
|
288
|
+
const recordRuns = new Map<string, WorkflowRun>();
|
|
289
|
+
const pointers = new Map<string, { path: string }>();
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
if (entry.type !== "custom") continue;
|
|
292
|
+
if (collectRecordRun(entry, recordRuns)) continue;
|
|
293
|
+
collectStateLinkPointer(entry, pointers);
|
|
294
|
+
}
|
|
295
|
+
return { recordRuns, pointers };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 旧 link 指针指向的 state 文件读取:末行 JSON 解析重建。损坏/不可读返回 null
|
|
299
|
+
* (单文件失败不阻断其余 run 重建)。 */
|
|
300
|
+
async function loadRunFromStateFile(filePath: string): Promise<WorkflowRun | null> {
|
|
301
|
+
try {
|
|
302
|
+
const content = await fs.promises.readFile(filePath, "utf8");
|
|
303
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
304
|
+
const lastLine = lines[lines.length - 1];
|
|
305
|
+
if (!lastLine) return null;
|
|
306
|
+
const parsed = JSON.parse(lastLine) as RunSnapshot;
|
|
307
|
+
// D-5: null = old format / version mismatch — skip silently
|
|
308
|
+
return deserializeRun(parsed);
|
|
309
|
+
} catch {
|
|
310
|
+
// Corrupt/unreadable state file — skip (don't crash loadAll).
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
221
315
|
// ── JsonlRunStore ────────────────────────────────────────────
|
|
222
316
|
|
|
223
317
|
/** Node fs 错误 code 判定(ENOENT = 路径不存在,并发删除场景)。 */
|
|
@@ -258,7 +352,7 @@ interface PendingSaveBatch {
|
|
|
258
352
|
export interface JsonlRunStoreOptions {
|
|
259
353
|
/** Session directory root (state files live under <sessionDir>/workflow-state/). */
|
|
260
354
|
sessionDir: string;
|
|
261
|
-
/** Pi ExtensionAPI for appendEntry
|
|
355
|
+
/** Pi ExtensionAPI for workflow-record appendEntry writes (optional for testing). */
|
|
262
356
|
pi?: ExtensionAPI;
|
|
263
357
|
/** Pi ExtensionContext for sessionManager.getEntries (optional for testing). */
|
|
264
358
|
ctx?: ExtensionContext;
|
|
@@ -313,7 +407,7 @@ export class JsonlRunStore {
|
|
|
313
407
|
*
|
|
314
408
|
* 去抖路由:
|
|
315
409
|
* - 冷路径(本实例首写,或 status !== "running")→ 立即挂链 flush(绕过 timer),
|
|
316
|
-
*
|
|
410
|
+
* 回滚资格 = 首写(flush 失败时 doFlush 回滚 writtenOnce,下次 save 重走冷路径);
|
|
317
411
|
* - 热路径(running 且已写过)→ 并入 per-runId 去抖批(固定窗口不重置 timer)。
|
|
318
412
|
*
|
|
319
413
|
* Promise 语义:本批实际落盘后 resolve(同批多次调用共享 settle);IO 错误
|
|
@@ -335,9 +429,9 @@ export class JsonlRunStore {
|
|
|
335
429
|
const isFirstWrite = !this.writtenOnce.has(runId);
|
|
336
430
|
const isCold = isFirstWrite || run.state.status !== "running";
|
|
337
431
|
if (isCold) {
|
|
338
|
-
// 判定即记录:原子防并发双冷(两次并发首写都判 true
|
|
339
|
-
// ENOENT 边界:首写 flush 遇 ENOENT
|
|
340
|
-
// sessionDir
|
|
432
|
+
// 判定即记录:原子防并发双冷(两次并发首写都判 true 会各 flush 一次 entry)。
|
|
433
|
+
// ENOENT 边界:首写 flush 遇 ENOENT 时 entry 未写但 writtenOnce 已记——
|
|
434
|
+
// sessionDir 已删场景持久化无意义,接受(非 ENOENT 失败由 doFlush 回滚,重走冷路径)。
|
|
341
435
|
this.writtenOnce.add(runId);
|
|
342
436
|
// 原子取走 pending 批(终态与最后一个 agent-call 的 debounced save 交错时,
|
|
343
437
|
// pending 批 settlers 并入本次同步 flush 的批合并 settle,timer 取消防二次写)。
|
|
@@ -346,8 +440,7 @@ export class JsonlRunStore {
|
|
|
346
440
|
clearTimeout(batch.timer);
|
|
347
441
|
this.pending.delete(runId);
|
|
348
442
|
}
|
|
349
|
-
|
|
350
|
-
return this.enqueueFlush(runId, run, batch ? batch.settlers : [], writePointer);
|
|
443
|
+
return this.enqueueFlush(runId, run, batch ? batch.settlers : [], isFirstWrite);
|
|
351
444
|
}
|
|
352
445
|
|
|
353
446
|
// 热路径:running 中间态,并入去抖批
|
|
@@ -406,7 +499,7 @@ export class JsonlRunStore {
|
|
|
406
499
|
runId: string,
|
|
407
500
|
run: WorkflowRun,
|
|
408
501
|
settlers: PendingSaveBatch["settlers"],
|
|
409
|
-
|
|
502
|
+
rollbackFirstWrite: boolean,
|
|
410
503
|
): Promise<void> {
|
|
411
504
|
const promise = new Promise<void>((resolve, reject) => {
|
|
412
505
|
settlers.push({ resolve, reject });
|
|
@@ -414,7 +507,7 @@ export class JsonlRunStore {
|
|
|
414
507
|
// 排队不跳过:前一 flush in-flight 时本次挂链尾顺序执行,同 runId 永不并发
|
|
415
508
|
// writeFile(整文件覆盖写并发会互相截断)。链尾吞错防断链——错误只经 settlers 传播。
|
|
416
509
|
const next = (this.chains.get(runId) ?? Promise.resolve())
|
|
417
|
-
.then(() => this.doFlush(runId, run, settlers,
|
|
510
|
+
.then(() => this.doFlush(runId, run, settlers, rollbackFirstWrite))
|
|
418
511
|
.catch(() => {});
|
|
419
512
|
this.chains.set(runId, next);
|
|
420
513
|
return promise;
|
|
@@ -428,7 +521,7 @@ export class JsonlRunStore {
|
|
|
428
521
|
runId: string,
|
|
429
522
|
run: WorkflowRun,
|
|
430
523
|
settlers: PendingSaveBatch["settlers"],
|
|
431
|
-
|
|
524
|
+
rollbackFirstWrite: boolean,
|
|
432
525
|
): Promise<void> {
|
|
433
526
|
const filePath = this.filePathFor(runId);
|
|
434
527
|
try {
|
|
@@ -451,23 +544,21 @@ export class JsonlRunStore {
|
|
|
451
544
|
// serialize-at-flush:写 flush 时刻的最新聚合状态(latestRun 语义)
|
|
452
545
|
const snapshot = serializeRun(run);
|
|
453
546
|
await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
547
|
+
// W17 [D4]:每次成功 flush 同步 append 自描述 workflow-record entry(同一份
|
|
548
|
+
// snapshot,entry 与 state 文件内容一致)。pi 文件是 workflow 数据持久化权威
|
|
549
|
+
//(loadAll 优先从 entry 重建),state 文件降级纯性能缓存。pi 未注入(测试)时跳过。
|
|
550
|
+
this.pi?.appendEntry(
|
|
551
|
+
WORKFLOW_RECORD_CUSTOM_TYPE,
|
|
552
|
+
toWorkflowRecordEntryData(snapshot),
|
|
553
|
+
);
|
|
461
554
|
for (const s of settlers) s.resolve();
|
|
462
555
|
} catch (err) {
|
|
463
|
-
// ES9
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
// 由崩溃等价论证覆盖(kill-9 恢复兜底)。
|
|
470
|
-
if (writePointer) {
|
|
556
|
+
// ES9 失败回滚(热路径 flush 也会写 entry,回滚的意义收敛为「下次 save 重走
|
|
557
|
+
// 冷路径立即重试」——不再有旧指针形态下「热路径永不写 entry → run 对重启
|
|
558
|
+
// 不可见」的窗口)。堵住首写失败后还得等去抖窗的恢复延迟。
|
|
559
|
+
// 残余窗口(已知接受):回滚后若再无任何 save(随即崩溃/退出),entry 与
|
|
560
|
+
// state 文件双双缺失——等价崩溃丢失,由 kill-9 恢复兜底。
|
|
561
|
+
if (rollbackFirstWrite) {
|
|
471
562
|
this.writtenOnce.delete(runId);
|
|
472
563
|
}
|
|
473
564
|
for (const s of settlers) s.reject(err);
|
|
@@ -520,10 +611,14 @@ export class JsonlRunStore {
|
|
|
520
611
|
}
|
|
521
612
|
|
|
522
613
|
/**
|
|
523
|
-
* Reconstruct all runs
|
|
614
|
+
* Reconstruct all runs(W17 [D4] 读序 = workflow-record entry > state 文件 > 空)。
|
|
524
615
|
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
616
|
+
* 1. 优先扫描自描述 `workflow-record` entry(重建源——同一 runId 多条时最后一条
|
|
617
|
+
* 胜出,等价「最后一次成功 flush」)。entry 层 v1 guard:不认识的版本跳过而非
|
|
618
|
+
* 猜测;snapshot 层 D-5 snapshotVersion guard 保持(版本不匹配 → deserializeRun
|
|
619
|
+
* 返回 null → 跳过,不做兼容迁移)。
|
|
620
|
+
* 2. 旧 `workflow-state-link` 指针 entry 兼容读取(优先级低——存量 run 不静默
|
|
621
|
+
* 丢失,父文档 #9 踩坑):entry 未覆盖的 runId 经指针读 state 文件最后行。
|
|
527
622
|
*
|
|
528
623
|
* 需要 ctx(构造时注入)——无 ctx 时返回空(测试或非 Pi 环境下)。
|
|
529
624
|
*/
|
|
@@ -532,32 +627,16 @@ export class JsonlRunStore {
|
|
|
532
627
|
const runs: WorkflowRun[] = [];
|
|
533
628
|
try {
|
|
534
629
|
const entries = this.ctx.sessionManager.getEntries();
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
for (const entry of entries) {
|
|
538
|
-
if (entry.type !== "custom") continue;
|
|
539
|
-
if (entry.customType !== "workflow-state-link") continue;
|
|
540
|
-
const data = entry.data as { runId?: string; path?: string } | undefined;
|
|
541
|
-
if (data?.runId && data?.path) {
|
|
542
|
-
pointers.set(data.runId, { path: data.path });
|
|
543
|
-
}
|
|
544
|
-
}
|
|
630
|
+
const { recordRuns, pointers } = collectEntrySources(entries);
|
|
545
631
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
// D-5: null = old format / version mismatch — skip silently
|
|
555
|
-
if (run) runs.push(run);
|
|
556
|
-
} catch (err) {
|
|
557
|
-
// Corrupt/unreadable state file — skip (don't crash loadAll).
|
|
558
|
-
// Single bad file must not abort reconstruction of the rest.
|
|
559
|
-
void err;
|
|
560
|
-
}
|
|
632
|
+
// 1) 自描述 entry 重建(优先——pi 文件是持久化权威)
|
|
633
|
+
runs.push(...recordRuns.values());
|
|
634
|
+
|
|
635
|
+
// 2) 旧 link 指针 → state 文件兼容(entry 已覆盖的 runId 跳过——link 优先级低)
|
|
636
|
+
for (const [runId, pointer] of pointers) {
|
|
637
|
+
if (recordRuns.has(runId)) continue;
|
|
638
|
+
const run = await loadRunFromStateFile(pointer.path);
|
|
639
|
+
if (run) runs.push(run);
|
|
561
640
|
}
|
|
562
641
|
} catch (err) {
|
|
563
642
|
// getEntries failed — return what we have (empty).
|
|
@@ -149,7 +149,8 @@ export function scheduleTimeBudget(
|
|
|
149
149
|
* (worker+gate+controller)+ assignRuntime(注入 runtime,恢复 I1)+ 注册到
|
|
150
150
|
* deps.runs + store.save。
|
|
151
151
|
*
|
|
152
|
-
* @param spec RunSpec
|
|
152
|
+
* @param spec RunSpec(scriptSource 只读;args 会被原地注入 _runId——rfl C2 契约,
|
|
153
|
+
* worker 启动与崩溃重建共用同一 args 对象)
|
|
153
154
|
* @param deps LifecycleDeps(store/workerHost/runner/runs)
|
|
154
155
|
* @param signal 外部 abort signal(可选;abort 时调 abortRun)
|
|
155
156
|
* @returns runId(wf-<timestamp>-<random>)
|
|
@@ -167,6 +168,14 @@ export async function runWorkflow(
|
|
|
167
168
|
validateRunArgs(spec);
|
|
168
169
|
|
|
169
170
|
const runId = generateRunId();
|
|
171
|
+
// rfl 仪表(tier-1 §7.1):注入稳定 _runId。runAndWait 与 executeNestedWorkflow
|
|
172
|
+
// 两个 args 入口都经本 choke point;rebuildRuntime 复用 run.spec.args 同一对象
|
|
173
|
+
// (error-recovery.ts),worker rebuild 后脚本侧 $ARGS._runId 不漂移——修复
|
|
174
|
+
// 「rebuild 回退 run-<Date.now()> 导致同一逻辑 run 碎裂到多个 state 目录」。
|
|
175
|
+
// 注入在 validateRunArgs 之后,不参与脚本参数 schema 校验(引擎内部字段)。
|
|
176
|
+
if (spec.args && typeof spec.args === "object") {
|
|
177
|
+
spec.args._runId = runId;
|
|
178
|
+
}
|
|
170
179
|
deps.log?.("debug", "workflow:lifecycle", "runWorkflow start", { runId, scriptName: spec.scriptName });
|
|
171
180
|
|
|
172
181
|
// P1-2: pre-aborted signal → fail fast
|
|
@@ -154,7 +154,9 @@ const WORKER_TEMPLATE_PRE = [
|
|
|
154
154
|
' // 不丢失。失败 resolve 为空字符串是既定容错策略。',
|
|
155
155
|
' // parsedOutput: validated data object from structured-output execute().',
|
|
156
156
|
' // Fallback to content (raw text) when no schema was requested or on error.',
|
|
157
|
-
' // W2 改动 9(b):returnMeta===true 时 resolve {value,sessionFile,worktreePath,error}
|
|
157
|
+
' // W2 改动 9(b):returnMeta===true 时 resolve {value,sessionFile,worktreePath,error,usage,durationMs,sessionId}',
|
|
158
|
+
' // (对称缓存重放 9c);否则单值。usage/durationMs/sessionId 为 rfl 仪表透传(tier-1 §7.1,',
|
|
159
|
+
' // AgentResult 已含、此处原样转发,缺省 undefined 兼容旧主线程)。',
|
|
158
160
|
' const _value = msg.result.parsedOutput ?? msg.result.content;',
|
|
159
161
|
' if (pending.returnMeta) {',
|
|
160
162
|
' pending.resolve({',
|
|
@@ -162,6 +164,9 @@ const WORKER_TEMPLATE_PRE = [
|
|
|
162
164
|
' sessionFile: msg.result.sessionFile,',
|
|
163
165
|
' worktreePath: msg.result.worktreePath,',
|
|
164
166
|
' error: msg.result.error,',
|
|
167
|
+
' usage: msg.result.usage,',
|
|
168
|
+
' durationMs: msg.result.durationMs,',
|
|
169
|
+
' sessionId: msg.result.sessionId,',
|
|
165
170
|
' });',
|
|
166
171
|
' } else {',
|
|
167
172
|
' pending.resolve(_value);',
|
|
@@ -261,6 +266,9 @@ const WORKER_TEMPLATE_PRE = [
|
|
|
261
266
|
' sessionFile: cached.sessionFile,',
|
|
262
267
|
' worktreePath: cached.worktreePath,',
|
|
263
268
|
' error: cached.error,',
|
|
269
|
+
' usage: cached.usage,',
|
|
270
|
+
' durationMs: cached.durationMs,',
|
|
271
|
+
' sessionId: cached.sessionId,',
|
|
264
272
|
' };',
|
|
265
273
|
' }',
|
|
266
274
|
' return _cachedValue;',
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getCachedFile,
|
|
25
25
|
getCachedFileContent,
|
|
26
26
|
} from "../resource-discovery.ts";
|
|
27
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
27
28
|
|
|
28
29
|
// ============================================================
|
|
29
30
|
// helpers
|
|
@@ -388,6 +389,29 @@ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
|
|
|
388
389
|
// 快照等价:源级并行 async 版与串行 sync 版输出逐项一致(含序与 source 标签)
|
|
389
390
|
expect(asyncResult).toEqual(discoverResourcesSync(config));
|
|
390
391
|
});
|
|
392
|
+
|
|
393
|
+
it("async: 同名遮蔽时输出 warn(D8d 有检测必有报告)", async () => {
|
|
394
|
+
const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
|
|
395
|
+
writePackageJson(npmPkg, { agents: ["./agents"] });
|
|
396
|
+
const npmFile = writeFile(path.join(npmPkg, "agents"), "dup.md", "npm-body");
|
|
397
|
+
const projFile = writeFile(path.join(ws, ".agents", "agents"), "dup.md", "project-body");
|
|
398
|
+
const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
|
|
402
|
+
|
|
403
|
+
// 遮蔽仍生效(last-writer-wins 语义不变)
|
|
404
|
+
expect(result.find((r) => path.basename(r.path) === "dup.md")?.source).toBe("project-agents");
|
|
405
|
+
// 但不再静默:warn 报告被遮蔽方与保留方路径(D8d「有检测无报告」修复)
|
|
406
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
407
|
+
const [msg, data] = warnSpy.mock.calls[0];
|
|
408
|
+
expect(String(msg)).toContain('duplicate agents "dup"');
|
|
409
|
+
expect(String(msg)).toContain("project-agents shadows npm");
|
|
410
|
+
expect(data).toMatchObject({ shadowed: npmFile, kept: projFile });
|
|
411
|
+
} finally {
|
|
412
|
+
warnSpy.mockRestore();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
391
415
|
});
|
|
392
416
|
|
|
393
417
|
// ── m5 TC4/TC5: 统一 mtime 缓存层(P-cache / P-cache-invalidation) ──
|
package/src/shared/agent-ref.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { isAbsolute, join } from "node:path";
|
|
14
14
|
|
|
15
|
+
/** home 目录简写前缀(`~/`),展开为 homedir 绝对路径。 */
|
|
16
|
+
const HOME_DIR_PREFIX = "~/";
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* 归一化资源引用:~ 展开 + 绝对路径校验。
|
|
17
20
|
*
|
|
@@ -23,7 +26,9 @@ export function normalizeRef(ref: string, ext?: string): string | null {
|
|
|
23
26
|
const trimmed = ref.trim();
|
|
24
27
|
if (!trimmed) return null;
|
|
25
28
|
|
|
26
|
-
const expanded = trimmed.startsWith(
|
|
29
|
+
const expanded = trimmed.startsWith(HOME_DIR_PREFIX)
|
|
30
|
+
? join(homedir(), trimmed.slice(HOME_DIR_PREFIX.length))
|
|
31
|
+
: trimmed;
|
|
27
32
|
if (!isAbsolute(expanded)) return null; // 相对路径无基准(注入段给绝对路径)
|
|
28
33
|
|
|
29
34
|
if (ext !== undefined && !expanded.endsWith(ext)) return null;
|
|
@@ -16,6 +16,11 @@ import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { delimiter, join, resolve } from "node:path";
|
|
18
18
|
|
|
19
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
20
|
+
|
|
21
|
+
// 模块级 logger(setPiHandle 注入后自动走 appendEntry,未注入时 console 兜底)
|
|
22
|
+
const logger = getLogger("subagents");
|
|
23
|
+
|
|
19
24
|
// ── 类型 ─────────────────────────────────────────────────────
|
|
20
25
|
|
|
21
26
|
/** 资源种类:agent 或 workflow */
|
|
@@ -531,10 +536,19 @@ export async function discoverResources(config: ScanConfig): Promise<DiscoveredR
|
|
|
531
536
|
for (const { resources } of allBySource) {
|
|
532
537
|
for (const r of resources) {
|
|
533
538
|
const key = stem(r.path);
|
|
539
|
+
const existing = merged.get(key);
|
|
534
540
|
// available=false 的占位不覆盖已有的 available=true
|
|
535
|
-
if (!r.available &&
|
|
541
|
+
if (!r.available && existing) {
|
|
536
542
|
continue;
|
|
537
543
|
}
|
|
544
|
+
// [D8d] 同名遮蔽可观测:高优先级源覆盖低优先级同名资源时 warn——此前
|
|
545
|
+
// 「有检测无报告」,用户自定义 agent/workflow 被静默遮蔽后排查无从下手。
|
|
546
|
+
if (existing && existing.path !== r.path) {
|
|
547
|
+
logger.warn(
|
|
548
|
+
`[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}`,
|
|
549
|
+
{ shadowed: existing.path, kept: r.path },
|
|
550
|
+
);
|
|
551
|
+
}
|
|
538
552
|
merged.set(key, r);
|
|
539
553
|
}
|
|
540
554
|
}
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
const cache = new WeakMap<object, { compact?: string; pretty?: string }>();
|
|
20
20
|
|
|
21
|
+
/** pretty 格式(JSON.stringify(x, null, N))的缩进空格数。 */
|
|
22
|
+
const PRETTY_PRINT_INDENT_SPACES = 2;
|
|
23
|
+
|
|
21
24
|
/**
|
|
22
25
|
* JSON.stringify(schema) 的引用级缓存版。
|
|
23
26
|
*
|
|
@@ -39,7 +42,7 @@ export function stringifySchemaCached(schema: object, mode: "compact" | "pretty"
|
|
|
39
42
|
// 抛含恢复指引的错误——不回退 String(value)("[object Object]" 会静默拼进 LLM
|
|
40
43
|
// 指令,比崩溃更难排查)。抛错时 entry[mode] 未被赋值,缓存无毒化。
|
|
41
44
|
const serialized: string | undefined =
|
|
42
|
-
mode === "compact" ? JSON.stringify(schema) : JSON.stringify(schema, null,
|
|
45
|
+
mode === "compact" ? JSON.stringify(schema) : JSON.stringify(schema, null, PRETTY_PRINT_INDENT_SPACES);
|
|
43
46
|
if (serialized === undefined) {
|
|
44
47
|
throw new Error(
|
|
45
48
|
`[subagent-workflow] stringifySchemaCached: JSON.stringify returned undefined (mode=${mode}) — ` +
|