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::protocol::dispatch_to_report against the agent’s seat transport —
the in-process IdentityTransport, or a wire engine’s WireTransport
(agent) — and renders the terminal Report that comes
back, or Outcome::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 and the outcome it formats:
- source +
script_name: "<tool>". Core’scompile_runrunscompile_and_typecheckseeded 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 asReport::Static { diagnostics }, whichrun_shellformats toOutcome::Static; on success the annotated comp runs (unify-turn-evaluation); caps— the sessionCapabilities, 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-levelgrant { … }the model could escape. External commands route through the same OS sandbox as ral (capabilities). The eval runs inMode::Session, so each landedDefinewrites back intoshell.envas it lands and the run’sContextis 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 inReport::Ran’scaptured: 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 cannottcsetpgrp, and the SIGTTIN crash of an agent stealing the controlling terminal becomes a state the types refuse to represent. Paired withstdin: 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, theraltool’stimeout_secs(60s default). Core arms it on the run’s foreground scope before compiling, so it bounds the whole run; onlyCancelCause::Deadlinereportstimed_out, whichrun_shellturns into the exit-124 stderr described under the wall is a place below, while Esc stays an interrupt. 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;deferred_lease— the idle-observation lease for workers the run detaches at the durable root:DETACHED_WORKER_CEILING(1 h) as the idle bound — a worker is reaped once unobserved that long, renewed by anypoll/await/racenaming its handle — underDETACHED_WORKER_BACKSTOP(24 h), the absolute age no polling extends. Not load-bearing for run exit;worker_cap—LIVE_WORKER_CAP(64), the admission bound core enforces at the spawn door: the 65th spawn is refused (the error namesawait/cancel) while 64 workers of any class still run; settled entries lingering under retention hold no seat. Its sibling constantSETTLED_WORKER_RETENTION(256 ral calls, matching the binding lease’s scratch expiry) is not on the request at all — it is armed once throughShell::arm_worker_retention, and the sweep it parameterises is engine housekeeping; Surface delivery and enquiry answering are notRunfields:dispatch_to_reporttakes onehost: Arc<dyn Host>— engine-protocol’s single object for a run’s whole host-facing surface. Every real caller buildsfleet::desk::RunHost, pairing the per-callExarchDeskwith aSurfaceApplier(builtins); a bareSurfaceApplieralone is the mute host the harness runs under, whoseenquireanswers the honestEnquiryError::no_desk()rather than reaching a handler.
BINDING_IDLE_CALLS (256, beside DETACHED_WORKER_CEILING) is the other
lease constant this module owns but does not put on the request: it is not
per-run policy, it is per-shell policy, armed once by
bootstrap::arm_session_ledgers — the one ledger-policy site, applied by
the identity seat’s ceremony and the wire engine’s boot recipe alike
(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)
becomes Outcome::Severed instead, folded by every attend-loop caller into
agent::seat::engine_gone (agent). A detached 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, nursery, cancel, the
leases — threads as an immutable &Mooring the stack itself restores. Exarch
needs no bracket of its own: its RunHost is a plain Arc Avatar::run_shell
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 the acts before it stand. A timed-out call unwinds
its bindings and keeps its effects: the child is running, the bytes are in the
inbox, the wakeup is armed, the staged reply is still harvested. Nothing
rolls back, and a model told only “retry with a higher timeout_secs” would
duplicate every one of them. So the timed_out branch writes four things to
stderr, in order, and exits 124:
- the engine’s rendering, verbatim — unconditionally, exactly as the ordinary-failure branch beside it. A cancel is stamped on the innermost node it unwound through (cancellation), so the diagnostic carries the span that locates the wall: the frontier between the steps that completed and the one that did not;
- the asymmetry and the remedy — it timed out after n seconds at the
point above; the steps before it completed, the step it names did not, and
the bindings are gone. Then
recovery:— raisetimeout_secsfor work that is simply slow, orlet h = defer { … }and let the run return, since the host notifies at the next exchange boundary andawait $hyields the value record without polling; - an audit of what already stands, when there is any — and not the wall’s
alone: every ending that discards the bindings files it, a chosen
exitas much as a suffered raise, last, after whichever remedy the ending offered, because the asymmetry belongs to the unwind and not to the deadline (a call that staged itsreplyand then died on a command’s non-zero exit, or choseexit 2, made that reply stand just as surely).Break::Stoppedis job control rather than an ending, keeps its bindings, and files nothing. This is the one exception to core’s own trail: the desk authors the sharedObservationvocabulary 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 inenquire, 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_actis the one door every acting handler funnels through — minted inAvatar::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 oneObserved::Actper attempt, at the arm where the outcome is known, and fans it out itself: the rail’sDisplay::HarnessCallrow always, off the veryverb/subject/payload/refusedthe observation carries, and the fragment only whenrefusedisfalse. 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, a seventh act cannot reach one and miss the other by construction, with no enum-adjacency discipline to maintain (harness-calls-are-acts). Aschedulecall’s subject is always the caller’s own label —schedulerequires one, so there is no minted default that could ever disagree with it.DeskActstill names the six acts and yields both spellings, the rail’sverbcolumn and the audit’s past tense. - the workers that survived binding loss, named. A
deferred worker is moored byMooring::for_workeronto the session root precisely so a foreground cancel cannot reach it, so a raise, the wall, or anexittakes the handle binding and leaves the work running — but notStopped, which is no ending and keeps bindings, so a stopped call draws neither this sentence nor the audit one. The sentence stops at binding loss — it says the binding went with the unwind and so the worker cannot beawaited, which is also what keeps it from reading as a contradiction of therecovery:line’sawait $h; the exchange-boundary promise is already made four lines above and is not made twice. That 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: the dispatch’s own trail carries anObserved::Workerfor every birth its extent gave, andshell_eval/report.rs’srenderjoins those ids against the`workersprobe, decoded byAvatar::probe_workersat 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 by itscmd, up to five, and counts aloud whatever it does not name — a silent truncation would read as “that was all of them”.
The per-stage journal exists but goes unrendered: run_shell asks with
trail: Some(CapturePolicy::Off), so every dispatch’s Report::Ran.trail
carries a per-command record — including the command a cancel struck — and
report::render currently reads only the Observed::Worker births from it.
“This command completed, that one was cut” is the engine diagnostic’s job;
nothing else in the digest walks the journal yet.
Surface decoding. 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::live
decodes — and the deferred sink (deferred_sink, installed on the transport
before each dispatch) mints identical events when a detached worker’s batch
flushes, both calling it directly — the accepted_surface wrapper that once
layered a protected-pin guard over it is gone with the guard itself. The
codomain is Surface, the shell’s own closed vocabulary: five channels (the
Pin/Unpin variants are one pin channel), tried pin-first. 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) or whoever records (the commit producer’s
SurfaceBuffer, frontend), never by the decoder:
- a
`pin/`unpinwrapper decodes toSurface::Pin { key, card }/Surface::Unpin { key }— a pin is a rendered card in a slot, so its card is the fact itself, not a copy of one; no key is reserved, so every pin decodes and writes the same way regardless of key. Accepted pins are mirrored asPinDigests 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); - a
Mapcore emits at a redirect, exec, or capability-check door decodes throughObservation::from_valueintoSurface::Observation, the raw observation alone (io-surface); - a
`noticecore’s ready-boundary housekeeping pushes (a worker reap, an idle-binding prune, a large-binding warning) decodes toSurface::Notice(engine-protocol); - any other value tries
value_to_cardand becomes aSurface::Card, the one shape whose payload is a card; the closed mark set and thevalue_to_card/render_carddecode-and-bind path live in cards; - the
`donecompletion event a detached worker flushes at the end of its batch decodes toSurface::Done; - a value that is none of these is dropped, the same graceful degradation
value_to_cardgives an unknown mark.
The producer is a direct surface call at each kit site, with no cross-language
sentinel constant. Same-thread children inherit the sink; detached workers do
not inherit the live sink. Core buffers their surface calls and flushes a
settled batch to exarch’s deferred sink (the inbox path); without that host
sink, the ordinary await/race path replays it into the awaiting run. Either
path keeps a bus Emitter clone from outliving 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 now 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-sh REPL and
exarch (capabilities).