@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/execution/__tests__/run-orchestration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [H3/R5 测试归位] run 域执行编排聚合(execution/service/run-orchestration.ts)的
|
|
4
|
+
// 同名域测试文件。本文件用例自 subagent-service.test.ts「execute() worktree 路径」
|
|
5
|
+
// 块整块迁入(用例名与断言零改动,纯移动);壳级生命周期/查询/emit 面用例留在
|
|
6
|
+
// subagent-service.test.ts。同域姊妹文件:conversation-continuation.test.ts(Continuation
|
|
7
|
+
// 协作面)/ chat-engine-routing.test.ts(引擎编排分叉)/ execute-nesting.test.ts /
|
|
8
|
+
// execute-and-await-worktree.test.ts / delivery-methods.test.ts /
|
|
9
|
+
// stream-sink-retirement.test.ts / explicit-agent-ref-guard.test.ts /
|
|
10
|
+
// subagent-service-notify-gate.test.ts / workflow-agent-dispatch.test.ts(派发链 × run 域协作面)。
|
|
11
|
+
//
|
|
12
|
+
// 覆盖(execute 入口 = RunOrchestration.execute,worktree 与 fork 解耦后):
|
|
13
|
+
// 1. worktree:true + fork:false → 解耦后正常(不抛 requires fork)
|
|
14
|
+
// 2. worktree:true + fork:true → 创建 worktree 路径(不抛 requires fork)
|
|
15
|
+
// 3. worktree:false + fork:false → 默认路径(不创建 worktree)
|
|
16
|
+
// 4. create-await 竞态守卫:create 的 await 窗口内 dispose 抢先 → cleanup 被调 +
|
|
17
|
+
// early-failed 返回(不 kickOff)——「赋值 worktreeHandle → 终态检查 → 轮次
|
|
18
|
+
// kick-off 同一同步段」不变量的回归锚点。
|
|
19
|
+
//
|
|
20
|
+
// 策略:真实 ModelConfigService 指向 os.tmpdir() 空目录,mock PiLike;本文件不 mock
|
|
21
|
+
// spawn(与 subagent-service.test.ts 文件头约定一致——execute 的 spawn 集成测试在
|
|
22
|
+
// execute-nesting / execute-integration)。
|
|
23
|
+
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as os from "node:os";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
|
|
28
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
29
|
+
|
|
30
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
31
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
32
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
33
|
+
import type { WorktreeManager } from "../worktree-manager.ts";
|
|
34
|
+
|
|
35
|
+
// ── 工具:建临时 agentDir + 真实 ModelConfigService(自 subagent-service.test.ts 迁移段自持副本)──
|
|
36
|
+
|
|
37
|
+
function makeTmpAgentDir(): string {
|
|
38
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subagents-test-"));
|
|
39
|
+
// agentDir/subagents/ 子目录(config 默认路径会用,空即可)
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeModelService(agentDir: string): ModelConfigService {
|
|
44
|
+
return new ModelConfigService({ agentDir, cwd: agentDir });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makePi(): PiLike & {
|
|
48
|
+
appendEntry: ReturnType<typeof vi.fn<(customType: string, data?: unknown) => void>>;
|
|
49
|
+
events: { emit: ReturnType<typeof vi.fn<(channel: string, data: unknown) => void>> };
|
|
50
|
+
sendMessage: ReturnType<typeof vi.fn<(message: Parameters<PiLike["sendMessage"]>[0], options?: Parameters<PiLike["sendMessage"]>[1]) => void>>;
|
|
51
|
+
} {
|
|
52
|
+
return {
|
|
53
|
+
appendEntry: vi.fn((customType: string, data?: unknown) => {}),
|
|
54
|
+
events: { emit: vi.fn((channel: string, data: unknown) => {}) },
|
|
55
|
+
sendMessage: vi.fn(() => {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("execute() worktree 路径(worktree 与 fork 解耦)", () => {
|
|
60
|
+
let agentDir: string;
|
|
61
|
+
let modelService: ModelConfigService;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
agentDir = makeTmpAgentDir();
|
|
65
|
+
modelService = makeModelService(agentDir);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ============================================================
|
|
73
|
+
// worktree(文件隔离)与 fork(上下文继承)已解耦:worktree:true 可独立于 fork 工作
|
|
74
|
+
// (worktreeManager.create 只看 opts.worktree,不读 fork)。此组验证三种 fork/worktree
|
|
75
|
+
// 组合下 worktree 路径的行为(均不应抛 'requires fork'——该 guard 已移除):
|
|
76
|
+
// 1. worktree:true + fork:false → 解耦后正常(创建 worktree 路径,不抛 requires fork)
|
|
77
|
+
// 2. worktree:true + fork:true → 创建 worktree 路径(测试环境 git 失败,抛非 requires fork 错)
|
|
78
|
+
// 3. worktree:false + fork:false → 默认路径(不创建 worktree)
|
|
79
|
+
//
|
|
80
|
+
// 本文件不 mock spawn(保持与源文件头声明一致——execute 集成测试在 execute-nesting /
|
|
81
|
+
// run-spawn-integration),因此 case 验证「不抛 requires fork」而非「执行完成」:
|
|
82
|
+
// 执行越过 worktree 创建后在后续步骤(worktreeManager.create 调 git / runSpawn 调
|
|
83
|
+
// spawn)抛与 fork/worktree 无关的错。用 try/catch 断言抛出的不是 requires fork。
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
/** 构造已就绪的 service(initSession + initModel 注入 ctxModel,使 resolveIdentity 不因 model 拗错)。 */
|
|
87
|
+
function makeReadyService(): SubagentService {
|
|
88
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
89
|
+
service.initSession({ pi: makePi(), sessionId: "s1" });
|
|
90
|
+
// 注入 modelRegistry + ctxModel:让 resolveIdentity 越过 resolveModel,
|
|
91
|
+
// 使 guard 之后的失败点稳定在 worktreeManager.create(git)或 runSpawn(spawn),
|
|
92
|
+
// 而非 modelService.resolveModel——避免与 guard 无关的 model 错误掩盖被测点。
|
|
93
|
+
modelService.initModel({
|
|
94
|
+
modelRegistry: {
|
|
95
|
+
getAvailable: () => [],
|
|
96
|
+
find: () => undefined,
|
|
97
|
+
hasConfiguredAuth: () => false,
|
|
98
|
+
},
|
|
99
|
+
sessionId: "s1",
|
|
100
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
101
|
+
});
|
|
102
|
+
return service;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("worktree:true + fork:false → 解耦后不抛 'requires fork'(worktree 独立于 fork)", async () => {
|
|
106
|
+
const service = makeReadyService();
|
|
107
|
+
// 解耦后 worktree:true+fork:false 不再 throw requires fork(worktreeManager.create 只看 worktree)
|
|
108
|
+
try {
|
|
109
|
+
await service.execute({
|
|
110
|
+
task: "worktree without fork (decoupled)",
|
|
111
|
+
slug: "test",
|
|
112
|
+
worktree: true,
|
|
113
|
+
fork: false,
|
|
114
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// 解耦后绝不抛 requires fork(执行继续到 worktreeManager.create/spawn 才可能抛其他错)
|
|
118
|
+
expect((err as Error).message).not.toMatch(/requires fork/);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("worktree:true + fork:true → 创建 worktree 路径(不抛 'requires fork')", async () => {
|
|
123
|
+
const service = makeReadyService();
|
|
124
|
+
// 执行继续:先创建 record,然后 worktreeManager.create 调 git(测试环境无 repo → 抛与 fork 无关的错)
|
|
125
|
+
try {
|
|
126
|
+
await service.execute({
|
|
127
|
+
task: "worktree with fork",
|
|
128
|
+
slug: "test",
|
|
129
|
+
worktree: true,
|
|
130
|
+
fork: true,
|
|
131
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
132
|
+
});
|
|
133
|
+
// 若未抛(理论上 worktreeManager.create 在某些环境成功)也 OK——重点是没命中 guard
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// guard 放行:抛出的错误绝不能是 "requires fork"
|
|
136
|
+
expect((err as Error).message).not.toMatch(/requires fork/);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("worktree:false + fork:false → 默认路径(不创建 worktree,不抛 'requires fork')", async () => {
|
|
141
|
+
const service = makeReadyService();
|
|
142
|
+
// 默认路径:runSpawn 调 child_process.spawn(测试环境无真实 pi → 抛与 fork 无关的错)
|
|
143
|
+
try {
|
|
144
|
+
await service.execute({
|
|
145
|
+
task: "default path",
|
|
146
|
+
slug: "test",
|
|
147
|
+
worktree: false,
|
|
148
|
+
fork: false,
|
|
149
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// guard 放行:抛出的错误绝不能是 "requires fork"
|
|
153
|
+
expect((err as Error).message).not.toMatch(/requires fork/);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ============================================================
|
|
158
|
+
// create-await 竞态守卫(Phase 2):create 的 await 窗口内 dispose/cancel
|
|
159
|
+
// 可把 record CAS 成 closed——守卫须主动 cleanup + early-failed 返回,不 kickOff。
|
|
160
|
+
// 实现约束固化:「赋值 record.worktreeHandle → 终态检查 → 轮次 kick-off」
|
|
161
|
+
// 必须同一同步段(中间禁止 await),本用例即该不变量的回归锚点。
|
|
162
|
+
// ============================================================
|
|
163
|
+
it("守卫:create await 窗口内 dispose 抢先 → cleanup 被调 + early-failed 返回(不 kickOff)", async () => {
|
|
164
|
+
const service = makeReadyService();
|
|
165
|
+
const wtm = Reflect.get(service, "worktreeManager") as WorktreeManager;
|
|
166
|
+
const handle = Object.freeze({
|
|
167
|
+
path: "/tmp/wt-guard",
|
|
168
|
+
branch: "pi-sub-guard",
|
|
169
|
+
baseCommit: "abc123",
|
|
170
|
+
mainCwd: "/repo",
|
|
171
|
+
}) as Parameters<WorktreeManager["cleanup"]>[0];
|
|
172
|
+
let resolveCreate!: (h: unknown) => void;
|
|
173
|
+
vi.spyOn(wtm, "create").mockImplementation(
|
|
174
|
+
() => new Promise<unknown>((r) => { resolveCreate = r; }) as ReturnType<WorktreeManager["create"]>,
|
|
175
|
+
);
|
|
176
|
+
const cleanupSpy = vi.spyOn(wtm, "cleanup").mockResolvedValue(undefined);
|
|
177
|
+
|
|
178
|
+
const execP = service.execute({
|
|
179
|
+
task: "guard test",
|
|
180
|
+
slug: "test",
|
|
181
|
+
worktree: true,
|
|
182
|
+
fork: false,
|
|
183
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
184
|
+
});
|
|
185
|
+
// 微任务推进:record 已建 + execute 挂在 pending create 上
|
|
186
|
+
await new Promise((r) => { setTimeout(r, 0); });
|
|
187
|
+
// dispose 抢先(无需 record id):CAS running → closed,此时 worktreeHandle 仍
|
|
188
|
+
// undefined(dispose 的 fire-and-forget cleanup 跳过)——守卫是唯一的清理点
|
|
189
|
+
service.disposeAllRecords("parent-shutdown");
|
|
190
|
+
resolveCreate(handle);
|
|
191
|
+
|
|
192
|
+
const ret = await execP;
|
|
193
|
+
// 守卫生效:handle 被主动清理(不等 60s reaper)
|
|
194
|
+
expect(cleanupSpy).toHaveBeenCalledWith(handle);
|
|
195
|
+
// 返回 early-failed 形态(details.status 已 closed),而非 kickOff 的 running 形态
|
|
196
|
+
expect(ret.mode).toBe("background");
|
|
197
|
+
expect(ret.details).toMatchObject({ status: "closed" });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -154,7 +154,35 @@ describe("maybeCleanupExpiredSessionFiles", () => {
|
|
|
154
154
|
expect(() => maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd")).not.toThrow();
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
// ---- .
|
|
157
|
+
// ---- 终态 .state sidecar 清理(L4 合并后新名)+ 兼容期旧名保留清理 ----
|
|
158
|
+
|
|
159
|
+
it("deletes .state sidecar along with expired .jsonl", () => {
|
|
160
|
+
forceCleanupTrigger();
|
|
161
|
+
const jsonl = createSessionFile("sess-state.jsonl", 31);
|
|
162
|
+
const state = `${jsonl}.state`;
|
|
163
|
+
fs.writeFileSync(state, JSON.stringify({ status: "finalized", reason: "gc" }), "utf-8");
|
|
164
|
+
const targetTime = Date.now() / 1000 - 31 * 86400;
|
|
165
|
+
fs.utimesSync(state, targetTime, targetTime);
|
|
166
|
+
|
|
167
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
168
|
+
|
|
169
|
+
expect(fs.existsSync(jsonl)).toBe(false);
|
|
170
|
+
expect(fs.existsSync(state)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("cleans orphan .state sidecar when .jsonl already gone", () => {
|
|
174
|
+
forceCleanupTrigger();
|
|
175
|
+
const subagentsDir = path.join(tmpAgentDir, "subagents");
|
|
176
|
+
fs.mkdirSync(subagentsDir, { recursive: true });
|
|
177
|
+
const orphan = path.join(subagentsDir, "dead-session.jsonl.state");
|
|
178
|
+
fs.writeFileSync(orphan, JSON.stringify({ status: "cancelled", endedAt: 1 }), "utf-8");
|
|
179
|
+
const targetTime = Date.now() / 1000 - 31 * 86400;
|
|
180
|
+
fs.utimesSync(orphan, targetTime, targetTime);
|
|
181
|
+
|
|
182
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
183
|
+
|
|
184
|
+
expect(fs.existsSync(orphan)).toBe(false);
|
|
185
|
+
});
|
|
158
186
|
|
|
159
187
|
it("deletes .finalized sidecar along with expired .jsonl", () => {
|
|
160
188
|
forceCleanupTrigger();
|
|
@@ -230,12 +258,14 @@ describe("maybeCleanupExpiredSessionFiles", () => {
|
|
|
230
258
|
expect(fs.existsSync(orphan)).toBe(false);
|
|
231
259
|
});
|
|
232
260
|
|
|
233
|
-
it("deletes .jsonl + all sidecars (.
|
|
261
|
+
it("deletes .jsonl + all sidecars (.state, 兼容期 .cancelled/.finalized, .alive) together", () => {
|
|
234
262
|
forceCleanupTrigger();
|
|
235
263
|
const jsonl = createSessionFile("sess-all-sidecars.jsonl", 31);
|
|
264
|
+
const state = `${jsonl}.state`;
|
|
236
265
|
const cancelled = `${jsonl}.cancelled`;
|
|
237
266
|
const finalized = `${jsonl}.finalized`;
|
|
238
267
|
const alive = `${jsonl}.alive`;
|
|
268
|
+
fs.writeFileSync(state, JSON.stringify({ status: "finalized", reason: "gc" }), "utf-8");
|
|
239
269
|
fs.writeFileSync(cancelled, "", "utf-8");
|
|
240
270
|
fs.writeFileSync(finalized, "", "utf-8");
|
|
241
271
|
fs.writeFileSync(alive, JSON.stringify({ pid: 99999, id: "s4", startedAt: Date.now() }), "utf-8");
|
|
@@ -246,6 +276,7 @@ describe("maybeCleanupExpiredSessionFiles", () => {
|
|
|
246
276
|
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
247
277
|
|
|
248
278
|
expect(fs.existsSync(jsonl)).toBe(false);
|
|
279
|
+
expect(fs.existsSync(state)).toBe(false);
|
|
249
280
|
expect(fs.existsSync(cancelled)).toBe(false);
|
|
250
281
|
expect(fs.existsSync(finalized)).toBe(false);
|
|
251
282
|
expect(fs.existsSync(alive)).toBe(false);
|
|
@@ -286,8 +317,8 @@ describe("maybeCleanupExpiredSessionFiles", () => {
|
|
|
286
317
|
expect(fs.existsSync(worktrees)).toBe(true);
|
|
287
318
|
});
|
|
288
319
|
|
|
289
|
-
it("[F2] preserves .json.tmp.* inside records/ (
|
|
290
|
-
// 跳过 .tmp.:session_start 的
|
|
320
|
+
it("[F2] preserves .json.tmp.* inside records/ (sweepTmpFiles owns them)", () => {
|
|
321
|
+
// 跳过 .tmp.:session_start 的 sweepTmpFiles 同步处理 tmp,GC 不重复。
|
|
291
322
|
forceCleanupTrigger();
|
|
292
323
|
const tmp = createSessionFile(
|
|
293
324
|
path.join("--Users-x-proj--", "records", "rec-2.json.tmp.123"),
|
|
@@ -728,3 +728,81 @@ describe("轻量 identity 扫描(readIdentityHeader / readIdentityTail / readI
|
|
|
728
728
|
expect(readIdentityHeader(filePath)?.id).toBe("bg-prefilter");
|
|
729
729
|
});
|
|
730
730
|
});
|
|
731
|
+
|
|
732
|
+
// ============================================================
|
|
733
|
+
// [H2 S3] identity 面 origin/parentRunId 透传(写 → 读 → 重建往返保真)
|
|
734
|
+
// ============================================================
|
|
735
|
+
//
|
|
736
|
+
// Gate B S3 FAIL 根因的 identity entry 侧防线:identity custom entry 携带
|
|
737
|
+
// origin/parentRunId 时,reconstructFromFile(全量重建面)与 readIdentityHeader
|
|
738
|
+
// (light 列表面)必须透传;缺省/非法值守卫归一 undefined(= "tool" 语义),
|
|
739
|
+
// 对齐 readEntryOriginFields 主 entry 重建侧守卫。
|
|
740
|
+
describe("[H2 S3] identity 面 origin/parentRunId 透传", () => {
|
|
741
|
+
let tmpDir: string;
|
|
742
|
+
let filePath: string;
|
|
743
|
+
|
|
744
|
+
beforeEach(() => {
|
|
745
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sr-origin-"));
|
|
746
|
+
filePath = path.join(tmpDir, "origin.jsonl");
|
|
747
|
+
});
|
|
748
|
+
afterEach(() => {
|
|
749
|
+
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
function writeOriginJsonl(identity: object): void {
|
|
753
|
+
const fd = fs.openSync(filePath, "w");
|
|
754
|
+
writeLine(fd, headerLine());
|
|
755
|
+
writeLine(fd, identityEntry(identity));
|
|
756
|
+
writeLine(fd, assistantEntry([{ type: "text", text: "done" }]));
|
|
757
|
+
fs.closeSync(fd);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
it("往返保真:identity(origin=workflow, parentRunId) → 全量重建 + 轻量扫描均透传", () => {
|
|
761
|
+
writeOriginJsonl({
|
|
762
|
+
id: "bg-wf", agent: "worker", mode: "background", task: "wf task", startedAt: 10,
|
|
763
|
+
origin: "workflow", parentRunId: "wf-run-9",
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const full = reconstructFromFile(filePath);
|
|
767
|
+
expect(full).toBeDefined();
|
|
768
|
+
expect(full!.origin).toBe("workflow");
|
|
769
|
+
expect(full!.parentRunId).toBe("wf-run-9");
|
|
770
|
+
|
|
771
|
+
const light = readIdentityHeader(filePath);
|
|
772
|
+
expect(light).toBeDefined();
|
|
773
|
+
expect(light!.origin).toBe("workflow");
|
|
774
|
+
expect(light!.parentRunId).toBe("wf-run-9");
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("缺省负向:旧 identity(无 origin/parentRunId)→ 重建两字段 undefined(= tool 语义)", () => {
|
|
778
|
+
writeOriginJsonl({ id: "bg-old", agent: "w", mode: "background", task: "t", startedAt: 1 });
|
|
779
|
+
|
|
780
|
+
expect(reconstructFromFile(filePath)!.origin).toBeUndefined();
|
|
781
|
+
expect(reconstructFromFile(filePath)!.parentRunId).toBeUndefined();
|
|
782
|
+
expect(readIdentityHeader(filePath)!.origin).toBeUndefined();
|
|
783
|
+
expect(readIdentityHeader(filePath)!.parentRunId).toBeUndefined();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("非法值守卫:origin 非白名单 / parentRunId 非 string → 守卫归一 undefined(不抛)", () => {
|
|
787
|
+
writeOriginJsonl({
|
|
788
|
+
id: "bg-bad", agent: "w", mode: "background", task: "t", startedAt: 1,
|
|
789
|
+
origin: "bogus", parentRunId: 42,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const full = reconstructFromFile(filePath);
|
|
793
|
+
expect(full!.origin).toBeUndefined();
|
|
794
|
+
expect(full!.parentRunId).toBeUndefined();
|
|
795
|
+
const light = readIdentityHeader(filePath)!;
|
|
796
|
+
expect(light.origin).toBeUndefined();
|
|
797
|
+
expect(light.parentRunId).toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("origin=tool 显式值 → 透传保留(负向判定 `!== \"workflow\"` 依赖真实值)", () => {
|
|
801
|
+
writeOriginJsonl({
|
|
802
|
+
id: "bg-tool", agent: "w", mode: "background", task: "t", startedAt: 1,
|
|
803
|
+
origin: "tool",
|
|
804
|
+
});
|
|
805
|
+
expect(reconstructFromFile(filePath)!.origin).toBe("tool");
|
|
806
|
+
expect(readIdentityHeader(filePath)!.origin).toBe("tool");
|
|
807
|
+
});
|
|
808
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// src/execution/__tests__/state-marker.test.ts
|
|
2
|
+
//
|
|
3
|
+
// state-marker 专属测试(L4 合并:finalized-marker + tombstone-store 两模块收编)。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖四层:
|
|
6
|
+
// 1. 写侧(.state 单一权威):finalized/cancelled 往返、旧名残留清理;
|
|
7
|
+
// 2. 写侧响亮重试(U1 A3 / §3.4):3 次指数退避(100ms 起)+ 仍失败 logger.error
|
|
8
|
+
// 响亮暴露返回 false(旧 best-effort 静默语义退役);
|
|
9
|
+
// 3. 读侧(兼容读):.state 优先、旧 .finalized / .cancelled 归一、旧名共存优先级
|
|
10
|
+
// (.cancelled > .finalized,对齐合并前判定分支序)、损坏降级边界;
|
|
11
|
+
// 4. statStateStamp(缓存校验戳):三文件合并戳的存在性/变化语义。
|
|
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
|
+
// [U1 A3] 响亮重试断言需要 error 级日志可观察(对齐 record-store.test.ts mock 模式)。
|
|
20
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
21
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../../core/logger.ts", () => ({
|
|
24
|
+
getLogger: () => loggerMock,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// fs partial mock:writeFileSync 可注错(暂时/持久失败注入),其余转发真实实现
|
|
28
|
+
// (模式对齐 manifest-store-tmp-recovery.test.ts)。ESM namespace 不可 spyOn,
|
|
29
|
+
// 注错必须走模块 mock;真实实现引用收进 hoisted holder(vi.mock factory 先于
|
|
30
|
+
// 模块级 let 初始化执行,裸 let 会 TDZ)。
|
|
31
|
+
type WriteFileSyncFn = typeof import("node:fs").writeFileSync;
|
|
32
|
+
const { writeFileSyncMock, actualWriteRef } = vi.hoisted(() => ({
|
|
33
|
+
writeFileSyncMock: vi.fn(),
|
|
34
|
+
actualWriteRef: { current: undefined as WriteFileSyncFn | undefined },
|
|
35
|
+
}));
|
|
36
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
37
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
38
|
+
actualWriteRef.current = actual.writeFileSync;
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
writeFileSync: writeFileSyncMock,
|
|
42
|
+
default: { ...actual, writeFileSync: writeFileSyncMock },
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
_setStateMarkerSleepForTest,
|
|
48
|
+
readStateMarker,
|
|
49
|
+
statStateStamp,
|
|
50
|
+
writeCancelledState,
|
|
51
|
+
writeFinalizedState,
|
|
52
|
+
} from "../state-marker.ts";
|
|
53
|
+
import { writeLegacyCancelledSidecar, writeLegacyFinalizedSidecar } from "./helpers/legacy-sidecar.ts";
|
|
54
|
+
|
|
55
|
+
describe("state-marker", () => {
|
|
56
|
+
let tmpDir: string;
|
|
57
|
+
let sessionFile: string;
|
|
58
|
+
/** 退避替身记录的延迟序列(fake sleep——免真实等待,可断言指数退避)。 */
|
|
59
|
+
let sleepDelays: number[];
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "state-marker-test-"));
|
|
63
|
+
sessionFile = path.join(tmpDir, "2026-01-01_uuid.jsonl");
|
|
64
|
+
sleepDelays = [];
|
|
65
|
+
_setStateMarkerSleepForTest((ms) => {
|
|
66
|
+
sleepDelays.push(ms);
|
|
67
|
+
});
|
|
68
|
+
loggerMock.error.mockClear();
|
|
69
|
+
// 基线 = 真实写(个别用例以 mockImplementationOnce/Implementation 注错覆盖)。
|
|
70
|
+
if (actualWriteRef.current === undefined) throw new Error("node:fs mock not initialized");
|
|
71
|
+
writeFileSyncMock.mockReset().mockImplementation(actualWriteRef.current);
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
75
|
+
_setStateMarkerSleepForTest(undefined); // 恢复实装同步等待
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const readStateRaw = (): { status?: unknown; reason?: unknown; endedAt?: unknown } =>
|
|
80
|
+
JSON.parse(fs.readFileSync(`${sessionFile}.state`, "utf-8")) as Record<string, unknown>;
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// 写侧
|
|
84
|
+
// ============================================================
|
|
85
|
+
describe("写侧 .state", () => {
|
|
86
|
+
it("writeFinalizedState 带 reason → .state {status:finalized, reason}", () => {
|
|
87
|
+
writeFinalizedState(sessionFile, "user-close");
|
|
88
|
+
expect(readStateRaw()).toEqual({ status: "finalized", reason: "user-close" });
|
|
89
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("writeFinalizedState 无 reason → .state {status:finalized}(死因不可考语义)", () => {
|
|
93
|
+
writeFinalizedState(sessionFile);
|
|
94
|
+
expect(readStateRaw()).toEqual({ status: "finalized" });
|
|
95
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("writeCancelledState → .state {status:cancelled, endedAt}", () => {
|
|
99
|
+
writeCancelledState(sessionFile, 7000);
|
|
100
|
+
expect(readStateRaw()).toEqual({ status: "cancelled", endedAt: 7000 });
|
|
101
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 7000 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("覆盖写:后写的 status 取代前者(单文件单状态,构造性互斥)", () => {
|
|
105
|
+
writeFinalizedState(sessionFile, "gc");
|
|
106
|
+
writeCancelledState(sessionFile, 8000);
|
|
107
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 8000 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("写 .state 时清理存量旧名 sidecar(.finalized / .cancelled 一并删)", () => {
|
|
111
|
+
writeLegacyFinalizedSidecar(sessionFile, "gc");
|
|
112
|
+
writeLegacyCancelledSidecar(sessionFile, {
|
|
113
|
+
id: "bg-1", status: "cancelled", agent: "w", startedAt: 1, endedAt: 2,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
writeFinalizedState(sessionFile, "user-close");
|
|
117
|
+
|
|
118
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
|
|
119
|
+
expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(false);
|
|
120
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("[§3.4] 写路径父目录不存在 → 重试耗尽不抛出、未写入、响亮暴露 + 返回 false", () => {
|
|
124
|
+
const badPath = path.join(tmpDir, "nonexistent-sub", "session.jsonl");
|
|
125
|
+
expect(writeFinalizedState(badPath)).toBe(false);
|
|
126
|
+
expect(writeCancelledState(badPath, 1)).toBe(false);
|
|
127
|
+
expect(readStateMarker(badPath)).toBeUndefined();
|
|
128
|
+
// 响亮暴露:每次写函数调用各产出一条 error(终态权威未落必须可见)。
|
|
129
|
+
expect(loggerMock.error).toHaveBeenCalledTimes(2);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ============================================================
|
|
134
|
+
// 写侧响亮重试(U1 A3 / §3.4:3 次指数退避 100ms 起 + 仍失败 logger.error)
|
|
135
|
+
// ============================================================
|
|
136
|
+
describe("写侧响亮重试", () => {
|
|
137
|
+
it("暂时性失败 → 指数退避重试后成功:返回 true,无 error 日志", () => {
|
|
138
|
+
writeFileSyncMock
|
|
139
|
+
.mockImplementationOnce(() => {
|
|
140
|
+
throw new Error("transient EBUSY");
|
|
141
|
+
})
|
|
142
|
+
.mockImplementationOnce(() => {
|
|
143
|
+
throw new Error("transient EACCES");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(writeFinalizedState(sessionFile, "user-close")).toBe(true);
|
|
147
|
+
expect(writeFileSyncMock).toHaveBeenCalledTimes(3); // 初始尝试 + 2 次重试后成功
|
|
148
|
+
expect(sleepDelays).toEqual([100, 200]); // 指数退避:100ms 起
|
|
149
|
+
expect(loggerMock.error).not.toHaveBeenCalled();
|
|
150
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("持久失败 → 4 次尝试(1+3 重试)后返回 false + logger.error 响亮暴露(含恢复指引)", () => {
|
|
154
|
+
writeFileSyncMock.mockImplementation(() => {
|
|
155
|
+
throw new Error("ENOSPC: no space left");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(writeCancelledState(sessionFile, 7000)).toBe(false);
|
|
159
|
+
expect(writeFileSyncMock).toHaveBeenCalledTimes(4); // 初始 + 3 次重试
|
|
160
|
+
expect(sleepDelays).toEqual([100, 200, 400]); // 完整指数退避序列
|
|
161
|
+
expect(loggerMock.error).toHaveBeenCalledTimes(1);
|
|
162
|
+
const msg = String(loggerMock.error.mock.calls[0]?.[0]);
|
|
163
|
+
expect(msg).toMatch(/terminal state marker write failed after retries/);
|
|
164
|
+
expect(msg).toMatch(/record stays running/); // §3.4:不因写失败翻终态的处置指引
|
|
165
|
+
// 磁盘确无终态位(record 留 running 形态的构造前提)。
|
|
166
|
+
expect(fs.existsSync(`${sessionFile}.state`)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("writeCancelledState 同款重试语义(终态二态共用 writeStateMarker)", () => {
|
|
170
|
+
writeFileSyncMock.mockImplementationOnce(() => {
|
|
171
|
+
throw new Error("transient");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(writeCancelledState(sessionFile, 9000)).toBe(true);
|
|
175
|
+
expect(sleepDelays).toEqual([100]);
|
|
176
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 9000 });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ============================================================
|
|
181
|
+
// 读侧:无 sidecar / .state 边界
|
|
182
|
+
// ============================================================
|
|
183
|
+
describe("读侧 .state", () => {
|
|
184
|
+
it("无任何 sidecar → undefined(未终态化)", () => {
|
|
185
|
+
expect(readStateMarker(sessionFile)).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it(".state JSON 损坏 → {status:finalized}(存在性即信号;不误判 cancelled)", () => {
|
|
189
|
+
fs.writeFileSync(`${sessionFile}.state`, "not-json", "utf-8");
|
|
190
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it(".state status 非枚举值 → {status:finalized}(结构不合法降级)", () => {
|
|
194
|
+
fs.writeFileSync(`${sessionFile}.state`, JSON.stringify({ status: "bogus" }), "utf-8");
|
|
195
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it(".state finalized 且 reason 非字符串 → 忽略 reason 字段(不抛)", () => {
|
|
199
|
+
fs.writeFileSync(`${sessionFile}.state`, JSON.stringify({ status: "finalized", reason: 42 }), "utf-8");
|
|
200
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized" });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it(".state cancelled 缺 endedAt → {status:cancelled}(endedAt undefined,重建回落 mtime)", () => {
|
|
204
|
+
fs.writeFileSync(`${sessionFile}.state`, JSON.stringify({ status: "cancelled" }), "utf-8");
|
|
205
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled" });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ============================================================
|
|
210
|
+
// 读侧:兼容旧名(存量文件不迁移)
|
|
211
|
+
// ============================================================
|
|
212
|
+
describe("读侧兼容旧名", () => {
|
|
213
|
+
it("旧 .finalized 带内容 → {status:finalized, reason}", () => {
|
|
214
|
+
writeLegacyFinalizedSidecar(sessionFile, "parent-shutdown");
|
|
215
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "parent-shutdown" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("旧 .finalized 空文件(v8.5 前格式)→ reason 空串(= 死因不可考)", () => {
|
|
219
|
+
writeLegacyFinalizedSidecar(sessionFile);
|
|
220
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "" });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("旧 .cancelled 完整 tombstone → {status:cancelled, endedAt}", () => {
|
|
224
|
+
writeLegacyCancelledSidecar(sessionFile, {
|
|
225
|
+
id: "bg-1", status: "cancelled", agent: "w", startedAt: 100, endedAt: 200,
|
|
226
|
+
});
|
|
227
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 200 });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("旧 .cancelled JSON 损坏 → undefined(不认 cancelled,对齐合并前降级)", () => {
|
|
231
|
+
fs.writeFileSync(`${sessionFile}.cancelled`, "{broken", "utf-8");
|
|
232
|
+
expect(readStateMarker(sessionFile)).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("旧 .cancelled status 非 cancelled → undefined(结构不合法降级)", () => {
|
|
236
|
+
fs.writeFileSync(`${sessionFile}.cancelled`, JSON.stringify({ status: "other", endedAt: 1 }), "utf-8");
|
|
237
|
+
expect(readStateMarker(sessionFile)).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("旧名共存(.cancelled + .finalized)→ cancelled 胜出(优先级对齐合并前分支序)", () => {
|
|
241
|
+
writeLegacyFinalizedSidecar(sessionFile, "gc");
|
|
242
|
+
writeLegacyCancelledSidecar(sessionFile, {
|
|
243
|
+
id: "bg-1", status: "cancelled", agent: "w", startedAt: 100, endedAt: 200,
|
|
244
|
+
});
|
|
245
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "cancelled", endedAt: 200 });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it(".state 优先于存量旧名(新写侧权威)", () => {
|
|
249
|
+
writeLegacyCancelledSidecar(sessionFile, {
|
|
250
|
+
id: "bg-1", status: "cancelled", agent: "w", startedAt: 100, endedAt: 200,
|
|
251
|
+
});
|
|
252
|
+
writeFinalizedState(sessionFile, "user-close");
|
|
253
|
+
expect(readStateMarker(sessionFile)).toEqual({ status: "finalized", reason: "user-close" });
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ============================================================
|
|
258
|
+
// statStateStamp(缓存校验)
|
|
259
|
+
// ============================================================
|
|
260
|
+
describe("statStateStamp", () => {
|
|
261
|
+
it("三者皆无 → null(未终态化,缓存校验 null 语义)", () => {
|
|
262
|
+
expect(statStateStamp(sessionFile)).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it(".state 存在 → 非 null;重写改变戳(mtime/size 至少一项变化)", () => {
|
|
266
|
+
writeFinalizedState(sessionFile, "gc");
|
|
267
|
+
const first = statStateStamp(sessionFile);
|
|
268
|
+
expect(first).not.toBeNull();
|
|
269
|
+
|
|
270
|
+
writeFinalizedState(sessionFile, "a-much-longer-reason-string");
|
|
271
|
+
const second = statStateStamp(sessionFile);
|
|
272
|
+
expect(second).not.toBeNull();
|
|
273
|
+
expect(second).not.toEqual(first); // size 变化即戳变化
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("仅旧名存在 → 非 null(合并戳覆盖兼容读面)", () => {
|
|
277
|
+
writeLegacyFinalizedSidecar(sessionFile, "gc");
|
|
278
|
+
expect(statStateStamp(sessionFile)).not.toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("旧名删除后戳变化(GC 清理旧侧车 → 缓存正确失效)", () => {
|
|
282
|
+
writeLegacyFinalizedSidecar(sessionFile, "gc");
|
|
283
|
+
const withLegacy = statStateStamp(sessionFile);
|
|
284
|
+
fs.rmSync(`${sessionFile}.finalized`, { force: true });
|
|
285
|
+
expect(statStateStamp(sessionFile)).not.toEqual(withLegacy);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|