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
5 changes: 5 additions & 0 deletions .changeset/restore-rollback-rolls-back-io-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`taskless rule restore` and `taskless rule rollback` no longer leave a half-written rule when a write fails part-way, for example on a full disk or an unwritable file. The rule is written in full under `.taskless/` first and then moved over its directory, and a failed move puts the previous version back, so `.taskless/rules/` is left as it was. Under `--json` that failure is now `RULE_RESTORE_FAILED`, and a served rule that cannot be written is `RULE_RESTORE_MISMATCH`, rather than `INTERNAL_ERROR`. The rule directory is replaced as a whole, so files the served rule does not include no longer survive a restore, and the "stale entries could not be removed" notice is gone.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-08
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Why

`multi-rule-delivery-rolls-back-io-failure` made `rule create` and `rule improve` stage every rule and move it into place with `rename`, rolling back on an IO failure. `rule restore` and `rule rollback` were left on the in-place write, so an IO failure part-way through (a full disk, `EACCES` on one file) could leave `.taskless/rules/<engine>/<id>/` holding the new rule file beside the old fixtures, while the command failed with `INTERNAL_ERROR` (#636).

## What Changes

- `rule restore` and `rule rollback` write their one rule through the same staged delivery as `rule create`: staged in full under `.taskless/`, then renamed over the rule directory, with any version already there renamed aside first and back again on failure.
- A write that fails leaves `.taskless/rules/` as it was and no staging directory behind, and exits with a new code, `RULE_RESTORE_FAILED`, rather than `INTERNAL_ERROR`.
- A verified served set that is still not a writable rule (an unsafe fixture path, an incomplete set) exits with `RULE_RESTORE_MISMATCH` rather than `INTERNAL_ERROR`, and writes nothing.
- The rule directory is replaced whole, so the "rule written, but a stale entry beside it could not be removed" success with a notice no longer exists. Every entry of the old directory goes with it, including ones the in-place purge declined to touch.
- The in-place `writeServedRule` and `writePreparedRule` are removed; no command writes a rule in place any more.
- What is not atomic is stated, as for `rule create`: a process killed outright between the two renames leaves the replaced version under `.taskless/.tmp-delivery-*`.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-generated-rule-delivery`: a delivered rule replaces its directory whole; the stale-entry notice is gone.
- `cli-rule-recovery`: restore and rollback roll back a failed write, and report it as `RULE_RESTORE_FAILED`.

## Impact

- `packages/cli/src/rules/recover.ts` (`write`), `packages/cli/src/rules/files.ts` (removes `writeServedRule` and `writePreparedRule`), `packages/cli/src/types/errors.ts` (`RULE_RESTORE_FAILED`), and the `recover-rule` recipe's error table.
- Delivery shape: **single PR**. The change is one write path and its tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## MODIFIED Requirements

### Requirement: A delivered rule replaces its directory

After verification, the CLI SHALL write a served rule so that its directory holds exactly the
served files: every file in the directory that the served set does not contain SHALL be removed,
including under `tests/`, since a served set always carries the rule's fixtures. The CLI SHALL
create each served file's parent directories as needed, so a nested path such as
`captures/env.yml` or `tests/fail/eval-call/case.ts` is written wherever it lands. The CLI
SHALL write the served set in full into a staging directory and move it over the rule
directory as a unit, so the old directory is removed whole, every entry in it included, and
there is no outcome in which the served bytes are written while a stale entry beside them
survives.

A served set whose fixtures are in the old format, under `.tests/`, SHALL be migrated just in
time: converted the same way migration 12 converts a local rule, written in the `tests/`
layout, and never written under `.tests/`. Fixtures carry no signature, so the conversion
changes no signed byte, and the CLI never writes a layout its own `verify` does not read. This
is a permanent reading of the old format, not a transition step. The CLI SHALL NOT depend on
the service emitting the new layout, and a served set already in the new layout SHALL be
written unchanged. A rule served with several legacy test files SHALL keep every snippet:
case names SHALL NOT collide across them, nor with a case the served set already carries
under `tests/`, nor with the name a fixture moved as served keeps. `sg test`'s
`__snapshots__/` SHALL be dropped and named. Fixtures the converter cannot place SHALL be written
under `tests/` at their served path below `.tests/`, and the delivery SHALL say so, naming them.
`verify` then refuses them by path, so an unplaced fixture is never mistaken for a passing one.

#### Scenario: A local extra file does not survive

- **WHEN** a rule directory holds `captures/extra.yml` and the served set does not
- **THEN** after the write `captures/extra.yml` SHALL NOT exist

#### Scenario: Nested paths are written

- **WHEN** a served set carries `tests/fail/eval-call/case.ts` and the rule directory does not exist
- **THEN** the CLI SHALL create `tests/fail/eval-call/` and write the file

#### Scenario: Legacy fixture paths are written in the new layout

- **WHEN** a served `sg` rule carries its fixtures as `.tests/<id>-test.yml` with `valid:` and `invalid:` snippets
- **THEN** the CLI SHALL write them as case directories under `tests/pass/` and `tests/fail/`
- **AND** the rule directory SHALL NOT contain `.tests/` afterwards

#### Scenario: Several legacy test files keep every snippet

- **WHEN** a served `sg` rule carries two legacy test files, each with one `invalid:` snippet
- **THEN** the CLI SHALL write two `fail/` cases, one per snippet

#### Scenario: A served set in both layouts keeps its cases apart

- **WHEN** a served `sg` rule carries `tests/fail/fail-01/` and a legacy test file with one `invalid:` snippet
- **THEN** the CLI SHALL write the converted snippet as `tests/fail/fail-02/`
- **AND** `tests/fail/fail-01/` SHALL hold only what was served there

#### Scenario: A fixture the converter cannot place is written and named

- **WHEN** a served `sg` rule's `files:` and `ignores:` admit no path the converter can derive for its `.tests/<id>-test.yml`
- **THEN** the CLI SHALL write that file at `tests/<id>-test.yml`, not under `.tests/`
- **AND** the delivery SHALL warn, naming the file and why
- **AND** the next `verify` of the rule SHALL report `tests/<id>-test.yml` as a stray entry

#### Scenario: A served set in the new layout is written as served

- **WHEN** a served set carries its fixtures under `tests/pass/<case>/` and `tests/fail/<case>/`
- **THEN** the CLI SHALL write them at those paths without conversion

#### Scenario: A generated rule's revision is confirmed

- **WHEN** `rule create` or `rule improve` fetches a produced rule whose served `revisionId` differs from the one the request reported
- **THEN** the CLI SHALL refuse that rule and write nothing for it

#### Scenario: An entry the old directory held is gone after the write

- **WHEN** a rule directory holds `notes.md` and `tests/pass/old/old.ts`, and the served set names neither
- **THEN** after the write the rule directory SHALL hold exactly the served files
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
## MODIFIED Requirements

### Requirement: Recovery commands report under --json

Under `--json`, `rule restore` and `rule rollback` SHALL print
`{ success: true, ruleId, revisionId, files, notices? }` on success and the standardized error
envelope `{ ok: false, code, message }` otherwise, with `code` distinguishing
`RULE_RECOVERY_NOT_IN_PLAN`, `RULE_NOT_FOUND`, `REVISION_NOT_FOUND`, `RULE_RESTORE_MISMATCH`,
`RULE_RESTORE_FAILED`, `AUTH_REQUIRED`, and `NETWORK_ERROR`.

#### Scenario: A refusal is machine-readable

- **WHEN** `rule restore --json` is refused for the plan
- **THEN** stdout SHALL be `{ ok: false, code: "RULE_RECOVERY_NOT_IN_PLAN", message }` with `message` carrying the server's guidance

## ADDED Requirements

### Requirement: A restore or rollback that cannot be written leaves the rule as it was

`rule restore` and `rule rollback` SHALL write the served rule through the same staged delivery
as `rule create`: the rule SHALL be written in full under `.taskless/`, on the same filesystem as
`.taskless/rules/`, and then moved over its rule directory, with any version already there moved
aside first. When an IO failure occurs while staging or moving, the CLI SHALL move back any
version it moved aside, SHALL remove the staging directory, and SHALL exit with
`RULE_RESTORE_FAILED`, leaving `.taskless/rules/` as it was. When the move back itself cannot
finish, the CLI SHALL keep the staging directory and SHALL name where the replaced version is.

A served set that passed verification but is not a writable rule, such as a fixture at an unsafe
path, SHALL be refused with `RULE_RESTORE_MISMATCH`, writing nothing.

A process killed outright between the two moves is outside this guarantee: each move is atomic,
the pair is not, and no rollback runs. The replaced version SHALL then still be on disk in the
staging directory.

#### Scenario: A restore that fails to write leaves the edited rule

- **WHEN** `rule restore --json` restores an edited rule and moving the served rule into place fails with an IO error
- **THEN** stdout SHALL be `{ ok: false, code: "RULE_RESTORE_FAILED", message }`
- **AND** `.taskless/rules/` SHALL be byte-identical to how it started
- **AND** no staging directory SHALL remain under `.taskless/`

#### Scenario: A restore of a missing rule that fails to write leaves no rule

- **WHEN** `rule restore --json` restores a missing rule and moving it into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_RESTORE_FAILED`
- **AND** the rule's directory SHALL NOT exist
- **AND** no staging directory SHALL remain under `.taskless/`

#### Scenario: A rollback that fails to write leaves the current version

- **WHEN** `rule rollback --json` replaces a rule already on disk and moving the served revision into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_RESTORE_FAILED`
- **AND** `.taskless/rules/` SHALL be byte-identical to how it started, including entries the served set does not name

#### Scenario: A read-only file in the replaced version does not split the rule

- **WHEN** `rule rollback` replaces a rule whose fixture file is read-only
- **THEN** the rollback SHALL succeed
- **AND** the rule directory SHALL hold exactly the served revision's files

#### Scenario: A served set that cannot be written is a mismatch

- **WHEN** a served set passes verification but carries a fixture at a path containing `..`
- **THEN** the CLI SHALL exit with `RULE_RESTORE_MISMATCH` and write nothing
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 1. Delivery

- [x] 1.1 Write `rule restore` and `rule rollback` through `writePreparedRules`, reporting a write failure as `RULE_RESTORE_FAILED` and an unwritable served set as `RULE_RESTORE_MISMATCH`
- [x] 1.2 Remove the stale-entry branch, and `writeServedRule` and `writePreparedRule`, which nothing calls any more
- [x] 1.3 Test a restore and a rollback whose write fails, with the rule both new and replacing an older version, and a rollback over a version holding a read-only file
12 changes: 10 additions & 2 deletions openspec/specs/cli-generated-rule-delivery/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,11 @@ After verification, the CLI SHALL write a served rule so that its directory hold
served files: every file in the directory that the served set does not contain SHALL be removed,
including under `tests/`, since a served set always carries the rule's fixtures. The CLI SHALL
create each served file's parent directories as needed, so a nested path such as
`captures/env.yml` or `tests/fail/eval-call/case.ts` is written wherever it lands. When a stale file cannot be removed, the CLI SHALL say that the served
bytes were written and name each entry it could not remove.
`captures/env.yml` or `tests/fail/eval-call/case.ts` is written wherever it lands. The CLI
SHALL write the served set in full into a staging directory and move it over the rule
directory as a unit, so the old directory is removed whole, every entry in it included, and
there is no outcome in which the served bytes are written while a stale entry beside them
survives.

A served set whose fixtures are in the old format, under `.tests/`, SHALL be migrated just in
time: converted the same way migration 12 converts a local rule, written in the `tests/`
Expand Down Expand Up @@ -177,6 +180,11 @@ under `tests/` at their served path below `.tests/`, and the delivery SHALL say
- **WHEN** `rule create` or `rule improve` fetches a produced rule whose served `revisionId` differs from the one the request reported
- **THEN** the CLI SHALL refuse that rule and write nothing for it

#### Scenario: An entry the old directory held is gone after the write

- **WHEN** a rule directory holds `notes.md` and `tests/pass/old/old.ts`, and the served set names neither
- **THEN** after the write the rule directory SHALL hold exactly the served files

### Requirement: Every rule of a request is checked before any is written

When a generation request produced more than one rule, the CLI SHALL verify every rule's
Expand Down
50 changes: 49 additions & 1 deletion openspec/specs/cli-rule-recovery/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Under `--json`, `rule restore` and `rule rollback` SHALL print
`{ success: true, ruleId, revisionId, files, notices? }` on success and the standardized error
envelope `{ ok: false, code, message }` otherwise, with `code` distinguishing
`RULE_RECOVERY_NOT_IN_PLAN`, `RULE_NOT_FOUND`, `REVISION_NOT_FOUND`, `RULE_RESTORE_MISMATCH`,
`AUTH_REQUIRED`, and `NETWORK_ERROR`.
`RULE_RESTORE_FAILED`, `AUTH_REQUIRED`, and `NETWORK_ERROR`.

#### Scenario: A refusal is machine-readable

Expand Down Expand Up @@ -258,3 +258,51 @@ not a failure of the service" requires. `--json` output SHALL NOT change.
- **WHEN** `check` reports sg rule `foo-1` as `missing`, its deletion is committed, and `restoreRules` is `false`
- **THEN** the `git restore` command for a committed change SHALL take `--source=<commit>~1`
- **AND** following it with the newest commit `git log` lists SHALL put the rule's files back

### Requirement: A restore or rollback that cannot be written leaves the rule as it was

`rule restore` and `rule rollback` SHALL write the served rule through the same staged delivery
as `rule create`: the rule SHALL be written in full under `.taskless/`, on the same filesystem as
`.taskless/rules/`, and then moved over its rule directory, with any version already there moved
aside first. When an IO failure occurs while staging or moving, the CLI SHALL move back any
version it moved aside, SHALL remove the staging directory, and SHALL exit with
`RULE_RESTORE_FAILED`, leaving `.taskless/rules/` as it was. When the move back itself cannot
finish, the CLI SHALL keep the staging directory and SHALL name where the replaced version is.

A served set that passed verification but is not a writable rule, such as a fixture at an unsafe
path, SHALL be refused with `RULE_RESTORE_MISMATCH`, writing nothing.

A process killed outright between the two moves is outside this guarantee: each move is atomic,
the pair is not, and no rollback runs. The replaced version SHALL then still be on disk in the
staging directory.

#### Scenario: A restore that fails to write leaves the edited rule

- **WHEN** `rule restore --json` restores an edited rule and moving the served rule into place fails with an IO error
- **THEN** stdout SHALL be `{ ok: false, code: "RULE_RESTORE_FAILED", message }`
- **AND** `.taskless/rules/` SHALL be byte-identical to how it started
- **AND** no staging directory SHALL remain under `.taskless/`

#### Scenario: A restore of a missing rule that fails to write leaves no rule

- **WHEN** `rule restore --json` restores a missing rule and moving it into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_RESTORE_FAILED`
- **AND** the rule's directory SHALL NOT exist
- **AND** no staging directory SHALL remain under `.taskless/`

#### Scenario: A rollback that fails to write leaves the current version

- **WHEN** `rule rollback --json` replaces a rule already on disk and moving the served revision into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_RESTORE_FAILED`
- **AND** `.taskless/rules/` SHALL be byte-identical to how it started, including entries the served set does not name

#### Scenario: A read-only file in the replaced version does not split the rule

- **WHEN** `rule rollback` replaces a rule whose fixture file is read-only
- **THEN** the rollback SHALL succeed
- **AND** the rule directory SHALL hold exactly the served revision's files

#### Scenario: A served set that cannot be written is a mismatch

- **WHEN** a served set passes verification but carries a fixture at a path containing `..`
- **THEN** the CLI SHALL exit with `RULE_RESTORE_MISMATCH` and write nothing
3 changes: 2 additions & 1 deletion packages/cli/src/agent/recover-rule.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v4)
# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v5)

%(ONE_COMMAND_PER_CALL)s

Expand Down Expand Up @@ -145,6 +145,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`:
| `RULE_NOT_FOUND` | Taskless did not issue this rule for this repository | check the id; a local rule cannot be restored |
| `REVISION_NOT_FOUND` | rollback named a revision that is not this rule's | list them with `%(TASKLESS_CLI)s rule revisions <ruleId>` |
| `RULE_RESTORE_MISMATCH` | the service served bytes other than the expected ones | nothing was written; report it, do not retry blindly |
| `RULE_RESTORE_FAILED` | the verified rule could not be written to disk | the rule is as it was; fix what `message` names, then run the command again |
| `RULE_ID_AMBIGUOUS` | two engines hold this id | rename the local one, then restore |
| `NETWORK_ERROR` | the service could not be reached or failed | report and suggest a retry |

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/rules/deliver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,12 @@ async function purgeUndeliveredFiles(
* Never swallowed. A stray this could not remove is still a file an engine
* reads, and an unreadable stray is indistinguishable from a clean directory
* unless somebody is told.
*
* Every command now writes through `writePreparedRules`, which calls
* {@link writeDeliveredFileSet} on a fresh staging directory and renames the
* result over the rule directory whole, so in that path there is nothing
* stale to purge. Should this be thrown there all the same, it fails the
* delivery and is rolled back like any other write error.
*/
export class PurgeIncompleteError extends Error {
/** Each entry that survived, with the errno that stopped it. */
Expand Down
Loading
Loading