@lmzhen/dsh-evolution-merge 0.30.23 → 0.30.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +518 -182
- package/lib/types/approve-command.js +2 -1
- package/lib/types/authoring-flow.d.ts +5 -0
- package/lib/types/authoring-flow.js +8 -5
- package/lib/types/commands.d.ts +7 -0
- package/lib/types/commands.js +8 -1
- package/lib/types/commit-execute.d.ts +4 -0
- package/lib/types/commit-execute.js +8 -5
- package/lib/types/commit-receipt.js +3 -1
- package/lib/types/contracts.d.ts +20 -1
- package/lib/types/doctor.js +1 -1
- package/lib/types/domain/errors.js +5 -1
- package/lib/types/domain/merge-snapshot.d.ts +12 -3
- package/lib/types/domain/merge-snapshot.js +9 -4
- package/lib/types/domain/reconcile.js +1 -1
- package/lib/types/domain/row-v4.d.ts +4 -0
- package/lib/types/domain/row-v4.js +9 -9
- package/lib/types/domain/run-coordinator.d.ts +9 -0
- package/lib/types/domain/stage-aggregate.d.ts +6 -1
- package/lib/types/domain/stage-aggregate.js +16 -9
- package/lib/types/domain/validation-plan.js +5 -1
- package/lib/types/index.js +14 -2
- package/lib/types/kernel/adapter-v4.d.ts +12 -0
- package/lib/types/kernel/adapter-v4.js +54 -0
- package/lib/types/kernel/failure.d.ts +8 -0
- package/lib/types/kernel/failure.js +33 -0
- package/lib/types/kernel/index.d.ts +9 -0
- package/lib/types/kernel/index.js +9 -0
- package/lib/types/kernel/invariants.d.ts +4 -0
- package/lib/types/kernel/invariants.js +47 -0
- package/lib/types/kernel/ledger.d.ts +46 -0
- package/lib/types/kernel/ledger.js +62 -0
- package/lib/types/kernel/machine.d.ts +13 -0
- package/lib/types/kernel/machine.js +108 -0
- package/lib/types/kernel/model.d.ts +154 -0
- package/lib/types/kernel/model.js +5 -0
- package/lib/types/kernel/projection.d.ts +28 -0
- package/lib/types/kernel/projection.js +44 -0
- package/lib/types/kernel/recovery.d.ts +31 -0
- package/lib/types/kernel/recovery.js +19 -0
- package/lib/types/kernel/unit.d.ts +43 -0
- package/lib/types/kernel/unit.js +63 -0
- package/lib/types/merge-routes.js +9 -0
- package/lib/types/new-child-authoring.d.ts +1 -0
- package/lib/types/new-child-authoring.js +15 -7
- package/lib/types/pending-actions.d.ts +1 -1
- package/lib/types/pending-actions.js +4 -0
- package/lib/types/recovery-coordinator.d.ts +2 -0
- package/lib/types/recovery-coordinator.js +14 -4
- package/lib/types/recovery-projection.d.ts +4 -2
- package/lib/types/recovery-projection.js +3 -3
- package/lib/types/resume-command.js +2 -1
- package/lib/types/round2-outcome.js +26 -9
- package/lib/types/run-shell.d.ts +1 -0
- package/lib/types/run-shell.js +57 -15
- package/lib/types/runtime.d.ts +4 -1
- package/lib/types/runtime.js +34 -22
- package/lib/types/scan-command.d.ts +1 -0
- package/lib/types/scan-command.js +1 -0
- package/lib/types/scan-ports.js +3 -0
- package/lib/types/scan-run.d.ts +6 -1
- package/lib/types/scan-run.js +34 -18
- package/lib/types/surface/merge-view.js +12 -4
- package/lib/types/undo-operation.d.ts +2 -1
- package/lib/types/undo-operation.js +10 -4
- package/lib/types/wire-projection.js +7 -2
- package/lib/types/wire.d.ts +1 -0
- package/package.json +4 -4
- package/lib/types/adapters/model.d.ts +0 -31
- package/lib/types/adapters/model.js +0 -47
- package/lib/types/gate-cache.d.ts +0 -12
- package/lib/types/gate-cache.js +0 -36
- package/lib/types/gate-filter.d.ts +0 -31
- package/lib/types/gate-filter.js +0 -64
- package/lib/types/ladder.d.ts +0 -65
- package/lib/types/ladder.js +0 -162
- package/lib/types/propose.d.ts +0 -26
- package/lib/types/propose.js +0 -69
- package/lib/types/screening-port.d.ts +0 -47
- package/lib/types/screening-port.js +0 -93
- package/lib/types/screening.d.ts +0 -30
- package/lib/types/screening.js +0 -75
|
@@ -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
|
|
98
|
-
// (manifest mismatch, support conflict, validation plan) is a
|
|
99
|
-
//
|
|
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'
|
|
46
|
-
const reason =
|
|
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: '
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/lib/types/run-shell.d.ts
CHANGED
|
@@ -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;
|