@zhushanwen/subagent-core 0.7.1 → 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-VURUAGMM.js → chunk-LZJOEDGQ.js} +603 -154
- 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 +325 -154
- 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 +9799 -7954
- package/dist/index.d.cts +1896 -1360
- package/dist/index.d.ts +1896 -1360
- package/dist/index.js +8952 -7513
- package/dist.bundle/index.cjs +7897 -6054
- package/package.json +5 -5
- package/src/__tests__/manifest-store.test.ts +16 -15
- package/src/__tests__/record-store-last-line.test.ts +12 -0
- package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
- package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
- package/src/core/logger.ts +1 -1
- 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__/inflight-production-wiring.test.ts +223 -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__/nested-visibility.test.ts +13 -1
- 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__/conformance/registry-fork-filter.test.ts +55 -53
- 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__/inflight-snapshot.test.ts +180 -0
- 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 +111 -26
- 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 +26 -41
- package/src/execution/engine/host/pi-host-binding.ts +5 -5
- package/src/execution/engine/host/spawned-children.ts +1 -1
- package/src/execution/engine/inflight-snapshot.ts +92 -0
- package/src/execution/engine/port.ts +52 -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/path-encoding.ts +6 -0
- 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 -2955
- 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 +12 -8
- package/src/index.ts +16 -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-ocpM8FmI.d.cts +0 -1608
- package/dist/engine-discovery-scan-ocpM8FmI.d.ts +0 -1608
- 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,613 @@
|
|
|
1
|
+
// src/execution/__tests__/record-binding.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [UF-1] 跨重启续聊绑定修复(U4 真机基线 S6 ❌):record↔sessionFile 映射落盘与消费。
|
|
4
|
+
//
|
|
5
|
+
// 缺陷背景:engine-CLI 化后子 session 文件只含 {session, model_change,
|
|
6
|
+
// thinking_level_change, message} 条目族(无身份 entry),旧
|
|
7
|
+
// PI_SUBAGENT_SELF_RECORD_ID 注入链消失 → collectRecords/findLightById 失去
|
|
8
|
+
// id→file 工件 → 跨重启 message 一律「not found or not owned」(展示层 entry
|
|
9
|
+
// 扫描源可见 3 条 record、message 链全部 not-found 的双注册表不同源形态)。
|
|
10
|
+
//
|
|
11
|
+
// 修复面(本套件锁定):
|
|
12
|
+
// ① 写入:sessionFile 回填点(轮应答 / idle 帧锚点 / run 域 outcome)经
|
|
13
|
+
// writeBindingForRecord 落 `<sessionFile>.record-binding`(best-effort,
|
|
14
|
+
// 写失败 warn 不阻断派发主路径);
|
|
15
|
+
// ② 消费:record-store scanFile 在 identity miss 时据绑定重建 light record
|
|
16
|
+
// (模拟重启后空内存),findLightById/collectRecords 恢复 id→file 解析;
|
|
17
|
+
// ③ 全链:getRecordForAction(coldLookupForAction)→ 绑定重建 → register →
|
|
18
|
+
// deliverChatMessage 续聊发起(resume 锚点续写原文件);
|
|
19
|
+
// ④ 终态不冲突:.state 优先级高于绑定的 running 形态(buildRecord 既有分支
|
|
20
|
+
// 矩阵构造性保证);终态后绑定保留(resurrect 回边删 .state 后绑定仍在,
|
|
21
|
+
// 再崩溃仍可恢复)——保留选项由此套件锁定;
|
|
22
|
+
// ⑤ 绑定写失败(只读目录)不阻塞派发主路径。
|
|
23
|
+
// ⑥ [H2 Gate B round-2] binding 携带 origin/parentRunId(身份面,S3 收口)+
|
|
24
|
+
// 终态快照 totalTokens/turns/endedAt(A3 收口,updateRecordBinding merge)——
|
|
25
|
+
// 本套件 record-binding/sessions-index 相关 describe 锁定。
|
|
26
|
+
//
|
|
27
|
+
// fixture 一律 mkdtempSync 自建自删(tmpdir),不触碰真实数据目录。
|
|
28
|
+
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as os from "node:os";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
|
|
33
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
34
|
+
|
|
35
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
36
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
37
|
+
}));
|
|
38
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
39
|
+
|
|
40
|
+
import { clearEngines } from "../engine/registry.ts";
|
|
41
|
+
import { _resetCoreSpawnedChildrenMirrorForTest } from "../engine/host/spawned-children.ts";
|
|
42
|
+
import { createRecord } from "../execution-record.ts";
|
|
43
|
+
import { _resetLifecycleState } from "../lifecycle-manager.ts";
|
|
44
|
+
import { getSubagentSessionDir } from "../path-encoding.ts";
|
|
45
|
+
import { RecordStore } from "../record-store.ts";
|
|
46
|
+
import { _resetSettledWatchdogsForTest } from "../settled-watchdog.ts";
|
|
47
|
+
import {
|
|
48
|
+
readRecordBinding,
|
|
49
|
+
updateRecordBinding,
|
|
50
|
+
writeFinalizedState,
|
|
51
|
+
writeRecordBinding,
|
|
52
|
+
RECORD_BINDING_SIDECAR_EXT,
|
|
53
|
+
} from "../state-marker.ts";
|
|
54
|
+
import type { RecordBinding } from "../state-marker.ts";
|
|
55
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
56
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
57
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
58
|
+
import { registerFakePiEngine, type FakePiEnginePort } from "./helpers/fake-engine-port.ts";
|
|
59
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
60
|
+
|
|
61
|
+
// 身份 env 清理(同 get-record-for-action-restart.test.ts:测试进程可能继承
|
|
62
|
+
// subagent env,污染 rootCwd 编码目录与 sessionRootId 基线)。
|
|
63
|
+
const IDENTITY_ENV_KEYS = [
|
|
64
|
+
"PI_SUBAGENT_ROOT_SESSION_ID",
|
|
65
|
+
"PI_SUBAGENT_SELF_RECORD_ID",
|
|
66
|
+
"PI_SUBAGENT_DEPTH",
|
|
67
|
+
"PI_SUBAGENT_ROOT_CWD",
|
|
68
|
+
"PI_SUBAGENT_FORK_DEPTH",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
const STARTED_AT = 1_700_000_000_000;
|
|
72
|
+
|
|
73
|
+
/** engine-CLI 化子 session 文件 fixture:{session, message} 条目族,无身份 entry。 */
|
|
74
|
+
function writePlainChildSession(sessionsDir: string): string {
|
|
75
|
+
const file = path.join(sessionsDir, "20260910T000000-000_sa-bind-1.jsonl");
|
|
76
|
+
const lines = [
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
type: "session",
|
|
79
|
+
version: 3,
|
|
80
|
+
id: "sess-child",
|
|
81
|
+
timestamp: new Date(STARTED_AT).toISOString(),
|
|
82
|
+
cwd: "/tmp",
|
|
83
|
+
}),
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
type: "message",
|
|
86
|
+
id: "msg-1",
|
|
87
|
+
parentId: null,
|
|
88
|
+
timestamp: new Date(STARTED_AT + 1000).toISOString(),
|
|
89
|
+
message: {
|
|
90
|
+
role: "assistant",
|
|
91
|
+
content: [{ type: "text", text: "first round done" }],
|
|
92
|
+
usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0 },
|
|
93
|
+
stopReason: "stop",
|
|
94
|
+
timestamp: STARTED_AT + 1000,
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf-8");
|
|
99
|
+
return file;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 绑定 sidecar fixture(经真实写函数落盘)。 */
|
|
103
|
+
function writeBindingFixture(file: string, overrides: Partial<RecordBinding> = {}): void {
|
|
104
|
+
writeRecordBinding(file, {
|
|
105
|
+
v: 1,
|
|
106
|
+
recordId: "sa-bind-1",
|
|
107
|
+
rootSessionId: "root-session",
|
|
108
|
+
parentRecordId: undefined,
|
|
109
|
+
depth: 0,
|
|
110
|
+
agent: "general-purpose",
|
|
111
|
+
task: "binding task",
|
|
112
|
+
slug: "bind-test",
|
|
113
|
+
mode: "background",
|
|
114
|
+
startedAt: STARTED_AT,
|
|
115
|
+
chatMode: true,
|
|
116
|
+
round: 1,
|
|
117
|
+
model: "prov/model-1",
|
|
118
|
+
thinkingLevel: undefined,
|
|
119
|
+
worktree: false,
|
|
120
|
+
...overrides,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================
|
|
125
|
+
// A. state-marker 绑定读写单元
|
|
126
|
+
// ============================================================
|
|
127
|
+
|
|
128
|
+
describe("[UF-1] record 绑定 sidecar 读写(state-marker)", () => {
|
|
129
|
+
let dir: string;
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "record-binding-unit-"));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("原子写落盘 + 读回 roundtrip(载荷含 recordId/rootSessionId/chatMode/round/startedAt);无 tmp 残留", () => {
|
|
140
|
+
const sessionFile = path.join(dir, "child.jsonl");
|
|
141
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
142
|
+
writeBindingFixture(sessionFile);
|
|
143
|
+
|
|
144
|
+
const target = `${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`;
|
|
145
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
146
|
+
// 原子写收口:tmp 中间产物不残留
|
|
147
|
+
expect(fs.readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
|
|
148
|
+
|
|
149
|
+
const binding = readRecordBinding(sessionFile);
|
|
150
|
+
expect(binding).toMatchObject({
|
|
151
|
+
v: 1,
|
|
152
|
+
recordId: "sa-bind-1",
|
|
153
|
+
rootSessionId: "root-session",
|
|
154
|
+
chatMode: true,
|
|
155
|
+
round: 1,
|
|
156
|
+
startedAt: STARTED_AT,
|
|
157
|
+
agent: "general-purpose",
|
|
158
|
+
task: "binding task",
|
|
159
|
+
mode: "background",
|
|
160
|
+
worktree: false,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("损坏载荷拒绝重建:JSON 残缺 / 关键身份域缺失 / 版本不识别 / 文件缺失 → undefined", () => {
|
|
165
|
+
const sessionFile = path.join(dir, "child.jsonl");
|
|
166
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
167
|
+
expect(readRecordBinding(sessionFile)).toBeUndefined(); // 文件缺失
|
|
168
|
+
|
|
169
|
+
fs.writeFileSync(`${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`, "{not json", "utf-8");
|
|
170
|
+
expect(readRecordBinding(sessionFile)).toBeUndefined(); // JSON 损坏
|
|
171
|
+
|
|
172
|
+
fs.writeFileSync(
|
|
173
|
+
`${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`,
|
|
174
|
+
JSON.stringify({ v: 1, agent: "a", task: "t", mode: "background", startedAt: 1 }),
|
|
175
|
+
"utf-8",
|
|
176
|
+
);
|
|
177
|
+
expect(readRecordBinding(sessionFile)).toBeUndefined(); // recordId 缺失
|
|
178
|
+
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
`${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`,
|
|
181
|
+
JSON.stringify({ v: 2, recordId: "sa-x", agent: "a", task: "t", mode: "background", startedAt: 1 }),
|
|
182
|
+
"utf-8",
|
|
183
|
+
);
|
|
184
|
+
expect(readRecordBinding(sessionFile)).toBeUndefined(); // 版本不识别
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("写失败只 warn 不抛(只读目录)", () => {
|
|
188
|
+
const roDir = path.join(dir, "ro");
|
|
189
|
+
fs.mkdirSync(roDir, { recursive: true });
|
|
190
|
+
fs.chmodSync(roDir, 0o555);
|
|
191
|
+
try {
|
|
192
|
+
expect(() => writeBindingFixture(path.join(roDir, "child.jsonl"))).not.toThrow();
|
|
193
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
194
|
+
expect.stringContaining("record binding write failed"),
|
|
195
|
+
expect.objectContaining({ detail: expect.objectContaining({ sessionFile: path.join(roDir, "child.jsonl") }) }),
|
|
196
|
+
);
|
|
197
|
+
} finally {
|
|
198
|
+
fs.chmodSync(roDir, 0o755);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ============================================================
|
|
204
|
+
// B. record-store 消费面(模拟重启后空内存)
|
|
205
|
+
// ============================================================
|
|
206
|
+
|
|
207
|
+
describe("[UF-1] record-store 据绑定 sidecar 重建(跨重启空内存场景)", () => {
|
|
208
|
+
let agentDir: string;
|
|
209
|
+
let sessionsDir: string;
|
|
210
|
+
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "record-binding-store-"));
|
|
213
|
+
sessionsDir = getSubagentSessionDir(agentDir, agentDir);
|
|
214
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
afterEach(() => {
|
|
218
|
+
// maxRetries:扫描尾 fire-and-forget sessions-index 写可能与删除并发
|
|
219
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("collectRecords:无 identity 子文件 + 绑定 → 重建 running light(id/rootSessionId/chatMode/round/sessionFile)", () => {
|
|
223
|
+
const file = writePlainChildSession(sessionsDir);
|
|
224
|
+
writeBindingFixture(file);
|
|
225
|
+
const store = new RecordStore(sessionsDir);
|
|
226
|
+
|
|
227
|
+
const records = store.collectRecords(10, "all", undefined);
|
|
228
|
+
expect(records).toHaveLength(1);
|
|
229
|
+
const rec = records[0]!;
|
|
230
|
+
expect(rec.id).toBe("sa-bind-1");
|
|
231
|
+
expect(rec.status).toBe("running"); // v4 B-1 跨重启可续聊语义(无 .state → 分支 4)
|
|
232
|
+
expect(rec.rootSessionId).toBe("root-session");
|
|
233
|
+
expect(rec.chatMode).toBe(true);
|
|
234
|
+
expect(rec.round).toBe(1);
|
|
235
|
+
expect(rec.sessionFile).toBe(file);
|
|
236
|
+
expect(rec.agent).toBe("general-purpose");
|
|
237
|
+
expect(rec.task).toBe("binding task");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("findLightById:冷启动空索引先 miss → collectRecords 全扫填充 → 索引命中(coldLookup 步骤 1 链)", () => {
|
|
241
|
+
const file = writePlainChildSession(sessionsDir);
|
|
242
|
+
writeBindingFixture(file);
|
|
243
|
+
const store = new RecordStore(sessionsDir);
|
|
244
|
+
|
|
245
|
+
// 重启后 idToFile 未热:直查 miss
|
|
246
|
+
expect(store.findLightById("sa-bind-1")).toBeUndefined();
|
|
247
|
+
// 全扫兜底命中并填充 idToFile 索引
|
|
248
|
+
expect(store.collectRecords(10, "all", undefined).map((r) => r.id)).toContain("sa-bind-1");
|
|
249
|
+
// 索引回暖:后续直查命中(把跨重启每条 message 一次全扫降为 O(1))
|
|
250
|
+
const light = store.findLightById("sa-bind-1");
|
|
251
|
+
expect(light?.id).toBe("sa-bind-1");
|
|
252
|
+
expect(light?.sessionFile).toBe(file);
|
|
253
|
+
expect(light?.status).toBe("running");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("rootSessionId 过滤仍生效:异树过滤排除绑定 record(session 隔离不因绑定旁路)", () => {
|
|
257
|
+
const file = writePlainChildSession(sessionsDir);
|
|
258
|
+
writeBindingFixture(file);
|
|
259
|
+
const store = new RecordStore(sessionsDir);
|
|
260
|
+
|
|
261
|
+
expect(store.collectRecords(10, "all", "other-root")).toHaveLength(0);
|
|
262
|
+
expect(store.collectRecords(10, "all", "root-session")).toHaveLength(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("终态优先级:绑定 + .state(finalized) → closed/gc(.state 胜过绑定的 running 形态);绑定保留在盘", () => {
|
|
266
|
+
const file = writePlainChildSession(sessionsDir);
|
|
267
|
+
writeBindingFixture(file);
|
|
268
|
+
writeFinalizedState(file, "gc");
|
|
269
|
+
const store = new RecordStore(sessionsDir);
|
|
270
|
+
|
|
271
|
+
const records = store.collectRecords(10, "all", undefined);
|
|
272
|
+
expect(records).toHaveLength(1);
|
|
273
|
+
expect(records[0]!.status).toBe("closed");
|
|
274
|
+
expect(records[0]!.closedReason).toBe("gc");
|
|
275
|
+
// 保留选项锁定:终态后绑定不删——resurrect 回边删 .state 后绑定仍在,再崩溃仍可恢复
|
|
276
|
+
expect(fs.existsSync(`${file}${RECORD_BINDING_SIDECAR_EXT}`)).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("负缓存打破:先仅子文件(无绑定)扫描为空 → 绑定后到落盘 → 再次扫描命中", () => {
|
|
280
|
+
const file = writePlainChildSession(sessionsDir);
|
|
281
|
+
const store = new RecordStore(sessionsDir);
|
|
282
|
+
expect(store.collectRecords(10, "all", undefined)).toHaveLength(0); // 无身份无绑定 → 负缓存
|
|
283
|
+
|
|
284
|
+
writeBindingFixture(file); // run 应答回填点后到:绑定戳变化
|
|
285
|
+
const records = store.collectRecords(10, "all", undefined);
|
|
286
|
+
expect(records).toHaveLength(1);
|
|
287
|
+
expect(records[0]!.id).toBe("sa-bind-1");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("无 identity 无绑定 → 不重建(负缓存语义保持,不误建幽灵 record)", () => {
|
|
291
|
+
writePlainChildSession(sessionsDir);
|
|
292
|
+
const store = new RecordStore(sessionsDir);
|
|
293
|
+
expect(store.collectRecords(10, "all", undefined)).toHaveLength(0);
|
|
294
|
+
expect(store.findLightById("sa-bind-1")).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ============================================================
|
|
299
|
+
// D. [H2 S3] 来源身份(origin/parentRunId)经 binding 身份面往返保真
|
|
300
|
+
// ============================================================
|
|
301
|
+
//
|
|
302
|
+
// Gate B S3 FAIL 根因:引擎子文件身份面(binding sidecar)重建丢 origin →
|
|
303
|
+
// 归档/重启后 workflow record 逃过 D1 投影过滤(默认 list / TUI overlay 显示
|
|
304
|
+
// 已归档 workflow record)。本套件锁定:写(writeBindingForRecord 载荷)→
|
|
305
|
+
// 读(readRecordBinding 守卫)→ 重建(identityFromBinding → buildRecord)→
|
|
306
|
+
// 过滤(collectRecords 缺省排除 / includeWorkflow + parentRunId 下钻可见)全链。
|
|
307
|
+
|
|
308
|
+
describe("[H2 S3] binding 身份面 origin/parentRunId 往返保真", () => {
|
|
309
|
+
let dir: string;
|
|
310
|
+
|
|
311
|
+
beforeEach(() => {
|
|
312
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "record-binding-origin-"));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
afterEach(() => {
|
|
316
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("读侧守卫归一:origin=workflow + parentRunId 保真;缺省 → undefined;非法值 → undefined", () => {
|
|
320
|
+
const sessionFile = path.join(dir, "child.jsonl");
|
|
321
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
322
|
+
|
|
323
|
+
// 往返保真(正例)
|
|
324
|
+
writeBindingFixture(sessionFile, { origin: "workflow", parentRunId: "run-77" });
|
|
325
|
+
expect(readRecordBinding(sessionFile)).toMatchObject({ origin: "workflow", parentRunId: "run-77" });
|
|
326
|
+
|
|
327
|
+
// 缺省负向(存量 binding 零迁移):undefined = "tool" 语义
|
|
328
|
+
writeBindingFixture(sessionFile);
|
|
329
|
+
const absent = readRecordBinding(sessionFile)!;
|
|
330
|
+
expect(absent.origin).toBeUndefined();
|
|
331
|
+
expect(absent.parentRunId).toBeUndefined();
|
|
332
|
+
|
|
333
|
+
// 非法值负向(字面量守卫,对齐 readEntryOriginFields)
|
|
334
|
+
for (const badOrigin of ["bogus", 1, null]) {
|
|
335
|
+
writeBindingFixture(sessionFile, { origin: badOrigin as unknown as "workflow" });
|
|
336
|
+
expect(readRecordBinding(sessionFile)!.origin).toBeUndefined();
|
|
337
|
+
}
|
|
338
|
+
writeBindingFixture(sessionFile, { parentRunId: 42 as unknown as string });
|
|
339
|
+
expect(readRecordBinding(sessionFile)!.parentRunId).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("[H2 A3] updateRecordBinding merge 更新 usage 快照:合法值保真、非法守卫、binding 缺失不造新", () => {
|
|
343
|
+
const sessionFile = path.join(dir, "child-usage.jsonl");
|
|
344
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
345
|
+
|
|
346
|
+
// binding 缺失 → 跳过不造新(updateRecordBinding 不承担身份创建职责)
|
|
347
|
+
updateRecordBinding(sessionFile, { totalTokens: 100 });
|
|
348
|
+
expect(readRecordBinding(sessionFile)).toBeUndefined();
|
|
349
|
+
|
|
350
|
+
// 合并语义:既有身份字段不动,usage 快照写入
|
|
351
|
+
writeBindingFixture(sessionFile, { origin: "workflow", parentRunId: "run-u" });
|
|
352
|
+
updateRecordBinding(sessionFile, { totalTokens: 1234, turns: 3, endedAt: 999 });
|
|
353
|
+
expect(readRecordBinding(sessionFile)).toMatchObject({
|
|
354
|
+
recordId: "sa-bind-1", // 身份字段保留
|
|
355
|
+
origin: "workflow",
|
|
356
|
+
totalTokens: 1234,
|
|
357
|
+
turns: 3,
|
|
358
|
+
endedAt: 999,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// 非法值守卫:写侧不会写非法值(类型约束),读侧对磁盘垃圾值归一 undefined
|
|
362
|
+
fs.writeFileSync(
|
|
363
|
+
`${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`,
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
v: 1, recordId: "sa-bind-1", agent: "a", task: "t", mode: "background",
|
|
366
|
+
startedAt: STARTED_AT, chatMode: false, depth: 0, slug: "s", model: "m", worktree: false,
|
|
367
|
+
totalTokens: "garbage", turns: null, endedAt: false,
|
|
368
|
+
}),
|
|
369
|
+
"utf-8",
|
|
370
|
+
);
|
|
371
|
+
const guarded = readRecordBinding(sessionFile)!;
|
|
372
|
+
expect(guarded.totalTokens).toBeUndefined();
|
|
373
|
+
expect(guarded.turns).toBeUndefined();
|
|
374
|
+
expect(guarded.endedAt).toBeUndefined();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("[H2 S3] record-store 据绑定重建 origin(D1 投影过滤端到端)", () => {
|
|
379
|
+
let agentDir: string;
|
|
380
|
+
let sessionsDir: string;
|
|
381
|
+
|
|
382
|
+
beforeEach(() => {
|
|
383
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "record-binding-origin-store-"));
|
|
384
|
+
sessionsDir = getSubagentSessionDir(agentDir, agentDir);
|
|
385
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
afterEach(() => {
|
|
389
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("binding(workflow) → collectRecords 重建 origin/parentRunId 保真;缺省过滤排除;includeWorkflow / parentRunId 下钻可见", () => {
|
|
393
|
+
const file = writePlainChildSession(sessionsDir);
|
|
394
|
+
writeBindingFixture(file, { origin: "workflow", parentRunId: "wf-run-1" });
|
|
395
|
+
const store = new RecordStore(sessionsDir);
|
|
396
|
+
|
|
397
|
+
// 默认 list(includeWorkflow 缺省 false):workflow record 被排除(S3 FAIL 的验收面)
|
|
398
|
+
expect(store.collectRecords(10, "all", undefined)).toHaveLength(0);
|
|
399
|
+
// includeWorkflow:true:可见且字段保真
|
|
400
|
+
const visible = store.collectRecords(10, "all", undefined, true);
|
|
401
|
+
expect(visible).toHaveLength(1);
|
|
402
|
+
expect(visible[0]!.origin).toBe("workflow");
|
|
403
|
+
expect(visible[0]!.parentRunId).toBe("wf-run-1");
|
|
404
|
+
// W2/W3 下钻:按 parentRunId 显式查询命中
|
|
405
|
+
expect(store.collectRecordsByParentRunId("wf-run-1", 10).map((r) => r.id)).toEqual(["sa-bind-1"]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("binding(tool 缺省) → 重建 origin undefined,默认 list 保留(零迁移)", () => {
|
|
409
|
+
const file = writePlainChildSession(sessionsDir);
|
|
410
|
+
writeBindingFixture(file); // 不带 origin(存量形态)
|
|
411
|
+
const store = new RecordStore(sessionsDir);
|
|
412
|
+
|
|
413
|
+
const records = store.collectRecords(10, "all", undefined);
|
|
414
|
+
expect(records).toHaveLength(1);
|
|
415
|
+
expect(records[0]!.origin).toBeUndefined();
|
|
416
|
+
expect(records[0]!.parentRunId).toBeUndefined();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("归档(.state finalized)后重建仍保 origin:终态 workflow record 默认 list 不出现(S3 真机场景)", () => {
|
|
420
|
+
const file = writePlainChildSession(sessionsDir);
|
|
421
|
+
writeBindingFixture(file, { origin: "workflow", parentRunId: "wf-run-2" });
|
|
422
|
+
writeFinalizedState(file, "gc"); // 模拟归档/重启后终态重建
|
|
423
|
+
const store = new RecordStore(sessionsDir);
|
|
424
|
+
|
|
425
|
+
expect(store.collectRecords(10, "all", undefined)).toHaveLength(0);
|
|
426
|
+
const visible = store.collectRecords(10, "all", undefined, true);
|
|
427
|
+
expect(visible).toHaveLength(1);
|
|
428
|
+
expect(visible[0]!.status).toBe("closed");
|
|
429
|
+
expect(visible[0]!.origin).toBe("workflow");
|
|
430
|
+
expect(visible[0]!.parentRunId).toBe("wf-run-2");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("[H2 A3] 终态快照补投影:collectRecords 重建 light 恢复 totalTokens/turns/endedAt(list 面不再恒 0)", () => {
|
|
434
|
+
const file = writePlainChildSession(sessionsDir);
|
|
435
|
+
writeBindingFixture(file, { origin: "workflow", parentRunId: "wf-run-u" });
|
|
436
|
+
// 终态写点同款:.state + binding usage 快照
|
|
437
|
+
writeFinalizedState(file, "gc");
|
|
438
|
+
updateRecordBinding(file, { totalTokens: 60725, turns: 4, endedAt: STARTED_AT + 55_000 });
|
|
439
|
+
const store = new RecordStore(sessionsDir);
|
|
440
|
+
|
|
441
|
+
const visible = store.collectRecords(10, "all", undefined, true);
|
|
442
|
+
expect(visible).toHaveLength(1);
|
|
443
|
+
expect(visible[0]!.totalTokens).toBe(60725);
|
|
444
|
+
expect(visible[0]!.turns).toBe(4);
|
|
445
|
+
expect(visible[0]!.endedAt).toBe(STARTED_AT + 55_000);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("[H2 A3] 快照缺省(存量 binding)→ 不投影,保持 light 缺省 0/0/undefined(零迁移)", () => {
|
|
449
|
+
const file = writePlainChildSession(sessionsDir);
|
|
450
|
+
writeBindingFixture(file);
|
|
451
|
+
const store = new RecordStore(sessionsDir);
|
|
452
|
+
|
|
453
|
+
const records = store.collectRecords(10, "all", undefined);
|
|
454
|
+
expect(records).toHaveLength(1);
|
|
455
|
+
expect(records[0]!.totalTokens).toBe(0);
|
|
456
|
+
expect(records[0]!.turns).toBe(0);
|
|
457
|
+
expect(records[0]!.endedAt).toBeUndefined();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ============================================================
|
|
462
|
+
// C. SubagentService 集成(写入点 + 跨重启全链)
|
|
463
|
+
// ============================================================
|
|
464
|
+
|
|
465
|
+
function makePi(): PiLike {
|
|
466
|
+
return {
|
|
467
|
+
appendEntry: vi.fn(),
|
|
468
|
+
events: { emit: vi.fn() },
|
|
469
|
+
sendMessage: vi.fn(),
|
|
470
|
+
} as unknown as PiLike;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
interface ServiceInternals {
|
|
474
|
+
store: RecordStore;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** chatMode 续聊 record(首轮已完成、等待续聊;sessionFile 预置在指定目录)。 */
|
|
478
|
+
function makeChatRecord(id: string, sessionFile: string): ExecutionRecord {
|
|
479
|
+
const record = createRecord(id, {
|
|
480
|
+
agent: "general-purpose",
|
|
481
|
+
model: "prov/model-1",
|
|
482
|
+
thinkingLevel: "low",
|
|
483
|
+
mode: "background",
|
|
484
|
+
task: "initial task",
|
|
485
|
+
slug: "cont",
|
|
486
|
+
startedAt: 1000,
|
|
487
|
+
rootSessionId: "root-session",
|
|
488
|
+
chatMode: true,
|
|
489
|
+
controller: new AbortController(),
|
|
490
|
+
});
|
|
491
|
+
record.status = "running";
|
|
492
|
+
record.round = 1;
|
|
493
|
+
record.sessionFile = sessionFile;
|
|
494
|
+
fs.writeFileSync(sessionFile, "{}\n", "utf-8");
|
|
495
|
+
return record;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
describe("[UF-1] SubagentService 集成:回填点绑定落盘 + 跨重启 message 链", () => {
|
|
499
|
+
let agentDir: string;
|
|
500
|
+
let sessionsDir: string;
|
|
501
|
+
let service: SubagentService;
|
|
502
|
+
let store: RecordStore;
|
|
503
|
+
let fake: FakePiEnginePort;
|
|
504
|
+
let readOnlyDir: string | undefined;
|
|
505
|
+
|
|
506
|
+
beforeEach(() => {
|
|
507
|
+
for (const k of IDENTITY_ENV_KEYS) delete process.env[k];
|
|
508
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "record-binding-svc-"));
|
|
509
|
+
sessionsDir = getSubagentSessionDir(agentDir, agentDir);
|
|
510
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
511
|
+
clearEngines();
|
|
512
|
+
fake = registerFakePiEngine();
|
|
513
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
514
|
+
service = new SubagentService({ cwd: agentDir, modelService });
|
|
515
|
+
service.initSession({ pi: makePi(), sessionId: "root-session" });
|
|
516
|
+
store = (service as unknown as ServiceInternals).store;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
afterEach(() => {
|
|
520
|
+
service.dispose();
|
|
521
|
+
clearEngines();
|
|
522
|
+
_resetLifecycleState();
|
|
523
|
+
_resetSettledWatchdogsForTest();
|
|
524
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
525
|
+
// 只读目录先恢复权限再删(⑤用例的绑定写失败面)
|
|
526
|
+
if (readOnlyDir !== undefined) {
|
|
527
|
+
fs.chmodSync(readOnlyDir, 0o755);
|
|
528
|
+
readOnlyDir = undefined;
|
|
529
|
+
}
|
|
530
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
531
|
+
for (const k of IDENTITY_ENV_KEYS) delete process.env[k];
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("① chat 轮 run 应答回填点触发绑定落盘:载荷 = recordId/rootSessionId/chatMode/round 快照", async () => {
|
|
535
|
+
const sessionFile = path.join(agentDir, "sa-bind-live-session.jsonl");
|
|
536
|
+
const record = makeChatRecord("sa-bind-live", sessionFile);
|
|
537
|
+
store.register(record);
|
|
538
|
+
|
|
539
|
+
await service.chatActions.deliverChatMessage(record, "second round");
|
|
540
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
541
|
+
fake.runs[0]!.settle({ content: "second round reply", sessionFile });
|
|
542
|
+
|
|
543
|
+
// 轮应答(= L2797 回填点)后绑定在盘,身份域与 record 对齐
|
|
544
|
+
await vi.waitFor(() => expect(fs.existsSync(`${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`)).toBe(true));
|
|
545
|
+
const binding = readRecordBinding(sessionFile);
|
|
546
|
+
expect(binding).toMatchObject({
|
|
547
|
+
v: 1,
|
|
548
|
+
recordId: "sa-bind-live",
|
|
549
|
+
rootSessionId: "root-session",
|
|
550
|
+
chatMode: true,
|
|
551
|
+
// 绑定写点在轮终 round+1 之前——写点时点快照(round 滞后一拍为已登记语义)
|
|
552
|
+
round: 1,
|
|
553
|
+
agent: "general-purpose",
|
|
554
|
+
model: "prov/model-1",
|
|
555
|
+
});
|
|
556
|
+
// 轮正常收口(绑定写不影响派发主路径)
|
|
557
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
558
|
+
expect(record.status).toBe("running");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("⑤ 绑定写失败(只读目录)不阻塞派发主路径:轮正常 settle,仅 warn", async () => {
|
|
562
|
+
readOnlyDir = path.join(agentDir, "ro-binding");
|
|
563
|
+
fs.mkdirSync(readOnlyDir, { recursive: true });
|
|
564
|
+
const sessionFile = path.join(readOnlyDir, "sa-bind-ro-session.jsonl");
|
|
565
|
+
const record = makeChatRecord("sa-bind-ro", sessionFile); // makeChatRecord 内部落 sessionFile(此刻目录仍可写)
|
|
566
|
+
fs.chmodSync(readOnlyDir, 0o555); // 轮应答(绑定写点)前锁只读——写失败面就位
|
|
567
|
+
store.register(record);
|
|
568
|
+
|
|
569
|
+
await service.chatActions.deliverChatMessage(record, "round on read-only dir");
|
|
570
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
571
|
+
fake.runs[0]!.settle({ content: "reply", sessionFile });
|
|
572
|
+
|
|
573
|
+
// 主路径不受影响:回填推进 + 轮终收口照常
|
|
574
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
575
|
+
expect(record.sessionFile).toBe(sessionFile);
|
|
576
|
+
// 记账面如实留痕:绑定缺失 + warn
|
|
577
|
+
expect(fs.existsSync(`${sessionFile}${RECORD_BINDING_SIDECAR_EXT}`)).toBe(false);
|
|
578
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
579
|
+
expect.stringContaining("record binding write failed"),
|
|
580
|
+
expect.anything(),
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("③ 跨重启全链:绑定 fixture → getRecordForAction 重建 register → deliverChatMessage 续写原文件", async () => {
|
|
585
|
+
const file = writePlainChildSession(sessionsDir);
|
|
586
|
+
writeBindingFixture(file, { recordId: "sa-bind-1", rootSessionId: "root-session" });
|
|
587
|
+
|
|
588
|
+
// 重启形态:内存空,冷查链(findLightById miss → collectRecords 绑定重建)命中
|
|
589
|
+
const record = service.chatActions.getRecordForAction("sa-bind-1");
|
|
590
|
+
expect(record.chatMode).toBe(true);
|
|
591
|
+
expect(record.sessionFile).toBe(file);
|
|
592
|
+
expect(record.rootSessionId).toBe("root-session");
|
|
593
|
+
expect(record.status).toBe("running");
|
|
594
|
+
expect(store.getMutable("sa-bind-1")).toBe(record); // register 生效
|
|
595
|
+
|
|
596
|
+
// 可 message:续聊 run 以原 sessionFile 为 resume 锚点(续写原文件)。
|
|
597
|
+
// chat 会话形态参数挂在 RunContext(协议 run.params.chat 的承载位),非 task。
|
|
598
|
+
await service.chatActions.deliverChatMessage(record, "resume after restart");
|
|
599
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
600
|
+
const chatParams = fake.runs[0]!.ctx.resume;
|
|
601
|
+
expect(chatParams?.resume?.sessionRef["sessionFile"]).toBe(file);
|
|
602
|
+
expect(chatParams?.recordId).toBe("sa-bind-1");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("④ service 面终态不冲突:绑定 + .state(closed) → getRecordForAction 仍拒(终态单向语义保持)", () => {
|
|
606
|
+
const file = writePlainChildSession(sessionsDir);
|
|
607
|
+
writeBindingFixture(file);
|
|
608
|
+
writeFinalizedState(file, "gc");
|
|
609
|
+
|
|
610
|
+
expect(() => service.chatActions.getRecordForAction("sa-bind-1")).toThrow(/not found or not owned/);
|
|
611
|
+
expect(store.getMutable("sa-bind-1")).toBeUndefined(); // 绑定不越权复活终态 record
|
|
612
|
+
});
|
|
613
|
+
});
|