@lmzhen/dsh-evolution-merge 0.30.10 → 0.30.12
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/lib/index.js +162 -77
- package/lib/types/approve-command.js +49 -36
- package/lib/types/commit-recovery.js +8 -1
- package/lib/types/index.js +4 -0
- package/lib/types/runtime.d.ts +2 -0
- package/lib/types/runtime.js +6 -1
- package/lib/types/scan-run.js +7 -6
- package/lib/types/state.d.ts +1 -1
- package/lib/types/state.js +4 -1
- package/lib/types/target-claim.d.ts +7 -0
- package/lib/types/target-claim.js +46 -0
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
3
3
|
import { pairKeyOf, screen, selectCandidates, toEngineCandidate } from "@lmzhen/dsh-evolution-merge-engine";
|
|
4
4
|
import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, skillsRoot, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
|
|
5
5
|
import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
//#region lib/types/adapters/select.js
|
|
8
8
|
/** Host selector correction: engine selector does not route judge errors to candidates. */
|
|
9
9
|
function selectWithErrors(report) {
|
|
@@ -345,10 +345,17 @@ async function mutateMergeState(io, home, now, fn) {
|
|
|
345
345
|
});
|
|
346
346
|
return result;
|
|
347
347
|
}
|
|
348
|
-
async function upsertRow(io, home, now, row, expect = "any") {
|
|
348
|
+
async function upsertRow(io, home, now, row, expect = "any", cas) {
|
|
349
349
|
return await mutateMergeState(io, home, now, (rows) => {
|
|
350
350
|
const index = rows.findIndex((item) => item.pairKey === row.pairKey);
|
|
351
351
|
const current = index < 0 ? void 0 : rows[index];
|
|
352
|
+
if (cas !== void 0 && (cas === null && current !== void 0 || cas !== null && current?.updatedAt !== cas)) return {
|
|
353
|
+
rows,
|
|
354
|
+
result: {
|
|
355
|
+
ok: false,
|
|
356
|
+
current: current ?? row
|
|
357
|
+
}
|
|
358
|
+
};
|
|
352
359
|
if (current !== void 0 && expect !== "any" && (expect === "absent" || queueOf(current.state) !== expect)) return {
|
|
353
360
|
rows,
|
|
354
361
|
result: {
|
|
@@ -799,7 +806,18 @@ async function recoverCommitJournals(io, home, now, skillRoot, restoreSource, is
|
|
|
799
806
|
});
|
|
800
807
|
continue;
|
|
801
808
|
}
|
|
802
|
-
|
|
809
|
+
let snapshot;
|
|
810
|
+
try {
|
|
811
|
+
snapshot = await readCommitSnapshot(io, home, journal.runId);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
reports.push({
|
|
814
|
+
runId: journal.runId,
|
|
815
|
+
phase: journal.phase,
|
|
816
|
+
action: "snapshot-read-failed: " + (error instanceof Error ? error.message : String(error)),
|
|
817
|
+
ok: false
|
|
818
|
+
});
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
803
821
|
let action = "report-only";
|
|
804
822
|
let ok = true;
|
|
805
823
|
if (recovery === "retry-commit") action = "no-host-write";
|
|
@@ -1779,16 +1797,16 @@ async function runScan(options) {
|
|
|
1779
1797
|
});
|
|
1780
1798
|
if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
|
|
1781
1799
|
row = created.row;
|
|
1782
|
-
const
|
|
1783
|
-
|
|
1784
|
-
|
|
1800
|
+
const before = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.pairKey === row.pairKey);
|
|
1801
|
+
const placed = await upsertRow(options.io, options.home, options.now, row, "verify", before?.updatedAt ?? null);
|
|
1802
|
+
if (!placed.ok && before?.state === "skipped") {
|
|
1803
|
+
const restored = transition(before, {
|
|
1785
1804
|
type: "restore",
|
|
1786
1805
|
at: at(options.now)
|
|
1787
1806
|
});
|
|
1788
1807
|
if (!restored.ok || restored.row === null) throw new Error("cannot restore skipped pair");
|
|
1808
|
+
if (!(await upsertRow(options.io, options.home, options.now, restored.row, "any", before.updatedAt)).ok) throw new Error("pair changed while restoring skipped row");
|
|
1789
1809
|
row = restored.row;
|
|
1790
|
-
const retried = await upsertRow(options.io, options.home, options.now, row, "verify");
|
|
1791
|
-
if (!retried.ok) throw new Error("pair already in " + retried.current.state);
|
|
1792
1810
|
} else if (!placed.ok) throw new Error("pair already in " + placed.current.state);
|
|
1793
1811
|
} catch (error) {
|
|
1794
1812
|
totals.failed += 1;
|
|
@@ -2216,6 +2234,56 @@ async function rejectPending(io, home, now, id) {
|
|
|
2216
2234
|
return await act(io, home, now, id, "reject");
|
|
2217
2235
|
}
|
|
2218
2236
|
//#endregion
|
|
2237
|
+
//#region lib/types/domain/claim.js
|
|
2238
|
+
function acquireClaim(current, now, leaseMs, token) {
|
|
2239
|
+
if (current !== void 0 && current.expiresAt > now) return void 0;
|
|
2240
|
+
return {
|
|
2241
|
+
id: "claim",
|
|
2242
|
+
token,
|
|
2243
|
+
expiresAt: now + Math.max(1, leaseMs)
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
function releaseClaim(current, token) {
|
|
2247
|
+
return current?.token === token ? void 0 : current;
|
|
2248
|
+
}
|
|
2249
|
+
//#endregion
|
|
2250
|
+
//#region lib/types/target-claim.js
|
|
2251
|
+
/** Durable target claim: one approve writer per merge target at a time. */
|
|
2252
|
+
const LEASE_MS = 5 * 6e4;
|
|
2253
|
+
function claimPath(home, target) {
|
|
2254
|
+
return join(home, "merge", "claims", contentHash(target) + ".json");
|
|
2255
|
+
}
|
|
2256
|
+
async function readClaim(io, home, target) {
|
|
2257
|
+
const path = claimPath(home, target);
|
|
2258
|
+
if (!await io.exists(path)) return void 0;
|
|
2259
|
+
const raw = await io.readText(path);
|
|
2260
|
+
if (raw === null) return void 0;
|
|
2261
|
+
const parsed = JSON.parse(raw);
|
|
2262
|
+
if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") throw new Error("corrupt target claim: " + target);
|
|
2263
|
+
return {
|
|
2264
|
+
id: "claim",
|
|
2265
|
+
token: parsed.token,
|
|
2266
|
+
expiresAt: parsed.expiresAt
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
async function acquireTargetClaim(io, home, target, now) {
|
|
2270
|
+
const current = await readClaim(io, home, target);
|
|
2271
|
+
const token = randomUUID();
|
|
2272
|
+
const claim = acquireClaim(current, now, LEASE_MS, token);
|
|
2273
|
+
if (claim === void 0) return null;
|
|
2274
|
+
await io.writeText(claimPath(home, target), JSON.stringify(claim) + "\n");
|
|
2275
|
+
return {
|
|
2276
|
+
token,
|
|
2277
|
+
async release() {
|
|
2278
|
+
const next = releaseClaim(await readClaim(io, home, target), token);
|
|
2279
|
+
if (next === void 0) try {
|
|
2280
|
+
await io.remove(claimPath(home, target));
|
|
2281
|
+
} catch {}
|
|
2282
|
+
else await io.writeText(claimPath(home, target), JSON.stringify(next) + "\n");
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
//#endregion
|
|
2219
2287
|
//#region lib/types/round2-outcome.js
|
|
2220
2288
|
async function recordRound2Outcome(io, home, now, row, outcome) {
|
|
2221
2289
|
const at = new Date(now()).toISOString();
|
|
@@ -2289,79 +2357,89 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
|
|
|
2289
2357
|
//#endregion
|
|
2290
2358
|
//#region lib/types/approve-command.js
|
|
2291
2359
|
async function runApproveCommand(options) {
|
|
2292
|
-
const
|
|
2293
|
-
|
|
2360
|
+
const target = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId)?.decision?.target;
|
|
2361
|
+
const claim = target === void 0 ? null : await acquireTargetClaim(options.io, options.home, target, options.now());
|
|
2362
|
+
if (target !== void 0 && claim === null) return {
|
|
2294
2363
|
ok: false,
|
|
2295
|
-
message:
|
|
2364
|
+
message: "target already claimed by another approve: " + target
|
|
2296
2365
|
};
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2366
|
+
try {
|
|
2367
|
+
const taken = await approvePending(options.io, options.home, options.now, options.rowId);
|
|
2368
|
+
if (!taken.ok) return {
|
|
2369
|
+
ok: false,
|
|
2370
|
+
message: taken.message
|
|
2371
|
+
};
|
|
2372
|
+
const row = taken.row;
|
|
2373
|
+
const decision = row.decision;
|
|
2374
|
+
if (decision === void 0) return {
|
|
2375
|
+
ok: false,
|
|
2376
|
+
message: "pending row has no decision"
|
|
2377
|
+
};
|
|
2378
|
+
return await withMergeRun({
|
|
2379
|
+
io: options.io,
|
|
2380
|
+
home: options.home,
|
|
2381
|
+
registry: options.registry,
|
|
2382
|
+
command: "approve",
|
|
2383
|
+
approval: {
|
|
2384
|
+
platformWrite: options.platformWrite,
|
|
2385
|
+
mergeApproval: true
|
|
2386
|
+
},
|
|
2387
|
+
now: options.now,
|
|
2388
|
+
async body(ctx) {
|
|
2389
|
+
const stage = (name) => ctx.stage(name, {
|
|
2390
|
+
done: 0,
|
|
2391
|
+
total: 1
|
|
2392
|
+
});
|
|
2393
|
+
await stage("author");
|
|
2394
|
+
let outcome;
|
|
2395
|
+
try {
|
|
2396
|
+
outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
outcome = {
|
|
2399
|
+
kind: "failed",
|
|
2400
|
+
failure: {
|
|
2401
|
+
stage: "author",
|
|
2402
|
+
code: "merge-failed",
|
|
2403
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2404
|
+
retryable: true,
|
|
2405
|
+
retryFrom: "agent2"
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
const base = {
|
|
2410
|
+
scope: {
|
|
2411
|
+
candidatePairs: 1,
|
|
2412
|
+
queueBefore: {
|
|
2413
|
+
verify: 0,
|
|
2414
|
+
pending: 0,
|
|
2415
|
+
fuse: 1,
|
|
2416
|
+
skipped: 0
|
|
2417
|
+
}
|
|
2418
|
+
},
|
|
2419
|
+
notes: []
|
|
2332
2420
|
};
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2421
|
+
const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
|
|
2422
|
+
return {
|
|
2423
|
+
...base,
|
|
2424
|
+
stopReason: record.merged ? "completed" : "failed",
|
|
2425
|
+
pairs: [record.pair],
|
|
2426
|
+
totals: record.merged ? {
|
|
2427
|
+
merged: 1,
|
|
2428
|
+
kept: 0,
|
|
2339
2429
|
pending: 0,
|
|
2340
|
-
|
|
2341
|
-
|
|
2430
|
+
failed: 0
|
|
2431
|
+
} : {
|
|
2432
|
+
merged: 0,
|
|
2433
|
+
kept: 0,
|
|
2434
|
+
pending: 0,
|
|
2435
|
+
failed: 1
|
|
2342
2436
|
}
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
stopReason: record.merged ? "completed" : "failed",
|
|
2350
|
-
pairs: [record.pair],
|
|
2351
|
-
totals: record.merged ? {
|
|
2352
|
-
merged: 1,
|
|
2353
|
-
kept: 0,
|
|
2354
|
-
pending: 0,
|
|
2355
|
-
failed: 0
|
|
2356
|
-
} : {
|
|
2357
|
-
merged: 0,
|
|
2358
|
-
kept: 0,
|
|
2359
|
-
pending: 0,
|
|
2360
|
-
failed: 1
|
|
2361
|
-
}
|
|
2362
|
-
};
|
|
2363
|
-
}
|
|
2364
|
-
});
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
} finally {
|
|
2441
|
+
await claim?.release();
|
|
2442
|
+
}
|
|
2365
2443
|
}
|
|
2366
2444
|
//#endregion
|
|
2367
2445
|
//#region lib/types/resume-command.js
|
|
@@ -3709,6 +3787,10 @@ function createMergeRuntime(deps) {
|
|
|
3709
3787
|
ok: false,
|
|
3710
3788
|
text: "merge pair 不接受受保护/保护状态未知技能: " + command.a + " / " + command.b
|
|
3711
3789
|
};
|
|
3790
|
+
if (deps.isBlocked?.(command.a) === true || deps.isBlocked?.(command.b) === true) return {
|
|
3791
|
+
ok: false,
|
|
3792
|
+
text: "merge pair 不接受被范围门拦下的技能: " + command.a + " / " + command.b
|
|
3793
|
+
};
|
|
3712
3794
|
const pair = [{
|
|
3713
3795
|
a: command.a,
|
|
3714
3796
|
b: command.b
|
|
@@ -3775,7 +3857,7 @@ function createMergeRuntime(deps) {
|
|
|
3775
3857
|
if (command.verb === "scan") {
|
|
3776
3858
|
const signal = new AbortController().signal;
|
|
3777
3859
|
const cards = await deps.cards(signal);
|
|
3778
|
-
const managed = new Set(cards.filter((card) => card.managed === true && card.protectedBy == null).map((card) => card.name));
|
|
3860
|
+
const managed = new Set(cards.filter((card) => card.managed === true && card.protectedBy == null && deps.isBlocked?.(card.name) !== true).map((card) => card.name));
|
|
3779
3861
|
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
3780
3862
|
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
3781
3863
|
const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || row.queue === "verify" && !canRetry(row)).map((row) => row.pairKey));
|
|
@@ -4713,6 +4795,9 @@ function apply(ctx, rawConfig = {}) {
|
|
|
4713
4795
|
parent: void 0,
|
|
4714
4796
|
allow: ["merge_stage"],
|
|
4715
4797
|
timeoutMinutes: params.mergeRunTimeoutMinutes,
|
|
4798
|
+
isBlocked: (name) => {
|
|
4799
|
+
return ctx.get("evolutionCurator")?.gates?.isBlocked?.(name) === true;
|
|
4800
|
+
},
|
|
4716
4801
|
async cards(signal) {
|
|
4717
4802
|
const listed = await skills.list({ withContent: true });
|
|
4718
4803
|
if (signal.aborted) return [];
|
|
@@ -1,42 +1,55 @@
|
|
|
1
1
|
import { approvePending } from "./pending-actions.js";
|
|
2
|
+
import { loadMergeState } from "./state.js";
|
|
3
|
+
import { acquireTargetClaim } from "./target-claim.js";
|
|
2
4
|
import { withMergeRun } from "./run-shell.js";
|
|
3
5
|
import { recordRound2Outcome } from "./round2-outcome.js";
|
|
4
6
|
export async function runApproveCommand(options) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
7
|
+
const loaded = await loadMergeState(options.io, options.home);
|
|
8
|
+
const before = loaded.state.rows.find(item => item.id === options.rowId);
|
|
9
|
+
const target = before?.decision?.target;
|
|
10
|
+
const claim = target === undefined ? null : await acquireTargetClaim(options.io, options.home, target, options.now());
|
|
11
|
+
if (target !== undefined && claim === null)
|
|
12
|
+
return { ok: false, message: 'target already claimed by another approve: ' + target };
|
|
13
|
+
try {
|
|
14
|
+
const taken = await approvePending(options.io, options.home, options.now, options.rowId);
|
|
15
|
+
if (!taken.ok)
|
|
16
|
+
return { ok: false, message: taken.message };
|
|
17
|
+
const row = taken.row;
|
|
18
|
+
const decision = row.decision;
|
|
19
|
+
if (decision === undefined)
|
|
20
|
+
return { ok: false, message: 'pending row has no decision' };
|
|
21
|
+
return await withMergeRun({
|
|
22
|
+
io: options.io,
|
|
23
|
+
home: options.home,
|
|
24
|
+
registry: options.registry,
|
|
25
|
+
command: 'approve',
|
|
26
|
+
approval: { platformWrite: options.platformWrite, mergeApproval: true },
|
|
27
|
+
now: options.now,
|
|
28
|
+
async body(ctx) {
|
|
29
|
+
const stage = (name) => ctx.stage(name, { done: 0, total: 1 });
|
|
30
|
+
await stage('author');
|
|
31
|
+
let outcome;
|
|
32
|
+
try {
|
|
33
|
+
outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
outcome = { kind: 'failed', failure: { stage: 'author', code: 'merge-failed', message: error instanceof Error ? error.message : String(error), retryable: true, retryFrom: 'agent2' } };
|
|
37
|
+
}
|
|
38
|
+
const base = { scope: { candidatePairs: 1, queueBefore: { verify: 0, pending: 0, fuse: 1, skipped: 0 } }, notes: [] };
|
|
39
|
+
const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
stopReason: record.merged ? 'completed' : 'failed',
|
|
43
|
+
pairs: [record.pair],
|
|
44
|
+
totals: record.merged
|
|
45
|
+
? { merged: 1, kept: 0, pending: 0, failed: 0 }
|
|
46
|
+
: { merged: 0, kept: 0, pending: 0, failed: 1 },
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await claim?.release();
|
|
53
|
+
}
|
|
41
54
|
}
|
|
42
55
|
//# sourceMappingURL=approve-command.js.map
|
|
@@ -42,7 +42,14 @@ export async function recoverCommitJournals(io, home, now, skillRoot, restoreSou
|
|
|
42
42
|
reports.push({ runId: journal.runId, phase: journal.phase, action: 'receipt-committed', ok: true });
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
let snapshot;
|
|
46
|
+
try {
|
|
47
|
+
snapshot = await readCommitSnapshot(io, home, journal.runId);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
reports.push({ runId: journal.runId, phase: journal.phase, action: 'snapshot-read-failed: ' + (error instanceof Error ? error.message : String(error)), ok: false });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
46
53
|
let action = 'report-only';
|
|
47
54
|
let ok = true;
|
|
48
55
|
if (recovery === 'retry-commit') {
|
package/lib/types/index.js
CHANGED
|
@@ -218,6 +218,10 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
218
218
|
const runtime = createMergeRuntime({
|
|
219
219
|
io, home, registry: runs, skills, now: () => Date.now(), params, platformWrite: true,
|
|
220
220
|
channel, parent: undefined, allow: ['merge_stage'], timeoutMinutes: params.mergeRunTimeoutMinutes,
|
|
221
|
+
isBlocked: (name) => {
|
|
222
|
+
const curator = ctx.get('evolutionCurator');
|
|
223
|
+
return curator?.gates?.isBlocked?.(name) === true;
|
|
224
|
+
},
|
|
221
225
|
async cards(signal) {
|
|
222
226
|
const listed = await skills.list({ withContent: true });
|
|
223
227
|
if (signal.aborted)
|
package/lib/types/runtime.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface MergeRuntimeDeps {
|
|
|
31
31
|
/** Start scan/pair in the background and return the run id immediately. */
|
|
32
32
|
readonly detached?: boolean;
|
|
33
33
|
readonly cards: (signal: AbortSignal) => Promise<readonly SkillCard[]>;
|
|
34
|
+
/** Curator gate set: excluded/referenced/suppressed/protected builtins. */
|
|
35
|
+
readonly isBlocked?: (name: string) => boolean;
|
|
34
36
|
/** Run-scoped engine screening. */
|
|
35
37
|
readonly screeningForRun: (cards: readonly SkillCard[], runId: string, signal: AbortSignal) => Promise<readonly MergeCandidate[]>;
|
|
36
38
|
readonly stage?: MergeStageBindingPort;
|
package/lib/types/runtime.js
CHANGED
|
@@ -59,6 +59,9 @@ export function createMergeRuntime(deps) {
|
|
|
59
59
|
|| cardA.protectionUnknown === true || cardB.protectionUnknown === true) {
|
|
60
60
|
return { ok: false, text: 'merge pair 不接受受保护/保护状态未知技能: ' + command.a + ' / ' + command.b };
|
|
61
61
|
}
|
|
62
|
+
if (deps.isBlocked?.(command.a) === true || deps.isBlocked?.(command.b) === true) {
|
|
63
|
+
return { ok: false, text: 'merge pair 不接受被范围门拦下的技能: ' + command.a + ' / ' + command.b };
|
|
64
|
+
}
|
|
62
65
|
const pair = [{ a: command.a, b: command.b }];
|
|
63
66
|
const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
|
|
64
67
|
if (existing?.state === 'skipped') {
|
|
@@ -101,7 +104,9 @@ export function createMergeRuntime(deps) {
|
|
|
101
104
|
if (command.verb === 'scan') {
|
|
102
105
|
const signal = new AbortController().signal;
|
|
103
106
|
const cards = await deps.cards(signal);
|
|
104
|
-
const managed = new Set(cards
|
|
107
|
+
const managed = new Set(cards
|
|
108
|
+
.filter(card => card.managed === true && card.protectedBy == null && deps.isBlocked?.(card.name) !== true)
|
|
109
|
+
.map(card => card.name));
|
|
105
110
|
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
106
111
|
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
107
112
|
const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || (row.queue === 'verify' && !canRetry(row))).map(row => row.pairKey));
|
package/lib/types/scan-run.js
CHANGED
|
@@ -65,15 +65,16 @@ export async function runScan(options) {
|
|
|
65
65
|
if (!created.ok || created.row === null)
|
|
66
66
|
throw new Error(created.ok ? 'candidate row missing' : created.message);
|
|
67
67
|
row = created.row;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
const before = (await loadMergeState(options.io, options.home)).state.rows.find(item => item.pairKey === row.pairKey);
|
|
69
|
+
const placed = await upsertRow(options.io, options.home, options.now, row, 'verify', before?.updatedAt ?? null);
|
|
70
|
+
if (!placed.ok && before?.state === 'skipped') {
|
|
71
|
+
const restored = transition(before, { type: 'restore', at: at(options.now) });
|
|
71
72
|
if (!restored.ok || restored.row === null)
|
|
72
73
|
throw new Error('cannot restore skipped pair');
|
|
74
|
+
const persisted = await upsertRow(options.io, options.home, options.now, restored.row, 'any', before.updatedAt);
|
|
75
|
+
if (!persisted.ok)
|
|
76
|
+
throw new Error('pair changed while restoring skipped row');
|
|
73
77
|
row = restored.row;
|
|
74
|
-
const retried = await upsertRow(options.io, options.home, options.now, row, 'verify');
|
|
75
|
-
if (!retried.ok)
|
|
76
|
-
throw new Error('pair already in ' + retried.current.state);
|
|
77
78
|
}
|
|
78
79
|
else if (!placed.ok) {
|
|
79
80
|
throw new Error('pair already in ' + placed.current.state);
|
package/lib/types/state.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type UpsertResult = {
|
|
|
29
29
|
readonly ok: false;
|
|
30
30
|
readonly current: MergeRow;
|
|
31
31
|
};
|
|
32
|
-
export declare function upsertRow(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow, expect?: UpsertExpect): Promise<UpsertResult>;
|
|
32
|
+
export declare function upsertRow(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow, expect?: UpsertExpect, cas?: string | null): Promise<UpsertResult>;
|
|
33
33
|
export declare function moveRow(io: EvolutionIoLike, home: string, now: () => number, id: string, patch: MoveRowPatch): Promise<MergeRow | undefined>;
|
|
34
34
|
export declare function dropRow(io: EvolutionIoLike, home: string, now: () => number, id: string): Promise<boolean>;
|
|
35
35
|
export declare function rowsOf(io: EvolutionIoLike, home: string, queue?: MergeQueue): Promise<readonly MergeRow[]>;
|
package/lib/types/state.js
CHANGED
|
@@ -55,10 +55,13 @@ export async function mutateMergeState(io, home, now, fn) {
|
|
|
55
55
|
void now;
|
|
56
56
|
return result;
|
|
57
57
|
}
|
|
58
|
-
export async function upsertRow(io, home, now, row, expect = 'any') {
|
|
58
|
+
export async function upsertRow(io, home, now, row, expect = 'any', cas) {
|
|
59
59
|
return await mutateMergeState(io, home, now, (rows) => {
|
|
60
60
|
const index = rows.findIndex(item => item.pairKey === row.pairKey);
|
|
61
61
|
const current = index < 0 ? undefined : rows[index];
|
|
62
|
+
if (cas !== undefined && ((cas === null && current !== undefined) || (cas !== null && current?.updatedAt !== cas))) {
|
|
63
|
+
return { rows, result: { ok: false, current: current ?? row } };
|
|
64
|
+
}
|
|
62
65
|
if (current !== undefined && expect !== 'any' && (expect === 'absent' || queueOf(current.state) !== expect)) {
|
|
63
66
|
return { rows, result: { ok: false, current } };
|
|
64
67
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
|
|
2
|
+
export interface TargetClaim {
|
|
3
|
+
readonly token: string;
|
|
4
|
+
release(): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function acquireTargetClaim(io: EvolutionIoLike, home: string, target: string, now: number): Promise<TargetClaim | null>;
|
|
7
|
+
//# sourceMappingURL=target-claim.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Durable target claim: one approve writer per merge target at a time. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { contentHash } from '@lmzhen/dsh-evolution-core';
|
|
5
|
+
import { acquireClaim, releaseClaim } from "./domain/claim.js";
|
|
6
|
+
const LEASE_MS = 5 * 60_000;
|
|
7
|
+
function claimPath(home, target) {
|
|
8
|
+
return join(home, 'merge', 'claims', contentHash(target) + '.json');
|
|
9
|
+
}
|
|
10
|
+
async function readClaim(io, home, target) {
|
|
11
|
+
const path = claimPath(home, target);
|
|
12
|
+
if (!(await io.exists(path)))
|
|
13
|
+
return undefined;
|
|
14
|
+
const raw = await io.readText(path);
|
|
15
|
+
if (raw === null)
|
|
16
|
+
return undefined;
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (typeof parsed.token !== 'string' || typeof parsed.expiresAt !== 'number')
|
|
19
|
+
throw new Error('corrupt target claim: ' + target);
|
|
20
|
+
return { id: 'claim', token: parsed.token, expiresAt: parsed.expiresAt };
|
|
21
|
+
}
|
|
22
|
+
export async function acquireTargetClaim(io, home, target, now) {
|
|
23
|
+
const current = await readClaim(io, home, target);
|
|
24
|
+
const token = randomUUID();
|
|
25
|
+
const claim = acquireClaim(current, now, LEASE_MS, token);
|
|
26
|
+
if (claim === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
await io.writeText(claimPath(home, target), JSON.stringify(claim) + '\n');
|
|
29
|
+
return {
|
|
30
|
+
token,
|
|
31
|
+
async release() {
|
|
32
|
+
const latest = await readClaim(io, home, target);
|
|
33
|
+
const next = releaseClaim(latest, token);
|
|
34
|
+
if (next === undefined) {
|
|
35
|
+
try {
|
|
36
|
+
await io.remove(claimPath(home, target));
|
|
37
|
+
}
|
|
38
|
+
catch { /* already gone */ }
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
await io.writeText(claimPath(home, target), JSON.stringify(next) + '\n');
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=target-claim.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmzhen/dsh-evolution-merge",
|
|
3
3
|
"description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
|
|
4
|
-
"version": "0.30.
|
|
4
|
+
"version": "0.30.12",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
30
|
-
"@lmzhen/dsh-evolution-core": "^0.30.
|
|
31
|
-
"@lmzhen/dsh-evolution-merge-engine": "^0.30.
|
|
30
|
+
"@lmzhen/dsh-evolution-core": "^0.30.12",
|
|
31
|
+
"@lmzhen/dsh-evolution-merge-engine": "^0.30.12"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@deepseek-ai/cordis": "^4.0.5"
|