Files
poimen/tasks/T1.1-ctx-and-runscope.md

4.5 KiB

T1.1 — Ctx and RunScope

Field Value
Phase P1 — Walking skeleton
Size M — 1 to 3 days
Status Not started
Flags
Spec inlined below
Blocks T1.3, T7.x

Goal

Explicit context threaded through every call, and a run-scoped task tree that cannot be escaped through the public API.

Facts (inlined — no spec read needed)

  • Runtime is tokio. Chosen for ecosystem access (sqlx, rdkafka, aws-sdk, tonic, axum, reqwest, object-store clients) — for a distributed framework those are the distribution layer, not optional deps.
  • RunScope wraps TaskTracker + CancellationToken from tokio-util, one tracker per run. It exists because tokio has no structural task tree: without the guard, a detached spawn outlives its run.
  • Ctx is an explicit struct passed to every call. Never task_local! for anything causal — that is the AsyncLocalStorage mistake in different clothes. Trace context propagates through Ctx too.
  • Cancellation is cooperative: a select!-dropped future stops at its next await and not before. Kernel code must never hold a lock or a half-applied state across an await that can be cancelled.
  • A dropped call resolves Cancelled if no intent reached Dispatched, and Indeterminate if one did. The intent record decides, not the drop. Collapsing the two is the easy implementation and it puts a permanent noisy floor under the Indeterminate alert, which is meant to sit near zero.

Steps

  1. Define Ctx { tenant, run, branch, cancel: CancellationToken, deadline, trace, log: Arc<dyn EventLog>, blobs: Arc<dyn BlobStore> }. Pass by &Ctx.
  2. Define RunScope owning a TaskTracker and a CancellationToken. Expose spawn only as a method on RunScope; keep the inner tracker private so no caller reaches tokio::spawn through the public API.
  3. RunScope::shutdown() / Drop: cancel the token, tracker.close(), then await tracker.wait(). Returning before every child is complete or aborted is the bug this type exists to prevent.
  4. In kernel code, select! the cancellation token at every await. Audit for locks or partially-applied state held across those awaits.
  5. Give user tool calls a hard timeout in addition to the token — a non-cooperative call must still be bounded.
  6. On cancellation, read the attempt's latest intent state to choose Cancelled vs Indeterminate. Do not decide at the drop site.

Acceptance

  • Cancel a run mid-step; assert every spawned task has completed or aborted before RunScope::drop returns.
  • The cancelled attempt lands in Cancelled, not Indeterminate — asserted directly, since collapsing the two is the easy implementation and silently ruins the alert.

Verify

Harness: tokio::time::pause for deadlines; a scoped task that records its own completion into a shared Arc<Mutex<Vec<_>>> so "aborted" and "completed" are distinguishable.

Integration testtests/it_runscope_cancel.rs:

  1. Start a run whose step spawns three children through RunScope::spawn, each parked on a long await.
  2. Cancel mid-step.
  3. Assert RunScope::drop (or shutdown().await) returns only after all three have recorded completion-or-abort. Assert the recorded set has size 3 — a scope that returns early leaves it at 0 or 1.
  4. Assert the attempt landed in Cancelled, not Indeterminate, with no intent dispatched.
  5. Second run: dispatch an intent, then cancel. Assert Indeterminate.
  6. Grep test (or a lint): assert no tokio::spawn outside RunScope, and no task_local! anywhere in kernel crates.

Command: cargo test -p runtime runscope

False pass:

  • Step 3 asserting only that drop returned. It always returns. The recorded child set is the evidence.
  • Steps 4 and 5 written against the same fixture with intent_dispatched hardcoded — they must run through the real intent record, or the distinction is asserted against itself.
  • A child that finishes on its own before cancellation lands, making the test green regardless of the tracker. Park children on a token that only the cancel releases.

Traps

  • A public spawn helper "for convenience" that bypasses the tracker.
  • Storing tenant or run id in a task-local. It works until a spawn boundary, then attributes work to the wrong run.

Background (not required to do this task): rust-agentic-sys.md §5.1, §6, §15 · rust-agentic-task.md