@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
@@ -0,0 +1,5 @@
1
+ export const TERMINAL_STATES = ['closed', 'expired', 'cancelled', 'committed', 'undone', 'superseded'];
2
+ export const ACTIVE_STATES = ['candidate', 'deciding', 'pending', 'approved', 'authoring', 'staged', 'committing', 'unknown'];
3
+ export const ALL_STATES = [...ACTIVE_STATES, ...TERMINAL_STATES];
4
+ export const CLOSED_REASONS = ['keep', 'reject', 'over-limit'];
5
+ //# sourceMappingURL=model.js.map
@@ -0,0 +1,28 @@
1
+ /** Pure projection over the ledger. One expired predicate, one queue mapping. */
2
+ import type { PairSnapshot } from './model.ts';
3
+ import type { LedgerState } from './ledger.ts';
4
+ import type { UnitSnapshot } from './unit.ts';
5
+ export type ProjectionQueue = 'verify' | 'pending' | 'fuse' | 'skipped';
6
+ export interface RowProjection {
7
+ readonly id: string;
8
+ readonly pairKey: string;
9
+ readonly state: PairSnapshot['state'];
10
+ readonly queue: ProjectionQueue | null;
11
+ readonly expired: boolean;
12
+ readonly version: number;
13
+ }
14
+ export interface ProjectionCounts {
15
+ readonly active: Readonly<Record<ProjectionQueue, number>>;
16
+ readonly expired: number;
17
+ }
18
+ export interface MergeProjection {
19
+ readonly seq: number;
20
+ readonly rows: readonly RowProjection[];
21
+ readonly units: readonly UnitSnapshot[];
22
+ readonly counts: ProjectionCounts;
23
+ }
24
+ export declare function queueOf(pair: PairSnapshot): ProjectionQueue | null;
25
+ export declare function isExpired(pair: PairSnapshot, now: number): boolean;
26
+ export declare function project(state: LedgerState, now: number): MergeProjection;
27
+ export declare function projectionJson(projection: MergeProjection): string;
28
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1,44 @@
1
+ export function queueOf(pair) {
2
+ switch (pair.state) {
3
+ case 'candidate':
4
+ case 'deciding': return 'verify';
5
+ case 'pending': return 'pending';
6
+ case 'approved':
7
+ case 'authoring':
8
+ case 'staged':
9
+ case 'committing':
10
+ case 'unknown':
11
+ case 'failed': return 'fuse';
12
+ case 'closed': return 'skipped';
13
+ case 'expired': return 'skipped';
14
+ default: return null;
15
+ }
16
+ }
17
+ export function isExpired(pair, now) {
18
+ if (pair.state === 'expired')
19
+ return true;
20
+ if (pair.expiresAt === undefined)
21
+ return false;
22
+ if (pair.state !== 'pending' && pair.state !== 'staged')
23
+ return false;
24
+ return pair.expiresAt <= now;
25
+ }
26
+ export function project(state, now) {
27
+ const counts = { active: { verify: 0, pending: 0, fuse: 0, skipped: 0 }, expired: 0 };
28
+ const rows = [];
29
+ for (const pair of [...state.pairs.values()].sort((left, right) => left.id.localeCompare(right.id))) {
30
+ const expired = isExpired(pair, now);
31
+ const queue = queueOf(pair);
32
+ rows.push({ id: pair.id, pairKey: pair.pairKey, state: pair.state, queue, expired, version: pair.version });
33
+ if (expired)
34
+ counts.expired += 1;
35
+ else if (queue !== null)
36
+ counts.active[queue] += 1;
37
+ }
38
+ const units = [...state.units.values()].sort((left, right) => left.unitId.localeCompare(right.unitId));
39
+ return { seq: state.seq, rows, units, counts };
40
+ }
41
+ export function projectionJson(projection) {
42
+ return JSON.stringify(projection);
43
+ }
44
+ //# sourceMappingURL=projection.js.map
@@ -0,0 +1,31 @@
1
+ /** Pure recovery planner and timeout decision. No IO. */
2
+ import type { CommitReceipt } from './model.ts';
3
+ export type RecoveryDecision = {
4
+ readonly kind: 'retry';
5
+ readonly reason: 'no-intent';
6
+ } | {
7
+ readonly kind: 'committed';
8
+ readonly receipt: CommitReceipt;
9
+ } | {
10
+ readonly kind: 'roll-forward';
11
+ readonly reason: 'effects-complete' | 'after-hash-matches';
12
+ } | {
13
+ readonly kind: 'abort';
14
+ readonly reason: 'no-effects' | 'before-hash-matches';
15
+ } | {
16
+ readonly kind: 'blocked';
17
+ readonly reason: 'partial-effects';
18
+ };
19
+ export interface RecoveryObservation {
20
+ readonly hasIntent: boolean;
21
+ readonly hasReceipt: boolean;
22
+ readonly receipt?: CommitReceipt;
23
+ readonly beforeMatches: boolean;
24
+ readonly afterMatches: boolean;
25
+ readonly effects: 'none' | 'partial' | 'complete';
26
+ }
27
+ export declare function planRecovery(observation: RecoveryObservation): RecoveryDecision;
28
+ export type TimeoutDecision = 'failed' | 'unknown';
29
+ /** Before a durable intent, timeout is a normal failed run; after it, only recovery may decide. */
30
+ export declare function timeoutDecision(unitPrepared: boolean): TimeoutDecision;
31
+ //# sourceMappingURL=recovery.d.ts.map
@@ -0,0 +1,19 @@
1
+ export function planRecovery(observation) {
2
+ if (observation.hasReceipt) {
3
+ if (observation.receipt === undefined)
4
+ return { kind: 'blocked', reason: 'partial-effects' };
5
+ return { kind: 'committed', receipt: observation.receipt };
6
+ }
7
+ if (!observation.hasIntent)
8
+ return { kind: 'retry', reason: 'no-intent' };
9
+ if (observation.effects === 'complete' || observation.afterMatches)
10
+ return { kind: 'roll-forward', reason: observation.effects === 'complete' ? 'effects-complete' : 'after-hash-matches' };
11
+ if (observation.effects === 'none' || observation.beforeMatches)
12
+ return { kind: 'abort', reason: observation.effects === 'none' ? 'no-effects' : 'before-hash-matches' };
13
+ return { kind: 'blocked', reason: 'partial-effects' };
14
+ }
15
+ /** Before a durable intent, timeout is a normal failed run; after it, only recovery may decide. */
16
+ export function timeoutDecision(unitPrepared) {
17
+ return unitPrepared ? 'unknown' : 'failed';
18
+ }
19
+ //# sourceMappingURL=recovery.js.map
@@ -0,0 +1,43 @@
1
+ /** Pure unit-of-work protocol: intent -> applied -> committed, with recovery states. */
2
+ import type { CommitReceipt } from './model.ts';
3
+ export type UnitState = 'planned' | 'prepared' | 'applied' | 'committed' | 'aborted' | 'blocked';
4
+ export interface UnitSnapshot {
5
+ readonly unitId: string;
6
+ readonly state: UnitState;
7
+ readonly version: number;
8
+ readonly planHash: string;
9
+ readonly receipt?: CommitReceipt;
10
+ readonly reason?: string;
11
+ }
12
+ export type UnitEvent = {
13
+ readonly type: 'prepare';
14
+ readonly planHash: string;
15
+ } | {
16
+ readonly type: 'applied';
17
+ } | {
18
+ readonly type: 'commit';
19
+ readonly receipt: CommitReceipt;
20
+ } | {
21
+ readonly type: 'abort';
22
+ readonly reason: string;
23
+ } | {
24
+ readonly type: 'block';
25
+ readonly reason: string;
26
+ } | {
27
+ readonly type: 'recover-commit';
28
+ readonly receipt: CommitReceipt;
29
+ } | {
30
+ readonly type: 'recover-abort';
31
+ readonly reason: string;
32
+ };
33
+ export type UnitTransition = {
34
+ readonly ok: true;
35
+ readonly snapshot: UnitSnapshot;
36
+ } | {
37
+ readonly ok: false;
38
+ readonly code: 'illegal-transition' | 'version-conflict' | 'invalid-event';
39
+ readonly message: string;
40
+ };
41
+ export declare function transitionUnit(snapshot: UnitSnapshot, event: UnitEvent, expectedVersion: number): UnitTransition;
42
+ export declare function checkUnit(snapshot: UnitSnapshot): string[];
43
+ //# sourceMappingURL=unit.d.ts.map
@@ -0,0 +1,63 @@
1
+ function fail(code, message) {
2
+ return { ok: false, code, message };
3
+ }
4
+ function next(snapshot, state, patch = {}) {
5
+ return { ...snapshot, state, version: snapshot.version + 1, ...patch };
6
+ }
7
+ export function transitionUnit(snapshot, event, expectedVersion) {
8
+ if (snapshot.version !== expectedVersion)
9
+ return fail('version-conflict', 'expected ' + String(expectedVersion) + ', got ' + String(snapshot.version));
10
+ switch (event.type) {
11
+ case 'prepare':
12
+ if (snapshot.state !== 'planned')
13
+ return fail('illegal-transition', 'prepare from ' + snapshot.state);
14
+ if (event.planHash.length === 0)
15
+ return fail('invalid-event', 'planHash is required');
16
+ return { ok: true, snapshot: next(snapshot, 'prepared', { planHash: event.planHash }) };
17
+ case 'applied':
18
+ return snapshot.state === 'prepared' ? { ok: true, snapshot: next(snapshot, 'applied') } : fail('illegal-transition', 'applied from ' + snapshot.state);
19
+ case 'commit':
20
+ if (snapshot.state !== 'applied')
21
+ return fail('illegal-transition', 'commit from ' + snapshot.state);
22
+ if (event.receipt.unitId.length === 0)
23
+ return fail('invalid-event', 'receipt.unitId is required');
24
+ return { ok: true, snapshot: next(snapshot, 'committed', { receipt: event.receipt }) };
25
+ case 'abort':
26
+ return snapshot.state === 'planned' || snapshot.state === 'prepared' || snapshot.state === 'applied'
27
+ ? { ok: true, snapshot: next(snapshot, 'aborted', { reason: event.reason }) }
28
+ : fail('illegal-transition', 'abort from ' + snapshot.state);
29
+ case 'block':
30
+ return snapshot.state === 'prepared' || snapshot.state === 'applied'
31
+ ? { ok: true, snapshot: next(snapshot, 'blocked', { reason: event.reason }) }
32
+ : fail('illegal-transition', 'block from ' + snapshot.state);
33
+ case 'recover-commit':
34
+ if (snapshot.state !== 'blocked')
35
+ return fail('illegal-transition', 'recover-commit from ' + snapshot.state);
36
+ return { ok: true, snapshot: next(snapshot, 'committed', { receipt: event.receipt }) };
37
+ case 'recover-abort':
38
+ return snapshot.state === 'blocked'
39
+ ? { ok: true, snapshot: next(snapshot, 'aborted', { reason: event.reason }) }
40
+ : fail('illegal-transition', 'recover-abort from ' + snapshot.state);
41
+ default:
42
+ return fail('invalid-event', 'unknown unit event');
43
+ }
44
+ }
45
+ export function checkUnit(snapshot) {
46
+ const problems = [];
47
+ if (snapshot.unitId.length === 0)
48
+ problems.push('unitId is empty');
49
+ if (snapshot.planHash.length === 0)
50
+ problems.push('planHash is empty');
51
+ if (!Number.isInteger(snapshot.version) || snapshot.version < 0)
52
+ problems.push('version must be a non-negative integer');
53
+ if (snapshot.state === 'committed' && snapshot.receipt === undefined)
54
+ problems.push('committed requires a receipt');
55
+ if (snapshot.state === 'blocked' && snapshot.reason === undefined)
56
+ problems.push('blocked requires a reason');
57
+ if (snapshot.state === 'aborted' && snapshot.reason === undefined)
58
+ problems.push('aborted requires a reason');
59
+ if (snapshot.receipt !== undefined && snapshot.receipt.unitId !== snapshot.unitId)
60
+ problems.push('receipt.unitId must equal unitId');
61
+ return problems;
62
+ }
63
+ //# sourceMappingURL=unit.js.map
@@ -13,6 +13,7 @@ export const MERGE_ROUTES = {
13
13
  export function makeMergeRoutes(deps, _audit) {
14
14
  const absent = () => json(404, { wireVersion: 1, code: 'E-325', message: 'merge service is not mounted' });
15
15
  const blocked = (readiness) => json(503, { wireVersion: 1, code: 'merge-state-blocked', readiness });
16
+ const hostOnly = (snapshot) => snapshot.hostOnly === undefined ? null : json(503, { wireVersion: 1, code: 'E-323', message: snapshot.hostOnly.detail ?? 'host data plane unavailable' });
16
17
  const notReady = async (source) => {
17
18
  if (source.readiness === undefined)
18
19
  return null;
@@ -38,6 +39,9 @@ export function makeMergeRoutes(deps, _audit) {
38
39
  const snapshot = await source.snapshot();
39
40
  if (snapshot.blocked !== undefined)
40
41
  return json(503, { wireVersion: 1, code: 'merge-state-blocked', readiness: snapshot.blocked.readiness });
42
+ const capability = hostOnly(snapshot);
43
+ if (capability !== null)
44
+ return capability;
41
45
  return json(200, buildSnapshotFacts(snapshot));
42
46
  }
43
47
  const readiness = await notReady(source);
@@ -72,6 +76,9 @@ export function makeMergeRoutes(deps, _audit) {
72
76
  const snapshot = await source.snapshot();
73
77
  if (snapshot.blocked !== undefined)
74
78
  return json(503, { wireVersion: 1, code: 'merge-state-blocked', readiness: snapshot.blocked.readiness });
79
+ const capability = hostOnly(snapshot);
80
+ if (capability !== null)
81
+ return capability;
75
82
  try {
76
83
  return json(200, buildQueuePage(snapshot, queue, expiredOnly, query.get('cursor') ?? undefined, Number.isFinite(limit) ? limit : 50));
77
84
  }
@@ -111,6 +118,8 @@ export function makeMergeRoutes(deps, _audit) {
111
118
  const snapshot = await source.snapshot();
112
119
  if (snapshot.blocked !== undefined)
113
120
  return json(503, { wireVersion: 1, code: 'merge-state-blocked', readiness: snapshot.blocked.readiness });
121
+ // Body/history are read-only projections of already-written files; a host without
122
+ // the live agent data plane can still read past reports and archived bodies.
114
123
  const refs = id !== null && id !== '' ? snapshot.bodyRefs.filter(ref => ref.rowId === id) : snapshot.bodyRefs.filter(ref => ref.name === name);
115
124
  if (refs.length === 0)
116
125
  return json(404, { wireVersion: 1, code: 'merge-body-not-found', message: 'merge body not found' });
@@ -26,6 +26,7 @@ export interface NewChildAuthoringOptions {
26
26
  readonly onChildClosed?: (childId: string) => void;
27
27
  readonly unit?: UnitTransaction | undefined;
28
28
  readonly limits?: import('@lmzhen/dsh-evolution-core').SkillLimits;
29
+ readonly parentRunId?: string | ((runId: string) => string | undefined);
29
30
  }
30
31
  export declare function runNewChildAuthoring(options: NewChildAuthoringOptions, row: MergeRow, decision: MergeDecision, signal: AbortSignal): Promise<ScanRound2Outcome>;
31
32
  /** Bind the round-2 port to one run: approve/resume open a NEW child per run. */
@@ -90,23 +90,26 @@ export async function runNewChildAuthoring(options, row, decision, signal) {
90
90
  if (missing !== null)
91
91
  return { kind: 'failed', failure: { stage: 'author', code: 'merge-manifest-missing', message: 'manifest file missing from staged tree: ' + missing, retryable: true, retryFrom: 'agent2' } };
92
92
  const limitsSnapshot = limitsSnapshotOf(options.limits ?? DEFAULT_SKILL_LIMITS);
93
- const validationPlan = buildValidationPlan({ tree: tree.value, target: decision.target, limits: limitsSnapshot, cards: options.cards, promptVersion: MERGE_PROMPT_VERSION, promptHash: promptHashOf(buildRound2Prompt(row, decision)), supportFiles: tree.value.files.map(file => ({ path: file.path, content: file.content, source: 'staged' })), manifestFiles, existingSupport: (options.cards.find(card => card.name === decision.target)?.files ?? []).map(file => file.path), supportSources: options.cards.flatMap(card => (card.files ?? []).map(file => ({ path: file.path, content: file.content, source: card.name }))) });
93
+ const validationPlan = buildValidationPlan({ tree: tree.value, target: decision.target, limits: limitsSnapshot, cards: options.cards, promptVersion: MERGE_PROMPT_VERSION, promptHash: promptHashOf(buildRound2Prompt(row, decision)), supportFiles: tree.value.files.map(file => ({ path: file.path, content: file.content, source: 'staged' })), manifestFiles, existingSupport: (options.cards.find(card => card.name === decision.target)?.files ?? []).map(file => file.path), supportSources: options.cards.filter(card => card.name === row.a || card.name === row.b).flatMap(card => (card.files ?? []).map(file => ({ path: file.path, content: file.content, source: card.name }))) });
94
94
  const findings = [...validateStagedTree(tree.value, limitsSnapshot.skillLimits), ...validationPlan.findings];
95
95
  if (hasBlockingFinding(findings)) {
96
96
  const lengths = { bodyBytes: Buffer.byteLength(tree.value.body, 'utf8'), fileBytes: tree.value.files.reduce((sum, file) => sum + Buffer.byteLength(file.content, 'utf8'), 0), totalBytes: Buffer.byteLength(tree.value.body, 'utf8') + tree.value.files.reduce((sum, file) => sum + Buffer.byteLength(file.content, 'utf8'), 0) };
97
- // FR-EXEC-11/14: only a LIMIT finding is over-limit; every other blocking rule
98
- // (manifest mismatch, support conflict, validation plan) is a validation block and
99
- // must land in skipped, not bounce back to fuse as a retryable generic failure.
97
+ // FR-EXEC-11/14: only a LIMIT finding is over-limit (a policy close); every other
98
+ // blocking rule (manifest mismatch, support conflict, validation plan) is a
99
+ // validation failure that goes to the report as failed, never to skipped.
100
100
  const limit = findings.find(finding => finding.rule === 'limit');
101
+ const plan = { planVersion: validationPlan.planVersion, stageDigest: validationPlan.stageDigest, revision: validationPlan.revision, manifestFiles: validationPlan.manifestFiles };
101
102
  return limit === undefined
102
- ? { kind: 'failed', failure: { stage: 'validate', code: 'merge-validation', message: 'staged tree failed the rule gate', retryable: false, retryFrom: 'none' } }
103
- : { kind: 'failed', failure: { stage: 'validate', code: 'merge-over-limit', message: limit.message, retryable: false, retryFrom: 'none', lengths } };
103
+ ? { kind: 'failed', failure: { stage: 'validate', code: 'merge-validation', message: 'staged tree failed the rule gate', retryable: false, retryFrom: 'none' }, validationPlan: plan, manifestFiles: validationPlan.manifestFiles, validationIssues: findings, criteria: { decision: 'merge', validation: 'required', commit: 'never', archive: 'none', evidence: 'none' } }
104
+ : { kind: 'failed', failure: { stage: 'validate', code: 'merge-over-limit', message: limit.message, retryable: false, retryFrom: 'none', lengths }, validationPlan: plan, manifestFiles: validationPlan.manifestFiles, validationIssues: findings, criteria: { decision: 'merge', validation: 'required', commit: 'never', archive: 'none', evidence: 'none' } };
104
105
  }
106
+ const parentRunId = typeof options.parentRunId === 'function' ? options.parentRunId(options.runId) : options.parentRunId;
105
107
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
106
- origin: 'foreground', summary: 'merge ' + row.a + ' + ' + row.b, now: options.now,
108
+ origin: 'foreground', summary: 'merge ' + row.a + ' + ' + row.b, now: options.now, signal,
107
109
  ...(options.isBlocked === undefined ? {} : { isBlocked: options.isBlocked }),
108
110
  ...(options.unit === undefined ? {} : { unit: options.unit }),
109
111
  ...(options.limits === undefined ? {} : { limits: options.limits }),
112
+ ...(parentRunId === undefined ? {} : { parentRunId }),
110
113
  validationPlan: { planVersion: validationPlan.planVersion, stageDigest: validationPlan.stageDigest, revision: validationPlan.revision },
111
114
  ...(decision.targetRevisionRef === undefined ? {} : { targetRevision: decision.targetRevisionRef }),
112
115
  ...(decision.sourceRevisions === undefined ? {} : { sourceRevisions: decision.sourceRevisions }),
@@ -123,6 +126,11 @@ export async function runNewChildAuthoring(options, row, decision, signal) {
123
126
  ...(receipt?.unitId === undefined ? {} : { unitId: receipt.unitId }),
124
127
  archived: receipt?.archived ?? [],
125
128
  lengths,
129
+ validationPlan: { planVersion: validationPlan.planVersion, stageDigest: validationPlan.stageDigest, revision: validationPlan.revision, manifestFiles: validationPlan.manifestFiles },
130
+ ...(parentRunId === undefined ? {} : { parentRunId }),
131
+ manifestFiles: validationPlan.manifestFiles,
132
+ validationIssues: findings,
133
+ criteria: { decision: 'merge', validation: 'required', commit: 'required', archive: 'sources', evidence: 'receipt' },
126
134
  target: { requested: decision.target, actual: commit.plan.target, created: receipt?.create ?? false },
127
135
  };
128
136
  }
@@ -9,7 +9,7 @@ export type PendingActionResult = {
9
9
  readonly row: MergeRow;
10
10
  } | {
11
11
  readonly ok: false;
12
- readonly reason: 'not-found' | 'not-pending' | 'invalid';
12
+ readonly reason: 'not-found' | 'not-pending' | 'invalid' | 'expired';
13
13
  readonly message: string;
14
14
  };
15
15
  export declare function approvePending(io: EvolutionIoLike, home: string, now: () => number, id: string): Promise<PendingActionResult>;
@@ -8,6 +8,10 @@ async function act(io, home, now, id, type) {
8
8
  return { ok: false, reason: 'not-found', message: 'pending row not found: ' + id };
9
9
  if (row.state !== 'pending')
10
10
  return { ok: false, reason: 'not-pending', message: 'row is not pending: ' + row.state };
11
+ // Expired pending rows are read-only until discarded or re-paired; approve/reject would
12
+ // resurrect a target/source decision that reconcile already invalidated.
13
+ if (row.expired !== undefined)
14
+ return { ok: false, reason: 'expired', message: 'pending row expired (' + row.expired + '): only discard or re-pair' };
11
15
  const event = type === 'approve'
12
16
  ? { type: 'approved', runId: row.runId ?? 'approve:' + id }
13
17
  : { type: 'skipped', reason: 'rejected', origin: 'human' };
@@ -6,6 +6,8 @@ export interface RecoveryReport {
6
6
  readonly phase: string;
7
7
  readonly branch: RecoveryBranch;
8
8
  readonly action: 'rollback' | 'rollForward' | 'finalize' | 'blocked' | 'none';
9
+ /** Branch D proves the unit committed after the process died; the run was interrupted, not cleanly completed. */
10
+ readonly state?: 'interrupted';
9
11
  readonly ok: boolean;
10
12
  readonly code?: string | undefined;
11
13
  readonly message?: string | undefined;
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { probeList, readUnitEvidence } from '@lmzhen/dsh-evolution-core';
4
4
  import { evidenceSha256 } from "./domain/evidence-hash.js";
5
5
  import { projectRecoveredUnit } from "./recovery-projection.js";
6
+ import { readCommitReceipts } from "./commit-receipt.js";
7
+ import { readUndoReceipt } from "./domain/undo-receipt.js";
6
8
  const branchOf = (phase) => {
7
9
  if (phase === 'prepared' || phase === 'target-pending')
8
10
  return 'A';
@@ -36,24 +38,32 @@ export async function recoverCommitUnits(options) {
36
38
  continue;
37
39
  }
38
40
  const evidence = await readUnitEvidence(options.io, options.home, journal.unitId);
41
+ // An undo is the newest durable fact. Recovery must not re-project a committed unit
42
+ // over an undo receipt (that would re-archive the source and reset pair-index to merged).
43
+ const receipt = (await readCommitReceipts(options.io, options.home)).find(item => item.unitId === journal.unitId);
44
+ const undoRunId = receipt?.parentRunId ?? journal.runId;
45
+ if (receipt?.pairKey !== undefined && await readUndoReceipt(options.io, options.home, undoRunId, receipt.pairKey) !== null) {
46
+ reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch: branchOf(journal.phase), action: 'none', ok: true, code: 'undo-recorded' });
47
+ continue;
48
+ }
39
49
  const branch = branchOf(journal.phase);
40
50
  if (journal.phase === 'committed' || evidence !== null || journal.phase === 'unit-written' || journal.phase === 'sources-archived') {
41
51
  const result = await options.unit.finalize(journal.unitId);
42
52
  if (result.ok) {
43
53
  let projection;
44
54
  try {
45
- projection = await projectRecoveredUnit({ io: options.io, home: options.home, runId: journal.runId, unitId: journal.unitId, evidence: result.evidence, now: options.now, ...(options.runs === undefined ? {} : { runs: options.runs }), ...(options.onProjectionPhase === undefined ? {} : { onPhase: options.onProjectionPhase }) });
55
+ projection = await projectRecoveredUnit({ io: options.io, home: options.home, runId: journal.runId, unitId: journal.unitId, evidence: result.evidence, now: options.now, ...(branch === 'D' ? { interrupted: true } : {}), ...(options.runs === undefined ? {} : { runs: options.runs }), ...(options.onProjectionPhase === undefined ? {} : { onPhase: options.onProjectionPhase }) });
46
56
  }
47
57
  catch (error) {
48
58
  projection = { ok: false, action: 'projection-threw', message: error instanceof Error ? error.message : String(error) };
49
59
  }
50
60
  if (!projection.ok)
51
- reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch, action: 'blocked', ok: false, code: 'projection-failed', message: projection.message });
61
+ reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch, action: 'blocked', ok: false, code: 'projection-failed', message: projection.message, ...(branch === 'D' ? { state: 'interrupted' } : {}) });
52
62
  else
53
- reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch, action: 'finalize', ok: true, evidenceSha256: evidenceSha256(result.evidence) });
63
+ reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch, action: 'finalize', ok: true, evidenceSha256: evidenceSha256(result.evidence), ...(branch === 'D' ? { state: 'interrupted' } : {}) });
54
64
  continue;
55
65
  }
56
- reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch, action: 'finalize', ok: false, code: result.code, message: result.message });
66
+ reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch, action: 'finalize', ok: false, code: result.code, message: result.message, ...(branch === 'D' ? { state: 'interrupted' } : {}) });
57
67
  continue;
58
68
  }
59
69
  if (branch === 'A') {
@@ -13,15 +13,17 @@ export declare function projectRecoveredUnit(options: {
13
13
  readonly unitId: string;
14
14
  readonly evidence: UnitEvidenceRecord;
15
15
  readonly now: () => number;
16
+ /** Branch D: the unit committed, but the run was interrupted by process death. */
17
+ readonly interrupted?: boolean;
16
18
  readonly runs?: {
17
19
  settle(id: string, outcome: {
18
- state: 'succeeded' | 'failed' | 'cancelled';
20
+ state: 'succeeded' | 'failed' | 'cancelled' | 'interrupted';
19
21
  resultRef?: string;
20
22
  failure?: string;
21
23
  }): Promise<void>;
22
24
  /** Controlled orphan upgrade: lets a committed unit turn failed(orphan) back into succeeded. */
23
25
  settleRecovered?(id: string, outcome: {
24
- state: 'succeeded' | 'failed' | 'cancelled';
26
+ state: 'succeeded' | 'failed' | 'cancelled' | 'interrupted';
25
27
  resultRef?: string;
26
28
  failure?: string;
27
29
  }): Promise<boolean>;
@@ -51,7 +51,7 @@ export async function projectRecoveredUnit(options) {
51
51
  else {
52
52
  const report = {
53
53
  schemaVersion: 2, runId: options.runId, kind: 'merge', command: 'approve', startedAt: at, endedAt: at,
54
- stopReason: 'completed', approval: { platformWrite: false, mergeApproval: true },
54
+ stopReason: options.interrupted === true ? 'interrupted' : 'completed', approval: { platformWrite: false, mergeApproval: true },
55
55
  scope: { candidatePairs: 1, queueBefore: { verify: 0, pending: 0, fuse: 0, skipped: 0 } },
56
56
  pairs: [{ id: row?.id ?? options.unitId, pairKey, a: row?.a ?? target, b: row?.b ?? options.evidence.sources[0]?.name ?? '', queue: 'fuse', outcome: 'merged', archived: options.evidence.sources.filter(source => source.archived).map(source => source.name), unitId: options.unitId, ...(validationPlan === undefined ? {} : { validationPlan }) }],
57
57
  totals: { merged: 1, kept: 0, pending: 0, failed: 0 }, notes: ['recovered from unit journal'],
@@ -83,10 +83,10 @@ export async function projectRecoveredUnit(options) {
83
83
  await recordPair(options.io, options.home, { pairKey, runId: options.runId, result: 'merged', target });
84
84
  await options.onPhase?.('run-meta');
85
85
  const meta = await readRunMeta(options.io, options.home, options.runId);
86
- await writeRunMeta(options.io, options.home, { version: 1, runId: options.runId, phase: 'settled', reportRef: reportPath, pairCount: 1, ...(meta?.parentRunId === undefined ? {} : { parentRunId: meta.parentRunId }), createdAt: meta?.createdAt ?? new Date(at).toISOString(), updatedAt: new Date(at).toISOString() });
86
+ await writeRunMeta(options.io, options.home, { version: 1, runId: options.runId, ...(meta?.pid === undefined ? {} : { pid: meta.pid }), phase: 'settled', ...(options.interrupted === true ? { stopReason: 'interrupted' } : {}), reportRef: reportPath, pairCount: 1, ...(meta?.parentRunId === undefined ? {} : { parentRunId: meta.parentRunId }), ...(meta?.stagingRef === undefined ? {} : { stagingRef: meta.stagingRef }), ...(meta?.pairs === undefined ? {} : { pairs: meta.pairs }), createdAt: meta?.createdAt ?? new Date(at).toISOString(), updatedAt: new Date(at).toISOString() });
87
87
  await options.onPhase?.('settle');
88
88
  if (options.runs !== undefined) {
89
- const outcome = { state: 'succeeded', resultRef: reportPath };
89
+ const outcome = { state: options.interrupted === true ? 'interrupted' : 'succeeded', resultRef: reportPath };
90
90
  const upgraded = options.runs.settleRecovered === undefined ? false : await options.runs.settleRecovered(options.runId, outcome);
91
91
  if (!upgraded)
92
92
  await options.runs.settle(options.runId, outcome);
@@ -60,7 +60,7 @@ export async function runResumeCommand(options) {
60
60
  const current = tx.rows().find(item => item.id === selected.id);
61
61
  if (current === undefined)
62
62
  return null;
63
- const moved = tx.transition(selected.id, { type: 'staged' }, current.version);
63
+ const moved = tx.transition(selected.id, { type: 'staged', stagingRef: staged.entry.ref, stagingExpiresAt: staged.entry.expiresAt }, current.version);
64
64
  return moved.ok && moved.row !== null ? v4ToLegacy(moved.row) : null;
65
65
  });
66
66
  if (fused === null)
@@ -71,6 +71,7 @@ export async function runResumeCommand(options) {
71
71
  const result = await withMergeRun({
72
72
  io: options.io, home: options.home, registry: options.registry,
73
73
  command: 'resume', approval: { platformWrite: options.platformWrite, mergeApproval: options.approval }, pairKey: row.pairKey,
74
+ ...(row.stagingRef === undefined ? {} : { stagingRef: row.stagingRef }),
74
75
  parentRunId, now: options.now,
75
76
  ...(options.detached === true ? { detached: true } : {}),
76
77
  async body(ctx) {
@@ -5,6 +5,11 @@ import { casExpiredHook } from "./domain/failure-route.js";
5
5
  import { recordPair } from "./domain/run-coordinator.js";
6
6
  export async function recordRound2Outcome(io, home, now, row, outcome, deferProjection = false) {
7
7
  void now;
8
+ const reportExtra = {
9
+ ...(outcome.manifestFiles === undefined ? {} : { manifestFiles: outcome.manifestFiles }),
10
+ ...(outcome.criteria === undefined ? {} : { criteria: outcome.criteria }),
11
+ ...(outcome.validationIssues === undefined ? {} : { validation: { issues: outcome.validationIssues } }),
12
+ };
8
13
  if (outcome.kind === 'merged') {
9
14
  const unitRef = { unitId: outcome.unitId ?? 'unknown', evidenceSha256: outcome.evidenceSha256 ?? 'unknown' };
10
15
  const target = outcome.target?.actual ?? outcome.target?.requested ?? row.decision?.target;
@@ -25,7 +30,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
25
30
  });
26
31
  };
27
32
  if (deferProjection)
28
- return { merged: true, finalize, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, ...(row.decision === undefined ? {} : { decision: row.decision }), outcome: 'merged', archived: outcome.archived ?? [], ...(outcome.unitId === undefined ? {} : { unitId: outcome.unitId }), ...(outcome.target === undefined ? {} : { target: outcome.target }) } };
33
+ return { merged: true, finalize, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, ...(row.decision === undefined ? {} : { decision: row.decision }), outcome: 'merged', archived: outcome.archived ?? [], ...(outcome.lengths === undefined ? {} : { lengths: outcome.lengths }), ...(outcome.unitId === undefined ? {} : { unitId: outcome.unitId }), ...(outcome.target === undefined ? {} : { target: outcome.target }), ...reportExtra } };
29
34
  {
30
35
  await finalize();
31
36
  }
@@ -35,6 +40,8 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
35
40
  id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue,
36
41
  ...(row.decision === undefined ? {} : { decision: row.decision }),
37
42
  outcome: 'merged', archived: outcome.archived ?? [],
43
+ ...(outcome.lengths === undefined ? {} : { lengths: outcome.lengths }),
44
+ ...reportExtra,
38
45
  ...(outcome.unitId === undefined ? {} : { unitId: outcome.unitId }),
39
46
  ...(outcome.target === undefined ? {} : { target: outcome.target }),
40
47
  ...(outcome.validationPlan === undefined ? {} : { validationPlan: outcome.validationPlan }),
@@ -42,39 +49,48 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
42
49
  };
43
50
  }
44
51
  const failure = outcome.failure ?? { stage: 'author', code: 'merge-failed', message: 'round2 failed', retryable: true, retryFrom: 'agent2' };
45
- if (failure.code === 'merge-over-limit' || failure.code === 'limit' || failure.code === 'merge-validation') {
46
- const reason = failure.code === 'merge-validation' ? 'validation-failed' : 'merge-over-limit';
52
+ if (failure.code === 'merge-over-limit' || failure.code === 'limit') {
53
+ const reason = 'merge-over-limit';
47
54
  const skipped = await pairLedger(io, home).transact(tx => {
48
55
  const current = tx.rows().find(item => item.id === row.id);
49
56
  return current === undefined ? false : tx.transition(row.id, { type: 'skipped', reason, origin: 'system', skipSource: 'system' }, current.version).ok;
50
57
  });
51
58
  if (skipped)
52
59
  await dropStage(io, home, row.id);
53
- return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, ...(row.decision === undefined ? {} : { decision: row.decision }), outcome: 'failed', archived: [], failure } };
60
+ return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, ...(row.decision === undefined ? {} : { decision: row.decision }), outcome: 'failed', archived: [], failure, ...reportExtra } };
54
61
  }
55
62
  const cas = casExpiredHook(failure.code);
56
63
  if (cas !== null) {
64
+ const terminalFailure = { ...failure, code: cas.reason, retryable: false, retryFrom: 'none' };
57
65
  await pairLedger(io, home).transact(tx => {
58
66
  const current = tx.rows().find(item => item.id === row.id);
59
67
  if (current === undefined)
60
68
  return false;
61
- const moved = tx.transition(row.id, { type: 'skipped', reason: cas.reason, origin: 'system', skipSource: 'system' }, current.version);
69
+ const moved = tx.transition(row.id, { type: 'failed', failure: terminalFailure }, current.version);
62
70
  return moved.ok;
63
71
  });
64
72
  await dropStage(io, home, row.id);
65
- return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, ...(row.decision === undefined ? {} : { decision: row.decision }), outcome: 'failed', archived: [], failure: { ...failure, code: cas.reason, retryable: false, retryFrom: 'none' } } };
73
+ return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, ...(row.decision === undefined ? {} : { decision: row.decision }), outcome: 'failed', archived: [], failure: terminalFailure, ...reportExtra } };
66
74
  }
67
75
  if (failure.code === 'commit-failed' || failure.code === 'validation-failed') {
68
- await pairLedger(io, home).transact(tx => {
76
+ // FR-EXEC-11/14: report-only failure. The pair leaves the active queue (so a later scan
77
+ // can evaluate it again); it is NOT written to skipped.
78
+ const dropped = await pairLedger(io, home).transact(tx => {
69
79
  const current = tx.rows().find(item => item.id === row.id);
70
80
  return current === undefined ? false : tx.remove(row.id, 'superseded', undefined, current.version).ok;
71
81
  });
82
+ if (dropped) {
83
+ await dropStage(io, home, row.id, row.stagingRef);
84
+ await recordPair(io, home, { pairKey: row.pairKey, runId: row.runId ?? 'merge', result: 'failed', ...(row.decision?.target === undefined ? {} : { target: row.decision.target }) });
85
+ }
72
86
  }
73
87
  if (failure.code === 'source-gone') {
74
- await pairLedger(io, home).transact(tx => {
88
+ const dropped = await pairLedger(io, home).transact(tx => {
75
89
  const current = tx.rows().find(item => item.id === row.id);
76
90
  return current === undefined ? false : tx.remove(row.id, 'superseded', undefined, current.version).ok;
77
91
  });
92
+ if (dropped)
93
+ await dropStage(io, home, row.id, row.stagingRef);
78
94
  return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, outcome: 'failed', archived: [], failure } };
79
95
  }
80
96
  const projected = await pairLedger(io, home).transact(tx => {
@@ -85,7 +101,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
85
101
  return moved.ok && moved.row !== null ? v4ToLegacy(moved.row) : null;
86
102
  });
87
103
  if (projected === null)
88
- return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, outcome: 'failed', archived: [], failure: { ...failure, code: 'row-changed', message: 'row changed before failure record' } } };
104
+ return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, outcome: 'failed', archived: [], failure: { ...failure, code: 'row-changed', message: 'row changed before failure record' }, ...reportExtra } };
89
105
  {
90
106
  await recordPair(io, home, {
91
107
  pairKey: row.pairKey,
@@ -100,6 +116,7 @@ export async function recordRound2Outcome(io, home, now, row, outcome, deferProj
100
116
  id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue,
101
117
  ...(row.decision === undefined ? {} : { decision: row.decision }),
102
118
  outcome: 'failed', archived: [], failure,
119
+ ...reportExtra,
103
120
  },
104
121
  };
105
122
  }
@@ -60,6 +60,7 @@ export interface WithMergeRunOptions {
60
60
  readonly total?: number;
61
61
  readonly pairKey?: string;
62
62
  readonly pairCount?: number;
63
+ readonly stagingRef?: string;
63
64
  readonly parentRunId?: string;
64
65
  readonly detached?: boolean;
65
66
  readonly timeoutMinutes?: number;