Per-agent eval-layer cancel; only the trunk publishes the signal slots
The publication half is superseded by cancel-is-a-join: a handler contributes a cause to two process-lifetime cells that facing scopes fold, so “only the trunk publishes” becomes “only the trunk’s session is minted facing” — the rule is carried by construction rather than by a flag. The eval-layer cascade below is untouched.
An Esc or agent_cancel set exarch Tokens and nothing else. Two structural
gaps made the cancel path slow and, under a running fleet, unsound:
- The token never reached a running eval.
run_shelltakes no token: it dispatches the turn and blocks ontransport.events().recv()until the engine reports. The registry cascade set onlyTokens, which the drive loop reads between steps — so a cancelled sub-agent mid-ral-tool ground on to itstimeout_secswall (default 60 s, model-raisable) before noticing, its subprocesses alive the whole time./clearand a settlingreplyreaped entries the same way, leaving orphan evals burning. - Every session published the process-global signal slots.
TurnGuardpublishedFOREGROUND_SCOPE/DURABLE_ROOT_SCOPEunconditionally, but the slots’ save/restore-previous discipline is sound only for LIFO publication on one thread. Sub-agents dispatch turns concurrently on their own threads, so the trunk’s Esc (request_foreground_cancel) hit whichever session dispatched last — possibly a sub-agent’s eval, leaving the trunk’s turn running — and cross-thread restore order could park a pointer to a freedScopeNodeflag in the slot: a latent use-after-free under the very interleaving that made Esc feel dead.
The decision
Two moves, one invariant each.
Only the signal-facing session publishes the slots.
SessionState::publishes_signal_slots marks it; Shell::new sets it,
Shell::fork_session — the sub-agent seam, which already renounces terminal
authority — clears it, and TurnGuard::install publishes only when it is set.
Publication is again LIFO on a single thread (the safety prerequisite the slot
SAFETY comments state), and a signal can only ever target the primary
session’s turn. This is the rule exarch’s own token slot already enforced
(“only the trunk publishes”), now honoured by ral’s slots too.
The registry cascade cancels both layers. Shell::cancel_handle returns a
clonable DurableRoot; each parented AgentRegistry entry carries one
(eval_root: Option<DurableRoot>, captured at registration), and both cascade
primitives (cancel, remove_descendants) cancel it with
CancelCause::Explicit alongside the Token. The root cancel unwinds an
in-flight eval at the evaluator’s existing poll points — the trampoline,
await’s 1 ms sweep, the child-wait loop’s 5–100 ms backoff with
cause-directed group teardown — so Esc-to-stop is bounded by poll cadence
(~100 ms; ~600 ms with an external child’s SIGTERM grace), not by
timeout_secs. The trunk registers no eval-root: its session outlives any
cancel, and latching its durable root would kill every future turn and
detached worker; its turn-level cancel rides the published foreground slot.
Superseded, in part. The trunk now registers a reach too, an interrupt-only one (
EvalReach::interrupt_only):eval_rootstaysNone, so a registryterminateon the trunk still degrades to theTokenalone and cannot poison the session, but a registryinterruptnow reaches the trunk’s run scope the same way it reaches any other entry’s. The published foreground slot remains the trunk’s Esc path for what the registry cannot reach — see cancellation and agent.
Where
core/src/types/shell/mod.rs/init.rs/inherit.rs—publishes_signal_slotsonSessionStateno longer exists either; the one-publishing-session rule dissolved into how a session’s scopes are minted (Shell::face_signals, cancel-is-a-join).core/src/process/cancel.rs— the slot-publication mechanism this bullet described (TurnGuard) no longer exists; it dissolved into the watermark this file now carries (cancel-is-a-watermark).core/src/types/shell/host.rs—Shell::cancel_handle, the host verb for a non-signal-facing session’s stop lever.exarch/src/fleet/registry.rs—eval_rootonEvalReach::Identity;terminate_entry(the cascade) andAgentRegistry::interrupt(single entry) each cancel token + reach.exarch/src/agent/seat.rs—Seat::eval_reachreads the handle off the agent’s shell;exarch/src/agent/build.rs—register_self/register_self_namedthread it into registration.
Covered
turn::tests::forked_session_turn_does_not_publish_signal_slots— a foreground-cancel request cannot reach a forked session’s turn; itscancel_handleproduces the same 130 unwind the slot gives the trunk.agent_registry::tests::cancel_sets_the_token_and_list_reports_the_subtreeandclear_subtree_cancels_descendant_eval_roots— both cascade primitives cancel both layers.agent::tests::reply_cancels_live_descendants— the reply reap cancels an abandoned child’s eval layer.shell_eval::tests::root_cancel_unwinds_inflight_run_shell— end to end: a forked session blocked inrun_shellon a 30 s sleep under a 30 s budget returns in well under a second when its handle is cancelled, and the spawned process tree is reaped.
The hard rule
At most one session per process publishes the signal slots — the signal-facing
one. Every other session is stopped through its own DurableRoot handle, and
any host cascade that cancels an agent must cancel both layers: the
cooperative token its drive loop polls, and the session root its eval polls.
A token without the root handle is a request the eval cannot hear.
See also per-root-turn-cancel (the token layer this completes), esc-non-escalating-interrupt (the delivery half), unify-turn-evaluation (the root/foreground split the handle leans on), cancellation, and agent.