@zhushanwen/pi-base-tool-enhance 0.6.2 → 0.7.1

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 补投判据纯函数(bg-task-notify-durability 设计 §5 U1)——送达痕迹提取与补投标记
3
+ * entry 的读写形态约定。
4
+ *
5
+ * 两个纯函数族(无 I/O、无 pi 依赖,形态穷举单测覆盖):
6
+ * ① collectDeliveredTaskIds:从会话 entries 提取「已送达完成通知的 taskId 集合」。
7
+ * 送达痕迹 = 会话文件里的完成通知消息本身(custom_message,customType =
8
+ * background-bash)——正常投递经 pi.sendMessage 落盘的形态;fork 副本随 session
9
+ * 文件复制同形态带走,天然命中(fork 窄窗口的跨会话重复已由设计决策 5 独立限定)。
10
+ * 判定通道两条,任一命中即算:details.taskId(结构化字段)/ content 文本含
11
+ * bt- 形态 token(缺 details 的旧消息——details 载荷是后加的,历史消息只有 content)。
12
+ * ② 补投标记(幂等锚):customType = background-bash:reconciled 的 plain custom
13
+ * entry(pi.appendEntry 同步入账),data = {taskId, reconciledAt}。不进 LLM 上下文
14
+ * (与 NotifyLedger 同款通道纪律:账务走 appendEntry,送达消息走 sendMessage)。
15
+ *
16
+ * 非目标(设计否决记录防回潮):纯痕迹反查判重不成立(送达消息落盘异步于投递返回,
17
+ * 双派发的第二次对账在窗口内查不到痕迹必现双投)——补投标记是唯一能在同激活内收敛的
18
+ * 锚点,两族函数必须配合使用(见 pending-reconcile.ts 判据三条件)。
19
+ */
20
+
21
+ import { BACKGROUND_BASH_CUSTOM_TYPE } from "./notify.ts";
22
+
23
+ /**
24
+ * 补投标记 entry 的 customType 落盘字符串(形态约定 SSOT,设计决策 1)。
25
+ * plain custom entry(appendEntry 通道),与送达消息的 background-bash 通道刻意区分。
26
+ */
27
+ export const RECONCILED_MARKER_CUSTOM_TYPE = "background-bash:reconciled";
28
+
29
+ /** 补投标记 entry 的 data 落盘形态(设计决策 1:{taskId, reconciledAt})。 */
30
+ export interface ReconciledMarkerData { // oe-exempt:20260929:framework:workflow/record 协议契约类型——ports 类型契约先行、单实现常态(dev-0.10.5 已验收代码 merge 带入)
31
+ taskId: string;
32
+ /** 补投发起时刻(epoch 毫秒;观测字段,判据不消费)。 */
33
+ reconciledAt: number;
34
+ }
35
+
36
+ /** 构造补投标记 data(写侧唯一入口——落盘形态演进只改此处与 collect 读侧)。 */
37
+ export function buildReconciledMarkerData(taskId: string, reconciledAt: number): ReconciledMarkerData {
38
+ return { taskId, reconciledAt };
39
+ }
40
+
41
+ /**
42
+ * content 中 bt- 形态 taskId 的提取模式(spawn-background generateTaskId 生成规则:
43
+ * bt-<epoch ms>-<base36 固定长随机段>)。按生成形态锚定而非任意 bt- 前缀串,避免把
44
+ * 普通英文词(如 "btw-")误判为任务 id。设计原文「content 含 taskId」的宽松语义:
45
+ * 尾部输出摘要里如出现其他任务 id 也会算送达痕迹——方向保守(漏补投优于重复投)。
46
+ */
47
+ const CONTENT_TASK_ID_PATTERN = /\bbt-\d+-[0-9a-z]+\b/g;
48
+
49
+ /**
50
+ * 从会话 entries 提取已送达完成通知的 taskId 集合(判据通道①:送达痕迹)。
51
+ *
52
+ * 五形态语义(设计 §5 U1 形态清单):正常消息(details + content 双通道命中)、
53
+ * fork 副本(同形态命中)、缺 details 旧消息(仅 content 命中)、非 bash 通知
54
+ * (customType 不同,不命中)、killed 无痕迹任务(无消息 entry,天然不在集合)。
55
+ *
56
+ * 容错对齐 protocol pending-entries 扫描同款纪律:null/非对象元素跳过;content 兼容
57
+ * string 与 TextContent[] 两种落盘形态(pi appendCustomMessageEntry 接受两者)。
58
+ */
59
+ export function collectDeliveredTaskIds(entries: readonly unknown[]): Set<string> {
60
+ const ids = new Set<string>();
61
+ for (const raw of entries) {
62
+ if (!raw || typeof raw !== "object") continue;
63
+ const entry = raw as Record<string, unknown>;
64
+ if (entry.customType !== BACKGROUND_BASH_CUSTOM_TYPE) continue;
65
+ const taskId = stringField(entry.details, "taskId");
66
+ if (taskId !== undefined) ids.add(taskId);
67
+ const content = entry.content;
68
+ if (typeof content === "string") {
69
+ collectTaskIdsFromText(content, ids);
70
+ } else if (Array.isArray(content)) {
71
+ for (const block of content) {
72
+ const text = stringField(block, "text");
73
+ if (text !== undefined) collectTaskIdsFromText(text, ids);
74
+ }
75
+ }
76
+ }
77
+ return ids;
78
+ }
79
+
80
+ /**
81
+ * 从会话 entries 提取已补投(标记 entry 在)的 taskId 集合(判据通道②:补投标记)。
82
+ * data 形态防御:缺 taskId / 非字符串的脏标记 entry 跳过(不因单条脏数据报废判据)。
83
+ */
84
+ export function collectReconciledTaskIds(entries: readonly unknown[]): Set<string> {
85
+ const ids = new Set<string>();
86
+ for (const raw of entries) {
87
+ if (!raw || typeof raw !== "object") continue;
88
+ const entry = raw as Record<string, unknown>;
89
+ if (entry.customType !== RECONCILED_MARKER_CUSTOM_TYPE) continue;
90
+ const taskId = stringField(entry.data, "taskId");
91
+ if (taskId !== undefined) ids.add(taskId);
92
+ }
93
+ return ids;
94
+ }
95
+
96
+ /** unknown 值上指定键的 string 字段(非对象/缺键/非字符串一律 undefined)。 */
97
+ function stringField(value: unknown, key: string): string | undefined {
98
+ if (value === null || typeof value !== "object") return undefined;
99
+ const field = (value as Record<string, unknown>)[key];
100
+ return typeof field === "string" ? field : undefined;
101
+ }
102
+
103
+ function collectTaskIdsFromText(text: string, ids: Set<string>): void {
104
+ for (const match of text.matchAll(CONTENT_TASK_ID_PATTERN)) {
105
+ ids.add(match[0]);
106
+ }
107
+ }
@@ -179,6 +179,17 @@ function sendTaskFinishedMessage(task: BackgroundTask): void {
179
179
  }
180
180
  }
181
181
 
182
+ /**
183
+ * 通知文案函数的参数面:只依赖终态渲染所需字段。收窄为结构子集(而非完整
184
+ * BackgroundTask)使补投路径可直接传 registry 终态条目(RegistryEntry——无
185
+ * registryPath 等运行时字段),文案组装逻辑主路径与补投路径共用一份。完整
186
+ * BackgroundTask 满足该子集,既有调用方不受影响。
187
+ */
188
+ export type NotificationTaskLike = Pick<
189
+ BackgroundTask,
190
+ "taskId" | "command" | "outputFile" | "durationMs" | "exitCode" | "reason" | "tailSummary"
191
+ >;
192
+
182
193
  /**
183
194
  * 通知文案(§3.1 终态样例):
184
195
  *
@@ -189,7 +200,7 @@ function sendTaskFinishedMessage(task: BackgroundTask): void {
189
200
  * 失败任务 head 行标 failed(exit code 即 error 摘要,尾部输出佐证);timeout 标
190
201
  * timed out;tailSummary 为空(无输出/文件丢失)时省略 Last lines 行。
191
202
  */
192
- export function buildNotificationContent(task: BackgroundTask): string {
203
+ export function buildNotificationContent(task: NotificationTaskLike): string {
193
204
  const duration = formatDurationMs(task.durationMs ?? 0);
194
205
  const command = truncateCommand(task.command);
195
206
  let head: string;
@@ -241,7 +252,7 @@ export interface BackgroundBashDetails {
241
252
  * command 取原始终态值不截断:截断(PENDING_NAME_LIMIT)是 content 行宽约束,结构化
242
253
  * 消费方的行宽由渲染层自己决定(旧数据无 details 时仍走 content 原文兜底)。
243
254
  */
244
- export function buildNotifyDetails(task: BackgroundTask): BackgroundBashDetails {
255
+ export function buildNotifyDetails(task: NotificationTaskLike): BackgroundBashDetails {
245
256
  return {
246
257
  taskId: task.taskId,
247
258
  command: task.command,
@@ -251,6 +262,21 @@ export function buildNotifyDetails(task: BackgroundTask): BackgroundBashDetails
251
262
  };
252
263
  }
253
264
 
265
+ /**
266
+ * 收殓终态(exited process-exit / orphaned)的补投文案行(bg-task-notify-durability
267
+ * 设计决策 3/4)——主路径不产这类消息(process-exit 收殓不 sendMessage),仅供补投:
268
+ *
269
+ * [background-bash] bt-x was terminated when the session went down (exit code unknown): <command>. Output file: <path>; use bash_output to check the output before rerunning.
270
+ *
271
+ * 措辞统一为「结果未知、先查输出再决定是否重跑」:不区分被补杀/自行退出(orphaned
272
+ * 的成因不在 reason 枚举内,二分须补 registry 字段,与零格式变更矛盾);省略时长
273
+ * (orphaned 的 durationMs 含属主死后滞留时长,展示误导);不谎报完成/失败。
274
+ */
275
+ export function buildTerminatedNotificationLine(task: Pick<NotificationTaskLike, "taskId" | "command" | "outputFile">): string {
276
+ const command = truncateCommand(task.command);
277
+ return `[background-bash] ${task.taskId} was terminated when the session went down (exit code unknown): ${command}. Output file: ${task.outputFile}; use bash_output to check the output before rerunning.`;
278
+ }
279
+
254
280
  /** 耗时换算常量(毫秒/秒/分/时 + 时分两位补零宽度)。 */
255
281
  const MS_PER_SECOND = 1000;
256
282
  const SECONDS_PER_MINUTE = 60;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * session_start pending 对账(M3,§3.5 接入细则第 4 条——pending 收尾的统一兜底)。
2
+ * session_start pending 对账 + 完成通知补投(M3 §3.5 接入细则第 4 条 +
3
+ * bg-task-notify-durability 设计 §5 U2/U3)。
3
4
  *
4
- * 职责:对「session entries 差集显示 active、但任务已终态」的 bt- 任务补写
5
+ * 职责一(既有):对「session entries 差集显示 active、但任务已终态」的 bt- 任务补写
5
6
  * pending:unregister entry。覆盖三类 otherwise 悬空场景(设计原文):
6
7
  * ① 进程 graceful 退出收殓时 pi API 已不可用,unregister 没写成 entry;
7
8
  * ② 强杀后收殓侧只改 registry(标 orphaned),碰不了 session 文件;
@@ -16,19 +17,42 @@
16
17
  * 删除——而对账 appendEntry 同步入账先于 emit 执行,emit 到达时该 id 必已注销
17
18
  * = 恒 no-op 死路径。)
18
19
  *
20
+ * 职责二(补投,设计决策 1/2):对「registry 严格终态(非 killed)∧ 会话 entries
21
+ * 无送达痕迹 ∧ 无补投标记」的后台任务,合并成一条消息补投完成通知,resolve 后
22
+ * 同步写补投标记 entry 保幂等。覆盖的失效面 = 主路径单次投递丢失(进程死亡收殓、
23
+ * 投递 throw、投递被接受但未落盘即崩溃)。**补投是 registry 全集的独立遍历,不挂
24
+ * pending 差集循环**(决策 2):差集候选 = pending:register 还挂着的任务,而失效
25
+ * 形态「poller 边沿 unregister 已写、sendMessage 才 throw」下差集为空,挂靠即永久
26
+ * 漏补。补投**不代写 pending:unregister**——收尾保持差集循环唯一写点(避免绕过
27
+ * listener 的 isPendingActive 幂等门落冗余 entry)。
28
+ *
19
29
  * 差集判据单点(ext-simplify-13):collectActivePendingIds(protocol
20
30
  * pending-entries 模块,与 pending-notifications 守卫判据同源)+ bt- 前缀过滤;
21
31
  * 本地差集副本已删除。task_id 前缀亦消费 protocol 契约常量
22
32
  * BACKGROUND_TASK_ID_PREFIX(§2.3,区别于 subagent-workflow 的 bg-/run-)。
23
33
  *
24
- * 终态判据:registry state ∈ {exited, orphaned},或(state=running/killing 且
25
- * kill(pid,0) 判死:收殓/写盘失败遗留的 running 条目按事实终态处理)。
26
- * 不改 registry——终态写入归收殓侧(runtime reaper)/轮询器(单点归属),对账只清
27
- * pending 侧。
34
+ * 补投终态判据(决策 2):protocol isTerminalState 严格终态(exited/orphaned)——
35
+ * **显式不用下方 isTerminalByRegistry 宽判据**(宽判据含 running/killing 且 pid 判死,
36
+ * 会把 killing 遗留——kill 已发令、poller 终态化前进程死亡,实为被杀任务——误判为
37
+ * 可补投);且 state=exited 时 reason≠killed(killed 是 reason 枚举值而非独立
38
+ * state,state 级过滤排不掉,必须 reason 级显式排除——kill 的发起方当次交互已同步
39
+ * 获知结果,补投是重复刺激)。killing/running+判死遗留不补投(保守正确,退化现状)。
40
+ *
41
+ * 补投幂等三配套(决策 1,缺一被双派发反例击穿):
42
+ * ① await sendMessage——见 ReconcilePi.sendMessage 注释的实装适配登记;
43
+ * ② 进程内 in-flight 单飞守卫(模块级 Set 按 taskId 键控):同一次激活是
44
+ * startup+resume 双派发,第二路对账落在第一路补投的 await 窗口内,若不挡会
45
+ * 再调 sendMessage(此刻 agent 循环进行中,消息不落盘)且三判据仍全命中
46
+ * (标记未写、痕迹未落盘)——按 taskId 键控天然消解同进程 session 替换时旧
47
+ * 会话 in-flight 误挡新会话的窗口。跳过粒度仅补投扫描,差集循环照常执行。
48
+ * 摘除绑定 sendMessage settle(finally),不绑「激活结束」;纯内存跨激活无
49
+ * 状态,进程死亡自然清零;
50
+ * ③ resolve 后同步 appendEntry 补投标记——同步入账使后续任何对账立即收敛。
28
51
  *
29
52
  * 执行链(index.ts session_start 链内):收殓下沉 runtime 后(u-bte-remove)本链
30
- * 仅剩对账——「对账见 running+pid 活则不动作,下一 session_start 兜底」的幂等
31
- * 语义覆盖孤儿终态由 runtime 异步写入的时序窗口。
53
+ * 仅剩对账 + 补投——「对账见 running+pid 活则不动作,下一 session_start 兜底」的
54
+ * 幂等语义覆盖孤儿终态由 runtime 异步写入的时序窗口。补投先于差集收尾(先通知后
55
+ * 清观察面),差集收尾无条件执行(幂等语义不依赖补投成败,两者无隐藏耦合)。
32
56
  */
33
57
 
34
58
  import {
@@ -40,7 +64,19 @@ import { isPidAlive } from "@zhushanwen/extension-protocol/background-task";
40
64
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
41
65
  import { getLogger } from "@zhushanwen/pi-extension-logger";
42
66
 
43
- import { toPendingReason } from "./notify.ts";
67
+ import {
68
+ buildReconciledMarkerData,
69
+ collectDeliveredTaskIds,
70
+ collectReconciledTaskIds,
71
+ RECONCILED_MARKER_CUSTOM_TYPE,
72
+ } from "./notify-reconcile-judgement.ts";
73
+ import {
74
+ BACKGROUND_BASH_CUSTOM_TYPE,
75
+ buildNotificationContent,
76
+ buildNotifyDetails,
77
+ buildTerminatedNotificationLine,
78
+ toPendingReason,
79
+ } from "./notify.ts";
44
80
  import { getRegistryPath, readRegistry } from "./registry.ts";
45
81
  import { isActiveState, isTerminalState, type RegistryEntry } from "./types.ts";
46
82
 
@@ -48,9 +84,25 @@ const logger = getLogger("base-tool-enhance");
48
84
 
49
85
  /**
50
86
  * 对账依赖的最小 pi 面(结构兼容 ExtensionAPI 的子集;测试注入不造完整 pi)。
87
+ *
88
+ * sendMessage 返回类型 `void | Promise<void>` 是 pi 0.84.4 实装的适配登记
89
+ * (bg-task-notify-durability 实装核实):ExtensionAPI.sendMessage 的类型声明与
90
+ * loader.js:296 / agent-session.js:2004 两级桥接均不返回 promise(bindCore 包装层
91
+ * 以 .catch 吞 rejection 转 runner emitError),真实 pi 注入时恒返回 undefined——
92
+ * 「await resolve 即已落盘」在实装通道上拿不到。await 两者皆合法:
93
+ * - 同步 throw(assertActive 失败 / 旧 bus)在调用点抛出,可捕;
94
+ * - Promise rejection(若未来 pi 透传 / 测试 mock)可 await 捕获;
95
+ * - 实装下 await undefined 立即通过,标记写入退到「调用返回后」——落盘时序核实
96
+ * (message_end 落盘先于 LLM 循环)使残余失败域收窄为 agent 循环 start 阶段
97
+ * 失败(主形态 = 双派发穿透,由 in-flight 守卫挡在 sendMessage 之前),残余
98
+ * 由设计决策 6 止损路径包络(下次激活重试语义保持)。
51
99
  */
52
100
  export interface ReconcilePi {
53
101
  appendEntry(customType: string, data?: unknown): void;
102
+ sendMessage(
103
+ message: { customType: string; content: string; display: boolean; details?: unknown },
104
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
105
+ ): void | Promise<void>;
54
106
  }
55
107
 
56
108
  /** 对账结果(日志 + 测试断言面)。 */
@@ -59,19 +111,37 @@ export interface ReconcileResult {
59
111
  reconciled: number;
60
112
  /** 差集 active 但判据不满足(活任务 / registry 无条目)而保守跳过的 task_id。 */
61
113
  skipped: string[];
114
+ /** 补投消息已成功送达(sendMessage settle 通过,同步 throw 与 await reject 均不计入)的 task_id——实装桥接层丢 promise,不等于 pi 侧已落盘,见 ReconcilePi.sendMessage 注释。 */
115
+ redelivered: string[];
62
116
  }
63
117
 
64
118
  /**
65
- * 对账主体(同步:readRegistry / kill(pid,0) / appendEntry 均同步,session_start
66
- * 链内毫秒级完成)。每个僵尸任务 appendEntry 一次(唯一权威写路径)。
119
+ * 补投 in-flight 单飞守卫(决策 1 配套②):模块级按 taskId 键控。双派发的两路
120
+ * handler 共享同一模块实例(factory 二调形态),模块级状态对它们可见。
67
121
  */
68
- export function reconcilePendingEntries(
122
+ const inflightRedeliveries = new Set<string>();
123
+
124
+ /** 测试专用:清空 in-flight 守卫(跨测试文件残留清理)。 */
125
+ export function resetRedeliveryInFlightForTest(): void {
126
+ inflightRedeliveries.clear();
127
+ }
128
+
129
+ /**
130
+ * 对账主体(session_start 维护链入口)。补投(可能 await sendMessage)先于差集
131
+ * 收尾;差集收尾无条件执行。每个僵尸任务 appendEntry 一次(唯一权威写路径)。
132
+ */
133
+ export async function reconcilePendingEntries(
69
134
  pi: ReconcilePi,
70
135
  dataDir: string,
71
136
  sessionId: string,
72
137
  entries: unknown[],
73
- ): ReconcileResult {
74
- const result: ReconcileResult = { reconciled: 0, skipped: [] };
138
+ ): Promise<ReconcileResult> {
139
+ const result: ReconcileResult = { reconciled: 0, skipped: [], redelivered: [] };
140
+
141
+ // ① 补投扫描(独立遍历 registry 全集,先通知后清观察面)
142
+ await redeliverMissingNotifications(pi, dataDir, sessionId, entries, result);
143
+
144
+ // ② 既有 pending 差集循环(无条件执行;unregister 收尾的唯一写点)
75
145
  const unsettled = collectActivePendingIds(entries, { idPrefix: BACKGROUND_TASK_ID_PREFIX });
76
146
  if (unsettled.size === 0) return result;
77
147
 
@@ -81,7 +151,12 @@ export function reconcilePendingEntries(
81
151
  if (entry === undefined) {
82
152
  // registry 无条目:终态无从判定(LRU 淘汰的终态条目其 unregister entry 应已
83
153
  // 落盘,差集里还出现 = spawn 后 registry 写失败等罕见路径)——保守不动作,
84
- // pending-notifications 已无任何 TTL 清理,差集残留由 next-session 对账重查收口
154
+ // pending-notifications 已无任何 TTL 清理,差集残留由 next-session 对账重查收口。
155
+ // warn 是设计决策 6 的 LRU 淘汰重审触发观测落点(含 taskId;此前该形态零日志)
156
+ logger.warn(
157
+ "pending reconcile: registry has no entry for a pending bt- id (LRU-evicted or spawn-write failure); unregister stays pending",
158
+ { detail: { taskId: id } },
159
+ );
85
160
  result.skipped.push(id);
86
161
  continue;
87
162
  }
@@ -135,3 +210,136 @@ function settledPendingReason(entry: RegistryEntry): "completed" | "failed" | "t
135
210
  }
136
211
  return "cancelled";
137
212
  }
213
+
214
+ /**
215
+ * 补投扫描(决策 2:registry 全集独立遍历)。候选 = 严格终态 ∧ 非 killed ∧ 双锚
216
+ * (送达痕迹 / 补投标记)皆无 ∧ 不在 in-flight。命中者合并为一条消息投递(对齐
217
+ * NotifyLedger 同批合并先例,避免 N 个任务驱动 N 个 LLM turn 排队),resolve 后
218
+ * 逐任务同步写补投标记。任一环节失败(同步 throw / await reject / 标记写失败):
219
+ * warn 日志(含 taskId 与失败类别),不写标记,下次 session_start 重试。
220
+ */
221
+ async function redeliverMissingNotifications(
222
+ pi: ReconcilePi,
223
+ dataDir: string,
224
+ sessionId: string,
225
+ entries: unknown[],
226
+ result: ReconcileResult,
227
+ ): Promise<void> {
228
+ const registry = readRegistry(getRegistryPath(dataDir, sessionId));
229
+ if (registry.size === 0) return;
230
+
231
+ const delivered = collectDeliveredTaskIds(entries);
232
+ const reconciled = collectReconciledTaskIds(entries);
233
+
234
+ const candidates: RegistryEntry[] = [];
235
+ for (const entry of registry.values()) {
236
+ if (!isRedeliveryCandidate(entry, delivered, reconciled)) continue;
237
+ if (inflightRedeliveries.has(entry.taskId)) {
238
+ // 同激活第二路对账(双派发)落在第一路补投的 await 窗口内——跳过该任务的
239
+ // 补投(差集循环不受影响)。debug 是设计决策 6 in-flight 残留边界的观测落点
240
+ logger.debug("background task notify redelivery in-flight; skipping duplicate reconcile pass", {
241
+ detail: { taskId: entry.taskId },
242
+ });
243
+ continue;
244
+ }
245
+ candidates.push(entry);
246
+ }
247
+ if (candidates.length === 0) return;
248
+
249
+ const message = buildRedeliveryMessage(candidates);
250
+ const taskIds = candidates.map((entry) => entry.taskId);
251
+ for (const id of taskIds) inflightRedeliveries.add(id);
252
+ try {
253
+ // 决策 1 配套①:同步 throw 与 await reject 分开捕——失败类别进 warn 日志,
254
+ // 两类统一不写标记(下次激活重试;判据三条件仍命中才会再投)
255
+ let sent: void | Promise<void>;
256
+ try {
257
+ sent = pi.sendMessage(
258
+ { customType: BACKGROUND_BASH_CUSTOM_TYPE, content: message.content, display: true, ...(message.details !== undefined ? { details: message.details } : {}) },
259
+ { deliverAs: "steer", triggerTurn: true },
260
+ );
261
+ } catch (err) {
262
+ logger.warn("background task notify redelivery sendMessage threw synchronously; no marker written, retry on next session_start", {
263
+ detail: { taskIds, err: toErrorMessage(err) },
264
+ });
265
+ return;
266
+ }
267
+ try {
268
+ await sent;
269
+ } catch (err) {
270
+ logger.warn("background task notify redelivery sendMessage rejected; no marker written, retry on next session_start", {
271
+ detail: { taskIds, err: toErrorMessage(err) },
272
+ });
273
+ return;
274
+ }
275
+ // 配套③:resolve 后同步写标记(appendEntry 同步入账)——后续任何对账立即收敛。
276
+ // 逐任务独立 try/catch:单条标记写失败只跳过该任务的重试收尾(下次激活重投;
277
+ // 若消息实际已落盘则痕迹命中判重,不会重复——仅「消息真丢了」才重投,语义正确)
278
+ for (const entry of candidates) {
279
+ try {
280
+ pi.appendEntry(RECONCILED_MARKER_CUSTOM_TYPE, buildReconciledMarkerData(entry.taskId, Date.now()));
281
+ } catch (err) {
282
+ logger.warn("background task notify reconciled marker appendEntry failed; redelivery may repeat on next session_start", {
283
+ detail: { taskId: entry.taskId, err: toErrorMessage(err) },
284
+ });
285
+ }
286
+ }
287
+ result.redelivered.push(...taskIds);
288
+ } finally {
289
+ // 摘除绑定 sendMessage settle(resolve/reject/同步 throw 皆经此),非「激活结束」;
290
+ // 摘除记 debug(决策 6 in-flight 残留边界的配对观测)
291
+ for (const id of taskIds) {
292
+ inflightRedeliveries.delete(id);
293
+ logger.debug("background task notify redelivery settled; in-flight guard released", {
294
+ detail: { taskId: id },
295
+ });
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 补投候选判据(决策 1/2 的三条件 + killed reason 级排除)。判据不查 in-flight
302
+ * (守卫是执行面关注点,独立于候选语义),调用方负责。
303
+ */
304
+ function isRedeliveryCandidate(entry: RegistryEntry, delivered: Set<string>, reconciled: Set<string>): boolean {
305
+ // 严格终态:protocol isTerminalState(exited/orphaned)——不用 isTerminalByRegistry
306
+ // 宽判据(含 running/killing+pid 判死,killing 遗留实为被杀任务,误判即违背
307
+ // killed 不补投设计意图)
308
+ if (!isTerminalState(entry.state)) return false;
309
+ // killed 是 exited 的 reason 枚举值而非独立 state,state 级过滤排不掉——reason 级
310
+ // 显式排除(bash_kill 发令方当次交互已同步获知结果,补投是重复刺激)
311
+ if (entry.state === "exited" && entry.reason === "killed") return false;
312
+ // 双锚判重:送达痕迹在(正常送达/历史补投的消息在)/ 补投标记在——任一命中不再投
313
+ if (delivered.has(entry.taskId)) return false;
314
+ if (reconciled.has(entry.taskId)) return false;
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * 补投消息组装(设计 §3.1 三分支,决策 4):
320
+ * - 单任务且真实终态(exited natural/timeout):与正常投递同款结构化消息(同
321
+ * details 载荷,renderer SystemNotice 结构化渲染正常工作);
322
+ * - 单任务收殓终态(process-exit/orphaned)或多任务合并:整条无 details(details
323
+ * 载荷只描述单任务,混合/复数场景携带任一任务的 details 会产生「结构化行只讲
324
+ * 一个任务、其余沉入降级文本」的错位形态),纯文本行逐任务列终态行——exited
325
+ * 真终态行复用 buildNotificationContent 的 content 文本(禁止把 exit 0 正常完成
326
+ * 套 terminated 措辞谎报),收殓终态行用 terminated 措辞。
327
+ */
328
+ function buildRedeliveryMessage(candidates: RegistryEntry[]): { content: string; details?: unknown } {
329
+ const single = candidates.length === 1 ? candidates[0] : undefined;
330
+ if (single !== undefined && isGenuineExit(single)) {
331
+ return {
332
+ content: buildNotificationContent(single),
333
+ details: buildNotifyDetails(single),
334
+ };
335
+ }
336
+ const content = candidates
337
+ .map((entry) => (isGenuineExit(entry) ? buildNotificationContent(entry) : buildTerminatedNotificationLine(entry)))
338
+ .join("\n");
339
+ return { content };
340
+ }
341
+
342
+ /** 真实终态:exited 且成因是自然退出/超时(process-exit 是收殓终止,不算)。 */
343
+ function isGenuineExit(entry: RegistryEntry): boolean {
344
+ return entry.state === "exited" && (entry.reason === "natural" || entry.reason === "timeout");
345
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,10 @@
6
6
  * 轮询器单例任务表)、bash_output / bash_kill 工具、进程退出收殓、subagent 降级。
7
7
  * M3:pending-notifications 通知接入——load 时刷新轮询器通知通路的 pi 引用(D17
8
8
  * session 替换接管)+ 挂 exit 边沿通知回调(unregister emit + sendMessage steer)+
9
- * session_start 对账(appendEntry 权威路径兜底 pending 收尾)。
9
+ * session_start 对账(appendEntry 权威路径兜底 pending 收尾)+ 完成通知补投
10
+ * (bg-task-notify-durability:终态无痕迹无标记的任务下次激活合并补投 + 同步幂等标记)。
11
+ * 维护链双触发面:session_start 事件 + `__taiji_bg_reconcile__` 内部命令(taiji runtime
12
+ * 在 getCommands 时按节流补触发,覆盖「切走切回同进程重挂接、session_start 不重发」场景)。
10
13
  * 收殓下沉(u-bte-remove):M5 孤儿收殓已移交 taiji runtime(background-task-
11
14
  * reaper 双触发面——session 销毁时 + 启动期兜底扫描,设计 file-lock-unification-
12
15
  * and-reaper-sink.md §3.2 D2),extension 不再做全局扫描/全局锁;session_start
@@ -16,6 +19,7 @@
16
19
 
17
20
  import type {
18
21
  ExtensionAPI,
22
+ ExtensionCommandContext,
19
23
  ExtensionContext,
20
24
  SessionStartEvent,
21
25
  } from "@earendil-works/pi-coding-agent";
@@ -52,23 +56,46 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
52
56
  // unified-hooks 退役承接:工具报错审计(D11 落点)
53
57
  setupToolErrorAudit(pi);
54
58
  // pending 对账(M3):任意 session 启动触发(startup/reload/new/resume/fork 全
55
- // reason)。孤儿收殓已下沉 runtime(见文件头),本链无全局扫描
59
+ // reason)。孤儿收殓已下沉 runtime(见文件头),本链无全局扫描。
60
+ // handler 返回维护链 promise(补投 await sendMessage 使对账 async 化,
61
+ // bg-task-notify-durability U3):pi runner emit await handler(runner.js:632),
62
+ // promise 由其消费不产生 unhandled rejection;错误在维护链内 async 捕获
56
63
  pi.on("session_start", (event: SessionStartEvent, ctx: ExtensionContext) => {
57
- runSessionStartMaintenance(pi, ctx, event.reason);
64
+ return runSessionStartMaintenance(pi, ctx, event.reason);
65
+ });
66
+ // 第二触发面(bg-task-notify-durability 补强):桌面「切走会话再切回」是同进程重新
67
+ // 挂接——pi 进程存活、不重发 session_start(真机实证),挂在 session_start 上的
68
+ // 维护链在「投递失败但进程存活」场景(设计 G2 核心场景)永不触发。taiji runtime 在
69
+ // getCommands(切回后 renderer 主动拉取命令的必经查询)时经 client.prompt 发起本
70
+ // 命令(60s per-session 节流、fire-and-forget),handler 调同一维护函数(reason 传
71
+ // 'resume' 语义)。`/__` 双下划线前缀 = 内部命令(前端 internal-command-filter 过滤
72
+ // 不显示);无待补任务时维护链三判据早退,零输出零 turn。
73
+ pi.registerCommand("__taiji_bg_reconcile__", {
74
+ description:
75
+ "Internal: rerun pending background-task reconcile + completion-notify redelivery (triggered by taiji runtime on session resume; session_start does not refire for same-process reattach)",
76
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
77
+ return runSessionStartMaintenance(pi, ctx, "resume");
78
+ },
58
79
  });
59
80
  }
60
81
 
61
82
  /**
62
- * session_start 维护链(仅剩 M3 对账——收殓下沉 runtime 后,u-bte-remove)。
83
+ * session_start 维护链(M3 对账 + 补投——收殓下沉 runtime 后,u-bte-remove)。
84
+ *
85
+ * 双入口共用本函数:① session_start 事件(reason 透传);② `__taiji_bg_reconcile__`
86
+ * 内部命令(runtime 切回触发,reason 恒 'resume')。维护逻辑与幂等性对两入口完全一致。
63
87
  *
64
- * 执行形态:同步直跑不 await——对账毫秒级(readRegistry + kill(pid,0) +
65
- * appendEntry),不构成 session 启动链延迟;错误吞掉记 warn(对账失败无害:僵尸
66
- * register 停留差集,下一 session_start 幂等重查)。
88
+ * 执行形态:async(补投 await sendMessage,bg-task-notify-durability 设计决策 1
89
+ * 规格①;实装下 pi.sendMessage 恒同步返回,见 pending-reconcile.ts ReconcilePi
90
+ * 注释的适配登记)。错误吞掉记 warn(对账/补投失败无害:僵尸 register 停留差集、
91
+ * 待补任务三判据仍命中,下一 session_start 幂等重查)——async 捕获防 unhandled
92
+ * rejection 复刻已否决的 fire-and-forget 失效形态。
67
93
  *
68
94
  * 频率语义(D3 守卫粒度):对账是 session 级操作(读当前 session 的 pi entries +
69
95
  * 当前 session 的 registry,appendEntry 幂等),属豁免类不挂进程级 once flag,
70
96
  * 每 session_start 都执行。桌面端每次激活是 startup+resume 双派发(factory 二调
71
- * 下 handler 还会累积),对账多次执行幂等无害;反之若挂进程级 flag,startup 消费
97
+ * 下 handler 还会累积),对账多次执行幂等无害(补投的重复穿透由 pending-reconcile
98
+ * 的 in-flight 单飞守卫 + 同步补投标记收敛);反之若挂进程级 flag,startup 消费
72
99
  * flag 后目标 session 的对账将永远被跳过(M3 对账在主链路被禁用,此处是唯一执行点)。
73
100
  *
74
101
  * 入口无条件 debug 日志(S6 观测通道):每次 handler 派发都打,含 reason——
@@ -77,17 +104,17 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
77
104
  * 不再含全局扫描/全局锁,原 reapSkipped 字段随 reap 调用一并移除——对应 S6
78
105
  * 场景「批 2 后 reap 类操作不再执行」。
79
106
  */
80
- function runSessionStartMaintenance(
107
+ async function runSessionStartMaintenance(
81
108
  pi: ExtensionAPI,
82
109
  ctx: ExtensionContext,
83
110
  reason: SessionStartEvent["reason"],
84
- ): void {
111
+ ): Promise<void> {
85
112
  logger.debug("session_start maintenance dispatch", {
86
113
  detail: { reason },
87
114
  });
88
115
  try {
89
116
  const sessionId = ctx.sessionManager.getSessionId();
90
- reconcilePendingEntries(pi, getAgentDir(), sessionId, ctx.sessionManager.getEntries());
117
+ await reconcilePendingEntries(pi, getAgentDir(), sessionId, ctx.sessionManager.getEntries());
91
118
  } catch (err) {
92
119
  // 对账失败无害:僵尸 register 停留差集,下一 session_start 幂等重查
93
120
  logger.warn("session_start pending reconcile failed; zombies retried next session start", {