@zhushanwen/pi-base-tool-enhance 0.5.1 → 0.5.3
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 +8 -7
- package/src/__tests__/background-lifecycle.test.ts +10 -6
- package/src/__tests__/index.test.ts +3 -5
- package/src/__tests__/maintenance-once.test.ts +10 -12
- package/src/__tests__/notify.test.ts +1 -1
- package/src/__tests__/pending-reconcile.test.ts +98 -33
- package/src/__tests__/subagent-guard.test.ts +29 -0
- package/src/background/notify.ts +4 -3
- package/src/background/output-tail.ts +9 -65
- package/src/background/pending-reconcile.ts +34 -67
- package/src/background/poller.ts +2 -1
- package/src/background/process-exit-guard.ts +9 -3
- package/src/background/registry.ts +36 -124
- package/src/background/spawn-background.ts +18 -6
- package/src/background/subagent-guard.ts +11 -15
- package/src/background/task-store.ts +12 -7
- package/src/bash-kill-tool.ts +19 -4
- package/src/bash-output-tool.ts +7 -3
- package/src/bash-tool.ts +3 -0
- package/src/config.ts +3 -6
- package/src/index.ts +2 -1
- package/src/tool-error-audit.ts +3 -2
- package/src/__tests__/kill-tree.test.ts +0 -161
- package/src/kill-tree.ts +0 -151
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design
|
|
3
|
+
"version": "0.5.3",
|
|
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",
|
|
7
7
|
"xyz-agent": {
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
"skills/"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@xyz-agent/extension-protocol": "0.
|
|
32
|
-
"@zhushanwen/pi-
|
|
33
|
-
"@zhushanwen/pi-
|
|
34
|
-
"@zhushanwen/pi-
|
|
31
|
+
"@xyz-agent/extension-protocol": "0.11.0",
|
|
32
|
+
"@zhushanwen/pi-ext-guards": "0.4.0",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.6.0",
|
|
34
|
+
"@zhushanwen/pi-file-lock": "0.4.0",
|
|
35
|
+
"@zhushanwen/pi-llm-shared": "0.8.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@vitest/coverage-v8": "^4.1.9",
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
42
43
|
"typebox": "*",
|
|
43
|
-
"@zhushanwen/pi-pending-notifications": "0.
|
|
44
|
+
"@zhushanwen/pi-pending-notifications": "0.7.2"
|
|
44
45
|
},
|
|
45
46
|
"peerDependenciesMeta": {
|
|
46
47
|
"@zhushanwen/pi-pending-notifications": {
|
|
@@ -17,19 +17,24 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
17
17
|
getAgentDir: () => dataDirRef.dir,
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
-
//
|
|
20
|
+
// 进程原语(ext-simplify-13 后单点在 protocol 子出口)转发真实杀 + 记录调用
|
|
21
|
+
//(timeout 分支断言 killProcessTree 被调)
|
|
21
22
|
const { killTreeCalls } = vi.hoisted(() => ({ killTreeCalls: [] as number[] }));
|
|
22
|
-
vi.mock("
|
|
23
|
-
const orig = await importOriginal<
|
|
23
|
+
vi.mock("@xyz-agent/extension-protocol/background-task", async (importOriginal) => {
|
|
24
|
+
const orig = await importOriginal<
|
|
25
|
+
typeof import("@xyz-agent/extension-protocol/background-task")
|
|
26
|
+
>();
|
|
24
27
|
return {
|
|
25
28
|
...orig,
|
|
26
|
-
killProcessTree: (pid: number): void => {
|
|
29
|
+
killProcessTree: (pid: number, onFallback?: (step: string, err: unknown) => void): void => {
|
|
27
30
|
killTreeCalls.push(pid);
|
|
28
|
-
orig.killProcessTree(pid);
|
|
31
|
+
orig.killProcessTree(pid, onFallback);
|
|
29
32
|
},
|
|
30
33
|
};
|
|
31
34
|
});
|
|
32
35
|
|
|
36
|
+
import { isPidAlive } from "@xyz-agent/extension-protocol/background-task";
|
|
37
|
+
|
|
33
38
|
import { createBashKillToolDefinition } from "../bash-kill-tool.ts";
|
|
34
39
|
import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
|
|
35
40
|
import { handleTaskExit, refreshPiReference, resetNotifyForTest } from "../background/notify.ts";
|
|
@@ -39,7 +44,6 @@ import type { RegistryEntry } from "../background/types.ts";
|
|
|
39
44
|
import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
|
|
40
45
|
import { clearTaskStoreForTest, getActiveTasks, getTask, markKillingIntent } from "../background/task-store.ts";
|
|
41
46
|
import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
|
|
42
|
-
import { isPidAlive } from "../kill-tree.ts";
|
|
43
47
|
|
|
44
48
|
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-data-"));
|
|
45
49
|
dataDirRef.dir = DATA_DIR;
|
|
@@ -121,11 +121,9 @@ describe("session_start chain: reconcile (M3, reap sunk into runtime by u-bte-re
|
|
|
121
121
|
status: "cancelled",
|
|
122
122
|
}),
|
|
123
123
|
);
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
reason: "cancelled",
|
|
128
|
-
});
|
|
124
|
+
// 尽力补 emit 已随 ext-simplify-13 删除(恒 no-op 死路径)——对账后不得有任何
|
|
125
|
+
// bus emit,appendEntry 即唯一权威路径
|
|
126
|
+
expect(pi.events.emit).not.toHaveBeenCalled();
|
|
129
127
|
} finally {
|
|
130
128
|
rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
131
129
|
dataDirRef.dir = "/tmp/bte-fake-agent-dir";
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
// src/__tests__/maintenance-once.test.ts —— 收殓下沉后的 session_start 维护链
|
|
2
|
-
// 语义(实施单元 u-bte-remove,设计 docs/
|
|
3
|
-
// sink.md §3.2 D2「extension 删 session_start reaper」/ §3.3 D3 粒度段 / §4 S6
|
|
2
|
+
// 语义(实施单元 u-bte-remove,设计 docs/architecture/file-lock-unification-and-
|
|
3
|
+
// reaper-sink.md §3.2 D2「extension 删 session_start reaper」/ §3.3 D3 粒度段 / §4 S6
|
|
4
4
|
// 「批 2 后 reap 类操作不再执行」):
|
|
5
5
|
// - reconcilePendingEntries 是 session 级豁免类,每 session_start 派发都执行
|
|
6
6
|
// (startup/resume/new 多派发 ×N——含 factory 二调 handler 累积的真实形态)
|
|
7
7
|
// - 触发面消失:reaper.ts 模块文件已删除(文件级断言,见首条用例)+ 维护链不再触发
|
|
8
|
-
//
|
|
8
|
+
// 全局文件锁(原 reapOrphanedTasks 的 reaper.lock 路径随模块删除一并消失)
|
|
9
9
|
// - 入口无条件 debug 日志按派发次数出现,detail 仅含 reason(reapSkipped 字段
|
|
10
10
|
// 随 reap 调用移除,S6 观测通道语义更新)
|
|
11
|
-
// 断言方式:reconcile / logger
|
|
11
|
+
// 断言方式:reconcile / logger 间谍注入观察调用次数与参数;file-lock 为 mock
|
|
12
|
+
// 缺键守卫 + withFileLockSync throw 哨兵(不计数)。
|
|
12
13
|
import { existsSync } from "node:fs";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
|
|
16
17
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
17
18
|
|
|
18
|
-
const { reconcileMock, loggerMock, officialBashFactoryMock, dataDirRef
|
|
19
|
+
const { reconcileMock, loggerMock, officialBashFactoryMock, dataDirRef } =
|
|
19
20
|
vi.hoisted(() => ({
|
|
20
21
|
reconcileMock: vi.fn(),
|
|
21
22
|
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
23
|
officialBashFactoryMock: vi.fn(),
|
|
23
24
|
dataDirRef: { dir: "/tmp/bte-fake-agent-dir" },
|
|
24
|
-
withFileLockMock: vi.fn(),
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
27
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
@@ -31,10 +31,10 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
31
31
|
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
32
32
|
getLogger: () => loggerMock,
|
|
33
33
|
}));
|
|
34
|
-
//
|
|
35
|
-
//
|
|
34
|
+
// 守卫形态 = mock 工厂缺键:包入口已删除的 async 锁导出不在工厂注册——维护链若
|
|
35
|
+
// 误回流,对已删导出的任何访问(调用与 typeof 均计)即抛错(vitest mock 缺键语义;
|
|
36
|
+
// 模块导入本身不抛)。withFileLockSync 哨兵保留:registry 写路径误入维护链即抛
|
|
36
37
|
vi.mock("@zhushanwen/pi-file-lock", () => ({
|
|
37
|
-
withFileLock: withFileLockMock,
|
|
38
38
|
withFileLockSync: vi.fn(() => {
|
|
39
39
|
throw new Error("unexpected registry write in maintenance chain");
|
|
40
40
|
}),
|
|
@@ -86,7 +86,6 @@ beforeEach(() => {
|
|
|
86
86
|
parameters: {},
|
|
87
87
|
execute: vi.fn(),
|
|
88
88
|
});
|
|
89
|
-
withFileLockMock.mockReset();
|
|
90
89
|
loggerMock.debug.mockClear();
|
|
91
90
|
loggerMock.warn.mockClear();
|
|
92
91
|
loggerMock.error.mockClear();
|
|
@@ -118,8 +117,7 @@ describe("session_start maintenance chain after reap sink (u-bte-remove)", () =>
|
|
|
118
117
|
expect(reconcileMock).toHaveBeenCalledTimes(3);
|
|
119
118
|
expect(reconcileMock).toHaveBeenNthCalledWith(1, expect.anything(), dataDirRef.dir, "sid-once", []);
|
|
120
119
|
expect(reconcileMock).toHaveBeenLastCalledWith(expect.anything(), dataDirRef.dir, "sid-once", []);
|
|
121
|
-
//
|
|
122
|
-
expect(withFileLockMock).not.toHaveBeenCalled();
|
|
120
|
+
// 无全局扫描回流:误触发已删锁导出的任何访问会被 mock 缺键直接抛错(守卫见工厂注释),无告警
|
|
123
121
|
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
124
122
|
});
|
|
125
123
|
|
|
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
10
|
|
|
11
|
-
import { killProcessTree } from "
|
|
11
|
+
import { killProcessTree } from "@xyz-agent/extension-protocol/background-task";
|
|
12
12
|
import {
|
|
13
13
|
BACKGROUND_BASH_CUSTOM_TYPE,
|
|
14
14
|
buildNotificationContent,
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// src/__tests__/pending-reconcile.test.ts —— M3 session_start 对账单元(§3.5 接入细则 4):
|
|
2
|
-
//
|
|
2
|
+
// bt- 差集(protocol collectActivePendingIds 单点消费)/ 三类僵尸场景 appendEntry
|
|
3
|
+
// 权威路径(唯一写路径,无 emit)/ 活任务与缺条目保守跳过
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
|
|
9
|
+
import {
|
|
10
|
+
BACKGROUND_TASK_ID_PREFIX,
|
|
11
|
+
collectActivePendingIds,
|
|
12
|
+
mapReasonToStatus,
|
|
13
|
+
} from "@xyz-agent/extension-protocol";
|
|
8
14
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
15
|
|
|
10
16
|
import {
|
|
11
|
-
collectUnsettledTaskIds,
|
|
12
17
|
reconcilePendingEntries,
|
|
13
18
|
type ReconcilePi,
|
|
14
19
|
} from "../background/pending-reconcile.ts";
|
|
@@ -18,9 +23,13 @@ import type { RegistryEntry } from "../background/types.ts";
|
|
|
18
23
|
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-reconcile-"));
|
|
19
24
|
const SESSION_ID = "sess-reconcile";
|
|
20
25
|
|
|
21
|
-
function createMockPi(overrides: Partial<Pick<ReconcilePi, "appendEntry">> = {}): ReconcilePi {
|
|
26
|
+
function createMockPi(overrides: Partial<Pick<ReconcilePi, "appendEntry">> = {}): ReconcilePi & {
|
|
27
|
+
events: { emit: ReturnType<typeof vi.fn> };
|
|
28
|
+
} {
|
|
22
29
|
return {
|
|
23
30
|
appendEntry: vi.fn(),
|
|
31
|
+
// emit 路径已随 ext-simplify-13 删除(恒 no-op 死路径):events spy 仅用于
|
|
32
|
+
// 断言实现不再触达 bus emit
|
|
24
33
|
events: { emit: vi.fn() },
|
|
25
34
|
...overrides,
|
|
26
35
|
};
|
|
@@ -56,41 +65,60 @@ afterEach(() => {
|
|
|
56
65
|
vi.restoreAllMocks();
|
|
57
66
|
});
|
|
58
67
|
|
|
59
|
-
describe("
|
|
68
|
+
describe("bt- diff via protocol collectActivePendingIds (single-source diff core)", () => {
|
|
69
|
+
// 差集本体单点在 protocol pending-entries(ext-simplify-13 D3,与 pending 守卫
|
|
70
|
+
// 判据同源);此处锁定 bte 对账消费面的语义(idPrefix = BACKGROUND_TASK_ID_PREFIX)
|
|
60
71
|
it("collects bt- registers without a matching unregister", () => {
|
|
61
|
-
const ids =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
const ids = collectActivePendingIds(
|
|
73
|
+
[
|
|
74
|
+
registerEntry("bt-a"),
|
|
75
|
+
registerEntry("bt-b"),
|
|
76
|
+
{ customType: "pending:unregister", data: { id: "bt-b", reason: "completed" } },
|
|
77
|
+
],
|
|
78
|
+
{ idPrefix: BACKGROUND_TASK_ID_PREFIX },
|
|
79
|
+
);
|
|
66
80
|
expect([...ids]).toEqual(["bt-a"]);
|
|
67
81
|
});
|
|
68
82
|
|
|
69
83
|
it("ignores non-bt ids (subagent bg-/run- namespace not ours)", () => {
|
|
70
|
-
const ids =
|
|
71
|
-
registerEntry("bg-1"),
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
]);
|
|
84
|
+
const ids = collectActivePendingIds(
|
|
85
|
+
[registerEntry("bg-1"), registerEntry("run-x-1"), registerEntry("bt-a")],
|
|
86
|
+
{ idPrefix: BACKGROUND_TASK_ID_PREFIX },
|
|
87
|
+
);
|
|
75
88
|
expect([...ids]).toEqual(["bt-a"]);
|
|
76
89
|
});
|
|
77
90
|
|
|
78
91
|
it("dedupes repeated registers and tolerates malformed entries", () => {
|
|
79
|
-
const ids =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
const ids = collectActivePendingIds(
|
|
93
|
+
[
|
|
94
|
+
null,
|
|
95
|
+
undefined,
|
|
96
|
+
{ customType: "pending:register" }, // data 缺失
|
|
97
|
+
{ customType: "pending:register", data: { id: 42 } }, // id 非字符串
|
|
98
|
+
registerEntry("bt-a"),
|
|
99
|
+
registerEntry("bt-a"),
|
|
100
|
+
{ customType: "other" },
|
|
101
|
+
],
|
|
102
|
+
{ idPrefix: BACKGROUND_TASK_ID_PREFIX },
|
|
103
|
+
);
|
|
88
104
|
expect([...ids]).toEqual(["bt-a"]);
|
|
89
105
|
});
|
|
106
|
+
|
|
107
|
+
it("register→unregister→register same id stays settled (global cancellation, §5.4①)", () => {
|
|
108
|
+
const ids = collectActivePendingIds(
|
|
109
|
+
[
|
|
110
|
+
registerEntry("bt-a"),
|
|
111
|
+
{ customType: "pending:unregister", data: { id: "bt-a", reason: "completed" } },
|
|
112
|
+
registerEntry("bt-a"),
|
|
113
|
+
],
|
|
114
|
+
{ idPrefix: BACKGROUND_TASK_ID_PREFIX },
|
|
115
|
+
);
|
|
116
|
+
expect(ids.size).toBe(0);
|
|
117
|
+
});
|
|
90
118
|
});
|
|
91
119
|
|
|
92
120
|
describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry never written)", () => {
|
|
93
|
-
it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape
|
|
121
|
+
it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape", () => {
|
|
94
122
|
const entry = makeRegistryEntry({
|
|
95
123
|
taskId: "bt-1700000000-zomb01",
|
|
96
124
|
state: "exited",
|
|
@@ -109,11 +137,6 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
|
|
|
109
137
|
reason: "completed",
|
|
110
138
|
status: "completed",
|
|
111
139
|
});
|
|
112
|
-
// emit 形态 {id, reason}(status 由 listener mapReasonToStatus 计算)
|
|
113
|
-
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
114
|
-
id: entry.taskId,
|
|
115
|
-
reason: "completed",
|
|
116
|
-
});
|
|
117
140
|
});
|
|
118
141
|
|
|
119
142
|
it("maps exited reason/exitCode through the same mapping as the exit edge", () => {
|
|
@@ -127,14 +150,43 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
|
|
|
127
150
|
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
128
151
|
const pi = createMockPi();
|
|
129
152
|
reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
153
|
+
// status 断言按「权威映射(reason)」而非 reason 本身表述(D10:identity 假设消除)
|
|
154
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
|
|
155
|
+
id: entry.taskId,
|
|
156
|
+
reason: expectedReason,
|
|
157
|
+
status: mapReasonToStatus(expectedReason),
|
|
158
|
+
});
|
|
134
159
|
}
|
|
135
160
|
});
|
|
136
161
|
});
|
|
137
162
|
|
|
163
|
+
describe("entry status maps via protocol mapReasonToStatus (D10 single-source, identity assumption removed)", () => {
|
|
164
|
+
// bte 写侧 status 与 pending-notifications unregister listener 同引 protocol 单点
|
|
165
|
+
// (ext-simplify-17 D10)——原 status: reason 的 identity 假设在非 identity reason
|
|
166
|
+
// (budget_limited→failed 等)上会静默漂移,映射表演化时两写侧不再可能分叉。
|
|
167
|
+
// settle 产域四值当前恰为 identity 映射,但断言面按「status === mapReasonToStatus
|
|
168
|
+
// (reason)」表述:映射表日后改任何一行的口径,本组用例随实现同源自洽。
|
|
169
|
+
it("appended status equals the authoritative mapping for every reason the settle path can produce", () => {
|
|
170
|
+
// settledPendingReason 全产域:exited 三分支(natural/timeout/killed|process-exit
|
|
171
|
+
// 经 toPendingReason)+ orphaned/判死兜底 cancelled + reason 缺失防御分支
|
|
172
|
+
const settleReasons = ["completed", "failed", "time_limited", "cancelled"] as const;
|
|
173
|
+
for (const reason of settleReasons) {
|
|
174
|
+
expect(mapReasonToStatus(reason)).toBe(reason); // 当前口径下四值均为 identity 映射
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("non-identity reasons exist in the authoritative mapping — identity assumption is provably gone", () => {
|
|
179
|
+
// 这些 reason 上 status ≠ reason 本身:若写侧仍持 identity 假设(status: reason),
|
|
180
|
+
// settle 产域将来扩展到这些值时落盘 entry 会静默漂移;现实现单点映射自动正确
|
|
181
|
+
expect(mapReasonToStatus("budget_limited")).toBe("failed");
|
|
182
|
+
expect(mapReasonToStatus("interrupted")).toBe("aborted");
|
|
183
|
+
expect(mapReasonToStatus("interrupted-by-restart")).toBe("aborted");
|
|
184
|
+
expect(mapReasonToStatus("interrupted-by-parent")).toBe("aborted");
|
|
185
|
+
expect(mapReasonToStatus("reopened")).toBe("completed");
|
|
186
|
+
expect(mapReasonToStatus("some-future-reason")).toBe("completed");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
138
190
|
describe("reconcile scenario ②: collector-only orphan (registry orphaned, session file untouched)", () => {
|
|
139
191
|
it("appends unregister with cancelled for orphaned entries", () => {
|
|
140
192
|
const entry = makeRegistryEntry({ state: "orphaned" });
|
|
@@ -214,6 +266,19 @@ describe("conservative no-op paths", () => {
|
|
|
214
266
|
});
|
|
215
267
|
});
|
|
216
268
|
|
|
269
|
+
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)", () => {
|
|
271
|
+
const entry = makeRegistryEntry({ state: "orphaned" });
|
|
272
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
273
|
+
const pi = createMockPi();
|
|
274
|
+
|
|
275
|
+
reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
276
|
+
|
|
277
|
+
expect(pi.appendEntry).toHaveBeenCalledTimes(1);
|
|
278
|
+
expect(pi.events.emit).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
217
282
|
describe("appendEntry failure tolerance", () => {
|
|
218
283
|
it("one appendEntry throw does not block the remaining zombie (count only successful)", () => {
|
|
219
284
|
const first = makeRegistryEntry({ taskId: "bt-1700000000-zomb01", state: "orphaned" });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/__tests__/subagent-guard.test.ts
|
|
2
|
+
//
|
|
3
|
+
// subagent-guard 判据钉值(ext-simplify-17 D4 重锚定后):
|
|
4
|
+
// - XYZ_AGENT_SUBAGENT === "1" 命中(引擎 spawn 链恒注入的现行标记)
|
|
5
|
+
// - 其他值 / 缺失不命中(顶层会话不误降级,background 正常后台化)
|
|
6
|
+
//
|
|
7
|
+
// [HISTORICAL] 对照(D4 重锚前的旧行为):旧判据查 PI_SUBAGENT_* 身份键族两枚、
|
|
8
|
+
// 任一存在即命中——该键族已无写入方,旧判据实际恒 false(降级从未生效)。旧键
|
|
9
|
+
// 不命中的钉值在 ext-guards predicates 测试(谓词实现方领地),此处只钉 bte
|
|
10
|
+
// 消费面的现行语义。
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { isSubagentProcess } from "../background/subagent-guard.ts";
|
|
15
|
+
|
|
16
|
+
describe("isSubagentProcess(subagent-guard,D4 重锚后)", () => {
|
|
17
|
+
it("XYZ_AGENT_SUBAGENT=1 命中(引擎链 subagent → D14 background 降级前提恢复)", () => {
|
|
18
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "1" })).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("值非 \"1\" 不命中", () => {
|
|
22
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "0" })).toBe(false);
|
|
23
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "" })).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("缺失不命中(顶层会话 → background 正常后台化,guard 不误命中)", () => {
|
|
27
|
+
expect(isSubagentProcess({})).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/background/notify.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
30
31
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
31
32
|
|
|
32
33
|
import type { BackgroundTask, BackgroundTaskEndReason } from "./types.ts";
|
|
@@ -107,7 +108,7 @@ export function emitPendingRegister(task: BackgroundTask): void {
|
|
|
107
108
|
pi.events.emit("pending:register", { id: task.taskId, type: "bash", name });
|
|
108
109
|
} catch (err) {
|
|
109
110
|
logger.warn("pending:register emit failed (stale bus?); task unaffected", {
|
|
110
|
-
detail: { taskId: task.taskId, err:
|
|
111
|
+
detail: { taskId: task.taskId, err: toErrorMessage(err) },
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -130,7 +131,7 @@ export function emitPendingUnregister(
|
|
|
130
131
|
pi.events.emit("pending:unregister", { id: taskId, reason: toPendingReason(reason, exitCode) });
|
|
131
132
|
} catch (err) {
|
|
132
133
|
logger.warn("pending:unregister emit failed (stale bus?); reconcile covers on next session_start", {
|
|
133
|
-
detail: { taskId, err:
|
|
134
|
+
detail: { taskId, err: toErrorMessage(err) },
|
|
134
135
|
});
|
|
135
136
|
}
|
|
136
137
|
}
|
|
@@ -160,7 +161,7 @@ function sendTaskFinishedMessage(task: BackgroundTask): void {
|
|
|
160
161
|
logger.warn("background task notify sendMessage failed; poll continues", {
|
|
161
162
|
detail: {
|
|
162
163
|
taskId: task.taskId,
|
|
163
|
-
err:
|
|
164
|
+
err: toErrorMessage(err),
|
|
164
165
|
},
|
|
165
166
|
});
|
|
166
167
|
}
|
|
@@ -1,84 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 输出文件 tail
|
|
2
|
+
* 输出文件 tail 读取薄壳(D7:输出落文件不占内存,查询时按需读尾部)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* tail 算法单点在 extension-protocol(ext-simplify-13:与 runtime 桌面预览侧共用,
|
|
5
|
+
* 原两份同构实现签名已实际漂移)。本模块只保留 bte 私有需求:
|
|
6
|
+
* - 50KB / 2000 行截断上限常量(pi 内置 bash 同款,bash.js truncate.ts)——口径是
|
|
7
|
+
* 本包产品决策,由调用方(bash-output-tool)传参给 protocol 原语
|
|
8
|
+
* - readTailSummary:exit 边沿的 tail 摘要(存条目 / M3 通知用)
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
12
|
-
|
|
13
|
-
const logger = getLogger("base-tool-enhance");
|
|
11
|
+
import { readOutputTail as readOutputTailPrimitive } from "@xyz-agent/extension-protocol/background-task";
|
|
14
12
|
|
|
15
13
|
/** pi 内置 bash 同款截断上限(last 2000 lines / 50KB = 51200 bytes,先到为准)。 */
|
|
16
14
|
export const TAIL_MAX_LINES = 2000;
|
|
17
15
|
export const TAIL_MAX_BYTES = 51_200;
|
|
18
|
-
/** 字节窗口余量:截窗口可能吞掉首行前半,余量降低残行概率。 */
|
|
19
|
-
const TAIL_WINDOW_MARGIN_BYTES = 64;
|
|
20
16
|
/** exit 边沿 tail 摘要参数(存条目/M3 通知用)。 */
|
|
21
17
|
const SUMMARY_TAIL_LINES = 5;
|
|
22
18
|
const SUMMARY_MAX_CHARS = 800;
|
|
23
19
|
|
|
24
|
-
export interface TailResult {
|
|
25
|
-
output: string;
|
|
26
|
-
/** 读取窗口被截断(内容超上限)时 true。 */
|
|
27
|
-
truncated: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 读文件尾部(行/字节双上限)。文件不存在/不可读返回 undefined——bash_output 对
|
|
32
|
-
* 此降级为 {output:"<lost>"} 不崩溃(§3.6)。
|
|
33
|
-
*/
|
|
34
|
-
export function readOutputTail(
|
|
35
|
-
outputFile: string,
|
|
36
|
-
maxLines: number = TAIL_MAX_LINES,
|
|
37
|
-
maxBytes: number = TAIL_MAX_BYTES,
|
|
38
|
-
): TailResult | undefined {
|
|
39
|
-
let size: number;
|
|
40
|
-
try {
|
|
41
|
-
size = statSync(outputFile).size;
|
|
42
|
-
} catch {
|
|
43
|
-
return undefined;
|
|
44
|
-
}
|
|
45
|
-
// 字节窗口从末尾取 maxBytes + 余量(截窗口可能吞掉首行前半,余量降低概率)
|
|
46
|
-
const windowSize = Math.min(size, maxBytes + TAIL_WINDOW_MARGIN_BYTES);
|
|
47
|
-
const buffer = Buffer.alloc(windowSize);
|
|
48
|
-
let fd: number | undefined;
|
|
49
|
-
try {
|
|
50
|
-
fd = openSync(outputFile, "r");
|
|
51
|
-
readSync(fd, buffer, 0, windowSize, size - windowSize);
|
|
52
|
-
} catch {
|
|
53
|
-
return undefined;
|
|
54
|
-
} finally {
|
|
55
|
-
if (fd !== undefined) {
|
|
56
|
-
try {
|
|
57
|
-
closeSync(fd);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
// 已读完内容,close 失败不影响结果,仅留诊断
|
|
60
|
-
logger.debug("output tail close failed", {
|
|
61
|
-
detail: { outputFile, err: err instanceof Error ? err.message : String(err) },
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const text = buffer.toString("utf8");
|
|
67
|
-
const lines = text.split("\n");
|
|
68
|
-
// 窗口起点可能落在行中间:首行是残行时丢弃(它必然不完整)
|
|
69
|
-
const firstLineIsPartial = windowSize < size && lines.length > 0;
|
|
70
|
-
const effectiveLines = firstLineIsPartial ? lines.slice(1) : lines;
|
|
71
|
-
const byteTruncated = size > maxBytes;
|
|
72
|
-
const shown = effectiveLines.slice(-maxLines).join("\n");
|
|
73
|
-
return { output: shown, truncated: byteTruncated || effectiveLines.length > maxLines };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
20
|
/**
|
|
77
21
|
* 轮询器 exit 边沿的 tail 摘要(存进条目、M3 通知用):末尾几行的紧凑文本。
|
|
78
22
|
*/
|
|
79
23
|
export function readTailSummary(outputFile: string, maxChars: number = SUMMARY_MAX_CHARS): string | undefined {
|
|
80
|
-
const tail =
|
|
24
|
+
const tail = readOutputTailPrimitive(outputFile, { maxLines: SUMMARY_TAIL_LINES, maxBytes: maxChars });
|
|
81
25
|
if (tail === undefined) return undefined;
|
|
82
|
-
const compact = tail.
|
|
26
|
+
const compact = tail.text.trim();
|
|
83
27
|
return compact.length > 0 ? compact.slice(-maxChars) : undefined;
|
|
84
28
|
}
|