@zhushanwen/pi-subagent-workflow 8.10.1 → 8.12.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.
@@ -12,8 +12,9 @@
12
12
 
13
13
  import { Key, matchesKey } from "@earendil-works/pi-tui";
14
14
 
15
- import { getAllToolCalls, projectLiveProgress } from "@zhushanwen/subagent-core";
15
+ import { computeElapsedSeconds } from "@zhushanwen/subagent-core";
16
16
  import type { AgentEventLogEntry } from "@zhushanwen/subagent-core";
17
+ import type { SubagentRecord } from "@zhushanwen/subagent-core";
17
18
  import type { ExecutionTraceNode } from "@zhushanwen/subagent-core";
18
19
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
19
20
  import {
@@ -41,6 +42,49 @@ import {
41
42
  /** 探测宽度:足够大避免截断折行影响行数统计(对齐 subagents DETAIL_LEN_PROBE_WIDTH)。 */
42
43
  const DETAIL_LEN_PROBE_WIDTH = 9999;
43
44
 
45
+ // ── [H2 W3] store record 的 live 进度投影(设计 D2:进度源从 node.live 切 store)──
46
+
47
+ /**
48
+ * running record 的实时进度投影——消费面七字段口径与旧 projectLiveProgress(node.live)
49
+ * 逐字段对齐(totalTokens / 工具计数 / elapsedSeconds / turns / eventLog /
50
+ * currentActivity / lastError)。
51
+ */
52
+ export interface LiveProgressView {
53
+ totalTokens: number;
54
+ toolCallCount: number;
55
+ elapsedSeconds: number;
56
+ turns: number;
57
+ eventLog: AgentEventLogEntry[];
58
+ currentActivity?: { type: "tool" | "text" | "thinking"; label: string };
59
+ lastError?: string;
60
+ }
61
+
62
+ /**
63
+ * SubagentRecord(store 查询投影)→ LiveProgressView。与旧路径
64
+ * projectLiveProgress(node.live) 的字段等价性(构造性论证,S1 等价表依据):
65
+ * - totalTokens / turns:recordToSubagent 直读 record.totalTokens / turnCount,同源;
66
+ * - toolCallCount:eventLog 里 tool_start 计数——getEventLog 对每个 toolCall 恰产
67
+ * 一条 tool_start,与 getAllToolCalls(record).length 恒等;
68
+ * - elapsedSeconds:computeElapsedSeconds 同一函数(startedAt/endedAt 同字段);
69
+ * - eventLog / currentActivity:recordToSubagent 内部即调 getEventLog /
70
+ * getCurrentActivity(同源 API,仅内存 running record 有 currentActivity);
71
+ * - lastError:eventLog 末条 type==="error" 的 label——getEventLog 仅当
72
+ * record.lastError 非空时追加该条且必在末尾,存在性 ⟺ 非空,label 即原文。
73
+ */
74
+ export function projectRecordProgress(rec: SubagentRecord): LiveProgressView {
75
+ const eventLog = rec.eventLog;
76
+ const last = eventLog[eventLog.length - 1];
77
+ return {
78
+ totalTokens: rec.totalTokens,
79
+ toolCallCount: eventLog.filter((e) => e.type === "tool_start").length,
80
+ elapsedSeconds: computeElapsedSeconds(rec),
81
+ turns: rec.turns,
82
+ eventLog,
83
+ currentActivity: rec.currentActivity,
84
+ lastError: last !== undefined && last.type === "error" ? last.label : undefined,
85
+ };
86
+ }
87
+
44
88
  /** status → 语义色标签(L2 detail 头用)。 */
45
89
  export function statusLabel(status: string, theme: ThemeLike): string {
46
90
  switch (status) {
@@ -57,7 +101,9 @@ export function statusLabel(status: string, theme: ThemeLike): string {
57
101
  * 构建 L2 右侧详情的完整内容行。
58
102
  *
59
103
  * 纯函数:无 Pi runtime、无副作用,可单测。入参 promptExpanded 用结构化类型,
60
- * 不依赖完整 ViewState(便于测试构造)。
104
+ * 不依赖完整 ViewState(便于测试构造)。liveView = 该节点配对的 running record 投影
105
+ * ([H2 W3] 从 trace.live 切 store 订阅,经 WorkflowsView.collectNodeLiveProgress
106
+ * 配对注入;缺省走终态 result 路径)。
61
107
  */
62
108
  export function buildDetailContent(
63
109
  node: ExecutionTraceNode,
@@ -66,6 +112,7 @@ export function buildDetailContent(
66
112
  theme: ThemeLike,
67
113
  mainWidth: number,
68
114
  now: number,
115
+ liveView?: LiveProgressView,
69
116
  ): string[] {
70
117
  const rightLines: string[] = [];
71
118
  const elapsed = formatElapsed(
@@ -75,20 +122,18 @@ export function buildDetailContent(
75
122
  rightLines.push(theme.fg("muted", "Detail"));
76
123
  rightLines.push("─".repeat(mainWidth));
77
124
  rightLines.push(`${statusDotStr(node.status, theme)} ${statusLabel(node.status, theme)} · ${node.model}`);
78
- // Live 路径优先:运行中用 node.live 的实时 usage/toolCalls/elapsed;否则用终态 result。
79
- if (node.live) {
80
- const live = projectLiveProgress(node.live);
81
- const tokK = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "0 tok";
82
- const tcCount = getAllToolCalls(node.live).length;
83
- rightLines.push(theme.fg("dim", `${tokK} · ${tcCount} tool calls · ${formatElapsedSeconds(live.elapsedSeconds)}`));
125
+ // Live 路径优先:运行中用 store record 投影的实时 usage/toolCalls/elapsed;否则用终态 result。
126
+ if (liveView) {
127
+ const tokK = liveView.totalTokens > 0 ? `${Math.round(liveView.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "0 tok";
128
+ rightLines.push(theme.fg("dim", `${tokK} · ${liveView.toolCallCount} tool calls · ${formatElapsedSeconds(liveView.elapsedSeconds)}`));
84
129
  } else {
85
130
  rightLines.push(theme.fg("dim", formatTokenStat(node.result?.usage, node.result?.toolCalls, elapsed)));
86
131
  }
87
132
  rightLines.push("");
88
133
  renderWorkerLogSection(rightLines, run, mainWidth, theme);
89
134
  renderPromptSection(rightLines, node, state, theme);
90
- renderActivitySection(rightLines, node, mainWidth, theme);
91
- renderOutcomeSection(rightLines, node, mainWidth, theme);
135
+ renderActivitySection(rightLines, node, liveView, mainWidth, theme);
136
+ renderOutcomeSection(rightLines, node, liveView, mainWidth, theme);
92
137
  renderSessionSection(rightLines, node, mainWidth, theme);
93
138
  return rightLines;
94
139
  }
@@ -99,8 +144,9 @@ export function detailContentLength(
99
144
  state: { promptExpanded: boolean },
100
145
  run: WorkflowRun,
101
146
  theme: ThemeLike,
147
+ live?: LiveProgressView,
102
148
  ): number {
103
- return buildDetailContent(node, state, run, theme, DETAIL_LEN_PROBE_WIDTH, Date.now()).length;
149
+ return buildDetailContent(node, state, run, theme, DETAIL_LEN_PROBE_WIDTH, Date.now(), live).length;
104
150
  }
105
151
 
106
152
  // ── L2 详情滚动按键(纯函数,对齐 subagents processKey)─────────
@@ -214,20 +260,20 @@ function renderPromptSection(
214
260
  function renderActivitySection(
215
261
  rightLines: string[],
216
262
  node: ExecutionTraceNode,
263
+ liveView: LiveProgressView | undefined,
217
264
  mainWidth: number,
218
265
  theme: ThemeLike,
219
266
  ): void {
220
- // Live 路径:agent 运行中,从 node.live 派生实时 eventLog + currentActivity。
267
+ // Live 路径:agent 运行中,从 store record 投影派生实时 eventLog + currentActivity。
221
268
  // 与 subagents TUI 一致:当前活动行 + 最近 N 条离散事件(tool/turn_end/error)。
222
- if (node.live) {
223
- const live = projectLiveProgress(node.live);
224
- const eventLog = live.eventLog.filter((e) => e.type !== "turn_end");
225
- const totalCount = getAllToolCalls(node.live).length;
226
- const label = `Activity · ${totalCount} tool call${totalCount !== 1 ? "s" : ""} · ${live.turns} turn${live.turns !== 1 ? "s" : ""}`;
269
+ if (liveView) {
270
+ const eventLog = liveView.eventLog.filter((e) => e.type !== "turn_end");
271
+ const totalCount = liveView.toolCallCount;
272
+ const label = `Activity · ${totalCount} tool call${totalCount !== 1 ? "s" : ""} · ${liveView.turns} turn${liveView.turns !== 1 ? "s" : ""}`;
227
273
  rightLines.push(theme.fg("muted", label));
228
274
  // 当前活动行(running tool / thinking / text)
229
- if (live.currentActivity) {
230
- rightLines.push(theme.fg("accent", ` ⎿ ${live.currentActivity.type}: ${live.currentActivity.label}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
275
+ if (liveView.currentActivity) {
276
+ rightLines.push(theme.fg("accent", ` ⎿ ${liveView.currentActivity.type}: ${liveView.currentActivity.label}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
231
277
  }
232
278
  // 最近 N 条事件
233
279
  const showCount = Math.min(MAX_TOOL_CALLS_DISPLAY, eventLog.length);
@@ -236,7 +282,7 @@ function renderActivitySection(
236
282
  const entry = eventLog[i] as AgentEventLogEntry;
237
283
  rightLines.push(theme.fg("dim", ` ${formatTraceEventLine(entry, theme)}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
238
284
  }
239
- if (totalCount === 0 && !live.currentActivity) {
285
+ if (totalCount === 0 && !liveView.currentActivity) {
240
286
  rightLines.push(theme.fg("dim", " (starting...)"));
241
287
  }
242
288
  rightLines.push("");
@@ -267,17 +313,17 @@ function renderActivitySection(
267
313
  function renderOutcomeSection(
268
314
  rightLines: string[],
269
315
  node: ExecutionTraceNode,
316
+ liveView: LiveProgressView | undefined,
270
317
  mainWidth: number,
271
318
  theme: ThemeLike,
272
319
  ): void {
273
320
  rightLines.push(theme.fg("muted", "Outcome"));
274
- if (node.status === "running" && node.live) {
321
+ if (node.status === "running" && liveView) {
275
322
  // 运行中:显示实时指标(elapsed/tokens/turns)替代空荡的 "Still running..."
276
- const live = projectLiveProgress(node.live);
277
- const tokK = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "0 tok";
278
- rightLines.push(theme.fg("dim", ` Running · ${formatElapsedSeconds(live.elapsedSeconds)} · ${tokK} · ${live.turns} turn${live.turns !== 1 ? "s" : ""}`));
279
- if (live.lastError) {
280
- rightLines.push(theme.fg("warning", ` ⚠ ${live.lastError.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
323
+ const tokK = liveView.totalTokens > 0 ? `${Math.round(liveView.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "0 tok";
324
+ rightLines.push(theme.fg("dim", ` Running · ${formatElapsedSeconds(liveView.elapsedSeconds)} · ${tokK} · ${liveView.turns} turn${liveView.turns !== 1 ? "s" : ""}`));
325
+ if (liveView.lastError) {
326
+ rightLines.push(theme.fg("warning", ` ⚠ ${liveView.lastError.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
281
327
  }
282
328
  } else if (node.status === "running") {
283
329
  rightLines.push(theme.fg("dim", " Still running..."));
@@ -50,7 +50,7 @@
50
50
  * - pi 侧版本不匹配静默跳过语义保持(D-5);「缺 v 宽容」是 core FileRunStore
51
51
  * 侧的存量预处理职责,不内聚进 codec(D4 裁决②),故本侧零改动即保持。
52
52
  *
53
- * [S3 查证结论] pi 0.84.1 实装(node_modules/@earendil-works/pi-coding-agent/dist,
53
+ * [S3 查证结论] pi 0.84.4 实装(node_modules/@earendil-works/pi-coding-agent/dist,
54
54
  * core/session-manager.js,PS-19)的 session 生命周期管理不含自动 GC:
55
55
  * listSessionsFromDir 只做只读扫描(readdir + `.jsonl` 过滤 + header 解析,:548-571,
56
56
  * 非递归——`<sessionDir>/workflow-state/` 子目录完全不在 pi 的任何扫描/清理范围内),
@@ -8,7 +8,7 @@
8
8
  * 1. identity env→appendEntry 重建(类型 13 字段含 1 个 @deprecated,写入 12)
9
9
  * 2. notify ledger host 装配 + 重启恢复
10
10
  * 3. 双 Service 装配 + initSession(createOrReuseServices 封装,单例语义 D8)
11
- * 4. GC / manifest tmp / worktree 恢复
11
+ * 4. GC / manifest tmp 清扫([U4c/D6] promote 退役)/ 索引重建([U4c/G1] boot 全量腿)/ worktree 恢复
12
12
  * 5. per-session run store + kill-9 恢复循环 + evictDoneRunsBeyondCap
13
13
  * 6. SAR + engine 基线(经 SessionLifecycleResult 返回,sessionState 写入留在组合根)
14
14
  *
@@ -22,7 +22,7 @@ import * as path from "node:path";
22
22
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
23
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
24
  import { getLogger } from "@zhushanwen/pi-extension-logger";
25
- import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
25
+ import { guardStaleCtx, oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
26
26
 
27
27
  // ═══ core 宿主端口消费(随迁块的依赖;production 默认实现住本文件) ═══
28
28
  import { getOrCreateChannelRegistry } from "@zhushanwen/subagent-core";
@@ -323,8 +323,12 @@ function appendSubagentIdentityEntry(pi: ExtensionAPI): void {
323
323
  * 随文件域隔离)。compaction 存活情况归 session_compact handler 的条件降级(P-B4
324
324
  * 探针阶段 5 实测,见 notify-ledger.ts compactionCheck)。装配失败不阻断
325
325
  * session_start(通知退回 notifier 的内核路径)。
326
+ *
327
+ * export + 返回装配的 NotifyLedgerHost = 测试直入 seam(可直调 sendDelivery 验证
328
+ * 守卫分诊);生产调用方(setupSessionLifecycle)忽略返回值,行为不变。装配失败
329
+ * 返回 undefined。
326
330
  */
327
- function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): void {
331
+ export function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): NotifyLedgerHost | undefined {
328
332
  try {
329
333
  const ledgerHost: NotifyLedgerHost = {
330
334
  appendLedgerEntry: (customType, data) => {
@@ -338,16 +342,31 @@ function bindLedgerHostAndRecover(pi: ExtensionAPI, ctx: ExtensionContext): void
338
342
  sendDelivery: (message) => {
339
343
  // D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
340
344
  // courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
341
- pi.sendMessage(message, { triggerTurn: true });
345
+ // stale ctx 防御(crash-resilience D1 / ext-guards 审计 §7 blockers#1 收口):
346
+ // sendDelivery 经 settled 边沿 / 看门狗 / 恢复重放异步触发——session 替换窗口
347
+ // 触碰 stale pi 命中 assertActive(PS-30)即无人接 rejection(E1 同机制)。
348
+ // stale 静默降级(本条通知不投递,attemptDeliver 按已受理标 sentAt——session
349
+ // 替换后通知对旧 session 已无意义,与守卫前「留 pending 反复撞 stale 直到账本
350
+ // 重绑」终局一致),非 stale 错误原样上抛(attemptDeliver 既有 catch 走
351
+ // settleRejected 留账重试语义不变)。
352
+ guardStaleCtx(() => pi.sendMessage(message, { triggerTurn: true }), {
353
+ label: "subagent-workflow:sendDelivery",
354
+ onStale: (error) =>
355
+ logger.warn("notify delivery skipped (stale ctx)", {
356
+ error: toErrorMessage(error),
357
+ }),
358
+ });
342
359
  },
343
360
  };
344
361
  // U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
345
362
  // 通道落盘),此处不再重复打日志。
346
363
  bindNotifyLedgerHost(ledgerHost).recoverFromSession();
364
+ return ledgerHost;
347
365
  } catch (err) {
348
366
  logger.warn("[subagents] notify ledger bind failed", {
349
367
  reason: toErrorMessage(err),
350
368
  });
369
+ return undefined;
351
370
  }
352
371
  }
353
372
 
@@ -389,17 +408,33 @@ async function runProcessLevelMaintenance(
389
408
  });
390
409
  }
391
410
 
392
- // ADR-035 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下,promote/unlink)。
411
+ // ADR-035 启动清扫:manifest tmp 残留(崩溃打断的 writeManifest 留下)。
412
+ // [U4c / D6] tmp 恢复退役为静默删除——manifest 已是可丢可重建缓存(权威 =
413
+ // `.state`,重建走下方 rebuildIndexes 钩子),promote 半写 tmp 的恢复语义失效。
393
414
  // 扫描属进程级维护——oncePerProcess 守卫防双跑(u-audit-fix);第二派发重放首次
394
- // Promise(结果缓存语义),recovered 计数日志可能重打,无文件副作用。
415
+ // Promise(结果缓存语义),deleted 计数日志可能重打,无文件副作用。
395
416
  try {
396
- const recovered = await oncePerProcess("subagent-workflow:recover-manifest-tmp-files", () =>
417
+ const swept = await oncePerProcess("subagent-workflow:sweep-manifest-tmp-files", () =>
397
418
  service.recoverManifestTmpFiles());
398
- if (recovered.recovered > 0 || recovered.deleted > 0) {
399
- logger.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
419
+ if (swept.deleted > 0) {
420
+ logger.warn(`[subagents] manifest tmp sweep: ${swept.deleted} stale tmp file(s) removed (manifest is rebuildable cache)`);
400
421
  }
401
422
  } catch (err) {
402
- logger.warn("[subagents] manifest tmp recovery failed", {
423
+ logger.warn("[subagents] manifest tmp sweep failed", {
424
+ reason: toErrorMessage(err),
425
+ });
426
+ }
427
+
428
+ // [U4c / G1] 缓存降级重建(boot 全量腿):boot revive 已完成(createOrReuseServices
429
+ // 内 initSession 的孤儿恢复/可重连 entry 重物化先于本 helper),此处全量重建可丢
430
+ // 缓存——manifest 幂等补缺 + sessions-index 经首扫自愈重写。进程级维护(磁盘域
431
+ // 全量),oncePerProcess 守卫防双跑;/resume /fork 同进程复用实例时跳过(查询面
432
+ // 惰性通道兜底,且终态写点本身维持 manifest 就位)。
433
+ try {
434
+ oncePerProcess("subagent-workflow:rebuild-record-indexes", () =>
435
+ service.rebuildIndexes());
436
+ } catch (err) {
437
+ logger.warn("[subagents] record index rebuild failed", {
403
438
  reason: toErrorMessage(err),
404
439
  });
405
440
  }