@zhushanwen/pi-base-tool-enhance 0.5.2 → 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 +6 -5
- package/src/__tests__/background-lifecycle.test.ts +10 -6
- package/src/__tests__/index.test.ts +3 -5
- package/src/__tests__/maintenance-once.test.ts +2 -2
- 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 +33 -69
- 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.
|
|
31
|
+
"@xyz-agent/extension-protocol": "0.11.0",
|
|
32
|
+
"@zhushanwen/pi-ext-guards": "0.4.0",
|
|
32
33
|
"@zhushanwen/pi-extension-logger": "0.6.0",
|
|
33
34
|
"@zhushanwen/pi-file-lock": "0.4.0",
|
|
34
|
-
"@zhushanwen/pi-llm-shared": "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.7.
|
|
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,6 +1,6 @@
|
|
|
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 累积的真实形态)
|
|
@@ -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
|
}
|
|
@@ -8,14 +8,18 @@
|
|
|
8
8
|
* ③ fork 后任务完成通知写进新 session,旧 session 文件的 register 成僵尸。
|
|
9
9
|
*
|
|
10
10
|
* 收尾写法(权威路径):直接 pi.appendEntry("pending:unregister", {id, reason,
|
|
11
|
-
* status})
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* status})——**appendEntry 即唯一权威路径**:appendEntry 同步入账(pi dist 实证)
|
|
12
|
+
* 且不依赖 listener 存活,差集消费方 goal 从持久化 entries 算差集
|
|
13
|
+
* (agent-end.ts getEntries()),对守卫直接生效,无不一致窗口。(尽力补 emit 的
|
|
14
|
+
* 第二写路径已随 ext-simplify-13 删除:pending unregister listener 的落盘前置
|
|
15
|
+
* isPendingActive 对 entries 现算——其内存 registry/rebuild 已随 ext-simplify-12
|
|
16
|
+
* 删除——而对账 appendEntry 同步入账先于 emit 执行,emit 到达时该 id 必已注销
|
|
17
|
+
* = 恒 no-op 死路径。)
|
|
18
|
+
*
|
|
19
|
+
* 差集判据单点(ext-simplify-13):collectActivePendingIds(protocol
|
|
20
|
+
* pending-entries 模块,与 pending-notifications 守卫判据同源)+ bt- 前缀过滤;
|
|
21
|
+
* 本地差集副本已删除。task_id 前缀亦消费 protocol 契约常量
|
|
22
|
+
* BACKGROUND_TASK_ID_PREFIX(§2.3,区别于 subagent-workflow 的 bg-/run-)。
|
|
19
23
|
*
|
|
20
24
|
* 终态判据:registry state ∈ {exited, orphaned},或(state=running/killing 且
|
|
21
25
|
* kill(pid,0) 判死:收殓/写盘失败遗留的 running 条目按事实终态处理)。
|
|
@@ -27,24 +31,26 @@
|
|
|
27
31
|
* 语义覆盖孤儿终态由 runtime 异步写入的时序窗口。
|
|
28
32
|
*/
|
|
29
33
|
|
|
34
|
+
import {
|
|
35
|
+
BACKGROUND_TASK_ID_PREFIX,
|
|
36
|
+
collectActivePendingIds,
|
|
37
|
+
mapReasonToStatus,
|
|
38
|
+
} from "@xyz-agent/extension-protocol";
|
|
39
|
+
import { isPidAlive } from "@xyz-agent/extension-protocol/background-task";
|
|
40
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
30
41
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
31
42
|
|
|
32
|
-
import { isPidAlive } from "../kill-tree.ts";
|
|
33
43
|
import { toPendingReason } from "./notify.ts";
|
|
34
44
|
import { getRegistryPath, readRegistry } from "./registry.ts";
|
|
35
45
|
import { isActiveState, isTerminalState, type RegistryEntry } from "./types.ts";
|
|
36
46
|
|
|
37
47
|
const logger = getLogger("base-tool-enhance");
|
|
38
48
|
|
|
39
|
-
/** 本包 task_id 前缀(§2.3,区别于 subagent-workflow 的 bg-/run-)。 */
|
|
40
|
-
export const BTE_TASK_ID_PREFIX = "bt-";
|
|
41
|
-
|
|
42
49
|
/**
|
|
43
50
|
* 对账依赖的最小 pi 面(结构兼容 ExtensionAPI 的子集;测试注入不造完整 pi)。
|
|
44
51
|
*/
|
|
45
52
|
export interface ReconcilePi {
|
|
46
53
|
appendEntry(customType: string, data?: unknown): void;
|
|
47
|
-
events: { emit(channel: string, data: unknown): void };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/** 对账结果(日志 + 测试断言面)。 */
|
|
@@ -55,48 +61,9 @@ export interface ReconcileResult {
|
|
|
55
61
|
skipped: string[];
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
/** entries 的最小可识别形状(duck-typed,与 pending-notifications state.ts EntryLike 同式)。 */
|
|
59
|
-
interface EntryLike {
|
|
60
|
-
customType?: string;
|
|
61
|
-
data?: { id?: unknown } | null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function readEntryId(raw: unknown): string | undefined {
|
|
65
|
-
if (!raw || typeof raw !== "object") return undefined;
|
|
66
|
-
const entry = raw as EntryLike;
|
|
67
|
-
const id = entry.data?.id;
|
|
68
|
-
return typeof id === "string" ? id : undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* 差集:bt- 前缀 pending:register 且无对应 pending:unregister 的 id 集合。
|
|
73
|
-
* 只认 bt- 前缀——workflow/subagent 的 register 不归本包对账(差集算法与
|
|
74
|
-
* pending-notifications countActiveFromEntries 同构:unregister 全局抵消 + register
|
|
75
|
-
* 去重,id 全局唯一前提)。
|
|
76
|
-
*/
|
|
77
|
-
export function collectUnsettledTaskIds(entries: unknown[]): Set<string> {
|
|
78
|
-
const unregistered = new Set<string>();
|
|
79
|
-
for (const raw of entries) {
|
|
80
|
-
if (!raw || typeof raw !== "object") continue;
|
|
81
|
-
if ((raw as EntryLike).customType !== "pending:unregister") continue;
|
|
82
|
-
const id = readEntryId(raw);
|
|
83
|
-
if (id !== undefined && id.startsWith(BTE_TASK_ID_PREFIX)) unregistered.add(id);
|
|
84
|
-
}
|
|
85
|
-
const active = new Set<string>();
|
|
86
|
-
for (const raw of entries) {
|
|
87
|
-
if (!raw || typeof raw !== "object") continue;
|
|
88
|
-
if ((raw as EntryLike).customType !== "pending:register") continue;
|
|
89
|
-
const id = readEntryId(raw);
|
|
90
|
-
if (id === undefined || !id.startsWith(BTE_TASK_ID_PREFIX)) continue;
|
|
91
|
-
if (unregistered.has(id) || active.has(id)) continue;
|
|
92
|
-
active.add(id);
|
|
93
|
-
}
|
|
94
|
-
return active;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
64
|
/**
|
|
98
65
|
* 对账主体(同步:readRegistry / kill(pid,0) / appendEntry 均同步,session_start
|
|
99
|
-
* 链内毫秒级完成)。每个僵尸任务 appendEntry
|
|
66
|
+
* 链内毫秒级完成)。每个僵尸任务 appendEntry 一次(唯一权威写路径)。
|
|
100
67
|
*/
|
|
101
68
|
export function reconcilePendingEntries(
|
|
102
69
|
pi: ReconcilePi,
|
|
@@ -105,7 +72,7 @@ export function reconcilePendingEntries(
|
|
|
105
72
|
entries: unknown[],
|
|
106
73
|
): ReconcileResult {
|
|
107
74
|
const result: ReconcileResult = { reconciled: 0, skipped: [] };
|
|
108
|
-
const unsettled =
|
|
75
|
+
const unsettled = collectActivePendingIds(entries, { idPrefix: BACKGROUND_TASK_ID_PREFIX });
|
|
109
76
|
if (unsettled.size === 0) return result;
|
|
110
77
|
|
|
111
78
|
const registry = readRegistry(getRegistryPath(dataDir, sessionId));
|
|
@@ -125,23 +92,20 @@ export function reconcilePendingEntries(
|
|
|
125
92
|
}
|
|
126
93
|
const pendingReason = settledPendingReason(entry);
|
|
127
94
|
try {
|
|
128
|
-
|
|
95
|
+
// status 经 protocol mapReasonToStatus 单点映射(ext-simplify-17 D10):与
|
|
96
|
+
// pending-notifications unregister listener 同一函数——原 status === reason 的
|
|
97
|
+
// identity 假设在非 identity reason(budget_limited→failed 等)上会静默漂移
|
|
98
|
+
pi.appendEntry("pending:unregister", {
|
|
99
|
+
id,
|
|
100
|
+
reason: pendingReason,
|
|
101
|
+
status: mapReasonToStatus(pendingReason),
|
|
102
|
+
});
|
|
129
103
|
} catch (err) {
|
|
130
104
|
logger.warn("reconcile appendEntry failed; retry on next session_start", {
|
|
131
|
-
detail: { id, err:
|
|
105
|
+
detail: { id, err: toErrorMessage(err) },
|
|
132
106
|
});
|
|
133
107
|
continue;
|
|
134
108
|
}
|
|
135
|
-
// 尽力补 emit(幂等兜底,纯日志性质:listener 落盘前置 isPendingActive 对
|
|
136
|
-
// entries 现算,该 id 已注销即跳过——appendEntry 同步入账已无不一致窗口;
|
|
137
|
-
// 失败无害)
|
|
138
|
-
try {
|
|
139
|
-
pi.events.emit("pending:unregister", { id, reason: pendingReason });
|
|
140
|
-
} catch (err) {
|
|
141
|
-
logger.debug("reconcile best-effort emit failed (harmless)", {
|
|
142
|
-
detail: { id, err: err instanceof Error ? err.message : String(err) },
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
109
|
result.reconciled++;
|
|
146
110
|
}
|
|
147
111
|
if (result.reconciled > 0) {
|
|
@@ -159,10 +123,10 @@ function isTerminalByRegistry(entry: RegistryEntry): boolean {
|
|
|
159
123
|
}
|
|
160
124
|
|
|
161
125
|
/**
|
|
162
|
-
* 收尾 reason
|
|
126
|
+
* 收尾 reason 映射(reason→status 的第二跳在写点经 protocol mapReasonToStatus 单点):
|
|
163
127
|
* - exited:按条目 reason/exitCode 走 toPendingReason(与 exit 边沿 emit 同一映射,
|
|
164
|
-
* 两路径写出的 entry 语义一致);reason 缺失按
|
|
165
|
-
*
|
|
128
|
+
* 两路径写出的 entry 语义一致);reason 缺失按 cancelled 处理(防御分支,正常路径
|
|
129
|
+
* finalize 必写 reason)
|
|
166
130
|
* - orphaned / running+判死:cancelled(任务非自身成败地终止/消失)
|
|
167
131
|
*/
|
|
168
132
|
function settledPendingReason(entry: RegistryEntry): "completed" | "failed" | "time_limited" | "cancelled" {
|
package/src/background/poller.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* 可查、对账可补),不加同步握手。
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { isPidAlive } from "
|
|
16
|
+
import { isPidAlive } from "@xyz-agent/extension-protocol/background-task";
|
|
17
|
+
|
|
17
18
|
import { readTailSummary } from "./output-tail.ts";
|
|
18
19
|
import { readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
19
20
|
import { finalizeTask, getActiveTasks } from "./task-store.ts";
|