fix: make migration 9 crash-resilient and stop it double-suffixing fixtures - #400
Conversation
|
@claude /review |
|
Claude finished @thecodedrift's task in 5m 20s —— View job Review completeReview mode: incremental — read 0 prior review thread(s) before reviewing.
SummaryThis is a careful fix, and the reasoning in the PR body checks out against the code: making the directory rename the last operation is what turns the existing One finding, posted inline on Nothing else stood out: the atomic-write-via-temp-sibling pattern ( |
Migration 9 renamed a rule's directory first and then chased the files inside it. The directory rename is what clears the collision, and the migration returns early when none is left, so a crash in between left a rule whose files and `id:` fields still carried the old name and that no re-run repaired. Every in-directory edit now happens under the old name and the directory rename runs last, as a single atomic commit. A rule interrupted before it still collides and is enumerated again; one interrupted after it is whole. Each inner step tolerates having already run, and file rewrites commit by renaming a temporary sibling. The fixture predicate also matched its own output, so a fixture named `<id>-sg-...` by hand became `<id>-sg-sg-...` on the first run. A name already at the target prefix now keeps it and only its `id:` follows. Migration 9 has never shipped (latest is 0.11.2, tagged 2026-09-19; the migration landed 2026-09-22), so the behaviour changes in place and no migration 10 is needed.
A crash is injected at a named rename destination, not the Nth call, so each case says which step it interrupts. Two of the three, and the double-suffix case, fail against the previous ordering.
Purely ADDED: 11 -> 12 requirements and 44 -> 47 scenarios in cli-taskless-bootstrap, with nothing dropped, verified by an archive dry-run diffing both lists before and after.
The harness named a rename by its destination basename only. Putting `<to>.yml` in place and committing a rewrite OF `<to>.yml` are two renames with the same destination, and the first runs first, so the state between them — renamed, `id:` not yet written — was unreachable. That is exactly the state `renameRuleFile`'s resume branch exists to repair, and the same ambiguity hid `renameSgFixtures`' equivalent pair. Renames are now named by source, destination or both, which separates them: the commit's source is the `.tskl-0009.tmp` sibling. Two crash points added for those windows, plus a case that builds the same state by hand so the repair is covered independently of the temporary file's name. Verified non-vacuous: reverting either repair fails all four cases.
8332793 to
e31a6ad
Compare
The one finding was correct and is fixed in e31a6ad: the crash harness matched a rename by destination basename only, so Also rebased onto — AI Coding Agent |
Migration
9renames rule directories whose id is held by more than one engine. Two defects, found while verifying that the nine scaffold migrations are idempotent on #395. Idempotency itself holds — repeated complete runs converge, measured by tree snapshot across a double run. What did not hold is atomicity.Defect 1 — an interrupted run could never be repaired
A collision is one directory name appearing under two or more engines (
rules/id-uniqueness.ts:29,62-66), and the migration returns early when there are none (0009:112). The rename resolved the collision by renaming the rule directory first, then chased the files inside it.So a run that died in between —
Ctrl-C, a full disk, an editor holding a file open — leftsg/no-eval-sg/containingno-eval.ymlwithid: no-evaland fixtures still under theno-eval-prefix. That tree is collision-free, so every later run returned at:112having found nothing to do.verifyreported the rule as broken and no amount of re-running fixed it.The shape chosen: the directory rename is the commit point
Of the three options weighed, this one is the cheapest and the only one that needs no new state on disk:
rename(2)on a directory is atomic — and it is already the operation that clears the collision.Every edit a rule needs now happens inside the rule's old directory, and the directory rename runs last. Because that rename is also what clears the collision, the existing
collisions.length === 0gate becomes a sound resume signal rather than merely a no-op check: a rule interrupted before its commit still collides and is enumerated again; one interrupted after its commit is already whole. That is why no entry-time detection is needed.Each inner step is written to tolerate having already run — a rule file whose old name is gone but whose new name is present still gets its
id:rewritten (the old early return read that as "no rule file" and left the id behind) — and every file rewrite is committed by renaming a temporary sibling, so an interrupted write cannot truncate a rule file. The temporary name is derived from the target rather than randomized, so a crash between the write and the rename leaves one predictable path the next run overwrites and consumes.The residual windows, stated plainly
"Fully atomic on a filesystem" would be false, so here is what remains:
verifyreports it, and the next migration run finishes it. The scaffold version is written only after every migration returns, so the nexttasklesscommand re-runs this by itself.sgandvaleboth hold an id, a complete run moves both. If the run dies after the first commit, the half that finished keeps its suffix and the half that never started keeps the bare id — the collision it would have been renamed for is gone. The tree is collision-free and every rule is internally consistent; only the symmetry is lost. Called out in the changeset so a user can rename it if they want the pair to match.rename(2)returning and the directory entry reaching disk is not addressed, and no user-space rename dance would address it. It is afsyncquestion, not an ordering one.Defect 2 — the fixture predicate matched its own output
entry.startsWith(${from}-)at0009:276-277accepted every name the loop produced, since the replacement is${from}-sg. Re-running could not trigger it (the directory rename threwENOENTfirst), but a fixture a human had namedno-eval-sg-basic-test.ymlbefore the migration ran came out asno-eval-sg-sg-basic-test.ymlon the first run. It is also the step a resumed run repeats, so once defect 1 is fixed it stops being cosmetic.A name already carrying the target prefix is now never renamed — it is where it belongs either way — and only its
id:field follows. That half still matters: ast-grep attributes cases by theid:inside the file, so a stale one reads as a rule that shipped no cases.Migration 9 has never shipped, so there is no migration 10
No user has ever run it, so there is no already-migrated tree in the wild and nothing for a repair migration to repair. Its behaviour changes in place.
Tests
The centrepiece is a crash-and-resume case, injected at a named
renamedestination rather than at the Nth call, so each case says which step it interrupts and stays readable as the number of writes changes. Three points: the sg rule-file rename, the sg fixture rename, and the sg directory commit. Each asserts the interrupted rule still collides, then runs the migration again and requires the directory name, rule file name, fixture names and everyid:field to agree — plus thevalehalf, which the resuming run renames.Measured against the pre-fix source, with only
0009-unique-rule-ids.tsreverted:+0is the point: the old ordering had already cleared the collision, so there was nothing left for a re-run to find. The third point passes against both, since under the old ordering it was the first operation; it is kept as a guard on the commit-point semantics.No existing assertion was weakened. The double-run snapshot idempotency case at
rule-id-uniqueness.test.ts:339, which compares size and mtime across the whole tree, still passes unchanged.OpenSpec
cli-taskless-bootstrapgains one ADDED requirement, "Migration 9 resumes after an interrupted run", with three scenarios. Nothing is MODIFIED: the standing requirement already says every.tests/<id>-*-test.ymlends at the new prefix with itsid:rewritten, which a fixture already at that prefix satisfies without being renamed again.Archive dry-run, diffing both lists before and after:
Purely additive, nothing dropped. This is a single-PR change with no follow-up, so it is the tip and the change is archived here.
Gates
pnpm build,pnpm typecheck,pnpm lint(No issues found.) andpnpm test(102 files, 1702 tests, all passing).Changeset: one file,
patch— the migration has never been in a released version, so no consumer crosses this boundary.