fix(migrations): let force-retry escape completed ledger entries (#2616)

statusForVersion short-circuited on any 'complete' entry before checking
the trailing 'retry' marker, so --force-retry appended an inert row and a
version marked complete with zero work done could never be re-run without
hand-editing completed.jsonl. Check retry-latest first: an explicit
--force-retry now yields 'pending' even past an earlier 'complete', while
a stray 'partial' after 'complete' still cannot regress the version.
This commit is contained in:
spiky02plateau
2026-07-23 09:17:01 -07:00
committed by GitHub
parent fc1f88cdcb
commit e79b8d5780
2 changed files with 42 additions and 6 deletions
+7 -6
View File
@@ -133,14 +133,15 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 — keep "complete wins" safety):
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - If the latest entry is `retry`, the version is pending. This is the
* explicit escape hatch written by `--force-retry`, and it overrides an
* earlier `complete` entry without hand-editing the ledger.
* - Otherwise, if any entry is `complete`, the version is complete.
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
* `complete` never regresses accidentally. A later `partial` append cannot
* undo a completed migration; only a trailing, explicit `retry` marker can.
*/
function statusForVersion(
version: string,
@@ -148,9 +149,9 @@ function statusForVersion(
): 'complete' | 'partial' | 'pending' | 'wedged' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
+35
View File
@@ -167,6 +167,41 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
});
});
describe('force-retry escape hatch', () => {
test("complete then retry-latest → pending and buildPlan lists the version as pending", () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'complete' },
{ version: '0.11.0', status: 'retry' },
]);
expect(statusForVersion('0.11.0', idx)).toBe('pending');
const plan = buildPlan(idx, '0.11.1', '0.11.0');
expect(plan.pending.map(m => m.version)).toEqual(['0.11.0']);
expect(plan.applied).toEqual([]);
expect(plan.partial).toEqual([]);
expect(plan.wedged).toEqual([]);
});
test('complete then stray partial without retry → still complete', () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'complete' },
{ version: '0.11.0', status: 'partial' },
]);
expect(statusForVersion('0.11.0', idx)).toBe('complete');
});
test('retry followed by a newer complete → complete', () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'complete' },
{ version: '0.11.0', status: 'retry' },
{ version: '0.11.0', status: 'complete' },
]);
expect(statusForVersion('0.11.0', idx)).toBe('complete');
});
});
// v0.36.1.x (cherry-pick #1062): list, dry-run, and "all migrations up to
// date" paths must exit 0 so shell scripts gating on the exit code work.
// Pre-fix, these `return` statements left the CLI dispatcher's implicit