Map: exarch / shell eval

shell_eval.rs runs one tool call as a ral top-level run against the persistent `Shell`. run_shell is a pure request supplier: it builds a transport-level Source Run, dispatches it through ral_core::carrier::dispatch_to_report against the agent’s seat transport — the in-process IdentityTransport, or a wire engine’s WireTransport (agent) — and returns the terminal Report that comes back, or the Severed when the engine is gone before one arrives — the transport is the canonical run vocabulary (engine-protocol; a run, end to end; run-turn-is-host-api). Evaluation can be entered only through a framed run door — the reduction primitive behind it is crate-private (run-turn-host-loop) — so core owns all the run machinery (compile, frame install, capture, the wall), and run_shell owns only the run it builds. Formatting is Avatar::ral’s, through report::tool_result, which also reports whether the run failed:

  • source + a virtual origin naming the call’s turn (turn N). Core’s compile_run runs compile_and_typecheck seeded from the live session (shell.session_schemes(), the one name→scheme seed — session-scheme-continuity); the prelude’s schemes ride scope[0], installed when the annotated prelude was evaluated at boot. The check is strict — any type error is fatal — over the one inference pass every evaluated path shares (unconditional-mode-pass). Parse/type errors come back as Report::Static { rendered, status }, already the caret report the protocol drew, which the model reads clipped whole, then the rejection tip (report::rejection: no command ran and no let bound, resend the whole call); a Report::Faulted reads clipped, then the fault sentence (report::fault: some or all of the call may have run, check what stands before retrying; shell_eval/clip.rs holds the head+tail clip that rejection/fault and agent::digest both use); on success the annotated comp runs (unify-turn-evaluation);
  • caps — the agent’s GrantStack (Agent::caps), pushed for the eval’s dynamic extent. This is the sandbox: the boundary is the pushed grant frame plus the top-level contract, not a source-level grant { … } the model could escape. External commands route through the same OS sandbox as ral (capabilities). The eval runs in Mode::Session, so each landed Define writes back into shell.env as it lands and the run’s Context is the session’s own: let, cd, and env persist across tool calls (the in-module tests pin this);
  • io: RunIo::Capture — core mints the stdout/stderr buffers and returns them in Report::Ran’s captured: the full, model-visible and logged text. Nothing echoes live; the rail surfaces cards instead, and the digest caps shape only the model’s history view;
  • terminal: RequestedTerminalAccess::Denied — a tool run holds no terminal lease, so the foreground handoff is uninvocable: a bare pipeline cannot tcsetpgrp, and the SIGTTIN crash of an agent stealing the controlling terminal becomes a state the types refuse to represent. Paired with stdin: RunStdin::Empty, the run reads no terminal at all — an explicit empty source, not a side effect of foreground denial;
  • wall — the per-tool deadline, the ral tool’s timeout_secs (60s default). Core arms it on the run’s foreground scope before compiling, so it bounds the whole run; the wall ends the run as Ending::Stopped with cause TimedOut, the one stop render follows with the timeout tip, the exit-124 stderr described under the wall is a place below, while Esc stays an interrupt and a closed stdout renders nothing. No try in the call can catch either. A grant body evaluates locally — no sandbox-IPC round trip to interrupt — so cancellation reaches any spawned child through the ordinary process-group / cancel-scope path;
  • worker_cap — LIVE_WORKER_CAP (64), the admission bound core enforces at the spawn door: the 65th spawn is refused (the error names await/cancel) while 64 workers still run; settled entries lingering under retention hold no seat. Its sibling constant SETTLED_WORKER_RETENTION (256 ral calls, matching the binding lease’s scratch expiry) is not on the request at all — it is armed once through Shell::arm_worker_retention, and the sweep it parameterises is engine housekeeping;
  • worker_title — TitlePolicy::Required: an untitled spawn is refused at its step, before any birth, so every worker the model starts is named by what it does in its birth card, its completion notice and workers; Surface delivery and enquiry answering are not Run fields: dispatch_to_report takes one host: Arc<dyn Host> — engine-protocol’s single object for a run’s whole host-facing surface. Every real caller builds agent::desk::RunHost, pairing the per-call ExarchDesk with a SurfaceApplier (shell_eval/surface.rs, builtins); a bare SurfaceApplier alone is the mute host the harness runs under, whose enquire answers the honest EnquiryError::no_desk() rather than reaching a handler.

BINDING_IDLE_CALLS (256) is the binding lease’s constant, which this module owns but does not put on the request: it is not per-run policy, it is per-shell policy, armed once by shell_eval::arm_session_ledgers — the one ledger-policy site, applied by the one root recipe both carriers boot through (boot::engine_runtime for the process half, boot::engine_root for the recipe, which refuses a Root that names no scratch: the host owns it); a fork keeps its terms through core’s Shell::bequeath and Shell::child_of (agent; agent-binding-reaping). Reusing the settled-worker-retention figure is deliberate: one ral-call clock, read by both ledgers for their own idle policy. LARGE_BINDING_BYTES (1 MiB) rides the same BindingLease at the same arming site — a residency threshold, not a lifetime one, so the install chokepoint checks it independently of idle age or baseline status.

Completion is dispatch_to_report returning Ok(Report); an Err(Severed) is folded by every attend-loop caller into agent::seat::EngineLost (agent). A spawned worker — a server, a watch — holds bounded deferred surface storage in core, never a clone of the bus `Emitter`, so it cannot keep the tool run from ending (run-turn-host-loop). Frame teardown is core’s: IoLoan brackets the byte streams and registers a run takes on loan from the shell, self-healing on a caught worker panic as well as on the normal return (panic-recovery), while the run’s invariant half — surface, deferred sink, desk, fork arm, cancel, the worker cap — threads as an immutable &Mooring the stack itself restores. Exarch needs no bracket of its own: its RunHost is a plain Arc Avatar::ral builds and passes to the dispatch, never installed as shared state, so an unwind drops it with the rest of that call’s stack. The dynamic-context half of the contract lives in agent.

The wall is a place, and everything before it stands. A timed-out call keeps both its effects and its bindings: the child is running, the bytes are in the inbox, the wakeup is armed, the staged reply is still harvested, and the lets that landed are still bound — a failed run keeps its bindings, pinned mid-script for the wall by mid_script_wall_keeps_the_bindings_that_landed. The asymmetry is before/after the wall, never bound/unbound. Nothing rolls back, and a model told only “retry with a higher timeout_secs” would duplicate every one of those effects. So a TimedOut stop writes four things to stderr, in order, and exits 124:

  1. the engine’s rendering, verbatim — unconditionally, exactly as the ordinary-failure branch beside it. A stop is stamped on the innermost node it unwound through, and the run’s closing poll keeps that location (cancellation), so the diagnostic carries the span that locates the wall: the frontier between the steps that completed and the one that did not;
  2. the asymmetry and the remedy — it timed out after n seconds at the point above; the steps before it completed and their definitions are still bound, the step it names did not complete, and the steps after it did not run, so resume from there rather than replaying the call. Then recovery: — raise timeout_secs for work that is simply slow, or let h = spawn "title" { … } and let the run return, since the host notifies at the next exchange boundary when it settles, and await $h, or await !{worker <id>} from the notice, then behaves as the block run there, without polling;
  3. an audit of what already stands, when there is any — and not the wall’s alone: every ending whose transcript does not otherwise show what landed files it, a chosen exit as much as a suffered raise, last, after whichever remedy the ending offered, because the record is owed by the failure and not by the deadline (a call that staged its reply and then died on a command’s non-zero exit, or chose exit 2, made that reply stand just as surely). This is the one exception to core’s own trail: the desk authors the shared Observation vocabulary from the host side, into a per-call fragment joined at render — never into the engine’s trail, because a wire seat’s cancel can land while the engine sits parked in enquire, mid-unwind of a builtin whose act the desk already committed, and engine-side recording alone would then report a standing act as failed. HostServices::commit_act is the one door every acting handler funnels through — minted in Avatar::host_services, the one place a call’s whole desk capture is assembled, so the fragment’s extent is the call’s — and it builds one Observed::Act per attempt, at the arm where the outcome is known, and fans it out itself: the rail’s Display::HarnessCall row always, off the very verb/subject/payload/refused the observation carries, and the fragment only when refused is false. A refused attempt leaves no fragment entry: it answers one question — what stands — and an entry for work that never happened would blunt it. Because one datum feeds both readers, an eighth act cannot reach one and miss the other by construction, with no enum-adjacency discipline to maintain (harness-calls-are-acts). A schedule call’s subject is always the caller’s own label — schedule requires one, so there is no minted default that could ever disagree with it. DeskAct still names the seven acts and yields both spellings, the rail’s verb column — one word per act, so evict and not the family and tag it is reached by — and the audit’s past tense.
  4. the workers that outlived the call, named. A spawned worker is moored by Mooring::for_worker onto the session root precisely so a foreground cancel cannot reach it, so a raise, the wall, or an exit leaves the work running. What the sentence reports is reachability. A handle bound by a step that completed before the failure is still bound, so await $h reaches it; one the failing step would have bound never landed — pattern::destructure is all-or-nothing. An Ending::Unreturnable — the run settled on a handle, block, or function, which the wire cannot carry — failed no step, so there the sentence says instead that a handle only returned was lost with the result. Either way the id still reaches the work: the sentence ends with await !{worker N}, so no survivor is orphaned. Which workers outlived the call is a different fact from a committed act, so it is its own sentence and the desk grows no worker view to hold it. Which workers are this dispatch’s is not arithmetic across the seam: every birth its extent gave reaches the host as an Observed::Worker on the surface, ordered before the Report, and the call’s SurfaceApplier keeps those ids; shell_eval/report.rs’s render joins them against the `workers probe, decoded into core’s WorkerRow by Transport::workers at the run boundary — legal there on a wire seat exactly as on the identity seat, since the registry never crosses. A birth still present in the registry, running or settled-unclaimed, is named (shell-state); one already claimed has left the registry and is nobody’s orphan. The sentence names each survivor as #id (title), the title being the one the worker_title policy required of its spawn, up to five, and counts aloud whatever it does not name — a silent truncation would read as “that was all of them”.

run_shell asks for no trail (trail: None). The host already hears every observation on the surface, so a trail would only retain, until the run ends, records nothing reads. “This command completed, that one was cut” is the engine diagnostic’s job.

Surface decoding. shell_eval/surface.rs is everything a surfaced value becomes in the record. decode_surface is the single decoder both delivery regimes share: the live path — dispatch_to_report’s drain loop calling host.surface(val) for every Event::Surface, which SurfaceApplier decodes and records — and the deferred sink (deferred_sink, installed on the transport before each dispatch), whose batch is posted to the inbox when a worker flushes and recorded by surface::replay as the item arrives; both go through one private decode_loudly and one surface::record, with no guard layered over them. The codomain is Surface, the shell’s own closed vocabulary, wrapped in Decoded beside Landed and Unknown. The decoder tries observation, then notice, card, and done — the shapes are disjoint, so the order carries no meaning — and has no pin arm. It carries only the structured value each channel names — no Card mark tree, since that is built by whoever renders — a printer’s fold over the recorded Display commit (frontend) — and never by the decoder or by the seam that records it (surface::record):

  • the register is not decoded here at all: exarch-pins reaches it through the desk, which builds Surface::Pin { key, card } / Surface::Unpin { key } itself. A pin is a rendered card in a slot, so its card is the fact itself, not a copy of one, and no key is reserved. Pins are mirrored as PinDigests so the nudge layer can name pinned state without parsing rendered text — the read side reuses the same store rather than adding a second one (pins);
  • an `observed value core emits at a redirect, exec, or capability-check door decodes through Observation::from_surface into Surface::Observation, the raw observation alone (io-surface); one landing declines is Decoded::Landed, kept off the rail without a word;
  • a `notice core’s ready-boundary housekeeping pushes (a worker reap, an idle-binding prune, a large-binding warning) decodes to Surface::Notice (engine-protocol);
  • any other value tries value_to_card and becomes a Surface::Card, the one shape whose payload is a card; the closed mark set and the value_to_card / render_card decode-and-bind path live in cards;
  • the `done completion event a worker flushes at the end of its batch decodes to Surface::Done — its id, title, run time and real ending — which the inbox also words as the model’s wake-up notice (surface_notice, frontend), ending in the one call that gets the result back: errand #7 (building) finished after 3 mins 12 secs (exit 101): build failed. `await !{worker 7}` replays its output and returns its value; `workers` lists every worker.;
  • a value that is none of these is Decoded::Unknown: decode_loudly records a Forensic::SystemNote naming the shape it dropped, the extension law’s loud unknown class.

The producer is a direct exarch-surface call at each kit site, with no cross-language sentinel constant. Same-thread children inherit the sink; workers do not inherit the live sink. Core buffers their exarch-surface calls and flushes a settled batch to exarch’s deferred sink (the inbox path), so no bus Emitter clone outlives the tool run. Across the OS-sandbox boundary the events are buffered in the confined child and replayed through the parent’s sink (carried on the IPC response), so they are batched rather than live under the sandbox.

shell_eval/builtins.rs registers exarch’s resident host atoms — view-text/view-hash, the grep-files search, the hash-addressed edit-hash/edit-replace, whose file I/O happens in Rust, below the redirect frame (io-surface) — and sources the small embedded data/agent.ral helper library (the -around readers, the tasks kit) into the shell at boot (builtins). The one host_surface() value declaring these sets rides core’s boot_shell at construction and is also the builtin surface a wire engine child’s Frame::Attach names, so a remote shell is dressed with the same atoms.

A tool command that fails under an active OS sandbox carries a kernel-denial diagnostic — the blocked syscall, the exact path to grant, the symlink caveat — appended to the error’s hint. That harvesting lives in core (core::sandbox::diag), driven by the command and pipeline runners over the failing call’s wall window and rendered identically by both the ral REPL and exarch (capabilities).