@zhushanwen/subagent-core 0.8.0 → 0.9.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-OL5BK4VN.js → chunk-LZJOEDGQ.js} +455 -203
- package/dist/engine-discovery-scan-Da4Dkzsi.d.cts +818 -0
- package/dist/engine-discovery-scan-Da4Dkzsi.d.ts +818 -0
- package/dist/execution/engine/engine-discovery-scan.cjs +216 -155
- package/dist/execution/engine/engine-discovery-scan.d.cts +1 -2
- package/dist/execution/engine/engine-discovery-scan.d.ts +1 -2
- package/dist/execution/engine/engine-discovery-scan.js +1 -1
- package/dist/execution/relay-env.js +5 -4
- package/dist/index.cjs +7686 -5894
- package/dist/index.d.cts +1876 -1360
- package/dist/index.d.ts +1876 -1360
- package/dist/index.js +7048 -5493
- package/dist.bundle/index.cjs +7747 -5957
- package/package.json +4 -4
- package/src/__tests__/manifest-store.test.ts +16 -15
- package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
- package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
- package/src/execution/__tests__/alive-store.test.ts +117 -1
- package/src/execution/__tests__/batch-finalized.test.ts +206 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +117 -1
- package/src/execution/__tests__/{cold-resurrect.test.ts → cold-lookup.test.ts} +133 -33
- package/src/execution/__tests__/collect-coordinator-service.test.ts +7 -5
- package/src/execution/__tests__/conversation-continuation.test.ts +1452 -0
- package/src/execution/__tests__/delivery-methods.test.ts +90 -176
- package/src/execution/__tests__/dialog-queue.test.ts +70 -3
- package/src/execution/__tests__/dispose-manifest-recovery.test.ts +475 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +3 -3
- package/src/execution/__tests__/execute-nesting.test.ts +8 -5
- package/src/execution/__tests__/execution-record.test.ts +8 -148
- package/src/execution/__tests__/finalize-record.test.ts +295 -129
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +21 -29
- package/src/execution/__tests__/helpers/fake-engine-port.ts +13 -68
- package/src/execution/__tests__/helpers/legacy-sidecar.ts +33 -0
- package/src/execution/__tests__/helpers/subagent-service-mocks.ts +9 -5
- package/src/execution/__tests__/idle-gc.test.ts +60 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +3 -254
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +43 -22
- package/src/execution/__tests__/model-config-service.test.ts +111 -0
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +10 -6
- package/src/execution/__tests__/notify-ledger.test.ts +42 -1
- package/src/execution/__tests__/rebuild-indexes.test.ts +329 -0
- package/src/execution/__tests__/record-binding.test.ts +613 -0
- package/src/execution/__tests__/record-origin.test.ts +318 -0
- package/src/execution/__tests__/record-store-intent-api.test.ts +600 -0
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +71 -3
- package/src/execution/__tests__/record-store.test.ts +72 -54
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +8 -5
- package/src/execution/__tests__/round-supervisor-workflow-origin.test.ts +205 -0
- package/src/execution/__tests__/run-orchestration-write-lease.test.ts +293 -0
- package/src/execution/__tests__/run-orchestration.test.ts +199 -0
- package/src/execution/__tests__/session-file-gc.test.ts +35 -4
- package/src/execution/__tests__/session-reconstructor.test.ts +78 -0
- package/src/execution/__tests__/state-marker.test.ts +288 -0
- package/src/execution/__tests__/stream-sink-retirement.test.ts +8 -5
- package/src/execution/__tests__/subagent-actions-core.test.ts +67 -16
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +0 -1
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -11
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +17 -11
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +130 -53
- package/src/execution/__tests__/subagent-service.test.ts +17 -215
- package/src/execution/__tests__/subprocess-agent-runner-no-progress-killall.test.ts +138 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +105 -660
- package/src/execution/__tests__/sync-collect-recovery.test.ts +23 -16
- package/src/execution/__tests__/terminal-write-latency.test.ts +79 -0
- package/src/execution/__tests__/workflow-agent-dispatch.test.ts +782 -0
- package/src/execution/alive-store.ts +37 -28
- package/src/execution/{cold-resurrect.ts → cold-lookup.ts} +48 -45
- package/src/execution/config.ts +1 -1
- package/src/execution/conversation-continuation.ts +541 -0
- package/src/execution/dialog-queue.ts +60 -12
- package/src/execution/engine/__tests__/common/capability-gate.test.ts +1 -1
- package/src/execution/engine/__tests__/common/journal-wiring.test.ts +16 -1
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +17 -51
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +0 -5
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +1 -1
- package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +5 -9
- package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +0 -3
- package/src/execution/engine/__tests__/engine-discovery.test.ts +0 -3
- package/src/execution/engine/__tests__/model-prompt.test.ts +0 -3
- package/src/execution/engine/__tests__/registry.test.ts +0 -1
- package/src/execution/engine/__tests__/routing.test.ts +0 -1
- package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +0 -3
- package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +0 -3
- package/src/execution/engine/client/__tests__/engine-client-reap.test.ts +212 -0
- package/src/execution/engine/client/__tests__/mirror.test.ts +73 -1
- package/src/execution/engine/client/__tests__/protocol-closure.test.ts +2 -9
- package/src/execution/engine/client/__tests__/remote-engine.test.ts +46 -12
- package/src/execution/engine/client/engine-client.ts +81 -25
- package/src/execution/engine/client/mirror.ts +9 -0
- package/src/execution/engine/client/remote-engine.ts +52 -34
- package/src/execution/engine/client/reverse-router.ts +6 -27
- package/src/execution/engine/common/__tests__/run-signals.test.ts +79 -0
- package/src/execution/engine/common/capability-gate.ts +27 -0
- package/src/execution/engine/common/data-dir.ts +3 -2
- package/src/execution/engine/common/errors.ts +1 -1
- package/src/execution/engine/common/journal-wiring.ts +18 -9
- package/src/execution/engine/common/run-signals.ts +83 -0
- package/src/execution/engine/d8-compat.ts +1 -1
- package/src/execution/engine/host/__tests__/host-bridge.test.ts +3 -18
- package/src/execution/engine/host/host-bridge.ts +19 -41
- package/src/execution/engine/host/pi-host-binding.ts +5 -5
- package/src/execution/engine/port.ts +34 -54
- package/src/execution/engine/types.ts +11 -14
- package/src/execution/execution-record.ts +15 -47
- package/src/execution/finalize-record.ts +94 -197
- package/src/execution/idle-gc.ts +9 -3
- package/src/execution/lifecycle-manager.ts +25 -361
- package/src/execution/lifecycle-predicates.ts +22 -19
- package/src/execution/manifest-store.ts +40 -42
- package/src/execution/model-config-service.ts +1 -1
- package/src/execution/notifier.ts +39 -1
- package/src/execution/notify-host.ts +13 -4
- package/src/execution/record-entry.ts +20 -2
- package/src/execution/record-store.ts +925 -160
- package/src/execution/relay-env.ts +8 -3
- package/src/execution/round-supervisor/domain.ts +17 -5
- package/src/execution/round-supervisor/service-binding.test.ts +89 -53
- package/src/execution/round-supervisor/service-binding.ts +71 -21
- package/src/execution/round-supervisor/supervisor.ts +23 -9
- package/src/execution/service/record-access.ts +547 -0
- package/src/execution/service/record-lifecycle.ts +542 -0
- package/src/execution/service/run-orchestration.ts +1647 -0
- package/src/execution/service/service-bootstrap.ts +111 -0
- package/src/execution/service/service-constants.ts +27 -0
- package/src/execution/service/session-baselines.ts +401 -0
- package/src/execution/service/sync-collect-domain.ts +442 -0
- package/src/execution/service/workflow-dispatch.ts +567 -0
- package/src/execution/session-file-gc.ts +11 -8
- package/src/execution/session-reconstructor.ts +44 -1
- package/src/execution/sessions-index.ts +20 -3
- package/src/execution/settled-watchdog.ts +86 -43
- package/src/execution/state-marker.ts +516 -0
- package/src/execution/stream-sink.ts +1 -1
- package/src/execution/subagent-actions-core.ts +53 -31
- package/src/execution/subagent-service.ts +643 -2980
- package/src/execution/subprocess-agent-runner.ts +46 -323
- package/src/execution/types.ts +60 -43
- package/src/execution/ui-request-handler-factory.ts +5 -0
- package/src/execution/worktree-manager.ts +1 -1
- package/src/execution/worktree-registry.ts +1 -1
- package/src/index.ts +6 -5
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -3
- package/src/orchestration/__tests__/agent-call-stream.test.ts +24 -49
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +4 -4
- package/src/orchestration/__tests__/file-run-store.test.ts +5 -7
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +2 -3
- package/src/orchestration/__tests__/run-snapshot.test.ts +27 -43
- package/src/orchestration/__tests__/worker-message-pump-workflow-dispatch.test.ts +274 -0
- package/src/orchestration/execute-agent-call.ts +1 -1
- package/src/orchestration/file-run-store.ts +1 -1
- package/src/orchestration/models/ports.ts +20 -13
- package/src/orchestration/models/types.ts +11 -13
- package/src/orchestration/run-snapshot.ts +18 -22
- package/src/orchestration/worker-message-pump.ts +33 -57
- package/src/shared/atomic-write.ts +10 -8
- package/dist/chunk-T2SYBW3I.js +0 -24
- package/dist/engine-discovery-scan-BXLA8y-z.d.cts +0 -1624
- package/dist/engine-discovery-scan-BXLA8y-z.d.ts +0 -1624
- package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +0 -255
- package/src/execution/__tests__/finalized-marker.test.ts +0 -104
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +0 -211
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +0 -488
- package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +0 -75
- package/src/execution/__tests__/tombstone-store.test.ts +0 -73
- package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +0 -230
- package/src/execution/finalized-marker.ts +0 -70
- package/src/execution/tombstone-store.ts +0 -72
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
// src/execution/__tests__/manifest-store-tmp-recovery.test.ts
|
|
2
2
|
//
|
|
3
|
-
// [
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// [U4c / G3] tmp 恢复退役(D6):sweepTmpFiles 语义 = 全部 tmp **静默删除**——
|
|
4
|
+
// manifest 已是可丢可重建缓存(权威 = `.state`,重建 = RecordStore.rebuildIndexes),
|
|
5
|
+
// promote 半写 tmp 的恢复语义失效。断言面:
|
|
6
|
+
// - 合法 JSON 的 tmp(旧 promote 分支 2 形态)也删——promote 退役的核心锚点;
|
|
7
|
+
// - manifest 已存在时的陈旧 tmp(旧分支 1 形态)删;
|
|
8
|
+
// - 非法 JSON 的 tmp(旧分支 3 形态)删;
|
|
9
|
+
// - 返回删除计数(promote 退役后无恢复形态;公开转发链 recoverManifestTmpFiles 签名不变);
|
|
10
|
+
// - [T5④ / PS-13] per-file 容错保留:单个 tmp 删除失败(ENOENT——并发回收/外部
|
|
11
|
+
// 清理抢先)只 warn + 跳过,不再中断整轮。
|
|
6
12
|
|
|
7
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
14
|
|
|
@@ -36,7 +42,7 @@ const VALID_MANIFEST = {
|
|
|
36
42
|
status: "running",
|
|
37
43
|
};
|
|
38
44
|
|
|
39
|
-
describe("
|
|
45
|
+
describe("[U4c/G3] sweepTmpFiles 静默删除(promote 语义失效;H4/U5 更名收口)", () => {
|
|
40
46
|
let dir: string;
|
|
41
47
|
let store: ManifestStore;
|
|
42
48
|
|
|
@@ -57,15 +63,32 @@ describe("T5④ recoverTmpFiles per-file tolerance", () => {
|
|
|
57
63
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
it("
|
|
61
|
-
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
62
|
-
// 三个 tmp:good(合法 → promote)、stale(manifest sa-stale.json 已存在 → 删)、
|
|
63
|
-
// doomed(unlink ENOENT)
|
|
66
|
+
it("合法 tmp + manifest 缺失(旧 promote 形态)→ 删而非提升(promote 退役锚点)", async () => {
|
|
64
67
|
fs.writeFileSync(path.join(dir, "sa-good.json.tmp.111"), JSON.stringify(VALID_MANIFEST));
|
|
68
|
+
const result = await store.sweepTmpFiles();
|
|
69
|
+
// 重建源 = `.state` + rebuildIndexes——半写 tmp 不再被复活成「看似权威」的索引
|
|
70
|
+
expect(result).toBe(1);
|
|
71
|
+
expect(fs.existsSync(path.join(dir, "sa-good.json"))).toBe(false);
|
|
72
|
+
expect(fs.existsSync(path.join(dir, "sa-good.json.tmp.111"))).toBe(false);
|
|
73
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("manifest 已存在 + 非法 JSON tmp → 全删,幸存 manifest 不动", async () => {
|
|
65
77
|
fs.writeFileSync(path.join(dir, "sa-stale.json"), JSON.stringify(VALID_MANIFEST));
|
|
66
78
|
fs.writeFileSync(path.join(dir, "sa-stale.json.tmp.222"), "{}");
|
|
67
|
-
fs.writeFileSync(path.join(dir, "sa-
|
|
68
|
-
const
|
|
79
|
+
fs.writeFileSync(path.join(dir, "sa-junk.json.tmp.333"), "not json");
|
|
80
|
+
const result = await store.sweepTmpFiles();
|
|
81
|
+
expect(result).toBe(2);
|
|
82
|
+
expect(fs.existsSync(path.join(dir, "sa-stale.json"))).toBe(true);
|
|
83
|
+
expect(fs.existsSync(path.join(dir, "sa-stale.json.tmp.222"))).toBe(false);
|
|
84
|
+
expect(fs.existsSync(path.join(dir, "sa-junk.json.tmp.333"))).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("per-file 容错保留:单个 unlink ENOENT 只 warn 跳过,不中断整轮", async () => {
|
|
88
|
+
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
89
|
+
fs.writeFileSync(path.join(dir, "sa-a.json.tmp.444"), "not json");
|
|
90
|
+
fs.writeFileSync(path.join(dir, "sa-doomed.json.tmp.555"), "not json");
|
|
91
|
+
const doomedPath = path.join(dir, "sa-doomed.json.tmp.555");
|
|
69
92
|
unlinkSyncMock.mockImplementation((p: fs.PathLike) => {
|
|
70
93
|
if (String(p) === doomedPath) {
|
|
71
94
|
const err = new Error("ENOENT: file vanished") as NodeJS.ErrnoException;
|
|
@@ -75,25 +98,23 @@ describe("T5④ recoverTmpFiles per-file tolerance", () => {
|
|
|
75
98
|
return actualFs.unlinkSync(p);
|
|
76
99
|
});
|
|
77
100
|
|
|
78
|
-
const result = await store.
|
|
101
|
+
const result = await store.sweepTmpFiles();
|
|
79
102
|
|
|
80
|
-
// 整轮不中断:doomed 之外的 tmp
|
|
81
|
-
expect(result
|
|
82
|
-
expect(
|
|
83
|
-
// good 被提升为正式 manifest
|
|
84
|
-
expect(fs.existsSync(path.join(dir, "sa-good.json"))).toBe(true);
|
|
103
|
+
// 整轮不中断:doomed 之外的 tmp 处理完
|
|
104
|
+
expect(result).toBe(1);
|
|
105
|
+
expect(fs.existsSync(path.join(dir, "sa-a.json.tmp.444"))).toBe(false);
|
|
85
106
|
// 失败留痕(warn 级,含文件名)
|
|
86
107
|
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
87
|
-
expect.stringContaining("sa-doomed.json.tmp.
|
|
108
|
+
expect.stringContaining("sa-doomed.json.tmp.555"),
|
|
88
109
|
expect.objectContaining({ detail: expect.stringContaining("ENOENT") }),
|
|
89
110
|
);
|
|
90
|
-
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("1 of
|
|
111
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("1 of 2 tmp file(s)"));
|
|
91
112
|
});
|
|
92
113
|
|
|
93
|
-
it("
|
|
94
|
-
fs.writeFileSync(path.join(dir, "sa-
|
|
95
|
-
const result = await store.
|
|
96
|
-
expect(result).
|
|
114
|
+
it("无 tmp 残留 → 零副作用零告警", async () => {
|
|
115
|
+
fs.writeFileSync(path.join(dir, "sa-quiescent.json"), JSON.stringify(VALID_MANIFEST));
|
|
116
|
+
const result = await store.sweepTmpFiles();
|
|
117
|
+
expect(result).toBe(0);
|
|
97
118
|
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
98
119
|
});
|
|
99
120
|
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/execution/__tests__/model-config-service.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [H3/R5 测试归位] ModelConfigService 自有域测试文件。本文件用例自
|
|
4
|
+
// subagent-service.test.ts「ModelConfigService ctxModel 缓存」describe 整块迁入
|
|
5
|
+
//(用例名与断言零改动,纯移动——被测主体是 ModelConfigService 模块而非
|
|
6
|
+
// SubagentService,按被测主体归属原则从 God-class 级测试文件拆出)。
|
|
7
|
+
//
|
|
8
|
+
// ============================================================
|
|
9
|
+
// ModelConfigService ctxModel 缓存(renderCall 标题行 model 显示的核心)
|
|
10
|
+
// ============================================================
|
|
11
|
+
//
|
|
12
|
+
// [HISTORICAL] 99f20da1e 后 renderCall 拿不到主 agent model(ToolRenderContext 无 model),
|
|
13
|
+
// resolveModel 第三层拗错→降级不显示 model。修复:session_start 缓存 ctxModel,
|
|
14
|
+
// resolveModel 第三参默认用缓存。此测试验证该透传链路。
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
23
|
+
import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
|
|
24
|
+
|
|
25
|
+
// ── 工具:建临时 agentDir + 真实 ModelConfigService(自 subagent-service.test.ts 迁移段自持副本)──
|
|
26
|
+
|
|
27
|
+
function makeTmpAgentDir(): string {
|
|
28
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subagents-test-"));
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeModelService(agentDir: string): ModelConfigService {
|
|
33
|
+
return new ModelConfigService({ agentDir, cwd: agentDir });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("ModelConfigService ctxModel 缓存", () => {
|
|
37
|
+
let agentDir: string;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
agentDir = makeTmpAgentDir();
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** 最小 mock registry:空可用列表(ctxModel 路径不需要 lookup)。 */
|
|
47
|
+
function makeEmptyRegistry(): ModelRegistryLike {
|
|
48
|
+
return {
|
|
49
|
+
getAvailable: () => [],
|
|
50
|
+
find: () => undefined,
|
|
51
|
+
hasConfiguredAuth: () => false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it("initModel 传 ctxModel 后,resolveModel 不传第三参返回缓存 model", () => {
|
|
56
|
+
const svc = makeModelService(agentDir);
|
|
57
|
+
const mainModel: ModelInfo = {
|
|
58
|
+
id: "main-model",
|
|
59
|
+
name: "Main",
|
|
60
|
+
provider: "anthropic",
|
|
61
|
+
reasoning: false,
|
|
62
|
+
};
|
|
63
|
+
svc.initModel({
|
|
64
|
+
modelRegistry: makeEmptyRegistry(),
|
|
65
|
+
sessionId: "sess-1",
|
|
66
|
+
ctxModel: mainModel,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// agent 无 model 声明 + 无 override → 走第三层 ctxModel 缓存
|
|
70
|
+
const r = svc.resolveModel("general-purpose");
|
|
71
|
+
expect(r.model).toBe(mainModel);
|
|
72
|
+
expect(r.model.provider).toBe("anthropic");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("显式 ctxModel 参数优先于缓存(execute 路径覆盖 renderCall 缓存)", () => {
|
|
76
|
+
const svc = makeModelService(agentDir);
|
|
77
|
+
const cached: ModelInfo = { id: "cached", name: "C", provider: "p1", reasoning: false };
|
|
78
|
+
const explicit: ModelInfo = { id: "explicit", name: "E", provider: "p2", reasoning: false };
|
|
79
|
+
svc.initModel({
|
|
80
|
+
modelRegistry: makeEmptyRegistry(),
|
|
81
|
+
sessionId: "sess-1",
|
|
82
|
+
ctxModel: cached,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// execute 传显式 ctxModel → 用显式,不用缓存
|
|
86
|
+
const r = svc.resolveModel("general-purpose", undefined, explicit);
|
|
87
|
+
expect(r.model).toBe(explicit);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("setCtxModel 刷新缓存(model_select 后 renderCall 能看到新 model)", () => {
|
|
91
|
+
const svc = makeModelService(agentDir);
|
|
92
|
+
const m1: ModelInfo = { id: "m1", name: "1", provider: "p", reasoning: false };
|
|
93
|
+
const m2: ModelInfo = { id: "m2", name: "2", provider: "p", reasoning: false };
|
|
94
|
+
svc.initModel({ modelRegistry: makeEmptyRegistry(), sessionId: "s", ctxModel: m1 });
|
|
95
|
+
|
|
96
|
+
expect(svc.resolveModel("general-purpose").model).toBe(m1);
|
|
97
|
+
svc.setCtxModel(m2); // 模拟 model_select 刷新
|
|
98
|
+
expect(svc.resolveModel("general-purpose").model).toBe(m2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("缓存为空且无 override/agentConfig.model → 拗错(不静默降级)", () => {
|
|
102
|
+
const svc = makeModelService(agentDir);
|
|
103
|
+
svc.initModel({
|
|
104
|
+
modelRegistry: makeEmptyRegistry(),
|
|
105
|
+
sessionId: "s",
|
|
106
|
+
// ctxModel 不传 → 缓存为空
|
|
107
|
+
});
|
|
108
|
+
// 空 registry + 空 ctxModel → 第三层不可用 → 拗错
|
|
109
|
+
expect(() => svc.resolveModel("general-purpose")).toThrow(/No available model/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -17,7 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
17
17
|
import {
|
|
18
18
|
aliveStoreModule,
|
|
19
19
|
childProcessModule,
|
|
20
|
-
|
|
20
|
+
stateMarkerModule,
|
|
21
21
|
fsSyncModule,
|
|
22
22
|
manifestStoreModule,
|
|
23
23
|
} from "./helpers/subagent-service-mocks.ts";
|
|
@@ -29,7 +29,7 @@ import { clearEngines } from "../engine/registry.ts";
|
|
|
29
29
|
vi.mock("node:child_process", () => childProcessModule());
|
|
30
30
|
vi.mock("node:fs", async (importOriginal) => fsSyncModule(await importOriginal<typeof import("node:fs")>()));
|
|
31
31
|
vi.mock("../alive-store.ts", async (importOriginal) => aliveStoreModule(await importOriginal<typeof import("../alive-store.ts")>()));
|
|
32
|
-
vi.mock("../
|
|
32
|
+
vi.mock("../state-marker.ts", () => stateMarkerModule());
|
|
33
33
|
vi.mock("../manifest-store.ts", () => manifestStoreModule());
|
|
34
34
|
|
|
35
35
|
import { spawn } from "node:child_process";
|
|
@@ -101,7 +101,9 @@ describe("TC-3: PI_SUBAGENT_ROOT_SESSION_ID env 贯穿正确", () => {
|
|
|
101
101
|
|
|
102
102
|
it("TC-3a: 根进程 initSession:无 env → sessionRootId = sessionId(自己是 ROOT)", () => {
|
|
103
103
|
const service = setupService("root-it");
|
|
104
|
-
|
|
104
|
+
// [R1 深绑改写] sessionRootId 随域 #2 聚合迁入 SessionBaselines,深绑路径改为
|
|
105
|
+
// service → baselines 聚合实例(断言对象与强度不变,路径对齐终态结构)。
|
|
106
|
+
const sessionRootId = (Reflect.get(service, "baselines") as { sessionRootId: string | null }).sessionRootId as string;
|
|
105
107
|
expect(sessionRootId).toBe("root-it");
|
|
106
108
|
});
|
|
107
109
|
|
|
@@ -109,7 +111,8 @@ describe("TC-3: PI_SUBAGENT_ROOT_SESSION_ID env 贯穿正确", () => {
|
|
|
109
111
|
process.env[ENV_ROOT_SESSION_ID] = "real-root-session";
|
|
110
112
|
const service = setupService("child-session");
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
// [R1 深绑改写] 同上:sessionRootId 经 baselines 聚合路径读取。
|
|
115
|
+
const sessionRootId = (Reflect.get(service, "baselines") as { sessionRootId: string | null }).sessionRootId as string;
|
|
113
116
|
expect(sessionRootId).toBe("real-root-session"); // env 贯穿,不是 child-session
|
|
114
117
|
});
|
|
115
118
|
|
|
@@ -122,11 +125,12 @@ describe("TC-3: PI_SUBAGENT_ROOT_SESSION_ID env 贯穿正确", () => {
|
|
|
122
125
|
const service = setupService("b-session");
|
|
123
126
|
|
|
124
127
|
// B 的 sessionRootId 仍为 ROOT(env 贯穿,不被 B 自己的 sessionId 覆盖)
|
|
125
|
-
|
|
128
|
+
// [R1 深绑改写] 同上:sessionRootId / execNesting 经 baselines 聚合路径读取。
|
|
129
|
+
const sessionRootId = (Reflect.get(service, "baselines") as { sessionRootId: string | null }).sessionRootId as string;
|
|
126
130
|
expect(sessionRootId).toBe("root-main-session");
|
|
127
131
|
|
|
128
132
|
// B 的嵌套基线记录了 B 自己的身份(recordId + depth;[D3-⑤] execNesting 公共层)
|
|
129
|
-
const baseline = (Reflect.get(service, "
|
|
133
|
+
const baseline = ((Reflect.get(service, "baselines") as { execNesting: { baseline(): { recordId: string; depth: number } | null } }).execNesting).baseline()!;
|
|
130
134
|
expect(baseline.recordId).toBe("sa-a-record");
|
|
131
135
|
expect(baseline.depth).toBe(1);
|
|
132
136
|
});
|
|
@@ -917,6 +917,47 @@ describe("createNotifier — ledger 四步接线(U2)", () => {
|
|
|
917
917
|
notifier.dispose();
|
|
918
918
|
});
|
|
919
919
|
|
|
920
|
+
it("dedupKey 显式覆盖 notifyId(drain 丢弃通知独立身份):同轮失败通知 key 不互吞,同 key 重放仍幂等;缺省回退 `id` / `id:round` 不变", () => {
|
|
921
|
+
const ledgerMock = makeLedgerHost();
|
|
922
|
+
const notifierHost = makeNotifierHost();
|
|
923
|
+
// 桥接:ledger sendDelivery → notifier host.sendMessage(模拟 index.ts 装配)
|
|
924
|
+
ledgerMock.host.sendDelivery = (message) => {
|
|
925
|
+
notifierHost.sendMessage(message, { triggerTurn: true });
|
|
926
|
+
if (ledgerMock.deliverPersists.value) {
|
|
927
|
+
ledgerMock.sessionEntries.push({
|
|
928
|
+
type: "custom_message",
|
|
929
|
+
customType: message.customType,
|
|
930
|
+
content: message.content,
|
|
931
|
+
display: message.display,
|
|
932
|
+
details: message.details,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
bindNotifyLedgerHost(ledgerMock.host);
|
|
937
|
+
|
|
938
|
+
const notifier = createNotifier(notifierHost);
|
|
939
|
+
// 场景复刻(conversation-continuation drain 丢弃修复):同 id 同轮——失败单发缺省
|
|
940
|
+
// key `id:round`,丢弃通知 dedupKey `id:round:drain-drop` → 两条都写账送达(修复
|
|
941
|
+
// 前丢弃通知沿用缺省 key 被幂等吞,「队列消息被丢」反馈永不可达)。
|
|
942
|
+
const failedRound = { id: "sa-drain", status: "closed" as const, outcome: "failed" as const, agent: "w", round: 1, startedAt: 1, endedAt: 2 };
|
|
943
|
+
notifier.notify({ ...failedRound, error: "round did not complete: crash" });
|
|
944
|
+
notifier.notify({ ...failedRound, error: "queued message could not be dispatched", dedupKey: "sa-drain:1:drain-drop" });
|
|
945
|
+
// 同 dedupKey 重放 = 同一显式反馈 → 幂等吞(去重语义保留,非全量放行)
|
|
946
|
+
notifier.notify({ ...failedRound, error: "dropped again", dedupKey: "sa-drain:1:drain-drop" });
|
|
947
|
+
|
|
948
|
+
expect(notifierHost.sentMessages).toHaveLength(2);
|
|
949
|
+
expect(notifierHost.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-drain:1" });
|
|
950
|
+
expect(notifierHost.sentMessages[1]?.details).toMatchObject({ notifyId: "sa-drain:1:drain-drop" });
|
|
951
|
+
|
|
952
|
+
// 缺省回退(dedupKey 未设):无 round → 裸 id(notifyClosed 终态通知消费形态),
|
|
953
|
+
// 与 `id:round` / `id:round:drain-drop` 互不吞——key 空间三形态互斥
|
|
954
|
+
notifier.notify({ ...failedRound, round: undefined, error: "closed" });
|
|
955
|
+
expect(notifierHost.sentMessages).toHaveLength(3);
|
|
956
|
+
expect(notifierHost.sentMessages[2]?.details).toMatchObject({ notifyId: "sa-drain" });
|
|
957
|
+
|
|
958
|
+
notifier.dispose();
|
|
959
|
+
});
|
|
960
|
+
|
|
920
961
|
it("ledger 未 bind 时退回内核路径(向后兼容,options 仍单通道 triggerTurn)", () => {
|
|
921
962
|
const notifierHost = makeNotifierHost();
|
|
922
963
|
const notifier = createNotifier(notifierHost);
|
|
@@ -982,7 +1023,7 @@ describe("createNotifier — ledger 四步接线(U2)", () => {
|
|
|
982
1023
|
// ─── MF-5: settled 监听单例化 ──────────────────────────────
|
|
983
1024
|
//
|
|
984
1025
|
// 回归背景:index.ts:428-430 每次 session_start 注册新 pi.on("agent_settled")
|
|
985
|
-
// handler,旧实例 dispose 只置标志不移除物理监听(pi 0.84.
|
|
1026
|
+
// handler,旧实例 dispose 只置标志不移除物理监听(pi 0.84.4 on() 无 off)——
|
|
986
1027
|
// 长会话 N 次切换累积 N 个死 handler。修复:bind 路径首次注册模块级单例
|
|
987
1028
|
// handler(settledEdgeDispatch),后续 bind 只换 boundLedger 引用。
|
|
988
1029
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// src/execution/__tests__/rebuild-indexes.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [U4c / G1+G2+G4-S5] 缓存降级三锚点:
|
|
4
|
+
// - S5① 手动删 manifest/sessions-index → 重建照常无错误静默吞(boot 全量通道
|
|
5
|
+
// rebuildIndexes + 查询面惰性通道 mergedRecords 双通道各自可独立触发);
|
|
6
|
+
// - 失败降级:无 identity 的损坏/异构文件不在扫描集 → 不重建不抛(sessions-index
|
|
7
|
+
// 「损坏静默回退全扫」同款先例);幸存 manifest 不覆写(幂等补缺);
|
|
8
|
+
// - G2 词汇双写:四 manifest 写面(markFinalized/markCancelled/markBatchFinalized/
|
|
9
|
+
// markIdleArchived)旧 status 三态投影 + executionStatus/closedReason 并存。
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { createRecord } from "../execution-record.ts";
|
|
18
|
+
import { RecordStore } from "../record-store.ts";
|
|
19
|
+
import { INDEX_FILENAME } from "../sessions-index.ts";
|
|
20
|
+
import { writeCancelledState, writeFinalizedState } from "../state-marker.ts";
|
|
21
|
+
import type { ExecutionRecord, SubagentRecord } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
/** 最小合法子 session 文件(session header + identity custom entry + assistant msg)。 */
|
|
24
|
+
function writeSessionJsonl(
|
|
25
|
+
filePath: string,
|
|
26
|
+
identity: { id: string; task: string; startedAt: number; rootSessionId?: string },
|
|
27
|
+
): void {
|
|
28
|
+
const header = JSON.stringify({
|
|
29
|
+
type: "session", version: 3, id: `sess-${identity.id}`, timestamp: new Date(identity.startedAt).toISOString(), cwd: "/tmp",
|
|
30
|
+
});
|
|
31
|
+
const identityData: Record<string, unknown> = {
|
|
32
|
+
id: identity.id,
|
|
33
|
+
agent: "worker",
|
|
34
|
+
mode: "background",
|
|
35
|
+
task: identity.task,
|
|
36
|
+
slug: "rebuild",
|
|
37
|
+
startedAt: identity.startedAt,
|
|
38
|
+
};
|
|
39
|
+
if (identity.rootSessionId !== undefined) identityData.rootSessionId = identity.rootSessionId;
|
|
40
|
+
const identityEntry = JSON.stringify({
|
|
41
|
+
type: "custom",
|
|
42
|
+
id: "id-1",
|
|
43
|
+
parentId: null,
|
|
44
|
+
timestamp: new Date(identity.startedAt).toISOString(),
|
|
45
|
+
customType: "subagent-identity",
|
|
46
|
+
data: identityData,
|
|
47
|
+
});
|
|
48
|
+
const assistantMsg = JSON.stringify({
|
|
49
|
+
type: "message",
|
|
50
|
+
id: "msg-1",
|
|
51
|
+
parentId: "id-1",
|
|
52
|
+
timestamp: new Date(identity.startedAt + 1000).toISOString(),
|
|
53
|
+
message: {
|
|
54
|
+
role: "assistant",
|
|
55
|
+
content: [{ type: "text", text: "result text" }],
|
|
56
|
+
usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30, cost: { total: 0 } },
|
|
57
|
+
stopReason: "stop",
|
|
58
|
+
timestamp: identity.startedAt + 1000,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
fs.writeFileSync(filePath, `${header}\n${identityEntry}\n${assistantMsg}\n`, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeRecord(id: string, overrides: Partial<ExecutionRecord> = {}): ExecutionRecord {
|
|
65
|
+
const r = createRecord(id, {
|
|
66
|
+
agent: "worker",
|
|
67
|
+
model: "test/model",
|
|
68
|
+
mode: "background",
|
|
69
|
+
task: "rebuild task",
|
|
70
|
+
slug: "rebuild",
|
|
71
|
+
startedAt: 1000,
|
|
72
|
+
rootSessionId: "root-session",
|
|
73
|
+
chatMode: false,
|
|
74
|
+
controller: new AbortController(),
|
|
75
|
+
});
|
|
76
|
+
Object.assign(r, overrides);
|
|
77
|
+
return r;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** markBatchFinalized 入参的最小 SubagentRecord。 */
|
|
81
|
+
function makeSubagentRecord(id: string, sessionFile?: string): SubagentRecord {
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
agent: "worker",
|
|
85
|
+
task: "batch task",
|
|
86
|
+
slug: "batch",
|
|
87
|
+
status: "running",
|
|
88
|
+
mode: "background",
|
|
89
|
+
startedAt: 1000,
|
|
90
|
+
rootSessionId: "root-session",
|
|
91
|
+
parentRecordId: undefined,
|
|
92
|
+
depth: 0,
|
|
93
|
+
endedAt: undefined,
|
|
94
|
+
turns: 0,
|
|
95
|
+
totalTokens: 0,
|
|
96
|
+
model: "test/model",
|
|
97
|
+
thinkingLevel: undefined,
|
|
98
|
+
eventLog: [],
|
|
99
|
+
displayItems: [],
|
|
100
|
+
result: undefined,
|
|
101
|
+
error: undefined,
|
|
102
|
+
sessionFile,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("[U4c/G1] rebuildIndexes 双通道 + S5 缓存可丢锚点", () => {
|
|
107
|
+
let rootDir: string;
|
|
108
|
+
let sessionsDir: string;
|
|
109
|
+
let recordsDir: string;
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebuild-indexes-"));
|
|
113
|
+
sessionsDir = path.join(rootDir, "sessions");
|
|
114
|
+
recordsDir = path.join(rootDir, "records");
|
|
115
|
+
fs.mkdirSync(sessionsDir);
|
|
116
|
+
fs.mkdirSync(recordsDir);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
fs.rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("S5①: 手动删 manifest/sessions-index → boot 全量重建照常,无错误静默吞", async () => {
|
|
124
|
+
const fileA = path.join(sessionsDir, "20260912T000000_a.jsonl");
|
|
125
|
+
const fileB = path.join(sessionsDir, "20260912T000001_b.jsonl");
|
|
126
|
+
writeSessionJsonl(fileA, { id: "sa-closed", task: "closed task", startedAt: 1000, rootSessionId: "root-session" });
|
|
127
|
+
writeSessionJsonl(fileB, { id: "sa-running", task: "running task", startedAt: 2000, rootSessionId: "root-session" });
|
|
128
|
+
// 权威终态位(重建源 = `.state` 优先)
|
|
129
|
+
writeFinalizedState(fileA, "gc");
|
|
130
|
+
// 损坏/异构文件(无 identity 无绑定):不在扫描集,重建面必须静默跳过
|
|
131
|
+
fs.writeFileSync(path.join(sessionsDir, "junk.jsonl"), "not a session at all\n", "utf-8");
|
|
132
|
+
// 「缓存曾存在」的现场:manifest + sessions-index 各留一份后人为删除(S5 场景步骤)
|
|
133
|
+
fs.writeFileSync(path.join(recordsDir, "sa-closed.json"), "{}", "utf-8");
|
|
134
|
+
fs.writeFileSync(path.join(rootDir, INDEX_FILENAME), "{corrupted", "utf-8");
|
|
135
|
+
fs.rmSync(path.join(recordsDir, "sa-closed.json"));
|
|
136
|
+
fs.rmSync(path.join(rootDir, INDEX_FILENAME));
|
|
137
|
+
|
|
138
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
139
|
+
let rebuilt = -1;
|
|
140
|
+
expect(() => {
|
|
141
|
+
rebuilt = store.rebuildIndexes();
|
|
142
|
+
}).not.toThrow();
|
|
143
|
+
// 幂等补缺面 = 扫描集内全部 record(closed + running 两种形态都补)
|
|
144
|
+
expect(rebuilt).toBe(2);
|
|
145
|
+
|
|
146
|
+
// manifest 重建产物:词汇双写(旧三态 + executionStatus)+ identity 富字段
|
|
147
|
+
const closed = JSON.parse(fs.readFileSync(path.join(recordsDir, "sa-closed.json"), "utf-8")) as Record<string, unknown>;
|
|
148
|
+
expect(closed.status).toBe("closed");
|
|
149
|
+
expect(closed.executionStatus).toBe("closed");
|
|
150
|
+
expect(closed.closedReason).toBe("gc");
|
|
151
|
+
expect(closed.agentName).toBe("worker");
|
|
152
|
+
expect(closed.task).toBe("closed task");
|
|
153
|
+
expect(closed.sessionFile).toBe(fileA);
|
|
154
|
+
const running = JSON.parse(fs.readFileSync(path.join(recordsDir, "sa-running.json"), "utf-8")) as Record<string, unknown>;
|
|
155
|
+
expect(running.status).toBe("running");
|
|
156
|
+
expect(running.executionStatus).toBe("running");
|
|
157
|
+
expect(running.completedAt).toBeUndefined();
|
|
158
|
+
|
|
159
|
+
// junk 文件不产生 manifest(扫描集外静默跳过)
|
|
160
|
+
expect(fs.readdirSync(recordsDir).sort()).toEqual(["sa-closed.json", "sa-running.json"]);
|
|
161
|
+
|
|
162
|
+
// sessions-index 经首扫自愈重写(fire-and-forget,等待落盘)
|
|
163
|
+
await vi.waitFor(() => expect(fs.existsSync(path.join(rootDir, INDEX_FILENAME))).toBe(true));
|
|
164
|
+
const index = JSON.parse(fs.readFileSync(path.join(rootDir, INDEX_FILENAME), "utf-8")) as {
|
|
165
|
+
version: number;
|
|
166
|
+
entries: Record<string, unknown>;
|
|
167
|
+
};
|
|
168
|
+
expect(index.version).toBe(1);
|
|
169
|
+
expect(Object.keys(index.entries)).toContain("20260912T000000_a.jsonl");
|
|
170
|
+
expect(Object.keys(index.entries)).toContain("20260912T000001_b.jsonl");
|
|
171
|
+
|
|
172
|
+
// 列表/详情照常(S5 通过标准)
|
|
173
|
+
const ids = store.collectRecords(100).map((r) => r.id);
|
|
174
|
+
expect(ids).toContain("sa-closed");
|
|
175
|
+
expect(ids).toContain("sa-running");
|
|
176
|
+
store.dispose();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("幸存 manifest 不覆写(重建是补缺不是刷新——防用尽力数据降级幸存快照)", () => {
|
|
180
|
+
const fileA = path.join(sessionsDir, "20260912T000002_survivor.jsonl");
|
|
181
|
+
writeSessionJsonl(fileA, { id: "sa-survivor", task: "disk task", startedAt: 1000, rootSessionId: "root-session" });
|
|
182
|
+
// 幸存 manifest 的 task 比 disk 重建源「新鲜」(终态写点真实值)
|
|
183
|
+
fs.writeFileSync(
|
|
184
|
+
path.join(recordsDir, "sa-survivor.json"),
|
|
185
|
+
JSON.stringify({ id: "sa-survivor", rootSessionId: "root-session", agentName: "worker", status: "closed", task: "survivor task", createdAt: 1000 }),
|
|
186
|
+
"utf-8",
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
190
|
+
expect(store.rebuildIndexes()).toBe(0);
|
|
191
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(recordsDir, "sa-survivor.json"), "utf-8")) as Record<string, unknown>;
|
|
192
|
+
expect(manifest.task).toBe("survivor task");
|
|
193
|
+
store.dispose();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("惰性通道: 查询面(collectRecords)补建缺员 manifest;在途 record 不建(创建时不写契约保持)", () => {
|
|
197
|
+
const fileA = path.join(sessionsDir, "20260912T000003_lazy.jsonl");
|
|
198
|
+
const fileLive = path.join(sessionsDir, "20260912T000004_live.jsonl");
|
|
199
|
+
writeSessionJsonl(fileA, { id: "sa-lazy", task: "lazy task", startedAt: 1000, rootSessionId: "root-session" });
|
|
200
|
+
writeSessionJsonl(fileLive, { id: "sa-live", task: "live task", startedAt: 2000, rootSessionId: "root-session" });
|
|
201
|
+
|
|
202
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
203
|
+
// 在途 record(内存持有)——终态写点会落 manifest,惰性通道不得提前建
|
|
204
|
+
store.register(makeRecord("sa-live", { sessionFile: fileLive }));
|
|
205
|
+
|
|
206
|
+
const ids = store.collectRecords(100).map((r) => r.id);
|
|
207
|
+
expect(ids).toContain("sa-lazy");
|
|
208
|
+
expect(ids).toContain("sa-live");
|
|
209
|
+
|
|
210
|
+
// 惰性补建只发生在外源 record
|
|
211
|
+
expect(fs.existsSync(path.join(recordsDir, "sa-lazy.json"))).toBe(true);
|
|
212
|
+
expect(fs.existsSync(path.join(recordsDir, "sa-live.json"))).toBe(false);
|
|
213
|
+
store.dispose();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("每 id 每进程一次守卫 + revive 复位(防高频查询放大磁盘写)", () => {
|
|
217
|
+
const fileA = path.join(sessionsDir, "20260912T000005_guard.jsonl");
|
|
218
|
+
writeSessionJsonl(fileA, { id: "sa-guard", task: "guard task", startedAt: 1000, rootSessionId: "root-session" });
|
|
219
|
+
|
|
220
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
221
|
+
store.collectRecords(100);
|
|
222
|
+
expect(fs.existsSync(path.join(recordsDir, "sa-guard.json"))).toBe(true);
|
|
223
|
+
|
|
224
|
+
// 再次删除 + 查询:本进程已尝试过,不重复补写(boot 全量轮/下次进程承接)
|
|
225
|
+
fs.rmSync(path.join(recordsDir, "sa-guard.json"));
|
|
226
|
+
store.collectRecords(100);
|
|
227
|
+
expect(fs.existsSync(path.join(recordsDir, "sa-guard.json"))).toBe(false);
|
|
228
|
+
|
|
229
|
+
// /new /resume 复活(revive)复位守卫——「重开重判」语义
|
|
230
|
+
store.revive();
|
|
231
|
+
store.collectRecords(100);
|
|
232
|
+
expect(fs.existsSync(path.join(recordsDir, "sa-guard.json"))).toBe(true);
|
|
233
|
+
store.dispose();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("[U4c/G2] manifest 词汇双写——四写面旧三态投影 + executionStatus/closedReason 并存", () => {
|
|
238
|
+
let rootDir: string;
|
|
239
|
+
let sessionsDir: string;
|
|
240
|
+
let recordsDir: string;
|
|
241
|
+
|
|
242
|
+
beforeEach(() => {
|
|
243
|
+
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebuild-dualwrite-"));
|
|
244
|
+
sessionsDir = path.join(rootDir, "sessions");
|
|
245
|
+
recordsDir = path.join(rootDir, "records");
|
|
246
|
+
fs.mkdirSync(sessionsDir);
|
|
247
|
+
fs.mkdirSync(recordsDir);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
afterEach(() => {
|
|
251
|
+
fs.rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
function readManifest(id: string): Record<string, unknown> {
|
|
255
|
+
return JSON.parse(fs.readFileSync(path.join(recordsDir, `${id}.json`), "utf-8")) as Record<string, unknown>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it("markFinalized:status closed(旧三态)+ executionStatus closed + closedReason 随写", () => {
|
|
259
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
260
|
+
const sessionFile = path.join(sessionsDir, "20260912T000006_fin.jsonl");
|
|
261
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
262
|
+
const record = makeRecord("sa-fin", { sessionFile, status: "closed", closedReason: "parent-shutdown", endedAt: 2000 });
|
|
263
|
+
|
|
264
|
+
expect(store.markFinalized(record, "parent-shutdown")).toBe(true);
|
|
265
|
+
const manifest = readManifest("sa-fin");
|
|
266
|
+
expect(manifest.status).toBe("closed");
|
|
267
|
+
expect(manifest.executionStatus).toBe("closed");
|
|
268
|
+
expect(manifest.closedReason).toBe("parent-shutdown");
|
|
269
|
+
store.dispose();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("markCancelled:终态投影同 markFinalized(双写字段齐)", () => {
|
|
273
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
274
|
+
const sessionFile = path.join(sessionsDir, "20260912T000007_cx.jsonl");
|
|
275
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
276
|
+
const record = makeRecord("sa-cx2", { sessionFile, endedAt: 2000 });
|
|
277
|
+
|
|
278
|
+
expect(store.markCancelled(record)).toBe(true);
|
|
279
|
+
const manifest = readManifest("sa-cx2");
|
|
280
|
+
expect(manifest.status).toBe("closed");
|
|
281
|
+
expect(manifest.executionStatus).toBe("closed");
|
|
282
|
+
store.dispose();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("markIdleArchived:归档点补写 running 投影(非终态化语义——磁盘仍 running 可接管)", () => {
|
|
286
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
287
|
+
const sessionFile = path.join(sessionsDir, "20260912T000008_idle.jsonl");
|
|
288
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
289
|
+
const record = makeRecord("sa-idle", { sessionFile, resumable: true });
|
|
290
|
+
|
|
291
|
+
store.markIdleArchived(record);
|
|
292
|
+
const manifest = readManifest("sa-idle");
|
|
293
|
+
expect(manifest.status).toBe("running");
|
|
294
|
+
expect(manifest.executionStatus).toBe("running");
|
|
295
|
+
expect(manifest.completedAt).toBeUndefined();
|
|
296
|
+
// 非终态化:不写 .state(归档 ≠ 放弃可重连性)
|
|
297
|
+
expect(fs.existsSync(`${sessionFile}.state`)).toBe(false);
|
|
298
|
+
store.dispose();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("markBatchFinalized:批成员 running 投影双写(barrier 面词汇一致)", async () => {
|
|
302
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
303
|
+
const sessionFile = path.join(sessionsDir, "20260912T000009_batch.jsonl");
|
|
304
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
305
|
+
|
|
306
|
+
await store.markBatchFinalized([makeSubagentRecord("sa-batch", sessionFile)]);
|
|
307
|
+
const manifest = readManifest("sa-batch");
|
|
308
|
+
expect(manifest.status).toBe("running");
|
|
309
|
+
expect(manifest.executionStatus).toBe("running");
|
|
310
|
+
expect(manifest.closedReason).toBeUndefined();
|
|
311
|
+
store.dispose();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("重建投影的旧三态 cancelled 派生:`.state` cancelled → status cancelled + closedReason cancelled", () => {
|
|
315
|
+
// markCancelled 终态位的磁盘重建形态(buildRecord 分支 1):重建 manifest 的旧
|
|
316
|
+
// 词汇须能区分 cancelled(session-reader 投影消费三态)。
|
|
317
|
+
const fileA = path.join(sessionsDir, "20260912T000010_cxl.jsonl");
|
|
318
|
+
writeSessionJsonl(fileA, { id: "sa-cxl", task: "cancelled task", startedAt: 1000, rootSessionId: "root-session" });
|
|
319
|
+
writeCancelledState(fileA, 3000);
|
|
320
|
+
|
|
321
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir);
|
|
322
|
+
store.rebuildIndexes();
|
|
323
|
+
const manifest = readManifest("sa-cxl");
|
|
324
|
+
expect(manifest.status).toBe("cancelled");
|
|
325
|
+
expect(manifest.executionStatus).toBe("closed");
|
|
326
|
+
expect(manifest.closedReason).toBe("cancelled");
|
|
327
|
+
store.dispose();
|
|
328
|
+
});
|
|
329
|
+
});
|