A run, end to end

A run is one top-level evaluation against a persistent Shell, and every host starts it through a synchronous, runtime-agnostic run door — Shell::run(RunRequest) -> RunReport (or Shell::run_under when the host already holds a foreground scope) in core/src/run.rs. The request’s Run carries a Program sum naming what runs: source text, or a registered hook applied to first-order arguments — a block, lambda, or builtin registered by name in the session-lived hook table (Shell::register_hook), so the host conveys data, never closures, across the dispatch boundary. No host reimplements evaluation, and no front-end calls the door itself: each is a run supplier that dispatches a Run through a transport, whose engine (Engine::run) enters the door and projects the RunReport onto the wire’s Report for the front-end to render its own way (unify-turn-evaluation, run-turn-host-loop). Completion is the door returning — never a channel disconnecting — so a spawned worker (a server, a watch) holding a clone of the surface cannot keep a run from ending (run-turn-host-loop). The reduction primitive behind the door is crate-private, so a host cannot start an unframed evaluation against a stale frame (run-turn-is-host-api).

There are two doors, and which one you use is decided by what you hold. Shell::run is for a host with no run in hand: its frame is minted under SessionState::anchor. A host that already holds a pre-minted dispatch scope (Scopes::open’s view) uses Shell::run_under(&scope, req); the identity and wire transports use this form so cancellation can land before a frame exists. Code already inside a run — a builtin body — opens no second run: it applies a function under the Mooring it was given, so the outer run’s interrupt and wall reach whatever it calls (cancel-is-a-watermark).

The request carries the policy axes as types, never flags. A RunRequest is exactly the places hosts differ:

  • IO — a RunIo regime sum over byte output. Inherit runs on the session’s ambient streams (cloned, then restored); Capture has core mint fresh stdout/stderr buffers it returns in RunReport::Ran’s captured.
  • Stdin — a RunStdin choice, orthogonal to the output regime: Inherit reads the session’s fd 0 (terminal, pipe, or file), Empty installs an immediate-EOF source with no fall-through.
  • Terminal — a RequestedTerminalAccess: Leased may foreground a terminal-bound child, Denied may not. Capture does not imply terminal ownership — a piped ral -c is Denied yet Inherits its stdin (terminal-lease).
  • Capabilities — the GrantStack ceiling pushed for the run’s dynamic extent, every layer of it (GrantStack::root() for the ral hosts, a grant profile for exarch).
  • Limits — wall (the foreground deadline), worker_cap (the cap on concurrently running workers), cleanup_grace (how long a halting worker or combinator waits for the children it cancels), and worker_title (whether a spawn must carry a title).
  • Surface — an optional run-local SurfaceSink (Arc<dyn EventSink>), installed only for this run; None is the identity.
  • Trail — trail: Option<CapturePolicy> (audit). Some delimits this dispatch’s own extent as an audit scope and carries it home on RunReport::Ran; None neither opens a scope nor collects one.

The spine the run doors orchestrate is one straight line, owning resources (Sink, Source, the run’s frame, guards, buffers) while the request describes policy. The run doors dispatch on the Program sum, and the arms differ only in how the program resolves — the source arm compiles first, the hook arm looks up the hook table; both then converge on run_built and the run module’s framed scaffold:

  • Mint the run’s foreground scope — a child of the scope the door was entered under, so a foreground timeout never reaches a worker (cancellation) — and arm its wall, if any, before compiling, so wall bounds the whole run — compile and typecheck included, not only evaluation. compile_run’s process::clear touches only the signal count, never the reaper, so the armed ceiling survives the compile.
  • The source arm’s compile_run runs compile_and_typecheck seeded from the live session’s schemes (the ladder; session-scheme-continuity). A parse or type failure returns RunReport::Static { diagnostics } at once — no run state, no root context. The diagnostic carries the Source its carets point into rather than registering it: a run that failed to compile leaves no live span, so its text has no business in a registry that is append-only precisely because live spans index it (static-diagnostics-render-at-the-seam). The hook arm skips this: its program is an already-compiled value resolved by name in the hook table, and the hook’s registered DefaultPolicy (capture, terminal authority, and aside — run on a join_session sibling, so nothing flows back) folds into the run’s conditions — the hook’s to decide, not the dispatching host’s. A fault in it is labelled by HookName::fault_label (plugin 'p' hook 'h', prompt), and a hook run is no ready boundary: only a source run emits the boundary notices.
  • run_built materialises the IO regime — Capture mints the buffers it reads back, Inherit leaves the ambient streams to flow — then assembles the run’s frame in two halves, split by mutability. What the run fixes once (the surface sink, the deferred rail with its worker_cap, cleanup_grace and worker_title, the desk, the Fork arm, the foreground scope, and the run’s terminal authority) is a Mooring, an owned local on run_built’s own Rust stack frame; the surface has no liveness role, so a clone of it can never define run completion. What genuinely changes within the run is taken on loan: the byte streams — the Io build_run seeds from the ambient session — and the two Copy registers the run’s frame owns for its life, the root-source register session.root_file and the dispatch register local.audit.call_site.
  • run_framed borrows the mooring, installs the run’s Io through an IoLoan, and evaluates. The loan is RAII over that one swap: it moves the new streams onto shell.io and restores the previous ones on Drop, so teardown survives a caught worker panic. The mooring needs no guard — it never moved, so an outer run’s is back the instant this stack frame ends, and the NurseryGuard beside it empties the nursery on the unwinding path as surely as on the clean one. The root context is installed — for a source run only, a hook having no text to register — and with_layers(caps, body) runs the run’s program under the request’s capability ceiling, pushed layer by layer and removed by position after, so a session frame the program pushes survives the run — run_phrases(&top.phrases, shell.env.clone(), Mode::Session, mooring, shell) for the source arm, the in-frame evaluator::apply of the resolved hook for the hook arm (the machine; unify-turn-evaluation).
  • run_phrases writes each let straight into shell.env as its Define lands, and a cd, alias, or hook registration is an unbracketed write straight to shell.context — there is no post-run install step, so a halted run keeps every write that landed before the halt and the run remains a resume point regardless of completion, error, or exit (exchange-ends-ready). Before the status is read, run_framed disarms the wall (waiting on a firing already claimed) and makes the closing poll, final_poll on the run’s node, so a stop that landed after the body’s last poll — a key a terminal loan heard back and struck on the node included — is not lost; a stop is an escape no try absorbs anyway. It then computes the transport status and emits ready-boundary notices while the run frame and sinks are still installed. Only then does the IO guard drop.
  • Back in run_built, classify_ending folds the settled Result, the transport status, single_command and root into one run::Ending — Settled, Raised, Stopped, or Exited — a wall being simply a TimedOut stop, so no separate “timed out” flag exists to disagree with the result. RunReport::Ran { ending, captured, trail } carries it home; trail starts Vec::new() here — run_built has no view of the dispatch’s own scope, only of what a body opened and closed on its own account.
  • One level up, at Shell::enter — the durability wrapper all three run doors funnel through — a Run.trail: Some holds a TrailScope outside the catch_unwind that recovers a mid-run panic, opened before dispatch runs and closed once it returns, on every exit. That placement is load-bearing: the (env, context) checkpoint the panic arm rolls back does not cover local.audit, so only a scope the panic itself cannot skip keeps the trail’s close law true at dispatch granularity. A clean exit’s observations land in RunReport::Ran.trail; a caught panic’s are drained and discarded — the panicked dispatch reports RunReport::Faulted, never a trail. RunReport::into_report then renders the engine’s Ending against the shell — a Raised error or a Stopped stop becomes the string the host prints verbatim and the {cmd, status, message, site} record try would hand its handler, command_exit/status computed alongside it; a Settled value the wire cannot carry (a handle, block, or function) becomes Unreturnable, its own rendered failure with a hint; a Static renders the same way through StaticDiagnostics::render, which also settles its exit status (2 parse, 1 type) — onto the wire’s own protocol::Ending, and projects each Observation through `encode` onto Report::Ran.trail: Vec<FOValue>, unbounded by declaration — the wire’s frame fuse is the shared backstop, as it already is for captured.

The hosts differ only in the request they supply.

  • The REPL’s exec::step (ral/src/repl/exec.rs) supplies origin: SourceOrigin::named("<stdin>"), GrantStack::root(), no limits, RunIo::Inherit, RequestedTerminalAccess::Leased, RunStdin::Inherit; it builds a Program::Source Run and drains it through protocol::dispatch_to_report on its IdentityTransport with the REPL’s own Host (ReplHost), rendering the terminal Report with print_result — an Err(Severed) prints the cause and ends the session. The pre-exec, chpwd and post-exec plugin hooks are dispatches of their own around it, as are the prompt body and every other hook (ral/src/repl/plugin.rs, prompt.rs): Program::Hook runs of hooks registered by name, through the same door (loop).
  • exarch’s run_shell (exarch/src/shell_eval.rs) supplies a virtual origin naming the call’s turn (turn N), its session grant profile, a per-tool wall, a worker_cap on concurrently running workers, a 2 s cleanup_grace, a required worker_title, RunIo::Capture, RequestedTerminalAccess::Denied, RunStdin::Empty, trail: Some(CapturePolicy::Off); it builds a Program::Source Run and drains it through protocol::dispatch_to_report against its call’s RunHost — one Host pairing the enquiry desk with the applier that renders each live surface value onto the presentation bus — and hands back the Result<Report, Severed> itself; Avatar::ral renders the capped ToolResult, or the one sentence agent::seat::EngineLost renders for a severance (agent). The pushed grant frame is the sandbox — ral’s grant, not a source-level grant { … } the model could escape — which is why exarch needs no runtime of its own (exarch-architecture).
  • ral’s batch path (ral/src/batch.rs) boots an IdentityTransport from the batch installer, dispatches the _ral-boot hook, then the script as one Program::Source run under the mute host, with RunIo::Inherit and RequestedTerminalAccess keyed to whether it owns the terminal — the same door, the same ending law, and the same typecheck as the REPL (startup).

The human and the model are interchangeable suppliers of top-level runs over one persistent Shell.

See also compilation-ladder, evaluator-machine, cancellation; run-turn-host-loop, run-turn-is-host-api, unify-turn-evaluation; maps repl, exarch.