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
2 changes: 2 additions & 0 deletions .changeset/multi-rule-delivery-checks-every-rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
---

`rule create` and `rule improve` no longer leave part of a request on disk when one of its rules is refused. Every rule a request produced is now checked (its engine, its paths, and whether its file set is complete) before the first is written, so a refusal leaves `.taskless/rules/` as it was. Under `--json`, these refusals now report `RULE_GENERATION_FAILED` or `RULE_UNSUPPORTED` instead of `INTERNAL_ERROR`.

An IO failure while the rules are being written (a full disk, a directory the CLI cannot write to) no longer leaves the earlier rules on disk either: every rule is staged under `.taskless/` first and moved into place afterwards, and a failure part-way moves back any rule already replaced, so `.taskless/rules/` is left as it was and the command reports `RULE_GENERATION_FAILED`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-07
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## Why

`multi-rule-delivery-checks-every-rule` made a request's rules all or nothing against every check that runs before a write, and named what it left out: an IO failure part-way through the writes themselves (a full disk, `EACCES` on the second rule) still left the earlier rules written while the command failed and reported no rules (#623). Under `--json` that failure also surfaced as `INTERNAL_ERROR`.

## What Changes

- `rule create` and `rule improve` stage every rule of a request in full in a temporary directory under `.taskless/`, then move each into `.taskless/rules/` with `rename`, moving any version already there aside first.
- An IO failure while staging or moving rolls back the rules already moved: each new directory is removed, each replaced version is renamed back, and directories the move created are removed. `.taskless/rules/` is left as it was, the staging directory is removed, and the command fails with `RULE_GENERATION_FAILED`.
- If the rollback itself cannot finish, the staging directory is kept and the error names where each replaced version is.
- What is not atomic is stated, not hidden: each rename is atomic on one filesystem, but a sequence of them is not, so a process killed outright between two renames can leave a mix of new and old rules, with the replaced versions kept under `.taskless/.tmp-delivery-*`.
- `rule restore` and `rule rollback` deliver one rule and behave as before.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-generated-rule-delivery`: the multi-rule guarantee now covers an IO failure during the writes, with the residual crash window named.

## Impact

- `packages/cli/src/rules/files.ts` (new `writePreparedRules`), `packages/cli/src/rules/generate.ts` (`deliverRevisions`), and `packages/cli/src/rules/deliver.ts` (exports `assertRuleDirectoryIsNotALink`).
- Delivery shape: **single PR, stacked on #632** (`multi-rule-delivery-checks-every-rule`). It extends that change's requirement and changeset, and lands after it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## MODIFIED Requirements

### 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
signatures, and check every rule's id, engine, and file set as a writable rule, before writing
any of them. A request in which any rule is refused SHALL leave `.taskless/rules/` as it was and
exit with an error. A refusal from these checks SHALL carry an error code: `RULE_UNSUPPORTED` for
an engine this CLI does not know, and `RULE_GENERATION_FAILED` otherwise.

The writes SHALL be all or nothing as well. The CLI SHALL stage every rule in full under
`.taskless/`, on the same filesystem as `.taskless/rules/`, before moving any into place. When an
IO failure occurs while staging or moving, the CLI SHALL undo every move already made, restoring
any version of a rule it replaced, SHALL remove the staging directory, and SHALL exit with
`RULE_GENERATION_FAILED`, leaving `.taskless/rules/` as it was. When the undo itself cannot
finish, the CLI SHALL keep the staging directory and SHALL name where each replaced version is.

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

#### Scenario: A later rule with an unknown engine writes nothing

- **WHEN** a request produced two rules and the second declares an engine the CLI does not know
- **THEN** the CLI SHALL exit with `RULE_UNSUPPORTED`
- **AND** `.taskless/rules/` SHALL be unchanged, including the first rule's directory

#### Scenario: A later rule with an unsafe path writes nothing

- **WHEN** a request produced two rules and the second delivers a path containing `..`
- **THEN** the CLI SHALL exit with `RULE_GENERATION_FAILED`, naming the second rule
- **AND** `.taskless/rules/` SHALL be unchanged

#### Scenario: A write that fails on a later rule restores the earlier one

- **WHEN** a request produced two rules, the first replaces an older version already on disk, and moving the second into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_GENERATION_FAILED`, naming the second rule
- **AND** `.taskless/rules/` SHALL be byte-identical to how it started, including every file of the older version
- **AND** no staging directory SHALL remain under `.taskless/`

#### Scenario: A write that fails on a later rule removes a new earlier one

- **WHEN** a request produced two rules, the first is new, and moving the second into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_GENERATION_FAILED`
- **AND** the first rule's directory SHALL NOT exist
- **AND** no staging directory SHALL remain under `.taskless/`
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 1. Delivery

- [x] 1.1 Add `writePreparedRules`, which stages every rule under `.taskless/` and moves each into place, rolling back on failure
- [x] 1.2 Write a request's rules through it in `deliverRevisions`, reporting a write failure as `RULE_GENERATION_FAILED`
- [x] 1.3 Test a two-rule request whose second write fails, with the first rule both new and replacing an older version
25 changes: 24 additions & 1 deletion openspec/specs/cli-generated-rule-delivery/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,16 @@ any of them. A request in which any rule is refused SHALL leave `.taskless/rules
exit with an error. A refusal from these checks SHALL carry an error code: `RULE_UNSUPPORTED` for
an engine this CLI does not know, and `RULE_GENERATION_FAILED` otherwise.

An IO failure while writing, after every rule passed, is outside this guarantee.
The writes SHALL be all or nothing as well. The CLI SHALL stage every rule in full under
`.taskless/`, on the same filesystem as `.taskless/rules/`, before moving any into place. When an
IO failure occurs while staging or moving, the CLI SHALL undo every move already made, restoring
any version of a rule it replaced, SHALL remove the staging directory, and SHALL exit with
`RULE_GENERATION_FAILED`, leaving `.taskless/rules/` as it was. When the undo itself cannot
finish, the CLI SHALL keep the staging directory and SHALL name where each replaced version is.

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

#### Scenario: A later rule with an unknown engine writes nothing

Expand All @@ -198,3 +207,17 @@ An IO failure while writing, after every rule passed, is outside this guarantee.
- **WHEN** a request produced two rules and the second delivers a path containing `..`
- **THEN** the CLI SHALL exit with `RULE_GENERATION_FAILED`, naming the second rule
- **AND** `.taskless/rules/` SHALL be unchanged

#### Scenario: A write that fails on a later rule restores the earlier one

- **WHEN** a request produced two rules, the first replaces an older version already on disk, and moving the second into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_GENERATION_FAILED`, naming the second rule
- **AND** `.taskless/rules/` SHALL be byte-identical to how it started, including every file of the older version
- **AND** no staging directory SHALL remain under `.taskless/`

#### Scenario: A write that fails on a later rule removes a new earlier one

- **WHEN** a request produced two rules, the first is new, and moving the second into place fails with an IO error
- **THEN** the CLI SHALL exit with `RULE_GENERATION_FAILED`
- **AND** the first rule's directory SHALL NOT exist
- **AND** no staging directory SHALL remain under `.taskless/`
4 changes: 3 additions & 1 deletion packages/cli/src/rules/deliver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,9 @@ function describeErrno(error: unknown): string {
* describes, and silently deleting it would orphan whatever a developer had
* pointed it at.
*/
async function assertRuleDirectoryIsNotALink(directory: string): Promise<void> {
export async function assertRuleDirectoryIsNotALink(
directory: string
): Promise<void> {
let entry;
try {
entry = await lstat(directory);
Expand Down
Loading
Loading