@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.
@@ -1,6 +1,10 @@
1
- // src/__tests__/pending-reconcile.test.ts —— M3 session_start 对账单元(§3.5 接入细则 4):
2
- // bt- 差集(protocol collectActivePendingIds 单点消费)/ 三类僵尸场景 appendEntry
3
- // 权威路径(唯一写路径,无 emit)/ 活任务与缺条目保守跳过
1
+ // src/__tests__/pending-reconcile.test.ts —— M3 session_start 对账单元(§3.5 接入细则 4)
2
+ // + 完成通知补投执行器(bg-task-notify-durability 设计 §5 U2/U3):
3
+ // - bt- 差集(protocol collectActivePendingIds 单点消费)/ 三类僵尸场景 appendEntry
4
+ // 权威路径(唯一写路径,无 emit)/ 活任务与缺条目保守跳过
5
+ // - 补投:严格终态(isTerminalState,killed reason 级排除)× 投递成败 × 单/多任务
6
+ // 文案矩阵、双锚判重、双派发幂等(in-flight 单飞守卫 + 同步补投标记)、失败路径
7
+ // 不写标记下次激活重试
4
8
  import { spawnSync } from "node:child_process";
5
9
  import { mkdtempSync, rmSync } from "node:fs";
6
10
  import { tmpdir } from "node:os";
@@ -13,21 +17,37 @@ import {
13
17
  } from "@zhushanwen/extension-protocol";
14
18
  import { afterEach, describe, expect, it, vi } from "vitest";
15
19
 
20
+ const { loggerMock } = vi.hoisted(() => ({
21
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
+ }));
23
+
24
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
25
+ getLogger: () => loggerMock,
26
+ }));
27
+
16
28
  import {
17
29
  reconcilePendingEntries,
30
+ resetRedeliveryInFlightForTest,
18
31
  type ReconcilePi,
19
32
  } from "../background/pending-reconcile.ts";
33
+ import { BACKGROUND_BASH_CUSTOM_TYPE } from "../background/notify.ts";
34
+ import { RECONCILED_MARKER_CUSTOM_TYPE } from "../background/notify-reconcile-judgement.ts";
20
35
  import { getRegistryPath, writeRegistryEntry } from "../background/registry.ts";
21
36
  import type { RegistryEntry } from "../background/types.ts";
22
37
 
23
38
  const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-reconcile-"));
24
39
  const SESSION_ID = "sess-reconcile";
25
40
 
26
- function createMockPi(overrides: Partial<Pick<ReconcilePi, "appendEntry">> = {}): ReconcilePi & {
41
+ type SendMessageMessage = { customType: string; content: string; display: boolean; details?: unknown };
42
+
43
+ function createMockPi(
44
+ overrides: Partial<Pick<ReconcilePi, "appendEntry" | "sendMessage">> = {},
45
+ ): ReconcilePi & {
27
46
  events: { emit: ReturnType<typeof vi.fn> };
28
47
  } {
29
48
  return {
30
49
  appendEntry: vi.fn(),
50
+ sendMessage: vi.fn<[], void | Promise<void>>(),
31
51
  // emit 路径已随 ext-simplify-13 删除(恒 no-op 死路径):events spy 仅用于
32
52
  // 断言实现不再触达 bus emit
33
53
  events: { emit: vi.fn() },
@@ -60,9 +80,25 @@ function registerEntry(id: string) {
60
80
  return { customType: "pending:register", data: { id, type: "bash", name: "sleep 3600" } };
61
81
  }
62
82
 
83
+ /** 送达消息 entry(custom_message 落盘形态,供双锚判重的「痕迹在」用例)。 */
84
+ function deliveredEntry(taskId: string) {
85
+ return {
86
+ type: "custom_message",
87
+ customType: BACKGROUND_BASH_CUSTOM_TYPE,
88
+ content: `[background-bash] ${taskId} finished (exit 0, 5s): pnpm test`,
89
+ details: { taskId },
90
+ display: true,
91
+ };
92
+ }
93
+
94
+ function reconciledMarkerEntry(taskId: string) {
95
+ return { type: "custom", customType: RECONCILED_MARKER_CUSTOM_TYPE, data: { taskId, reconciledAt: 1 } };
96
+ }
97
+
63
98
  afterEach(() => {
64
99
  rmSync(DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
65
- vi.restoreAllMocks();
100
+ resetRedeliveryInFlightForTest();
101
+ vi.clearAllMocks();
66
102
  });
67
103
 
68
104
  describe("bt- diff via protocol collectActivePendingIds (single-source diff core)", () => {
@@ -118,7 +154,7 @@ describe("bt- diff via protocol collectActivePendingIds (single-source diff core
118
154
  });
119
155
 
120
156
  describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry never written)", () => {
121
- it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape", () => {
157
+ it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape", async () => {
122
158
  const entry = makeRegistryEntry({
123
159
  taskId: "bt-1700000000-zomb01",
124
160
  state: "exited",
@@ -128,7 +164,7 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
128
164
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
129
165
  const pi = createMockPi();
130
166
 
131
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
167
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
132
168
 
133
169
  expect(result.reconciled).toBe(1);
134
170
  // 落盘形态逐字段对齐 pending-notifications index.ts unregister listener:{id, reason, status}
@@ -139,7 +175,7 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
139
175
  });
140
176
  });
141
177
 
142
- it("maps exited reason/exitCode through the same mapping as the exit edge", () => {
178
+ it("maps exited reason/exitCode through the same mapping as the exit edge", async () => {
143
179
  const cases: Array<[RegistryEntry, string]> = [
144
180
  [makeRegistryEntry({ state: "exited", reason: "natural", exitCode: 3 }), "failed"],
145
181
  [makeRegistryEntry({ state: "exited", reason: "timeout", exitCode: null }), "time_limited"],
@@ -149,7 +185,7 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
149
185
  for (const [entry, expectedReason] of cases) {
150
186
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
151
187
  const pi = createMockPi();
152
- reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
188
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
153
189
  // status 断言按「权威映射(reason)」而非 reason 本身表述(D10:identity 假设消除)
154
190
  expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
155
191
  id: entry.taskId,
@@ -188,12 +224,12 @@ describe("entry status maps via protocol mapReasonToStatus (D10 single-source, i
188
224
  });
189
225
 
190
226
  describe("reconcile scenario ②: collector-only orphan (registry orphaned, session file untouched)", () => {
191
- it("appends unregister with cancelled for orphaned entries", () => {
227
+ it("appends unregister with cancelled for orphaned entries", async () => {
192
228
  const entry = makeRegistryEntry({ state: "orphaned" });
193
229
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
194
230
  const pi = createMockPi();
195
231
 
196
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
232
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
197
233
 
198
234
  expect(result.reconciled).toBe(1);
199
235
  expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
@@ -205,12 +241,12 @@ describe("reconcile scenario ②: collector-only orphan (registry orphaned, sess
205
241
  });
206
242
 
207
243
  describe("reconcile scenario ③: running entry whose pid is already dead (fact-terminal)", () => {
208
- it("appends unregister with cancelled when kill(pid,0) says dead", () => {
244
+ it("appends unregister with cancelled when kill(pid,0) says dead", async () => {
209
245
  const entry = makeRegistryEntry({ state: "running", pid: deadPid() });
210
246
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
211
247
  const pi = createMockPi();
212
248
 
213
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
249
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
214
250
 
215
251
  expect(result.reconciled).toBe(1);
216
252
  expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
@@ -222,29 +258,34 @@ describe("reconcile scenario ③: running entry whose pid is already dead (fact-
222
258
  });
223
259
 
224
260
  describe("conservative no-op paths", () => {
225
- it("running entry with LIVE pid is not settled (D12 task survives session replacement)", () => {
261
+ it("running entry with LIVE pid is not settled (D12 task survives session replacement)", async () => {
226
262
  const entry = makeRegistryEntry({ state: "running", pid: process.pid }); // 当前测试进程 = 活 pid
227
263
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
228
264
  const pi = createMockPi();
229
265
 
230
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
266
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
231
267
 
232
268
  expect(result.reconciled).toBe(0);
233
269
  expect(result.skipped).toEqual([entry.taskId]);
234
270
  expect(pi.appendEntry).not.toHaveBeenCalled();
235
271
  });
236
272
 
237
- it("registry has no entry for the id → skip (terminal state unverifiable)", () => {
273
+ it("registry has no entry for the id → skip (terminal state unverifiable) + warn (decision-6 LRU observation)", async () => {
238
274
  const pi = createMockPi();
239
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry("bt-unknown")]);
275
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry("bt-unknown")]);
240
276
  expect(result.reconciled).toBe(0);
241
277
  expect(result.skipped).toEqual(["bt-unknown"]);
242
278
  expect(pi.appendEntry).not.toHaveBeenCalled();
279
+ // 决策 6:LRU 淘汰重审触发的观测落点——warn 含 taskId(此前该形态零日志)
280
+ expect(loggerMock.warn).toHaveBeenCalledWith(
281
+ "pending reconcile: registry has no entry for a pending bt- id (LRU-evicted or spawn-write failure); unregister stays pending",
282
+ { detail: { taskId: "bt-unknown" } },
283
+ );
243
284
  });
244
285
 
245
- it("entries with no bt- register → early no-op", () => {
286
+ it("entries with no bt- register → early no-op", async () => {
246
287
  const pi = createMockPi();
247
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
288
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
248
289
  { customType: "pending:register", data: { id: "bg-1", type: "subagent" } },
249
290
  { customType: "user" },
250
291
  ]);
@@ -253,26 +294,33 @@ describe("conservative no-op paths", () => {
253
294
  expect(pi.appendEntry).not.toHaveBeenCalled();
254
295
  });
255
296
 
256
- it("unsettled set empty (unregister already present) → no-op", () => {
297
+ it("unsettled set empty (unregister already present) → no unregister write", async () => {
257
298
  const entry = makeRegistryEntry({ state: "orphaned" });
258
299
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
259
300
  const pi = createMockPi();
260
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
301
+ // 送达消息随 entries 在(正常投递形态:unregister 由 exit 边沿写、消息已送达)——
302
+ // 补投被痕迹锚阻断;本用例锁定「差集空 → 无 unregister 写点」的既有语义
303
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
261
304
  registerEntry(entry.taskId),
262
305
  { customType: "pending:unregister", data: { id: entry.taskId, reason: "cancelled" } },
306
+ deliveredEntry(entry.taskId),
263
307
  ]);
264
308
  expect(result.reconciled).toBe(0);
265
- expect(pi.appendEntry).not.toHaveBeenCalled();
309
+ expect(pi.appendEntry).not.toHaveBeenCalledWith("pending:unregister", expect.anything());
266
310
  });
267
311
  });
268
312
 
269
313
  describe("best-effort emit removed (ext-simplify-13 D5: appendEntry is the sole authority)", () => {
270
- it("reconcile never touches pi.events (the emit path is deleted, not just disabled)", () => {
314
+ it("reconcile never touches pi.events (the emit path is deleted, not just disabled)", async () => {
271
315
  const entry = makeRegistryEntry({ state: "orphaned" });
272
316
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
273
317
  const pi = createMockPi();
274
318
 
275
- reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
319
+ // 送达消息在(痕迹锚阻断补投):appendEntry 恰 1 次 = 差集收尾的 unregister
320
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
321
+ registerEntry(entry.taskId),
322
+ deliveredEntry(entry.taskId),
323
+ ]);
276
324
 
277
325
  expect(pi.appendEntry).toHaveBeenCalledTimes(1);
278
326
  expect(pi.events.emit).not.toHaveBeenCalled();
@@ -280,23 +328,414 @@ describe("best-effort emit removed (ext-simplify-13 D5: appendEntry is the sole
280
328
  });
281
329
 
282
330
  describe("appendEntry failure tolerance", () => {
283
- it("one appendEntry throw does not block the remaining zombie (count only successful)", () => {
331
+ it("one appendEntry throw does not block the remaining zombie (count only successful)", async () => {
284
332
  const first = makeRegistryEntry({ taskId: "bt-1700000000-zomb01", state: "orphaned" });
285
333
  const second = makeRegistryEntry({ taskId: "bt-1700000000-zomb02", state: "orphaned" });
286
334
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), first);
287
335
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), second);
288
336
  const appendEntry = vi.fn((customType: string, data?: unknown) => {
289
- if ((data as { id: string }).id === first.taskId) throw new Error("append failed");
337
+ if (customType === "pending:unregister" && (data as { id: string }).id === first.taskId) {
338
+ throw new Error("append failed");
339
+ }
290
340
  });
291
341
  const pi = createMockPi({ appendEntry });
292
342
 
293
- const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
343
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
294
344
  registerEntry(first.taskId),
295
345
  registerEntry(second.taskId),
296
346
  ]);
297
347
 
298
- expect(appendEntry).toHaveBeenCalledTimes(2);
299
- expect(result.reconciled).toBe(1); // 仅第二条成功计数
348
+ expect(result.reconciled).toBe(1); // 仅 unregister 第二条成功计数
300
349
  expect(result.skipped).toEqual([]);
301
350
  });
302
351
  });
352
+
353
+ // ============================================================================
354
+ // 补投执行器(bg-task-notify-durability 设计 §5 U2,决策 1-4)
355
+ // ============================================================================
356
+
357
+ /** 终态真终态条目(exit 0 natural——补投矩阵的「真实完成」代表)。 */
358
+ function genuineExitEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
359
+ return makeRegistryEntry({
360
+ taskId: "bt-1700000000-genu01",
361
+ state: "exited",
362
+ reason: "natural",
363
+ exitCode: 0,
364
+ durationMs: 192_000,
365
+ tailSummary: "Tests: 42 passed",
366
+ ...overrides,
367
+ });
368
+ }
369
+
370
+ function lastSendMessageCall(pi: ReturnType<typeof createMockPi>): SendMessageMessage {
371
+ expect(pi.sendMessage).toHaveBeenCalled();
372
+ return (pi.sendMessage as ReturnType<typeof vi.fn>).mock.lastCall[0] as SendMessageMessage;
373
+ }
374
+
375
+ describe("redelivery: single-task message matrix (decision 4 three branches)", () => {
376
+ it("single genuine exit (natural): structured message identical to main path, details carried", async () => {
377
+ const entry = genuineExitEntry();
378
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
379
+ const pi = createMockPi();
380
+
381
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
382
+
383
+ expect(result.redelivered).toEqual([entry.taskId]);
384
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
385
+ const message = lastSendMessageCall(pi);
386
+ expect(message.customType).toBe(BACKGROUND_BASH_CUSTOM_TYPE);
387
+ expect(message.content).toContain(`${entry.taskId} finished (exit 0, 3m12s)`);
388
+ expect(message.content).toContain("Last lines: Tests: 42 passed");
389
+ expect(message.content).toContain(`use bash_output {task_id:"${entry.taskId}"}`);
390
+ // 结构化载荷与主路径 buildNotifyDetails 同源(SystemNotice 渲染正常)
391
+ expect(message.details).toEqual({
392
+ taskId: entry.taskId,
393
+ command: entry.command,
394
+ durationMs: 192_000,
395
+ endReason: "natural",
396
+ exitCode: 0,
397
+ });
398
+ });
399
+
400
+ it("single genuine exit (timeout): details.endReason = timeout", async () => {
401
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-time01", reason: "timeout", exitCode: null, tailSummary: undefined });
402
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
403
+ const pi = createMockPi();
404
+
405
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
406
+
407
+ const message = lastSendMessageCall(pi);
408
+ expect(message.content).toContain("timed out");
409
+ expect(message.details).toMatchObject({ taskId: entry.taskId, endReason: "timeout", exitCode: null });
410
+ });
411
+
412
+ it("single orphaned: terminated wording, no details, no duration (decision 3)", async () => {
413
+ const entry = makeRegistryEntry({ taskId: "bt-1700000000-orph01", state: "orphaned" });
414
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
415
+ const pi = createMockPi();
416
+
417
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
418
+
419
+ const message = lastSendMessageCall(pi);
420
+ expect(message.content).toContain(`${entry.taskId} was terminated when the session went down (exit code unknown)`);
421
+ expect(message.content).toContain(`Output file: ${entry.outputFile}`);
422
+ expect(message.content).toContain("use bash_output to check the output before rerunning");
423
+ expect(message.details).toBeUndefined();
424
+ // 省略时长:orphaned 的 durationMs 含属主死后滞留时长,展示误导
425
+ expect(message.content).not.toMatch(/\d+[smh]/);
426
+ });
427
+
428
+ it("single exited process-exit: same terminated wording (not failed/exit unknown)", async () => {
429
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-pexit1", reason: "process-exit", exitCode: null, tailSummary: undefined });
430
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
431
+ const pi = createMockPi();
432
+
433
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
434
+
435
+ const message = lastSendMessageCall(pi);
436
+ expect(message.content).toContain("was terminated when the session went down");
437
+ expect(message.details).toBeUndefined();
438
+ // 不谎报:收殓终止不渲染成主路径的 failed/finished 形态
439
+ expect(message.content).not.toContain("failed (exit");
440
+ expect(message.content).not.toContain("finished (exit");
441
+ });
442
+ });
443
+
444
+ describe("redelivery: multi-task merge (decision 4 merge branch)", () => {
445
+ it("multiple tasks merge into ONE message, no details, genuine-exit rows keep main-path wording", async () => {
446
+ const natural = genuineExitEntry();
447
+ const timeout = genuineExitEntry({
448
+ taskId: "bt-1700000000-time02",
449
+ reason: "timeout",
450
+ exitCode: null,
451
+ tailSummary: undefined,
452
+ });
453
+ const orphaned = makeRegistryEntry({ taskId: "bt-1700000000-orph02", state: "orphaned" });
454
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), natural);
455
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), timeout);
456
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), orphaned);
457
+ const pi = createMockPi();
458
+
459
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
460
+
461
+ // 合并单条:不是 N 个任务 N 条消息 N 个 turn
462
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
463
+ expect(result.redelivered).toEqual([natural.taskId, timeout.taskId, orphaned.taskId]);
464
+ const message = lastSendMessageCall(pi);
465
+ expect(message.details).toBeUndefined();
466
+ // 真终态行复用 buildNotificationContent 的 content 文本(禁止套 terminated 措辞谎报)
467
+ expect(message.content).toContain(`${natural.taskId} finished (exit 0, 3m12s)`);
468
+ expect(message.content).toContain(`${timeout.taskId} timed out`);
469
+ expect(message.content).not.toContain(`${natural.taskId} was terminated`);
470
+ expect(message.content).not.toContain(`${timeout.taskId} was terminated`);
471
+ // 收殓终态行用 terminated 措辞
472
+ expect(message.content).toContain(`${orphaned.taskId} was terminated when the session went down`);
473
+ });
474
+ });
475
+
476
+ describe("redelivery: success settles the reconciled marker (decision 1 anchor ③)", () => {
477
+ it("sendMessage resolve → one marker entry appended per task, synchronously", async () => {
478
+ const first = genuineExitEntry();
479
+ const second = makeRegistryEntry({ taskId: "bt-1700000000-orph03", state: "orphaned" });
480
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), first);
481
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), second);
482
+ const pi = createMockPi();
483
+
484
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
485
+
486
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
487
+ expect(pi.appendEntry).toHaveBeenCalledWith(RECONCILED_MARKER_CUSTOM_TYPE, {
488
+ taskId: first.taskId,
489
+ reconciledAt: expect.any(Number),
490
+ });
491
+ expect(pi.appendEntry).toHaveBeenCalledWith(RECONCILED_MARKER_CUSTOM_TYPE, {
492
+ taskId: second.taskId,
493
+ reconciledAt: expect.any(Number),
494
+ });
495
+ // 补投绝不代写 pending:unregister(决策 2:收尾归差集循环唯一写点)
496
+ expect(pi.appendEntry).not.toHaveBeenCalledWith("pending:unregister", expect.anything());
497
+ });
498
+
499
+ it("one marker appendEntry throw does not block the other tasks' markers", async () => {
500
+ const first = genuineExitEntry();
501
+ const second = makeRegistryEntry({ taskId: "bt-1700000000-orph04", state: "orphaned" });
502
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), first);
503
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), second);
504
+ const appendEntry = vi.fn((customType: string, data?: unknown) => {
505
+ if (customType === RECONCILED_MARKER_CUSTOM_TYPE && (data as { taskId: string }).taskId === first.taskId) {
506
+ throw new Error("marker append failed");
507
+ }
508
+ });
509
+ const pi = createMockPi({ appendEntry });
510
+
511
+ await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
512
+
513
+ expect(appendEntry).toHaveBeenCalledWith(RECONCILED_MARKER_CUSTOM_TYPE, {
514
+ taskId: second.taskId,
515
+ reconciledAt: expect.any(Number),
516
+ });
517
+ // 失败可观察(含 taskId 与类别)
518
+ expect(loggerMock.warn).toHaveBeenCalledWith(
519
+ "background task notify reconciled marker appendEntry failed; redelivery may repeat on next session_start",
520
+ { detail: { taskId: first.taskId, err: "marker append failed" } },
521
+ );
522
+ });
523
+ });
524
+
525
+ describe("redelivery: failure paths write NO marker (retry on next session_start)", () => {
526
+ it("synchronous sendMessage throw → warn with category, no marker", async () => {
527
+ const entry = genuineExitEntry();
528
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
529
+ const sendMessage = vi.fn<[], void | Promise<void>>(() => {
530
+ throw new Error("stale bus");
531
+ });
532
+ const pi = createMockPi({ sendMessage });
533
+
534
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
535
+
536
+ expect(result.redelivered).toEqual([]);
537
+ expect(pi.appendEntry).not.toHaveBeenCalled();
538
+ expect(loggerMock.warn).toHaveBeenCalledWith(
539
+ "background task notify redelivery sendMessage threw synchronously; no marker written, retry on next session_start",
540
+ { detail: { taskIds: [entry.taskId], err: "stale bus" } },
541
+ );
542
+ });
543
+
544
+ it("await sendMessage rejection → warn with category, no marker", async () => {
545
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-rej001" });
546
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
547
+ const sendMessage = vi.fn<[], void | Promise<void>>(() => Promise.reject(new Error("agent loop failed")));
548
+ const pi = createMockPi({ sendMessage });
549
+
550
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
551
+
552
+ expect(result.redelivered).toEqual([]);
553
+ expect(pi.appendEntry).not.toHaveBeenCalled();
554
+ expect(loggerMock.warn).toHaveBeenCalledWith(
555
+ "background task notify redelivery sendMessage rejected; no marker written, retry on next session_start",
556
+ { detail: { taskIds: [entry.taskId], err: "agent loop failed" } },
557
+ );
558
+ });
559
+
560
+ it("redelivery failure does not block the pending diff pass (unconditional diff settle)", async () => {
561
+ const entry = genuineExitEntry();
562
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
563
+ const sendMessage = vi.fn<[], void | Promise<void>>(() => {
564
+ throw new Error("stale bus");
565
+ });
566
+ const pi = createMockPi({ sendMessage });
567
+
568
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
569
+
570
+ // 差集收尾无条件执行(幂等语义不依赖补投成败)
571
+ expect(result.reconciled).toBe(1);
572
+ expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", expect.anything());
573
+ });
574
+ });
575
+
576
+ describe("redelivery: exclusion criteria (decision 2 strict terminal + killed reason-level)", () => {
577
+ it("exited reason=killed is NOT redelivered (state-level filter cannot exclude it)", async () => {
578
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-kill01", reason: "killed", exitCode: null, tailSummary: undefined });
579
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
580
+ const pi = createMockPi();
581
+
582
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
583
+
584
+ expect(pi.sendMessage).not.toHaveBeenCalled();
585
+ expect(result.redelivered).toEqual([]);
586
+ });
587
+
588
+ it("killing leftover with dead pid is NOT redelivered (wide isTerminalByRegistry would; strict state filter refuses)", async () => {
589
+ const entry = makeRegistryEntry({ taskId: "bt-1700000000-killg01", state: "killing", pid: deadPid() });
590
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
591
+ const pi = createMockPi();
592
+
593
+ // 差集侧:宽判据把 killing+判死当事实终态收尾 unregister(既有语义保持)
594
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
595
+ expect(result.reconciled).toBe(1);
596
+ // 补投侧:killing 遗留实为被杀任务,不补投
597
+ expect(pi.sendMessage).not.toHaveBeenCalled();
598
+ });
599
+
600
+ it("running leftover with dead pid is NOT redelivered (poller write-failure domain, unregister covers)", async () => {
601
+ const entry = makeRegistryEntry({ taskId: "bt-1700000000-rund01", state: "running", pid: deadPid() });
602
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
603
+ const pi = createMockPi();
604
+
605
+ // 差集侧:宽判据把 running+判死当事实终态收尾 unregister(既有语义保持)
606
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
607
+ expect(result.reconciled).toBe(1);
608
+ // 补投侧:严格终态过滤排除(退化现状行为,由 unregister 收尾兜底)
609
+ expect(pi.sendMessage).not.toHaveBeenCalled();
610
+ });
611
+
612
+ it("delivered trace present (normal delivery message in entries) → no redelivery", async () => {
613
+ const entry = genuineExitEntry();
614
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
615
+ const pi = createMockPi();
616
+
617
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [deliveredEntry(entry.taskId)]);
618
+
619
+ expect(pi.sendMessage).not.toHaveBeenCalled();
620
+ expect(result.redelivered).toEqual([]);
621
+ });
622
+
623
+ it("reconciled marker present → no redelivery (cross-activation convergence)", async () => {
624
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-mark01" });
625
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
626
+ const pi = createMockPi();
627
+
628
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [reconciledMarkerEntry(entry.taskId)]);
629
+
630
+ expect(pi.sendMessage).not.toHaveBeenCalled();
631
+ expect(result.redelivered).toEqual([]);
632
+ });
633
+
634
+ it("non-terminal-looking registry only: running with live pid and no pending diff → nothing at all", async () => {
635
+ const entry = makeRegistryEntry({ taskId: "bt-1700000000-live01", state: "running", pid: process.pid });
636
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
637
+ const pi = createMockPi();
638
+
639
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, []);
640
+
641
+ expect(pi.sendMessage).not.toHaveBeenCalled();
642
+ expect(result.reconciled).toBe(0);
643
+ });
644
+ });
645
+
646
+ describe("redelivery: double-dispatch idempotence (design P5, decision 1 guards)", () => {
647
+ it("second pass after settled first pass sees the marker and skips (cross-pass convergence)", async () => {
648
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-dbl001" });
649
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
650
+
651
+ const firstPi = createMockPi();
652
+ await reconcilePendingEntries(firstPi, DATA_DIR, SESSION_ID, []);
653
+ expect(firstPi.sendMessage).toHaveBeenCalledTimes(1);
654
+
655
+ // 双派发第二路(同激活毫秒级到达)——appendEntry 同步入账(pi dist 实证),第二路
656
+ // 读到的 entries 已含第一路落的标记,三判据不再全命中
657
+ const secondPi = createMockPi();
658
+ await reconcilePendingEntries(secondPi, DATA_DIR, SESSION_ID, [reconciledMarkerEntry(entry.taskId)]);
659
+ expect(secondPi.sendMessage).not.toHaveBeenCalled();
660
+ expect(secondPi.appendEntry).not.toHaveBeenCalled();
661
+ });
662
+
663
+ it("second pass during first pass's await window sees in-flight and skips; diff pass unaffected", async () => {
664
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-infl01" });
665
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
666
+
667
+ // 未 resolve 的 sendMessage mock 驱动 in-flight 分支:第一路停在 await 窗口内
668
+ let release!: () => void;
669
+ const gate = new Promise<void>((resolve) => {
670
+ release = resolve;
671
+ });
672
+ const sendMessage = vi.fn<[], void | Promise<void>>(() => gate);
673
+ const firstPi = createMockPi({ sendMessage });
674
+
675
+ const first = reconcilePendingEntries(firstPi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
676
+ expect(sendMessage).toHaveBeenCalledTimes(1); // 同步段已到投递点并挂起
677
+
678
+ // 双派发第二路:in-flight 键控命中 → 补投跳过;差集循环照常收尾(跳过粒度仅补投扫描)
679
+ const secondPi = createMockPi();
680
+ await reconcilePendingEntries(secondPi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
681
+ expect(sendMessage).toHaveBeenCalledTimes(1);
682
+ expect(secondPi.appendEntry).toHaveBeenCalledWith("pending:unregister", expect.anything());
683
+ // 第二路未写任何补投标记
684
+ expect(secondPi.appendEntry).not.toHaveBeenCalledWith(RECONCILED_MARKER_CUSTOM_TYPE, expect.anything());
685
+ // 跳过记 debug(决策 6 in-flight 残留边界观测落点)
686
+ expect(loggerMock.debug).toHaveBeenCalledWith(
687
+ "background task notify redelivery in-flight; skipping duplicate reconcile pass",
688
+ { detail: { taskId: entry.taskId } },
689
+ );
690
+
691
+ // 第一路 settle:resolve 后同步写标记 + finally 摘除
692
+ release();
693
+ await first;
694
+ expect(firstPi.appendEntry).toHaveBeenCalledWith(RECONCILED_MARKER_CUSTOM_TYPE, {
695
+ taskId: entry.taskId,
696
+ reconciledAt: expect.any(Number),
697
+ });
698
+ expect(loggerMock.debug).toHaveBeenCalledWith(
699
+ "background task notify redelivery settled; in-flight guard released",
700
+ { detail: { taskId: entry.taskId } },
701
+ );
702
+ });
703
+
704
+ it("in-flight guard is released after failure too (settle via finally, retry possible next pass)", async () => {
705
+ const entry = genuineExitEntry({ taskId: "bt-1700000000-fl001" });
706
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
707
+
708
+ let rejectSent!: (err: Error) => void;
709
+ const gate = new Promise<void>((_resolve, reject) => {
710
+ rejectSent = reject;
711
+ });
712
+ const sendMessage = vi.fn<[], void | Promise<void>>(() => gate);
713
+ const firstPi = createMockPi({ sendMessage });
714
+
715
+ const first = reconcilePendingEntries(firstPi, DATA_DIR, SESSION_ID, []);
716
+ rejectSent(new Error("agent loop failed"));
717
+ await first;
718
+ expect(firstPi.appendEntry).not.toHaveBeenCalled();
719
+
720
+ // 守卫已摘除:下一次激活(失败路径未写标记,判据仍命中)可重试
721
+ const retryPi = createMockPi();
722
+ const retry = await reconcilePendingEntries(retryPi, DATA_DIR, SESSION_ID, []);
723
+ expect(retryPi.sendMessage).toHaveBeenCalledTimes(1);
724
+ expect(retry.redelivered).toEqual([entry.taskId]);
725
+ });
726
+
727
+ it("delivery marker dedupe is keyed by taskId: other pending tasks still redeliver in the same pass", async () => {
728
+ const delivered = genuineExitEntry({ taskId: "bt-1700000000-have01" });
729
+ const fresh = genuineExitEntry({ taskId: "bt-1700000000-fresh1" });
730
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), delivered);
731
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), fresh);
732
+ const pi = createMockPi();
733
+
734
+ const result = await reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [deliveredEntry(delivered.taskId)]);
735
+
736
+ expect(result.redelivered).toEqual([fresh.taskId]);
737
+ const message = lastSendMessageCall(pi);
738
+ expect(message.content).toContain(fresh.taskId);
739
+ expect(message.content).not.toContain(`${delivered.taskId} finished`);
740
+ });
741
+ });