@zhushanwen/pi-base-tool-enhance 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design decisions documented in source comments)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -28,11 +28,11 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@zhushanwen/extension-protocol": "0.13.0",
32
- "@zhushanwen/pi-ext-guards": "0.4.1",
31
+ "@zhushanwen/extension-protocol": "0.15.0",
32
+ "@zhushanwen/pi-ext-guards": "0.4.2",
33
33
  "@zhushanwen/pi-extension-logger": "0.6.1",
34
34
  "@zhushanwen/pi-file-lock": "0.4.1",
35
- "@zhushanwen/pi-llm-shared": "0.9.0"
35
+ "@zhushanwen/pi-llm-shared": "0.10.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@vitest/coverage-v8": "^4.1.9",
@@ -41,7 +41,7 @@
41
41
  "peerDependencies": {
42
42
  "@earendil-works/pi-coding-agent": "^0.84.4",
43
43
  "typebox": "*",
44
- "@zhushanwen/pi-pending-notifications": "0.7.6"
44
+ "@zhushanwen/pi-pending-notifications": "0.7.7"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "@zhushanwen/pi-pending-notifications": {
@@ -164,7 +164,8 @@ afterAll(() => {
164
164
 
165
165
  describe("real spawn lifecycle (poll edge finalization)", () => {
166
166
  it("short task: running → exited with exitCode 0, output file readable, registry terminal", async () => {
167
- const spawned = spawnBg("sleep 0.3 && echo done");
167
+ // 断言只要求「poll 边沿前 state=running」与退出产物,不依赖命令时长(终态化只在 finalizeTask)
168
+ const spawned = spawnBg("sleep 0.1 && echo done");
168
169
  if (!spawned.ok) throw new Error(spawned.error);
169
170
  const { task } = spawned;
170
171
  expect(task.state).toBe("running");
@@ -192,19 +193,30 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
192
193
  });
193
194
 
194
195
  it("poller interval auto-finalizes without manual tick (lazy start/stop)", async () => {
195
- const spawned = spawnBg("sleep 0.3 && echo auto");
196
- if (!spawned.ok) throw new Error(spawned.error);
197
- const { task } = spawned;
198
- // 不手动 tick,等真实 2s 轮询边沿(命令 0.3s 已退出,首轮 tick 即收尾):
199
- // 轮询等终态出现(真实定时器 + 满载下轮询边沿延迟不可预估)
200
- await pollUntil(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "poller auto-finalization");
201
- expect(getTask(task.taskId)?.state).toBe("exited");
202
- // 无活跃条目后轮询器自停(防空转)
203
- expect(getActiveTasks()).toHaveLength(0);
196
+ // 只 fake setInterval/clearInterval(setTimeout 保持真实:命令退出与 pollUntil
197
+ // 的等待都真实流逝)——2s 轮询间隔定时器进 fake 队列,advanceTimersByTime 推进
198
+ // 即触发真实 pollTick 边沿。本用例是「真实间隔边沿自动收尾(lazy start/stop)」
199
+ // 语义的唯一集成探针:边沿推进逻辑(pollTick 本体)原样执行,仅等待被压缩。
200
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
201
+ try {
202
+ const spawned = spawnBg("sleep 0.1 && echo auto");
203
+ if (!spawned.ok) throw new Error(spawned.error);
204
+ const { task } = spawned;
205
+ // 真实等命令退出(不手动 tick——收尾必须来自轮询器定时器边沿)
206
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die without poll edge");
207
+ // fake 队列推进一个完整轮询间隔 → 首个真实边沿收尾
208
+ vi.advanceTimersByTime(2000);
209
+ expect(getTask(task.taskId)?.state).toBe("exited");
210
+ // 无活跃条目后轮询器自停(防空转)
211
+ expect(getActiveTasks()).toHaveLength(0);
212
+ } finally {
213
+ vi.useRealTimers();
214
+ }
204
215
  });
205
216
 
206
217
  it("nonzero exit code is surfaced (exitCode 1)", async () => {
207
- const spawned = spawnBg("sleep 0.2; exit 3");
218
+ // 断言只看 exitCode 透写,不依赖命令时长
219
+ const spawned = spawnBg("sleep 0.1; exit 3");
208
220
  if (!spawned.ok) throw new Error(spawned.error);
209
221
  const { task } = spawned;
210
222
  await pollUntilTicked(() => getTask(task.taskId)?.exitCode === 3, POLL_DEADLINE_MS, "exitCode to surface");
@@ -313,7 +325,8 @@ describe("bash_output tool", () => {
313
325
  });
314
326
 
315
327
  it("deleted output file degrades to <lost> without crashing (§3.6)", async () => {
316
- const spawned = spawnBg("sleep 0.2 && echo gone");
328
+ // 断言只看退出后输出文件缺失的降级路径,不依赖命令时长
329
+ const spawned = spawnBg("sleep 0.1 && echo gone");
317
330
  if (!spawned.ok) throw new Error(spawned.error);
318
331
  const { task } = spawned;
319
332
  await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
@@ -386,7 +399,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
386
399
  });
387
400
 
388
401
  it("already exited task → killed:false with exit code", async () => {
389
- const spawned = spawnBg("sleep 0.2");
402
+ // 断言只看已终态条目的 kill 拒绝,不依赖命令时长
403
+ const spawned = spawnBg("sleep 0.1");
390
404
  if (!spawned.ok) throw new Error(spawned.error);
391
405
  const { task } = spawned;
392
406
  await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
@@ -434,7 +448,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
434
448
  });
435
449
 
436
450
  it("P0-2.2: store entry whose pid already died (poll edge not landed) → already exited style, no kill/intent", async () => {
437
- const spawned = spawnBg("sleep 0.2");
451
+ // 死亡等待 ~0.1s 远小于 2s 真实轮询首轮(单例表仍 running 是本用例前提)
452
+ const spawned = spawnBg("sleep 0.1");
438
453
  if (!spawned.ok) throw new Error(spawned.error);
439
454
  const { task } = spawned;
440
455
  // 轮询进程死透(不 tick——单例表仍 running 是本用例前提)
@@ -603,21 +618,22 @@ describe("explicit background timeout (D6)", () => {
603
618
  });
604
619
 
605
620
  it("natural completion before the deadline cancels the timer (no late kill)", async () => {
606
- // 全程真实 timers:命令 0.2s 完成 < 1s 超时
621
+ // 全程真实 timers:命令 0.1s 完成 < 0.5s 超时(真实时间必须真实流过——
622
+ // 本用例证明「到点不补杀」,fake 时钟会让回调先于进程死亡触发)
607
623
  const before = killTreeCalls.length;
608
- const spawned = spawnBg("sleep 0.2 && echo quick", { timeoutSec: 1 });
624
+ const spawned = spawnBg("sleep 0.1 && echo quick", { timeoutSec: 0.5 });
609
625
  if (!spawned.ok) throw new Error(spawned.error);
610
626
  const { task } = spawned;
611
627
 
612
- // 轮询命令退出收尾(未到 1s deadline)
628
+ // 轮询命令退出收尾(未到 0.5s deadline)
613
629
  await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "natural finalization");
614
630
  expect(getTask(task.taskId)?.state).toBe("exited");
615
631
  expect(getTask(task.taskId)?.reason).toBe("natural");
616
632
 
617
633
  // 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer):轮询真实时钟
618
- // 越过 deadline+余量后断言——这里等待的是时钟流逝本身,非子进程事件
634
+ // 越过 deadline+200ms 余量后断言——这里等待的是时钟流逝本身,非子进程事件
619
635
  await pollUntil(
620
- () => Date.now() >= task.startedAt + 1_000 + 500,
636
+ () => Date.now() >= task.startedAt + 500 + 200,
621
637
  POLL_DEADLINE_SLOW_MS,
622
638
  "real clock to pass the deadline window",
623
639
  );
@@ -639,8 +655,7 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
639
655
  writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
640
656
  expect(getTask(task.taskId)?.intent).toBeUndefined(); // 前置:内存无 intent
641
657
  process.kill(task.pid, "SIGKILL");
642
- await sleep(500);
643
- expect(isPidAlive(task.pid)).toBe(false);
658
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die after SIGKILL");
644
659
  pollTickForTest();
645
660
 
646
661
  const finalized = getTask(task.taskId);
@@ -663,8 +678,7 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
663
678
  //(模拟 intent 落盘失败)——若实现误读回,会按 running 判 natural
664
679
  expect(markKillingIntent(task.taskId, "killed")).toBeDefined();
665
680
  process.kill(task.pid, "SIGKILL");
666
- await sleep(500);
667
- expect(isPidAlive(task.pid)).toBe(false);
681
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die after SIGKILL");
668
682
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running"); // 前置成立
669
683
  pollTickForTest();
670
684
  expect(getTask(task.taskId)?.reason).toBe("killed"); // intent 优先,非 natural
@@ -676,7 +690,7 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
676
690
  const { task } = spawned;
677
691
  writeFileSync(REGISTRY_PATH, "{corrupted", "utf8"); // 读回路径解析失败
678
692
  process.kill(task.pid, "SIGKILL");
679
- await sleep(500);
693
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die after SIGKILL");
680
694
  pollTickForTest();
681
695
  const finalized = getTask(task.taskId);
682
696
  expect(finalized?.state).toBe("exited"); // 终态化未被阻塞
@@ -686,15 +700,21 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
686
700
 
687
701
  describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recorded pid is gone", () => {
688
702
  it("UI kill × timeout overlap: no timeout mark; poll edge reads back registry killing → killed", async () => {
689
- const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
703
+ // 真实时间必须真实流过(R2-S1 前提:到点时 pid 已死才有「跳过补杀」可言),
704
+ // fake 时钟会让 timeout 回调先于进程死亡触发
705
+ const spawned = spawnBg("sleep 30", { timeoutSec: 0.5 });
690
706
  if (!spawned.ok) throw new Error(spawned.error);
691
707
  const { task } = spawned;
692
708
  // UI 代杀(跨进程形态):registry 预写 killing + 直接杀进程,不经内存
693
709
  writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
694
710
  process.kill(task.pid, "SIGKILL");
695
- await sleep(500); // 进程死亡 + libuv reap(deadline 1s 之前)
696
- expect(isPidAlive(task.pid)).toBe(false);
697
- await sleep(700); // 越过 deadline(累计 ~1.2s,仍在 2s 轮询 tick 之前)
711
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die before the deadline");
712
+ // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟,等待的是时钟流逝本身
713
+ await pollUntil(
714
+ () => Date.now() >= task.startedAt + 500 + 200,
715
+ POLL_DEADLINE_SLOW_MS,
716
+ "real clock to pass the deadline window",
717
+ );
698
718
  // 加固生效:不补杀、不改写内存(无 intent)、不冲掉 registry 侧 killing 预写
699
719
  expect(killTreeCalls).not.toContain(task.pid);
700
720
  expect(getTask(task.taskId)?.state).toBe("running");
@@ -706,14 +726,18 @@ describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recor
706
726
  });
707
727
 
708
728
  it("AI bash_kill × timeout overlap: killed intent survives (no 'timed out' misreport)", async () => {
709
- const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
729
+ const spawned = spawnBg("sleep 30", { timeoutSec: 0.5 });
710
730
  if (!spawned.ok) throw new Error(spawned.error);
711
731
  const { task } = spawned;
712
732
  const killed = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
713
733
  expect(killed.killed).toBe(true);
714
- await sleep(500); // 进程死亡 + reap(deadline 之前)
715
- expect(isPidAlive(task.pid)).toBe(false);
716
- await sleep(700); // 越过 deadline:pid 已死 → 跳过 timeout mark
734
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die before the deadline");
735
+ // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟——pid 已死 → 跳过 timeout mark
736
+ await pollUntil(
737
+ () => Date.now() >= task.startedAt + 500 + 200,
738
+ POLL_DEADLINE_SLOW_MS,
739
+ "real clock to pass the deadline window",
740
+ );
717
741
  expect(getTask(task.taskId)?.intent?.reason).toBe("killed"); // 未被覆盖为 timeout
718
742
  expect(killTreeCalls.filter((pid) => pid === task.pid)).toHaveLength(1); // 无第二次 kill
719
743
  pollTickForTest();
@@ -722,15 +746,20 @@ describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recor
722
746
  });
723
747
 
724
748
  it("natural death before the deadline: no timeout mark; poll edge finalizes natural (accepted shift)", async () => {
749
+ // 命令 0.1s 自然退出 < 0.5s deadline;真实时间必须真实流过(fake 会先于死亡触发回调)
725
750
  const before = killTreeCalls.length;
726
- const spawned = spawnBg("sleep 0.3", { timeoutSec: 1 });
751
+ const spawned = spawnBg("sleep 0.1", { timeoutSec: 0.5 });
727
752
  if (!spawned.ok) throw new Error(spawned.error);
728
753
  const { task } = spawned;
729
- await sleep(600); // 自然死亡 + reap,deadline(1s) 未到
730
- expect(getTask(task.taskId)?.state).toBe("running"); // 轮询未收尾(不手动 tick)
731
- await sleep(600); // 越过 deadline(累计 ~1.2s,仍在 2s 轮询 tick 之前)
754
+ await pollUntil(() => !isPidAlive(task.pid), 5000, "natural death before the deadline");
755
+ // 越过 deadline(0.5s)+200ms 余量(仍在 2s 真实轮询 tick 之前,不手动 tick)
756
+ await pollUntil(
757
+ () => Date.now() >= task.startedAt + 500 + 200,
758
+ POLL_DEADLINE_SLOW_MS,
759
+ "real clock to pass the deadline window",
760
+ );
732
761
  expect(killTreeCalls.length).toBe(before); // 死任务不补杀
733
- expect(getTask(task.taskId)?.state).toBe("running"); // 未被标 killing
762
+ expect(getTask(task.taskId)?.state).toBe("running"); // 轮询未收尾,未被标 killing
734
763
  expect(getTask(task.taskId)?.intent).toBeUndefined();
735
764
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
736
765
  pollTickForTest();
@@ -766,16 +795,16 @@ describe("process-exit reap (D12)", () => {
766
795
  describe("D15: abort signal does not propagate to background tasks", () => {
767
796
  it("aborted execute-signal leaves the task running", async () => {
768
797
  const controller = new AbortController();
769
- const spawned = spawnBg("sleep 5");
798
+ const spawned = spawnBg("sleep 1");
770
799
  if (!spawned.ok) throw new Error(spawned.error);
771
800
  const { task } = spawned;
772
801
  // 模拟用户中断当前 turn:signal abort(后台分支不接触 signal,此处模拟上游已 abort)
773
802
  controller.abort();
774
803
  expect(controller.signal.aborted).toBe(true);
775
804
 
776
- // 非断言性观察窗口(留事件传播时间):断言的是「任务不受 abort 影响」的
777
- // 负向不变量,期望状态恒真,轮询语义不适用
778
- await sleep(300);
805
+ // 非断言性观察窗口(150ms,留事件传播时间;命令 sleep 1 未退出):断言的是
806
+ // 「任务不受 abort 影响」的负向不变量,期望状态恒真,轮询语义不适用
807
+ await sleep(150);
779
808
  // 任务不受中断影响:进程活、状态 running
780
809
  expect(isPidAlive(task.pid)).toBe(true);
781
810
  expect(getTask(task.taskId)?.state).toBe("running");
@@ -24,6 +24,7 @@ import baseToolEnhanceExtension from "../index.ts";
24
24
  function createMockPi() {
25
25
  return {
26
26
  registerTool: vi.fn(),
27
+ registerCommand: vi.fn(),
27
28
  on: vi.fn(),
28
29
  appendEntry: vi.fn(),
29
30
  events: { emit: vi.fn(), on: vi.fn() },
@@ -71,6 +72,75 @@ describe("baseToolEnhanceExtension entry", () => {
71
72
 
72
73
  expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function));
73
74
  });
75
+
76
+ it("registers the __taiji_bg_reconcile__ internal command with a non-empty description", () => {
77
+ setupOfficialFactory();
78
+ const pi = createMockPi();
79
+
80
+ baseToolEnhanceExtension(pi as unknown as ExtensionAPI);
81
+
82
+ expect(pi.registerCommand).toHaveBeenCalledTimes(1);
83
+ const [name, def] = pi.registerCommand.mock.calls[0] as [
84
+ string,
85
+ { description: string; handler: (args: string, ctx: unknown) => Promise<void> },
86
+ ];
87
+ expect(name).toBe("__taiji_bg_reconcile__");
88
+ expect(typeof def.description).toBe("string");
89
+ expect(def.description.length).toBeGreaterThan(0);
90
+ expect(typeof def.handler).toBe("function");
91
+ });
92
+ });
93
+
94
+ describe("__taiji_bg_reconcile__ command: runtime-resume trigger face (bg-task-notify-durability)", () => {
95
+ it("handler runs the same maintenance chain as session_start (zombie settle via appendEntry on the SAME pi reference)", async () => {
96
+ setupOfficialFactory();
97
+ // 独立临时 dataDir(同 session_start 用例:维护链只读不扫),registry 预置终态僵尸条目
98
+ const dataDir = mkdtempSync(join(tmpdir(), "bte-bgcmd-"));
99
+ dataDirRef.dir = dataDir;
100
+ try {
101
+ const sessionId = "sess-bgcmd";
102
+ writeRegistryEntry(getRegistryPath(dataDir, sessionId), {
103
+ taskId: "bt-1700000000-bgc001",
104
+ pid: 12345,
105
+ command: "sleep 3600",
106
+ outputFile: "/tmp/bgc.log",
107
+ startedAt: 1_700_000_000_000,
108
+ state: "orphaned",
109
+ ownerPiPid: 1,
110
+ sessionId,
111
+ });
112
+ const pi = createMockPi();
113
+ baseToolEnhanceExtension(pi as unknown as ExtensionAPI);
114
+
115
+ const [, def] = pi.registerCommand.mock.calls[0] as [
116
+ string,
117
+ { description: string; handler: (args: string, ctx: unknown) => Promise<void> },
118
+ ];
119
+ // handler 返回维护链 promise(runtime 侧 fire-and-forget 消费;本测试 await 落定)
120
+ await def.handler("", {
121
+ sessionManager: {
122
+ getSessionId: () => sessionId,
123
+ getEntries: () => [
124
+ {
125
+ customType: "pending:register",
126
+ data: { id: "bt-1700000000-bgc001", type: "bash", name: "sleep 3600" },
127
+ },
128
+ ],
129
+ },
130
+ });
131
+
132
+ expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
133
+ id: "bt-1700000000-bgc001",
134
+ reason: "cancelled",
135
+ status: "cancelled",
136
+ });
137
+ expect(pi.events.emit).not.toHaveBeenCalled();
138
+ } finally {
139
+ rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
140
+ dataDirRef.dir = "/tmp/bte-fake-agent-dir";
141
+ resetNotifyForTest();
142
+ }
143
+ });
74
144
  });
75
145
 
76
146
  describe("session_start chain: reconcile (M3, reap sunk into runtime by u-bte-remove)", () => {
@@ -47,6 +47,9 @@ import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-
47
47
  function createMockPi() {
48
48
  return {
49
49
  registerTool: vi.fn(),
50
+ // __taiji_bg_reconcile__ 第二触发面(runtime 切回补投)注册桩——本文件只验证
51
+ // session_start 入口,命令注册面断言在 index.test.ts
52
+ registerCommand: vi.fn(),
50
53
  on: vi.fn(),
51
54
  appendEntry: vi.fn(),
52
55
  events: { emit: vi.fn(), on: vi.fn() },
@@ -57,7 +60,8 @@ function createMockPi() {
57
60
  function getSessionStartHandler(pi: ReturnType<typeof createMockPi>) {
58
61
  const handler = pi.on.mock.calls.find((call) => call[0] === "session_start")?.[1];
59
62
  expect(handler).toBeTypeOf("function");
60
- return handler as (event: SessionStartEvent, ctx: unknown) => void;
63
+ // handler 返回 promise(补投 await sendMessage 使维护链 async 化,bg-task-notify-durability)
64
+ return handler as (event: SessionStartEvent, ctx: unknown) => Promise<void>;
61
65
  }
62
66
 
63
67
  function makeEvent(reason: SessionStartEvent["reason"]): SessionStartEvent {
@@ -109,9 +113,9 @@ describe("session_start maintenance chain after reap sink (u-bte-remove)", () =>
109
113
 
110
114
  // 多派发覆盖真实形态:startup + resume 双发(桌面端每次激活)+ new;
111
115
  // factory 二调 handler 累积时同一事件被多组 handler 各跑一次,同理多次执行
112
- handler(makeEvent("startup"), ctx);
113
- handler(makeEvent("resume"), ctx);
114
- handler(makeEvent("new"), ctx);
116
+ await handler(makeEvent("startup"), ctx);
117
+ await handler(makeEvent("resume"), ctx);
118
+ await handler(makeEvent("new"), ctx);
115
119
 
116
120
  expect(reconcileMock).toHaveBeenCalledTimes(3);
117
121
  expect(reconcileMock).toHaveBeenNthCalledWith(1, expect.anything(), dataDirRef.dir, "sid-once", []);
@@ -127,8 +131,8 @@ describe("session_start maintenance chain after reap sink (u-bte-remove)", () =>
127
131
  const handler = getSessionStartHandler(pi);
128
132
  const ctx = makeCtx();
129
133
 
130
- handler(makeEvent("startup"), ctx);
131
- handler(makeEvent("resume"), ctx);
134
+ await handler(makeEvent("startup"), ctx);
135
+ await handler(makeEvent("resume"), ctx);
132
136
 
133
137
  // 每次派发恰好一条入口日志(无条件 = 不以任何条件短路),reason 取自事件;
134
138
  // reapSkipped 字段已随 reap 下沉移除(u-bte-remove 变更登记,S6「批 2 后
@@ -142,20 +146,41 @@ describe("session_start maintenance chain after reap sink (u-bte-remove)", () =>
142
146
  });
143
147
  });
144
148
 
145
- it("keeps reconcile failures non-fatal: warn logged, dispatch chain not throwing", async () => {
149
+ it("keeps reconcile failures non-fatal: handler promise resolves, warn logged", async () => {
150
+ // async 化后的非致命语义(bg-task-notify-durability U3 防假绿改写):维护链已
151
+ // async——同步 not.toThrow() 断言恒真,改为显式断言「handler 的 promise resolve
152
+ //(错误在链内被 async 捕获,不向上传播成 unhandled rejection)+ warn 日志确实
153
+ // 记录」。两个断言缺一即红:吞掉后不打日志 = 观测丢失;打日志但 reject = fire
154
+ // 调用点 unhandled rejection。
155
+ reconcileMock.mockImplementation(() => Promise.reject(new Error("reconcile boom")));
156
+ const mod = await loadExtension();
157
+ const pi = createMockPi();
158
+ mod.default(pi as unknown as ExtensionAPI);
159
+ const handler = getSessionStartHandler(pi);
160
+
161
+ // 对账 reject 被维护链 async 捕获记 warn(僵尸停留差集,下一 session_start 幂等重查)
162
+ await expect(handler(makeEvent("resume"), makeCtx())).resolves.toBeUndefined();
163
+ expect(loggerMock.warn).toHaveBeenCalledWith(
164
+ "session_start pending reconcile failed; zombies retried next session start",
165
+ expect.objectContaining({ detail: expect.objectContaining({ err: "reconcile boom" }) }),
166
+ );
167
+ });
168
+
169
+ it("async chain surfaces synchronous setup errors through the same warn path", async () => {
170
+ // 同步 throw(非 promise reject)走同一 catch:覆盖 getSessionId 抛错等装配期异常,
171
+ // 锁定「try 块包住整个 await 链」不因 async 化出现裸同步段
146
172
  reconcileMock.mockImplementation(() => {
147
- throw new Error("reconcile boom");
173
+ throw new Error("sync boom");
148
174
  });
149
175
  const mod = await loadExtension();
150
176
  const pi = createMockPi();
151
177
  mod.default(pi as unknown as ExtensionAPI);
152
178
  const handler = getSessionStartHandler(pi);
153
179
 
154
- // 对账抛错被维护链吞掉记 warn(僵尸停留差集,下一 session_start 幂等重查)
155
- expect(() => handler(makeEvent("resume"), makeCtx())).not.toThrow();
180
+ await expect(handler(makeEvent("resume"), makeCtx())).resolves.toBeUndefined();
156
181
  expect(loggerMock.warn).toHaveBeenCalledWith(
157
182
  "session_start pending reconcile failed; zombies retried next session start",
158
- expect.objectContaining({ detail: expect.objectContaining({ err: "reconcile boom" }) }),
183
+ expect.objectContaining({ detail: expect.objectContaining({ err: "sync boom" }) }),
159
184
  );
160
185
  });
161
186
  });
@@ -0,0 +1,148 @@
1
+ // src/__tests__/notify-reconcile-judgement.test.ts —— 补投判据纯函数(设计 §5 U1):
2
+ // ① collectDeliveredTaskIds 五形态(正常消息/fork 副本/缺 details 旧消息/非 bash
3
+ // 通知/killed 无痕迹任务)+ content 双落盘形态容错;② 补投标记 entry 读写形态
4
+ // (customType background-bash:reconciled,data {taskId, reconciledAt})
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { BACKGROUND_BASH_CUSTOM_TYPE } from "../background/notify.ts";
8
+ import {
9
+ buildReconciledMarkerData,
10
+ collectDeliveredTaskIds,
11
+ collectReconciledTaskIds,
12
+ RECONCILED_MARKER_CUSTOM_TYPE,
13
+ } from "../background/notify-reconcile-judgement.ts";
14
+
15
+ const TASK_ID = "bt-1700000000-abcd12";
16
+ const OTHER_TASK_ID = "bt-1700000000-efgh34";
17
+
18
+ /** 送达消息 entry(pi CustomMessageEntry 落盘形态:type=custom_message + customType)。 */
19
+ function deliveredEntry(overrides: {
20
+ taskId?: string;
21
+ details?: unknown;
22
+ content?: unknown;
23
+ customType?: string;
24
+ } = {}): Record<string, unknown> {
25
+ const taskId = overrides.taskId ?? TASK_ID;
26
+ return {
27
+ type: "custom_message",
28
+ customType: overrides.customType ?? BACKGROUND_BASH_CUSTOM_TYPE,
29
+ content: overrides.content ?? `[background-bash] ${taskId} finished (exit 0, 3m12s): pnpm test\nFull output: /tmp/out/${taskId}.log`,
30
+ details: "details" in overrides ? overrides.details : { taskId, endReason: "natural", exitCode: 0 },
31
+ display: true,
32
+ };
33
+ }
34
+
35
+ describe("collectDeliveredTaskIds: five entry shapes (design §5 U1)", () => {
36
+ it("normal delivered message: hits via details.taskId and content both", () => {
37
+ const ids = collectDeliveredTaskIds([deliveredEntry()]);
38
+ expect([...ids]).toEqual([TASK_ID]);
39
+ });
40
+
41
+ it("fork copy (same shape carried into the forked session): hits identically", () => {
42
+ // fork 复制 session 文件时送达消息原样带走——同形态命中是决策 5 的构造性来源
43
+ const ids = collectDeliveredTaskIds([
44
+ { type: "user", content: "hello" }, // fork 前的旧消息
45
+ deliveredEntry(), // 复制带来的送达消息
46
+ { type: "user", content: "after fork" },
47
+ ]);
48
+ expect([...ids]).toEqual([TASK_ID]);
49
+ });
50
+
51
+ it("legacy message without details: hits via content token alone", () => {
52
+ const ids = collectDeliveredTaskIds([
53
+ deliveredEntry({ details: undefined }),
54
+ ]);
55
+ expect([...ids]).toEqual([TASK_ID]);
56
+ });
57
+
58
+ it("non-bash notification (different customType, content mentions bt- token): does NOT hit", () => {
59
+ const ids = collectDeliveredTaskIds([
60
+ deliveredEntry({ customType: "subagent-bg-notify" }),
61
+ deliveredEntry({ customType: "background-bash:reconciled", details: undefined }),
62
+ ]);
63
+ expect(ids.size).toBe(0);
64
+ });
65
+
66
+ it("killed task with no delivery message: naturally absent (empty entries side)", () => {
67
+ // kill 路径不产通知(notify.ts 单点归属规则)——entries 无该任务消息,集合不含它,
68
+ // 补投判据的排除由 registry reason=killed 承担(执行器侧用例),此处锁定痕迹面为空
69
+ const ids = collectDeliveredTaskIds([
70
+ deliveredEntry(), // 只有别的任务的送达消息
71
+ { type: "custom", customType: "pending:unregister", data: { id: OTHER_TASK_ID, reason: "cancelled" } },
72
+ ]);
73
+ expect(ids.has(TASK_ID)).toBe(true);
74
+ expect(ids.has(OTHER_TASK_ID)).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe("collectDeliveredTaskIds: content落盘形态与脏数据容错", () => {
79
+ it("TextContent[] content form (appendCustomMessageEntry accepts arrays) hits via text blocks", () => {
80
+ const ids = collectDeliveredTaskIds([
81
+ deliveredEntry({
82
+ details: undefined,
83
+ content: [{ type: "text", text: `[background-bash] ${TASK_ID} finished (exit 0, 5s): pnpm test` }],
84
+ }),
85
+ ]);
86
+ expect([...ids]).toEqual([TASK_ID]);
87
+ });
88
+
89
+ it("malformed entries (null / primitives / wrong field types) are skipped, not fatal", () => {
90
+ const ids = collectDeliveredTaskIds([
91
+ null,
92
+ undefined,
93
+ 42,
94
+ "custom_message",
95
+ { customType: BACKGROUND_BASH_CUSTOM_TYPE }, // content/details 均缺
96
+ { customType: BACKGROUND_BASH_CUSTOM_TYPE, content: 123, details: "no" },
97
+ deliveredEntry({ details: { taskId: 42 } }), // details.taskId 非字符串 → 只剩 content 通道
98
+ ]);
99
+ expect([...ids]).toEqual([TASK_ID]);
100
+ });
101
+
102
+ it("multiple messages across tasks collect all ids", () => {
103
+ const ids = collectDeliveredTaskIds([
104
+ deliveredEntry(),
105
+ deliveredEntry({ taskId: OTHER_TASK_ID, details: undefined }),
106
+ ]);
107
+ expect([...ids].sort()).toEqual([TASK_ID, OTHER_TASK_ID].sort());
108
+ });
109
+ });
110
+
111
+ describe("reconciled marker entry: write/read shape contract (design decision 1)", () => {
112
+ it("buildReconciledMarkerData produces {taskId, reconciledAt}", () => {
113
+ expect(buildReconciledMarkerData(TASK_ID, 1_700_000_000_000)).toEqual({
114
+ taskId: TASK_ID,
115
+ reconciledAt: 1_700_000_000_000,
116
+ });
117
+ });
118
+
119
+ it("marker customType is the plain-entry channel, distinct from the delivery customType", () => {
120
+ // 通道纪律:账务走 plain custom entry(appendEntry),送达消息走 sendMessage——
121
+ // 两者 customType 不得混用(NotifyLedger 同款纪律)
122
+ expect(RECONCILED_MARKER_CUSTOM_TYPE).toBe("background-bash:reconciled");
123
+ expect(RECONCILED_MARKER_CUSTOM_TYPE).not.toBe(BACKGROUND_BASH_CUSTOM_TYPE);
124
+ });
125
+
126
+ it("collectReconciledTaskIds picks marker entries and tolerates malformed data", () => {
127
+ const ids = collectReconciledTaskIds([
128
+ { type: "custom", customType: RECONCILED_MARKER_CUSTOM_TYPE, data: { taskId: TASK_ID, reconciledAt: 1 } },
129
+ { type: "custom", customType: RECONCILED_MARKER_CUSTOM_TYPE }, // data 缺失
130
+ { type: "custom", customType: RECONCILED_MARKER_CUSTOM_TYPE, data: { reconciledAt: 2 } }, // taskId 缺
131
+ { type: "custom", customType: RECONCILED_MARKER_CUSTOM_TYPE, data: { taskId: 42 } }, // 非字符串
132
+ { type: "custom_message", customType: RECONCILED_MARKER_CUSTOM_TYPE, data: { taskId: OTHER_TASK_ID } }, // 误入消息通道的形态仍按 data 读
133
+ deliveredEntry(), // 送达消息不是标记
134
+ null,
135
+ 7,
136
+ ]);
137
+ expect([...ids].sort()).toEqual([TASK_ID, OTHER_TASK_ID].sort());
138
+ });
139
+
140
+ it("delivered and reconciled collectors partition the two anchors without cross-talk", () => {
141
+ const entries = [
142
+ deliveredEntry(), // TASK_ID 送达痕迹在
143
+ { type: "custom", customType: RECONCILED_MARKER_CUSTOM_TYPE, data: { taskId: OTHER_TASK_ID, reconciledAt: 1 } },
144
+ ];
145
+ expect(collectDeliveredTaskIds(entries).has(OTHER_TASK_ID)).toBe(false);
146
+ expect(collectReconciledTaskIds(entries).has(TASK_ID)).toBe(false);
147
+ });
148
+ });
@@ -135,13 +135,13 @@ describe("register emit (data flow ⑤)", () => {
135
135
  it("emits pending:register {id, type:'bash', name} with NO expiresAt key after successful spawn", () => {
136
136
  const pi = createMockPi();
137
137
  attach(pi);
138
- const spawned = spawnBg("sleep 0.5 && echo hi");
138
+ const spawned = spawnBg("sleep 0.1 && echo hi");
139
139
  if (!spawned.ok) throw new Error(spawned.error);
140
140
 
141
141
  expect(pi.events.emit).toHaveBeenCalledWith("pending:register", {
142
142
  id: spawned.task.taskId,
143
143
  type: "bash",
144
- name: "sleep 0.5 && echo hi",
144
+ name: "sleep 0.1 && echo hi",
145
145
  });
146
146
  // process 档(D16):emit 不携带 expiresAt——键级断言防字段悄悄混入
147
147
  const payload = pi.events.emit.mock.calls.find((c) => c[0] === "pending:register")?.[1] as Record<
@@ -178,7 +178,7 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
178
178
  const pi = createMockPi();
179
179
  attach(pi);
180
180
  setOnTaskExit(handleTaskExit);
181
- const spawned = spawnBg("sleep 0.3 && echo done");
181
+ const spawned = spawnBg("sleep 0.1 && echo done");
182
182
  if (!spawned.ok) throw new Error(spawned.error);
183
183
  const { task } = spawned;
184
184
 
@@ -207,7 +207,7 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
207
207
  const finalizedEntry = getTask(task.taskId);
208
208
  expect(message.details).toMatchObject({
209
209
  taskId: task.taskId,
210
- command: "sleep 0.3 && echo done",
210
+ command: "sleep 0.1 && echo done",
211
211
  endReason: "natural",
212
212
  exitCode: 0,
213
213
  });
@@ -254,7 +254,8 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
254
254
  const pi = createMockPi();
255
255
  attach(pi);
256
256
  setOnTaskExit(handleTaskExit);
257
- const spawned = spawnBg("sleep 0.3; exit 3");
257
+ // 断言只看 reason=failed 与文案 exit code,不依赖命令时长
258
+ const spawned = spawnBg("sleep 0.1; exit 3");
258
259
  if (!spawned.ok) throw new Error(spawned.error);
259
260
  const { task } = spawned;
260
261
 
@@ -401,8 +402,9 @@ describe("D17: pi reference refresh (session replacement takeover)", () => {
401
402
  attach(stale);
402
403
  setOnTaskExit(handleTaskExit);
403
404
 
404
- const first = spawnBg("sleep 0.3 && echo one");
405
- const second = spawnBg("sleep 0.3 && echo two");
405
+ // 断言只看双任务终态化与异常吞没,不依赖命令时长
406
+ const first = spawnBg("sleep 0.1 && echo one");
407
+ const second = spawnBg("sleep 0.1 && echo two");
406
408
  if (!first.ok || !second.ok) throw new Error("spawn failed");
407
409
  // 边沿回调内部全捕获:轮询到两条任务都完成终态化(每迭代 tick 后查)
408
410
  await pollUntilTicked(