Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/migration-9-atomicity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@taskless/cli": patch
---

Scaffold migration `9` — the one that renames a rule id held by more than one engine — now survives being interrupted, and no longer double-suffixes a fixture you had already named `<id>-sg-…`.

Migration 9 has not been in a released version, so nothing on disk anywhere was produced by the old behaviour and there is no repair step to run. `latest` is `0.11.2`, tagged 2026-09-19; the migration landed 2026-09-22.

**It could not resume.** It renamed the rule directory first and then chased the files inside it, but renaming the directory is what resolves the collision, and the migration returns early when no collision is left. So a run that died in between — a `Ctrl-C`, a full disk, an editor holding a file open — left `sg/no-eval-sg/` containing `no-eval.yml` with `id: no-eval` and fixtures still under the `no-eval-` prefix. `verify` reported that as broken, and running `taskless init` again fixed nothing, because every later run found no collision and returned.

The order is reversed: the rule file, its `id:` field, the `.tests/` fixtures and a Vale rule's `.vale.ini` are all rewritten under the old directory name, and the directory rename is the last thing to happen. A directory rename is a single atomic operation, so it is the moment a rule is done. A rule interrupted before it still collides and is picked up by the next run; a rule interrupted after it is already whole. Each inner step also tolerates having already run, and every file rewrite is committed by renaming a temporary sibling, so an interrupted write cannot truncate a rule file.

One asymmetry can survive an interruption. Where `sg` and `vale` both hold an id, a complete run moves both and neither keeps the bare id. If a run is interrupted between the two halves, the half that finished keeps its suffix and the other keeps the bare id, because 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. Rename it yourself if you want the pair to match.

**A fixture already named `<id>-sg-…` is no longer renamed again.** The predicate picking fixtures to rename matched every name it produced, so `no-eval-sg-basic-test.yml` in `sg/no-eval/.tests/` came out as `no-eval-sg-sg-basic-test.yml` on the first run. Such a fixture is already at the right prefix, so it now keeps its name and only its `id:` field follows — which still matters, since ast-grep attributes cases by the `id:` inside the file and a stale one reads as a rule that shipped no cases.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-23
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
## Why

Migration `9` renames rule directories whose id is held by more than one
engine. Two defects were found while verifying that the nine scaffold
migrations are idempotent (taskless/cli#395). Idempotency holds — repeated
COMPLETE runs converge, measured by tree snapshot across a double run. What
does not hold is atomicity.

**It cannot resume after an interrupted run.** The migration renamed the rule
DIRECTORY first and then chased the files inside it. A collision is defined as
one directory name appearing under two or more engines, so the directory
rename is the operation that CLEARS the collision — and the migration returns
early when there are none. A crash between the directory rename and the rest
therefore leaves `sg/no-eval-sg/` holding `no-eval.yml` with `id: no-eval` and
fixtures still under the `no-eval-` prefix, a tree `verify` reports as broken
and that no number of re-runs repairs, because every later run returns at the
collision gate having found nothing to do.

**The fixture predicate matched its own output.** `entry.startsWith(`${from}-`)`
accepted every name the loop produced, since the replacement is `${from}-sg`.
Re-running could not trigger it — the directory rename throws `ENOENT`first —
but a fixture a human had named`no-eval-sg-basic-test.yml`BEFORE the
migration ran came out as`no-eval-sg-sg-basic-test.yml` on the first run. It
is also the step a resumed run repeats, so it stops being cosmetic the moment
the resume above exists.

**Migration 9 has never shipped, so its behaviour is changed in place.**
`npm view @taskless/cli dist-tags` reports `latest: 0.11.2`, tagged
2026-09-19; the migration landed in `d41576f` on 2026-09-22, and
`git merge-base --is-ancestor d41576f v0.11.2` fails. No user has run it, so
there is no already-migrated tree in the wild and no migration `10` to write.

## What Changes

- **Ordering is the fix.** Every edit inside a rule now happens under the OLD
directory name, and the directory rename runs LAST as the single atomic
commit. A rule that crashes before it still holds the colliding id, so the
collision gate finds it again; a rule that crashes after it is already whole.
The gate becomes a sound resume signal rather than merely a no-op check.
- Each step inside the directory tolerates having already run: a rule file
whose source is gone but whose target is present still has its `id:`
rewritten, and file rewrites are committed by renaming a temporary sibling.
- The fixture predicate skips a name already carrying the target prefix and
rewrites only its `id:`, so it can no longer match what it produces.
- Tests: a crash injected at three named `rename` destinations, each resumed
and asserted to reach a consistent directory, fixture name and `id:` field;
and the pre-existing `-sg-` fixture name. Two of the three crash points and
the double-suffix case fail against the previous code.

## Capabilities

### New Capabilities

None. `cli-taskless-bootstrap` gains one requirement.

### Modified Capabilities

None. "Migration 9 renames a rule id held by more than one engine" is
unchanged: it already requires every `.tests/<id>-*-test.yml` to end at the new
prefix with its `id:` rewritten, which a fixture already at that prefix
satisfies without being renamed again.

## Impact

`packages/cli/src/filesystem/migrations/0009-unique-rule-ids.ts` and its test.
No public surface, no other command. The bump is `patch`: the migration has
never been in a released version, so no consumer crosses this boundary.

## Delivery shape

**Single PR.** One source file, one test file, one spec requirement — a diff
under 300 hand-written lines that is only reviewable together, since the
ordering change and the tests that prove it are the same argument. It is the
tip, so the change is archived here.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## ADDED Requirements

### Requirement: Migration 9 resumes after an interrupted run

Migration `9` SHALL be resumable: after a run that ends part-way through, for
any reason, a subsequent run SHALL bring the scaffold to the same end state a
single uninterrupted run would have reached.

Every edit a rule needs SHALL be made inside the rule's existing directory, and
the directory rename SHALL be the LAST operation of that rule's rename. A
directory rename is a single atomic filesystem operation, so it is the point at
which a rule is done, and no earlier step SHALL be observable as progress.

Because the directory rename is also the operation that clears the collision,
the collision scan SHALL remain a sound resume signal: a rule interrupted
before its commit still holds the colliding id and SHALL be enumerated again,
and a rule interrupted after its commit is already consistent. The migration
SHALL therefore still write nothing when no collision remains.

Each step inside a rule's directory SHALL tolerate having already run:

- a rule file whose old name is gone and whose new name is present SHALL still
have its `id:` field rewritten, rather than being treated as absent
- a fixture already carrying the target prefix SHALL NOT be renamed again, and
only its `id:` field SHALL follow
- a file rewrite SHALL be committed by renaming a temporary sibling over the
target, so an interrupted write SHALL NOT leave a truncated rule file

The predicate selecting fixtures to rename SHALL NOT match the names it
produces. The target id is always the old id plus a suffix, so a predicate
keyed only on the old id accepts its own output and appends the suffix twice.

#### Scenario: A run interrupted before a rule's directory rename is resumed

- **WHEN** migration 9 fails after rewriting a colliding rule's file, `id:`
field or fixtures but before its directory is renamed
- **THEN** the rule SHALL still hold the colliding id
- **AND** a subsequent run SHALL enumerate it again and complete the rename
- **AND** the rule's directory name, rule file name, fixture names and every
`id:` field SHALL agree afterwards

#### Scenario: A run interrupted after a rule's directory rename leaves that rule whole

- **WHEN** migration 9 fails immediately after a rule's directory is renamed
- **THEN** that rule SHALL already carry its new id in its directory name, its
rule file, its fixtures and every `id:` field
- **AND** a subsequent run SHALL have nothing to do for it

#### Scenario: A fixture already at the target prefix is not suffixed twice

- **WHEN** a colliding `sg` rule's `.tests/` holds a fixture whose name already
begins with the target id, whether written by hand before the migration ran
or renamed by an interrupted run
- **THEN** the fixture SHALL keep its name
- **AND** its `id:` field SHALL be rewritten to the new id
23 changes: 23 additions & 0 deletions openspec/changes/archive/2026-09-23-migration-9-atomicity/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## 1. Implementation

- [x] 1.1 Move every in-directory edit ahead of the directory rename, so the
rename is the commit point.
- [x] 1.2 Make `renameRuleFile` finish a rename interrupted between the file
rename and the `id:` rewrite.
- [x] 1.3 Make the fixture predicate skip a name already at the target prefix,
rewriting only its `id:`.
- [x] 1.4 Commit file rewrites by renaming a temporary sibling.

## 2. Tests

- [x] 2.1 Inject a crash at three named `rename` destinations, resume, and
assert directory, fixture name and `id:` all agree.
- [x] 2.2 Prove the crash-resume and double-suffix cases fail against the
previous source.
- [x] 2.3 Keep the double-run snapshot idempotency coverage passing.

## 3. Spec

- [x] 3.1 ADD the crash-resilience requirement to `cli-taskless-bootstrap`.
- [x] 3.2 Dry-run `openspec archive` and compare requirement and scenario
counts before and after.
54 changes: 54 additions & 0 deletions openspec/specs/cli-taskless-bootstrap/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,57 @@ It SHALL write nothing when there is no collision. A project in that state SHALL

- **WHEN** `.taskless/rules/` does not exist and migration 9 runs
- **THEN** the migration SHALL succeed and write nothing

### Requirement: Migration 9 resumes after an interrupted run

Migration `9` SHALL be resumable: after a run that ends part-way through, for
any reason, a subsequent run SHALL bring the scaffold to the same end state a
single uninterrupted run would have reached.

Every edit a rule needs SHALL be made inside the rule's existing directory, and
the directory rename SHALL be the LAST operation of that rule's rename. A
directory rename is a single atomic filesystem operation, so it is the point at
which a rule is done, and no earlier step SHALL be observable as progress.

Because the directory rename is also the operation that clears the collision,
the collision scan SHALL remain a sound resume signal: a rule interrupted
before its commit still holds the colliding id and SHALL be enumerated again,
and a rule interrupted after its commit is already consistent. The migration
SHALL therefore still write nothing when no collision remains.

Each step inside a rule's directory SHALL tolerate having already run:

- a rule file whose old name is gone and whose new name is present SHALL still
have its `id:` field rewritten, rather than being treated as absent
- a fixture already carrying the target prefix SHALL NOT be renamed again, and
only its `id:` field SHALL follow
- a file rewrite SHALL be committed by renaming a temporary sibling over the
target, so an interrupted write SHALL NOT leave a truncated rule file

The predicate selecting fixtures to rename SHALL NOT match the names it
produces. The target id is always the old id plus a suffix, so a predicate
keyed only on the old id accepts its own output and appends the suffix twice.

#### Scenario: A run interrupted before a rule's directory rename is resumed

- **WHEN** migration 9 fails after rewriting a colliding rule's file, `id:`
field or fixtures but before its directory is renamed
- **THEN** the rule SHALL still hold the colliding id
- **AND** a subsequent run SHALL enumerate it again and complete the rename
- **AND** the rule's directory name, rule file name, fixture names and every
`id:` field SHALL agree afterwards

#### Scenario: A run interrupted after a rule's directory rename leaves that rule whole

- **WHEN** migration 9 fails immediately after a rule's directory is renamed
- **THEN** that rule SHALL already carry its new id in its directory name, its
rule file, its fixtures and every `id:` field
- **AND** a subsequent run SHALL have nothing to do for it

#### Scenario: A fixture already at the target prefix is not suffixed twice

- **WHEN** a colliding `sg` rule's `.tests/` holds a fixture whose name already
begins with the target id, whether written by hand before the migration ran
or renamed by an interrupted run
- **THEN** the fixture SHALL keep its name
- **AND** its `id:` field SHALL be rewritten to the new id
Loading
Loading