ADR-016
Cancellation model: future-drop primary, CancelCtx cooperative module
accepted · 2026-04-16 · L0.5 · cites 14
0ADR-016: Cancellation model: future-drop primary, CancelCtx cooperative module #
Context #
The kernel today has exactly one cancellation mechanism — ShellCancel — and it
is intentionally narrow.
Design decision #9 in crates/nika-kernel-core/src/io/process.rs:9-11 states:
"cancel is a fn cancel(&self, id) method, not a CancellationToken field on
ShellCommand. This keeps tokio-util out of nika-kernel." The trait method
itself is ShellCancel::cancel(&self, id: &str) at
crates/nika-kernel-core/src/io/process.rs:156-159 and exists for subprocess
management (kill the child by id), not for cooperative task cancellation.
No other cancellation mechanism existed before Wave 2:
InferRequest(crates/nika-kernel-ai/src/provider.rs:197-220, 11 fields pre-S1-B) had no cancel field.ContextCompressor::compress(crates/nika-kernel-ai/src/context.rs:48-58) takesmessagesandpolicyonly — no cancel parameter.MemoryStore::recall(crates/nika-kernel-ai/src/memory.rs:275-279) is purely query in / hits out.
For v0.95 (DAG executor, multi-step agent loops, streaming inference) we need
cooperative cancellation that propagates through a tree of in-flight tasks.
Tokio's select! plus future-drop already covers single futures, but agent
loops and streams must check a cancellation flag between iterations to flush
partial work cleanly.
The kernel sits at L0.5 and must remain runtime-agnostic. Today's
crates/nika-kernel/Cargo.toml:18-26 lists only nika-error,
trait-variant, thiserror, miette, serde, serde_json, bytes,
futures-core. Adding tokio or tokio-util here would violate the layer
contract (scripts/ci/check-layering.sh).
Decision #
Cancellation is layered, not unified. Three coexisting mechanisms, each solving a different problem:
- Primary: future drop. Dropping a task's future stops its work.
tokio::select!andtokio::time::timeoutare the day-to-day tools. No new API is needed for the common case. - Cooperative: `CancelCtx` (new in S1-B,
crates/nika-kernel-core/src/cancel.rs:35-67).Arc<AtomicBool>-backed token that all clones share.cancel()flips the flag; long-running loops pollis_cancelled()between iterations. Reserved fieldInferRequest.cancel: Option<CancelCtx>is pre-planted at v0.81 so future activation is purely additive (#[non_exhaustive]ratchet). - Process management: `ShellCancel` (existing,
crates/nika-kernel-core/src/io/process.rs:156-159). Kill-by-id for subprocesses. Stays as-is — different problem class.
CancelCtx deliberately uses std::sync::atomic::AtomicBool (no tokio,
no tokio-util). v0.95 may internally wrap a
tokio_util::CancellationToken if we need richer behavior (timeout-as-cancel,
parent-child trees), but the public API of CancelCtx will not change.
Two explicit cancel traits planned for v0.95: Cancellable (poll-style check)
and CancelPropagator (tree-wide propagation). Both will be defined when the
DAG executor lands; defining them now would lock in shape choices we have not
benchmarked.
Consequences #
Positive #
- Pre-planting
InferRequest.cancelat v0.81 turns "wire cancellation" into a zero-breaking-change activation later (Pattern 2, ROI 6.7x per ADR-007). - Kernel stays free of
tokio-util—cargo denycontinues to enforce L0.5 purity. CancelCtxis testable in isolation today (crates/nika-kernel-core/src/cancel.rsships with 6 unit tests including clone-state propagation and Send+Sync).- Three orthogonal mechanisms keep mental model clean: drop = default, cooperative = loops, process = subprocess.
Negative #
- Two cancellation idioms (drop vs cooperative) means contributors must learn
when to use which. Mitigated by docs in
cancel.rsmodule header. CancelCtxdoes not propagate timeouts. v0.95 must layertokio::time::timeouton top, not replace it.- Pre-planted
Option<CancelCtx>field is dead weight until v0.95 — accepted cost, ~16 bytes perInferRequest.
Neutral #
- ShellCancel's id-based signature stays unchanged. It is not migrated to
CancelCtxbecause subprocess management is a different problem (the OS kills, not cooperative checking).
Evidence / Affected code #
crates/nika-kernel-core/src/cancel.rs:35-67—CancelCtxstruct + impl (Wave 2 / S1-B).crates/nika-kernel-core/src/cancel.rs:78-128— 6 unit tests, including clone-state propagation, idempotent cancel, Send+Sync.crates/nika-kernel-ai/src/provider.rs:21—use crate::cancel::CancelCtx;crates/nika-kernel-ai/src/provider.rs—InferRequest.cancel: Option<CancelCtx>reserved field (S1-B).crates/nika-kernel-core/src/io/process.rs:9-11— design decision #9 rationale (notokio-utilin kernel).crates/nika-kernel-core/src/io/process.rs:156-159—ShellCancel::cancel(&self, id: &str)method.crates/nika-kernel/Cargo.toml:18-26— kernel deps (no tokio, no tokio-util).docs/architecture/forward-compat-invariants.md— Pattern 1 (kernel traits upfront), Pattern 2 (#[non_exhaustive]).
Alternatives considered #
Alt A -- `tokio_util::CancellationToken` directly in kernel #
Use CancellationToken as the public type in InferRequest.cancel.
Rejected: pulls tokio-util into L0.5. Kernel must remain
runtime-agnostic; CancellationToken is not no_std compatible and
blocks future WASM compilation of kernel types. Wrapping it inside a
private CancelCtx is an option for v0.95 — that keeps the public API
runtime-agnostic.
Alt B -- No cancellation field until v0.95 #
Add cancel only when the DAG executor needs it.
Rejected: even with #[non_exhaustive] we must update every call site
that builds InferRequest. Pre-planting the Option<CancelCtx> is
~16 bytes and zero ergonomic cost (constructor sets None). ROI 6.7x
per ADR-007's calculation.
Alt C -- Cancel as a method on `ProviderInfer` #
Expose provider.cancel(request_id) instead of a field on the request.
Rejected: providers do not own request lifetimes; the runtime does. A
field on the DTO lets the runtime cancel without round-tripping through
the provider trait, and it composes (a workflow can hand the same
CancelCtx to many providers in parallel).
Alt D -- Single global cancel signal #
Process-wide cancellation flag (e.g., on receipt of SIGINT). Rejected: too coarse. We need per-task and per-DAG-subtree cancellation so the user can cancel one branch of a workflow without killing the rest.
Related #
- ADR-006 — kernel ISP trait design. ADR-016 adds
CancelCtxas a data type, not a kernel trait, so it does not extend ADR-006's trait list. - ADR-007 — forward-compat invariants. The pre-planted
cancelfield is Pattern 2 (#[non_exhaustive]) applied to a v0.95 hook. - ADR-014 — sealed kernel traits.
CancelCtxis a struct, not a trait, so sealing does not apply. - ADR-018 — runtime + sync primitives (chooses tokio rt + parking_lot). ADR-016 explicitly does not depend on the runtime choice.
docs/architecture/forward-compat-invariants.md— Pattern 1, Pattern 2.
Notes #
Follow-ups:
- v0.95 DAG executor: define
Cancellable(poll-check trait) andCancelPropagator(tree-wide propagation trait). - v0.95 may wrap
tokio_util::CancellationTokeninsideCancelCtx— decision deferred until benchmarks show whether the wrap is worth the added dep. - Document the "drop vs cooperative" choice in the agent self-serve docs (Phase E).
read at v0.107.0 · the decision record ships with the engine