@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 +5 -5
- package/src/__tests__/background-lifecycle.test.ts +70 -41
- package/src/__tests__/index.test.ts +70 -0
- package/src/__tests__/maintenance-once.test.ts +36 -11
- package/src/__tests__/notify-reconcile-judgement.test.ts +148 -0
- package/src/__tests__/notify.test.ts +9 -7
- package/src/__tests__/pending-reconcile.test.ts +468 -29
- package/src/background/notify-reconcile-judgement.ts +107 -0
- package/src/background/notify.ts +28 -2
- package/src/background/pending-reconcile.ts +223 -15
- package/src/index.ts +38 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
621
|
+
// 全程真实 timers:命令 0.1s 完成 < 0.5s 超时(真实时间必须真实流过——
|
|
622
|
+
// 本用例证明「到点不补杀」,fake 时钟会让回调先于进程死亡触发)
|
|
607
623
|
const before = killTreeCalls.length;
|
|
608
|
-
const spawned = spawnBg("sleep 0.
|
|
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
|
-
// 轮询命令退出收尾(未到
|
|
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 +
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
696
|
-
|
|
697
|
-
await
|
|
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:
|
|
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
|
|
715
|
-
|
|
716
|
-
await
|
|
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.
|
|
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
|
|
730
|
-
|
|
731
|
-
await
|
|
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"); //
|
|
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
|
|
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
|
-
//
|
|
777
|
-
//
|
|
778
|
-
await sleep(
|
|
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
|
-
|
|
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:
|
|
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("
|
|
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
|
-
|
|
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: "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
const
|
|
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(
|