synergyspec-selfevolving 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -19
- package/dist/commands/learn.d.ts +12 -1
- package/dist/commands/learn.js +373 -31
- package/dist/commands/self-evolution-episode.d.ts +177 -0
- package/dist/commands/self-evolution-episode.js +423 -0
- package/dist/commands/self-evolution.d.ts +12 -190
- package/dist/commands/self-evolution.js +179 -786
- package/dist/commands/workflow/status.js +3 -1
- package/dist/core/archive.d.ts +0 -1
- package/dist/core/archive.js +0 -58
- package/dist/core/artifact-graph/instruction-loader.d.ts +2 -4
- package/dist/core/artifact-graph/instruction-loader.js +3 -31
- package/dist/core/config-prompts.js +4 -0
- package/dist/core/fitness/health/health-metrics.d.ts +26 -56
- package/dist/core/fitness/health/health-metrics.js +19 -58
- package/dist/core/fitness/health/index.d.ts +15 -2
- package/dist/core/fitness/health/index.js +25 -1
- package/dist/core/fitness/health/local-source.d.ts +43 -4
- package/dist/core/fitness/health/local-source.js +181 -25
- package/dist/core/fitness/health/metric-source.d.ts +48 -19
- package/dist/core/fitness/health/metric-source.js +8 -18
- package/dist/core/fitness/health/resolve-source.js +4 -1
- package/dist/core/fitness/loss.d.ts +7 -7
- package/dist/core/fitness/loss.js +6 -6
- package/dist/core/fitness/sample.d.ts +10 -0
- package/dist/core/fitness/test-failures.d.ts +30 -0
- package/dist/core/fitness/test-failures.js +123 -0
- package/dist/core/learn/credit-path.d.ts +36 -0
- package/dist/core/learn/credit-path.js +198 -0
- package/dist/core/learn/trajectory-discovery.d.ts +39 -0
- package/dist/core/learn/trajectory-discovery.js +140 -0
- package/dist/core/learn.d.ts +39 -5
- package/dist/core/learn.js +131 -14
- package/dist/core/project-config.d.ts +4 -0
- package/dist/core/project-config.js +52 -1
- package/dist/core/self-evolution/candidate-fitness.d.ts +23 -1
- package/dist/core/self-evolution/candidate-fitness.js +31 -5
- package/dist/core/self-evolution/candidates.d.ts +0 -9
- package/dist/core/self-evolution/canonical-targets.d.ts +8 -4
- package/dist/core/self-evolution/canonical-targets.js +8 -4
- package/dist/core/self-evolution/critic-agent.d.ts +150 -0
- package/dist/core/self-evolution/critic-agent.js +487 -0
- package/dist/core/self-evolution/edits-contract.d.ts +53 -0
- package/dist/core/self-evolution/edits-contract.js +89 -0
- package/dist/core/self-evolution/episode-orchestrator.d.ts +197 -0
- package/dist/core/self-evolution/episode-orchestrator.js +534 -0
- package/dist/core/self-evolution/episode-store.d.ts +266 -0
- package/dist/core/self-evolution/episode-store.js +573 -0
- package/dist/core/self-evolution/evolution-switches.d.ts +1 -1
- package/dist/core/self-evolution/evolution-switches.js +5 -10
- package/dist/core/self-evolution/evolving-agent.d.ts +162 -0
- package/dist/core/self-evolution/evolving-agent.js +449 -0
- package/dist/core/self-evolution/health-baseline.d.ts +25 -6
- package/dist/core/self-evolution/health-baseline.js +30 -6
- package/dist/core/self-evolution/host-harness.d.ts +1 -2
- package/dist/core/self-evolution/host-harness.js +1 -2
- package/dist/core/self-evolution/index.d.ts +10 -6
- package/dist/core/self-evolution/index.js +19 -6
- package/dist/core/self-evolution/learn-hints.d.ts +31 -0
- package/dist/core/self-evolution/learn-hints.js +16 -0
- package/dist/core/self-evolution/learn-observation-adapter.d.ts +35 -0
- package/dist/core/self-evolution/learn-observation-adapter.js +285 -10
- package/dist/core/self-evolution/line-diff.d.ts +60 -0
- package/dist/core/self-evolution/line-diff.js +130 -0
- package/dist/core/self-evolution/policy/fs-safe.d.ts +19 -0
- package/dist/core/self-evolution/policy/fs-safe.js +89 -0
- package/dist/core/self-evolution/policy/index.d.ts +13 -0
- package/dist/core/self-evolution/policy/index.js +13 -0
- package/dist/core/self-evolution/policy/policy-store.d.ts +217 -0
- package/dist/core/self-evolution/policy/policy-store.js +774 -0
- package/dist/core/self-evolution/policy/reject-buffer.d.ts +48 -0
- package/dist/core/self-evolution/policy/reject-buffer.js +168 -0
- package/dist/core/self-evolution/promote.d.ts +1 -1
- package/dist/core/self-evolution/promote.js +6 -33
- package/dist/core/self-evolution/promotion.js +1 -2
- package/dist/core/self-evolution/proposer-agent.d.ts +41 -0
- package/dist/core/self-evolution/proposer-agent.js +94 -13
- package/dist/core/self-evolution/proposer-slice.d.ts +26 -0
- package/dist/core/self-evolution/proposer-slice.js +54 -0
- package/dist/core/self-evolution/reward-agent.d.ts +234 -0
- package/dist/core/self-evolution/reward-agent.js +564 -0
- package/dist/core/self-evolution/scope-gate.d.ts +66 -0
- package/dist/core/self-evolution/scope-gate.js +107 -0
- package/dist/core/self-evolution/success-channel.d.ts +79 -0
- package/dist/core/self-evolution/success-channel.js +361 -0
- package/dist/core/self-evolution/target-evolution.d.ts +11 -0
- package/dist/core/self-evolution/target-evolution.js +2 -0
- package/dist/core/self-evolution/tool-evolution.js +2 -13
- package/dist/core/self-evolution/verdict.d.ts +8 -5
- package/dist/core/self-evolution/verdict.js +4 -7
- package/dist/core/templates/skill-templates.d.ts +1 -0
- package/dist/core/templates/skill-templates.js +1 -0
- package/dist/core/templates/workflow-manifest.js +2 -0
- package/dist/core/templates/workflows/learn.d.ts +4 -2
- package/dist/core/templates/workflows/learn.js +25 -166
- package/dist/core/templates/workflows/self-evolving.d.ts +13 -0
- package/dist/core/templates/workflows/self-evolving.js +127 -0
- package/dist/core/trajectory/facts.d.ts +16 -0
- package/dist/core/trajectory/facts.js +12 -4
- package/dist/core/trajectory/skeleton.d.ts +43 -0
- package/dist/core/trajectory/skeleton.js +239 -0
- package/dist/dashboard/data.d.ts +25 -51
- package/dist/dashboard/data.js +68 -180
- package/dist/dashboard/react-client.js +458 -503
- package/dist/dashboard/react-styles.js +3 -3
- package/dist/dashboard/server.js +23 -17
- package/dist/ui/ascii-patterns.d.ts +7 -15
- package/dist/ui/ascii-patterns.js +123 -54
- package/dist/ui/welcome-screen.d.ts +0 -14
- package/dist/ui/welcome-screen.js +16 -35
- package/package.json +3 -1
- package/scripts/code-health.py +1066 -638
- package/scripts/slop_rules.yaml +2151 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 策略 POLICY = design template(主智能体的「权重」)· CLI 持有版本.
|
|
3
|
+
*
|
|
4
|
+
* In-context-RL framing (loop v2): the 主智能体 MAIN AGENT runs the current
|
|
5
|
+
* policy vN+1 as a frozen actor; the CRITIC AGENT(基线智能体 baseline agent)
|
|
6
|
+
* reruns LAST episode's policy vN on the SAME change; the 奖励智能体 REWARD
|
|
7
|
+
* AGENT calculates 算分 reward(主臂)&reward(基线臂), advantage =
|
|
8
|
+
* reward(主臂) − reward(基线臂), and the 文本梯度 textual gradient — it never
|
|
9
|
+
* edits, and it 弃权 abstains when there is no nameable gap; the 演进智能体
|
|
10
|
+
* EVOLVING AGENT is optimizer.step — ONE bounded edit ≤L, never scoring.
|
|
11
|
+
*
|
|
12
|
+
* The policy itself lives in the USER's repo (the design template file(s));
|
|
13
|
+
* this module is the CLI side that 持有版本 (holds the VERSION): the
|
|
14
|
+
* 版本账本 ledger, byte-for-byte snapshots, and the one-in-flight lock.
|
|
15
|
+
*
|
|
16
|
+
* On-disk layout, rooted at
|
|
17
|
+
* `<repoRoot>/.synergyspec-selfevolving/self-evolution/policy/`:
|
|
18
|
+
* - `ledger.ndjson` — 版本账本, append-only, 单一血统
|
|
19
|
+
* single lineage per target.
|
|
20
|
+
* - `snapshots/<target-id-slug>/v<N>/` — `manifest.json` + `files/<relPath>`
|
|
21
|
+
* (byte-for-byte) + `delta.patch`
|
|
22
|
+
* (unified diff vs v<N-1>; v0 has no
|
|
23
|
+
* delta).
|
|
24
|
+
* - `in-flight.json` — one in-flight episode per target
|
|
25
|
+
* (stale after 60 minutes).
|
|
26
|
+
*
|
|
27
|
+
* Versioning rules (what each ledger `action` means for the version axis):
|
|
28
|
+
* - 'init' → v0: snapshot of the target's CURRENT resolved local files.
|
|
29
|
+
* - 'evolve' → vN+1: the 演进智能体 EVOLVING AGENT's ONE bounded edit.
|
|
30
|
+
* - 'refused' → version does NOT bump (vN+1 ≡ vN). This is what the
|
|
31
|
+
* CRITIC AGENT(基线智能体 baseline agent)'s skip condition
|
|
32
|
+
* reads: the policy did not change, so there is no new arm
|
|
33
|
+
* to compare against the baseline.
|
|
34
|
+
* - 'rollback' → vN+1 whose files are byte-identical to snapshot
|
|
35
|
+
* v<toVersion>. Rolling FORWARD to old content (git-revert
|
|
36
|
+
* style) keeps the 单一血统 single lineage monotonic: the
|
|
37
|
+
* version axis never rewinds, so no snapshot dir is ever
|
|
38
|
+
* overwritten.
|
|
39
|
+
*
|
|
40
|
+
* Snapshots are the durable record; any per-episode worktree is 产物即弃
|
|
41
|
+
* (worktree artifacts discarded). All writes are fail-closed: snapshot dirs
|
|
42
|
+
* are committed via tmp dir + rename, live files via sibling tmp + rename,
|
|
43
|
+
* the order is snapshot-then-write (the NEW version dir exists before any
|
|
44
|
+
* live file changes), and a mid-write failure restores every live file from
|
|
45
|
+
* the head snapshot before rethrowing.
|
|
46
|
+
*/
|
|
47
|
+
import { promises as fs } from 'node:fs';
|
|
48
|
+
import * as path from 'node:path';
|
|
49
|
+
import * as crypto from 'node:crypto';
|
|
50
|
+
import { resolveTargetLocalFiles } from '../local-targets.js';
|
|
51
|
+
import { assertWithinRepo, writeFileAtomic, appendFileDurable } from './fs-safe.js';
|
|
52
|
+
export const POLICY_LEDGER_FILE = 'ledger.ndjson';
|
|
53
|
+
export const POLICY_IN_FLIGHT_FILE = 'in-flight.json';
|
|
54
|
+
export const POLICY_REJECT_BUFFER_FILE = 'reject-buffer.ndjson';
|
|
55
|
+
export const POLICY_SNAPSHOT_MANIFEST_FILE = 'manifest.json';
|
|
56
|
+
export const POLICY_SNAPSHOT_FILES_DIR = 'files';
|
|
57
|
+
export const POLICY_SNAPSHOT_DELTA_FILE = 'delta.patch';
|
|
58
|
+
/** An in-flight episode older than this is stale and its slot reclaimable. */
|
|
59
|
+
export const IN_FLIGHT_STALE_MS = 60 * 60 * 1000;
|
|
60
|
+
const POLICY_SUBDIR = path.join('.synergyspec-selfevolving', 'self-evolution', 'policy');
|
|
61
|
+
/** Compute the policy dir layout for a repo. */
|
|
62
|
+
export function resolvePolicyLayout(repoRoot) {
|
|
63
|
+
const baseDir = path.join(repoRoot, POLICY_SUBDIR);
|
|
64
|
+
return {
|
|
65
|
+
repoRoot,
|
|
66
|
+
baseDir,
|
|
67
|
+
ledgerPath: path.join(baseDir, POLICY_LEDGER_FILE),
|
|
68
|
+
snapshotsDir: path.join(baseDir, 'snapshots'),
|
|
69
|
+
inFlightPath: path.join(baseDir, POLICY_IN_FLIGHT_FILE),
|
|
70
|
+
rejectBufferPath: path.join(baseDir, POLICY_REJECT_BUFFER_FILE),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Kebab-case a target id for use as a snapshot directory name. */
|
|
74
|
+
export function targetIdSlug(targetId) {
|
|
75
|
+
return targetId
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
78
|
+
.replace(/^-|-$/g, '');
|
|
79
|
+
}
|
|
80
|
+
/** Absolute path of one version's snapshot dir. No I/O is performed. */
|
|
81
|
+
export function policySnapshotDir(layout, targetId, version) {
|
|
82
|
+
return path.join(layout.snapshotsDir, targetIdSlug(targetId), `v${version}`);
|
|
83
|
+
}
|
|
84
|
+
function sha256Of(content) {
|
|
85
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
86
|
+
}
|
|
87
|
+
function toPosix(p) {
|
|
88
|
+
return p.replace(/\\/g, '/');
|
|
89
|
+
}
|
|
90
|
+
function assertNonEmptyString(value, name) {
|
|
91
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
92
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// 版本账本 ledger
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
function isValidLedgerEntry(value) {
|
|
99
|
+
if (!value || typeof value !== 'object')
|
|
100
|
+
return false;
|
|
101
|
+
const e = value;
|
|
102
|
+
if (e.schemaVersion !== 1)
|
|
103
|
+
return false;
|
|
104
|
+
if (typeof e.version !== 'number' || !Number.isInteger(e.version) || e.version < 0) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (typeof e.targetId !== 'string' || e.targetId.length === 0)
|
|
108
|
+
return false;
|
|
109
|
+
if (typeof e.at !== 'string')
|
|
110
|
+
return false;
|
|
111
|
+
if (e.action !== 'init' &&
|
|
112
|
+
e.action !== 'evolve' &&
|
|
113
|
+
e.action !== 'rollback' &&
|
|
114
|
+
e.action !== 'refused') {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (e.episodeId !== null && typeof e.episodeId !== 'string')
|
|
118
|
+
return false;
|
|
119
|
+
if (!Array.isArray(e.files))
|
|
120
|
+
return false;
|
|
121
|
+
for (const f of e.files) {
|
|
122
|
+
const ff = f;
|
|
123
|
+
if (!ff || typeof ff !== 'object')
|
|
124
|
+
return false;
|
|
125
|
+
if (typeof ff.relPath !== 'string' || typeof ff.sha256 !== 'string')
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
async function appendLedgerEntry(layout, entry) {
|
|
131
|
+
await fs.mkdir(layout.baseDir, { recursive: true });
|
|
132
|
+
// Durable append: fsync the fd so a host crash cannot lose a rollback ledger
|
|
133
|
+
// entry a later separate process (resumeEpisode) reads to decide head version.
|
|
134
|
+
await appendFileDurable(layout.ledgerPath, `${JSON.stringify(entry)}\n`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Read one target's slice of the 版本账本 ledger, in append order. Returns
|
|
138
|
+
* `[]` when the ledger does not exist. Malformed/blank lines are skipped
|
|
139
|
+
* best-effort (forward-compatible, matching the repo's other ndjson readers).
|
|
140
|
+
*/
|
|
141
|
+
export async function readPolicyLedger(repoRoot, targetId) {
|
|
142
|
+
const layout = resolvePolicyLayout(path.resolve(repoRoot));
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = await fs.readFile(layout.ledgerPath, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err.code === 'ENOENT')
|
|
149
|
+
return [];
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
const entries = [];
|
|
153
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
if (trimmed.length === 0)
|
|
156
|
+
continue;
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(trimmed);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (isValidLedgerEntry(parsed) && parsed.targetId === targetId)
|
|
165
|
+
entries.push(parsed);
|
|
166
|
+
}
|
|
167
|
+
return entries;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Read the ENTIRE 版本账本 ledger across all targets, in append order. Returns
|
|
171
|
+
* `[]` when the ledger does not exist. Malformed/blank lines are skipped
|
|
172
|
+
* best-effort (forward-compatible, matching the repo's other ndjson readers).
|
|
173
|
+
*/
|
|
174
|
+
export async function readPolicyLedgerAll(repoRoot) {
|
|
175
|
+
const layout = resolvePolicyLayout(path.resolve(repoRoot));
|
|
176
|
+
let raw;
|
|
177
|
+
try {
|
|
178
|
+
raw = await fs.readFile(layout.ledgerPath, 'utf8');
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (err.code === 'ENOENT')
|
|
182
|
+
return [];
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
const entries = [];
|
|
186
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
187
|
+
const trimmed = line.trim();
|
|
188
|
+
if (trimmed.length === 0)
|
|
189
|
+
continue;
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(trimmed);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (isValidLedgerEntry(parsed))
|
|
198
|
+
entries.push(parsed);
|
|
199
|
+
}
|
|
200
|
+
return entries;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The lineage head version for a target, or `null` when the lineage has not
|
|
204
|
+
* been initialized. The 单一血统 single lineage is append-only, so the head is
|
|
205
|
+
* simply the LAST ledger entry's version ('refused' entries carry the
|
|
206
|
+
* unchanged head; 'evolve'/'rollback' carry the new one).
|
|
207
|
+
*/
|
|
208
|
+
export async function currentPolicyVersion(repoRoot, targetId) {
|
|
209
|
+
const entries = await readPolicyLedger(repoRoot, targetId);
|
|
210
|
+
if (entries.length === 0)
|
|
211
|
+
return null;
|
|
212
|
+
return entries[entries.length - 1].version;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Write one version's snapshot dir atomically (tmp dir + rename, mirroring
|
|
216
|
+
* `writeCandidatePackage`). Refuses to overwrite an existing version dir —
|
|
217
|
+
* snapshots, like the ledger, are append-only history. Returns the
|
|
218
|
+
* content-addressed manifest file list.
|
|
219
|
+
*/
|
|
220
|
+
async function writeSnapshot(layout, opts) {
|
|
221
|
+
const slugDir = path.join(layout.snapshotsDir, targetIdSlug(opts.targetId));
|
|
222
|
+
const finalDir = path.join(slugDir, `v${opts.version}`);
|
|
223
|
+
// Refuse to overwrite. Detect via stat; ENOENT means safe to proceed.
|
|
224
|
+
try {
|
|
225
|
+
await fs.stat(finalDir);
|
|
226
|
+
throw new Error(`Refusing to overwrite existing policy snapshot: ${finalDir}`);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
if (err.code !== 'ENOENT')
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
await fs.mkdir(slugDir, { recursive: true });
|
|
233
|
+
const tmpDir = await fs.mkdtemp(path.join(slugDir, `v${opts.version}.tmp-`));
|
|
234
|
+
try {
|
|
235
|
+
const manifestFiles = opts.files.map((f) => ({
|
|
236
|
+
relPath: toPosix(f.relPath),
|
|
237
|
+
sha256: sha256Of(f.content),
|
|
238
|
+
}));
|
|
239
|
+
const manifest = {
|
|
240
|
+
version: opts.version,
|
|
241
|
+
targetId: opts.targetId,
|
|
242
|
+
at: opts.at,
|
|
243
|
+
files: manifestFiles,
|
|
244
|
+
};
|
|
245
|
+
await fs.writeFile(path.join(tmpDir, POLICY_SNAPSHOT_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
246
|
+
const filesRoot = path.join(tmpDir, POLICY_SNAPSHOT_FILES_DIR);
|
|
247
|
+
for (const f of opts.files) {
|
|
248
|
+
const abs = path.join(filesRoot, ...toPosix(f.relPath).split('/'));
|
|
249
|
+
assertWithinRepo(filesRoot, abs);
|
|
250
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
251
|
+
await fs.writeFile(abs, f.content, 'utf8');
|
|
252
|
+
}
|
|
253
|
+
if (opts.deltaPatch !== null) {
|
|
254
|
+
await fs.writeFile(path.join(tmpDir, POLICY_SNAPSHOT_DELTA_FILE), opts.deltaPatch, 'utf8');
|
|
255
|
+
}
|
|
256
|
+
await fs.rename(tmpDir, finalDir);
|
|
257
|
+
return manifestFiles;
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
// Best-effort cleanup; surface the original error.
|
|
261
|
+
try {
|
|
262
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// ignore
|
|
266
|
+
}
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Read one version's snapshotted files (byte-for-byte). Verifies every file
|
|
272
|
+
* against the manifest's sha256 and throws on a mismatch — a snapshot is the
|
|
273
|
+
* restore source of truth, so corruption is a hard error, never silent.
|
|
274
|
+
*/
|
|
275
|
+
export async function readPolicySnapshotFiles(repoRoot, targetId, version) {
|
|
276
|
+
const layout = resolvePolicyLayout(path.resolve(repoRoot));
|
|
277
|
+
const dir = policySnapshotDir(layout, targetId, version);
|
|
278
|
+
let manifestRaw;
|
|
279
|
+
try {
|
|
280
|
+
manifestRaw = await fs.readFile(path.join(dir, POLICY_SNAPSHOT_MANIFEST_FILE), 'utf8');
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
if (err.code === 'ENOENT') {
|
|
284
|
+
throw new Error(`No policy snapshot v${version} for ${targetId} (looked under ${dir}).`);
|
|
285
|
+
}
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
let manifest;
|
|
289
|
+
try {
|
|
290
|
+
manifest = JSON.parse(manifestRaw);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
throw new Error(`Policy snapshot v${version} for ${targetId} has an unreadable manifest: ${err.message}`);
|
|
294
|
+
}
|
|
295
|
+
if (!Array.isArray(manifest.files)) {
|
|
296
|
+
throw new Error(`Policy snapshot v${version} for ${targetId} has a malformed manifest (no files[]).`);
|
|
297
|
+
}
|
|
298
|
+
const filesRoot = path.join(dir, POLICY_SNAPSHOT_FILES_DIR);
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const f of manifest.files) {
|
|
301
|
+
if (typeof f?.relPath !== 'string' || typeof f?.sha256 !== 'string') {
|
|
302
|
+
throw new Error(`Policy snapshot v${version} for ${targetId} has a malformed manifest file entry.`);
|
|
303
|
+
}
|
|
304
|
+
const abs = path.join(filesRoot, ...toPosix(f.relPath).split('/'));
|
|
305
|
+
assertWithinRepo(filesRoot, abs);
|
|
306
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
307
|
+
if (sha256Of(content) !== f.sha256) {
|
|
308
|
+
throw new Error(`Policy snapshot corrupted: sha256 mismatch for ${f.relPath} in v${version} of ${targetId}.`);
|
|
309
|
+
}
|
|
310
|
+
out.push({ relPath: toPosix(f.relPath), content });
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Delta rendering
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
/**
|
|
318
|
+
* Whole-file-replacement unified diff for one policy file. Same format as
|
|
319
|
+
* `edits-contract.ts`'s `renderUnifiedDiff`, duplicated locally on purpose so
|
|
320
|
+
* the policy module stays free of the shared edits-contract module graph.
|
|
321
|
+
*/
|
|
322
|
+
function renderFileDelta(relPath, oldContent, newContent) {
|
|
323
|
+
const oldLines = oldContent.length === 0 ? [] : oldContent.replace(/\n$/, '').split('\n');
|
|
324
|
+
const newLines = newContent.replace(/\n$/, '').split('\n');
|
|
325
|
+
const oldStart = oldLines.length === 0 ? 0 : 1;
|
|
326
|
+
const header = `--- a/${relPath}\n+++ b/${relPath}\n` +
|
|
327
|
+
`@@ -${oldStart},${oldLines.length} +1,${newLines.length} @@`;
|
|
328
|
+
const body = [...oldLines.map((l) => `-${l}`), ...newLines.map((l) => `+${l}`)].join('\n');
|
|
329
|
+
return `${header}\n${body}`;
|
|
330
|
+
}
|
|
331
|
+
/** Derive ledger `deltaStats` from a rendered `delta.patch` body. */
|
|
332
|
+
function countDeltaStats(deltaPatch, filesChanged) {
|
|
333
|
+
let linesAdded = 0;
|
|
334
|
+
let linesRemoved = 0;
|
|
335
|
+
for (const line of deltaPatch.split('\n')) {
|
|
336
|
+
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@'))
|
|
337
|
+
continue;
|
|
338
|
+
if (line.startsWith('+'))
|
|
339
|
+
linesAdded += 1;
|
|
340
|
+
else if (line.startsWith('-'))
|
|
341
|
+
linesRemoved += 1;
|
|
342
|
+
}
|
|
343
|
+
return { filesChanged, linesAdded, linesRemoved };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Start a target's 单一血统 single lineage: snapshot v0 of the target's
|
|
347
|
+
* CURRENT resolved local files and append the 'init' ledger entry. Refuses to
|
|
348
|
+
* re-init an existing lineage (the version axis never restarts).
|
|
349
|
+
*/
|
|
350
|
+
export async function initPolicyLineage(opts) {
|
|
351
|
+
assertNonEmptyString(opts.targetId, 'targetId');
|
|
352
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
353
|
+
const existing = await currentPolicyVersion(repoRoot, opts.targetId);
|
|
354
|
+
if (existing !== null) {
|
|
355
|
+
throw new Error(`Policy lineage for ${opts.targetId} is already initialized (head v${existing}); ` +
|
|
356
|
+
`单一血统 single lineage — refusing to re-init.`);
|
|
357
|
+
}
|
|
358
|
+
const resolveFiles = opts.resolveFiles ?? resolveTargetLocalFiles;
|
|
359
|
+
const resolved = await resolveFiles(opts.targetId, repoRoot);
|
|
360
|
+
const files = resolved.files.map((f) => ({ relPath: toPosix(f.relPath), content: f.content }));
|
|
361
|
+
if (files.length === 0) {
|
|
362
|
+
throw new Error(`Cannot init policy lineage for ${opts.targetId}: the target resolves to no local files in this repo.`);
|
|
363
|
+
}
|
|
364
|
+
const layout = resolvePolicyLayout(repoRoot);
|
|
365
|
+
const at = new Date().toISOString();
|
|
366
|
+
const manifestFiles = await writeSnapshot(layout, {
|
|
367
|
+
targetId: opts.targetId,
|
|
368
|
+
version: 0,
|
|
369
|
+
at,
|
|
370
|
+
files,
|
|
371
|
+
deltaPatch: null, // v0 has no predecessor
|
|
372
|
+
});
|
|
373
|
+
const entry = {
|
|
374
|
+
schemaVersion: 1,
|
|
375
|
+
version: 0,
|
|
376
|
+
targetId: opts.targetId,
|
|
377
|
+
at,
|
|
378
|
+
action: 'init',
|
|
379
|
+
episodeId: null,
|
|
380
|
+
files: manifestFiles,
|
|
381
|
+
};
|
|
382
|
+
await appendLedgerEntry(layout, entry);
|
|
383
|
+
return entry;
|
|
384
|
+
}
|
|
385
|
+
const PREDICTION_METRICS = new Set([
|
|
386
|
+
'loss',
|
|
387
|
+
'passRate',
|
|
388
|
+
'healthPenalty',
|
|
389
|
+
]);
|
|
390
|
+
function assertValidPrediction(prediction) {
|
|
391
|
+
if (!prediction || typeof prediction !== 'object') {
|
|
392
|
+
throw new Error('advancePolicyVersion requires a prediction ({metric, direction, checkBy}) — every evolve step must be falsifiable.');
|
|
393
|
+
}
|
|
394
|
+
if (!PREDICTION_METRICS.has(prediction.metric)) {
|
|
395
|
+
throw new Error(`prediction.metric must be 'loss' | 'passRate' | 'healthPenalty', got ${JSON.stringify(prediction.metric)}`);
|
|
396
|
+
}
|
|
397
|
+
if (prediction.direction !== 'down' && prediction.direction !== 'up') {
|
|
398
|
+
throw new Error(`prediction.direction must be 'down' | 'up', got ${JSON.stringify(prediction.direction)}`);
|
|
399
|
+
}
|
|
400
|
+
assertNonEmptyString(prediction.checkBy, 'prediction.checkBy');
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Apply the 演进智能体 EVOLVING AGENT's ONE bounded edit as the next policy
|
|
404
|
+
* version. 单一血统 single-lineage enforcement: the live files must still be
|
|
405
|
+
* byte-identical to the lineage head snapshot — if anything advanced or edited
|
|
406
|
+
* them out-of-band, the head is not the expected base and this throws (roll
|
|
407
|
+
* back, or re-init, before evolving again).
|
|
408
|
+
*
|
|
409
|
+
* Order is snapshot-then-write: the NEW version's snapshot dir is committed
|
|
410
|
+
* first, then the live files are written atomically; a mid-write failure
|
|
411
|
+
* restores every live file from the head content and discards the new
|
|
412
|
+
* snapshot before rethrowing. The 'evolve' ledger entry (with `prediction`
|
|
413
|
+
* and `deltaStats`) is appended only after every live write succeeded.
|
|
414
|
+
*/
|
|
415
|
+
export async function advancePolicyVersion(opts) {
|
|
416
|
+
assertNonEmptyString(opts.targetId, 'targetId');
|
|
417
|
+
assertNonEmptyString(opts.episodeId, 'episodeId');
|
|
418
|
+
assertValidPrediction(opts.prediction);
|
|
419
|
+
if (!Array.isArray(opts.edits) || opts.edits.length === 0) {
|
|
420
|
+
throw new Error('advancePolicyVersion requires at least one edit (the 演进智能体 EVOLVING AGENT makes ONE bounded edit ≤L, never zero).');
|
|
421
|
+
}
|
|
422
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
423
|
+
const layout = resolvePolicyLayout(repoRoot);
|
|
424
|
+
const entries = await readPolicyLedger(repoRoot, opts.targetId);
|
|
425
|
+
if (entries.length === 0) {
|
|
426
|
+
throw new Error(`Policy lineage for ${opts.targetId} is not initialized; run initPolicyLineage first.`);
|
|
427
|
+
}
|
|
428
|
+
const headVersion = entries[entries.length - 1].version;
|
|
429
|
+
// The head snapshot is the expected base.
|
|
430
|
+
const headFiles = await readPolicySnapshotFiles(repoRoot, opts.targetId, headVersion);
|
|
431
|
+
const headByPath = new Map(headFiles.map((f) => [f.relPath, f.content]));
|
|
432
|
+
// 单一血统 single-lineage enforcement: every live file must match the head
|
|
433
|
+
// snapshot byte-for-byte before we stack a new version on top of it.
|
|
434
|
+
for (const f of headFiles) {
|
|
435
|
+
const abs = path.resolve(repoRoot, ...f.relPath.split('/'));
|
|
436
|
+
assertWithinRepo(repoRoot, abs);
|
|
437
|
+
let live;
|
|
438
|
+
try {
|
|
439
|
+
live = await fs.readFile(abs, 'utf8');
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
if (err.code === 'ENOENT') {
|
|
443
|
+
throw new Error(`Refusing to advance ${opts.targetId}: live file ${f.relPath} is missing — ` +
|
|
444
|
+
`lineage head v${headVersion} is not the expected base.`);
|
|
445
|
+
}
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
if (live !== f.content) {
|
|
449
|
+
throw new Error(`Refusing to advance ${opts.targetId}: live file ${f.relPath} diverged from lineage head ` +
|
|
450
|
+
`v${headVersion} — the head is not the expected base (单一血统 single lineage; roll back ` +
|
|
451
|
+
`or re-init before evolving again).`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Validate the edits BEFORE writing anything: lineage files only, and at
|
|
455
|
+
// least one byte must actually change (a no-op evolve is a bug upstream).
|
|
456
|
+
const editByPath = new Map();
|
|
457
|
+
for (const edit of opts.edits) {
|
|
458
|
+
const rel = toPosix(edit.relPath);
|
|
459
|
+
if (!headByPath.has(rel)) {
|
|
460
|
+
throw new Error(`Refusing to advance ${opts.targetId}: edit targets ${rel}, which is not part of the ` +
|
|
461
|
+
`policy lineage (evolve existing policy files only).`);
|
|
462
|
+
}
|
|
463
|
+
if (typeof edit.content !== 'string') {
|
|
464
|
+
throw new Error(`Refusing to advance ${opts.targetId}: edit for ${rel} has no string content.`);
|
|
465
|
+
}
|
|
466
|
+
editByPath.set(rel, edit.content);
|
|
467
|
+
}
|
|
468
|
+
const changed = [...editByPath].filter(([rel, content]) => content !== headByPath.get(rel));
|
|
469
|
+
if (changed.length === 0) {
|
|
470
|
+
throw new Error(`Refusing to advance ${opts.targetId}: every edit is byte-identical to head v${headVersion} (no-op evolve).`);
|
|
471
|
+
}
|
|
472
|
+
const deltaPatch = changed
|
|
473
|
+
.map(([rel, content]) => renderFileDelta(rel, headByPath.get(rel), content))
|
|
474
|
+
.join('\n');
|
|
475
|
+
const deltaStats = countDeltaStats(deltaPatch, changed.length);
|
|
476
|
+
const newVersion = headVersion + 1;
|
|
477
|
+
const at = new Date().toISOString();
|
|
478
|
+
const newFiles = headFiles.map((f) => ({
|
|
479
|
+
relPath: f.relPath,
|
|
480
|
+
content: editByPath.get(f.relPath) ?? f.content,
|
|
481
|
+
}));
|
|
482
|
+
// 1) Snapshot the NEW version dir first (durable before any live change).
|
|
483
|
+
const manifestFiles = await writeSnapshot(layout, {
|
|
484
|
+
targetId: opts.targetId,
|
|
485
|
+
version: newVersion,
|
|
486
|
+
at,
|
|
487
|
+
files: newFiles,
|
|
488
|
+
deltaPatch,
|
|
489
|
+
});
|
|
490
|
+
// 2) Write the live files, then append the ledger entry, under ONE
|
|
491
|
+
// fail-closed guard: any failure restores the written live files from the
|
|
492
|
+
// head content and discards the new snapshot, so the lineage either fully
|
|
493
|
+
// advances or stays byte-unchanged at the head.
|
|
494
|
+
const written = [];
|
|
495
|
+
try {
|
|
496
|
+
for (const [rel, content] of changed) {
|
|
497
|
+
const abs = path.resolve(repoRoot, ...rel.split('/'));
|
|
498
|
+
const before = headByPath.get(rel);
|
|
499
|
+
await writeFileAtomic(abs, content);
|
|
500
|
+
written.push({ abs, before });
|
|
501
|
+
}
|
|
502
|
+
const entry = {
|
|
503
|
+
schemaVersion: 1,
|
|
504
|
+
version: newVersion,
|
|
505
|
+
targetId: opts.targetId,
|
|
506
|
+
at,
|
|
507
|
+
action: 'evolve',
|
|
508
|
+
episodeId: opts.episodeId,
|
|
509
|
+
files: manifestFiles,
|
|
510
|
+
prediction: opts.prediction,
|
|
511
|
+
deltaStats,
|
|
512
|
+
};
|
|
513
|
+
await appendLedgerEntry(layout, entry);
|
|
514
|
+
return entry;
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
for (const w of written) {
|
|
518
|
+
try {
|
|
519
|
+
await writeFileAtomic(w.abs, w.before);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// best-effort restore; surface the original error
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
await fs.rm(policySnapshotDir(layout, opts.targetId, newVersion), {
|
|
527
|
+
recursive: true,
|
|
528
|
+
force: true,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// ignore
|
|
533
|
+
}
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Restore the live policy files byte-for-byte from snapshot v<toVersion> and
|
|
539
|
+
* append a 'rollback' ledger entry. The restore is recorded as a NEW head
|
|
540
|
+
* version whose files equal the old snapshot (git-revert style) so the
|
|
541
|
+
* 单一血统 single lineage stays monotonic — the version axis never rewinds.
|
|
542
|
+
* Unlike advance, rollback does NOT require the live files to match the head:
|
|
543
|
+
* it is the recovery path, including for out-of-band live-file divergence.
|
|
544
|
+
*/
|
|
545
|
+
export async function rollbackPolicyVersion(opts) {
|
|
546
|
+
assertNonEmptyString(opts.targetId, 'targetId');
|
|
547
|
+
assertNonEmptyString(opts.episodeId, 'episodeId');
|
|
548
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
549
|
+
const layout = resolvePolicyLayout(repoRoot);
|
|
550
|
+
const entries = await readPolicyLedger(repoRoot, opts.targetId);
|
|
551
|
+
if (entries.length === 0) {
|
|
552
|
+
throw new Error(`Policy lineage for ${opts.targetId} is not initialized; run initPolicyLineage first.`);
|
|
553
|
+
}
|
|
554
|
+
const headVersion = entries[entries.length - 1].version;
|
|
555
|
+
if (!Number.isInteger(opts.toVersion) || opts.toVersion < 0 || opts.toVersion >= headVersion) {
|
|
556
|
+
throw new Error(`Cannot roll back ${opts.targetId} to v${opts.toVersion}: head is v${headVersion} ` +
|
|
557
|
+
`(toVersion must be an existing earlier version).`);
|
|
558
|
+
}
|
|
559
|
+
const restoreFiles = await readPolicySnapshotFiles(repoRoot, opts.targetId, opts.toVersion);
|
|
560
|
+
const headFiles = await readPolicySnapshotFiles(repoRoot, opts.targetId, headVersion);
|
|
561
|
+
const headByPath = new Map(headFiles.map((f) => [f.relPath, f.content]));
|
|
562
|
+
const changedVsHead = restoreFiles.filter((f) => headByPath.get(f.relPath) !== f.content);
|
|
563
|
+
const deltaPatch = changedVsHead
|
|
564
|
+
.map((f) => renderFileDelta(f.relPath, headByPath.get(f.relPath) ?? '', f.content))
|
|
565
|
+
.join('\n');
|
|
566
|
+
const deltaStats = countDeltaStats(deltaPatch, changedVsHead.length);
|
|
567
|
+
const newVersion = headVersion + 1;
|
|
568
|
+
const at = new Date().toISOString();
|
|
569
|
+
// Snapshot-then-write, same order and fail-closed guard as advance. The
|
|
570
|
+
// pre-images captured here are whatever is live right now (possibly
|
|
571
|
+
// diverged), so a mid-restore failure puts back exactly what it found.
|
|
572
|
+
const manifestFiles = await writeSnapshot(layout, {
|
|
573
|
+
targetId: opts.targetId,
|
|
574
|
+
version: newVersion,
|
|
575
|
+
at,
|
|
576
|
+
files: restoreFiles,
|
|
577
|
+
deltaPatch,
|
|
578
|
+
});
|
|
579
|
+
const written = [];
|
|
580
|
+
try {
|
|
581
|
+
for (const f of restoreFiles) {
|
|
582
|
+
const abs = path.resolve(repoRoot, ...f.relPath.split('/'));
|
|
583
|
+
assertWithinRepo(repoRoot, abs);
|
|
584
|
+
let before;
|
|
585
|
+
try {
|
|
586
|
+
before = await fs.readFile(abs, 'utf8');
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
if (err.code !== 'ENOENT')
|
|
590
|
+
throw err;
|
|
591
|
+
before = null;
|
|
592
|
+
}
|
|
593
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
594
|
+
await writeFileAtomic(abs, f.content);
|
|
595
|
+
written.push({ abs, before });
|
|
596
|
+
}
|
|
597
|
+
const entry = {
|
|
598
|
+
schemaVersion: 1,
|
|
599
|
+
version: newVersion,
|
|
600
|
+
targetId: opts.targetId,
|
|
601
|
+
at,
|
|
602
|
+
action: 'rollback',
|
|
603
|
+
episodeId: opts.episodeId,
|
|
604
|
+
files: manifestFiles,
|
|
605
|
+
...(opts.advantage !== undefined ? { advantage: opts.advantage } : {}),
|
|
606
|
+
deltaStats,
|
|
607
|
+
};
|
|
608
|
+
await appendLedgerEntry(layout, entry);
|
|
609
|
+
return entry;
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
for (const w of written) {
|
|
613
|
+
try {
|
|
614
|
+
if (w.before !== null)
|
|
615
|
+
await writeFileAtomic(w.abs, w.before);
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// best-effort restore; surface the original error
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
await fs.rm(policySnapshotDir(layout, opts.targetId, newVersion), {
|
|
623
|
+
recursive: true,
|
|
624
|
+
force: true,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// ignore
|
|
629
|
+
}
|
|
630
|
+
throw err;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Record an episode where the 演进智能体 EVOLVING AGENT refused to edit. The
|
|
635
|
+
* version does NOT bump (vN+1 ≡ vN) — this entry is exactly what the
|
|
636
|
+
* CRITIC AGENT(基线智能体 baseline agent)'s skip condition reads: the policy
|
|
637
|
+
* is unchanged, so rerunning the baseline arm would compare a policy against
|
|
638
|
+
* itself. No files are touched.
|
|
639
|
+
*/
|
|
640
|
+
export async function recordEvolutionRefused(opts) {
|
|
641
|
+
assertNonEmptyString(opts.targetId, 'targetId');
|
|
642
|
+
assertNonEmptyString(opts.episodeId, 'episodeId');
|
|
643
|
+
assertNonEmptyString(opts.reason, 'reason');
|
|
644
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
645
|
+
const layout = resolvePolicyLayout(repoRoot);
|
|
646
|
+
const entries = await readPolicyLedger(repoRoot, opts.targetId);
|
|
647
|
+
if (entries.length === 0) {
|
|
648
|
+
throw new Error(`Policy lineage for ${opts.targetId} is not initialized; run initPolicyLineage first.`);
|
|
649
|
+
}
|
|
650
|
+
const head = entries[entries.length - 1];
|
|
651
|
+
const entry = {
|
|
652
|
+
schemaVersion: 1,
|
|
653
|
+
version: head.version, // unchanged: vN+1 ≡ vN
|
|
654
|
+
targetId: opts.targetId,
|
|
655
|
+
at: new Date().toISOString(),
|
|
656
|
+
action: 'refused',
|
|
657
|
+
episodeId: opts.episodeId,
|
|
658
|
+
files: head.files,
|
|
659
|
+
reason: opts.reason,
|
|
660
|
+
};
|
|
661
|
+
await appendLedgerEntry(layout, entry);
|
|
662
|
+
return entry;
|
|
663
|
+
}
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// One in-flight episode per target
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
function isValidInFlightEntry(value) {
|
|
668
|
+
if (!value || typeof value !== 'object')
|
|
669
|
+
return false;
|
|
670
|
+
const e = value;
|
|
671
|
+
return (typeof e.targetId === 'string' &&
|
|
672
|
+
e.targetId.length > 0 &&
|
|
673
|
+
typeof e.episodeId === 'string' &&
|
|
674
|
+
e.episodeId.length > 0 &&
|
|
675
|
+
typeof e.sinceVersion === 'number' &&
|
|
676
|
+
Number.isInteger(e.sinceVersion) &&
|
|
677
|
+
e.sinceVersion >= 0 &&
|
|
678
|
+
typeof e.startedAt === 'string');
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Read the in-flight map (targetId → entry). A missing or unparseable file
|
|
682
|
+
* reads as empty: the staleness window — not the file's integrity — is the
|
|
683
|
+
* real liveness guarantee, so a corrupt lock file must not wedge the loop.
|
|
684
|
+
*/
|
|
685
|
+
async function readInFlightMap(layout) {
|
|
686
|
+
let raw;
|
|
687
|
+
try {
|
|
688
|
+
raw = await fs.readFile(layout.inFlightPath, 'utf8');
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
if (err.code === 'ENOENT')
|
|
692
|
+
return {};
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
let parsed;
|
|
696
|
+
try {
|
|
697
|
+
parsed = JSON.parse(raw);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return {};
|
|
701
|
+
}
|
|
702
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
703
|
+
return {};
|
|
704
|
+
const out = {};
|
|
705
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
706
|
+
if (isValidInFlightEntry(value))
|
|
707
|
+
out[key] = value;
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
async function writeInFlightMap(layout, map) {
|
|
712
|
+
await fs.mkdir(layout.baseDir, { recursive: true });
|
|
713
|
+
await writeFileAtomic(layout.inFlightPath, `${JSON.stringify(map, null, 2)}\n`);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Claim the one in-flight slot for a target. Throws while a NON-stale entry
|
|
717
|
+
* exists for the target (one in-flight per target); an entry whose
|
|
718
|
+
* `startedAt` is ≥ {@link IN_FLIGHT_STALE_MS} old is stale and its slot is
|
|
719
|
+
* reclaimed (the abandoned episode's worktree is 产物即弃 — worktree
|
|
720
|
+
* artifacts discarded). Records `sinceVersion` = the lineage head at acquire
|
|
721
|
+
* time, so the episode knows which policy version it started from.
|
|
722
|
+
*/
|
|
723
|
+
export async function acquireInFlight(opts) {
|
|
724
|
+
assertNonEmptyString(opts.targetId, 'targetId');
|
|
725
|
+
assertNonEmptyString(opts.episodeId, 'episodeId');
|
|
726
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
727
|
+
const layout = resolvePolicyLayout(repoRoot);
|
|
728
|
+
const now = opts.now ?? new Date();
|
|
729
|
+
const map = await readInFlightMap(layout);
|
|
730
|
+
const existing = map[opts.targetId];
|
|
731
|
+
if (existing) {
|
|
732
|
+
const startedMs = Date.parse(existing.startedAt);
|
|
733
|
+
const stale = !Number.isFinite(startedMs) || now.getTime() - startedMs >= IN_FLIGHT_STALE_MS;
|
|
734
|
+
if (!stale) {
|
|
735
|
+
throw new Error(`An episode is already in flight for ${opts.targetId} ` +
|
|
736
|
+
`(episode ${existing.episodeId}, started ${existing.startedAt}); one in-flight per target.`);
|
|
737
|
+
}
|
|
738
|
+
// Stale: reclaim the slot below.
|
|
739
|
+
}
|
|
740
|
+
const sinceVersion = await currentPolicyVersion(repoRoot, opts.targetId);
|
|
741
|
+
if (sinceVersion === null) {
|
|
742
|
+
throw new Error(`Cannot acquire in-flight for ${opts.targetId}: policy lineage not initialized (run initPolicyLineage first).`);
|
|
743
|
+
}
|
|
744
|
+
const entry = {
|
|
745
|
+
targetId: opts.targetId,
|
|
746
|
+
episodeId: opts.episodeId,
|
|
747
|
+
sinceVersion,
|
|
748
|
+
startedAt: now.toISOString(),
|
|
749
|
+
};
|
|
750
|
+
map[opts.targetId] = entry;
|
|
751
|
+
await writeInFlightMap(layout, map);
|
|
752
|
+
return entry;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Release a target's in-flight slot. Idempotent when no entry exists; throws
|
|
756
|
+
* when the slot is held by a DIFFERENT episode (an episode may only release
|
|
757
|
+
* its own claim).
|
|
758
|
+
*/
|
|
759
|
+
export async function releaseInFlight(opts) {
|
|
760
|
+
assertNonEmptyString(opts.targetId, 'targetId');
|
|
761
|
+
assertNonEmptyString(opts.episodeId, 'episodeId');
|
|
762
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
763
|
+
const layout = resolvePolicyLayout(repoRoot);
|
|
764
|
+
const map = await readInFlightMap(layout);
|
|
765
|
+
const existing = map[opts.targetId];
|
|
766
|
+
if (!existing)
|
|
767
|
+
return; // idempotent: nothing to release
|
|
768
|
+
if (existing.episodeId !== opts.episodeId) {
|
|
769
|
+
throw new Error(`Refusing to release in-flight for ${opts.targetId}: held by episode ${existing.episodeId}, not ${opts.episodeId}.`);
|
|
770
|
+
}
|
|
771
|
+
delete map[opts.targetId];
|
|
772
|
+
await writeInFlightMap(layout, map);
|
|
773
|
+
}
|
|
774
|
+
//# sourceMappingURL=policy-store.js.map
|