@lmzhen/dsh-evolution-merge 0.44.0 → 0.46.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/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { dirname, isAbsolute, join } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { pairKeyOf, screen, selectCandidates } from "@lmzhen/dsh-evolution-merge-engine";
4
4
  import { createUserMessage, defineTool } from "@lmzhen/dsh-evolution-core/platform";
5
- import { DEFAULT_SKILL_LIMITS, LeaseStore, acquireTreeWriteLock, authorityFrom, contentHash, createSubagentChannel, errorText, evolutionHome, evolutionIoAdapter, isPresent, isProcessAlive, newRunRegistry, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, readTreeLock, registerOwnerSettings, reportsSweepLockTarget, runsFile, scanContentThreats, supportRefs, sweepReports, transactIo } from "@lmzhen/dsh-evolution-core";
5
+ import { DEFAULT_SKILL_LIMITS, HomeAuthority, LeaseStore, authorityFrom, authorityHolder, contentHash, createSubagentChannel, errorText, evolutionHome, evolutionIoAdapter, isPresent, isProcessAlive, leaseAsTreeLock, newRunRegistry, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, reportsSweepLockTarget, runsFile, scanContentThreats, supportRefs, sweepReports, transactIo } from "@lmzhen/dsh-evolution-core";
6
6
  import { SkillLibrary, newSkillLibrary, parseFrontmatter, revisionAnchorOf, revisionFingerprint, revisionSupportDigest, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core/skills";
7
7
  import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
8
8
  import { createHash, randomUUID } from "node:crypto";
@@ -3300,17 +3300,22 @@ var MergeUnitTransactions = class {
3300
3300
  lock = fenceLease.lock;
3301
3301
  this.options.onLock?.("tree");
3302
3302
  } else {
3303
- const acquired = await acquireTreeWriteLock({
3304
- io: this.options.io,
3303
+ const fallbackLease = await new HomeAuthority({
3305
3304
  home: this.options.home,
3306
3305
  waitMs: this.options.waitMs ?? 5e3
3307
- });
3308
- if (!acquired.ok) return {
3306
+ }).lease("tree").catch(() => void 0);
3307
+ if (fallbackLease === void 0) return {
3309
3308
  ok: false,
3310
3309
  code: "unit-fence-failed",
3311
- message: acquired.message ?? acquired.reason
3310
+ message: "tree lock unavailable"
3311
+ };
3312
+ fenceLease = {
3313
+ lock: leaseAsTreeLock(fallbackLease),
3314
+ release: async () => {
3315
+ await fallbackLease.release();
3316
+ }
3312
3317
  };
3313
- lock = acquired.lock;
3318
+ lock = fenceLease.lock;
3314
3319
  this.options.onLock?.("tree");
3315
3320
  }
3316
3321
  if (!await lock.assertHeld()) return {
@@ -3691,17 +3696,22 @@ var MergeUnitTransactions = class {
3691
3696
  fenceLease = await this.options.fence.acquire();
3692
3697
  lock = fenceLease.lock;
3693
3698
  } else {
3694
- const acquired = await acquireTreeWriteLock({
3695
- io: this.options.io,
3699
+ const fallbackLease = await new HomeAuthority({
3696
3700
  home: this.options.home,
3697
3701
  waitMs: this.options.waitMs ?? 5e3
3698
- });
3699
- if (!acquired.ok) return {
3702
+ }).lease("tree").catch(() => void 0);
3703
+ if (fallbackLease === void 0) return {
3700
3704
  ok: false,
3701
3705
  code: "unit-fence-failed",
3702
- message: acquired.message ?? acquired.reason
3706
+ message: "tree lock unavailable"
3703
3707
  };
3704
- lock = acquired.lock;
3708
+ fenceLease = {
3709
+ lock: leaseAsTreeLock(fallbackLease),
3710
+ release: async () => {
3711
+ await fallbackLease.release();
3712
+ }
3713
+ };
3714
+ lock = fenceLease.lock;
3705
3715
  }
3706
3716
  this.options.onLock?.("tree");
3707
3717
  if (!await lock.assertHeld()) return {
@@ -5025,12 +5035,12 @@ async function mergeDoctor(io, home, configuredRoot) {
5025
5035
  status: quarantineCount < 0 ? "unknown" : quarantineCount === 0 ? "ok" : "warn",
5026
5036
  detail: quarantineCount < 0 ? "quarantine 不可读" : quarantineCount + " 行待人工检查"
5027
5037
  });
5028
- const holder = await readTreeLock(io, home).catch(() => null);
5038
+ const holder = await authorityHolder(home).catch(() => null);
5029
5039
  rows.push({
5030
5040
  key: "tree-lock",
5031
5041
  label: "tree lock",
5032
5042
  status: holder == null ? "ok" : "warn",
5033
- detail: holder == null ? "free" : "holder pid=" + String(holder.pid) + " epoch=" + String(holder.epoch) + " token=" + holder.token.slice(0, 8)
5043
+ detail: holder == null ? "free" : "holder pid=" + String(holder.pid)
5034
5044
  });
5035
5045
  const backupPath = v2BackupPath(home);
5036
5046
  const backupRaw = await readTextOrNull(io, backupPath);
@@ -7860,20 +7870,23 @@ async function runResumeCommand(options) {
7860
7870
  }
7861
7871
  //#endregion
7862
7872
  //#region lib/types/undo-merge.js
7863
- async function undoMergeReceipt(receipt, port) {
7864
- const targetVersions = (await port.listVersions(receipt.target)).filter((entry) => entry.unitId === receipt.unitId);
7865
- if (targetVersions.length === 0) return {
7866
- ok: false,
7867
- message: "unit " + receipt.unitId + " is not in the target history; refusing to guess",
7868
- reverted: []
7869
- };
7870
- const members = [{
7871
- skill: receipt.target,
7872
- entries: targetVersions,
7873
- status: "live",
7874
- ...receipt.create ? { createdInUnit: true } : {}
7875
- }];
7876
- for (const source of receipt.sources) {
7873
+ async function undoMergeReceipt(receipt, port, options = {}) {
7874
+ const members = [];
7875
+ if (options.skipTarget !== true) {
7876
+ const targetVersions = (await port.listVersions(receipt.target)).filter((entry) => entry.unitId === receipt.unitId);
7877
+ if (targetVersions.length === 0) return {
7878
+ ok: false,
7879
+ message: "unit " + receipt.unitId + " is not in the target history; refusing to guess",
7880
+ reverted: []
7881
+ };
7882
+ members.push({
7883
+ skill: receipt.target,
7884
+ entries: targetVersions,
7885
+ status: "live",
7886
+ ...receipt.create ? { createdInUnit: true } : {}
7887
+ });
7888
+ }
7889
+ if (options.skipSources !== true) for (const source of receipt.sources) {
7877
7890
  const live = await port.readSkill(source);
7878
7891
  members.push({
7879
7892
  skill: source,
@@ -7882,6 +7895,11 @@ async function undoMergeReceipt(receipt, port) {
7882
7895
  ...live === null ? { archivedPath: "archive" } : {}
7883
7896
  });
7884
7897
  }
7898
+ if (members.length === 0) return {
7899
+ ok: true,
7900
+ message: "nothing left to restore",
7901
+ reverted: []
7902
+ };
7885
7903
  const plan = planUnitUndoAcross(members);
7886
7904
  if (plan.refusals.length > 0) return {
7887
7905
  ok: false,
@@ -7896,6 +7914,15 @@ async function undoMergeReceipt(receipt, port) {
7896
7914
  function undoJournalPath(home, runId) {
7897
7915
  return join(home, "merge", "undo-journal", encodeURIComponent(runId) + ".json");
7898
7916
  }
7917
+ async function readUndoJournal(io, home, runId) {
7918
+ const raw = await readTextOrNull(io, undoJournalPath(home, runId));
7919
+ if (raw === null) return null;
7920
+ try {
7921
+ return JSON.parse(raw);
7922
+ } catch {
7923
+ return null;
7924
+ }
7925
+ }
7899
7926
  async function writeUndoJournal(io, home, journal) {
7900
7927
  await transactIo(io, undoJournalPath(home, journal.runId), () => JSON.stringify(journal, null, 2) + "\n");
7901
7928
  }
@@ -7990,7 +8017,20 @@ async function settleRun(io, home, runId, pairKey, allPairs, at) {
7990
8017
  updatedAt: new Date(at).toISOString()
7991
8018
  });
7992
8019
  }
8020
+ /**
8021
+ * Run one merge-undo pass.
8022
+ *
8023
+ * The fence, when the caller supplies one, covers the WHOLE pass: undo writes the skill tree, the
8024
+ * undo receipt, the pair index and run metadata, so a pass that lost exclusion halfway would
8025
+ * interleave with the commit it is undoing (W9-commit-protocol §3.2). A partially applied pair is
8026
+ * reported as `resumable` rather than refused: the journal records the phase, and
8027
+ * `resumeUndo` continues from it.
8028
+ */
7993
8029
  async function runUndoOperation(options) {
8030
+ const run = async () => await runUndoSteps(options);
8031
+ return options.fence === void 0 ? await run() : await options.fence.run(run);
8032
+ }
8033
+ async function runUndoSteps(options) {
7994
8034
  const runReceipts = (await readCommitReceipts(options.io, options.home)).filter((receipt) => (receipt.parentRunId ?? receipt.runId) === options.runId);
7995
8035
  const allPairKeys = runReceipts.map((receipt) => receipt.pairKey ?? receipt.unitId);
7996
8036
  const allReceipts = runReceipts.filter((receipt) => options.target === void 0 || receipt.target === options.target);
@@ -8015,6 +8055,20 @@ async function runUndoOperation(options) {
8015
8055
  const undone = [];
8016
8056
  for (const receipt of sortReceipts(receipts)) {
8017
8057
  const pairKey = receipt.pairKey ?? receipt.unitId;
8058
+ /** Record the durable phase before the step it names (W9-commit-protocol §3.1). */
8059
+ const markPhase = async (next) => {
8060
+ const current = await readUndoJournal(options.io, options.home, options.runId);
8061
+ if (current === null) return;
8062
+ await writeUndoJournal(options.io, options.home, {
8063
+ ...current,
8064
+ phase: "running",
8065
+ pairs: current.pairs.map((pair) => pair.pairKey === pairKey ? {
8066
+ ...pair,
8067
+ phase: next
8068
+ } : pair),
8069
+ updatedAt: new Date(options.now()).toISOString()
8070
+ });
8071
+ };
8018
8072
  if (options.finalizeUnit !== void 0) {
8019
8073
  const finalized = await options.finalizeUnit(receipt.unitId);
8020
8074
  if (!finalized.ok) return {
@@ -8024,6 +8078,7 @@ async function runUndoOperation(options) {
8024
8078
  };
8025
8079
  }
8026
8080
  if (await readUndoReceipt(options.io, options.home, options.runId, pairKey) !== null) {
8081
+ await markPhase("pair-index");
8027
8082
  await options.onPhase?.("pair-index", pairKey);
8028
8083
  await recordPair(options.io, options.home, {
8029
8084
  pairKey,
@@ -8031,6 +8086,7 @@ async function runUndoOperation(options) {
8031
8086
  result: "undone",
8032
8087
  target: receipt.target
8033
8088
  });
8089
+ await markPhase("run-meta");
8034
8090
  await options.onPhase?.("run-meta", pairKey);
8035
8091
  await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
8036
8092
  undone.push(pairKey);
@@ -8056,6 +8112,7 @@ async function runUndoOperation(options) {
8056
8112
  restoredSourceHashes: Object.fromEntries(checked.evidence.sources.map((source) => [source.name, contentHash(source.before)])),
8057
8113
  at: options.now()
8058
8114
  });
8115
+ await markPhase("pair-index");
8059
8116
  await options.onPhase?.("pair-index", pairKey);
8060
8117
  await recordPair(options.io, options.home, {
8061
8118
  pairKey,
@@ -8063,21 +8120,15 @@ async function runUndoOperation(options) {
8063
8120
  result: "undone",
8064
8121
  target: receipt.target
8065
8122
  });
8123
+ await markPhase("run-meta");
8066
8124
  await options.onPhase?.("run-meta", pairKey);
8067
8125
  await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
8068
8126
  undone.push(pairKey);
8069
8127
  continue;
8070
8128
  }
8071
- if (!checked.targetDone || !checked.sourcesDone) {
8072
- const targetStage = checked.targetDone ? "skip" : "restore";
8073
- const sourceStage = checked.sourcesDone ? "skip" : "restore";
8074
- if (targetStage === "skip" && sourceStage === "skip") {}
8075
- }
8076
- if (checked.targetDone !== checked.sourcesDone && (checked.targetDone || checked.sourcesDone)) return {
8077
- ok: false,
8078
- code: "undo-partial-state",
8079
- message: "undo is partially applied; manual retry required",
8080
- undone
8129
+ const skip = {
8130
+ ...checked.targetDone ? { skipTarget: true } : {},
8131
+ ...checked.sourcesDone ? { skipSources: true } : {}
8081
8132
  };
8082
8133
  const phase = (next) => ({
8083
8134
  pairKey,
@@ -8092,11 +8143,12 @@ async function runUndoOperation(options) {
8092
8143
  updatedAt: new Date(options.now()).toISOString()
8093
8144
  });
8094
8145
  await options.onPhase?.("restore-target", pairKey);
8095
- const executed = await undoMergeReceipt(receipt, options.port);
8146
+ const executed = await undoMergeReceipt(receipt, options.port, skip);
8096
8147
  if (!executed.ok) return {
8097
8148
  ok: false,
8098
8149
  message: executed.message,
8099
- undone
8150
+ undone,
8151
+ resumable: true
8100
8152
  };
8101
8153
  await writeUndoJournal(options.io, options.home, {
8102
8154
  version: 1,
@@ -11325,7 +11377,6 @@ function formatUnreadyError() {
11325
11377
  }
11326
11378
  const inject = [];
11327
11379
  function apply(ctx, rawConfig = {}) {
11328
- registerOwnerSettings(ctx, "evolution-merge", Config);
11329
11380
  configureMergePresentation(ctx);
11330
11381
  const startupParams = resolveMergeParams(rawConfig);
11331
11382
  let mergeRun;
@@ -7,7 +7,7 @@ import { readTextOrNull } from "./domain/read-probe.js";
7
7
  import { createHash } from 'node:crypto';
8
8
  import { stat } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
- import { probeList, readTreeLock, runsFile } from '@lmzhen/dsh-evolution-core';
10
+ import { authorityHolder, probeList, runsFile } from '@lmzhen/dsh-evolution-core';
11
11
  import { readUnitJournal } from '@lmzhen/dsh-evolution-core/write';
12
12
  import { migrationJournalPath, migrationReportPath, v2BackupPath } from "./domain/state-bootstrap.js";
13
13
  import { readMergeSnapshot } from "./domain/merge-snapshot.js";
@@ -104,8 +104,8 @@ export async function mergeDoctor(io, home, configuredRoot) {
104
104
  quarantineCount = -1;
105
105
  }
106
106
  rows.push({ key: 'quarantine', label: 'quarantine', status: quarantineCount < 0 ? 'unknown' : quarantineCount === 0 ? 'ok' : 'warn', detail: quarantineCount < 0 ? 'quarantine 不可读' : quarantineCount + ' 行待人工检查' });
107
- const holder = await readTreeLock(io, home).catch(() => null);
108
- rows.push({ key: 'tree-lock', label: 'tree lock', status: holder == null ? 'ok' : 'warn', detail: holder == null ? 'free' : 'holder pid=' + String(holder.pid) + ' epoch=' + String(holder.epoch) + ' token=' + holder.token.slice(0, 8) });
107
+ const holder = await authorityHolder(home).catch(() => null);
108
+ rows.push({ key: 'tree-lock', label: 'tree lock', status: holder == null ? 'ok' : 'warn', detail: holder == null ? 'free' : 'holder pid=' + String(holder.pid) });
109
109
  const backupPath = v2BackupPath(home);
110
110
  const backupRaw = await readTextOrNull(io, backupPath);
111
111
  const backupMode = await stat(backupPath).then(value => value.mode).catch(() => null);
@@ -1,5 +1,6 @@
1
1
  import { type EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
- export type UndoPairPhase = 'pending' | 'restore-target' | 'restore-source' | 'receipt' | 'done';
2
+ import type { JournalProtocol } from '@lmzhen/dsh-evolution-core';
3
+ export type UndoPairPhase = 'pending' | 'restore-target' | 'restore-source' | 'receipt' | 'pair-index' | 'run-meta' | 'done';
3
4
  export interface UndoPairState {
4
5
  readonly pairKey: string;
5
6
  readonly unitId: string;
@@ -13,6 +14,12 @@ export interface UndoJournal {
13
14
  readonly pairs: readonly UndoPairState[];
14
15
  readonly updatedAt: string;
15
16
  }
17
+ /**
18
+ * The undo journal's protocol (W9-commit-protocol §3.1): `pair-index` and `run-meta` are explicit
19
+ * phases, because "the receipt was written but the pair index was not" is exactly the state a
20
+ * restart has to recognise.
21
+ */
22
+ export declare const UNDO_PAIR_PROTOCOL: JournalProtocol<UndoPairPhase>;
16
23
  export declare function undoJournalPath(home: string, runId: string): string;
17
24
  export declare function readUndoJournal(io: EvolutionIoLike, home: string, runId: string): Promise<UndoJournal | null>;
18
25
  export declare function writeUndoJournal(io: EvolutionIoLike, home: string, journal: UndoJournal): Promise<void>;
@@ -2,6 +2,35 @@ import { readTextOrNull } from "./read-probe.js";
2
2
  /** Per-run undo journal: one pair at a time, durable phase before each write. */
3
3
  import { join } from 'node:path';
4
4
  import { transactIo } from '@lmzhen/dsh-evolution-core';
5
+ /**
6
+ * The undo journal's protocol (W9-commit-protocol §3.1): `pair-index` and `run-meta` are explicit
7
+ * phases, because "the receipt was written but the pair index was not" is exactly the state a
8
+ * restart has to recognise.
9
+ */
10
+ export const UNDO_PAIR_PROTOCOL = {
11
+ phases: ['pending', 'restore-target', 'restore-source', 'receipt', 'pair-index', 'run-meta', 'done'],
12
+ next: {
13
+ pending: ['restore-target', 'receipt', 'done'],
14
+ 'restore-target': ['restore-source', 'receipt'],
15
+ 'restore-source': ['receipt'],
16
+ receipt: ['pair-index'],
17
+ 'pair-index': ['run-meta'],
18
+ 'run-meta': ['done'],
19
+ done: [],
20
+ },
21
+ recoveryOf(phase) {
22
+ switch (phase) {
23
+ case 'done': return 'done';
24
+ case 'pending': return 'retry';
25
+ case 'restore-target':
26
+ case 'restore-source':
27
+ case 'receipt':
28
+ case 'pair-index':
29
+ case 'run-meta': return 'continue';
30
+ default: return 'refuse';
31
+ }
32
+ },
33
+ };
5
34
  export function undoJournalPath(home, runId) { return join(home, 'merge', 'undo-journal', encodeURIComponent(runId) + '.json'); }
6
35
  export async function readUndoJournal(io, home, runId) {
7
36
  const raw = await readTextOrNull(io, undoJournalPath(home, runId));
@@ -5,7 +5,6 @@ import { selectWithErrors } from "./adapters/select.js";
5
5
  import { createEngineScreening } from "./adapters/engine-screen.js";
6
6
  import { createEngineRunner } from "./adapters/model-runner.js";
7
7
  import { authorityFrom, createSubagentChannel, evolutionHome, evolutionIoAdapter, newRunRegistry, reportsSweepLockTarget, sweepReports, transactIo } from '@lmzhen/dsh-evolution-core';
8
- import { registerOwnerSettings } from '@lmzhen/dsh-evolution-core';
9
8
  import { newSkillLibrary } from '@lmzhen/dsh-evolution-core/skills';
10
9
  import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
11
10
  import { loadMergeState } from "./state.js";
@@ -137,7 +136,6 @@ function formatUnreadyError() {
137
136
  }
138
137
  export const inject = [];
139
138
  export function apply(ctx, rawConfig = {}) {
140
- registerOwnerSettings(ctx, 'evolution-merge', Config);
141
139
  configureMergePresentation(ctx);
142
140
  const startupParams = resolveMergeParams(rawConfig);
143
141
  let mergeRun;
@@ -9,5 +9,11 @@ import type { CommitReceipt } from './commit-receipt.ts';
9
9
  export interface MergeUndoPort extends UnitUndoSink {
10
10
  listVersions(skill: string): Promise<readonly SkillVersion[]>;
11
11
  }
12
- export declare function undoMergeReceipt(receipt: CommitReceipt, port: MergeUndoPort): Promise<UnitUndoExecution>;
12
+ export interface UndoMergeOptions {
13
+ /** The target already holds its pre-commit content (a resumed pass). */
14
+ readonly skipTarget?: boolean;
15
+ /** Every source already holds its pre-commit content (a resumed pass). */
16
+ readonly skipSources?: boolean;
17
+ }
18
+ export declare function undoMergeReceipt(receipt: CommitReceipt, port: MergeUndoPort, options?: UndoMergeOptions): Promise<UnitUndoExecution>;
13
19
  //# sourceMappingURL=undo-merge.d.ts.map
@@ -1,19 +1,26 @@
1
1
  import { executeUnitUndo, planUnitUndoAcross } from '@lmzhen/dsh-evolution-core/write';
2
- export async function undoMergeReceipt(receipt, port) {
3
- const targetVersions = (await port.listVersions(receipt.target)).filter(entry => entry.unitId === receipt.unitId);
4
- if (targetVersions.length === 0) {
5
- return { ok: false, message: 'unit ' + receipt.unitId + ' is not in the target history; refusing to guess', reverted: [] };
2
+ export async function undoMergeReceipt(receipt, port, options = {}) {
3
+ const members = [];
4
+ if (options.skipTarget !== true) {
5
+ const targetVersions = (await port.listVersions(receipt.target)).filter(entry => entry.unitId === receipt.unitId);
6
+ if (targetVersions.length === 0) {
7
+ return { ok: false, message: 'unit ' + receipt.unitId + ' is not in the target history; refusing to guess', reverted: [] };
8
+ }
9
+ members.push({ skill: receipt.target, entries: targetVersions, status: 'live', ...(receipt.create ? { createdInUnit: true } : {}) });
6
10
  }
7
- const members = [{ skill: receipt.target, entries: targetVersions, status: 'live', ...(receipt.create ? { createdInUnit: true } : {}) }];
8
- for (const source of receipt.sources) {
9
- const live = await port.readSkill(source);
10
- members.push({
11
- skill: source,
12
- entries: (await port.listVersions(source)).filter(entry => entry.unitId === receipt.unitId),
13
- status: live === null ? 'archived' : 'live',
14
- ...(live === null ? { archivedPath: 'archive' } : {}),
15
- });
11
+ if (options.skipSources !== true) {
12
+ for (const source of receipt.sources) {
13
+ const live = await port.readSkill(source);
14
+ members.push({
15
+ skill: source,
16
+ entries: (await port.listVersions(source)).filter(entry => entry.unitId === receipt.unitId),
17
+ status: live === null ? 'archived' : 'live',
18
+ ...(live === null ? { archivedPath: 'archive' } : {}),
19
+ });
20
+ }
16
21
  }
22
+ if (members.length === 0)
23
+ return { ok: true, message: 'nothing left to restore', reverted: [] };
17
24
  const plan = planUnitUndoAcross(members);
18
25
  if (plan.refusals.length > 0) {
19
26
  return { ok: false, message: 'merge undo preflight refused: ' + plan.refusals.join('; '), reverted: [] };
@@ -6,9 +6,10 @@ export interface UndoResult {
6
6
  readonly ok: boolean;
7
7
  readonly code?: UndoErrorCode;
8
8
  readonly message: string;
9
- readonly undone: readonly string[];
9
+ readonly undone: readonly string[]; /** True when a journal phase remains and `resumeUndo` can continue. */
10
+ readonly resumable?: boolean;
10
11
  }
11
- export declare function runUndoOperation(options: {
12
+ type UndoOperationOptions = {
12
13
  readonly io: EvolutionIoLike;
13
14
  readonly home: string;
14
15
  readonly runId: string;
@@ -24,5 +25,16 @@ export declare function runUndoOperation(options: {
24
25
  readonly fence?: {
25
26
  run<T>(body: () => Promise<T>): Promise<T>;
26
27
  };
27
- }): Promise<UndoResult>;
28
+ };
29
+ /**
30
+ * Run one merge-undo pass.
31
+ *
32
+ * The fence, when the caller supplies one, covers the WHOLE pass: undo writes the skill tree, the
33
+ * undo receipt, the pair index and run metadata, so a pass that lost exclusion halfway would
34
+ * interleave with the commit it is undoing (W9-commit-protocol §3.2). A partially applied pair is
35
+ * reported as `resumable` rather than refused: the journal records the phase, and
36
+ * `resumeUndo` continues from it.
37
+ */
38
+ export declare function runUndoOperation(options: UndoOperationOptions): Promise<UndoResult>;
39
+ export {};
28
40
  //# sourceMappingURL=undo-operation.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { contentHash } from '@lmzhen/dsh-evolution-core';
2
2
  import { readCommitReceipts } from "./commit-receipt.js";
3
3
  import { undoMergeReceipt } from "./undo-merge.js";
4
- import { writeUndoJournal } from "./domain/undo-journal.js";
4
+ import { readUndoJournal, writeUndoJournal } from "./domain/undo-journal.js";
5
5
  import { readUndoReceipt, writeUndoReceipt } from "./domain/undo-receipt.js";
6
6
  import { recordPair } from "./domain/pair-index-store.js";
7
7
  import { readRunMeta, writeRunMeta } from "./domain/run-coordinator.js";
@@ -71,7 +71,20 @@ async function settleRun(io, home, runId, pairKey, allPairs, at) {
71
71
  createdAt: current?.createdAt ?? new Date(at).toISOString(), updatedAt: new Date(at).toISOString(),
72
72
  });
73
73
  }
74
+ /**
75
+ * Run one merge-undo pass.
76
+ *
77
+ * The fence, when the caller supplies one, covers the WHOLE pass: undo writes the skill tree, the
78
+ * undo receipt, the pair index and run metadata, so a pass that lost exclusion halfway would
79
+ * interleave with the commit it is undoing (W9-commit-protocol §3.2). A partially applied pair is
80
+ * reported as `resumable` rather than refused: the journal records the phase, and
81
+ * `resumeUndo` continues from it.
82
+ */
74
83
  export async function runUndoOperation(options) {
84
+ const run = async () => await runUndoSteps(options);
85
+ return options.fence === undefined ? await run() : await options.fence.run(run);
86
+ }
87
+ async function runUndoSteps(options) {
75
88
  const runReceipts = (await readCommitReceipts(options.io, options.home)).filter(receipt => (receipt.parentRunId ?? receipt.runId) === options.runId);
76
89
  const allPairKeys = runReceipts.map(receipt => receipt.pairKey ?? receipt.unitId);
77
90
  const allReceipts = runReceipts.filter(receipt => options.target === undefined || receipt.target === options.target);
@@ -83,6 +96,18 @@ export async function runUndoOperation(options) {
83
96
  const undone = [];
84
97
  for (const receipt of sortReceipts(receipts)) {
85
98
  const pairKey = receipt.pairKey ?? receipt.unitId;
99
+ /** Record the durable phase before the step it names (W9-commit-protocol §3.1). */
100
+ const markPhase = async (next) => {
101
+ const current = await readUndoJournal(options.io, options.home, options.runId);
102
+ if (current === null)
103
+ return;
104
+ await writeUndoJournal(options.io, options.home, {
105
+ ...current,
106
+ phase: 'running',
107
+ pairs: current.pairs.map(pair => (pair.pairKey === pairKey ? { ...pair, phase: next } : pair)),
108
+ updatedAt: new Date(options.now()).toISOString(),
109
+ });
110
+ };
86
111
  if (options.finalizeUnit !== undefined) {
87
112
  const finalized = await options.finalizeUnit(receipt.unitId);
88
113
  if (!finalized.ok)
@@ -90,8 +115,10 @@ export async function runUndoOperation(options) {
90
115
  }
91
116
  const existing = await readUndoReceipt(options.io, options.home, options.runId, pairKey);
92
117
  if (existing !== null) {
118
+ await markPhase('pair-index');
93
119
  await options.onPhase?.('pair-index', pairKey);
94
120
  await recordPair(options.io, options.home, { pairKey, runId: options.runId, result: 'undone', target: receipt.target });
121
+ await markPhase('run-meta');
95
122
  await options.onPhase?.('run-meta', pairKey);
96
123
  await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
97
124
  undone.push(pairKey);
@@ -103,27 +130,27 @@ export async function runUndoOperation(options) {
103
130
  if (checked.done) {
104
131
  await options.onPhase?.('receipt', pairKey);
105
132
  await writeUndoReceipt(options.io, options.home, { version: 1, runId: options.runId, ...(receipt.parentRunId === undefined ? {} : { parentRunId: receipt.parentRunId }), pairKey, unitId: receipt.unitId, evidenceSha256: receipt.evidenceSha256 ?? unitEvidenceDigest(checked.evidence), target: receipt.target, sources: checked.evidence.sources.map(source => source.name), restoredTargetHash: receipt.create ? null : digest(checked.evidence.target.before), restoredSourceHashes: Object.fromEntries(checked.evidence.sources.map(source => [source.name, contentHash(source.before)])), at: options.now() });
133
+ await markPhase('pair-index');
106
134
  await options.onPhase?.('pair-index', pairKey);
107
135
  await recordPair(options.io, options.home, { pairKey, runId: options.runId, result: 'undone', target: receipt.target });
136
+ await markPhase('run-meta');
108
137
  await options.onPhase?.('run-meta', pairKey);
109
138
  await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
110
139
  undone.push(pairKey);
111
140
  continue;
112
141
  }
113
- if (!checked.targetDone || !checked.sourcesDone) {
114
- const targetStage = checked.targetDone ? 'skip' : 'restore';
115
- const sourceStage = checked.sourcesDone ? 'skip' : 'restore';
116
- if (targetStage === 'skip' && sourceStage === 'skip') { /* unreachable, done */ }
117
- }
118
- if (checked.targetDone !== checked.sourcesDone && (checked.targetDone || checked.sourcesDone)) {
119
- return { ok: false, code: 'undo-partial-state', message: 'undo is partially applied; manual retry required', undone };
120
- }
142
+ // A resumed pass continues from what the previous attempt already restored: the plan is built
143
+ // from the missing side only, so re-running the pass is idempotent instead of refused.
144
+ const skip = {
145
+ ...(checked.targetDone ? { skipTarget: true } : {}),
146
+ ...(checked.sourcesDone ? { skipSources: true } : {}),
147
+ };
121
148
  const phase = (next) => ({ pairKey, unitId: receipt.unitId, phase: next });
122
149
  await writeUndoJournal(options.io, options.home, { version: 1, runId: options.runId, phase: 'running', pairs: pairs.map(pair => pair.pairKey === pairKey ? phase('restore-target') : pair), updatedAt: new Date(options.now()).toISOString() });
123
150
  await options.onPhase?.('restore-target', pairKey);
124
- const executed = await undoMergeReceipt(receipt, options.port);
151
+ const executed = await undoMergeReceipt(receipt, options.port, skip);
125
152
  if (!executed.ok)
126
- return { ok: false, message: executed.message, undone };
153
+ return { ok: false, message: executed.message, undone, resumable: true };
127
154
  await writeUndoJournal(options.io, options.home, { version: 1, runId: options.runId, phase: 'running', pairs: pairs.map(pair => pair.pairKey === pairKey ? phase('restore-source') : pair), updatedAt: new Date(options.now()).toISOString() });
128
155
  await options.onPhase?.('restore-source', pairKey);
129
156
  await writeUndoJournal(options.io, options.home, { version: 1, runId: options.runId, phase: 'running', pairs: pairs.map(pair => pair.pairKey === pairKey ? phase('receipt') : pair), updatedAt: new Date(options.now()).toISOString() });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Resume an interrupted merge undo from its journal (W9-commit-protocol §3.2).
3
+ *
4
+ * The pass itself is idempotent: a pair whose target or sources already hold their pre-commit
5
+ * content is planned from the missing side only. What a resume needs from the journal is therefore
6
+ * not step replay but a decision — is there anything left, and may it continue? That is what this
7
+ * entry point answers before delegating to the same pass.
8
+ * @module @lmzhen/dsh-evolution-merge/undo-resume
9
+ */
10
+ import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
11
+ import { type UndoResult } from './undo-operation.ts';
12
+ import type { MergeUndoPort } from './undo-merge.ts';
13
+ export interface ResumeUndoOptions {
14
+ readonly io: EvolutionIoLike;
15
+ readonly home: string;
16
+ readonly runId: string;
17
+ readonly port: MergeUndoPort;
18
+ readonly now: () => number;
19
+ readonly pairKey?: string;
20
+ readonly target?: string;
21
+ readonly finalizeUnit?: (unitId: string) => Promise<{
22
+ readonly ok: boolean;
23
+ readonly message?: string;
24
+ }>;
25
+ readonly onPhase?: (phase: string, pairKey: string) => void | Promise<void>;
26
+ readonly fence?: {
27
+ run<T>(body: () => Promise<T>): Promise<T>;
28
+ };
29
+ }
30
+ /**
31
+ * Continue an undo that stopped in a non-terminal phase.
32
+ * @param options - the same inputs the original pass took.
33
+ * @returns the pass result, or a refusal when there is nothing to resume.
34
+ */
35
+ export declare function resumeUndo(options: ResumeUndoOptions): Promise<UndoResult>;
36
+ //# sourceMappingURL=undo-resume.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { readUndoJournal } from "./domain/undo-journal.js";
2
+ import { runUndoOperation } from "./undo-operation.js";
3
+ /**
4
+ * Continue an undo that stopped in a non-terminal phase.
5
+ * @param options - the same inputs the original pass took.
6
+ * @returns the pass result, or a refusal when there is nothing to resume.
7
+ */
8
+ export async function resumeUndo(options) {
9
+ const journal = await readUndoJournal(options.io, options.home, options.runId);
10
+ if (journal === null) {
11
+ return { ok: false, code: 'undo-partial-state', message: 'no readable undo journal for run ' + options.runId + '; nothing to resume', undone: [] };
12
+ }
13
+ if (journal.pairs.every(pair => pair.phase === 'done')) {
14
+ return { ok: true, message: 'undo already settled: ' + options.runId, undone: [] };
15
+ }
16
+ return await runUndoOperation(options);
17
+ }
18
+ //# sourceMappingURL=undo-resume.js.map
@@ -1,6 +1,6 @@
1
1
  /** G2 opaque UnitTransaction. Core owns prepare/commit/verify/rollback lifecycle. */
2
2
  import { createHash } from "node:crypto";
3
- import { acquireTreeWriteLock } from '@lmzhen/dsh-evolution-core';
3
+ import { HomeAuthority, leaseAsTreeLock } from '@lmzhen/dsh-evolution-core';
4
4
  import { readUnitEvidence, readUnitReceipt, readUnitIndex, readUnitSnapshot, unitStorageKey, writeUnitEvidence, writeUnitReceipt, writeUnitSnapshot, } from '@lmzhen/dsh-evolution-core/write';
5
5
  import { readUnitJournal, writeUnitJournal } from '@lmzhen/dsh-evolution-core/write';
6
6
  function canonicalJson(value) {
@@ -163,10 +163,12 @@ export class MergeUnitTransactions {
163
163
  this.options.onLock?.("tree");
164
164
  }
165
165
  else {
166
- const acquired = await acquireTreeWriteLock({ io: this.options.io, home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
167
- if (!acquired.ok)
168
- return { ok: false, code: "unit-fence-failed", message: acquired.message ?? acquired.reason };
169
- lock = acquired.lock;
166
+ const fallback = new HomeAuthority({ home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
167
+ const fallbackLease = await fallback.lease("tree").catch(() => undefined);
168
+ if (fallbackLease === undefined)
169
+ return { ok: false, code: "unit-fence-failed", message: "tree lock unavailable" };
170
+ fenceLease = { lock: leaseAsTreeLock(fallbackLease), release: async () => { await fallbackLease.release(); } };
171
+ lock = fenceLease.lock;
170
172
  this.options.onLock?.("tree");
171
173
  }
172
174
  if (!(await lock.assertHeld()))
@@ -476,10 +478,12 @@ export class MergeUnitTransactions {
476
478
  lock = fenceLease.lock;
477
479
  }
478
480
  else {
479
- const acquired = await acquireTreeWriteLock({ io: this.options.io, home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
480
- if (!acquired.ok)
481
- return { ok: false, code: "unit-fence-failed", message: acquired.message ?? acquired.reason };
482
- lock = acquired.lock;
481
+ const fallback = new HomeAuthority({ home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
482
+ const fallbackLease = await fallback.lease("tree").catch(() => undefined);
483
+ if (fallbackLease === undefined)
484
+ return { ok: false, code: "unit-fence-failed", message: "tree lock unavailable" };
485
+ fenceLease = { lock: leaseAsTreeLock(fallbackLease), release: async () => { await fallbackLease.release(); } };
486
+ lock = fenceLease.lock;
483
487
  }
484
488
  this.options.onLock?.("tree");
485
489
  if (!(await lock.assertHeld()))
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.44.0",
4
+ "version": "0.46.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -34,9 +34,9 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@deepseek-ai/schemastery": "^3.18.5",
37
- "@lmzhen/dsh-evolution-core": "^0.44.0",
38
- "@lmzhen/dsh-evolution-commands": "^0.44.0",
39
- "@lmzhen/dsh-evolution-merge-engine": "0.44.0"
37
+ "@lmzhen/dsh-evolution-core": "^0.46.0",
38
+ "@lmzhen/dsh-evolution-commands": "^0.46.0",
39
+ "@lmzhen/dsh-evolution-merge-engine": "0.46.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@deepseek-ai/cordis": "^4.0.5"