watch is the REPL’s builtin, not a vetoed core one
A builtin a host cannot run should be absent from that host — not present
and refused at call time. watch is an interactive-REPL affordance: it
streams a worker’s output line-framed to a durable terminal sink. The host that
has no such sink should not resolve the name at all. The proposal moves watch
out of the shared core table and registers it only in the REPL, through the same
mechanism exarch already uses to add its agent tools, and collapses the frame’s
detached-worker policy to its one remaining axis.
Context
The active
concurrency-detached-vs-structured
made watch a runtime-gated builtin. It is registered in the shared
CORE_BUILTINS table (core/src/builtins.rs:360), so both hosts seed it;
builtin_watch (core/src/builtins/concurrency.rs:184) then refuses at call
time when the turn frame denies it
(if shell.turn.detached.watch == WatchAdmission::Denied { … },
concurrency.rs:186).
That admission flag rides the frame. The detached-worker policy is a struct,
DetachedPolicy { lifetime: Option<Duration>, watch: WatchAdmission }
(core/src/types/shell/mod.rs:170), supplied by TurnFrame and installed into
TurnState. The REPL supplies DetachedPolicy::interactive()
(watch: Admitted, mod.rs:181); exarch supplies DetachedPolicy::agent(1h)
(watch: Denied, mod.rs:187).
The cost of this shape is a contract that lies. Under exarch the name watch
still resolves and typechecks, then always fails at runtime — the frame carries
a “this builtin is forbidden” axis for a primitive the host simply has no use
for. watch’s purpose — live, line-framed streaming to a durable terminal sink
(ChildIoMode::Watch → Sink::LineFramed, concurrency.rs:70) — is inherently
interactive: the REPL’s stdout is the rustyline external printer, while exarch’s
streams are per-call capture buffers a root-surviving watcher would write into
after the turn had already taken them.
The machinery to add a host’s own builtins already exists. exarch installs
EXARCH_BUILTINS via ral_core::builtins::register_builtins
(core/src/builtins.rs:444) from agent_builtins::install_on
(exarch/src/agent_builtins.rs:26). A host adding builtins beyond the core set
is established, not novel.
Decision
-
Move
watchout ofCORE_BUILTINSand register it only in the REPL. AREPL_BUILTINSset, installed at REPL boot through the sameregister_builtinsmechanism exarch uses for its agent tools, carries thewatchentry. Core then provides the host-agnostic concurrency primitives —spawn,await,race,cancel,poll— and each host adds its own affordances: exarch its agent tools, the REPLwatch. The registration model is symmetric. -
Keep
watch’s implementation in core. The line-framed spawn machinery —builtin_watch(concurrency.rs:184),spawn_child,ChildIoMode::Watch,Sink::LineFramed— is general and stays private to core. Only registration moves: core exposes thewatchBuiltinEntryso the REPL can install it, which keepsbuiltin_watchandscheme::watch(core/src/typecheck/builtins.rs:870) from having to becomepub. This is the one asymmetry the proposal introduces — implementation in core, registration in the host. -
Collapse
DetachedPolicyto a single axis. Withwatchadmission gone from the frame, the detached-worker policy reduces to the per-host lifetime ceiling: the frame carriesdetached_ceiling: Option<Duration>(the REPLNone, exarchSome(1h)).WatchAdmission, thewatchfield, theinteractive()/agent()watch distinction, and the runtime gate inbuiltin_watchare all removed. At one field theDetachedPolicystruct is arguably not worth a name (see Open questions). -
The result is that exarch genuinely lacks
watch. It is absent: not in exarch’s builtin table, not inhelp, not in exarch’s system prompt. Naming it gets no special treatment — ral is a shell, so a bare word that is not a binding, builtin, or handler falls through to external-command resolution (exec_comp_ty→external_exec_comp_ty,core/src/typecheck/infer.rs).watchunder exarch is therefore an ordinary unknown command that fails at runtime with command not found, exactly as if it had never existed — not a builtin that resolves and refuses at call time. The frame stops carrying a “this builtin is forbidden” axis; the cost is that the failure is the generic unknown-command error, not the old tailored “use spawn” hint.
Consequences
- The frame’s detached-worker policy is one axis — a lifetime ceiling — not a policy struct with an admission flag.
- exarch lacks
watchby absence: there is no typechecks-then-always-errors path, because there is no builtin. Naming it is an unknown command, not a vetoed primitive — the generic command not found at runtime, not a tailored diagnostic. watchbecomes the one concurrency primitive registered out-of-band from its siblings: implemented in core, registered by the host. This is the cost — weighed against the removed frame axis and the honester contract.- SPEC §13.5 gains “a builtin the host installs” in place of “admitted only by a host whose streams are a durable sink” — registration-by-absence, not a frame rejection.
What shipped
The split landed on turn-local-state.
watchleftCORE_BUILTINSforcore::builtins::WATCH_BUILTIN— a one-entry&[BuiltinEntry]core exports. The implementation (builtin_watch,spawn_child,Sink::LineFramed) and the scheme (scheme::watch) stay private to core; only the entry is public, so the one asymmetry holds — implemented in core, registered by the host.- The
ralhost installs it; exarch does not.register_host_surfaceregistersWATCH_BUILTINprocess-wide (so the typechecker sees it) alongside the_ed-*editor builtins, and both the REPL session boot and the batch (run_batch) path install it into their shell — the ral binary has a durable stdout sink in every mode, so the axis is the host, not the interactive REPL alone. Read the ADR’s “REPL” as “theralbinary, all modes.” exarch installs onlyEXARCH_BUILTINS, sowatchis absent there. DetachedPolicycollapsed todetached_ceiling: Option<Duration>— resolving the open question toward the bare field onTurnFrame/TurnState.WatchAdmission, theinteractive()/agent()constructors, and the runtime gate inbuiltin_watchare gone; the frame carries one axis — the lifetime ceiling (Noneral,Some(1 h)exarch).- The exarch failure is the generic unknown command (see the corrected
Decision bullet), not a compile-time diagnostic. The core
watch_is_denied_under_an_agent_frametest is deleted, and the worker-thread-pathwatchtest left the core layer with the builtin — that path is pinned by thespawntwin andral/tests/spawn_watch.rs.
Alternatives considered
- Keep the runtime gate and
WatchAdmission(the status quo). Rejected: an extra frame axis and a compile-time lie for a builtin a host has no use for. - Move
watch’s whole implementation into theralcrate. Rejected: it would forcespawn_child,ChildIoMode::Watch, andSink::LineFramedto becomepub, leaking concurrency internals across the crate boundary. Onlywatch’s availability is REPL-specific; its mechanism is general core machinery. - Derive admission from the IO regime (
InheritvsCapture) rather than a flag. Rejected: it still requires retaining the regime discriminant on the frame and still leaveswatchresolvable-but-failing under exarch — the same lie, differently spelled.
Relationship
This decision revises the watch-admission mechanism of the active
concurrency-detached-vs-structured.
Everything else there stands: the detached-root model, the one-hour death-clock,
the shared process::reaper, the await/race unification, and forget’s
deletion. That ADR’s frame-gated watch and its DetachedPolicy { lifetime, watch } are replaced here by registration-by-absence and the bare
detached_ceiling; its “What shipped” watch and policy bullets point forward
to this page.
Open questions
DetachedPolicy— resolved. Collapsed to a baredetached_ceiling: Option<Duration>onTurnFrame/TurnState; a one-field struct earned no name, and theNone/Some(d)field is its own documentation.- Registry coverage. A hand-written
SchemeBuiltinEntryis not const-arity-checked the way the macro checks aSigentry — it injects its declared arity verbatim.WATCH_BUILTINdeclaresSome(2), matchingscheme::watch’sString → U(F α) → F (Handle α). Once the ral host registers it,watchridesbuiltin_names()for the arity sweep like any other entry; in the bareral-coretest binary it is unregistered, so the sweep (registry_and_scheme_arity_agree,core/src/typecheck/builtins.rs) does not see it — andRESOURCE_BACKEDinbuiltin_registry_propertydropped its now-deadwatchline.
See also
concurrency-detached-vs-structured
(the ADR this revises),
unify-turn-evaluation (the frame and
the root/foreground split), map: builtins,
loop, and docs/SPEC.md §13.5.