Keep cancellation typed from token to wire, client and effect outcome
Class
G: lost causes. A fired CancellationToken lost its cause at every conversion site. Each site turned it into whatever string error was nearby:
| Site | Became |
|---|---|
durable_host/sources.rs (3 sites), task_runtime/inspection/project.rs (2), root_source/verify.rs | Unauthorized |
PreparationIo::{from_budget, for_unmeasured, check_cancellation}, configured_recursive_tools, default_tools/{executor, merge}, recursive/source.rs | Storage |
store/history/measured.rs | Invalid |
machines/identity.rs (ProviderError::Cancelled), host/keys.rs (WorkspaceError::Cancelled) | Storage |
Native NativeTreeMaterializationError::Cancelled | wire Storage |
So the clients reported a cancelled run wrongly:
- headless reported it as failed (exit 1), never cancelled (exit 2);
- gRPC returned
UnavailableorPermissionDenied, neverCancelled.
Invariant
-
One cancellation conversion.
impl From<acyclic_fs::CancellationError> for ErroryieldsError::Cancelled, and every harnesscheck()site now uses it (?ormap_err(Error::from)). Native work still keepsNativeWork { Cancelled }for custody, but it classifies as the same wire code. -
Durable codec.
ERROR_CODE_CANCELLED = 12inharness.proto; the compatibility manifest digest is updated. -
No catch-all at the client boundaries.
HeadlessOutcome::from_erroris now an exhaustive match, with the_ => Failedarm deleted, so a future code must be classified.grpc::wire_statusmapsCancelledtoCode::Cancelled.
-
One owner for accepted-effect outcomes. Eleven duplicated
Err(Indeterminate) | Err(Storage) => reconcile, else rejectarms move toError::leaves_effect_uncertain(), which includesCancelled. The arms are in the tool execution, resolution, dispatch and durable-tool runner paths.This is required, not optional. Without it, the typed variant would fall through to
reject_tool_call, and a tool cancelled after its effect started would be journaled as a definitiveExecutorRejected. Before this PR it was reconciled only because cancellation happened to be spelledStorage.
Why it can't recur
CancellationErrorhas one conversion. A site that writesmap_err(|_| Error::Storage(..))for a token is now a visible deviation from?, not the local idiom.- Both client classifiers are exhaustive, so a new wire code doesn't compile until it is classified.
- Effect arms ask one predicate instead of re-listing variants, so adding an uncertain variant updates every arm.
Historical findings
- rev-833-verdict.md:3: "Retained failures lose their types… returns as
Unauthorized; clients must parse strings". This PR fixes the same collapse for cancellation. The hook instance itself is #833's. - Latent bug found here: headless returned exit 1 for every cancellation that wasn't an interaction cancellation, and the bounded-io test pins exit 2 now. The duplicated effect arms are the study's "typed retained outcomes distinguish cancelled from uncertain" row.
Deletions
88 lines deleted, 176 added (about 95 of the additions are the two new tests). This includes:
- 15 string cancellation conversions;
- 11 duplicated effect-classification patterns;
- 1 catch-all client arm.
Verification (bb1, debug, --features filesystem-local,native-execution,grpc,machines,test-support)
| Filter | Result | Time |
|---|---|---|
fork_preparer::bounded_io (includes new fired_cancellation_stays_cancelled_through_preparation_and_history) | 7 passed | 12 ms |
cancelled_tool_effects_are_reconciled_and_definitive_failures_are_not | 1 passed | 11 ms |
executor:: | 83 passed | 2.5 s |
durable_tool | 2 passed | 11 ms |
machines:: | 4 passed | 10 ms |
grpc:: | 3 passed | 35 ms |
- Cancellation test: checks the real
PreparationIoandHistoryWorkpaths,code()andHeadlessOutcome. Its negative control asserts that aStoragefailure still maps toFailed. - Tool-effect test: drives the real
StockExecutorturn. A tool that ran and then reportsCancelledmust come backIndeterminate(reconciled). Its negative control is the same tool returningInvalid, which must be rejected and not reconciled. RemovingCancelledfromleaves_effect_uncertainfails it. - Builds: the test build took 438 s incrementally.
- Windows:
cargo check --locked -p acyclic-harness --lib --tests --features <same> --target x86_64-pc-windows-gnupassed (20 s incremental). - WASM:
cargo check -p acyclic-harness --lib --features wasm,filesystem --target wasm32-unknown-unknownpassed (10 s incremental).
- Windows:
- Compatibility digests:
node scripts/check-compatibility-digests.mjspassed. - Failures that predate this PR: with this feature set, 4
local::admission::poll_sweeptests (stream-read coverage maps) andnative_message_lookup_provider_corruption_bound_and_cancellation_release_recordsalso fail at the unmodified basef69a63004. I reproduced that in a separate worktree with the same features, so it isn't caused by this PR.
Decisions
- Overlap with open PRs:
contract_errors.rsis also touched by #833 andexecutor/dispatch.rsby #669, in separate hunks.git merge-treeagainst both PR heads and current main is clean. - Slice size: 25 files, above the usual 12. Splitting would either ship the typed variant without the effect owner (cancelled tools journaled as rejected) or leave some sites still collapsing. Most of the files are one-line conversions.
- Missing token is still
Unauthorized: a project-source read with no invocation cancellation token returnsUnauthorized, because it is not authorized without one. Only a fired token isCancelled. The two cases are now separated incheck_project_source_invocation.
🤖 Generated with Claude Code
Summary by cubic
Keeps cancellation typed from token to wire, client, and effect outcome. A fired CancellationToken previously collapsed into whichever string error was nearby — Unauthorized, Storage, or Invalid — so headless reported cancelled runs as failed (exit 1) and gRPC returned Unavailable or PermissionDenied instead of Cancelled. Now From<CancellationError> is the single conversion to Error::Cancelled, it rides the wire as ERROR_CODE_CANCELLED, and both headless and gRPC map it to Cancelled through exhaustive matches.
Accepted-effect classification moves from eleven duplicated Indeterminate | Storage arms to one Error::leaves_effect_uncertain() predicate that includes Cancelled, so a tool cancelled after its effect started is reconciled rather than journaled as rejected. New tests replay cancelled and definitively-rejected tool calls against the same journal: a cancelled effect reconciles exactly once with no second call, and an Invalid rejection replays as the same Invalid without reconciling.
Notes
- Adds
ERROR_CODE_CANCELLED = 12toharness.protoand updates the compatibility manifest digest. - A missing cancellation token still errors as
Unauthorized; only a fired token isCancelled. - The headless and gRPC classifiers are now exhaustive, so a new wire code won't compile until it is classified.