@zhushanwen/pi-base-tool-enhance 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
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,8 +28,8 @@
|
|
|
28
28
|
"skills/"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@zhushanwen/extension-protocol": "0.
|
|
32
|
-
"@zhushanwen/pi-delivery-port": "0.
|
|
31
|
+
"@zhushanwen/extension-protocol": "0.24.0",
|
|
32
|
+
"@zhushanwen/pi-delivery-port": "0.3.0",
|
|
33
33
|
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
34
34
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
35
35
|
"@zhushanwen/pi-file-lock": "0.4.1",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
44
44
|
"@earendil-works/pi-tui": "^1.0.0",
|
|
45
45
|
"typebox": "1.3.27",
|
|
46
|
-
"@zhushanwen/pi-pending-notifications": "0.
|
|
46
|
+
"@zhushanwen/pi-pending-notifications": "0.11.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@earendil-works/pi-tui": {
|
|
@@ -1,21 +1,57 @@
|
|
|
1
|
-
// src/__tests__/background-lifecycle.test.ts —— background
|
|
1
|
+
// src/__tests__/background-lifecycle.test.ts —— background 核心生命周期:
|
|
2
2
|
// 真实 spawn 边沿收尾 / exit 事件自动收尾 / killing intent 双侧 / timeout / 并发上限 /
|
|
3
3
|
// 收殓 / D15 abort / bash_output / bash_kill(黑盒:经工具 execute 断言使用者可见行为)
|
|
4
|
+
//
|
|
5
|
+
// 轨道拆分(单测层极端测试整治第二波):
|
|
6
|
+
// - 单测层(本文件主体)= 进程端点桩化:vi.mock 把 node:child_process 的 spawn 换成
|
|
7
|
+
// 桩 ChildProcess(EventEmitter + pid/exitCode/unref),protocol 判活 / start time
|
|
8
|
+
// 读取对桩 pid 截获,kill-tree 只记录不转发。被测逻辑——单例表、registry 锁内
|
|
9
|
+
// RMW、killing intent 双侧标记、timeout 定时器杀链编排、exit 边沿收尾、通知入账
|
|
10
|
+
// ——全部真跑;exit 事件由 emitFakeExit 显式派发(exitCode 先落再派发,对齐真
|
|
11
|
+
// ChildProcess 语义),确定性同步、无等待窗口。
|
|
12
|
+
// - 集成组(文件尾门控 describe,TAIJI_TEST_INTEGRATION=1 真跑)= 5 条真子进程核心:
|
|
13
|
+
// spawn+自然退出全链 / bash_kill 真杀进程树 / timeout 真杀链 / 进程退出收殓真杀 /
|
|
14
|
+
// 真实时钟下自然完成不补杀。真子进程满载下延迟不可预估(无确定性完成信号),
|
|
15
|
+
// 属「代码 + OS 组合行为」,本地默认跳过(门控 SSOT = test-guard/integration-gate.ts)。
|
|
16
|
+
// - 预算:单测用例走 vitest 默认;集成组用例单独挂 20s(原文件级预算随拆组下沉到
|
|
17
|
+
// 用例级,非抬升),轮询 deadline 常量(10s / 8s)仅集成组消费。
|
|
4
18
|
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
19
|
import { tmpdir } from "node:os";
|
|
6
20
|
import { join } from "node:path";
|
|
21
|
+
import { EventEmitter } from "node:events";
|
|
7
22
|
|
|
8
23
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
24
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
import { INTEGRATION_TESTS_ENABLED, INTEGRATION_TESTS_SKIP_REASON } from "../../../../../test-guard/integration-gate.ts";
|
|
12
27
|
|
|
13
28
|
// getAgentDir → 测试临时数据目录(工具 execute 内部调用,不写真 ~/.pi/agent)。
|
|
14
29
|
// [session-store-layout-inversion 域 5] 工具的 dataDir 来源已换为 resolveTaijiDataDir()
|
|
15
30
|
// (env 注入)——getAgentDir mock 随换根退役,改 beforeEach stubEnv + afterEach 还原。
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
//
|
|
32
|
+
// 进程端点桩(单测层):vi.mock 两处——
|
|
33
|
+
// 1) node:child_process.spawn:spawnMode.real=false(默认)返回桩 ChildProcess,
|
|
34
|
+
// 集成组 describe 的 beforeEach 置 true 走真 spawn;
|
|
35
|
+
// 2) @zhushanwen/extension-protocol/background-task 的进程原语(kill-tree 发令 /
|
|
36
|
+
// pid 判活 / start time 读取):桩 pid 截获(fakePids 登记表),真 pid 原样转发
|
|
37
|
+
// (timeout 分支断言 killProcessTree 被调;集成组真杀)。
|
|
38
|
+
const { killTreeCalls, fakePids, spawnMode, fakeChildSeq } = vi.hoisted(() => ({
|
|
39
|
+
killTreeCalls: [] as number[],
|
|
40
|
+
fakePids: new Map<number, { alive: boolean; startSec?: number }>(),
|
|
41
|
+
spawnMode: { real: false },
|
|
42
|
+
fakeChildSeq: { n: 0 },
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
46
|
+
const orig = await importOriginal<typeof import("node:child_process")>();
|
|
47
|
+
const realSpawn = orig.spawn;
|
|
48
|
+
return {
|
|
49
|
+
...orig,
|
|
50
|
+
spawn: (...args: Parameters<typeof realSpawn>) =>
|
|
51
|
+
spawnMode.real ? realSpawn(...args) : fakeSpawn(),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
19
55
|
vi.mock("@zhushanwen/extension-protocol/background-task", async (importOriginal) => {
|
|
20
56
|
const orig = await importOriginal<
|
|
21
57
|
typeof import("@zhushanwen/extension-protocol/background-task")
|
|
@@ -24,8 +60,18 @@ vi.mock("@zhushanwen/extension-protocol/background-task", async (importOriginal)
|
|
|
24
60
|
...orig,
|
|
25
61
|
killProcessTree: (pid: number, onFallback?: (step: string, err: unknown) => void): void => {
|
|
26
62
|
killTreeCalls.push(pid);
|
|
63
|
+
// 桩 pid 上无进程可杀,不转发真信号;真 pid(集成组真杀 / 误杀守卫断言对象)原样转发
|
|
64
|
+
if (fakePids.has(pid)) return;
|
|
27
65
|
orig.killProcessTree(pid, onFallback);
|
|
28
66
|
},
|
|
67
|
+
isPidAlive: (pid: number): boolean => {
|
|
68
|
+
const fake = fakePids.get(pid);
|
|
69
|
+
return fake === undefined ? orig.isPidAlive(pid) : fake.alive;
|
|
70
|
+
},
|
|
71
|
+
getProcessStartTimeSec: (pid: number): number | undefined => {
|
|
72
|
+
const fake = fakePids.get(pid);
|
|
73
|
+
return fake === undefined ? orig.getProcessStartTimeSec(pid) : fake.startSec;
|
|
74
|
+
},
|
|
29
75
|
};
|
|
30
76
|
});
|
|
31
77
|
|
|
@@ -36,7 +82,7 @@ import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
|
|
|
36
82
|
import { handleTaskExit, refreshPiReference, resetNotifyForTest } from "../background/notify.ts";
|
|
37
83
|
import { setOnTaskExit } from "../background/exit-collector.ts";
|
|
38
84
|
import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "../background/registry.ts";
|
|
39
|
-
import type { RegistryEntry } from "../background/types.ts";
|
|
85
|
+
import type { BackgroundTask, RegistryEntry } from "../background/types.ts";
|
|
40
86
|
import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
|
|
41
87
|
import { clearTaskStoreForTest, getActiveTasks, getTask, markKillingIntent } from "../background/task-store.ts";
|
|
42
88
|
import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
|
|
@@ -72,6 +118,45 @@ function sleep(ms: number): Promise<void> {
|
|
|
72
118
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
119
|
}
|
|
74
120
|
|
|
121
|
+
// ──────────────────────── 进程端点桩(单测层专用) ────────────────────────
|
|
122
|
+
|
|
123
|
+
/** 桩 pid 基数:远离真实 pid 区间,fakePids 登记即截获判活/身份读取,与真 pid 互不串扰。 */
|
|
124
|
+
const FAKE_PID_BASE = 4_000_000;
|
|
125
|
+
/**
|
|
126
|
+
* 桩进程 start time(epoch 秒,恒定过去时刻):镜像 macOS ps 可读形态(spawn 即得
|
|
127
|
+
* pidStartTime);取过去值保证 ≤ 任何真实 startedAt 的秒级降级比较恒匹配
|
|
128
|
+
* (pidStartMatchesRegistered 的降级分支用「actual ≤ floor(startedAt/1000)」)。
|
|
129
|
+
*/
|
|
130
|
+
const FAKE_START_SEC = 1_700_000_000;
|
|
131
|
+
|
|
132
|
+
/** 桩 ChildProcess 最小形状:BackgroundTask 只消费 pid / exitCode / unref / on('exit')。 */
|
|
133
|
+
type FakeChildProcess = EventEmitter & { pid: number; exitCode: number | null; unref: () => void };
|
|
134
|
+
|
|
135
|
+
/** 桩 spawn:登记 fake pid(默认存活 + 恒定 start time),返回可派发 exit 的假 ChildProcess。 */
|
|
136
|
+
function fakeSpawn(): FakeChildProcess {
|
|
137
|
+
const pid = FAKE_PID_BASE + ++fakeChildSeq.n;
|
|
138
|
+
fakePids.set(pid, { alive: true, startSec: FAKE_START_SEC });
|
|
139
|
+
return Object.assign(new EventEmitter(), {
|
|
140
|
+
pid,
|
|
141
|
+
exitCode: null as number | null,
|
|
142
|
+
unref: () => {},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 派发桩进程退出(单测层的 exit 事件边沿):exitCode 先落再 emit——对齐真
|
|
148
|
+
* ChildProcess 语义(finalizeExitedTask 从 child.exitCode 读码)。同步触发全部
|
|
149
|
+
* 收尾链(tail 摘要 → reason 判定 → 两侧终态写 → onTaskExit 回调),确定性完成。
|
|
150
|
+
*/
|
|
151
|
+
function emitFakeExit(task: BackgroundTask, exitCode: number | null): void {
|
|
152
|
+
const child = task.child as FakeChildProcess | undefined;
|
|
153
|
+
if (child === undefined) throw new Error("fake child missing (already finalized)");
|
|
154
|
+
child.exitCode = exitCode;
|
|
155
|
+
child.emit("exit", exitCode, null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ──────────────────────── 集成组轮询原语(仅门控 describe 消费) ────────────────────────
|
|
159
|
+
|
|
75
160
|
/**
|
|
76
161
|
* 真实子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,deadline 放宽到 10s;
|
|
77
162
|
* timeout 路径单独放宽到 8s(任务语义内最慢的等待对象)。
|
|
@@ -105,6 +190,13 @@ function spawnBg(command: string, extra: { timeoutSec?: number; maxConcurrent?:
|
|
|
105
190
|
});
|
|
106
191
|
}
|
|
107
192
|
|
|
193
|
+
/** spawn 后台命令并断言受理成功(拒绝即炸出用例——正向用例样板收敛点)。 */
|
|
194
|
+
function spawnOk(command: string, extra: { timeoutSec?: number; maxConcurrent?: number } = {}) {
|
|
195
|
+
const spawned = spawnBg(command, extra);
|
|
196
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
197
|
+
return spawned.task;
|
|
198
|
+
}
|
|
199
|
+
|
|
108
200
|
/** 手写 registry 条目(模拟他进程/历史 session 写入的形状)。 */
|
|
109
201
|
function makeRegistryEntry(overrides: Partial<RegistryEntry> & { taskId: string }): RegistryEntry {
|
|
110
202
|
return {
|
|
@@ -126,7 +218,11 @@ beforeEach(() => {
|
|
|
126
218
|
afterEach(() => {
|
|
127
219
|
// env stub 逐用例还原(防同 worker 跨文件残留)
|
|
128
220
|
vi.unstubAllEnvs();
|
|
129
|
-
//
|
|
221
|
+
// 进程端点桩复位:集成组的 real 开关关回、桩 pid 登记表清空(防截获串到下一用例)
|
|
222
|
+
spawnMode.real = false;
|
|
223
|
+
fakePids.clear();
|
|
224
|
+
// 残留活跃任务先杀干净再清表(防句柄/进程泄漏影响后续测试);桩 pid 无进程可杀
|
|
225
|
+
// (kill ESRCH 落 catch),真 pid(集成组)正常回收
|
|
130
226
|
for (const task of getActiveTasks()) {
|
|
131
227
|
try {
|
|
132
228
|
process.kill(-task.pid, "SIGKILL");
|
|
@@ -152,26 +248,26 @@ afterAll(() => {
|
|
|
152
248
|
rmSync(DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
153
249
|
});
|
|
154
250
|
|
|
155
|
-
|
|
251
|
+
// ──────────────────────── 单测层:spawn + exit 边沿收尾(进程端点桩化) ────────────────────────
|
|
252
|
+
|
|
253
|
+
describe("spawn + exit edge finalization (stub process endpoint)", () => {
|
|
156
254
|
it("short task: running → exited with exitCode 0, output file readable, registry terminal", async () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
160
|
-
const { task } = spawned;
|
|
255
|
+
const task = spawnOk("sleep 0.1 && echo done");
|
|
256
|
+
// spawn 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
|
|
161
257
|
expect(task.state).toBe("running");
|
|
162
258
|
expect(existsSync(task.outputFile)).toBe(true);
|
|
163
|
-
// registry 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
|
|
164
259
|
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
165
260
|
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.ownerPiPid).toBe(process.pid);
|
|
166
261
|
|
|
167
|
-
//
|
|
168
|
-
|
|
262
|
+
// 桩端点:命令「输出」由测试直写输出文件(真 spawn 下由子进程写,内容同源)
|
|
263
|
+
writeFileSync(task.outputFile, "done\n", "utf8");
|
|
264
|
+
emitFakeExit(task, 0);
|
|
169
265
|
|
|
170
266
|
const finalized = getTask(task.taskId);
|
|
171
267
|
expect(finalized?.state).toBe("exited");
|
|
172
268
|
expect(finalized?.exitCode).toBe(0);
|
|
173
269
|
expect(finalized?.reason).toBe("natural");
|
|
174
|
-
expect(finalized?.durationMs).
|
|
270
|
+
expect(finalized?.durationMs).toBeGreaterThanOrEqual(0);
|
|
175
271
|
|
|
176
272
|
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
|
|
177
273
|
expect(registryEntry?.state).toBe("exited");
|
|
@@ -182,33 +278,27 @@ describe("real spawn lifecycle (exit event edge finalization)", () => {
|
|
|
182
278
|
expect(detail.exitCode).toBe(0);
|
|
183
279
|
});
|
|
184
280
|
|
|
185
|
-
it("nonzero exit code is surfaced (exitCode 1)",
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
189
|
-
const { task } = spawned;
|
|
190
|
-
await pollUntilTicked(() => getTask(task.taskId)?.exitCode === 3, POLL_DEADLINE_MS, "exitCode to surface");
|
|
281
|
+
it("nonzero exit code is surfaced (exitCode 1)", () => {
|
|
282
|
+
const task = spawnOk("sleep 0.1; exit 3");
|
|
283
|
+
emitFakeExit(task, 3);
|
|
191
284
|
expect(getTask(task.taskId)?.exitCode).toBe(3);
|
|
192
|
-
|
|
193
|
-
expect(registryEntry?.exitCode).toBe(3);
|
|
285
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.exitCode).toBe(3);
|
|
194
286
|
});
|
|
195
287
|
|
|
196
|
-
it("onTaskExit callback (M3 hook point) fires with the finalized task",
|
|
288
|
+
it("onTaskExit callback (M3 hook point) fires with the finalized task", () => {
|
|
197
289
|
const seen: string[] = [];
|
|
198
290
|
setOnTaskExit((task) => seen.push(`${task.taskId}:${task.state}:${task.reason}`));
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
291
|
+
const task = spawnOk("true");
|
|
292
|
+
emitFakeExit(task, 0);
|
|
293
|
+
// 桩派发同步走完收尾链(无等待窗口),回调即时可见
|
|
202
294
|
expect(seen).toHaveLength(1);
|
|
203
|
-
expect(seen[0]).toBe(`${
|
|
295
|
+
expect(seen[0]).toBe(`${task.taskId}:exited:natural`);
|
|
204
296
|
});
|
|
205
297
|
|
|
206
|
-
it("spawned entry carries pidStartTime for kill-side precise comparison (M5→M3)",
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// 单例表与 registry 两侧均含字段(epoch 秒;ps 可用平台读取成功)
|
|
211
|
-
expect(typeof task.pidStartTime).toBe("number");
|
|
298
|
+
it("spawned entry carries pidStartTime for kill-side precise comparison (M5→M3)", () => {
|
|
299
|
+
const task = spawnOk("sleep 30");
|
|
300
|
+
// 桩端点:spawn 即从身份原语读 start time 进条目(真 ps 读取形态由集成轨覆盖)
|
|
301
|
+
expect(task.pidStartTime).toBe(FAKE_START_SEC);
|
|
212
302
|
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId) as
|
|
213
303
|
| { pidStartTime?: number }
|
|
214
304
|
| undefined;
|
|
@@ -216,6 +306,8 @@ describe("real spawn lifecycle (exit event edge finalization)", () => {
|
|
|
216
306
|
});
|
|
217
307
|
});
|
|
218
308
|
|
|
309
|
+
// ──────────────────────── 单测层:bash_output 工具 ────────────────────────
|
|
310
|
+
|
|
219
311
|
describe("bash_output tool", () => {
|
|
220
312
|
it("description pins the auto-notify contract (on-demand checks only, DO NOT bash sleep)", () => {
|
|
221
313
|
// 与 bash description / 启动回执同一契约话术(统一 "DO NOT bash sleep")——删改必须红灯
|
|
@@ -224,9 +316,8 @@ describe("bash_output tool", () => {
|
|
|
224
316
|
});
|
|
225
317
|
|
|
226
318
|
it("list merges store + registry terminal entries, store wins on same id", async () => {
|
|
227
|
-
// 单例表:一个 running
|
|
228
|
-
const
|
|
229
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
319
|
+
// 单例表:一个 running 桩任务
|
|
320
|
+
const task = spawnOk("sleep 5");
|
|
230
321
|
// registry 手写:另一个终态历史条目(不在单例表——模拟已被 LRU 淘汰后回落)
|
|
231
322
|
writeRegistryEntry(REGISTRY_PATH, {
|
|
232
323
|
taskId: "bt-9000-hist",
|
|
@@ -245,14 +336,14 @@ describe("bash_output tool", () => {
|
|
|
245
336
|
// registry 手写:与单例表同 id 的过期 exited 版本(须被单例表 running 覆盖)
|
|
246
337
|
writeRegistryEntry(
|
|
247
338
|
REGISTRY_PATH,
|
|
248
|
-
taskToRegistryEntry({ ...
|
|
339
|
+
taskToRegistryEntry({ ...task, state: "exited" as const, ownerPiPid: 1 }),
|
|
249
340
|
);
|
|
250
341
|
|
|
251
342
|
const listed = JSON.parse(await outputTool({})) as {
|
|
252
343
|
tasks: Array<{ task_id: string; state: string; command: string }>;
|
|
253
344
|
};
|
|
254
345
|
const byId = new Map(listed.tasks.map((t) => [t.task_id, t]));
|
|
255
|
-
expect(byId.get(
|
|
346
|
+
expect(byId.get(task.taskId)?.state).toBe("running"); // 单例表优先
|
|
256
347
|
expect(byId.get("bt-9000-hist")?.state).toBe("exited");
|
|
257
348
|
// 命令展示截断(前 80 字符)
|
|
258
349
|
expect(byId.get("bt-9000-hist")?.command.length).toBeLessThanOrEqual(81);
|
|
@@ -293,11 +384,8 @@ describe("bash_output tool", () => {
|
|
|
293
384
|
});
|
|
294
385
|
|
|
295
386
|
it("deleted output file degrades to <lost> without crashing (§3.6)", async () => {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
299
|
-
const { task } = spawned;
|
|
300
|
-
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
|
|
387
|
+
const task = spawnOk("sleep 0.1 && echo gone");
|
|
388
|
+
emitFakeExit(task, 0);
|
|
301
389
|
unlinkSync(task.outputFile);
|
|
302
390
|
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
|
|
303
391
|
output: string;
|
|
@@ -308,34 +396,33 @@ describe("bash_output tool", () => {
|
|
|
308
396
|
});
|
|
309
397
|
|
|
310
398
|
it("output tail respects line cap", async () => {
|
|
311
|
-
// 2100 行输出 → tail 只保留最后 2000
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "output loop to finish");
|
|
399
|
+
// 2100 行输出 → tail 只保留最后 2000 行(输出内容由测试直写,截断逻辑真跑)
|
|
400
|
+
const task = spawnOk("for i in $(seq 1 2100); do echo line$i; done");
|
|
401
|
+
const lines = Array.from({ length: 2100 }, (_, i) => `line${i + 1}`);
|
|
402
|
+
writeFileSync(task.outputFile, `${lines.join("\n")}\n`, "utf8");
|
|
403
|
+
emitFakeExit(task, 0);
|
|
317
404
|
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
|
|
318
405
|
output: string;
|
|
319
406
|
truncated: boolean;
|
|
320
407
|
};
|
|
321
|
-
const
|
|
322
|
-
expect(
|
|
408
|
+
const got = detail.output.split("\n");
|
|
409
|
+
expect(got.length).toBeLessThanOrEqual(2000);
|
|
323
410
|
expect(detail.truncated).toBe(true);
|
|
324
411
|
expect(detail.output).toContain("line2100");
|
|
325
412
|
expect(detail.output).not.toContain("line50\n");
|
|
326
413
|
});
|
|
327
414
|
});
|
|
328
415
|
|
|
416
|
+
// ──────────────────────── 单测层:bash_kill 工具(killing intent 双侧) ────────────────────────
|
|
417
|
+
|
|
329
418
|
describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
330
419
|
it("kill marks BOTH store and registry as killing before the exit event edge lands", async () => {
|
|
331
|
-
const
|
|
332
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
333
|
-
const { task } = spawned;
|
|
420
|
+
const task = spawnOk("sleep 60");
|
|
334
421
|
|
|
335
422
|
const killResult = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
336
423
|
expect(killResult.killed).toBe(true);
|
|
337
424
|
|
|
338
|
-
// exit
|
|
425
|
+
// exit 边沿未派发(sleep 60 语义下任务仍在):两侧 killing 即可见,无倒挂
|
|
339
426
|
expect(getTask(task.taskId)?.state).toBe("killing");
|
|
340
427
|
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
|
|
341
428
|
// kill-tree 确实对该 pid 发过令
|
|
@@ -343,12 +430,10 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
343
430
|
});
|
|
344
431
|
|
|
345
432
|
it("kill edge: intent consumed → exited with reason killed", async () => {
|
|
346
|
-
const
|
|
347
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
348
|
-
const { task } = spawned;
|
|
433
|
+
const task = spawnOk("sleep 60");
|
|
349
434
|
await killTool(task.taskId);
|
|
350
|
-
//
|
|
351
|
-
|
|
435
|
+
// 桩端点派发 kill 后的进程死亡(SIGKILL 终止 = exitCode null)
|
|
436
|
+
emitFakeExit(task, null);
|
|
352
437
|
const finalized = getTask(task.taskId);
|
|
353
438
|
expect(finalized?.state).toBe("exited");
|
|
354
439
|
expect(finalized?.reason).toBe("killed");
|
|
@@ -367,11 +452,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
367
452
|
});
|
|
368
453
|
|
|
369
454
|
it("already exited task → killed:false with exit code", async () => {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
373
|
-
const { task } = spawned;
|
|
374
|
-
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
|
|
455
|
+
const task = spawnOk("sleep 0.1");
|
|
456
|
+
emitFakeExit(task, 0);
|
|
375
457
|
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
376
458
|
expect(result.killed).toBe(false);
|
|
377
459
|
expect(result.reason).toBe("already exited (code 0)");
|
|
@@ -416,10 +498,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
416
498
|
});
|
|
417
499
|
|
|
418
500
|
it("P0-2.2: pidStartTime mismatch (pid reuse suspected) → refuse kill", async () => {
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
const { task } = spawned;
|
|
422
|
-
// 构造不匹配的登记值(真实进程 start time 之外的时间)——pid 活但身份不符
|
|
501
|
+
const task = spawnOk("sleep 30");
|
|
502
|
+
// 构造不匹配的登记值(桩身份原语读到的 start time 之外的时间)——pid 活但身份不符
|
|
423
503
|
task.pidStartTime = (task.pidStartTime ?? 0) + 500;
|
|
424
504
|
|
|
425
505
|
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string; hint?: string };
|
|
@@ -431,24 +511,25 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
431
511
|
});
|
|
432
512
|
|
|
433
513
|
it("P0-2.2: matching pidStartTime → kill proceeds(start time 校验不误拦正常 kill)", async () => {
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
const { task } = spawned;
|
|
437
|
-
// spawn 时读到的真实 start time 原样保留 → 校验通过,正常 kill 路径
|
|
514
|
+
const task = spawnOk("sleep 30");
|
|
515
|
+
// spawn 时身份原语读到的 start time 原样保留 → 校验通过,正常 kill 路径
|
|
438
516
|
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
|
|
439
517
|
expect(result.killed).toBe(true);
|
|
440
518
|
expect(killTreeCalls).toContain(task.pid);
|
|
441
519
|
});
|
|
442
520
|
});
|
|
443
521
|
|
|
522
|
+
// ──────────────────────── 单测层:并发上限(D10) ────────────────────────
|
|
523
|
+
|
|
444
524
|
describe("concurrency cap (D10, default 8)", () => {
|
|
445
|
-
it("rejects the 9th task with the oldest task id in the error",
|
|
525
|
+
it("rejects the 9th task with the oldest task id in the error", () => {
|
|
446
526
|
const tasks: string[] = [];
|
|
447
527
|
for (let i = 0; i < DEFAULT_MAX_CONCURRENT_BACKGROUND; i++) {
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
528
|
+
const task = spawnOk("sleep 30");
|
|
529
|
+
// 桩端点同毫秒连发:直接改写条目 startedAt 保证「最老」判定确定有序
|
|
530
|
+
//(单例表持同一对象引用;真 spawn 形态靠错峰 sleep,此处无需)
|
|
531
|
+
task.startedAt = 1_000 + i;
|
|
532
|
+
tasks.push(task.taskId);
|
|
452
533
|
}
|
|
453
534
|
const ninth = spawnBg("echo should-fail");
|
|
454
535
|
expect(ninth.ok).toBe(false);
|
|
@@ -471,36 +552,29 @@ describe("concurrency cap (D10, default 8)", () => {
|
|
|
471
552
|
});
|
|
472
553
|
});
|
|
473
554
|
|
|
555
|
+
// ──────────────────────── 单测层:显式后台 timeout(D6,fake timers 驱动) ────────────────────────
|
|
556
|
+
|
|
474
557
|
describe("explicit background timeout (D6)", () => {
|
|
475
|
-
it("fires kill-tree at the deadline and finalizes with reason timeout",
|
|
558
|
+
it("fires kill-tree at the deadline and finalizes with reason timeout", () => {
|
|
476
559
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
477
|
-
let
|
|
478
|
-
let pid = -1;
|
|
560
|
+
let finalized: BackgroundTask | undefined;
|
|
479
561
|
try {
|
|
480
562
|
// fake 生效后 spawn:timeout 定时器进 fake 队列,由 advanceTimers 推进
|
|
481
|
-
const
|
|
482
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
483
|
-
taskId = spawned.task.taskId;
|
|
484
|
-
pid = spawned.task.pid;
|
|
563
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
485
564
|
|
|
486
565
|
vi.advanceTimersByTime(1000);
|
|
487
566
|
// 到点:kill-tree 已发令 + 两侧 killing intent(reason 候选 timeout)
|
|
488
|
-
expect(killTreeCalls).toContain(pid);
|
|
489
|
-
expect(getTask(taskId)?.state).toBe("killing");
|
|
490
|
-
expect(getTask(taskId)?.intent?.reason).toBe("timeout");
|
|
491
|
-
expect(readRegistry(REGISTRY_PATH).get(taskId)?.state).toBe("killing");
|
|
567
|
+
expect(killTreeCalls).toContain(task.pid);
|
|
568
|
+
expect(getTask(task.taskId)?.state).toBe("killing");
|
|
569
|
+
expect(getTask(task.taskId)?.intent?.reason).toBe("timeout");
|
|
570
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
|
|
571
|
+
|
|
572
|
+
// 桩端点派发 kill 后的进程死亡:exit 边沿按 intent 收尾 → exited(reason:"timeout")
|
|
573
|
+
emitFakeExit(task, null);
|
|
574
|
+
finalized = getTask(task.taskId);
|
|
492
575
|
} finally {
|
|
493
576
|
vi.useRealTimers();
|
|
494
577
|
}
|
|
495
|
-
|
|
496
|
-
// SIGKILL 已真实发出:exit 事件边沿收尾 → exited(reason:"timeout"),终态由边沿写
|
|
497
|
-
//(timeout 路径满载下 SIGKILL 生效延迟不可预估,deadline 放宽到 8s)
|
|
498
|
-
await pollUntilTicked(
|
|
499
|
-
() => getTask(taskId)?.state === "exited" && getTask(taskId)?.reason === "timeout",
|
|
500
|
-
POLL_DEADLINE_SLOW_MS,
|
|
501
|
-
"timeout finalization",
|
|
502
|
-
);
|
|
503
|
-
const finalized = getTask(taskId);
|
|
504
578
|
expect(finalized?.state).toBe("exited");
|
|
505
579
|
expect(finalized?.reason).toBe("timeout");
|
|
506
580
|
});
|
|
@@ -508,10 +582,8 @@ describe("explicit background timeout (D6)", () => {
|
|
|
508
582
|
it("P0-4/D6-en R2-S1: pid reuse suspected at the deadline → skip kill AND skip intent mark", () => {
|
|
509
583
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
510
584
|
try {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
const { task } = spawned;
|
|
514
|
-
// 构造不匹配的登记值(真实进程 start time 之外的时间)——pid 活但身份不符,
|
|
585
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
586
|
+
// 构造不匹配的登记值(身份原语读到的 start time 之外的时间)——pid 活但身份不符,
|
|
515
587
|
// 同 bash_kill P0-2.2 篡改范式
|
|
516
588
|
task.pidStartTime = (task.pidStartTime ?? 0) + 500;
|
|
517
589
|
|
|
@@ -532,11 +604,9 @@ describe("explicit background timeout (D6)", () => {
|
|
|
532
604
|
it("P0-4/D6-en R2-S1: missing pidStartTime degrades to startedAt check (mismatch → skip kill and intent mark)", () => {
|
|
533
605
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
534
606
|
try {
|
|
535
|
-
const
|
|
536
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
537
|
-
const { task } = spawned;
|
|
607
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
538
608
|
// 模拟 ps 不可用平台的 spawn(无 pidStartTime 字段)→ 降级 startedAt 秒级
|
|
539
|
-
// 比较;startedAt 篡改为 epoch 1
|
|
609
|
+
// 比较;startedAt 篡改为 epoch 1(桩 start time 远大于 0)→ 不匹配
|
|
540
610
|
task.pidStartTime = undefined;
|
|
541
611
|
task.startedAt = 1;
|
|
542
612
|
|
|
@@ -554,11 +624,10 @@ describe("explicit background timeout (D6)", () => {
|
|
|
554
624
|
it("P0-4: degradation match (recent startedAt) → kill proceeds(降级判据不误拦正常 timeout)", () => {
|
|
555
625
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
556
626
|
try {
|
|
557
|
-
const
|
|
558
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
559
|
-
const { task } = spawned;
|
|
627
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
560
628
|
// 无 pidStartTime + 真实 startedAt(登记晚于 spawn → 原进程 start time 必然
|
|
561
|
-
// ≤ floor(startedAt/1000),floor
|
|
629
|
+
// ≤ floor(startedAt/1000),floor 单调性;桩 start time 取恒定过去值同判)→
|
|
630
|
+
// 降级判据匹配,正常发 kill
|
|
562
631
|
task.pidStartTime = undefined;
|
|
563
632
|
|
|
564
633
|
vi.advanceTimersByTime(1000);
|
|
@@ -569,46 +638,42 @@ describe("explicit background timeout (D6)", () => {
|
|
|
569
638
|
}
|
|
570
639
|
});
|
|
571
640
|
|
|
572
|
-
it("natural completion before the deadline cancels the timer (no late kill)",
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
);
|
|
592
|
-
expect(killTreeCalls.length).toBe(before);
|
|
593
|
-
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
641
|
+
it("natural completion before the deadline cancels the timer (no late kill)", () => {
|
|
642
|
+
// fake timers + 桩端点:先自然退出(终态化清 timer)再推进时钟越过 deadline——
|
|
643
|
+
// 「到点不补杀」不变量确定性验证(真进程 + 真实时钟形态由集成组覆盖)
|
|
644
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
645
|
+
try {
|
|
646
|
+
const before = killTreeCalls.length;
|
|
647
|
+
const task = spawnOk("sleep 0.1 && echo quick", { timeoutSec: 1 });
|
|
648
|
+
|
|
649
|
+
emitFakeExit(task, 0);
|
|
650
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
651
|
+
expect(getTask(task.taskId)?.reason).toBe("natural");
|
|
652
|
+
|
|
653
|
+
// 越过 deadline:终态化必须已清 timer(fake 队列中被 clearTimeout 的回调不再触发)
|
|
654
|
+
vi.advanceTimersByTime(5000);
|
|
655
|
+
expect(killTreeCalls.length).toBe(before);
|
|
656
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
657
|
+
} finally {
|
|
658
|
+
vi.useRealTimers();
|
|
659
|
+
}
|
|
594
660
|
});
|
|
595
661
|
});
|
|
596
662
|
|
|
663
|
+
// ──────────────────────── 单测层:D6-en intent 读回(exit 边沿 reason 判定) ────────────────────────
|
|
664
|
+
|
|
597
665
|
describe("D6-en: exit edge finalization reads back registry killing (cross-process UI kill)", () => {
|
|
598
|
-
it("memory intent missing + registry killing → reason killed; AI not woken (no sendMessage)",
|
|
666
|
+
it("memory intent missing + registry killing → reason killed; AI not woken (no sendMessage)", () => {
|
|
599
667
|
const pi = { events: { emit: vi.fn() }, sendMessage: vi.fn(), appendEntry: vi.fn() };
|
|
600
668
|
refreshPiReference(pi as unknown as ExtensionAPI);
|
|
601
669
|
setOnTaskExit(handleTaskExit);
|
|
602
|
-
const
|
|
603
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
604
|
-
const { task } = spawned;
|
|
670
|
+
const task = spawnOk("sleep 30");
|
|
605
671
|
// runtime UI 代杀形态:只写 registry 侧 killing(跨进程,pi 内存表不可见)+
|
|
606
672
|
// 杀进程树,不经本进程 markKillingIntent
|
|
607
673
|
writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
|
|
608
674
|
expect(getTask(task.taskId)?.intent).toBeUndefined(); // 前置:内存无 intent
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to read back killing");
|
|
675
|
+
// 桩端点派发被杀死亡(exit 事件边沿自动收尾,读回 registry killing → killed)
|
|
676
|
+
emitFakeExit(task, null);
|
|
612
677
|
|
|
613
678
|
const finalized = getTask(task.taskId);
|
|
614
679
|
expect(finalized?.state).toBe("exited");
|
|
@@ -622,103 +687,97 @@ describe("D6-en: exit edge finalization reads back registry killing (cross-proce
|
|
|
622
687
|
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
623
688
|
});
|
|
624
689
|
|
|
625
|
-
it("memory intent present wins — registry NOT read back (killed kept while registry stays running)",
|
|
626
|
-
const
|
|
627
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
628
|
-
const { task } = spawned;
|
|
690
|
+
it("memory intent present wins — registry NOT read back (killed kept while registry stays running)", () => {
|
|
691
|
+
const task = spawnOk("sleep 30");
|
|
629
692
|
// 内存预写 killed intent(AI bash_kill 形态),registry 刻意停留 running
|
|
630
693
|
//(模拟 intent 落盘失败)——若实现误读回,会按 running 判 natural
|
|
631
694
|
expect(markKillingIntent(task.taskId, "killed")).toBeDefined();
|
|
632
|
-
// 前置(SIGKILL 前断言——事件模式下死亡即边沿收尾,registry 停留 running
|
|
633
|
-
//(模拟 intent 落盘失败,markKillingIntent 不写盘):若实现误读回会按 running 判 natural
|
|
634
695
|
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
635
|
-
|
|
636
|
-
await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to finalize with intent");
|
|
696
|
+
emitFakeExit(task, null);
|
|
637
697
|
expect(getTask(task.taskId)?.reason).toBe("killed"); // intent 优先,非 natural
|
|
638
698
|
});
|
|
639
699
|
|
|
640
|
-
it("registry read failure (corrupt file) degrades to no-intent natural; finalization not blocked",
|
|
641
|
-
const
|
|
642
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
643
|
-
const { task } = spawned;
|
|
700
|
+
it("registry read failure (corrupt file) degrades to no-intent natural; finalization not blocked", () => {
|
|
701
|
+
const task = spawnOk("sleep 30");
|
|
644
702
|
writeFileSync(REGISTRY_PATH, "{corrupted", "utf8"); // 读回路径解析失败
|
|
645
|
-
|
|
703
|
+
emitFakeExit(task, 0);
|
|
646
704
|
// exit 事件边沿自动收尾(读回失败按无 intent 处理)
|
|
647
|
-
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", 5000, "exit edge to finalize");
|
|
648
705
|
const finalized = getTask(task.taskId);
|
|
649
706
|
expect(finalized?.state).toBe("exited"); // 终态化未被阻塞
|
|
650
707
|
expect(finalized?.reason).toBe("natural"); // 读失败按无 intent 处理
|
|
651
708
|
});
|
|
652
709
|
});
|
|
653
710
|
|
|
711
|
+
// ──────────────────────── 单测层:D6-en/R2-S1 加固(已终态任务到点不补杀不改写) ────────────────────────
|
|
712
|
+
|
|
654
713
|
describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recorded pid is gone", () => {
|
|
655
|
-
it("UI kill × timeout overlap: no timeout mark; exit edge reads back registry killing → killed",
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
"
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
expect(getTask(task.taskId)?.reason).toBe("killed"); // D6-en 读回(非 timeout)
|
|
714
|
+
it("UI kill × timeout overlap: no timeout mark; exit edge reads back registry killing → killed", () => {
|
|
715
|
+
// 桩端点 + fake timers:deadline 前先被 UI 代杀收尾(终态化清 timer),再推进
|
|
716
|
+
// 时钟越过 deadline——「不补杀、不改写 intent」确定性验证(真实时钟形态由集成组覆盖)
|
|
717
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
718
|
+
try {
|
|
719
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
720
|
+
// UI 代杀(跨进程形态):registry 预写 killing + 直接杀进程,不经内存
|
|
721
|
+
writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
|
|
722
|
+
emitFakeExit(task, null);
|
|
723
|
+
// exit 边沿在 deadline 前收尾(D6-en 读回 registry killing → killed)
|
|
724
|
+
expect(getTask(task.taskId)?.reason).toBe("killed");
|
|
725
|
+
|
|
726
|
+
vi.advanceTimersByTime(5000); // 越过 deadline
|
|
727
|
+
// 加固生效:到点时任务已终态 → 不补杀、intent 不被改写为 timeout、reason 保持 killed
|
|
728
|
+
expect(killTreeCalls).not.toContain(task.pid);
|
|
729
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
730
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined();
|
|
731
|
+
expect(getTask(task.taskId)?.reason).toBe("killed"); // D6-en 读回(非 timeout)
|
|
732
|
+
} finally {
|
|
733
|
+
vi.useRealTimers();
|
|
734
|
+
}
|
|
677
735
|
});
|
|
678
736
|
|
|
679
737
|
it("AI bash_kill × timeout overlap: killed intent survives (no 'timed out' misreport)", async () => {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
it("natural death before the deadline: no timeout mark; exit edge finalizes natural (accepted shift)",
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
()
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
expect(getTask(task.taskId)?.reason).toBe("natural"); // 语义微移:natural 非 timeout
|
|
738
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
739
|
+
try {
|
|
740
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
741
|
+
const killed = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
|
|
742
|
+
expect(killed.killed).toBe(true);
|
|
743
|
+
// exit 边沿在 deadline 前收尾(intent 优先 → killed)
|
|
744
|
+
emitFakeExit(task, null);
|
|
745
|
+
expect(getTask(task.taskId)?.reason).toBe("killed");
|
|
746
|
+
|
|
747
|
+
vi.advanceTimersByTime(5000); // 越过 deadline
|
|
748
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined(); // 终态化消费 intent,不再存在
|
|
749
|
+
expect(killTreeCalls.filter((pid) => pid === task.pid)).toHaveLength(1); // 无第二次 kill
|
|
750
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
751
|
+
expect(getTask(task.taskId)?.reason).toBe("killed"); // 加固后保持 killed(无通知路径)
|
|
752
|
+
} finally {
|
|
753
|
+
vi.useRealTimers();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("natural death before the deadline: no timeout mark; exit edge finalizes natural (accepted shift)", () => {
|
|
758
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
759
|
+
try {
|
|
760
|
+
const before = killTreeCalls.length;
|
|
761
|
+
const task = spawnOk("sleep 0.1", { timeoutSec: 1 });
|
|
762
|
+
// exit 边沿在 deadline 前收尾(自然退出)
|
|
763
|
+
emitFakeExit(task, 0);
|
|
764
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
765
|
+
|
|
766
|
+
vi.advanceTimersByTime(5000); // 越过 deadline
|
|
767
|
+
expect(killTreeCalls.length).toBe(before); // 死任务不补杀
|
|
768
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
769
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined();
|
|
770
|
+
expect(getTask(task.taskId)?.reason).toBe("natural"); // 语义微移:natural 非 timeout
|
|
771
|
+
} finally {
|
|
772
|
+
vi.useRealTimers();
|
|
773
|
+
}
|
|
717
774
|
});
|
|
718
775
|
});
|
|
719
776
|
|
|
777
|
+
// ──────────────────────── 单测层:进程退出收殓(D12,编排逻辑) ────────────────────────
|
|
778
|
+
|
|
720
779
|
describe("process-exit reap (D12)", () => {
|
|
721
|
-
it("reaps all active tasks with reason process-exit, kills pids, writes registry terminal",
|
|
780
|
+
it("reaps all active tasks with reason process-exit, kills pids, writes registry terminal", () => {
|
|
722
781
|
const first = spawnBg("sleep 30");
|
|
723
782
|
const second = spawnBg("sleep 30");
|
|
724
783
|
if (!first.ok || !second.ok) throw new Error("spawn failed");
|
|
@@ -726,11 +785,9 @@ describe("process-exit reap (D12)", () => {
|
|
|
726
785
|
expect(pids.every((pid) => isPidAlive(pid))).toBe(true);
|
|
727
786
|
|
|
728
787
|
reapBackgroundTasksNow();
|
|
729
|
-
//
|
|
730
|
-
await pollUntilTicked(() => pids.every((pid) => !isPidAlive(pid)), 5000, "reaped pids to die");
|
|
731
|
-
|
|
788
|
+
// 桩端点:kill 发令断言 + 终态两侧断言(真 pid 判死由集成组覆盖)
|
|
732
789
|
for (const pid of pids) {
|
|
733
|
-
expect(
|
|
790
|
+
expect(killTreeCalls).toContain(pid);
|
|
734
791
|
}
|
|
735
792
|
for (const t of [first.task, second.task]) {
|
|
736
793
|
const finalized = getTask(t.taskId);
|
|
@@ -742,25 +799,25 @@ describe("process-exit reap (D12)", () => {
|
|
|
742
799
|
});
|
|
743
800
|
});
|
|
744
801
|
|
|
802
|
+
// ──────────────────────── 单测层:D15 abort 不传播 ────────────────────────
|
|
803
|
+
|
|
745
804
|
describe("D15: abort signal does not propagate to background tasks", () => {
|
|
746
|
-
it("aborted execute-signal leaves the task running",
|
|
805
|
+
it("aborted execute-signal leaves the task running", () => {
|
|
747
806
|
const controller = new AbortController();
|
|
748
|
-
const
|
|
749
|
-
if (!spawned.ok) throw new Error(spawned.error);
|
|
750
|
-
const { task } = spawned;
|
|
807
|
+
const task = spawnOk("sleep 1");
|
|
751
808
|
// 模拟用户中断当前 turn:signal abort(后台分支不接触 signal,此处模拟上游已 abort)
|
|
752
809
|
controller.abort();
|
|
753
810
|
expect(controller.signal.aborted).toBe(true);
|
|
754
811
|
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
await sleep(150);
|
|
758
|
-
// 任务不受中断影响:进程活、状态 running
|
|
812
|
+
// 不变量「spawn 链路不读 signal」同步即成立(无需观察窗);任务不受中断影响:
|
|
813
|
+
// 进程活、状态 running
|
|
759
814
|
expect(isPidAlive(task.pid)).toBe(true);
|
|
760
815
|
expect(getTask(task.taskId)?.state).toBe("running");
|
|
761
816
|
});
|
|
762
817
|
});
|
|
763
818
|
|
|
819
|
+
// ──────────────────────── 单测层:spawn 失败路径(§3.6) ────────────────────────
|
|
820
|
+
|
|
764
821
|
describe("spawn failure paths (§3.6)", () => {
|
|
765
822
|
it("nonexistent cwd reports the builtin-style error", () => {
|
|
766
823
|
const result = spawnBackgroundTask({
|
|
@@ -776,9 +833,12 @@ describe("spawn failure paths (§3.6)", () => {
|
|
|
776
833
|
});
|
|
777
834
|
|
|
778
835
|
it("missing shell reports a spawn failure error", () => {
|
|
779
|
-
// 通过临时 SHELL 指向不存在路径模拟 shell 缺失(POSIX
|
|
836
|
+
// 通过临时 SHELL 指向不存在路径模拟 shell 缺失(POSIX 分支)。走真 spawn 调用
|
|
837
|
+
// (不存在路径不产生真进程:ENOENT → 无 pid → 失败返回),验证的是 spawn 侧
|
|
838
|
+
// 失败分支而非桩工厂
|
|
780
839
|
const original = process.env.SHELL;
|
|
781
840
|
process.env.SHELL = "/no/such/shell-binary";
|
|
841
|
+
spawnMode.real = true;
|
|
782
842
|
try {
|
|
783
843
|
const result = spawnBg("echo hi");
|
|
784
844
|
expect(result.ok).toBe(false);
|
|
@@ -786,8 +846,164 @@ describe("spawn failure paths (§3.6)", () => {
|
|
|
786
846
|
expect(result.error).toContain("Failed to start background command");
|
|
787
847
|
}
|
|
788
848
|
} finally {
|
|
849
|
+
spawnMode.real = false;
|
|
789
850
|
if (original === undefined) delete process.env.SHELL;
|
|
790
851
|
else process.env.SHELL = original;
|
|
791
852
|
}
|
|
792
853
|
});
|
|
793
854
|
});
|
|
855
|
+
|
|
856
|
+
// ──────────────────────── 集成组:真子进程核心 5 条(TAIJI_TEST_INTEGRATION 门控) ────────────────────────
|
|
857
|
+
|
|
858
|
+
// 真子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,无确定性完成信号可等——
|
|
859
|
+
// 属「代码 + OS 组合行为」,移出默认单测层:本地默认跳过,CI / 按改动范围触发时
|
|
860
|
+
// 设 TAIJI_TEST_INTEGRATION=1 真跑(门控 SSOT = test-guard/integration-gate.ts)。
|
|
861
|
+
// 保留的 5 条核心 = 真进程端点语义(真 spawn 产物 / 真 SIGKILL 生效 / 真 pid 判死 /
|
|
862
|
+
// 真实时钟不补杀),编排与判定逻辑已由上方单测层全覆盖。
|
|
863
|
+
describe.skipIf(!INTEGRATION_TESTS_ENABLED)(
|
|
864
|
+
`real process core (spawn / kill tree / timeout chain / reap)${INTEGRATION_TESTS_SKIP_REASON ? `|skip:${INTEGRATION_TESTS_SKIP_REASON}` : ""}`,
|
|
865
|
+
() => {
|
|
866
|
+
beforeEach(() => {
|
|
867
|
+
// 真 spawn 模式:node:child_process.spawn 走原实现,进程原语对真 pid 直通
|
|
868
|
+
spawnMode.real = true;
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it(
|
|
872
|
+
"short task: running → exited with exitCode 0, output file readable, registry terminal, pidStartTime carried",
|
|
873
|
+
{ timeout: 20_000 },
|
|
874
|
+
async () => {
|
|
875
|
+
// 断言只要求「exit 事件边沿前 state=running」与退出产物,不依赖命令时长(终态化只在 finalizeTask)
|
|
876
|
+
const task = spawnOk("sleep 0.1 && echo done");
|
|
877
|
+
expect(task.state).toBe("running");
|
|
878
|
+
expect(existsSync(task.outputFile)).toBe(true);
|
|
879
|
+
// registry 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
|
|
880
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
881
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.ownerPiPid).toBe(process.pid);
|
|
882
|
+
// 真实 ps 读取的 start time 进条目与 registry(M5→M3 kill 侧精确比较依据)
|
|
883
|
+
expect(typeof task.pidStartTime).toBe("number");
|
|
884
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.pidStartTime).toBe(task.pidStartTime);
|
|
885
|
+
|
|
886
|
+
// 轮询等命令退出 + libuv reap(tick 收尾;满载下延迟不可预估)
|
|
887
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
|
|
888
|
+
|
|
889
|
+
const finalized = getTask(task.taskId);
|
|
890
|
+
expect(finalized?.state).toBe("exited");
|
|
891
|
+
expect(finalized?.exitCode).toBe(0);
|
|
892
|
+
expect(finalized?.reason).toBe("natural");
|
|
893
|
+
expect(finalized?.durationMs).toBeGreaterThan(0);
|
|
894
|
+
|
|
895
|
+
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
|
|
896
|
+
expect(registryEntry?.state).toBe("exited");
|
|
897
|
+
expect(registryEntry?.reason).toBe("natural");
|
|
898
|
+
|
|
899
|
+
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as { output: string; exitCode: number };
|
|
900
|
+
expect(detail.output).toContain("done");
|
|
901
|
+
expect(detail.exitCode).toBe(0);
|
|
902
|
+
},
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
it(
|
|
906
|
+
"kill edge: intent consumed → exited with reason killed",
|
|
907
|
+
{ timeout: 20_000 },
|
|
908
|
+
async () => {
|
|
909
|
+
const task = spawnOk("sleep 60");
|
|
910
|
+
await killTool(task.taskId);
|
|
911
|
+
// 轮询到 kill 边沿收尾(SIGKILL 生效 + tick 终态化)
|
|
912
|
+
await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", POLL_DEADLINE_MS, "killed finalization");
|
|
913
|
+
const finalized = getTask(task.taskId);
|
|
914
|
+
expect(finalized?.state).toBe("exited");
|
|
915
|
+
expect(finalized?.reason).toBe("killed");
|
|
916
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.reason).toBe("killed");
|
|
917
|
+
},
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
it(
|
|
921
|
+
"fires kill-tree at the deadline and finalizes with reason timeout",
|
|
922
|
+
{ timeout: 20_000 },
|
|
923
|
+
async () => {
|
|
924
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
925
|
+
let taskId = "";
|
|
926
|
+
let pid = -1;
|
|
927
|
+
try {
|
|
928
|
+
// fake 生效后 spawn:timeout 定时器进 fake 队列,由 advanceTimers 推进
|
|
929
|
+
const task = spawnOk("sleep 30", { timeoutSec: 1 });
|
|
930
|
+
taskId = task.taskId;
|
|
931
|
+
pid = task.pid;
|
|
932
|
+
|
|
933
|
+
vi.advanceTimersByTime(1000);
|
|
934
|
+
// 到点:kill-tree 已发令 + 两侧 killing intent(reason 候选 timeout)
|
|
935
|
+
expect(killTreeCalls).toContain(pid);
|
|
936
|
+
expect(getTask(taskId)?.state).toBe("killing");
|
|
937
|
+
expect(getTask(taskId)?.intent?.reason).toBe("timeout");
|
|
938
|
+
expect(readRegistry(REGISTRY_PATH).get(taskId)?.state).toBe("killing");
|
|
939
|
+
} finally {
|
|
940
|
+
vi.useRealTimers();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// SIGKILL 已真实发出:exit 事件边沿收尾 → exited(reason:"timeout"),终态由边沿写
|
|
944
|
+
//(timeout 路径满载下 SIGKILL 生效延迟不可预估,deadline 放宽到 8s)
|
|
945
|
+
await pollUntilTicked(
|
|
946
|
+
() => getTask(taskId)?.state === "exited" && getTask(taskId)?.reason === "timeout",
|
|
947
|
+
POLL_DEADLINE_SLOW_MS,
|
|
948
|
+
"timeout finalization",
|
|
949
|
+
);
|
|
950
|
+
const finalized = getTask(taskId);
|
|
951
|
+
expect(finalized?.state).toBe("exited");
|
|
952
|
+
expect(finalized?.reason).toBe("timeout");
|
|
953
|
+
},
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
it(
|
|
957
|
+
"reaps all active tasks with reason process-exit, real pids die, writes registry terminal",
|
|
958
|
+
{ timeout: 20_000 },
|
|
959
|
+
async () => {
|
|
960
|
+
const first = spawnBg("sleep 30");
|
|
961
|
+
const second = spawnBg("sleep 30");
|
|
962
|
+
if (!first.ok || !second.ok) throw new Error("spawn failed");
|
|
963
|
+
const pids = [first.task.pid, second.task.pid];
|
|
964
|
+
expect(pids.every((pid) => isPidAlive(pid))).toBe(true);
|
|
965
|
+
|
|
966
|
+
reapBackgroundTasksNow();
|
|
967
|
+
// SIGKILL 发出到进程表移除是异步的:轮询等 pid 判死(满载下延迟不可预估)
|
|
968
|
+
await pollUntilTicked(() => pids.every((pid) => !isPidAlive(pid)), POLL_DEADLINE_MS, "reaped pids to die");
|
|
969
|
+
|
|
970
|
+
for (const pid of pids) {
|
|
971
|
+
expect(isPidAlive(pid)).toBe(false);
|
|
972
|
+
}
|
|
973
|
+
for (const t of [first.task, second.task]) {
|
|
974
|
+
const finalized = getTask(t.taskId);
|
|
975
|
+
expect(finalized?.state).toBe("exited");
|
|
976
|
+
expect(finalized?.reason).toBe("process-exit");
|
|
977
|
+
expect(readRegistry(REGISTRY_PATH).get(t.taskId)?.reason).toBe("process-exit");
|
|
978
|
+
}
|
|
979
|
+
expect(getActiveTasks()).toHaveLength(0);
|
|
980
|
+
},
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
it(
|
|
984
|
+
"natural completion before the deadline cancels the timer (no late kill, real clock)",
|
|
985
|
+
{ timeout: 20_000 },
|
|
986
|
+
async () => {
|
|
987
|
+
// 全程真实 timers:命令 0.1s 完成 < 0.5s 超时(真实时间必须真实流过——
|
|
988
|
+
// 本用例证明「到点不补杀」,fake 时钟会让回调先于进程死亡触发)
|
|
989
|
+
const before = killTreeCalls.length;
|
|
990
|
+
const task = spawnOk("sleep 0.1 && echo quick", { timeoutSec: 0.5 });
|
|
991
|
+
|
|
992
|
+
// 轮询命令退出收尾(未到 0.5s deadline)
|
|
993
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "natural finalization");
|
|
994
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
995
|
+
expect(getTask(task.taskId)?.reason).toBe("natural");
|
|
996
|
+
|
|
997
|
+
// 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer):轮询真实时钟
|
|
998
|
+
// 越过 deadline+200ms 余量后断言——这里等待的是时钟流逝本身,非子进程事件
|
|
999
|
+
await pollUntilTicked(
|
|
1000
|
+
() => Date.now() >= task.startedAt + 500 + 200,
|
|
1001
|
+
POLL_DEADLINE_SLOW_MS,
|
|
1002
|
+
"real clock to pass the deadline window",
|
|
1003
|
+
);
|
|
1004
|
+
expect(killTreeCalls.length).toBe(before);
|
|
1005
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
1006
|
+
},
|
|
1007
|
+
);
|
|
1008
|
+
},
|
|
1009
|
+
);
|
|
@@ -228,7 +228,8 @@ describe("entry status maps via protocol mapReasonToStatus (D10 single-source, i
|
|
|
228
228
|
expect(mapReasonToStatus("interrupted-by-restart")).toBe("aborted");
|
|
229
229
|
expect(mapReasonToStatus("interrupted-by-parent")).toBe("aborted");
|
|
230
230
|
expect(mapReasonToStatus("reopened")).toBe("completed");
|
|
231
|
-
|
|
231
|
+
// 词表外 reason 缺省 unknown(fail-visible——§3.3.11④ fail-open=completed 废除)
|
|
232
|
+
expect(mapReasonToStatus("some-future-reason")).toBe("unknown");
|
|
232
233
|
});
|
|
233
234
|
});
|
|
234
235
|
|
|
@@ -70,6 +70,34 @@ describe("buildBashKillSummary", () => {
|
|
|
70
70
|
expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe(`Kill failed: ${"x".repeat(80)}…`);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it("reason 含换行 → 摘要归一单行(details.summary 单行契约)", () => {
|
|
74
|
+
const text = JSON.stringify({ killed: false, reason: "no such task\ntry bash_output" });
|
|
75
|
+
expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe(
|
|
76
|
+
"Kill failed: no such task try bash_output",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reason 含 emoji 超长 → 码点安全截断不劈代理对(S-3)", () => {
|
|
81
|
+
// "x🎉" = 3 个 UTF-16 码元;UTF-16 slice(0,80) 会在第 80 码元处劈开 🎉 的代理对。
|
|
82
|
+
// 码点口径:60 码点 ≤ 80 → 原样保留(全为完整代理对)
|
|
83
|
+
const text = JSON.stringify({ killed: false, reason: "x🎉".repeat(30) });
|
|
84
|
+
const summary = buildBashKillSummary({ task_id: "t1" }, resultOf(text));
|
|
85
|
+
expect(summary).not.toContain("\n");
|
|
86
|
+
const loneSurrogates = Array.from(summary).filter(
|
|
87
|
+
(ch) => ch.length === 1 && /[\uD800-\uDFFF]/.test(ch),
|
|
88
|
+
);
|
|
89
|
+
expect(loneSurrogates).toHaveLength(0);
|
|
90
|
+
|
|
91
|
+
// 超过 80 码点的 emoji 输入同样不产生孤立代理项(截 80 码点 + 省略号)
|
|
92
|
+
const long = JSON.stringify({ killed: false, reason: "🎉".repeat(100) });
|
|
93
|
+
const clipped = buildBashKillSummary({ task_id: "t1" }, resultOf(long));
|
|
94
|
+
const loneInClipped = Array.from(clipped).filter(
|
|
95
|
+
(ch) => ch.length === 1 && /[\uD800-\uDFFF]/.test(ch),
|
|
96
|
+
);
|
|
97
|
+
expect(loneInClipped).toHaveLength(0);
|
|
98
|
+
expect(clipped).toBe(`Kill failed: ${"🎉".repeat(80)}…`);
|
|
99
|
+
});
|
|
100
|
+
|
|
73
101
|
it("content 非 JSON(防御)→ 兜底 Done", () => {
|
|
74
102
|
expect(buildBashKillSummary({ task_id: "t1" }, resultOf(""))).toBe("Done");
|
|
75
103
|
});
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,6 @@ import { createBashOverrideToolDefinition } from "./bash-tool.ts";
|
|
|
34
34
|
import { handleTaskExit, refreshPiReference } from "./background/notify.ts";
|
|
35
35
|
import { installProcessExitGuard } from "./background/process-exit-guard.ts";
|
|
36
36
|
import { reconcilePendingEntries } from "./background/pending-reconcile.ts";
|
|
37
|
-
import { countActiveTasks } from "./background/task-store.ts";
|
|
38
37
|
import { resolveTaijiDataDir } from "./background/registry.ts";
|
|
39
38
|
import { setOnTaskExit } from "./background/exit-collector.ts";
|
|
40
39
|
import { setupToolErrorAudit } from "./tool-error-audit.ts";
|
|
@@ -65,7 +64,7 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
|
|
|
65
64
|
// handler 消费该 promise,runner.js:632),
|
|
66
65
|
// promise 由其消费不产生 unhandled rejection;错误在维护链内 async 捕获
|
|
67
66
|
pi.on("session_start", (event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
68
|
-
// 统一投递端口装配(unified-delivery 波 2,设计 §3.7 装配点):custom
|
|
67
|
+
// 统一投递端口装配(unified-delivery 波 2,设计 §3.7 装配点):custom 投递路径
|
|
69
68
|
// 唯一执行者、恒装配(本扩展为 infrastructure 档必载——两套装配并存违背
|
|
70
69
|
// 装配单一归属,§3.7)。每次 session_start 重绑(dispose 旧实例 + 新实例
|
|
71
70
|
// recover 差集重放);消费方(notifier / session-manager / workflow,
|
|
@@ -75,7 +74,8 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
|
|
|
75
74
|
createPiDeliveryPortHost(pi, ctx, {
|
|
76
75
|
component: "base-tool-enhance",
|
|
77
76
|
logger,
|
|
78
|
-
|
|
77
|
+
// [subagent-observability §3.3.7③] 原 hasRunningBackground(攒批条件谓词)
|
|
78
|
+
// 已随攒批窗整机制退役删除——投递边沿即投,装配宿主无合批控制面。
|
|
79
79
|
}),
|
|
80
80
|
).recover();
|
|
81
81
|
return runSessionStartMaintenance(pi, ctx, event.reason);
|
package/src/render-result.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
-
import { firstContentText } from "@zhushanwen/extension-protocol";
|
|
11
|
+
import { firstContentText, firstLine, readDetailsSummary, resultContentText } from "@zhushanwen/extension-protocol";
|
|
12
12
|
|
|
13
13
|
// ── summary 纯函数 ───────────────────────────────────
|
|
14
14
|
|
|
@@ -54,6 +54,15 @@ export function buildBashOutputSummary(
|
|
|
54
54
|
/** 收起态摘要中失败 reason 的保留长度(超长截断加省略号)。 */
|
|
55
55
|
const KILL_REASON_SUMMARY_CHARS = 80;
|
|
56
56
|
|
|
57
|
+
/** reason 归一单行 + 码点安全截断到上限(超长补省略号;与 goal/todo/plan 的摘要 helper
|
|
58
|
+
* 同语义:换行/连续空白折叠为单空格,Array.from 按码点切分,emoji 代理对不劈半——
|
|
59
|
+
* details.summary 单行契约见 extension-conventions.md)。 */
|
|
60
|
+
function clipSummaryText(text: string, max: number): string {
|
|
61
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
62
|
+
const chars = Array.from(line);
|
|
63
|
+
return chars.length > max ? chars.slice(0, max).join("") + "…" : line;
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
/** bash_kill 摘要:killed=true → `Killed task <id>`;killed=false(正常返回的失败形态)
|
|
58
67
|
* → `Kill failed: <reason>`。 */
|
|
59
68
|
export function buildBashKillSummary(args: { task_id: string }, result: ToolResultLike): string {
|
|
@@ -61,46 +70,18 @@ export function buildBashKillSummary(args: { task_id: string }, result: ToolResu
|
|
|
61
70
|
if (parsed === undefined) return "Done";
|
|
62
71
|
if (parsed.killed === true) return `Killed task ${args.task_id}`;
|
|
63
72
|
const reason = typeof parsed.reason === "string" ? parsed.reason : "unknown";
|
|
64
|
-
|
|
65
|
-
? `${reason.slice(0, KILL_REASON_SUMMARY_CHARS)}…`
|
|
66
|
-
: reason;
|
|
67
|
-
return `Kill failed: ${clipped}`;
|
|
73
|
+
return `Kill failed: ${clipSummaryText(reason, KILL_REASON_SUMMARY_CHARS)}`;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
// ── TUI renderResult ─────────────────────────────────
|
|
71
77
|
|
|
72
|
-
/** unknown 值按字段名安全取值(index-signature 收窄,替代全可选结构断言)。 */
|
|
73
|
-
function fieldValue(v: unknown, key: string): unknown {
|
|
74
|
-
if (typeof v !== "object" || v === null) return undefined;
|
|
75
|
-
return (v as Record<string, unknown>)[key];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 从 details 安全读取 summary(容错非对象形态与旧版本 entry)。 */
|
|
79
|
-
function readDetailsSummary(result: unknown): string | undefined {
|
|
80
|
-
const summary = fieldValue(fieldValue(result, "details"), "summary");
|
|
81
|
-
return typeof summary === "string" && summary !== "" ? summary : undefined;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** content[0] 的 text 纯文本(content 缺失或非 text 类型时兜底空串)。 */
|
|
85
|
-
function contentText(result: unknown): string {
|
|
86
|
-
const content = fieldValue(result, "content");
|
|
87
|
-
if (!Array.isArray(content)) return "";
|
|
88
|
-
return firstContentText({ content });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** 折叠态回退:JSON 文本取首行(保持一行摘要形态)。 */
|
|
92
|
-
function firstLine(text: string): string {
|
|
93
|
-
const idx = text.indexOf("\n");
|
|
94
|
-
return idx === -1 ? text : text.slice(0, idx);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
78
|
/** 折叠态(expanded=false)= details.summary 一行(dim);展开态 = content 纯文本。 */
|
|
98
79
|
export function renderBashToolResult(
|
|
99
80
|
result: unknown,
|
|
100
81
|
options: { expanded: boolean },
|
|
101
82
|
theme: Theme,
|
|
102
83
|
): Text {
|
|
103
|
-
if (options.expanded) return new Text(
|
|
104
|
-
const summary = readDetailsSummary(result) ?? firstLine(
|
|
84
|
+
if (options.expanded) return new Text(resultContentText(result), 0, 0);
|
|
85
|
+
const summary = readDetailsSummary(result) ?? firstLine(resultContentText(result));
|
|
105
86
|
return new Text(theme.fg("dim", summary), 0, 0);
|
|
106
87
|
}
|