@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.5.2",
4
- "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design: docs/design/base-tool-enhance.md)",
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.9.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.7.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.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
- // kill-tree 转发真实杀 + 记录调用(timeout 分支断言 killProcessTree 被调)
20
+ // 进程原语(ext-simplify-13 后单点在 protocol 子出口)转发真实杀 + 记录调用
21
+ //(timeout 分支断言 killProcessTree 被调)
21
22
  const { killTreeCalls } = vi.hoisted(() => ({ killTreeCalls: [] as number[] }));
22
- vi.mock("../kill-tree.ts", async (importOriginal) => {
23
- const orig = await importOriginal<typeof import("../kill-tree.ts")>();
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
- // 对账之外尽力补一次 emit(幂等兜底,纯日志性质;失败无害)
125
- expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
126
- id: "bt-1700000000-idx001",
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/design/file-lock-unification-and-reaper-
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 "../kill-tree.ts";
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
- // 差集收集 / 三类僵尸场景 appendEntry 权威路径 + 尽力 emit / 活任务与缺条目保守跳过
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("collectUnsettledTaskIds (bt- prefixed register/unregister diff)", () => {
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 = collectUnsettledTaskIds([
62
- registerEntry("bt-a"),
63
- registerEntry("bt-b"),
64
- { customType: "pending:unregister", data: { id: "bt-b", reason: "completed" } },
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 = collectUnsettledTaskIds([
71
- registerEntry("bg-1"),
72
- registerEntry("run-x-1"),
73
- registerEntry("bt-a"),
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 = collectUnsettledTaskIds([
80
- null,
81
- undefined,
82
- { customType: "pending:register" }, // data 缺失
83
- { customType: "pending:register", data: { id: 42 } }, // id 非字符串
84
- registerEntry("bt-a"),
85
- registerEntry("bt-a"),
86
- { customType: "other" },
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 + best-effort emit", () => {
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
- expect(pi.appendEntry).toHaveBeenCalledWith(
131
- "pending:unregister",
132
- { id: entry.taskId, reason: expectedReason, status: expectedReason },
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
+ });
@@ -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: err instanceof Error ? err.message : String(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: err instanceof Error ? err.message : String(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: err instanceof Error ? err.message : String(err),
164
+ err: toErrorMessage(err),
164
165
  },
165
166
  });
166
167
  }
@@ -1,84 +1,28 @@
1
1
  /**
2
- * 输出文件 tail 读取(D7:输出落文件不占内存,查询时按需读尾部)。
2
+ * 输出文件 tail 读取薄壳(D7:输出落文件不占内存,查询时按需读尾部)。
3
3
  *
4
- * 截断规则与 pi 内置 bash 一致:末尾 2000 行 / 50KB 先到为准(bash.js truncate.ts
5
- * DEFAULT_MAX_LINES/DEFAULT_MAX_BYTES)。实现从文件末尾按字节窗口读(不整读大文件,
6
- * O(maxBytes) 而非 O(fileSize))。
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 { openSync, readSync, closeSync, statSync } from "node:fs";
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 = readOutputTail(outputFile, SUMMARY_TAIL_LINES, maxChars);
24
+ const tail = readOutputTailPrimitive(outputFile, { maxLines: SUMMARY_TAIL_LINES, maxBytes: maxChars });
81
25
  if (tail === undefined) return undefined;
82
- const compact = tail.output.trim();
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})——**不走 bus emit 作为权威**:appendEntry 不依赖 listener 存活,两个
12
- * extension 的加载/派发顺序(CLI --extension 顺序用户可控)无保障,顺序反转时
13
- * emit 无人接收、对账失效。差集消费方 goal 从持久化 entries 算差集
14
- * (agent-end.ts getEntries()),appendEntry 同步入账(pi dist 实证)对守卫直接
15
- * 生效,无不一致窗口。appendEntry 之外尽力补一次 emit(幂等兜底,纯日志性质:
16
- * pending-notifications 的 unregister listener 落盘前置判断 isPendingActive 对
17
- * getEntries() 现算——其内存 registry/rebuild 已随 ext-simplify-12 删除,emit
18
- * 到达时该 id 已注销即跳过,失败无害)。
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 一次 + 尽力 emit 一次。
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 = collectUnsettledTaskIds(entries);
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
- pi.appendEntry("pending:unregister", { id, reason: pendingReason, status: pendingReason });
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: err instanceof Error ? err.message : String(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/status 映射:
126
+ * 收尾 reason 映射(reason→status 的第二跳在写点经 protocol mapReasonToStatus 单点):
163
127
  * - exited:按条目 reason/exitCode 走 toPendingReason(与 exit 边沿 emit 同一映射,
164
- * 两路径写出的 entry 语义一致);reason 缺失按 pending mapReasonToStatus 的
165
- * default=completed 语义处理(防御分支,正常路径 finalize 必写 reason)
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" {
@@ -13,7 +13,8 @@
13
13
  * 可查、对账可补),不加同步握手。
14
14
  */
15
15
 
16
- import { isPidAlive } from "../kill-tree.ts";
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";