@lmzhen/dsh-evolution-merge 0.39.2 → 0.40.1

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.
@@ -178,13 +178,14 @@ export function apply(ctx, rawConfig = {}) {
178
178
  const loaded = await loadMergeState(io, home);
179
179
  return { ok: true, schema: 3, migrated: false, state: { schemaVersion: 3, rows: loaded.state.rows.map(legacyToV3).filter((row) => row !== null) }, quarantine: [], report: null };
180
180
  }
181
- const state = await ensureMergeStateReady(io, home);
181
+ const state = await ensureMergeStateReady(io, home, ctx.get('evolutionMutationAuthority')?.homeFence);
182
182
  if (!state.ok)
183
183
  return v4.code === 'merge-state-forward-only' || v4.code === 'merge-state-needs-repair' ? { ok: false, code: v4.code, message: v4.message } : state;
184
184
  const normalized = await normalizePairLedger(io, home);
185
185
  return normalized.ok ? state : { ok: false, code: 'merge-state-needs-repair', message: normalized.message ?? 'v4 normalize failed' };
186
186
  })().catch(error => ({ ok: false, code: 'merge-state-migration-blocked', message: error instanceof Error ? error.message : String(error) }));
187
- const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); } });
187
+ const homeFence = ctx.get('evolutionMutationAuthority')?.homeFence;
188
+ const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); }, ...(homeFence === undefined ? {} : { fence: homeFence }) });
188
189
  ctx.effect(() => {
189
190
  const host = ctx;
190
191
  if (typeof host.on !== 'function')
@@ -201,16 +202,18 @@ export function apply(ctx, rawConfig = {}) {
201
202
  return typeof hostApproval?.platformWrite === 'boolean' ? hostApproval.platformWrite : (resolveMergeParams(rawConfig).platformWrite ?? false);
202
203
  };
203
204
  const getParams = () => ({ ...resolveMergeParams(rawConfig), limitsSnapshot, platformWrite: getPlatformWrite() });
205
+ const mutationAuthority = ctx.get('evolutionMutationAuthority');
204
206
  const skills = newSkillLibrary({
205
207
  io, ctx, authority: authorityFrom({ origin: 'subagent' }),
206
208
  limits: limitsSnapshot.skillLimits,
209
+ ...(mutationAuthority?.mutationLock === undefined ? {} : { mutationLock: mutationAuthority.mutationLock }),
207
210
  });
208
211
  void ready.then(value => {
209
212
  if (!value.ok) {
210
213
  ctx.logger.warn('merge state is not ready: ' + value.code + ': ' + value.message);
211
214
  return;
212
215
  }
213
- const unit = new UnitTransactions({ io, home, backend: skillLibraryUnitBackend(skills), claim: targetClaimUnitPort(io, home, () => Date.now()), now: () => Date.now() });
216
+ const unit = new UnitTransactions({ io, home, backend: skillLibraryUnitBackend(skills), claim: targetClaimUnitPort(io, home, () => Date.now()), now: () => Date.now(), isActive: async (runId) => await runs.hasOwner(runId), ...(mutationAuthority?.homeFence === undefined ? {} : { fence: mutationAuthority.homeFence }) });
214
217
  const cards = async () => await readSkillCards(skills);
215
218
  void (async () => {
216
219
  // NFR-05: load the shared run index BEFORE recovery. Recovery settles through the
@@ -219,7 +222,12 @@ export function apply(ctx, rawConfig = {}) {
219
222
  const loaded = await runs.load();
220
223
  if (!loaded.ok)
221
224
  ctx.logger.warn('merge run index: ' + (loaded.note ?? 'unreadable'));
222
- const reports = await recoverCommitUnits({ io, home, unit, now: () => Date.now(), isActive: () => false, runs });
225
+ const activeRuns = new Set();
226
+ for (const record of runs.runs().filter(record => record.state === 'running')) {
227
+ if (await runs.hasOwner(record.id))
228
+ activeRuns.add(record.id);
229
+ }
230
+ const reports = await recoverCommitUnits({ io, home, unit, now: () => Date.now(), isActive: (runId) => activeRuns.has(runId), runs });
223
231
  if (reports.length > 0)
224
232
  ctx.logger.info('merge unit recovery: ' + reports.map(report => report.runId + '/' + report.unitId + '=' + report.action).join(', '));
225
233
  const v4 = await readV4Readiness(io, home);
@@ -341,6 +349,7 @@ export function apply(ctx, rawConfig = {}) {
341
349
  return;
342
350
  const runtime = createMergeRuntime({
343
351
  io, home, registry: runs, skills, now: () => Date.now(), getParams, getPlatformWrite,
352
+ ...(mutationAuthority?.homeFence === undefined ? {} : { fence: mutationAuthority.homeFence }),
344
353
  ...(defaultModel === undefined ? {} : { model: defaultModel.currentSelection().model }),
345
354
  channel, parent: undefined, allow: ['merge_stage'], timeoutMinutes: startupParams.mergeRunTimeoutMinutes,
346
355
  hostCapabilities,
@@ -6,6 +6,9 @@ import type { ScanPorts } from './scan-run.ts';
6
6
  import type { MergeRegistryPort, WithMergeRunResult } from './run-shell.ts';
7
7
  export interface RunPairCommandOptions {
8
8
  readonly io: EvolutionIoLike;
9
+ readonly fence?: {
10
+ run<T>(body: () => Promise<T>): Promise<T>;
11
+ };
9
12
  readonly home: string;
10
13
  readonly registry: MergeRegistryPort;
11
14
  readonly ports: ScanPorts;
@@ -4,6 +4,7 @@ export async function runPairCommand(options) {
4
4
  io: options.io,
5
5
  home: options.home,
6
6
  registry: options.registry,
7
+ ...(options.fence === undefined ? {} : { fence: options.fence }),
7
8
  approval: options.approval,
8
9
  platformWrite: options.platformWrite,
9
10
  now: options.now,
@@ -12,6 +12,10 @@ export type PendingActionResult = {
12
12
  readonly reason: 'not-found' | 'not-pending' | 'invalid' | 'expired';
13
13
  readonly message: string;
14
14
  };
15
- export declare function approvePending(io: EvolutionIoLike, home: string, now: () => number, id: string): Promise<PendingActionResult>;
16
- export declare function rejectPending(io: EvolutionIoLike, home: string, now: () => number, id: string): Promise<PendingActionResult>;
15
+ export declare function approvePending(io: EvolutionIoLike, home: string, now: () => number, id: string, fence?: {
16
+ run<T>(body: () => Promise<T>): Promise<T>;
17
+ }): Promise<PendingActionResult>;
18
+ export declare function rejectPending(io: EvolutionIoLike, home: string, now: () => number, id: string, fence?: {
19
+ run<T>(body: () => Promise<T>): Promise<T>;
20
+ }): Promise<PendingActionResult>;
17
21
  //# sourceMappingURL=pending-actions.d.ts.map
@@ -1,8 +1,8 @@
1
1
  import { v4ToLegacy } from "./domain/row-v4.js";
2
2
  import { pairLedger } from "./domain/pair-ledger.js";
3
- async function act(io, home, now, id, type) {
3
+ async function act(io, home, now, id, type, fence) {
4
4
  void now;
5
- return await pairLedger(io, home).transact(tx => {
5
+ return await pairLedger(io, home, fence).transact(tx => {
6
6
  const row = tx.rows().find(item => item.id === id);
7
7
  if (row === undefined)
8
8
  return { ok: false, reason: 'not-found', message: 'pending row not found: ' + id };
@@ -23,10 +23,10 @@ async function act(io, home, now, id, type) {
23
23
  return { ok: true, row: v4ToLegacy(moved.row) };
24
24
  });
25
25
  }
26
- export async function approvePending(io, home, now, id) {
27
- return await act(io, home, now, id, 'approve');
26
+ export async function approvePending(io, home, now, id, fence) {
27
+ return await act(io, home, now, id, 'approve', fence);
28
28
  }
29
- export async function rejectPending(io, home, now, id) {
30
- return await act(io, home, now, id, 'reject');
29
+ export async function rejectPending(io, home, now, id, fence) {
30
+ return await act(io, home, now, id, 'reject', fence);
31
31
  }
32
32
  //# sourceMappingURL=pending-actions.js.map
@@ -8,6 +8,9 @@ import type { ScanPorts, ScanRound2Outcome } from './scan-run.ts';
8
8
  import type { MergeDecision, MergeRow } from './contracts.ts';
9
9
  export interface RunResumeCommandOptions {
10
10
  readonly io: EvolutionIoLike;
11
+ readonly fence?: {
12
+ run<T>(body: () => Promise<T>): Promise<T>;
13
+ };
11
14
  readonly home: string;
12
15
  readonly registry: MergeRegistryPort;
13
16
  readonly ports: ScanPorts;
@@ -23,7 +23,7 @@ export async function runResumeCommand(options) {
23
23
  return failure('resume', 'run-owns-row', '该 verify 行仍由运行中的 run 持有:' + row.runId, '等待该 run 结束,或运行 /evolution merge cancel ' + row.runId, row.runId);
24
24
  }
25
25
  return await runScanCommand({
26
- io: options.io, home: options.home, registry: options.registry,
26
+ io: options.io, home: options.home, registry: options.registry, ...(options.fence === undefined ? {} : { fence: options.fence }),
27
27
  approval: options.approval, platformWrite: options.platformWrite, now: options.now,
28
28
  ...(options.detached === true ? { detached: true } : {}),
29
29
  ...(options.makePorts === undefined
@@ -57,7 +57,7 @@ export async function runResumeCommand(options) {
57
57
  const staged = await options.ports.stage(selected, decision);
58
58
  if (!staged.ok)
59
59
  return failure('resume', 'stage-failed', staged.message, '检查暂存目录后重试;不影响已记录的决策', selected.runId);
60
- const fused = await pairLedger(options.io, options.home).transact(tx => {
60
+ const fused = await pairLedger(options.io, options.home, options.fence).transact(tx => {
61
61
  const current = tx.rows().find(item => item.id === selected.id);
62
62
  if (current === undefined)
63
63
  return null;
@@ -76,7 +76,7 @@ export async function runResumeCommand(options) {
76
76
  parentRunId, now: options.now,
77
77
  ...(options.detached === true ? { detached: true } : {}),
78
78
  async body(ctx) {
79
- await attachRunToRow(options.io, options.home, row.id, ctx.runId);
79
+ await attachRunToRow(options.io, options.home, row.id, ctx.runId, options.isOwnerActive, options.fence);
80
80
  await ctx.stage('author', { done: 0, total: 1 });
81
81
  let outcome;
82
82
  try {
@@ -87,7 +87,7 @@ export async function runResumeCommand(options) {
87
87
  catch (error) {
88
88
  outcome = { kind: 'failed', failure: { stage: 'author', code: 'merge-failed', message: error instanceof Error ? error.message : String(error), retryable: true, retryFrom: 'agent2' } };
89
89
  }
90
- const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome, true);
90
+ const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome, true, options.fence);
91
91
  return {
92
92
  stopReason: record.merged ? 'completed' : 'failed',
93
93
  scope: { candidatePairs: 1, queueBefore: { verify: 0, pending: 0, fuse: 1, skipped: 0 } },
@@ -10,5 +10,7 @@ export interface Round2Record {
10
10
  readonly merged: boolean;
11
11
  readonly finalize?: () => Promise<void>;
12
12
  }
13
- export declare function recordRound2Outcome(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow, outcome: ScanRound2Outcome, deferProjection?: boolean): Promise<Round2Record>;
13
+ export declare function recordRound2Outcome(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow, outcome: ScanRound2Outcome, deferProjection?: boolean, fence?: {
14
+ run<T>(body: () => Promise<T>): Promise<T>;
15
+ }): Promise<Round2Record>;
14
16
  //# sourceMappingURL=round2-outcome.d.ts.map
@@ -3,7 +3,7 @@ import { pairLedger } from "./domain/pair-ledger.js";
3
3
  import { dropStage } from "./domain/stage-aggregate.js";
4
4
  import { casExpiredHook } from "./domain/failure-route.js";
5
5
  import { recordPair } from "./domain/run-coordinator.js";
6
- export async function recordRound2Outcome(io, home, now, row, outcome, deferProjection = false) {
6
+ export async function recordRound2Outcome(io, home, now, row, outcome, deferProjection = false, fence) {
7
7
  void now;
8
8
  const reportExtra = {
9
9
  ...(outcome.manifestFiles === undefined ? {} : { manifestFiles: outcome.manifestFiles }),
@@ -14,7 +14,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
14
14
  const unitRef = { unitId: outcome.unitId ?? 'unknown', evidenceSha256: outcome.evidenceSha256 ?? 'unknown' };
15
15
  const target = outcome.target?.actual ?? outcome.target?.requested ?? row.decision?.target;
16
16
  const finalize = async () => {
17
- const removed = await pairLedger(io, home).transact(tx => {
17
+ const removed = await pairLedger(io, home, fence).transact(tx => {
18
18
  const current = tx.rows().find(item => item.id === row.id);
19
19
  return current === undefined ? { ok: false, code: 'row-not-found', message: 'row not found' } : tx.remove(row.id, 'committed', unitRef, current.version);
20
20
  });
@@ -51,7 +51,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
51
51
  const failure = outcome.failure ?? { stage: 'author', code: 'merge-failed', message: 'round2 failed', retryable: true, retryFrom: 'agent2' };
52
52
  if (failure.code === 'merge-over-limit' || failure.code === 'limit') {
53
53
  const reason = 'merge-over-limit';
54
- const skipped = await pairLedger(io, home).transact(tx => {
54
+ const skipped = await pairLedger(io, home, fence).transact(tx => {
55
55
  const current = tx.rows().find(item => item.id === row.id);
56
56
  return current === undefined ? false : tx.transition(row.id, { type: 'skipped', reason, origin: 'system', skipSource: 'system' }, current.version).ok;
57
57
  });
@@ -62,7 +62,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
62
62
  const cas = casExpiredHook(failure.code);
63
63
  if (cas !== null) {
64
64
  const terminalFailure = { ...failure, code: cas.reason, retryable: false, retryFrom: 'none' };
65
- await pairLedger(io, home).transact(tx => {
65
+ await pairLedger(io, home, fence).transact(tx => {
66
66
  const current = tx.rows().find(item => item.id === row.id);
67
67
  if (current === undefined)
68
68
  return false;
@@ -75,7 +75,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
75
75
  if (failure.code === 'commit-failed' || failure.code === 'validation-failed') {
76
76
  // FR-EXEC-11/14: report-only failure. The pair leaves the active queue (so a later scan
77
77
  // can evaluate it again); it is NOT written to skipped.
78
- const dropped = await pairLedger(io, home).transact(tx => {
78
+ const dropped = await pairLedger(io, home, fence).transact(tx => {
79
79
  const current = tx.rows().find(item => item.id === row.id);
80
80
  return current === undefined ? false : tx.remove(row.id, 'superseded', undefined, current.version).ok;
81
81
  });
@@ -85,7 +85,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
85
85
  }
86
86
  }
87
87
  if (failure.code === 'source-gone') {
88
- const dropped = await pairLedger(io, home).transact(tx => {
88
+ const dropped = await pairLedger(io, home, fence).transact(tx => {
89
89
  const current = tx.rows().find(item => item.id === row.id);
90
90
  return current === undefined ? false : tx.remove(row.id, 'superseded', undefined, current.version).ok;
91
91
  });
@@ -93,7 +93,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
93
93
  await dropStage(io, home, row.id, row.stagingRef);
94
94
  return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, outcome: 'failed', archived: [], failure } };
95
95
  }
96
- const projected = await pairLedger(io, home).transact(tx => {
96
+ const projected = await pairLedger(io, home, fence).transact(tx => {
97
97
  const current = tx.rows().find(item => item.id === row.id);
98
98
  if (current === undefined)
99
99
  return null;
@@ -64,6 +64,14 @@ export interface WithMergeRunOptions {
64
64
  readonly parentRunId?: string;
65
65
  readonly detached?: boolean;
66
66
  readonly timeoutMinutes?: number;
67
+ /** Drain budget after abort; tests may shrink it. Defaults to 30s. */
68
+ readonly drainMs?: number;
69
+ /** Observability for a detached finish failure; defaults to console.warn. */
70
+ readonly warn?: (message: string) => void;
71
+ /** Optional authority fence for run metadata writes. */
72
+ readonly fence?: {
73
+ run<T>(body: () => Promise<T>): Promise<T>;
74
+ };
67
75
  readonly body: (ctx: MergeRunBodyContext) => Promise<MergeRunReportDraft>;
68
76
  }
69
77
  export type WithMergeRunResult = {
@@ -1,5 +1,5 @@
1
1
  import { writeMergeReport } from "./report.js";
2
- import { writeRunMeta } from "./domain/run-coordinator.js";
2
+ import { readRunMeta, writeRunMeta } from "./domain/run-coordinator.js";
3
3
  /** Bounded wait for a detached body before the run is reported as timed out. */
4
4
  const DETACHED_DRAIN_MS = 30_000;
5
5
  function emptyDraft(reason) {
@@ -21,21 +21,32 @@ export async function withMergeRun(options) {
21
21
  ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
22
22
  });
23
23
  const startedAt = handle.startedAt;
24
+ const writeMeta = async (meta) => { await writeRunMeta(options.io, options.home, meta, options.fence); };
24
25
  const stage = (name, progress) => options.registry.stage(handle.id, name, progress);
25
- await stage('screening', options.total === undefined ? undefined : { done: 0, total: options.total });
26
- await writeRunMeta(options.io, options.home, {
27
- version: 1, runId: handle.id, pid: process.pid,
28
- ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
29
- ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }),
30
- ...(options.stagingRef === undefined ? {} : { stagingRef: options.stagingRef }),
31
- ...(options.pairKey === undefined ? {} : { pairs: [{ pairKey: options.pairKey, state: 'pending' }] }),
32
- pairCount: options.pairCount ?? (options.pairKey === undefined ? 0 : 1),
33
- phase: 'running', approval: options.approval.mergeApproval ? 'pending' : 'approved',
34
- createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString(),
35
- });
26
+ try {
27
+ await stage('screening', options.total === undefined ? undefined : { done: 0, total: options.total });
28
+ await writeMeta({
29
+ version: 1, runId: handle.id, pid: process.pid,
30
+ ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
31
+ ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }),
32
+ ...(options.stagingRef === undefined ? {} : { stagingRef: options.stagingRef }),
33
+ ...(options.pairKey === undefined ? {} : { pairs: [{ pairKey: options.pairKey, state: 'pending' }] }),
34
+ pairCount: options.pairCount ?? (options.pairKey === undefined ? 0 : 1),
35
+ phase: 'running', approval: options.approval.mergeApproval ? 'pending' : 'approved',
36
+ createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString(),
37
+ });
38
+ }
39
+ catch (error) {
40
+ // P1-1: begin() succeeded, so a staging/meta failure must still settle the run.
41
+ await options.registry.settle(handle.id, {
42
+ state: 'failed',
43
+ failure: 'merge run failed before body: ' + (error instanceof Error ? error.message : String(error)),
44
+ }).catch(() => { });
45
+ throw error;
46
+ }
36
47
  const writeFailureMeta = async (stopReason) => {
37
48
  try {
38
- await writeRunMeta(options.io, options.home, {
49
+ await writeMeta({
39
50
  version: 1, runId: handle.id, pid: process.pid,
40
51
  ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
41
52
  ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }),
@@ -80,13 +91,20 @@ export async function withMergeRun(options) {
80
91
  let drainTimer;
81
92
  const drained = await Promise.race([
82
93
  bodyOutcome,
83
- new Promise(resolve => { drainTimer = setTimeout(() => resolve({ kind: 'drain-timeout' }), DETACHED_DRAIN_MS); }),
94
+ new Promise(resolve => { drainTimer = setTimeout(() => resolve({ kind: 'drain-timeout' }), options.drainMs ?? DETACHED_DRAIN_MS); }),
84
95
  ]);
85
96
  if (drainTimer !== undefined)
86
97
  clearTimeout(drainTimer);
87
- outcome = drained.kind === 'drain-timeout'
88
- ? { kind: 'error', error: new Error('timeout: body did not stop within 30s drain window') }
89
- : drained;
98
+ if (drained.kind === 'drain-timeout') {
99
+ // P1-2: the body ignored abort. Close the run now so its stage/registry
100
+ // writes stop; the report below records the timeout.
101
+ await options.registry.settle(handle.id, { state: 'failed', failure: 'timeout: body did not stop within 30s drain window' }).catch(error => {
102
+ (options.warn ?? ((message) => { console.warn(message); }))('evolution-merge: drain-timeout settle failed for ' + handle.id + ': ' + (error instanceof Error ? error.message : String(error)));
103
+ });
104
+ outcome = { kind: 'error', error: new Error('timeout: body did not stop within 30s drain window') };
105
+ }
106
+ else
107
+ outcome = drained;
90
108
  }
91
109
  }
92
110
  draft = outcome.kind === 'draft'
@@ -157,13 +175,17 @@ export async function withMergeRun(options) {
157
175
  const finalPairs = draft.pairs.length > 0
158
176
  ? draft.pairs.map(pair => ({ pairKey: pair.pairKey, state: pair.outcome === 'merged' ? 'fused' : pair.outcome === 'pending' ? 'pending' : 'skipped' }))
159
177
  : options.pairKey === undefined ? [] : [{ pairKey: options.pairKey, state: 'pending' }];
160
- await writeRunMeta(options.io, options.home, { version: 1, runId: handle.id, pid: process.pid, ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }), ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }), ...(options.stagingRef === undefined ? {} : { stagingRef: options.stagingRef }), ...(finalPairs.length === 0 ? {} : { pairs: finalPairs }), pairCount: options.pairCount ?? (options.pairKey === undefined ? 0 : 1), phase: awaiting ? 'awaiting-approval' : 'settled', approval: awaiting ? 'pending' : 'approved', stopReason: report.stopReason, reportRef: reportPath, createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString() });
178
+ const currentMeta = await readRunMeta(options.io, options.home, handle.id);
179
+ await writeMeta({ version: 1, runId: handle.id, pid: process.pid, ...(currentMeta?.revision === undefined ? {} : { revision: currentMeta.revision }), ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }), ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }), ...(options.stagingRef === undefined ? {} : { stagingRef: options.stagingRef }), ...(finalPairs.length === 0 ? {} : { pairs: finalPairs }), pairCount: options.pairCount ?? (options.pairKey === undefined ? 0 : 1), phase: awaiting ? 'awaiting-approval' : 'settled', approval: awaiting ? 'pending' : 'approved', stopReason: report.stopReason, reportRef: reportPath, createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString() });
161
180
  if (report.stopReason === 'completed')
162
181
  return { ok: true, runId: handle.id, reportPath, report };
163
182
  return { ok: false, runId: handle.id, message: 'merge ' + report.stopReason, reportPath };
164
183
  };
165
184
  if (options.detached === true) {
166
- void finish().catch(() => { });
185
+ void finish().catch((error) => {
186
+ const warn = options.warn ?? ((message) => { console.warn(message); });
187
+ warn('evolution-merge: detached run ' + handle.id + ' finish failed: ' + (error instanceof Error ? error.message : String(error)));
188
+ });
167
189
  return { ok: true, accepted: true, runId: handle.id };
168
190
  }
169
191
  return await finish();
@@ -1,5 +1,5 @@
1
1
  import { SkillLibrary, type EvolutionIoLike, type UnitTransaction } from '@lmzhen/dsh-evolution-core';
2
- import { type MergeCandidate, type MergeDecision } from './contracts.ts';
2
+ import { type MergeCandidate, type MergeDecision, type MergeRow } from './contracts.ts';
3
3
  import type { MergeParams } from './params.ts';
4
4
  import type { SkillCard } from './skill-card.ts';
5
5
  import type { MergeRegistryPort } from './run-shell.ts';
@@ -7,6 +7,16 @@ import type { MergeCommandService } from './merge-command.ts';
7
7
  import type { StageToolContext } from './stage-tool.ts';
8
8
  import type { ConversationChannelLike } from './channel-port.ts';
9
9
  import type { HostCapabilities } from './host-capabilities.ts';
10
+ interface BusyTarget {
11
+ readonly id: string;
12
+ readonly runId?: string;
13
+ }
14
+ /**
15
+ * The approved/fuse row that already holds a target. Pair/approve move a row to
16
+ * approved/fuse BEFORE their run starts, so this row IS the in-flight claim a second
17
+ * same-target run would collide with (the pair itself is guarded by run-meta).
18
+ */
19
+ export declare function busyTarget(rows: readonly MergeRow[], target: string, excludeRowId: string): BusyTarget | undefined;
10
20
  export declare function forcedDecisionFor(a: string, b: string): MergeDecision;
11
21
  export interface MergeStageBindingPort {
12
22
  bind(context: StageToolContext): void;
@@ -44,7 +54,12 @@ export interface MergeRuntimeDeps {
44
54
  readonly stage?: MergeStageBindingPort;
45
55
  /** G2 transitional: when set, authoring commits through core UnitTransaction. */
46
56
  readonly unit?: UnitTransaction;
57
+ /** Authority home fence for metadata writes. */
58
+ readonly fence?: {
59
+ run<T>(body: () => Promise<T>): Promise<T>;
60
+ };
47
61
  }
48
62
  export declare function unitFor(deps: MergeRuntimeDeps): UnitTransaction | undefined;
49
63
  export declare function createMergeRuntime(deps: MergeRuntimeDeps): MergeCommandService;
64
+ export {};
50
65
  //# sourceMappingURL=runtime.d.ts.map
@@ -49,8 +49,12 @@ async function hostOnlyFailure(verb, capabilities) {
49
49
  * approved/fuse BEFORE their run starts, so this row IS the in-flight claim a second
50
50
  * same-target run would collide with (the pair itself is guarded by run-meta).
51
51
  */
52
- function busyTarget(rows, target, excludeRowId) {
53
- const row = rows.find(item => item.id !== excludeRowId && (item.state === 'approved' || item.state === 'fuse') && item.decision?.target === target);
52
+ export function busyTarget(rows, target, excludeRowId) {
53
+ const row = rows.find(item => item.id !== excludeRowId
54
+ && (item.state === 'approved' || item.state === 'fuse')
55
+ && item.decision?.target === target
56
+ // P1-4: a non-retryable failure is auditable but must not hold the target forever.
57
+ && !(item.failure !== undefined && item.failure.retryable !== true));
54
58
  return row === undefined ? undefined : { id: row.id, ...(row.runId === undefined ? {} : { runId: row.runId }) };
55
59
  }
56
60
  export function forcedDecisionFor(a, b) {
@@ -59,8 +63,8 @@ export function forcedDecisionFor(a, b) {
59
63
  const target = raw.length <= 64 ? raw : raw.slice(0, 55) + '-' + createHash('sha256').update(raw).digest('hex').slice(0, 8);
60
64
  return { shouldMerge: true, mode: 'create-umbrella', target, sources: [a, b], rationale: 'forced pair merge of ' + sorted.join(' and '), confidence: 0.5, aSummary: 'A: ' + sorted[0] + ' 的现有技能面。', bSummary: 'B: ' + sorted[1] + ' 的现有技能面。' };
61
65
  }
62
- async function removeRuntimeRow(io, home, id, event, unitId) {
63
- return await pairLedger(io, home).transact(tx => {
66
+ async function removeRuntimeRow(io, home, id, event, unitId, fence) {
67
+ return await pairLedger(io, home, fence).transact(tx => {
64
68
  const current = tx.rows().find(item => item.id === id);
65
69
  if (current === undefined)
66
70
  return false;
@@ -76,6 +80,7 @@ export function unitFor(deps) {
76
80
  return new UnitTransactions({ io: deps.io, home: deps.home, backend: skillLibraryUnitBackend(deps.skills), claim: targetClaimUnitPort(deps.io, deps.home, deps.now), now: deps.now });
77
81
  }
78
82
  export function createMergeRuntime(deps) {
83
+ const ledger = () => pairLedger(deps.io, deps.home, deps.fence);
79
84
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
80
85
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : undefined;
81
86
  const paramsOf = () => {
@@ -91,6 +96,7 @@ export function createMergeRuntime(deps) {
91
96
  platformWrite: platformWriteOf(), now: deps.now,
92
97
  ...(deps.model === undefined ? {} : { model: deps.model }),
93
98
  ...(deps.isOwnerActive === undefined ? {} : { isOwnerActive: deps.isOwnerActive }),
99
+ ...(deps.fence === undefined ? {} : { fence: deps.fence }),
94
100
  ...(timeout === undefined ? {} : { timeoutMinutes: timeout }),
95
101
  });
96
102
  const stage = deps.stage;
@@ -149,7 +155,7 @@ export function createMergeRuntime(deps) {
149
155
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
150
156
  let cleared = false;
151
157
  if (existing?.state === 'skipped' || (existing?.state === 'fuse' && existing.failure?.retryable === false)) {
152
- const dropped = await removeRuntimeRow(deps.io, deps.home, existing.id, 'superseded');
158
+ const dropped = await removeRuntimeRow(deps.io, deps.home, existing.id, 'superseded', undefined, deps.fence);
153
159
  if (!dropped)
154
160
  return fail('pair', 'row-changed', '清理可替换行时发现并发修改:' + existing.id, '运行 /evolution merge status 后重试');
155
161
  cleared = true;
@@ -310,7 +316,7 @@ export function createMergeRuntime(deps) {
310
316
  const row = loaded.state.rows.find(item => item.id === command.id);
311
317
  if (row === undefined)
312
318
  return fail('reject', 'row-not-found', '找不到合并行 ' + command.id, '运行 /evolution merge pending 查看待处理行', command.id);
313
- const changed = await pairLedger(deps.io, deps.home).transact(tx => {
319
+ const changed = await ledger().transact(tx => {
314
320
  const current = tx.rows().find(item => item.id === row.id);
315
321
  if (current === undefined || current.state !== 'pending')
316
322
  return false;
@@ -378,7 +384,7 @@ export function createMergeRuntime(deps) {
378
384
  return fail('discard', 'row-not-found', '找不到合并行 ' + command.id, '运行 /evolution merge status 查看当前队列', command.id);
379
385
  if (row.state !== 'pending' && row.state !== 'approved' && row.state !== 'fuse')
380
386
  return fail('discard', 'row-not-discardable', '该行状态是 ' + row.state + ',不可丢弃:' + command.id, '只有 pending/approved/fuse 行可丢弃', row.runId);
381
- const dropped = await removeRuntimeRow(deps.io, deps.home, row.id, 'superseded');
387
+ const dropped = await removeRuntimeRow(deps.io, deps.home, row.id, 'superseded', undefined, deps.fence);
382
388
  if (!dropped)
383
389
  return fail('discard', 'row-changed', '丢弃时发现并发修改:' + command.id, '运行 /evolution merge status ' + command.id + ' 后重试');
384
390
  return { ok: true, text: '已丢弃合并行:' + command.id };
@@ -392,7 +398,7 @@ export function createMergeRuntime(deps) {
392
398
  if (undoRegistry.runs('merge').some(run => run.id === undoRunId && run.supersededAt !== undefined))
393
399
  return fail('undo', 'run-superseded', '该 run 已因技能树 restore 失效,不能再 undo:' + undoRunId, '运行 /evolution merge runs 查看失效标记');
394
400
  const unit = unitFor(deps);
395
- const result = await runUndoOperation({ io: deps.io, home: deps.home, runId: undoRunId, port: skillLibraryUndoPort(deps.skills), now: deps.now, target: command.target, ...(unit === undefined ? {} : { finalizeUnit: id => unit.finalize.call(unit, id) }) });
401
+ const result = await runUndoOperation({ io: deps.io, home: deps.home, runId: undoRunId, port: skillLibraryUndoPort(deps.skills), now: deps.now, target: command.target, ...(deps.fence === undefined ? {} : { fence: deps.fence }), ...(unit === undefined ? {} : { finalizeUnit: id => unit.finalize.call(unit, id) }) });
396
402
  return result.ok ? { ok: true, text: result.message } : fail('undo', 'undo-failed', result.message, '检查回执与暂存后重试');
397
403
  }
398
404
  if (command.verb === 'skipped' && command.restore !== undefined) {
@@ -400,7 +406,7 @@ export function createMergeRuntime(deps) {
400
406
  const row = loaded.state.rows.find(item => item.id === command.restore);
401
407
  if (row === undefined || row.state !== 'skipped')
402
408
  return fail('skipped', 'row-not-found', '找不到可恢复的 skipped 行:' + command.restore, '运行 /evolution merge skipped 查看跳过队列', command.restore);
403
- const dropped = await removeRuntimeRow(deps.io, deps.home, row.id, 'restored');
409
+ const dropped = await removeRuntimeRow(deps.io, deps.home, row.id, 'restored', undefined, deps.fence);
404
410
  if (!dropped)
405
411
  return fail('skipped', 'row-changed', '恢复时发现并发修改:' + row.id, '运行 /evolution merge status 后重试');
406
412
  return { ok: true, text: '已恢复参与筛选:' + row.id };
@@ -410,7 +416,7 @@ export function createMergeRuntime(deps) {
410
416
  const cleared = (await loadMergeState(deps.io, deps.home)).state.rows.find(item => item.id === command.clear);
411
417
  if (cleared === undefined)
412
418
  return fail('resume', 'row-not-found', '找不到要清除的行:' + command.clear, '运行 /evolution merge status 查看当前队列');
413
- const dropped = await removeRuntimeRow(deps.io, deps.home, cleared.id, 'superseded');
419
+ const dropped = await removeRuntimeRow(deps.io, deps.home, cleared.id, 'superseded', undefined, deps.fence);
414
420
  if (!dropped)
415
421
  return fail('resume', 'row-changed', '清除时发现并发修改:' + cleared.id, '运行 /evolution merge status 后重试');
416
422
  return { ok: true, text: '已清除合并行:' + cleared.id };
@@ -508,7 +514,7 @@ export function createMergeRuntime(deps) {
508
514
  }
509
515
  const missingSource = [candidate.a, candidate.b].find(name => !cards.some(card => card.name === name && card.managed === true));
510
516
  if (missingSource !== undefined) {
511
- const dropped = await removeRuntimeRow(deps.io, deps.home, candidate.id, 'superseded');
517
+ const dropped = await removeRuntimeRow(deps.io, deps.home, candidate.id, 'superseded', undefined, deps.fence);
512
518
  if (!dropped)
513
519
  return fail('approve', 'row-changed', 'source-gone,但行已被并发修改,未清理:' + candidate.id, '运行 /evolution merge status ' + candidate.id + ' 后重试');
514
520
  return fail('approve', 'source-gone', missingSource + ' 已不在 family-managed 技能库,已从 active 队列清除', '重新 pair 该技能对;剩余行用 /evolution merge discard ' + candidate.id, candidate.runId);
@@ -517,20 +523,20 @@ export function createMergeRuntime(deps) {
517
523
  const targetExpected = candidate.decision?.targetRevisionRef ?? candidate.targetRevisionRef;
518
524
  const targetMismatch = revisionMismatch(targetExpected, targetRevisionRefOf(cards, candidate.decision.target));
519
525
  if (targetMismatch !== null) {
520
- await pairLedger(deps.io, deps.home).transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok; });
526
+ await ledger().transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok; });
521
527
  return fail('approve', 'target-taken', '目标 ' + candidate.decision.target + ' 在决策后已被占用', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
522
528
  }
523
529
  if (candidate.decision.supportDigest !== undefined && candidate.decision.supportDigest !== supportDigestOf(cards, [candidate.decision.target, candidate.a, candidate.b])) {
524
- await pairLedger(deps.io, deps.home).transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok; });
530
+ await ledger().transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok; });
525
531
  return fail('approve', 'source-changed', '源技能内容在决策后变化,拒绝照跑', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
526
532
  }
527
533
  if (revisionMismatch(sourceExpected, sourceRevisionRefOf(cards, candidate.a, candidate.b)) !== null) {
528
- await pairLedger(deps.io, deps.home).transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok; });
534
+ await ledger().transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok; });
529
535
  return fail('approve', 'source-changed', '源技能参考在决策后变化,拒绝照跑', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
530
536
  }
531
537
  const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
532
538
  if (candidate.sourceRevision !== undefined && currentRevision !== undefined && currentRevision !== candidate.sourceRevision) {
533
- const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
539
+ const recorded = await ledger().transact(tx => {
534
540
  const current = tx.rows().find(item => item.id === candidate.id);
535
541
  return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok;
536
542
  });
@@ -541,7 +547,7 @@ export function createMergeRuntime(deps) {
541
547
  const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
542
548
  if (candidate.targetRevision !== undefined && currentTargetRevision !== undefined
543
549
  && currentTargetRevision !== candidate.targetRevision) {
544
- const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
550
+ const recorded = await ledger().transact(tx => {
545
551
  const current = tx.rows().find(item => item.id === candidate.id);
546
552
  return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
547
553
  });
@@ -562,7 +568,7 @@ export function createMergeRuntime(deps) {
562
568
  }
563
569
  }
564
570
  if (candidate.decision.mode === 'merge-into-existing' && !cards.some(card => card.name === candidate.decision?.target)) {
565
- const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
571
+ const recorded = await ledger().transact(tx => {
566
572
  const current = tx.rows().find(item => item.id === candidate.id);
567
573
  return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
568
574
  });
@@ -7,6 +7,9 @@ import { type ScanPorts } from './scan-run.ts';
7
7
  import { type MergeRegistryPort, type WithMergeRunResult } from './run-shell.ts';
8
8
  export interface RunScanCommandOptions {
9
9
  readonly io: EvolutionIoLike;
10
+ readonly fence?: {
11
+ run<T>(body: () => Promise<T>): Promise<T>;
12
+ };
10
13
  readonly home: string;
11
14
  readonly registry: MergeRegistryPort;
12
15
  readonly ports?: ScanPorts;
@@ -5,6 +5,7 @@ export async function runScanCommand(options) {
5
5
  io: options.io,
6
6
  home: options.home,
7
7
  registry: options.registry,
8
+ ...(options.fence === undefined ? {} : { fence: options.fence }),
8
9
  command: 'scan',
9
10
  approval: { platformWrite: options.platformWrite, mergeApproval: options.approval },
10
11
  now: options.now,
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { contentHash, isProcessAlive } from '@lmzhen/dsh-evolution-core';
6
6
  import { acquireClaim, releaseClaim } from "./domain/claim.js";
7
7
  const PROCESS_STARTED_AT = Date.now() - process.uptime() * 1000;
8
+ const TARGET_CLAIM_TTL_MS = 30 * 60_000;
8
9
  function claimPath(home, target) {
9
10
  return join(home, 'merge', 'claims', contentHash(target) + '.json');
10
11
  }
@@ -24,10 +25,12 @@ async function withGuard(path, fn) {
24
25
  const guard = path + '.guard';
25
26
  await mkdir(dirname(path), { recursive: true });
26
27
  for (let attempt = 0; attempt < 500; attempt += 1) {
28
+ let handle;
27
29
  try {
28
- const handle = await open(guard, 'wx');
30
+ handle = await open(guard, 'wx');
29
31
  await handle.writeFile(String(process.pid));
30
32
  await handle.close();
33
+ handle = undefined;
31
34
  try {
32
35
  return await fn();
33
36
  }
@@ -36,6 +39,9 @@ async function withGuard(path, fn) {
36
39
  }
37
40
  }
38
41
  catch (error) {
42
+ await handle?.close().catch(() => { });
43
+ if (error.code !== 'EEXIST')
44
+ await unlink(guard).catch(() => { });
39
45
  if (error.code !== 'EEXIST')
40
46
  throw error;
41
47
  const pid = Number(await readFile(guard, 'utf8').catch(() => ''));
@@ -83,7 +89,7 @@ export async function acquireTargetClaim(io, home, target, now) {
83
89
  const reused = current !== undefined && current.pid === process.pid && current.startedAt !== undefined && Math.abs(current.startedAt - PROCESS_STARTED_AT) > 1_000;
84
90
  const abandoned = reused || (current !== undefined && current.expiresAt > now
85
91
  && current.pid !== undefined && current.pid !== process.pid && !isProcessAlive(current.pid));
86
- const claim = acquireClaim(abandoned ? undefined : current, now, Number.MAX_SAFE_INTEGER, token, process.pid, PROCESS_STARTED_AT);
92
+ const claim = acquireClaim(abandoned ? undefined : current, now, TARGET_CLAIM_TTL_MS, token, process.pid, PROCESS_STARTED_AT);
87
93
  if (claim === undefined)
88
94
  return raw;
89
95
  granted = claim;
@@ -21,5 +21,8 @@ export declare function runUndoOperation(options: {
21
21
  readonly message?: string;
22
22
  }>;
23
23
  readonly onPhase?: (phase: string, pairKey: string) => void | Promise<void>;
24
+ readonly fence?: {
25
+ run<T>(body: () => Promise<T>): Promise<T>;
26
+ };
24
27
  }): Promise<UndoResult>;
25
28
  //# sourceMappingURL=undo-operation.d.ts.map