@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.
Files changed (55) hide show
  1. package/package.json +7 -4
  2. package/scripts/rfl.mjs +308 -0
  3. package/src/execution/__tests__/finalize-record.test.ts +33 -7
  4. package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
  5. package/src/execution/__tests__/record-store.test.ts +284 -2
  6. package/src/execution/__tests__/spawn-args.test.ts +5 -5
  7. package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
  8. package/src/execution/__tests__/worktree-manager.test.ts +9 -2
  9. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
  10. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  11. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  12. package/src/execution/agent-result-mapper.ts +4 -1
  13. package/src/execution/argv-mirror.ts +1 -1
  14. package/src/execution/channel-registry-access.ts +3 -1
  15. package/src/execution/finalize-record.ts +16 -7
  16. package/src/execution/idle-gc.ts +47 -0
  17. package/src/execution/lifecycle-manager.ts +9 -2
  18. package/src/execution/record-entry.ts +118 -0
  19. package/src/execution/record-store.ts +290 -1
  20. package/src/execution/session-pending.ts +5 -4
  21. package/src/execution/session-runner.ts +484 -289
  22. package/src/execution/subagent-service.ts +87 -37
  23. package/src/execution/temp-prompt.ts +8 -3
  24. package/src/execution/types.ts +20 -2
  25. package/src/execution/worktree-manager.ts +325 -14
  26. package/src/execution/worktree-registry.ts +90 -33
  27. package/src/index.ts +42 -2
  28. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  29. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  30. package/src/interface/format.ts +10 -4
  31. package/src/interface/tool-render.ts +10 -3
  32. package/src/interface/tool-workflow-script.ts +29 -33
  33. package/src/interface/tool-workflow.ts +47 -65
  34. package/src/interface/views/detail-content.ts +1 -1
  35. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
  36. package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
  37. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  38. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
  39. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  40. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  41. package/src/orchestration/__tests__/test-mocks.ts +9 -3
  42. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  43. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
  44. package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
  45. package/src/orchestration/error-recovery.ts +4 -1
  46. package/src/orchestration/execute-agent-call.ts +13 -3
  47. package/src/orchestration/jsonl-run-store.ts +139 -60
  48. package/src/orchestration/lifecycle.ts +10 -1
  49. package/src/orchestration/worker-script-builder.ts +9 -1
  50. package/src/shared/__tests__/resource-discovery.test.ts +24 -0
  51. package/src/shared/agent-ref.ts +6 -1
  52. package/src/shared/resource-discovery.ts +15 -1
  53. package/src/shared/schema-jsonify.ts +4 -1
  54. package/workflows/review-fix-loop-utils.cjs +542 -32
  55. 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
- * 的 session 返回空数组(spec 决策:旧 run 历史价值低,不尝试兼容迁移)。
14
+ * 的 session 返回空数组(spec 决策:旧 run 历史价值低,不尝试兼容迁移)。
15
15
  * - rewrite mode(writeFile 覆盖,文件始终是最新单行快照)。
16
- * - workflow-state-link 指针条目机制保留(pi.appendEntry)。
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
- * 发现文件)、done 立即落盘(终态优先持久化:transition("done") 后的 save
27
+ * 同步挂链 flush 绕过 timer——首写立即可见(跨 session 重启后 loadAll 从 entry
28
+ * 发现 run)、done 立即落盘(终态优先持久化:transition("done") 后的 save
26
29
  * 不进去抖批,去抖窗口内的崩溃不吞终态)。
27
- * - 指针(workflow-state-link)只在两处写:创建(本实例首写,即使 status 是
28
- * running)与终态(status==="done")。中间态 flush 永不写指针——每实例每 run ≤2 条。
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 pointer writes (optional for testing). */
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
- * writePointer = 首写 || status==="done";
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 时指针未写但 writtenOnce 已记——
340
- // sessionDir 已删场景指针无意义,接受(非 ENOENT 失败由 doFlush 回滚,重走冷路径)。
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
- const writePointer = isFirstWrite || run.state.status === "done";
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
- writePointer: boolean,
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, writePointer))
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
- writePointer: boolean,
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
- if (writePointer && this.pi) {
455
- this.pi.appendEntry("workflow-state-link", {
456
- runId,
457
- path: filePath,
458
- updatedAt: new Date().toISOString(),
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 失败回滚:应写指针的 flush 未写成时回滚首写资格——下次 save 判
464
- // !writtenOnce.has(runId) 重走冷路径、writePointer 再判 true 重试指针。堵住
465
- // 「首写失败后热路径 writePointer 恒 false → 指针永失 → run 对重启后
466
- // loadAll 不可见」窗口(loadAll 仅经指针发现文件)。
467
- // 残余窗口(已知接受):回滚后若仅剩 writePointer=false 的热批 flush 成功且
468
- // 再无任何 save(随即崩溃/退出),指针仍可能缺失——窗口远窄于 ES9 所堵场景,
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 from session JSONL pointer entries.
614
+ * Reconstruct all runs(W17 [D4] 读序 = workflow-record entry > state 文件 > 空)。
524
615
  *
525
- * D-5:旧格式(无版本号 / 版本不匹配)返回空——loadAll 跳过这些条目,
526
- * 不尝试向后兼容旧 session(spec 决策)。
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 pointers = new Map<string, { path: string }>();
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
- for (const [, pointer] of pointers) {
547
- try {
548
- const content = await fs.promises.readFile(pointer.path, "utf8");
549
- const lines = content.split("\n").filter((l) => l.trim());
550
- const lastLine = lines[lines.length - 1];
551
- if (!lastLine) continue;
552
- const parsed = JSON.parse(lastLine) as RunSnapshot;
553
- const run = deserializeRun(parsed);
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(不可变输入,含 scriptSource/args)
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}(对称缓存重放 9c);否则单值。',
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) ──
@@ -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("~/") ? join(homedir(), trimmed.slice(2)) : trimmed;
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 && merged.has(key)) {
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, 2);
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}) — ` +