@zhushanwen/subagent-core 0.2.0 → 0.4.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/README.md +45 -2
- package/agents/analyst.md +60 -0
- package/agents/coder.md +69 -0
- package/agents/debugger.md +66 -0
- package/agents/doc-reviewer.md +49 -0
- package/agents/explorer.md +63 -0
- package/agents/general-purpose.md +32 -0
- package/agents/orchestrator.md +61 -0
- package/agents/planner.md +53 -0
- package/agents/researcher.md +67 -0
- package/agents/reviewer.md +73 -0
- package/dist/chunk-4KN4TTG7.js +240 -0
- package/dist/chunk-APZY4IME.js +27 -0
- package/dist/chunk-X7SZ5HLQ.js +66 -0
- package/dist/execution/engine/engines/zcode/constants.cjs +65 -0
- package/dist/execution/engine/engines/zcode/constants.d.cts +93 -1
- package/dist/execution/engine/engines/zcode/constants.d.ts +93 -1
- package/dist/execution/engine/engines/zcode/constants.js +43 -1
- package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
- package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
- package/dist/execution/engine/engines/zcode/reader.js +4 -234
- package/dist/execution/engine/paths.js +7 -19
- package/dist/index.cjs +14709 -100
- package/dist/index.d.cts +4053 -200
- package/dist/index.d.ts +4053 -200
- package/dist/index.js +14358 -102
- package/dist/{types-BxyAidGf.d.cts → types-DpUO16pj.d.cts} +464 -1
- package/dist/{types-BxyAidGf.d.ts → types-DpUO16pj.d.ts} +464 -1
- package/package.json +6 -3
- package/src/__tests__/review-fix-loop-script.test.ts +90 -2
- package/src/__tests__/review-fix-loop-utils.test.ts +21 -0
- package/src/__tests__/smoke.test.ts +9 -2
- package/src/execution/__tests__/agent-profile.test.ts +232 -0
- package/src/execution/__tests__/agents-assembly.test.ts +219 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +71 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +1 -0
- package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
- package/src/execution/__tests__/delivery-methods.test.ts +2 -0
- package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
- package/src/execution/__tests__/dialog-queue.test.ts +199 -1
- package/src/execution/__tests__/epipe-fallback.test.ts +2 -0
- package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
- package/src/execution/__tests__/finalize-record.test.ts +106 -0
- package/src/execution/__tests__/gc-timer.test.ts +2 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +2 -0
- package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
- package/src/execution/__tests__/keep-alive-no-progress.test.ts +378 -0
- package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
- package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
- package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
- package/src/execution/__tests__/notify-ledger.test.ts +294 -0
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
- package/src/execution/__tests__/record-store.test.ts +83 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +4 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +1 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +407 -17
- package/src/execution/__tests__/run-spawn-integration.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +12 -4
- package/src/execution/__tests__/service-kill-escalation.test.ts +89 -0
- package/src/execution/__tests__/session-pending.test.ts +228 -0
- package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
- package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
- package/src/execution/__tests__/session-runner-epipe.test.ts +1 -0
- package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +226 -0
- package/src/execution/__tests__/settled-watchdog.test.ts +289 -0
- package/src/execution/__tests__/spawned-children.test.ts +153 -10
- package/src/execution/__tests__/subagent-actions-core.test.ts +997 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +71 -10
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +266 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +3 -0
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +316 -0
- package/src/execution/__tests__/timeout-integration.test.ts +7 -7
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +95 -1
- package/src/execution/__tests__/ui-request-queue.test.ts +25 -0
- package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
- package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
- package/src/execution/agent-registry.ts +225 -1
- package/src/execution/agents-assembly.ts +88 -0
- package/src/execution/concurrency-pool.ts +71 -9
- package/src/execution/dialog-queue.ts +101 -3
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +49 -2
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +87 -1
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +26 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +83 -1
- package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +131 -0
- package/src/execution/engine/__tests__/registry.test.ts +90 -1
- package/src/execution/engine/engine-discovery.ts +12 -16
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +3 -3
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +274 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
- package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +472 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +387 -0
- package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +780 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +815 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +462 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +119 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +136 -6
- package/src/execution/engine/engines/zcode/appserver-home.ts +442 -0
- package/src/execution/engine/engines/zcode/appserver-probe.ts +141 -0
- package/src/execution/engine/engines/zcode/connection.ts +585 -0
- package/src/execution/engine/engines/zcode/constants.ts +129 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +54 -8
- package/src/execution/engine/engines/zcode/preparer.ts +25 -22
- package/src/execution/engine/engines/zcode/session-channel.ts +656 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +909 -41
- package/src/execution/engine/host-task-spec.ts +3 -3
- package/src/execution/engine/port.ts +41 -1
- package/src/execution/engine/registry.ts +53 -0
- package/src/execution/finalize-record.ts +63 -5
- package/src/execution/get-state-handshake.ts +85 -10
- package/src/execution/lifecycle-manager.ts +27 -3
- package/src/execution/manifest-store.ts +53 -62
- package/src/execution/notifier.ts +10 -10
- package/src/execution/notify-ledger.ts +117 -16
- package/src/execution/record-entry.ts +8 -2
- package/src/execution/record-store.ts +29 -15
- package/src/execution/session-pending.ts +213 -62
- package/src/execution/session-runner.ts +974 -128
- package/src/execution/sessions-index.ts +10 -55
- package/src/execution/settled-watchdog.ts +99 -0
- package/src/execution/subagent-actions-core.ts +686 -0
- package/src/execution/subagent-service.ts +396 -49
- package/src/execution/ui-request-handler-factory.ts +27 -4
- package/src/execution/worktree-git-ops.ts +397 -0
- package/src/execution/worktree-manager.ts +72 -10
- package/src/execution/worktree-registry.ts +6 -12
- package/src/index.ts +413 -6
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -4
- package/src/orchestration/__tests__/args-meta.test.ts +358 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +1 -8
- package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +382 -0
- package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store.test.ts +389 -0
- package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
- package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +77 -0
- package/src/orchestration/__tests__/run-snapshot.test.ts +353 -0
- package/src/orchestration/__tests__/script-generate.test.ts +314 -0
- package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
- package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
- package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
- package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
- package/src/orchestration/agent-opts-resolver.ts +7 -7
- package/src/orchestration/args-meta.ts +198 -0
- package/src/orchestration/error-recovery.ts +416 -146
- package/src/orchestration/execute-agent-call.ts +9 -9
- package/src/orchestration/file-run-store.ts +327 -0
- package/src/orchestration/launcher.ts +35 -26
- package/src/orchestration/lifecycle.ts +355 -79
- package/src/orchestration/models/agent-call.ts +7 -7
- package/src/orchestration/models/budget.ts +5 -5
- package/src/orchestration/models/run-runtime.ts +13 -13
- package/src/orchestration/models/trace.ts +8 -8
- package/src/orchestration/models/workflow-run.ts +27 -27
- package/src/orchestration/models/workflow-script.ts +4 -4
- package/src/orchestration/run-snapshot.ts +266 -0
- package/src/orchestration/script-generate.ts +154 -0
- package/src/orchestration/script-lint.ts +33 -33
- package/src/orchestration/worker-handle.ts +10 -10
- package/src/orchestration/worker-host.ts +10 -10
- package/src/orchestration/worker-script-builder.ts +401 -346
- package/src/orchestration/workflow-files.ts +37 -11
- package/src/orchestration/workflow-run-summary.ts +69 -0
- package/src/orchestration/workflow-script-registry-impl.ts +31 -9
- package/src/shared/__tests__/agent-ref.test.ts +209 -2
- package/src/shared/__tests__/atomic-write.test.ts +267 -0
- package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
- package/src/shared/__tests__/injection-render.test.ts +518 -0
- package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
- package/src/shared/__tests__/resource-discovery.test.ts +3 -2
- package/src/shared/agent-ref.ts +143 -2
- package/src/shared/atomic-write.ts +320 -0
- package/src/shared/bounded-serialize.ts +154 -0
- package/src/shared/injection-render.ts +279 -0
- package/src/shared/meta-parser.ts +41 -1
- package/src/shared/resource-discovery.ts +104 -37
- package/src/shared/resource-meta.ts +14 -0
- package/src/shared/xml-injection.ts +9 -9
- package/workflows/README.md +9 -9
- package/workflows/chain.js +4 -2
- package/workflows/map-reduce.js +5 -3
- package/workflows/parallel.js +5 -3
- package/workflows/review-fix-loop-utils.cjs +14 -3
- package/workflows/review-fix-loop.js +18 -7
- package/workflows/scatter-gather.js +4 -2
- package/dist/chunk-3VOERJPJ.js +0 -22
|
@@ -13,8 +13,9 @@ import type { ChildProcess } from "node:child_process";
|
|
|
13
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
14
|
|
|
15
15
|
// mock logger(doFinalizeRecord manifest 写入降级路径用)
|
|
16
|
-
const { loggerMock } = vi.hoisted(() => ({
|
|
16
|
+
const { loggerMock, killEscalationSpy } = vi.hoisted(() => ({
|
|
17
17
|
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
18
|
+
killEscalationSpy: vi.fn(),
|
|
18
19
|
}));
|
|
19
20
|
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
20
21
|
|
|
@@ -23,6 +24,9 @@ vi.mock("../session-runner.ts", () => ({
|
|
|
23
24
|
runSpawn: vi.fn(),
|
|
24
25
|
killAllSpawnedChildren: vi.fn(),
|
|
25
26
|
getChildByRecord: vi.fn(() => undefined),
|
|
27
|
+
registerSpawnedChildForRecord: vi.fn(),
|
|
28
|
+
killRecordChildWithEscalation: killEscalationSpy,
|
|
29
|
+
spawnedChildren: new Map(),
|
|
26
30
|
}));
|
|
27
31
|
|
|
28
32
|
import { createRecord, updateFromEvent } from "../execution-record.ts";
|
|
@@ -32,6 +36,9 @@ import { SubagentService } from "../subagent-service.ts";
|
|
|
32
36
|
import type { PiLike } from "../subagent-service.ts";
|
|
33
37
|
import type { ExecutionRecord } from "../types.ts";
|
|
34
38
|
import { getChildByRecord } from "../session-runner.ts";
|
|
39
|
+
|
|
40
|
+
// [T2④] kill 收敛入口 spy(工厂引用,断言服务侧三条路径收敛到它)
|
|
41
|
+
const killRecordChildWithEscalationMock = killEscalationSpy;
|
|
35
42
|
import {
|
|
36
43
|
armIdleTimer,
|
|
37
44
|
disarmIdleTimer,
|
|
@@ -207,9 +214,8 @@ describe("closeSubagent 行为分流", () => {
|
|
|
207
214
|
// 旧代码 Path A 走 else 置 closeAfterRound,但 chatMode 轮完成恒经 agent_settled →
|
|
208
215
|
// onRoundSettled → runAndFinalize early return(isIdle 恒 true),标志无人消费,
|
|
209
216
|
// tool 却返回 {closed:true}(谎报)。
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
vi.mocked(getChildByRecord).mockReturnValueOnce(child);
|
|
217
|
+
// [T2④] closeChatIdle 的 kill 收敛到 killRecordChildWithEscalation(recordId 入口),
|
|
218
|
+
// 不再读 child 句柄——无 child mock(Once mock 不被消费会泄漏劫持后续用例)
|
|
213
219
|
const record = makeRecord({ status: "running", round: 1 });
|
|
214
220
|
record.sessionFile = path.join(agentDir, "path-a.jsonl");
|
|
215
221
|
store.register(record);
|
|
@@ -226,7 +232,8 @@ describe("closeSubagent 行为分流", () => {
|
|
|
226
232
|
expect(record.status).toBe("closed");
|
|
227
233
|
expect(record.closedReason).toBe("user-close");
|
|
228
234
|
expect(store.getMutable(record.id)).toBeUndefined(); // archived
|
|
229
|
-
|
|
235
|
+
// [T2④] kill 收敛到 killRecordChildWithEscalation(SIGTERM + 30s SIGKILL 升级)
|
|
236
|
+
expect(killRecordChildWithEscalationMock).toHaveBeenCalledWith(record.id, "closeChatIdle");
|
|
230
237
|
expect(hasIdleTimer(record.id)).toBe(false); // timer disarmed
|
|
231
238
|
});
|
|
232
239
|
|
|
@@ -253,9 +260,8 @@ describe("closeSubagent 行为分流", () => {
|
|
|
253
260
|
it("[M6] running + force:true + 活进程(热路径轮中)→ cancelBackground 显式 SIGTERM + disarm idle timer", async () => {
|
|
254
261
|
// chatMode 首轮 agent_settled 后 runSpawn 提前 resolveRun(0) 返回,
|
|
255
262
|
// abort→kill listener 已被 removeEventListener——热路径轮中 cancel 只能靠显式 kill。
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
vi.mocked(getChildByRecord).mockReturnValueOnce(child);
|
|
263
|
+
// [T2④] cancelBackground 的 kill 收敛到 killRecordChildWithEscalation(recordId 入口),
|
|
264
|
+
// 不再读 child 句柄
|
|
259
265
|
const record = makeRecord({ status: "running", sessionFile: path.join(agentDir, "hot.jsonl") });
|
|
260
266
|
record.controller = new AbortController();
|
|
261
267
|
store.register(record);
|
|
@@ -268,7 +274,8 @@ describe("closeSubagent 行为分流", () => {
|
|
|
268
274
|
disarmIdleTimer(record.id); // 断言失败路径的兜底清理
|
|
269
275
|
}
|
|
270
276
|
|
|
271
|
-
|
|
277
|
+
// [T2④] kill 收敛到 killRecordChildWithEscalation(SIGTERM + 升级)
|
|
278
|
+
expect(killRecordChildWithEscalationMock).toHaveBeenCalledWith(record.id, "cancelBackground");
|
|
272
279
|
expect(record.status).toBe("closed");
|
|
273
280
|
expect(record.closedReason).toBe("cancelled");
|
|
274
281
|
expect(hasIdleTimer(record.id)).toBe(false); // timer disarmed
|
|
@@ -276,6 +283,59 @@ describe("closeSubagent 行为分流", () => {
|
|
|
276
283
|
});
|
|
277
284
|
});
|
|
278
285
|
|
|
286
|
+
// ============================================================
|
|
287
|
+
// [A2-1] cancel 迟到:record 已终态(detached 已 finalize)→ CAS 拒绝
|
|
288
|
+
// ============================================================
|
|
289
|
+
|
|
290
|
+
describe("cancelBackground CAS 抢锁(A2-1)——cancel 迟到不覆写已完成 record", () => {
|
|
291
|
+
let agentDir: string;
|
|
292
|
+
let service: SubagentService;
|
|
293
|
+
let store: RecordStore;
|
|
294
|
+
let pi: MockPi;
|
|
295
|
+
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
({ agentDir, service, store, pi } = setup());
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
afterEach(() => {
|
|
301
|
+
service.dispose();
|
|
302
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("record 已 closed 未 archive(doFinalizeRecord Step 0 await 窗口形态)→ cancel 返回 false,不覆写终态/不写 tombstone/不 notify/不 archive", () => {
|
|
306
|
+
const sessionFile = path.join(agentDir, "late-cancel.jsonl");
|
|
307
|
+
const record = makeRecord({ status: "closed", sessionFile });
|
|
308
|
+
// 模拟 detached 已 finalize 的终态快照(closedReason=gc 正常完成,非 cancelled)
|
|
309
|
+
record.closedReason = "gc";
|
|
310
|
+
record.endedAt = 5555;
|
|
311
|
+
record.result = "detached final result";
|
|
312
|
+
store.register(record); // 未 archive:恰好处于 finalize await 窗口(收尾后段才 archive)
|
|
313
|
+
|
|
314
|
+
const cancelled = service.cancel(record.id);
|
|
315
|
+
|
|
316
|
+
expect(cancelled).toBe(false); // CAS 没抢到——不谎报 true
|
|
317
|
+
expect(record.status).toBe("closed");
|
|
318
|
+
expect(record.closedReason).toBe("gc"); // 不被 completeRecord 改写为 cancelled
|
|
319
|
+
expect(record.endedAt).toBe(5555); // finalize 冻结的终态值不被重写
|
|
320
|
+
expect(record.result).toBe("detached final result"); // 不被空 cancelled result 覆写
|
|
321
|
+
// 不写 cancelled tombstone(与 finalized marker 双标 = record 双重终态化)
|
|
322
|
+
expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
|
|
323
|
+
expect(store.getMutable(record.id)).toBe(record); // 不 archive(record 留内存)
|
|
324
|
+
expect(pi.sendMessage).not.toHaveBeenCalled(); // 不重复 notify(detached finalize 已发)
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("record running → cancel 返回 true 且终态化(CAS 正常路径不回归)", () => {
|
|
328
|
+
const sessionFile = path.join(agentDir, "cancel-ok.jsonl");
|
|
329
|
+
const record = makeRecord({ status: "running", sessionFile });
|
|
330
|
+
store.register(record);
|
|
331
|
+
|
|
332
|
+
expect(service.cancel(record.id)).toBe(true);
|
|
333
|
+
expect(record.status).toBe("closed");
|
|
334
|
+
expect(record.closedReason).toBe("cancelled");
|
|
335
|
+
expect(store.getMutable(record.id)).toBeUndefined(); // archived
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
279
339
|
// ============================================================
|
|
280
340
|
// [M5] closeAfterRound 消费点:onRoundSettled(chatMode 热路径轮完成)
|
|
281
341
|
// ============================================================
|
|
@@ -325,7 +385,8 @@ describe("[M5] closeAfterRound 消费点挂 onRoundSettled(chatMode 轮完成
|
|
|
325
385
|
expect(record.closeAfterRound).toBeUndefined(); // 标志已消费
|
|
326
386
|
expect(record.status).toBe("closed");
|
|
327
387
|
expect(record.closedReason).toBe("user-close");
|
|
328
|
-
|
|
388
|
+
// [T2④] kill 收敛到 killRecordChildWithEscalation(SIGTERM + 升级)
|
|
389
|
+
expect(killRecordChildWithEscalationMock).toHaveBeenCalledWith(record.id, "closeAfterRoundSettled");
|
|
329
390
|
expect(hasIdleTimer(record.id)).toBe(false);
|
|
330
391
|
});
|
|
331
392
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/execution/__tests__/subagent-service-multiproc-guard.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [u-svc / T5] 多进程共享文件守卫(subagent-service 侧两项):
|
|
4
|
+
// - T5①/PS-8:子进程 initSession 跳过 recoverOrphanRecords(env PI_SUBAGENT_SELF_RECORD_ID
|
|
5
|
+
// 判子进程身份)——恢复机制假设「单扫描者」,env 贯穿让每个子进程都成了扫描者,
|
|
6
|
+
// 兄弟记录 marker 缺失/超软超时被无关进程盖终态 sidecar;
|
|
7
|
+
// - T5③/PS-7b:running 候选冷查补 findForeignLiveInstance 探针守卫——异进程活实例在册
|
|
8
|
+
// 时拒绝 resurrect + resume spawn(防双写者窗口)。
|
|
9
|
+
//
|
|
10
|
+
// mock 形态沿用 subagent-service-message-close.test.ts;alive-store mock(findForeignLiveInstance
|
|
11
|
+
// 换 spy 控制探针命中)。
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
20
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
23
|
+
|
|
24
|
+
const { killChildSpy, foreignLiveSpy } = vi.hoisted(() => ({
|
|
25
|
+
killChildSpy: vi.fn(),
|
|
26
|
+
foreignLiveSpy: vi.fn<() => { pid: number; id: string; startedAt: number } | undefined>(),
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("../alive-store.ts", () => ({
|
|
29
|
+
writeAliveMarker: vi.fn(),
|
|
30
|
+
removeAliveMarker: vi.fn(),
|
|
31
|
+
readAliveMarker: vi.fn(() => undefined),
|
|
32
|
+
isProcessAlive: vi.fn(() => false),
|
|
33
|
+
ALIVE_SOFT_TIMEOUT_MS: 3_600_000,
|
|
34
|
+
findForeignLiveInstance: foreignLiveSpy,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock("../session-runner.ts", () => ({
|
|
38
|
+
runSpawn: vi.fn(),
|
|
39
|
+
killAllSpawnedChildren: vi.fn(),
|
|
40
|
+
getChildByRecord: vi.fn(() => undefined),
|
|
41
|
+
registerSpawnedChildForRecord: vi.fn(),
|
|
42
|
+
spawnedChildren: new Map(),
|
|
43
|
+
killRecordChildWithEscalation: killChildSpy,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
47
|
+
import type { RecordStore } from "../record-store.ts";
|
|
48
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
49
|
+
import { ResurrectDeniedError } from "../types.ts";
|
|
50
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
51
|
+
|
|
52
|
+
function makeTmpAgentDir(): string {
|
|
53
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "svc-multiproc-"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makePi(): PiLike {
|
|
57
|
+
return {
|
|
58
|
+
appendEntry: vi.fn(),
|
|
59
|
+
events: { emit: vi.fn() },
|
|
60
|
+
sendMessage: vi.fn(),
|
|
61
|
+
} as unknown as PiLike;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setup(): { agentDir: string; service: SubagentService; store: RecordStore } {
|
|
65
|
+
const agentDir = makeTmpAgentDir();
|
|
66
|
+
const modelService = new ModelConfigService({ agentDir });
|
|
67
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
68
|
+
service.initSession({ pi: makePi(), sessionId: "root-session" });
|
|
69
|
+
const store = (service as unknown as { store: RecordStore }).store;
|
|
70
|
+
return { agentDir, service, store };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("T5① child process skips orphan recovery scan", () => {
|
|
74
|
+
let agentDir: string;
|
|
75
|
+
|
|
76
|
+
/** 构造 service 并在其 store 上挂 spy 后再 initSession(spy 必须先于触发点)。 */
|
|
77
|
+
const makeServiceWithRecoverySpies = (): {
|
|
78
|
+
service: SubagentService;
|
|
79
|
+
recover: ReturnType<typeof vi.spyOn>;
|
|
80
|
+
entryOnly: ReturnType<typeof vi.spyOn>;
|
|
81
|
+
} => {
|
|
82
|
+
const modelService = new ModelConfigService({ agentDir });
|
|
83
|
+
const svc = new SubagentService({ cwd: agentDir, modelService });
|
|
84
|
+
const store = (svc as unknown as { store: RecordStore }).store;
|
|
85
|
+
const recover = vi.spyOn(store, "recoverOrphanRecords").mockImplementation(() => {});
|
|
86
|
+
const entryOnly = vi.spyOn(store, "recoverEntryOnlyOrphans").mockImplementation(() => {});
|
|
87
|
+
return { service: svc, recover, entryOnly };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.stubEnv("PI_SUBAGENT_SELF_RECORD_ID", "");
|
|
92
|
+
agentDir = makeTmpAgentDir();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.unstubAllEnvs();
|
|
97
|
+
vi.restoreAllMocks();
|
|
98
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("root process (no self-record env) runs the orphan recovery scan", () => {
|
|
102
|
+
const { service: root, recover, entryOnly } = makeServiceWithRecoverySpies();
|
|
103
|
+
root.initSession({ pi: makePi(), sessionId: "root-session-2" });
|
|
104
|
+
expect(recover).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(entryOnly).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("child process (PI_SUBAGENT_SELF_RECORD_ID set) skips the scan entirely", () => {
|
|
109
|
+
vi.stubEnv("PI_SUBAGENT_SELF_RECORD_ID", "sa-child-self");
|
|
110
|
+
const { service: child, recover, entryOnly } = makeServiceWithRecoverySpies();
|
|
111
|
+
child.initSession({ pi: makePi(), sessionId: "child-session" });
|
|
112
|
+
expect(recover).not.toHaveBeenCalled();
|
|
113
|
+
expect(entryOnly).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("T5③ cold-lookup running candidate foreign-instance guard", () => {
|
|
118
|
+
let agentDir: string;
|
|
119
|
+
let service: SubagentService;
|
|
120
|
+
let store: RecordStore;
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
foreignLiveSpy.mockReset();
|
|
124
|
+
foreignLiveSpy.mockReturnValue(undefined);
|
|
125
|
+
({ agentDir, service, store } = setup());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
vi.restoreAllMocks();
|
|
130
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const stubColdCandidate = (record: {
|
|
134
|
+
id: string;
|
|
135
|
+
status: "running";
|
|
136
|
+
sessionFile: string;
|
|
137
|
+
rootSessionId: string;
|
|
138
|
+
parentRecordId?: string;
|
|
139
|
+
}): void => {
|
|
140
|
+
vi.spyOn(store, "findLightById").mockReturnValue(undefined);
|
|
141
|
+
vi.spyOn(store, "collectRecords").mockReturnValue([record] as never);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
it("denies resurrection with ResurrectDeniedError when a foreign live instance holds the session", () => {
|
|
145
|
+
stubColdCandidate({
|
|
146
|
+
id: "sa-foreign",
|
|
147
|
+
status: "running",
|
|
148
|
+
sessionFile: "/tmp/fake-session.jsonl",
|
|
149
|
+
rootSessionId: "root-session",
|
|
150
|
+
});
|
|
151
|
+
foreignLiveSpy.mockReturnValue({ pid: 4242, id: "sa-foreign", startedAt: Date.now() });
|
|
152
|
+
|
|
153
|
+
expect(() => service.getRecordForAction("sa-foreign")).toThrow(ResurrectDeniedError);
|
|
154
|
+
try {
|
|
155
|
+
service.getRecordForAction("sa-foreign");
|
|
156
|
+
} catch (err) {
|
|
157
|
+
expect((err as Error).message).toContain("4242");
|
|
158
|
+
expect((err as Error).message).toContain("double-write");
|
|
159
|
+
expect((err as Error).message).toContain("Recovery");
|
|
160
|
+
}
|
|
161
|
+
expect(foreignLiveSpy).toHaveBeenCalledWith("/tmp/fake-session.jsonl");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("allows cold lookup when no foreign live instance (marker dead/stale)", () => {
|
|
165
|
+
stubColdCandidate({
|
|
166
|
+
id: "sa-cold",
|
|
167
|
+
status: "running",
|
|
168
|
+
sessionFile: "/tmp/fake-session-2.jsonl",
|
|
169
|
+
rootSessionId: "root-session",
|
|
170
|
+
});
|
|
171
|
+
const found = service.getRecordForAction("sa-cold");
|
|
172
|
+
expect(found.id).toBe("sa-cold");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// src/execution/__tests__/subagent-service-notify-gate.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [u-svc / T4] 通知可靠性(subagent-service 侧三措施):
|
|
4
|
+
// - T4①/PS-2:kickOffBackground.then notify 门按 closedReason 白名单放行——
|
|
5
|
+
// parent-new/parent-fork 不注入(可能已切换的)新 session(A-6 决策落实);
|
|
6
|
+
// - T4②/PS-4:idleTimeoutMs 非法值(>2^31-1 / 非有限值)在 spawn 入口同步 fail-fast,
|
|
7
|
+
// 错误含合法范围(不静默 clamp、不静默不挂);
|
|
8
|
+
// - T4④/PS-5:shutdown flush 被 isIdle 门拦时,未投递 pending 复写落盘(ledger entry
|
|
9
|
+
// 通道,notifyId 幂等)供重启 replay——修复「flush 后 dispose 防丢失」注释承诺差距。
|
|
10
|
+
//
|
|
11
|
+
// mock 形态沿用 subagent-service-message-close.test.ts;notify-ledger 用真模块
|
|
12
|
+
//(bindNotifyLedgerHost + fake host 构造 pending)。
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
21
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
24
|
+
|
|
25
|
+
const { killChildSpy } = vi.hoisted(() => ({ killChildSpy: vi.fn() }));
|
|
26
|
+
vi.mock("../session-runner.ts", () => ({
|
|
27
|
+
runSpawn: vi.fn(),
|
|
28
|
+
killAllSpawnedChildren: vi.fn(),
|
|
29
|
+
getChildByRecord: vi.fn(() => undefined),
|
|
30
|
+
registerSpawnedChildForRecord: vi.fn(),
|
|
31
|
+
spawnedChildren: new Map(),
|
|
32
|
+
killRecordChildWithEscalation: killChildSpy,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { bindNotifyLedgerHost, NOTIFY_LEDGER_CUSTOM_TYPE, _resetNotifyLedgerForTest } from "../notify-ledger.ts";
|
|
36
|
+
import { createRecord } from "../execution-record.ts";
|
|
37
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
38
|
+
import type { RecordStore } from "../record-store.ts";
|
|
39
|
+
import { notifyGateAllowsDelivery, SubagentService } from "../subagent-service.ts";
|
|
40
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
41
|
+
import { MAX_TIMER_DELAY_MS } from "../../shared/timer-delay.ts";
|
|
42
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
43
|
+
|
|
44
|
+
function makeTmpAgentDir(): string {
|
|
45
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "svc-notify-gate-"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makePi(): PiLike & {
|
|
49
|
+
appendEntry: ReturnType<typeof vi.fn>;
|
|
50
|
+
events: { emit: ReturnType<typeof vi.fn> };
|
|
51
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
52
|
+
on?: ReturnType<typeof vi.fn>;
|
|
53
|
+
} {
|
|
54
|
+
return {
|
|
55
|
+
appendEntry: vi.fn(),
|
|
56
|
+
events: { emit: vi.fn() },
|
|
57
|
+
sendMessage: vi.fn(),
|
|
58
|
+
} as unknown as PiLike & {
|
|
59
|
+
appendEntry: ReturnType<typeof vi.fn>;
|
|
60
|
+
events: { emit: ReturnType<typeof vi.fn> };
|
|
61
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
62
|
+
on?: ReturnType<typeof vi.fn>;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ServiceInternals {
|
|
67
|
+
store: RecordStore;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type MockPi = ReturnType<typeof makePi>;
|
|
71
|
+
|
|
72
|
+
function setup(initOverrides: Partial<{ isIdle: () => boolean }> = {}): {
|
|
73
|
+
agentDir: string;
|
|
74
|
+
service: SubagentService;
|
|
75
|
+
store: RecordStore;
|
|
76
|
+
pi: MockPi;
|
|
77
|
+
} {
|
|
78
|
+
const agentDir = makeTmpAgentDir();
|
|
79
|
+
const modelService = new ModelConfigService({ agentDir });
|
|
80
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
81
|
+
const pi = makePi();
|
|
82
|
+
service.initSession({ pi, sessionId: "root-session", isIdle: initOverrides.isIdle });
|
|
83
|
+
const store = (service as unknown as ServiceInternals).store;
|
|
84
|
+
return { agentDir, service, store, pi };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("T4① notify gate closedReason whitelist", () => {
|
|
88
|
+
it("blocks cancelled, parent-new, parent-fork; allows real closures and undefined (CAS-winner path)", () => {
|
|
89
|
+
expect(notifyGateAllowsDelivery("cancelled")).toBe(false);
|
|
90
|
+
expect(notifyGateAllowsDelivery("parent-new")).toBe(false);
|
|
91
|
+
expect(notifyGateAllowsDelivery("parent-fork")).toBe(false);
|
|
92
|
+
expect(notifyGateAllowsDelivery("user-close")).toBe(true);
|
|
93
|
+
expect(notifyGateAllowsDelivery("gc")).toBe(true);
|
|
94
|
+
expect(notifyGateAllowsDelivery("parent-shutdown")).toBe(true);
|
|
95
|
+
expect(notifyGateAllowsDelivery(undefined)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("kickOffBackground.then does not inject parent-new closed records into the session", async () => {
|
|
99
|
+
const { agentDir, service, pi } = setup();
|
|
100
|
+
const record = createRecord("sa-gate-new", {
|
|
101
|
+
agent: "general-purpose",
|
|
102
|
+
model: "test/model",
|
|
103
|
+
mode: "background",
|
|
104
|
+
task: "test",
|
|
105
|
+
startedAt: 1000,
|
|
106
|
+
rootSessionId: "root-session",
|
|
107
|
+
controller: new AbortController(),
|
|
108
|
+
});
|
|
109
|
+
// 模拟 disposeAllRecords 先行编排性关闭后,迟到的 kickOffBackground.then 回注
|
|
110
|
+
record.closedReason = "parent-new";
|
|
111
|
+
const stub = vi.fn().mockResolvedValue({
|
|
112
|
+
text: "",
|
|
113
|
+
turns: 0,
|
|
114
|
+
durationMs: 1,
|
|
115
|
+
success: false,
|
|
116
|
+
error: "closed due to parent-new",
|
|
117
|
+
sessionId: record.id,
|
|
118
|
+
toolCalls: [],
|
|
119
|
+
});
|
|
120
|
+
(service as unknown as Record<string, unknown>).runAndFinalize = stub;
|
|
121
|
+
const kickOffBackground = (
|
|
122
|
+
service as unknown as Record<string, (...args: unknown[]) => void>
|
|
123
|
+
)["kickOffBackground"];
|
|
124
|
+
kickOffBackground.call(service, record, { task: "t" }, {}, {}, undefined, 1000, undefined);
|
|
125
|
+
await vi.waitFor(() => expect(stub).toHaveBeenCalled());
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
await Promise.resolve();
|
|
128
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
129
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("kickOffBackground.then still notifies for real failure closures (gc)", async () => {
|
|
133
|
+
const { agentDir, service, pi } = setup();
|
|
134
|
+
const record = createRecord("sa-gate-gc", {
|
|
135
|
+
agent: "general-purpose",
|
|
136
|
+
model: "test/model",
|
|
137
|
+
mode: "background",
|
|
138
|
+
task: "test",
|
|
139
|
+
startedAt: 1000,
|
|
140
|
+
rootSessionId: "root-session",
|
|
141
|
+
controller: new AbortController(),
|
|
142
|
+
});
|
|
143
|
+
record.closedReason = "gc";
|
|
144
|
+
(service as unknown as Record<string, unknown>).runAndFinalize = vi.fn().mockResolvedValue({});
|
|
145
|
+
const kickOffBackground = (
|
|
146
|
+
service as unknown as Record<string, (...args: unknown[]) => void>
|
|
147
|
+
)["kickOffBackground"];
|
|
148
|
+
kickOffBackground.call(service, record, { task: "t" }, {}, {}, undefined, 1000, undefined);
|
|
149
|
+
await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalled());
|
|
150
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("T4② idleTimeoutMs entry fail-fast", () => {
|
|
155
|
+
let agentDir: string;
|
|
156
|
+
let service: SubagentService;
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
({ agentDir, service } = setup());
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("execute rejects idleTimeoutMs above the setTimeout limit with the valid range in the message", async () => {
|
|
167
|
+
await expect(service.execute({ task: "x", idleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })).rejects.toThrow(
|
|
168
|
+
new RegExp(`${MAX_TIMER_DELAY_MS}`),
|
|
169
|
+
);
|
|
170
|
+
await expect(service.execute({ task: "x", idleTimeoutMs: 3_000_000_000 })).rejects.toThrow(/2\^31-1/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("execute rejects non-finite idleTimeoutMs", async () => {
|
|
174
|
+
await expect(service.execute({ task: "x", idleTimeoutMs: Number.NaN })).rejects.toThrow(/not a finite number/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects before any record is created (no side effects)", async () => {
|
|
178
|
+
const internals = service as unknown as ServiceInternals;
|
|
179
|
+
await expect(service.executeAndAwait({ task: "x", idleTimeoutMs: 3_000_000_000 })).rejects.toThrow();
|
|
180
|
+
expect(internals.store.listRunning()).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("accepts valid values (0 = explicit disable, positive within limit)", async () => {
|
|
184
|
+
// 校验通过后执行链继续(runSpawn 被 mock,返回 undefined result 会在后续流程抛错/
|
|
185
|
+
// 返回——但绝不能是 idleTimeoutMs 校验错误)
|
|
186
|
+
await expect(service.execute({ task: "x", idleTimeoutMs: 0 })).rejects.not.toThrow(/idleTimeoutMs/);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("T4④ shutdown flush blocked → pending persisted for replay", () => {
|
|
191
|
+
let agentDir: string;
|
|
192
|
+
let service: SubagentService;
|
|
193
|
+
let pi: MockPi;
|
|
194
|
+
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
_resetNotifyLedgerForTest();
|
|
197
|
+
({ agentDir, service, pi } = setup({ isIdle: () => false }));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
_resetNotifyLedgerForTest();
|
|
202
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("persists undelivered pending notifications as ledger entries on dispose when main agent is busy", () => {
|
|
206
|
+
// 绑定真 ledger 并制造一条未投递 pending(isIdle=false → attemptDeliver 挂回 pending)
|
|
207
|
+
const ledger = bindNotifyLedgerHost({
|
|
208
|
+
appendLedgerEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
209
|
+
readSessionEntries: () => [],
|
|
210
|
+
isIdle: () => false,
|
|
211
|
+
onAgentSettled: () => {},
|
|
212
|
+
sendDelivery: (message) => pi.sendMessage(message as never),
|
|
213
|
+
});
|
|
214
|
+
const recorded = ledger.record("notify-1", "Subagent x completed", { id: "sa-x", agent: "x" });
|
|
215
|
+
expect(recorded).toBe(true);
|
|
216
|
+
expect(ledger.pendingEntries()).toHaveLength(1);
|
|
217
|
+
|
|
218
|
+
service.dispose();
|
|
219
|
+
|
|
220
|
+
// 复写落盘:NOTIFY_LEDGER_CUSTOM_TYPE entry 携带 notifyId(幂等键)+ v1 schema
|
|
221
|
+
const replayEntries = pi.appendEntry.mock.calls.filter(
|
|
222
|
+
(c) => c[0] === NOTIFY_LEDGER_CUSTOM_TYPE && (c[1] as { notifyId?: string })?.notifyId === "notify-1",
|
|
223
|
+
);
|
|
224
|
+
expect(replayEntries.length).toBeGreaterThanOrEqual(2); // record 原账 + dispose 复写
|
|
225
|
+
expect(replayEntries[0]?.[1]).toMatchObject({ v: 1, notifyId: "notify-1" });
|
|
226
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
227
|
+
expect.stringContaining("persisted to ledger for replay"),
|
|
228
|
+
expect.objectContaining({ count: 1 }),
|
|
229
|
+
);
|
|
230
|
+
// 重启 replay 可重放:新 ledger 实例扫账面(含复写 entry)应入账该 pending
|
|
231
|
+
const host2 = {
|
|
232
|
+
appendLedgerEntry: vi.fn(),
|
|
233
|
+
readSessionEntries: () =>
|
|
234
|
+
pi.appendEntry.mock.calls
|
|
235
|
+
.filter((c) => c[0] === NOTIFY_LEDGER_CUSTOM_TYPE)
|
|
236
|
+
.map((c) => ({ type: "custom", customType: c[0], data: c[1] })),
|
|
237
|
+
isIdle: () => false,
|
|
238
|
+
onAgentSettled: () => {},
|
|
239
|
+
sendDelivery: vi.fn(),
|
|
240
|
+
};
|
|
241
|
+
const ledger2 = bindNotifyLedgerHost(host2);
|
|
242
|
+
expect(ledger2.recoverFromSession()).toBe(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("does not rewrite when main agent is idle (flush already delivered)", () => {
|
|
246
|
+
const ledger = bindNotifyLedgerHost({
|
|
247
|
+
appendLedgerEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
248
|
+
readSessionEntries: () => [],
|
|
249
|
+
isIdle: () => false,
|
|
250
|
+
onAgentSettled: () => {},
|
|
251
|
+
sendDelivery: (message) => pi.sendMessage(message as never),
|
|
252
|
+
});
|
|
253
|
+
ledger.record("notify-2", "content", { id: "sa-y" });
|
|
254
|
+
pi.appendEntry.mockClear();
|
|
255
|
+
|
|
256
|
+
// idle service:flush 投出(无门拦),dispose 无复写动作
|
|
257
|
+
const idleService = new SubagentService({
|
|
258
|
+
cwd: agentDir,
|
|
259
|
+
modelService: new ModelConfigService({ agentDir }),
|
|
260
|
+
});
|
|
261
|
+
const piIdle = makePi();
|
|
262
|
+
idleService.initSession({ pi: piIdle, sessionId: "root-session", isIdle: () => true });
|
|
263
|
+
idleService.dispose();
|
|
264
|
+
expect(piIdle.appendEntry).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -30,6 +30,9 @@ vi.mock("../session-runner.ts", () => ({
|
|
|
30
30
|
runSpawn: vi.fn(),
|
|
31
31
|
killAllSpawnedChildren: vi.fn(),
|
|
32
32
|
getChildByRecord: vi.fn(() => undefined),
|
|
33
|
+
registerSpawnedChildForRecord: vi.fn(),
|
|
34
|
+
killRecordChildWithEscalation: vi.fn(),
|
|
35
|
+
spawnedChildren: new Map(),
|
|
33
36
|
}));
|
|
34
37
|
|
|
35
38
|
import { createRecord } from "../execution-record.ts";
|