Long-running work is born, not promoted
Superseded by leases-and-budgets: the open regime question is answered as Regime 1, delivered as the durable lease class of a universal worker registry; the verb is spelled
service, now with a mandatorydescription. The binding-pinning question dissolves because the registry retains the handle itself. The cancel-by-id question is answered narrowly, not by a general listing: amended, 2026-07-06, the registry grew a model-facingworkerslisting and then retired it again — a never-bound durable job is instead re-acquired byservice-handle <id>off a host-owned pinned ledger row, never a queried value.
Work that must outlive the one-hour detached-worker death-clock is born
durable — launched as its own construct with its own lifetime — not started as
an ordinary spawn and promoted later. This is the second, explicit mechanism
concurrency-detached-vs-structured
deferred. Its shape is settled — a distinct exarch-registered verb that births a
worker into a listable durable-job registry — but its headline decision is
open: which lifetime regime(s) it supports, and so which to build.
Context
Two bounds exist; long work collides with only one.
- The 30 s foreground wall (
CALL_TIMEOUT_SECS,exarch/src/tools/ral.rs:31) bounds a synchronous tool call. Long work already escapes it via the canonical idiom —spawn→pollacross turns →awaitonce settled — so this is not the bound that pinches. - The one-hour death-clock reaps an abandoned detached
spawnworker. That is the bound a long job collides with.
The death-clock is realised by arming the shared reaper. spawn_child does
crate::process::arm_lifetime(cancel.clone(), ceiling).keep()
(core/src/builtins/concurrency.rs), where ceiling is the frame’s
detached_ceiling: Option<Duration> (core/src/types/shell/mod.rs). exarch
supplies Some(DETACHED_WORKER_CEILING) = one hour
(exarch/src/shell_eval.rs); the REPL supplies None. .keep() is
fire-and-forget: the reaper’s Deadline guard
(core/src/process/reaper.rs) is forgotten, so the ceiling fires even when
the handle is dropped — that is precisely what reaps an abandoned worker.
(watch-repl-builtin collapsed the frame’s
detached-worker policy to this bare ceiling when watch admission left the
frame.)
The concurrency ADR named this follow-up directly: long-running work should be
“a second, explicit mechanism … a host-managed durable job, with its own
ownership, listing, cancellation, and lifetime policy,” and it rejected a
timeout: knob on ordinary spawn. This ADR is that mechanism’s design.
Decided
-
Birth, not promote. A long job is born durable, not started as an ordinary
spawnand promoted afterwards. The mechanical reason: a born-durable worker never arms the death-clock, so there is nothing to undo. Promotion, by contrast, would have to retain the death-clock’sDeadlineguard in a side registry keyed by id — solely so a later call could disarm it — machinery whose only purpose is to undo a ceiling a promoted worker wishes had never been armed. The intent reason: a server or a multi-hour run is known to be long at launch; the “spawn, then realise it must persist” case promotion serves is rare. So build birth only; add apromoteoperation later only if a concrete mid-flight need appears — not speculatively. -
A distinct verb, registered by exarch. Birth is its own construct, not a knob on
spawn(the concurrency ADR rejected the knob, andspawnstays one boring primitive). It is an exarch-registered builtin, reusing the principle from watch-repl-builtin: core supplies the spawn mechanism, the host registers the affordance. The REPL does not get it — the REPL has its own POSIX job control (jobs/fg/bg/disown,ral/src/jobs.rs, jobs); durable agent jobs are exarch’s concern. Becausedisownalready names REPL job control, it is unavailable as a spelling here. -
Born into a durable-job registry → listable. This is the management surface the concurrency ADR reserved. It is load-bearing: across context compaction the model loses the handle’s binding name, so it must rediscover its jobs by listing and then
poll/cancelby id rather than by name. A born-durable handle must therefore be pinned against agent-binding-reaping, so its binding is not pruned out from under it.
The open decision: which regime(s)?
Birth means two very different things by lifetime. They share almost no code, so the real decision is which to build.
-
Regime 1 — in-process durable (dies when exarch exits). The worker is an in-process OS thread that never arms the one-hour ceiling (or arms a long backstop instead). It returns a real
Handle, sopoll/await/cancelall still apply; it is registered and listable. It reuses the existing reaper/handle/registry machinery, so the implementation is small. It fits “a multi-hour build or training run I babysit across this session.” Candidate spelling:service { … }(neutral) ordaemon { … }(evocative, butdaemonconnotes survives process exit, which this regime does not). -
Regime 2 — survives exarch exit (a server you leave running). This is not a ral-handle concept at all: an in-process thread cannot outlive its process. It is launching a detached OS process that escapes the worker’s process-group teardown — a new session via
setsid, IO redirected to files — reconnected later by pid and exit status. There is noHandle, nopoll/await; it is closer tonohup/process-group detachment than to a ral job. Observe that Regime 2 independently confirms birth, not promote: you cannot promote an in-process thread into a surviving process.
Recommendation. Build Regime 1 now — the in-process durable birth primitive, with listing and cancel-by-id. Treat Regime 2 as a separate, later, smaller process-detachment capability, pursued only if a concrete need appears. Do not fuse the two: they share almost no implementation.
Alternatives considered
- Promote an existing
spawnhandle. Rejected for birth: the guard-retention machinery above, and intent is known at launch. Apromoteverb may return later for a concrete mid-flight need; it is not built speculatively. - A lifetime /
timeout:knob on ordinaryspawn. Already rejected by the concurrency ADR.spawnstays one boring primitive with a host-owned default ceiling; long life is a different construct, not a dial on every spawn. - Fuse both regimes into one primitive. Rejected: different handle types
(
Handlevs none), different observability (poll/awaitvs pid/status), different lifetime, and essentially no shared code. One primitive spanning both would be two implementations behind one name.
Open questions
- Regime 1 only, or also Regime 2? The headline decision this page frames.
- Spelling:
servicevsdaemon(the latter mis-suggests survives-exit). - Regime-1 lifetime: truly no ceiling (a forgotten job is a zombie until exarch exits) versus a long backstop (e.g. 24 h) with the listing surface to bound it by hand.
- The cancel-by-id surface for a job whose handle binding is gone — the registry must answer cancellation by id, not by the lost name.
See also
concurrency-detached-vs-structured
(the ADR that deferred this; the death-clock and timeout:-knob rejection),
unify-turn-evaluation (the frame and
the root/foreground split the death-clock rides),
watch-repl-builtin (the core-mechanism /
host-affordance registration principle this reuses),
agent-binding-reaping (a born-durable
handle must be pinned against the reaper),
jobs (the REPL’s POSIX job control this is distinct from),
map: builtins, and docs/SPEC.md §13.