Keep cancellation typed from token to wire, client and effect outcome

Merged
VA
2d ago
+187-88

Class

G: lost causes. A fired CancellationToken lost its cause at every conversion site. Each site turned it into whatever string error was nearby:

SiteBecame
durable_host/sources.rs (3 sites), task_runtime/inspection/project.rs (2), root_source/verify.rsUnauthorized
PreparationIo::{from_budget, for_unmeasured, check_cancellation}, configured_recursive_tools, default_tools/{executor, merge}, recursive/source.rsStorage
store/history/measured.rsInvalid
machines/identity.rs (ProviderError::Cancelled), host/keys.rs (WorkspaceError::Cancelled)Storage
Native NativeTreeMaterializationError::Cancelledwire Storage

So the clients reported a cancelled run wrongly:

  • headless reported it as failed (exit 1), never cancelled (exit 2);
  • gRPC returned Unavailable or PermissionDenied, never Cancelled.

Invariant

  1. One cancellation conversion. impl From<acyclic_fs::CancellationError> for Error yields Error::Cancelled, and every harness check() site now uses it (? or map_err(Error::from)). Native work still keeps NativeWork { Cancelled } for custody, but it classifies as the same wire code.

  2. Durable codec. ERROR_CODE_CANCELLED = 12 in harness.proto; the compatibility manifest digest is updated.

  3. No catch-all at the client boundaries.

    • HeadlessOutcome::from_error is now an exhaustive match, with the _ => Failed arm deleted, so a future code must be classified.
    • grpc::wire_status maps Cancelled to Code::Cancelled.
  4. One owner for accepted-effect outcomes. Eleven duplicated Err(Indeterminate) | Err(Storage) => reconcile, else reject arms move to Error::leaves_effect_uncertain(), which includes Cancelled. 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 definitive ExecutorRejected. Before this PR it was reconciled only because cancellation happened to be spelled Storage.

Why it can't recur

  • CancellationError has one conversion. A site that writes map_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)

FilterResultTime
fork_preparer::bounded_io (includes new fired_cancellation_stays_cancelled_through_preparation_and_history)7 passed12 ms
cancelled_tool_effects_are_reconciled_and_definitive_failures_are_not1 passed11 ms
executor::83 passed2.5 s
durable_tool2 passed11 ms
machines::4 passed10 ms
grpc::3 passed35 ms
  • Cancellation test: checks the real PreparationIo and HistoryWork paths, code() and HeadlessOutcome. Its negative control asserts that a Storage failure still maps to Failed.
  • Tool-effect test: drives the real StockExecutor turn. A tool that ran and then reports Cancelled must come back Indeterminate (reconciled). Its negative control is the same tool returning Invalid, which must be rejected and not reconciled. Removing Cancelled from leaves_effect_uncertain fails 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-gnu passed (20 s incremental).
    • WASM: cargo check -p acyclic-harness --lib --features wasm,filesystem --target wasm32-unknown-unknown passed (10 s incremental).
  • Compatibility digests: node scripts/check-compatibility-digests.mjs passed.
  • Failures that predate this PR: with this feature set, 4 local::admission::poll_sweep tests (stream-read coverage maps) and native_message_lookup_provider_corruption_bound_and_cancellation_release_records also fail at the unmodified base f69a63004. 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.rs is also touched by #833 and executor/dispatch.rs by #669, in separate hunks. git merge-tree against 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 returns Unauthorized, because it is not authorized without one. Only a fired token is Cancelled. The two cases are now separated in check_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 = 12 to harness.proto and updates the compatibility manifest digest.
  • A missing cancellation token still errors as Unauthorized; only a fired token is Cancelled.
  • The headless and gRPC classifiers are now exhaustive, so a new wire code won't compile until it is classified.