@lmzhen/dsh-evolution-merge 0.30.24 → 0.30.27

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.
Files changed (84) hide show
  1. package/lib/index.js +549 -183
  2. package/lib/types/approve-command.js +2 -1
  3. package/lib/types/authoring-flow.d.ts +5 -0
  4. package/lib/types/authoring-flow.js +14 -6
  5. package/lib/types/commands.d.ts +7 -0
  6. package/lib/types/commands.js +8 -1
  7. package/lib/types/commit-execute.d.ts +4 -0
  8. package/lib/types/commit-execute.js +8 -5
  9. package/lib/types/commit-receipt.js +3 -1
  10. package/lib/types/contracts.d.ts +20 -1
  11. package/lib/types/doctor.js +1 -1
  12. package/lib/types/domain/errors.js +5 -1
  13. package/lib/types/domain/merge-snapshot.d.ts +12 -3
  14. package/lib/types/domain/merge-snapshot.js +9 -4
  15. package/lib/types/domain/reconcile.js +1 -1
  16. package/lib/types/domain/row-v4.d.ts +4 -0
  17. package/lib/types/domain/row-v4.js +9 -9
  18. package/lib/types/domain/run-coordinator.d.ts +9 -0
  19. package/lib/types/domain/stage-aggregate.d.ts +6 -1
  20. package/lib/types/domain/stage-aggregate.js +16 -9
  21. package/lib/types/domain/validation-plan.js +5 -1
  22. package/lib/types/host-capabilities.d.ts +22 -0
  23. package/lib/types/host-capabilities.js +24 -0
  24. package/lib/types/index.js +12 -2
  25. package/lib/types/kernel/adapter-v4.d.ts +12 -0
  26. package/lib/types/kernel/adapter-v4.js +54 -0
  27. package/lib/types/kernel/failure.d.ts +8 -0
  28. package/lib/types/kernel/failure.js +33 -0
  29. package/lib/types/kernel/index.d.ts +9 -0
  30. package/lib/types/kernel/index.js +9 -0
  31. package/lib/types/kernel/invariants.d.ts +4 -0
  32. package/lib/types/kernel/invariants.js +47 -0
  33. package/lib/types/kernel/ledger.d.ts +46 -0
  34. package/lib/types/kernel/ledger.js +62 -0
  35. package/lib/types/kernel/machine.d.ts +13 -0
  36. package/lib/types/kernel/machine.js +108 -0
  37. package/lib/types/kernel/model.d.ts +154 -0
  38. package/lib/types/kernel/model.js +5 -0
  39. package/lib/types/kernel/projection.d.ts +28 -0
  40. package/lib/types/kernel/projection.js +44 -0
  41. package/lib/types/kernel/recovery.d.ts +31 -0
  42. package/lib/types/kernel/recovery.js +19 -0
  43. package/lib/types/kernel/unit.d.ts +43 -0
  44. package/lib/types/kernel/unit.js +63 -0
  45. package/lib/types/merge-routes.js +9 -0
  46. package/lib/types/new-child-authoring.d.ts +1 -0
  47. package/lib/types/new-child-authoring.js +15 -7
  48. package/lib/types/pending-actions.d.ts +1 -1
  49. package/lib/types/pending-actions.js +4 -0
  50. package/lib/types/recovery-coordinator.d.ts +2 -0
  51. package/lib/types/recovery-coordinator.js +14 -4
  52. package/lib/types/recovery-projection.d.ts +4 -2
  53. package/lib/types/recovery-projection.js +3 -3
  54. package/lib/types/resume-command.js +2 -1
  55. package/lib/types/round2-outcome.js +26 -9
  56. package/lib/types/run-shell.d.ts +1 -0
  57. package/lib/types/run-shell.js +57 -15
  58. package/lib/types/runtime.d.ts +6 -1
  59. package/lib/types/runtime.js +46 -22
  60. package/lib/types/scan-command.d.ts +1 -0
  61. package/lib/types/scan-command.js +1 -0
  62. package/lib/types/scan-ports.js +3 -0
  63. package/lib/types/scan-run.d.ts +6 -1
  64. package/lib/types/scan-run.js +34 -18
  65. package/lib/types/surface/merge-view.js +12 -4
  66. package/lib/types/undo-operation.d.ts +2 -1
  67. package/lib/types/undo-operation.js +10 -4
  68. package/lib/types/wire-projection.js +7 -2
  69. package/lib/types/wire.d.ts +1 -0
  70. package/package.json +5 -5
  71. package/lib/types/adapters/model.d.ts +0 -31
  72. package/lib/types/adapters/model.js +0 -47
  73. package/lib/types/gate-cache.d.ts +0 -12
  74. package/lib/types/gate-cache.js +0 -36
  75. package/lib/types/gate-filter.d.ts +0 -31
  76. package/lib/types/gate-filter.js +0 -64
  77. package/lib/types/ladder.d.ts +0 -65
  78. package/lib/types/ladder.js +0 -162
  79. package/lib/types/propose.d.ts +0 -26
  80. package/lib/types/propose.js +0 -69
  81. package/lib/types/screening-port.d.ts +0 -47
  82. package/lib/types/screening-port.js +0 -93
  83. package/lib/types/screening.d.ts +0 -30
  84. package/lib/types/screening.js +0 -75
@@ -1,5 +1,7 @@
1
1
  import { writeMergeReport } from "./report.js";
2
2
  import { writeRunMeta } from "./domain/run-coordinator.js";
3
+ /** Bounded wait for a detached body before the run is reported as timed out. */
4
+ const DETACHED_DRAIN_MS = 30_000;
3
5
  function emptyDraft(reason) {
4
6
  return {
5
7
  stopReason: 'failed',
@@ -21,7 +23,16 @@ export async function withMergeRun(options) {
21
23
  const startedAt = handle.startedAt;
22
24
  const stage = (name, progress) => options.registry.stage(handle.id, name, progress);
23
25
  await stage('screening', options.total === undefined ? undefined : { done: 0, total: options.total });
24
- await writeRunMeta(options.io, options.home, { version: 1, runId: handle.id, ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }), ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }), pairCount: options.pairCount ?? (options.pairKey === undefined ? 0 : 1), phase: 'running', approval: options.approval.mergeApproval ? 'pending' : 'approved', createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString() });
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
+ });
25
36
  const finish = async () => {
26
37
  let draft;
27
38
  const runController = new AbortController();
@@ -29,19 +40,46 @@ export async function withMergeRun(options) {
29
40
  handle.signal.addEventListener('abort', onAbort, { once: true });
30
41
  let timer;
31
42
  const timeoutMinutes = options.timeoutMinutes;
32
- const timeout = timeoutMinutes === undefined || timeoutMinutes <= 0 ? undefined : new Promise((_, reject) => {
33
- timer = setTimeout(() => {
34
- runController.abort();
35
- reject(new Error('merge run timed out after ' + String(timeoutMinutes) + ' minutes'));
36
- }, timeoutMinutes * 60_000);
37
- });
43
+ const bodyOutcome = options.body({ runId: handle.id, startedAt, signal: runController.signal, stage })
44
+ .then(value => ({ kind: 'draft', value }))
45
+ .catch(error => ({ kind: 'error', error }));
46
+ let outcome;
47
+ let timedOut = false;
38
48
  try {
39
- draft = timeout === undefined
40
- ? await options.body({ runId: handle.id, startedAt, signal: runController.signal, stage })
41
- : await Promise.race([options.body({ runId: handle.id, startedAt, signal: runController.signal, stage }), timeout]);
42
- }
43
- catch (error) {
44
- draft = emptyDraft('run body failed: ' + (error instanceof Error ? error.message : String(error)));
49
+ if (timeoutMinutes === undefined || timeoutMinutes <= 0) {
50
+ outcome = await bodyOutcome;
51
+ }
52
+ else {
53
+ const timeout = new Promise(resolve => {
54
+ timer = setTimeout(() => {
55
+ // Abort first, then wait for the body, but never without a second bound: a body
56
+ // that ignores the signal must not leave the run open forever.
57
+ runController.abort();
58
+ resolve({ kind: 'timeout' });
59
+ }, timeoutMinutes * 60_000);
60
+ });
61
+ const raced = await Promise.race([bodyOutcome, timeout]);
62
+ if (raced.kind !== 'timeout')
63
+ outcome = raced;
64
+ else {
65
+ timedOut = true;
66
+ let drainTimer;
67
+ const drained = await Promise.race([
68
+ bodyOutcome,
69
+ new Promise(resolve => { drainTimer = setTimeout(() => resolve({ kind: 'drain-timeout' }), DETACHED_DRAIN_MS); }),
70
+ ]);
71
+ if (drainTimer !== undefined)
72
+ clearTimeout(drainTimer);
73
+ outcome = drained.kind === 'drain-timeout'
74
+ ? { kind: 'error', error: new Error('timeout: body did not stop within 30s drain window') }
75
+ : drained;
76
+ }
77
+ }
78
+ draft = outcome.kind === 'draft'
79
+ ? outcome.value
80
+ : emptyDraft('run body failed: ' + (outcome.error instanceof Error ? outcome.error.message : String(outcome.error)));
81
+ if (timedOut)
82
+ draft = { ...draft, stopReason: 'timeout', notes: [...draft.notes, 'merge run timeout reached; body aborted'] };
45
83
  }
46
84
  finally {
47
85
  if (timer !== undefined)
@@ -59,9 +97,10 @@ export async function withMergeRun(options) {
59
97
  approval: options.approval,
60
98
  scope: draft.scope,
61
99
  pairs: draft.pairs,
100
+ processedPairs: draft.pairs,
62
101
  totals: draft.totals,
63
102
  notes: draft.notes,
64
- ...(draft.remaining === undefined ? {} : { remaining: draft.remaining }),
103
+ ...(draft.remaining === undefined ? {} : { remaining: draft.remaining, remainingPairs: draft.remaining }),
65
104
  ...(draft.reportOnly === undefined ? {} : { reportOnly: draft.reportOnly }),
66
105
  ...(draft.validationFailures === undefined ? {} : { validationFailures: draft.validationFailures }),
67
106
  ...(draft.archived === undefined ? {} : { archived: draft.archived }),
@@ -99,7 +138,10 @@ export async function withMergeRun(options) {
99
138
  && (options.command === 'scan' || options.command === 'pair')
100
139
  && report.stopReason === 'completed'
101
140
  && report.totals.pending > 0;
102
- await writeRunMeta(options.io, options.home, { version: 1, runId: handle.id, ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }), ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }), 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() });
141
+ const finalPairs = draft.pairs.length > 0
142
+ ? draft.pairs.map(pair => ({ pairKey: pair.pairKey, state: pair.outcome === 'merged' ? 'fused' : pair.outcome === 'pending' ? 'pending' : 'skipped' }))
143
+ : options.pairKey === undefined ? [] : [{ pairKey: options.pairKey, state: 'pending' }];
144
+ 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() });
103
145
  if (report.stopReason === 'completed')
104
146
  return { ok: true, runId: handle.id, reportPath, report };
105
147
  return { ok: false, runId: handle.id, message: 'merge ' + report.stopReason, reportPath };
@@ -1,11 +1,13 @@
1
1
  import { SkillLibrary, type EvolutionIoLike, type UnitTransaction } from '@lmzhen/dsh-evolution-core';
2
- import { type MergeCandidate } from './contracts.ts';
2
+ import { type MergeCandidate, type MergeDecision } 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';
6
6
  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
+ import type { HostCapabilities } from './host-capabilities.ts';
10
+ export declare function forcedDecisionFor(a: string, b: string): MergeDecision;
9
11
  export interface MergeStageBindingPort {
10
12
  bind(context: StageToolContext): void;
11
13
  unbind(childId: string): void;
@@ -19,6 +21,7 @@ export interface MergeRuntimeDeps {
19
21
  readonly params: MergeParams;
20
22
  readonly platformWrite: boolean;
21
23
  readonly channel: ConversationChannelLike;
24
+ readonly hostCapabilities?: HostCapabilities;
22
25
  readonly parent: unknown;
23
26
  readonly allow: readonly string[];
24
27
  readonly cwd?: string;
@@ -29,6 +32,8 @@ export interface MergeRuntimeDeps {
29
32
  /** Curator gate set: excluded/referenced/suppressed/protected builtins. */
30
33
  readonly isBlocked?: (name: string) => boolean | Promise<boolean>;
31
34
  readonly isOwnerActive?: (runId: string) => boolean | Promise<boolean>;
35
+ /** The host's current model identity, recorded on pending decisions. */
36
+ readonly model?: string;
32
37
  /** Run-scoped engine screening. */
33
38
  readonly screeningForRun: (cards: readonly SkillCard[], runId: string, signal: AbortSignal) => Promise<readonly MergeCandidate[]>;
34
39
  readonly stage?: MergeStageBindingPort;
@@ -4,7 +4,7 @@
4
4
  * out of apply() and makes the mutating verbs testable with fakes.
5
5
  */
6
6
  import { createHash } from 'node:crypto';
7
- import { errorText, probeText, SkillLibrary, UnitTransactions, skillLibraryUnitBackend } from '@lmzhen/dsh-evolution-core';
7
+ import { errorText, isProcessAlive, probeText, SkillLibrary, UnitTransactions, skillLibraryUnitBackend } from '@lmzhen/dsh-evolution-core';
8
8
  import { targetClaimUnitPort } from "./unit-claim.js";
9
9
  import { canRetry, pairKey } from "./contracts.js";
10
10
  import { skippedPairKeys } from "./skipped.js";
@@ -18,7 +18,7 @@ import { runApproveCommand } from "./approve-command.js";
18
18
  import { runResumeCommand } from "./resume-command.js";
19
19
  import { loadMergeState } from "./state.js";
20
20
  import { pairLedger } from "./domain/pair-ledger.js";
21
- import { readCommitReceipt } from "./commit-receipt.js";
21
+ import { readCommitReceipts } from "./commit-receipt.js";
22
22
  import { runUndoOperation } from "./undo-operation.js";
23
23
  import { readStageIndex } from "./domain/stage-aggregate.js";
24
24
  import { skillLibraryUndoPort } from "./adapters/undo.js";
@@ -38,6 +38,12 @@ function fail(stage, code, hint, next, runId) {
38
38
  function failRun(stage, hint, next, result) {
39
39
  return result.error === undefined ? fail(stage, 'run-failed', hint, next, result.runId) : { ok: false, text: formatMergeError(result.error) };
40
40
  }
41
+ async function hostOnlyFailure(verb, capabilities) {
42
+ if (capabilities === undefined)
43
+ return undefined;
44
+ const state = await capabilities.hostOnly();
45
+ return state === undefined ? undefined : fail(verb, 'e-323', 'host data plane unavailable: ' + state.code + (state.detail === undefined ? '' : ' (' + state.detail + ')'), 'run /evolution merge doctor');
46
+ }
41
47
  /**
42
48
  * The approved/fuse row that already holds a target. Pair/approve move a row to
43
49
  * approved/fuse BEFORE their run starts, so this row IS the in-flight claim a second
@@ -47,7 +53,7 @@ function busyTarget(rows, target, excludeRowId) {
47
53
  const row = rows.find(item => item.id !== excludeRowId && (item.state === 'approved' || item.state === 'fuse') && item.decision?.target === target);
48
54
  return row === undefined ? undefined : { id: row.id, ...(row.runId === undefined ? {} : { runId: row.runId }) };
49
55
  }
50
- function forcedDecisionFor(a, b) {
56
+ export function forcedDecisionFor(a, b) {
51
57
  const sorted = [a, b].sort();
52
58
  const raw = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
53
59
  const target = raw.length <= 64 ? raw : raw.slice(0, 55) + '-' + createHash('sha256').update(raw).digest('hex').slice(0, 8);
@@ -75,6 +81,7 @@ export function createMergeRuntime(deps) {
75
81
  const common = {
76
82
  io: deps.io, home: deps.home, registry: deps.registry,
77
83
  platformWrite: deps.platformWrite, now: deps.now,
84
+ ...(deps.model === undefined ? {} : { model: deps.model }),
78
85
  ...(deps.isOwnerActive === undefined ? {} : { isOwnerActive: deps.isOwnerActive }),
79
86
  ...(timeout === undefined ? {} : { timeoutMinutes: timeout }),
80
87
  };
@@ -92,17 +99,29 @@ export function createMergeRuntime(deps) {
92
99
  const signal = new AbortController().signal;
93
100
  if (command.a === command.b)
94
101
  return fail('pair', 'pair-same-source', 'merge pair 需要两个不同技能源:' + command.a, '重新指定两个不同的 family-managed 技能');
95
- if ([command.a, command.b].some(name => name.includes('/') || name.includes(':')))
96
- return fail('pair', 'target-invalid', '技能名不能包含 provider/name 或 provider:name 语法:' + command.a + ' / ' + command.b, '使用裸技能名重新 pair');
102
+ if ([command.a, command.b].some(name => name.includes('/') || name.includes(':'))) {
103
+ return { ok: false, text: 'merge: 技能名不能包含 provider/name 或 provider:name 语法:' + command.a + ' / ' + command.b + ' (provider-conflict)\nusage: /evolution merge pair <a> <b>' };
104
+ }
97
105
  // FR-RUN-03: a session-less invocation cannot host a merge child. Answer E-305 in
98
106
  // the three-line shape instead of opening a run that is guaranteed to fail.
99
107
  if (agentless)
100
108
  return fail('pair', 'e-305', errorText('e-305-this-invocation-carries-no', { a1: 'pair' }), '在 GUI 或 CLI 的会话里运行 /evolution merge pair ' + command.a + ' ' + command.b);
109
+ const pairHost = await hostOnlyFailure('pair', deps.hostCapabilities);
110
+ if (pairHost !== undefined)
111
+ return pairHost;
101
112
  // FR-EXEC-17/NFR-04: two pair runs for one pair must not race for the same row.
102
113
  const pairKeyValue = pairKey(command.a, command.b);
103
114
  const inFlight = inFlightPairRun(await readRunMetas(deps.io, deps.home), pairKeyValue);
104
- if (inFlight !== undefined && (deps.isOwnerActive === undefined || (await deps.isOwnerActive(inFlight.runId)) === true)) {
105
- return fail('pair', 'merge-pair-in-flight', '同一 pair 已在运行 ' + inFlight.runId, '等待该 run 结束,或运行 /evolution merge status ' + inFlight.runId, inFlight.runId);
115
+ let ownerActive = false;
116
+ if (inFlight !== undefined) {
117
+ if (deps.isOwnerActive !== undefined)
118
+ ownerActive = await deps.isOwnerActive(inFlight.runId);
119
+ else
120
+ ownerActive = inFlight.pid === undefined ? true : isProcessAlive(inFlight.pid);
121
+ }
122
+ if (inFlight !== undefined && ownerActive) {
123
+ // NFR-04: the same pair is idempotent, not an error — answer the run that already owns it.
124
+ return { ok: true, text: 'merge pair 已在运行:' + inFlight.runId + ';作用域:family-managed 技能对,1 对,无 per-run 上限' };
106
125
  }
107
126
  const cards = await deps.cards(signal);
108
127
  const cardA = cards.find(card => card.name === command.a);
@@ -119,10 +138,12 @@ export function createMergeRuntime(deps) {
119
138
  }
120
139
  const pair = [{ a: command.a, b: command.b }];
121
140
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
122
- if (existing?.state === 'skipped') {
141
+ let cleared = false;
142
+ if (existing?.state === 'skipped' || (existing?.state === 'fuse' && existing.failure?.retryable === false)) {
123
143
  const dropped = await removeRuntimeRow(deps.io, deps.home, existing.id, 'superseded');
124
144
  if (!dropped)
125
- return fail('pair', 'row-changed', '清理 skipped 行时发现并发修改:' + existing.id, '运行 /evolution merge status 后重试');
145
+ return fail('pair', 'row-changed', '清理可替换行时发现并发修改:' + existing.id, '运行 /evolution merge status 后重试');
146
+ cleared = true;
126
147
  }
127
148
  if (existing?.state === 'verify' && existing.runId !== undefined && (await deps.isOwnerActive?.(existing.runId)) === true) {
128
149
  return fail('pair', 'merge-pair-in-flight', '同一 pair 已在运行 ' + existing.runId, '等待该 run 结束,或运行 /evolution merge status ' + existing.runId, existing.runId);
@@ -130,7 +151,7 @@ export function createMergeRuntime(deps) {
130
151
  if (existing?.state === 'pending') {
131
152
  return fail('pair', 'pair-pending', '该 pair 已在待批准队列:' + existing.id, '先运行 /evolution merge approve/reject/discard ' + existing.id, existing.runId);
132
153
  }
133
- if (existing?.state === 'approved' || existing?.state === 'fuse') {
154
+ if (!cleared && (existing?.state === 'approved' || existing?.state === 'fuse')) {
134
155
  return fail('pair', 'pair-fuse', '该 pair 已在待合并队列,可复用决策重跑', '运行 /evolution merge resume --retry ' + (existing.runId ?? existing.id), existing.runId);
135
156
  }
136
157
  const forced = command.force === true ? forcedDecisionFor(command.a, command.b) : undefined;
@@ -159,7 +180,7 @@ export function createMergeRuntime(deps) {
159
180
  if (!result.ok)
160
181
  return failRun('pair', result.message, '修复后重新发起 pair;run 报告见 /evolution merge report', result);
161
182
  if ('accepted' in result)
162
- return { ok: true, text: 'merge pair 已启动:' + result.runId };
183
+ return { ok: true, text: 'merge pair 已启动:' + result.runId + ';作用域:family-managed 技能对,1 对,无 per-run 上限' };
163
184
  if (!('report' in result))
164
185
  return fail('pair', 'report-missing', 'merge pair 已结束但没有写报告', '重新运行 pair,或运行 /evolution merge runs 查看运行');
165
186
  return { ok: true, text: 'merge pair 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
@@ -167,6 +188,9 @@ export function createMergeRuntime(deps) {
167
188
  if (command.verb === 'scan') {
168
189
  if (agentless)
169
190
  return fail('scan', 'e-305', errorText('e-305-this-invocation-carries-no', { a1: 'scan' }), '在 GUI 或 CLI 的会话里运行 /evolution merge scan');
191
+ const scanHost = await hostOnlyFailure('scan', deps.hostCapabilities);
192
+ if (scanHost !== undefined)
193
+ return scanHost;
170
194
  const signal = new AbortController().signal;
171
195
  const cards = await deps.cards(signal);
172
196
  const blocked = new Set();
@@ -225,7 +249,7 @@ export function createMergeRuntime(deps) {
225
249
  if (!result.ok)
226
250
  return failRun('scan', result.message, '修复后重新 scan;run 报告见 /evolution merge report', result);
227
251
  if ('accepted' in result)
228
- return { ok: true, text: 'merge scan 已启动:' + result.runId };
252
+ return { ok: true, text: 'merge scan 已启动:' + result.runId + ';作用域:family-managed 技能库,候选来源 engine screening,无 per-run 上限' };
229
253
  if (!('report' in result))
230
254
  return fail('scan', 'report-missing', 'merge scan 已结束但没有写报告', '重新运行 scan,或运行 /evolution merge runs 查看运行');
231
255
  return { ok: true, text: 'merge scan 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
@@ -267,7 +291,7 @@ export function createMergeRuntime(deps) {
267
291
  if (!result.ok)
268
292
  return failRun('candidates', result.message, '修复后重试 candidates --retry', result);
269
293
  if ('accepted' in result)
270
- return { ok: true, text: 'candidates --retry 已启动:' + result.runId };
294
+ return { ok: true, text: 'candidates --retry 已启动:' + result.runId + ';作用域:单条 verify 行重跑,无 per-run 上限' };
271
295
  if (!('report' in result))
272
296
  return fail('candidates', 'report-missing', 'candidates --retry 已结束但没有写报告', '运行 /evolution merge runs 查看运行');
273
297
  return { ok: true, text: 'candidates --retry 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
@@ -351,17 +375,15 @@ export function createMergeRuntime(deps) {
351
375
  return { ok: true, text: '已丢弃合并行:' + command.id };
352
376
  }
353
377
  if (command.verb === 'undo') {
354
- const receipt = await readCommitReceipt(deps.io, deps.home, command.run, command.target);
355
- if (receipt === null)
356
- return fail('undo', 'receipt-not-found', '该 run 没有提交回执:' + command.run, '只有已提交的 run 可 undo;运行 /evolution merge runs 查看');
357
- if (receipt.target !== command.target)
358
- return fail('undo', 'receipt-target-mismatch', '回执目标是 ' + receipt.target + ',不是 ' + command.target, '按回执目标重新运行 undo');
359
- const undoRunId = receipt.parentRunId ?? receipt.runId;
378
+ const receipts = (await readCommitReceipts(deps.io, deps.home)).filter(receipt => (receipt.parentRunId ?? receipt.runId) === command.run && receipt.target === command.target);
379
+ if (receipts.length === 0)
380
+ return fail('undo', 'receipt-not-found', '该 run 没有目标回执:' + command.run + ' / ' + command.target, '只有已提交的 run 可 undo;运行 /evolution merge runs 查看');
381
+ const undoRunId = receipts[0].parentRunId ?? receipts[0].runId;
360
382
  const undoRegistry = deps.registry;
361
383
  if (undoRegistry.runs('merge').some(run => run.id === undoRunId && run.supersededAt !== undefined))
362
384
  return fail('undo', 'run-superseded', '该 run 已因技能树 restore 失效,不能再 undo:' + undoRunId, '运行 /evolution merge runs 查看失效标记');
363
385
  const unit = unitFor(deps);
364
- const result = await runUndoOperation({ io: deps.io, home: deps.home, runId: undoRunId, port: skillLibraryUndoPort(deps.skills), now: deps.now, ...(receipt.pairKey === undefined ? {} : { pairKey: receipt.pairKey }), ...(unit === undefined ? {} : { finalizeUnit: id => unit.finalize.call(unit, id) }) });
386
+ 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) }) });
365
387
  return result.ok ? { ok: true, text: result.message } : fail('undo', 'undo-failed', result.message, '检查回执与暂存后重试');
366
388
  }
367
389
  if (command.verb === 'skipped' && command.restore !== undefined) {
@@ -437,6 +459,7 @@ export function createMergeRuntime(deps) {
437
459
  }),
438
460
  round2WithRun: createNewChildRound2({
439
461
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
462
+ parentRunId: (runId) => runId,
440
463
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
441
464
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
442
465
  commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
@@ -448,7 +471,7 @@ export function createMergeRuntime(deps) {
448
471
  if (!result.ok)
449
472
  return failRun('resume', result.message, '修复后重新 resume;run 报告见 /evolution merge report', result);
450
473
  if ('accepted' in result)
451
- return { ok: true, text: 'merge resume 已启动:' + result.runId };
474
+ return { ok: true, text: 'merge resume 已启动:' + result.runId + ';作用域:单条待合并行重跑 agent2,无 per-run 上限' };
452
475
  if (!('report' in result))
453
476
  return fail('resume', 'report-missing', 'merge resume 已结束但没有写报告', '重新运行 resume,或运行 /evolution merge runs 查看运行');
454
477
  return { ok: true, text: 'merge resume 完成:merged ' + String(result.report.totals.merged) };
@@ -549,6 +572,7 @@ export function createMergeRuntime(deps) {
549
572
  stage: createStageForCards({ io: deps.io, home: deps.home, runId: command.id, cards, now: deps.now }),
550
573
  round2: createNewChildRound2({
551
574
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
575
+ parentRunId: (runId) => runId,
552
576
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
553
577
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
554
578
  commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
@@ -561,7 +585,7 @@ export function createMergeRuntime(deps) {
561
585
  if (!result.ok)
562
586
  return failRun('approve', result.message, '修复后重新 approve;run 报告见 /evolution merge report', result);
563
587
  if ('accepted' in result)
564
- return { ok: true, text: 'merge approve 已启动:' + result.runId };
588
+ return { ok: true, text: 'merge approve 已启动:' + result.runId + ';作用域:单条 pending 行批准后重跑 agent2,无 per-run 上限' };
565
589
  if (!('report' in result))
566
590
  return fail('approve', 'report-missing', 'merge approve 已结束但没有写报告', '重新运行 approve,或运行 /evolution merge runs 查看运行');
567
591
  return { ok: true, text: 'merge approve 完成:merged ' + String(result.report.totals.merged) };
@@ -20,6 +20,7 @@ export interface RunScanCommandOptions {
20
20
  readonly notes?: readonly string[];
21
21
  readonly isOwnerActive?: (runId: string) => boolean | Promise<boolean>;
22
22
  readonly cards?: readonly import('./skill-card.ts').SkillCard[];
23
+ readonly model?: string;
23
24
  }
24
25
  export declare function runScanCommand(options: RunScanCommandOptions): Promise<WithMergeRunResult>;
25
26
  //# sourceMappingURL=scan-command.d.ts.map
@@ -28,6 +28,7 @@ export async function runScanCommand(options) {
28
28
  ...(options.notes === undefined ? {} : { notes: options.notes }),
29
29
  ...(options.isOwnerActive === undefined ? {} : { isOwnerActive: options.isOwnerActive }),
30
30
  ...(options.cards === undefined ? {} : { cards: options.cards }),
31
+ ...(options.model === undefined ? {} : { model: options.model }),
31
32
  });
32
33
  },
33
34
  });
@@ -85,6 +85,7 @@ export function createChannelScanPorts(options) {
85
85
  reuseStaging: true,
86
86
  target: decision.target,
87
87
  mode: decision.mode,
88
+ sources: [row.a, row.b],
88
89
  cards: options.cards,
89
90
  commitPort: options.commitPort,
90
91
  ...(options.unit === undefined ? {} : { unit: options.unit }),
@@ -94,6 +95,7 @@ export function createChannelScanPorts(options) {
94
95
  ...(options.limits === undefined ? {} : { limits: options.limits }),
95
96
  promptVersion: MERGE_PROMPT_VERSION,
96
97
  promptHash: promptHashOf(buildRound2Prompt(row, decision)),
98
+ signal,
97
99
  revisions: {
98
100
  ...(decision.targetRevisionRef === undefined ? {} : { targetRevision: decision.targetRevisionRef }),
99
101
  ...(decision.sourceRevisions === undefined ? {} : { sourceRevisions: decision.sourceRevisions }),
@@ -113,6 +115,7 @@ export function createChannelScanPorts(options) {
113
115
  ...(receipt?.unitId === undefined ? {} : { unitId: receipt.unitId }),
114
116
  ...(receipt?.evidenceSha256 === undefined ? {} : { evidenceSha256: receipt.evidenceSha256 }),
115
117
  ...(receipt?.validationPlan === undefined ? {} : { validationPlan: receipt.validationPlan }),
118
+ ...(authored.lengths === undefined ? {} : { lengths: authored.lengths }),
116
119
  ...(usage === undefined ? {} : { usage }),
117
120
  archived: receipt?.archived ?? [],
118
121
  target: { requested: decision.target, actual: authored.commit.plan.target, created: receipt?.create ?? false },
@@ -2,7 +2,7 @@
2
2
  * The scan pair loop. Registry/report lifecycle stays in withMergeRun; this module owns
3
3
  * candidate → round1 → pending/fuse → commit transitions and returns the report draft.
4
4
  */
5
- import { type MergeCandidate, type MergeDecision, type MergeFailure, type MergeRow } from './contracts.ts';
5
+ import { type MergeCandidate, type MergeDecision, type MergeFailure, type MergeValidationIssue, type MergeRow } from './contracts.ts';
6
6
  import { type StageEntry } from './domain/stage-aggregate.ts';
7
7
  import type { EvolutionIoLike, RunStage } from '@lmzhen/dsh-evolution-core';
8
8
  import type { SkillCard } from './skill-card.ts';
@@ -23,6 +23,9 @@ export interface ScanRound2Outcome {
23
23
  readonly unitId?: string;
24
24
  readonly evidenceSha256?: string;
25
25
  readonly validationPlan?: import('./contracts.ts').MergeValidationPlanRef;
26
+ readonly manifestFiles?: readonly string[];
27
+ readonly criteria?: import('./contracts.ts').MergeCriteria;
28
+ readonly validationIssues?: readonly MergeValidationIssue[];
26
29
  readonly archived?: readonly string[];
27
30
  readonly target?: {
28
31
  readonly requested: string;
@@ -63,6 +66,8 @@ export interface RunScanOptions {
63
66
  readonly runId?: string;
64
67
  readonly isOwnerActive?: (runId: string) => boolean | Promise<boolean>;
65
68
  readonly cards?: readonly SkillCard[];
69
+ /** The model identity recorded on pending decisions/summary; absent when the host has none. */
70
+ readonly model?: string;
66
71
  }
67
72
  export declare function runScan(options: RunScanOptions): Promise<MergeRunReportDraft>;
68
73
  //# sourceMappingURL=scan-run.d.ts.map
@@ -23,6 +23,15 @@ function failureOf(stage, error) {
23
23
  retryFrom: stage === 'decide' ? 'agent1' : 'agent2',
24
24
  };
25
25
  }
26
+ function pairCriteria(outcome) {
27
+ if (outcome === 'merged')
28
+ return { decision: 'merge', validation: 'required', commit: 'required', archive: 'sources', evidence: 'receipt' };
29
+ if (outcome === 'kept')
30
+ return { decision: 'keep', validation: 'report-only', commit: 'never', archive: 'none', evidence: 'none' };
31
+ if (outcome === 'pending')
32
+ return { decision: 'merge', validation: 'required', commit: 'if-approved', archive: 'none', evidence: 'none' };
33
+ return { decision: 'merge', validation: 'required', commit: 'never', archive: 'none', evidence: 'none' };
34
+ }
26
35
  function pairOf(row, candidate, outcome, extra = {}) {
27
36
  return {
28
37
  id: row.id,
@@ -34,6 +43,7 @@ function pairOf(row, candidate, outcome, extra = {}) {
34
43
  ...(candidate.gate === undefined ? {} : { gate: candidate.gate }),
35
44
  outcome,
36
45
  archived: [],
46
+ criteria: pairCriteria(outcome),
37
47
  ...extra,
38
48
  };
39
49
  }
@@ -176,7 +186,8 @@ export async function runScan(options) {
176
186
  totals.failed += 1;
177
187
  continue;
178
188
  }
179
- const mergeDecision = options.cards !== undefined ? withDecisionRevisions(round1.decision, options.cards, row.a, row.b) : round1.decision;
189
+ const decided = options.model === undefined || round1.decision.model !== undefined ? round1.decision : { ...round1.decision, model: options.model };
190
+ const mergeDecision = options.cards !== undefined ? withDecisionRevisions(decided, options.cards, row.a, row.b) : decided;
180
191
  if (options.approval) {
181
192
  const event = { type: 'verify-passed', decision: mergeDecision, target: mergeDecision.target, ...(round1.sourceRevision === undefined ? {} : { sourceRevision: round1.sourceRevision }), ...(mergeDecision.promptVersion === undefined ? {} : { promptVersion: mergeDecision.promptVersion }), ...(mergeDecision.promptHash === undefined ? {} : { promptHash: mergeDecision.promptHash }) };
182
193
  const projected = await pairLedger(options.io, options.home).transact(tx => {
@@ -223,7 +234,7 @@ export async function runScan(options) {
223
234
  const current = tx.rows().find(item => item.id === row.id);
224
235
  if (current === undefined)
225
236
  return null;
226
- const moved = tx.transition(row.id, { type: 'staged' }, current.version);
237
+ const moved = tx.transition(row.id, { type: 'staged', ...(stageEntry === undefined ? {} : { stagingRef: stageEntry.ref, stagingExpiresAt: stageEntry.expiresAt }) }, current.version);
227
238
  return moved.ok && moved.row !== null ? v4ToLegacy(moved.row) : null;
228
239
  });
229
240
  if (fused === null) {
@@ -235,17 +246,15 @@ export async function runScan(options) {
235
246
  if (stageEntry !== undefined)
236
247
  await presentStage(options.io, options.home, stageEntry);
237
248
  if (isAborted(options.signal)) {
238
- const projected = await pairLedger(options.io, options.home).transact(tx => {
249
+ // Cancel/timeout after staging is not a policy close: remove the in-flight row (so the
250
+ // next scan can re-evaluate it) and drop its stage tree. Never write skipped here.
251
+ await pairLedger(options.io, options.home).transact(tx => {
239
252
  const current = tx.rows().find(item => item.id === row.id);
240
- if (current === undefined)
241
- return null;
242
- const moved = tx.transition(row.id, { type: 'skipped', reason: 'cancelled' }, current.version);
243
- return moved.ok && moved.row !== null ? v4ToLegacy(moved.row) : null;
253
+ return current === undefined ? false : tx.remove(row.id, 'superseded', undefined, current.version).ok;
244
254
  });
245
- if (projected !== null)
246
- row = projected;
255
+ await dropStage(options.io, options.home, row.id, row.stagingRef);
247
256
  stopReason = 'cancelled';
248
- remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'fuse' })));
257
+ remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'verify' })));
249
258
  break;
250
259
  }
251
260
  await stage('author', { done: index, total: candidates.length });
@@ -260,6 +269,8 @@ export async function runScan(options) {
260
269
  finalizeRows.push({ id: row.id, unitId: round2.unitId ?? 'unknown', evidenceSha256: round2.evidenceSha256 ?? 'unknown' });
261
270
  pairs.push({ ...pairOf(row, candidate, 'merged', {
262
271
  decision: mergeDecision,
272
+ ...(round2.manifestFiles === undefined ? {} : { manifestFiles: round2.manifestFiles }),
273
+ ...(round2.validationIssues === undefined ? {} : { validation: { issues: round2.validationIssues } }),
263
274
  ...(round2.unitId === undefined ? {} : { unitId: round2.unitId }),
264
275
  ...(round2.target === undefined ? {} : { target: round2.target }),
265
276
  ...(round2.usage === undefined ? {} : { usage: round2.usage }),
@@ -270,30 +281,31 @@ export async function runScan(options) {
270
281
  continue;
271
282
  }
272
283
  const failed = round2.failure ?? failureOf('author', 'round2 failed');
273
- if (failed.code === 'merge-over-limit' || failed.code === 'limit' || failed.code === 'merge-validation') {
274
- const reason = failed.code === 'merge-validation' ? 'validation-failed' : 'merge-over-limit';
284
+ if (failed.code === 'merge-over-limit' || failed.code === 'limit') {
285
+ const reason = 'merge-over-limit';
275
286
  const skipped = await pairLedger(options.io, options.home).transact(tx => {
276
287
  const current = tx.rows().find(item => item.id === row.id);
277
288
  return current === undefined ? false : tx.transition(row.id, { type: 'skipped', reason, origin: 'system', skipSource: 'system' }, current.version).ok;
278
289
  });
279
290
  if (skipped)
280
291
  await dropStage(options.io, options.home, row.id);
281
- pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: failed }));
292
+ pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: failed, ...(round2.manifestFiles === undefined ? {} : { manifestFiles: round2.manifestFiles }), ...(round2.criteria === undefined ? {} : { criteria: round2.criteria }), ...(round2.validationIssues === undefined ? {} : { validation: { issues: round2.validationIssues } }) }));
282
293
  totals.failed += 1;
283
294
  continue;
284
295
  }
285
296
  if (failed.code === 'target-changed' || failed.code === 'target-taken' || failed.code === 'source-changed' || failed.code === 'support-changed' || failed.code === 'source-gone') {
286
297
  const code = failed.code === 'target-changed' ? 'target-taken' : failed.code === 'support-changed' ? 'source-changed' : failed.code;
298
+ const terminalFailure = { ...failed, code, retryable: false, retryFrom: 'none' };
287
299
  await pairLedger(options.io, options.home).transact(tx => {
288
300
  const current = tx.rows().find(item => item.id === row.id);
289
301
  if (current === undefined)
290
302
  return false;
291
303
  if (code === 'source-gone')
292
304
  return tx.remove(row.id, 'superseded', undefined, current.version).ok;
293
- return tx.transition(row.id, { type: 'skipped', reason: code, origin: 'system', skipSource: 'system' }, current.version).ok;
305
+ return tx.transition(row.id, { type: 'failed', failure: terminalFailure }, current.version).ok;
294
306
  });
295
307
  await dropStage(options.io, options.home, row.id);
296
- pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: { ...failed, code, retryable: false, retryFrom: 'none' } }));
308
+ pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: terminalFailure }));
297
309
  totals.failed += 1;
298
310
  continue;
299
311
  }
@@ -306,11 +318,15 @@ export async function runScan(options) {
306
318
  return moved.ok && moved.row !== null ? v4ToLegacy(moved.row) : null;
307
319
  });
308
320
  if (projected === null && terminal) {
309
- await pairLedger(options.io, options.home).transact(tx => {
321
+ const dropped = await pairLedger(options.io, options.home).transact(tx => {
310
322
  const current = tx.rows().find(item => item.id === row.id);
311
323
  return current === undefined ? false : tx.remove(row.id, 'superseded', undefined, current.version).ok;
312
324
  });
313
- pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: failed }));
325
+ if (dropped) {
326
+ await dropStage(options.io, options.home, row.id, row.stagingRef);
327
+ await recordPair(options.io, options.home, { pairKey: row.pairKey, runId: row.runId ?? 'merge', result: 'failed', ...(row.decision?.target === undefined ? {} : { target: row.decision.target }) });
328
+ }
329
+ pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: failed, ...(round2.manifestFiles === undefined ? {} : { manifestFiles: round2.manifestFiles }), ...(round2.criteria === undefined ? {} : { criteria: round2.criteria }), ...(round2.validationIssues === undefined ? {} : { validation: { issues: round2.validationIssues } }) }));
314
330
  totals.failed += 1;
315
331
  continue;
316
332
  }
@@ -320,7 +336,7 @@ export async function runScan(options) {
320
336
  continue;
321
337
  }
322
338
  row = projected;
323
- pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: failed }));
339
+ pairs.push(pairOf(row, candidate, 'failed', { decision: mergeDecision, failure: failed, ...(round2.manifestFiles === undefined ? {} : { manifestFiles: round2.manifestFiles }), ...(round2.criteria === undefined ? {} : { criteria: round2.criteria }), ...(round2.validationIssues === undefined ? {} : { validation: { issues: round2.validationIssues } }) }));
324
340
  totals.failed += 1;
325
341
  }
326
342
  const selectionReportOnly = candidates.reportOnly ?? [];
@@ -26,10 +26,15 @@ function actions(row) {
26
26
  return [renderAction('discard', context, true, true), renderAction('pair', context, true, true)];
27
27
  if (row.row.state === 'pending')
28
28
  return [renderAction('approve', context), renderAction('reject', context, true, true), renderAction('discard', context, true, true)];
29
- if (row.row.state === 'approved' || row.row.state === 'fuse')
29
+ if (row.row.state === 'approved' || row.row.state === 'fuse') {
30
+ if (row.row.failure !== undefined && row.row.failure.retryable === false)
31
+ return [renderAction('pair', context, true, true), renderAction('discard', context, true, true)];
30
32
  return [renderAction('retry-resume', context, true, true), renderAction('discard', context, true, true), renderAction('status', context)];
33
+ }
31
34
  if (row.row.state === 'skipped')
32
35
  return [renderAction('restore', context, true, true)];
36
+ if (row.row.state === 'verify')
37
+ return [renderAction('retry-candidates', context)];
33
38
  return [renderAction('pair', context)];
34
39
  }
35
40
  function verifyState(row) {
@@ -59,7 +64,7 @@ export function toRowFact(row) {
59
64
  a: state.a, b: state.b,
60
65
  ...(target === undefined ? {} : { target }),
61
66
  ...(summary === undefined ? {} : { summary }),
62
- ...(state.state === 'skipped' ? { reason: state.reason } : {}),
67
+ ...(state.state === 'skipped' ? { reason: state.reason, reasonCode: state.reason } : {}),
63
68
  ...(state.origin === undefined ? {} : { origin: state.origin }),
64
69
  ...(state.failure === undefined ? {} : { failure: { stage: state.failure.stage, code: state.failure.code, retryable: state.failure.retryable, retryFrom: state.failure.retryFrom, detail: state.failure.message } }),
65
70
  ...(staging === undefined ? {} : { staging }),
@@ -76,7 +81,10 @@ function runView(run, readAt, includeResultRef = false) {
76
81
  void includeResultRef;
77
82
  const record = run.record;
78
83
  const metaPhase = run.meta?.phase;
79
- const state = metaPhase === 'undone' ? 'undone' : run.meta?.approval === 'pending' ? 'awaiting-approval' : record.state;
84
+ const state = metaPhase === 'undone' ? 'undone'
85
+ : run.meta?.stopReason === 'interrupted' ? 'interrupted'
86
+ : run.meta?.approval === 'pending' ? 'awaiting-approval'
87
+ : record.state;
80
88
  const stopReason = run.meta?.stopReason ?? (record.state === 'cancelled' ? 'cancelled' : record.state === 'failed' ? 'failed' : record.state === 'succeeded' ? 'completed' : undefined);
81
89
  return { wireVersion: 1, readAt, id: record.id, state, command: record.command ?? 'scan', ...(record.stage === undefined ? {} : { stage: record.stage }), progress: record.progress === undefined ? null : { done: record.progress.done, total: record.progress.total }, startedAt: record.startedAt, ...(record.endedAt === undefined ? {} : { endedAt: record.endedAt }), ...(stopReason === undefined ? {} : { stopReason }), ...(record.failure === undefined ? {} : { failure: { stage: 'settle', code: 'run-failed', retryable: false, retryFrom: 'none', detail: record.failure } }), ...(record.parentRunId === undefined ? {} : { parentRunId: record.parentRunId }), ...(run.pairs[0]?.pairKey === undefined ? {} : { currentPairKey: run.pairs[0].pairKey }), ...(record.resultRef === undefined ? {} : { resultRef: record.resultRef }), actions: [{ id: 'status', command: base + 'status ' + record.id, confirm: false, enabled: true }, { id: 'report', command: base + 'report ' + record.id, confirm: false, enabled: record.resultRef !== undefined }, { id: 'cancel', command: base + 'cancel ' + record.id, confirm: false, enabled: record.state === 'running' }] };
82
90
  }
@@ -121,6 +129,6 @@ export function buildRunDetail(snapshot, runId) {
121
129
  }
122
130
  export function buildRunsView(snapshot) { return { wireVersion: 1, readAt: snapshot.readAt, runs: [...snapshot.runs].sort((a, b) => b.record.startedAt - a.record.startedAt).map(run => runView(run, snapshot.readAt, true)) }; }
123
131
  export function buildDoctorView(snapshot) {
124
- return { wireVersion: 1, readAt: snapshot.readAt, counts: snapshot.doctor.counts, ...(snapshot.runs[0] === undefined ? {} : { latestRun: runView(snapshot.runs[0], snapshot.readAt) }), staging: snapshot.doctor.staging, approval: snapshot.approval, ...(snapshot.blocked === undefined ? {} : { blocked: { code: snapshot.blocked.code, message: snapshot.blocked.message } }), rows: snapshot.doctor.rows };
132
+ return { wireVersion: 1, readAt: snapshot.readAt, counts: snapshot.doctor.counts, ...(snapshot.runs[0] === undefined ? {} : { latestRun: runView(snapshot.runs[0], snapshot.readAt) }), staging: snapshot.doctor.staging, approval: snapshot.approval, ...(snapshot.hostOnly === undefined ? {} : { hostOnly: snapshot.hostOnly }), ...(snapshot.blocked === undefined ? {} : { blocked: { code: snapshot.blocked.code, message: snapshot.blocked.message } }), rows: snapshot.doctor.rows };
125
133
  }
126
134
  //# sourceMappingURL=merge-view.js.map
@@ -1,7 +1,7 @@
1
1
  /** Batch 2b undo operation: per-pair preflight, journal, restore, receipt and settlement. */
2
2
  import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
3
3
  import { type MergeUndoPort } from './undo-merge.ts';
4
- export type UndoErrorCode = 'undo-unavailable-legacy-receipt' | 'undo-evidence-hash-mismatch' | 'undo-target-drift' | 'undo-source-drift' | 'undo-partial-state' | 'undo-unit-not-committed';
4
+ export type UndoErrorCode = 'undo-unavailable-legacy-receipt' | 'undo-evidence-expired' | 'undo-evidence-hash-mismatch' | 'undo-target-drift' | 'undo-source-drift' | 'undo-partial-state' | 'undo-unit-not-committed';
5
5
  export interface UndoResult {
6
6
  readonly ok: boolean;
7
7
  readonly code?: UndoErrorCode;
@@ -15,6 +15,7 @@ export declare function runUndoOperation(options: {
15
15
  readonly port: MergeUndoPort;
16
16
  readonly now: () => number;
17
17
  readonly pairKey?: string;
18
+ readonly target?: string;
18
19
  readonly finalizeUnit?: (unitId: string) => Promise<{
19
20
  readonly ok: boolean;
20
21
  readonly message?: string;