@zhushanwen/subagent-core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-VURUAGMM.js → chunk-OL5BK4VN.js} +237 -40
- package/dist/{engine-discovery-scan-ocpM8FmI.d.cts → engine-discovery-scan-BXLA8y-z.d.cts} +17 -1
- package/dist/{engine-discovery-scan-ocpM8FmI.d.ts → engine-discovery-scan-BXLA8y-z.d.ts} +17 -1
- package/dist/execution/engine/engine-discovery-scan.cjs +150 -40
- package/dist/execution/engine/engine-discovery-scan.d.cts +1 -1
- package/dist/execution/engine/engine-discovery-scan.d.ts +1 -1
- package/dist/execution/engine/engine-discovery-scan.js +1 -1
- package/dist/index.cjs +278 -225
- package/dist/index.d.cts +24 -4
- package/dist/index.d.ts +24 -4
- package/dist/index.js +188 -304
- package/dist.bundle/index.cjs +279 -226
- package/package.json +5 -5
- package/src/__tests__/record-store-last-line.test.ts +12 -0
- package/src/core/logger.ts +1 -1
- package/src/execution/__tests__/inflight-production-wiring.test.ts +223 -0
- package/src/execution/__tests__/nested-visibility.test.ts +13 -1
- package/src/execution/engine/__tests__/conformance/registry-fork-filter.test.ts +55 -53
- package/src/execution/engine/__tests__/inflight-snapshot.test.ts +180 -0
- package/src/execution/engine/client/engine-client.ts +30 -1
- package/src/execution/engine/host/host-bridge.ts +7 -0
- package/src/execution/engine/host/spawned-children.ts +1 -1
- package/src/execution/engine/inflight-snapshot.ts +92 -0
- package/src/execution/engine/port.ts +18 -0
- package/src/execution/path-encoding.ts +6 -0
- package/src/execution/subagent-service.ts +25 -0
- package/src/execution/worktree-registry.ts +11 -7
- package/src/index.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/subagent-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Cross-host shared subagent execution layer and workflow orchestration core (dual-form npm package: TS source for workspace, dist ESM+CJS for npm)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
"ajv": "^8.20.0",
|
|
74
74
|
"proper-lockfile": "^4.1.2",
|
|
75
75
|
"yaml": "^2.9.0",
|
|
76
|
-
"@xyz-agent/extension-protocol": "0.
|
|
77
|
-
"@zhushanwen/subagent-engine-sdk": "0.2.
|
|
76
|
+
"@xyz-agent/extension-protocol": "0.9.0",
|
|
77
|
+
"@zhushanwen/subagent-engine-sdk": "0.2.1"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/node": "^24.0.0",
|
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
"tsup": "^8.5.1",
|
|
83
83
|
"typescript": "^5.7.0",
|
|
84
84
|
"vitest": "^4.1.8",
|
|
85
|
-
"@zhushanwen/pi-subagent-cli": "0.1.
|
|
86
|
-
"@zhushanwen/zcode-subagent-cli": "0.
|
|
85
|
+
"@zhushanwen/pi-subagent-cli": "0.1.2",
|
|
86
|
+
"@zhushanwen/zcode-subagent-cli": "0.2.0"
|
|
87
87
|
},
|
|
88
88
|
"scripts": {
|
|
89
89
|
"build": "tsup",
|
|
@@ -21,6 +21,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
21
21
|
import { ManifestStore } from "../execution/manifest-store";
|
|
22
22
|
import { RecordStore } from "../execution/record-store";
|
|
23
23
|
|
|
24
|
+
// [Gate A teardown 稳定性] no-op 掉索引落盘。根因:recoverOrphanRecords 走的
|
|
25
|
+
// reconstructAll(record-store.ts)扫描尾 fire-and-forget saveIndex(tmp+fsync+rename
|
|
26
|
+
// 异步 fs),满载下该 promise 可能在本文件 teardown 之后才 settle,其失败分支经
|
|
27
|
+
// logger.warn → console.warn 上报 vitest(rpc onUserConsoleLog 在途)——worker 关闭
|
|
28
|
+
// rpc 时在途调用被 reject 为 EnvironmentTeardownError(0 断言失败,纯 teardown 时序)。
|
|
29
|
+
// 本文件用一次性 tmpdir、断言只观察 appendEntry 捕获的判定结果,落盘与否无观察者——
|
|
30
|
+
// no-op 消除在途 IO 链,任何负载下确定;loadIndex 等其余导出保留原实现。
|
|
31
|
+
vi.mock("../execution/sessions-index.ts", async (importOriginal) => ({
|
|
32
|
+
...(await importOriginal<typeof import("../execution/sessions-index.ts")>()),
|
|
33
|
+
saveIndex: async () => {},
|
|
34
|
+
}));
|
|
35
|
+
|
|
24
36
|
interface CapturedEntry {
|
|
25
37
|
type: string;
|
|
26
38
|
data: Record<string, unknown>;
|
package/src/core/logger.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { getHostServices } from "./host-services.ts";
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/** 日志级别。级别集合对齐 pi-extension-logger(其 LogLevel 为包内部类型、含 info 四值;实例 API 仅 debug/warn/error 三方法,core facade 据此收窄为三值)。 */
|
|
17
17
|
export type LogLevel = "debug" | "warn" | "error";
|
|
18
18
|
|
|
19
19
|
/** core logger 接口。与 pi-extension-logger 的 ExtensionLogger 结构兼容——
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// src/execution/__tests__/inflight-production-wiring.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [u7a 生产补挂] D5 在途推送的生产链路 wiring 测试(crash-forensics impl-plan §7 v6)。
|
|
4
|
+
//
|
|
5
|
+
// 背景:notifyInFlightChanged 此前唯一挂点 = host-bridge arm/disarm 委托,而
|
|
6
|
+
// createHostBridge 全仓无生产调用点——生产迁移点(subagent-service 裸 arm/disarm/
|
|
7
|
+
// 镜像置死、EngineClient 反向通道镜像)全部不推送,extension 监听者恒收不到帧,
|
|
8
|
+
// runtime 侧 inflight 镜像恒 0(Gate B A4 BLOCKED 根因 a)。
|
|
9
|
+
//
|
|
10
|
+
// 三视角:
|
|
11
|
+
// ①使用者(extension reporter 视角)——注册 setInFlightListener 后,生产迁移点
|
|
12
|
+
// (idle 相位 arm / 续聊 disarm / 守护击杀 / dispose 批量回收 / 引擎反向通道
|
|
13
|
+
// childSpawned/childStateChanged)每一处都推来含该 record 贡献的绝对计数快照。
|
|
14
|
+
// ②构建者——推送携带的是双谓词过滤后的真实计数:活句柄 + 无 armed idle timer
|
|
15
|
+
// 才计在途;镜像置死后计数即刻回落(EngineClient 镜像桥接投影)。
|
|
16
|
+
// ③观察者——EngineClient 镜像 → core 镜像的单向投影可从镜像面观察(core 镜像
|
|
17
|
+
// 条目随反向通道事件出现/置死),壳层监听者异常不反噬主链(既有出口测试覆盖,
|
|
18
|
+
// 此处不重复)。
|
|
19
|
+
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { EventEmitter } from "node:events";
|
|
24
|
+
import type { ChildProcess } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
27
|
+
|
|
28
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
29
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
32
|
+
|
|
33
|
+
import { clearEngines } from "../engine/registry.ts";
|
|
34
|
+
import { registerFakePiEngine, type FakePiEnginePort } from "./helpers/fake-engine-port.ts";
|
|
35
|
+
import { createRecord } from "../execution-record.ts";
|
|
36
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
37
|
+
import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
|
|
38
|
+
import type { RecordStore } from "../record-store.ts";
|
|
39
|
+
import { SubagentService, type PiLike } from "../subagent-service.ts";
|
|
40
|
+
import { _resetLifecycleState, hasIdleTimer } from "../lifecycle-manager.ts";
|
|
41
|
+
import {
|
|
42
|
+
registerSpawnedChildForRecord,
|
|
43
|
+
_resetCoreSpawnedChildrenMirrorForTest,
|
|
44
|
+
} from "../engine/host/spawned-children.ts";
|
|
45
|
+
import {
|
|
46
|
+
getInFlightSnapshot,
|
|
47
|
+
setInFlightListener,
|
|
48
|
+
type InFlightSnapshot,
|
|
49
|
+
} from "../engine/inflight-snapshot.ts";
|
|
50
|
+
import { EngineClient } from "../engine/client/engine-client.ts";
|
|
51
|
+
|
|
52
|
+
const CTX_MODEL: ModelInfo = { id: "m", name: "M", provider: "p", reasoning: false };
|
|
53
|
+
|
|
54
|
+
function makeEmptyRegistry(): ModelRegistryLike {
|
|
55
|
+
return { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makePi(): PiLike {
|
|
59
|
+
return {
|
|
60
|
+
appendEntry: vi.fn(),
|
|
61
|
+
events: { emit: vi.fn() },
|
|
62
|
+
sendMessage: vi.fn(),
|
|
63
|
+
} as unknown as PiLike;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 最小 fake 子进程(EventEmitter + killed/pid——hasLiveProcessHandle 消费面)。 */
|
|
67
|
+
function makeFakeChild(pid = 4242): ChildProcess & EventEmitter {
|
|
68
|
+
const child = new EventEmitter() as ChildProcess & EventEmitter;
|
|
69
|
+
(child as { pid?: number }).pid = pid;
|
|
70
|
+
(child as { killed: boolean }).killed = false;
|
|
71
|
+
return child;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ServiceInternals {
|
|
75
|
+
store: RecordStore;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setup(): {
|
|
79
|
+
agentDir: string;
|
|
80
|
+
service: SubagentService;
|
|
81
|
+
store: RecordStore;
|
|
82
|
+
fake: FakePiEnginePort;
|
|
83
|
+
} {
|
|
84
|
+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "inflight-wiring-"));
|
|
85
|
+
clearEngines();
|
|
86
|
+
const fake = registerFakePiEngine();
|
|
87
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
88
|
+
modelService.initModel({ modelRegistry: makeEmptyRegistry(), sessionId: "root-session", ctxModel: CTX_MODEL });
|
|
89
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
90
|
+
service.initSession({ pi: makePi(), sessionId: "root-session" });
|
|
91
|
+
const store = (service as unknown as ServiceInternals).store;
|
|
92
|
+
return { agentDir, service, store, fake };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** chatMode 续聊态 record(首轮已完成等待续聊——v4 B-1 折入 running)。 */
|
|
96
|
+
function makeResumableRecord(id: string): ReturnType<typeof createRecord> {
|
|
97
|
+
const record = createRecord(id, {
|
|
98
|
+
agent: "general-purpose",
|
|
99
|
+
model: "test/model",
|
|
100
|
+
mode: "background",
|
|
101
|
+
task: "initial task",
|
|
102
|
+
slug: "chat",
|
|
103
|
+
startedAt: 1000,
|
|
104
|
+
rootSessionId: "root-session",
|
|
105
|
+
chatMode: true,
|
|
106
|
+
});
|
|
107
|
+
record.status = "running";
|
|
108
|
+
record.round = 1;
|
|
109
|
+
record.controller = new AbortController();
|
|
110
|
+
return record;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("u7a 生产迁移点 → 在途推送(wiring)", () => {
|
|
114
|
+
let agentDir: string;
|
|
115
|
+
let service: SubagentService;
|
|
116
|
+
let store: RecordStore;
|
|
117
|
+
let fake: FakePiEnginePort;
|
|
118
|
+
let seen: InFlightSnapshot[];
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
({ agentDir, service, store, fake } = setup());
|
|
122
|
+
seen = [];
|
|
123
|
+
setInFlightListener((s) => seen.push({ ...s }));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
setInFlightListener(null);
|
|
128
|
+
service.dispose();
|
|
129
|
+
clearEngines();
|
|
130
|
+
_resetLifecycleState();
|
|
131
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
132
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("idle 相位 arm(生产路径 = handleChatRoundPhase → armChatIdleTimer):活句柄翻入保活,推送 inFlight=0", async () => {
|
|
136
|
+
const handle = await service.execute({ task: "first round", slug: "t", ctxModel: CTX_MODEL, conversation: true });
|
|
137
|
+
const run = fake.runs[0]!;
|
|
138
|
+
// 引擎侧活句柄(生产经 EngineClient 反向通道桥接投影——此处直接落 core 镜像,
|
|
139
|
+
// 桥接本体在下方 EngineClient 套件独立验证)
|
|
140
|
+
registerSpawnedChildForRecord(handle.subagentId, makeFakeChild());
|
|
141
|
+
expect(getInFlightSnapshot().inFlight).toBe(1); // 前置:在途句柄在场
|
|
142
|
+
|
|
143
|
+
seen.length = 0;
|
|
144
|
+
run.emitLifecycle({ phase: "idle" });
|
|
145
|
+
expect(hasIdleTimer(handle.subagentId)).toBe(true); // idle 相位 = timer armed
|
|
146
|
+
expect(seen.at(-1)).toEqual({ inFlight: 0 }); // 保活不算在途(D5 谓词)
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("续聊投递 disarm(生产路径 = deliverChatMessage):翻回正在执行,推送 inFlight=1", async () => {
|
|
150
|
+
const record = makeResumableRecord("sa-wiring-disarm");
|
|
151
|
+
store.register(record);
|
|
152
|
+
registerSpawnedChildForRecord(record.id, makeFakeChild());
|
|
153
|
+
|
|
154
|
+
seen.length = 0;
|
|
155
|
+
await service.chatActions.deliverChatMessage(record, "next round msg", false);
|
|
156
|
+
|
|
157
|
+
expect(fake.interacts[0]!.action).toEqual({ kind: "message", payload: "next round msg", interrupt: false });
|
|
158
|
+
expect(hasIdleTimer(record.id)).toBe(false);
|
|
159
|
+
expect(seen.at(-1)).toEqual({ inFlight: 1 }); // disarm 后该 record 计在途
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("dispose 批量回收:逐 record kill+disarm 后收敛推送终态 inFlight=0", async () => {
|
|
163
|
+
const record = makeResumableRecord("sa-wiring-dispose");
|
|
164
|
+
store.register(record);
|
|
165
|
+
registerSpawnedChildForRecord(record.id, makeFakeChild());
|
|
166
|
+
expect(getInFlightSnapshot().inFlight).toBe(1);
|
|
167
|
+
|
|
168
|
+
seen.length = 0;
|
|
169
|
+
service.dispose();
|
|
170
|
+
expect(seen.at(-1)).toEqual({ inFlight: 0 });
|
|
171
|
+
expect(getInFlightSnapshot().inFlight).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("u7a 数据面桥接:EngineClient 反向通道镜像 → core 镜像 + 推送", () => {
|
|
176
|
+
let dataDir: string;
|
|
177
|
+
let client: EngineClient;
|
|
178
|
+
let seen: InFlightSnapshot[];
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "inflight-bridge-"));
|
|
182
|
+
// 不 connect(构造即被测面:镜像变更经 onChange 同步广播)——零子进程零 fs 写
|
|
183
|
+
client = new EngineClient({
|
|
184
|
+
engineId: "fake",
|
|
185
|
+
command: process.execPath,
|
|
186
|
+
args: ["-e", "process.exit(0)"],
|
|
187
|
+
hostKind: "test",
|
|
188
|
+
hostVersion: "test-host-1.0",
|
|
189
|
+
dataDir,
|
|
190
|
+
envPrefixes: [],
|
|
191
|
+
});
|
|
192
|
+
seen = [];
|
|
193
|
+
setInFlightListener((s) => seen.push({ ...s }));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
afterEach(() => {
|
|
197
|
+
setInFlightListener(null);
|
|
198
|
+
client.dispose();
|
|
199
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
200
|
+
_resetLifecycleState();
|
|
201
|
+
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("childSpawned → core 镜像落项 + 推送 inFlight=1;childStateChanged(exited) → 置死 + 推送 inFlight=0", () => {
|
|
205
|
+
client.mirror.recordSpawned(4242, "sa-bridge");
|
|
206
|
+
expect(seen.at(-1)).toEqual({ inFlight: 1 });
|
|
207
|
+
expect(getInFlightSnapshot().inFlight).toBe(1); // core 镜像已投影(计数数据源)
|
|
208
|
+
|
|
209
|
+
client.mirror.recordStateChanged({ pid: 4242, recordId: "sa-bridge", state: "exited", killed: true });
|
|
210
|
+
expect(seen.at(-1)).toEqual({ inFlight: 0 });
|
|
211
|
+
expect(getInFlightSnapshot().inFlight).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("同 recordId 重 spawn 覆盖 + killedAll 整体置死(引擎回收语义投影)", () => {
|
|
215
|
+
client.mirror.recordSpawned(111, "sa-bridge-a");
|
|
216
|
+
client.mirror.recordSpawned(222, "sa-bridge-b");
|
|
217
|
+
expect(seen.at(-1)).toEqual({ inFlight: 2 });
|
|
218
|
+
|
|
219
|
+
client.mirror.killAll(); // 引擎 exit / killAll:逐项 killedAll 事件
|
|
220
|
+
expect(seen.at(-1)).toEqual({ inFlight: 0 });
|
|
221
|
+
expect(getInFlightSnapshot().inFlight).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -13,11 +13,23 @@ import * as fs from "node:fs";
|
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
17
|
|
|
18
18
|
import { createRecord } from "../execution-record.ts";
|
|
19
19
|
import { RecordStore } from "../record-store.ts";
|
|
20
20
|
|
|
21
|
+
// [Gate A teardown 稳定性] no-op 掉索引落盘。根因:collectRecords 扫描尾的
|
|
22
|
+
// flushIndexAfterScan(record-store.ts)fire-and-forget saveIndex(tmp+fsync+rename
|
|
23
|
+
// 异步 fs),满载下该 promise 可能在本文件 teardown 之后才 settle,其失败分支经
|
|
24
|
+
// logger.warn → console.warn 上报 vitest(rpc onUserConsoleLog 在途)——worker 关闭
|
|
25
|
+
// rpc 时在途调用被 reject 为 EnvironmentTeardownError(0 断言失败,纯 teardown 时序)。
|
|
26
|
+
// 本文件用一次性 tmpdir、断言不观察索引文件,落盘与否无观察者——no-op 消除在途 IO
|
|
27
|
+
// 链,任何负载下确定;loadIndex 等其余导出保留原实现(磁盘重建语义不变)。
|
|
28
|
+
vi.mock("../sessions-index.ts", async (importOriginal) => ({
|
|
29
|
+
...(await importOriginal<typeof import("../sessions-index.ts")>()),
|
|
30
|
+
saveIndex: async () => {},
|
|
31
|
+
}));
|
|
32
|
+
|
|
21
33
|
// ── helpers ──
|
|
22
34
|
|
|
23
35
|
/**
|
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
// bash 跨 session 可见性显式断言(R4 一刀语义钉住)+ 偏差 #4 探针(goal 守卫口径)。
|
|
3
3
|
//
|
|
4
4
|
// 设计权威源:chat-domain-v1x-liveness-governance.md §3.2 D4(翻档三连带——读侧
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// 过滤三口;分档常量已随 ext-simplify-12 删除,三类型 process 档成为无条件代码
|
|
6
|
+
// 自然状态)+ 修订记录 v5-⑤(bash 跨 session 可见性一刀钉成显式选择)+ 验收 A9②
|
|
7
|
+
// + impl-plan §5 偏差 #4(W4 交接:goal 守卫消费点未传基准,W6 实测裁决是否需
|
|
8
|
+
// goal 侧两行传参)。
|
|
8
9
|
//
|
|
9
10
|
// 被测函数 = `extensions/universal/pending-notifications/src/state.ts` 的导出纯函数
|
|
10
11
|
// (零依赖、不触 Pi 运行时——文件头注自证;经相对路径源码消费,subagent-core 无该
|
|
11
12
|
// workspace 依赖声明,不加 phantom dep)。分工:
|
|
12
|
-
// - 过滤函数本体 /
|
|
13
|
+
// - 过滤函数本体 / pending_notifications 工具投影(entries 现算)的两口径行为:
|
|
14
|
+
// 本套件钉语义(conformance 视角);
|
|
13
15
|
// - 过滤①②③的**接线**(goal/subagent-workflow/pending-notifications 三消费点
|
|
14
16
|
// 透传 currentSessionId):W4 包内测试已承载(pi-pending-notifications
|
|
15
|
-
// __tests__
|
|
17
|
+
// __tests__、subagent-workflow pi-host.test);
|
|
16
18
|
// - 后代判定口(读侧过滤②)的差集口径与①共用同一函数——本套件①的断言即其
|
|
17
19
|
// 语义核心;接线透传由上述 W4 测试守护。
|
|
18
20
|
//
|
|
19
21
|
// 探针结论(偏差 #4,实测证据 = 「goal 守卫口径」describe):**有虚增**——fork 后
|
|
20
|
-
// 父 session
|
|
21
|
-
//
|
|
22
|
+
// 父 session 仍活跃的后台任务残留使无基准口径幻 defer。该结论已由 d0b9a6faf
|
|
23
|
+
// (2026-09-09,fix(goal): pass currentSessionId)关闭——goal 两处守卫现传
|
|
24
|
+
// currentSessionId 基准,下方「无基准」形态仅作历史口径对照面保留。
|
|
22
25
|
|
|
23
26
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
24
27
|
import { tmpdir } from "node:os";
|
|
@@ -27,13 +30,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
27
30
|
|
|
28
31
|
// 相对路径源码消费(7 层上溯到仓库根):state.ts 是零依赖纯函数(不触 Pi 运行时),
|
|
29
32
|
// 无需为探针/契约测试给 subagent-core 添加 workspace 依赖声明。
|
|
30
|
-
import
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
countActiveFromEntries,
|
|
34
|
-
createRegistry,
|
|
35
|
-
rebuildFromEntries,
|
|
36
|
-
} from "../../../../../../../extensions/universal/pending-notifications/src/state.ts";
|
|
33
|
+
// [ext-simplify-12] import 面收窄到 countActiveFromEntries——历史的 registry/
|
|
34
|
+
// session 档机器已整体删除,entries 是唯一状态源(跨包语义耦合面收敛到该函数)。
|
|
35
|
+
import { countActiveFromEntries } from "../../../../../../../extensions/universal/pending-notifications/src/state.ts";
|
|
37
36
|
|
|
38
37
|
import { runReconcileSweep } from "../../../round-supervisor/index.ts";
|
|
39
38
|
|
|
@@ -52,16 +51,13 @@ function registerEntry(
|
|
|
52
51
|
name: id,
|
|
53
52
|
registeredAt: 1_000,
|
|
54
53
|
sessionId,
|
|
55
|
-
//
|
|
56
|
-
//
|
|
54
|
+
// 真实落盘无 expiresAt 键(写入侧无条件省略);overrides.expiresAt 模拟历史
|
|
55
|
+
// session 文件遗留的带 TTL 键 entry——差集读取侧刻意不读该键(对照面)。
|
|
57
56
|
...(overrides.expiresAt !== undefined ? { expiresAt: overrides.expiresAt } : {}),
|
|
58
57
|
},
|
|
59
58
|
};
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
/** 语义对照面的基准时刻(远超旧 1h TTL——钉「无 TTL 清理」而非「窗口内未到期」假绿)。 */
|
|
63
|
-
const NOW = 10 * PENDING_TTL_MS + 1;
|
|
64
|
-
|
|
65
61
|
let tmpDirs: string[] = [];
|
|
66
62
|
|
|
67
63
|
afterEach(() => {
|
|
@@ -69,23 +65,23 @@ afterEach(() => {
|
|
|
69
65
|
tmpDirs = [];
|
|
70
66
|
});
|
|
71
67
|
|
|
72
|
-
describe("[W6/D4
|
|
73
|
-
it("
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
68
|
+
describe("[W6/D4 翻档余义] 三类型跨时长无 TTL 清理(entries 差集现算)", () => {
|
|
69
|
+
it("翻档后跨时长(远超旧 1h TTL)无 TTL 清理:三类型 register 均仍 active", () => {
|
|
70
|
+
// NOW = 10 * 3_600_000 + 1:语义对照面基准时刻(旧 1h TTL = 3_600_000ms 的
|
|
71
|
+
// 10 倍 + 1ms,远超旧 1h TTL)。entries 差集不接收时间参数——差集语义刻意
|
|
72
|
+
// 不校验时间,NOW 仅标注对照语义:若旧 session 档 TTL 机器仍在,NOW 时刻
|
|
73
|
+
// bg-1/wf-1 早已被过期清理。
|
|
74
|
+
const NOW = 10 * 3_600_000 + 1;
|
|
75
|
+
// 护栏:钉住对照面确实覆盖「超 TTL」域而非「窗口内未到期」假绿。
|
|
76
|
+
expect(NOW - 1_000).toBeGreaterThan(3_600_000);
|
|
79
77
|
const entries = [
|
|
80
|
-
registerEntry("bg-1", "subagent", "sess-root", { expiresAt: 1_000 + 1 }), //
|
|
78
|
+
registerEntry("bg-1", "subagent", "sess-root", { expiresAt: 1_000 + 1 }), // 历史遗留 TTL 键(读取侧不读)
|
|
81
79
|
registerEntry("wf-1", "workflow", "sess-root", { expiresAt: 1_000 + 1 }),
|
|
82
80
|
registerEntry("bt-1", "bash", "sess-root"), // 真实落盘形态:无 expiresAt 键
|
|
83
81
|
];
|
|
84
|
-
const result = rebuildFromEntries(registry, entries, "sess-root", NOW);
|
|
85
82
|
// 旧 session 档语义(1h TTL + 跨 session 补注销)曾把长任务/跨重启注册静默
|
|
86
|
-
// 清除 → 守卫失明(事故环 4
|
|
87
|
-
expect(
|
|
88
|
-
expect(result.activeIds).toEqual(["bg-1", "wf-1", "bt-1"]);
|
|
83
|
+
// 清除 → 守卫失明(事故环 4 放大器);三类型全 process 档后差集一律续存。
|
|
84
|
+
expect(countActiveFromEntries(entries).ids).toEqual(["bg-1", "wf-1", "bt-1"]);
|
|
89
85
|
});
|
|
90
86
|
});
|
|
91
87
|
|
|
@@ -121,41 +117,45 @@ describe("[W6/R4 一刀] bash 跨 session 可见性显式断言(钉成显式
|
|
|
121
117
|
expect(scoped.count).toBe(1); // 只数本 session 的任务
|
|
122
118
|
});
|
|
123
119
|
|
|
124
|
-
it("
|
|
120
|
+
it("旧行为显式登记:无基准对照面(历史形态)父 bash 残留仍入计数(= defer 驱动)", () => {
|
|
125
121
|
const childEntries = [registerEntry("parent-bt", "bash", "sess-parent")];
|
|
126
122
|
// 显式钉住「一刀的对照面」:不传基准 = 不过滤 = 子 session goal 仍为父 bash
|
|
127
123
|
// 任务 defer。这是读侧过滤一刀**改变的旧行为**——保留此断言防止有人误以为
|
|
128
|
-
// 过滤是缺省语义(偏差 #4:goal
|
|
124
|
+
// 过滤是缺省语义(偏差 #4:goal 消费点历史形态即此口径,已由 d0b9a6faf 修复
|
|
125
|
+
// 传基准,见探针结论)。
|
|
129
126
|
expect(countActiveFromEntries(childEntries).count).toBe(1);
|
|
130
127
|
});
|
|
131
128
|
});
|
|
132
129
|
|
|
133
|
-
describe("[W6/D4 读侧过滤③]
|
|
134
|
-
it("跨 session
|
|
135
|
-
const registry = createRegistry();
|
|
130
|
+
describe("[W6/D4 读侧过滤③] pending_notifications 工具投影(entries 现算)", () => {
|
|
131
|
+
it("跨 session 残留不进差集、不产生任何写回(跳过不补注销——落盘收口归 core sweep / bte 对账通道)", () => {
|
|
136
132
|
const entries = [
|
|
137
133
|
registerEntry("parent-bg", "subagent", "sess-parent"),
|
|
138
134
|
registerEntry("own-bg", "subagent", "sess-child"),
|
|
139
135
|
];
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
// 工具投影面(count/list)不虚报继承残留(A9②:count/list 不含继承残留)——
|
|
137
|
+
// 现算后与读侧过滤①共用同一 countActiveFromEntries 扫描,「落盘了什么」与
|
|
138
|
+
// 「查询到什么」构造性一致。
|
|
139
|
+
const result = countActiveFromEntries(entries, { currentSessionId: "sess-child" });
|
|
140
|
+
expect(result.ids).toEqual(["own-bg"]);
|
|
144
141
|
// 「跳过不补注销」:残留 entry 留在 session 文件(读侧①③口各自兜住差集消费
|
|
145
|
-
//
|
|
146
|
-
|
|
142
|
+
// 方),countActiveFromEntries 只读不写——跨 session 写达域缺口由 core 对账
|
|
143
|
+
// sweep 收口(bte 对账同理直接 appendEntry)。只读性实证:输入数组长度不变,
|
|
144
|
+
// 无补发的 unregister entry。
|
|
145
|
+
expect(entries).toHaveLength(2);
|
|
147
146
|
});
|
|
148
147
|
});
|
|
149
148
|
|
|
150
|
-
describe("[W6/偏差 #4 探针] goal 守卫口径(无基准)× fork
|
|
149
|
+
describe("[W6/偏差 #4 探针] goal 守卫口径(无基准)× fork 残留——实测结论(历史口径)", () => {
|
|
151
150
|
// 偏差 #4(impl-plan §5,W4 交接):W4 读侧过滤①的 currentSessionId 为可选参数,
|
|
152
151
|
// goal 守卫消费点未传基准。本探针在 fork 场景实测该口径是否被父 session 注册
|
|
153
152
|
// 残留虚增,结论二选一回写报告:
|
|
154
153
|
// (a) 无影响(其他机制覆盖)→ 偏差 #4 关闭,goal 零改动;
|
|
155
154
|
// (b) 有虚增 → 列出确需的两行传参改动点(不改 goal——红线),留主会话裁决。
|
|
156
155
|
//
|
|
157
|
-
// **实测结论:(b)
|
|
158
|
-
//
|
|
156
|
+
// **实测结论:(b) 有虚增。已由 d0b9a6faf(2026-09-09)关闭——goal 现传
|
|
157
|
+
// currentSessionId 基准,下方证据链与用例为无基准历史形态的对照面。**
|
|
158
|
+
// 证据链(历史口径,d0b9a6faf 前的 goal 守卫两处消费点均无基准调用):
|
|
159
159
|
// `extensions/universal/goal/src/adapters/event-handlers/agent-end.ts:198`
|
|
160
160
|
// `const pendingOps = countActiveFromEntries(entries);`(defer 分支判据
|
|
161
161
|
// = `pendingOps.count > 0`)
|
|
@@ -166,21 +166,23 @@ describe("[W6/偏差 #4 探针] goal 守卫口径(无基准)× fork 残留
|
|
|
166
166
|
// 用例 2 证明仅当父任务 record 已终态(core sweep 补注销落盘)才归零——
|
|
167
167
|
// 「父任务仍活跃」窗口内无任何机制覆盖(W4 翻档后 U4 不再中性化)。
|
|
168
168
|
//
|
|
169
|
-
//
|
|
169
|
+
// 已执行的修复(d0b9a6faf,即当时登记的两行传参改动点):
|
|
170
170
|
// agent-end.ts:198 → `countActiveFromEntries(entries, { currentSessionId: ctx.sessionManager.getSessionId() })`
|
|
171
171
|
// agent-end.ts:294 → `countActiveFromEntries(ctx.sessionManager.getEntries(), { currentSessionId: ctx.sessionManager.getSessionId() })`
|
|
172
172
|
// (getSessionId 是 extension 侧既有取 sessionId 形态——pending-notifications
|
|
173
|
-
// index.ts:216、subagent-workflow session-lifecycle.ts
|
|
173
|
+
// index.ts:216、subagent-workflow session-lifecycle.ts 同款先例;现行
|
|
174
|
+
// agent-end.ts 守卫注释反向引用本探针作为传基准依据。)
|
|
174
175
|
|
|
175
|
-
it("用例 1:父 session 活跃 subagent 残留 →
|
|
176
|
+
it("用例 1:父 session 活跃 subagent 残留 → 无基准对照面(历史形态)count>0 → defer 分支命中(虚增)", () => {
|
|
176
177
|
// fork 时刻快照:父 session 派了后台子代理(record 仍 running-resumable),
|
|
177
178
|
// 子 session 继承其 register 残留;goal 守卫读到的 entries = 本 session 文件
|
|
178
|
-
//
|
|
179
|
+
// 全量(含继承残留)。历史形态 = 无基准调用(d0b9a6faf 前 agent-end.ts:198
|
|
180
|
+
// 逐字形态;现行已传基准,此对照面锁定「不传则虚增」的机制事实)。
|
|
179
181
|
const childEntries = [
|
|
180
182
|
registerEntry("parent-bg", "subagent", "sess-parent"),
|
|
181
183
|
registerEntry("child-bg", "subagent", "sess-child"),
|
|
182
184
|
];
|
|
183
|
-
const pendingOps = countActiveFromEntries(childEntries); // ←
|
|
185
|
+
const pendingOps = countActiveFromEntries(childEntries); // ← 无基准对照面(历史形态)
|
|
184
186
|
// defer 分支判据(agent-end.ts:199 `if (pendingOps.count > 0)`)命中:
|
|
185
187
|
// 父 session 的任务让子 session 的 goal 停发 continuation 并 notify
|
|
186
188
|
// 「Goal waiting for 2 background task(s)」——计数含非本 session 任务 = 虚增。
|
|
@@ -209,9 +211,9 @@ describe("[W6/偏差 #4 探针] goal 守卫口径(无基准)× fork 残留
|
|
|
209
211
|
});
|
|
210
212
|
expect(result.reconciled).toEqual(["parent-bg"]);
|
|
211
213
|
|
|
212
|
-
// 落盘后 goal
|
|
213
|
-
//
|
|
214
|
-
//
|
|
214
|
+
// 落盘后 goal 守卫(无基准口径,历史形态)重读 entries:计数归零——sweep 是
|
|
215
|
+
// 无基准口径的唯一收敛通道,且只覆盖「record 可判定终态」的窗口;「父任务仍
|
|
216
|
+
// 活跃」窗口(用例 1)无机制覆盖 → 虚增结论 (b) 成立。
|
|
215
217
|
const entriesAfter = readFileSync(sessionFile, "utf8")
|
|
216
218
|
.trim()
|
|
217
219
|
.split("\n")
|