src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
20 KiB
Slice 5: routines and jobs (spec)
Status: reviewed, ready to build (2026-10-24; reviews by Astra, Fable, Opus, Sol folded in). Guiding rule (owner): simple, fast, correct. The single source for building
slice 5, together with design.md §14 (routines) and §15 (jobs), read in full with the rest of design.md, the
feature map in slices.md (entries assigned to slice 5) and its "Carry-over" for slice 5, the decision log entries
"Routines for slice 5", "Routines and jobs open points" (with "Jobs keep accepts" and "Job crash handling"), D4, R11,
Q9, S8, W2b, P2, and the root AGENTS.md section "Chord v2 Code Style". Builds on slice 4b (1c88b45d7).
Slice 5 builds routines completely and jobs in memory. Persistence of jobs is slice 6 (it needs the store); the
reload rules of both (handover, accepts in use, blocked { version }, native restarts) are slice 7 (they need
reloads). Their APIs are declared here so that later slices add behaviour, not shape.
What an app can do after this slice
// routines
host.serve(ForwardStore, (_, self) => {
const forwards = self.state(Forwards);
const catalog = self.get(Catalog, {});
self.on(catalog.sessions, async (sessions, ops, ctx) => {
for (const f of forwards.value.list) if (sessions[f.session]?.archived) await closeForward(f.id, ctx);
});
self.onLive(async (ctx) => {
for (;;) { await sleep(HOUR, ctx); await dropExpired(ctx); }
});
return { … };
});
// jobs
const ShellJob = defineJob("chat.shell", {
version: 1,
args: obj({ command: str(), author: Author }),
result: obj({ exit: int(), output: str() }),
state: ShellState,
concurrency: 1,
});
const shell = self.job(ShellJob, { run, finish });
await shell.start({ command: "npm test", author }, ctx, { key: requestId });
1. Routines (§14)
self.on(source, fn)andself.onLive(fn), declared only while the factory runs (asstate,derive,get,open), in objects of both kinds and in facets (design §16: a facet holds documents, references, routines and contributions).onover a JSONWatchablecallsfn(value, ops, ctx); over aNativeWatchable(a registry value,ref.state)fn(value, ctx). Overloads by the source's type. At runtime the source is resolved at declaration withresolveWatchable(Chord's own sources, constants, reference members); anything else that is aNativeWatchable(itswatchyields aNativeWatch) is followed as native. There is no separate kind tag.- Start: right after the object went live, in the same turn (owner); a method call admitted in that turn may run
before the first
oncall. Stop: when the object ends, theirctxis aborted with the object's end error; the drain (§6) waits for routine calls like for method calls, bounded bycloseTimeout. onis implemented over the sharedWatchwith a listener that never throws (a throwing listener would end the watch): it runs the handler, catches its error, reports it (routine_failed) and schedules its own retry. Calls never overlap; changes during a call fold into the next (the watch's pending queue: newest value, folded ops); a running call is never aborted by a change.- The first call gets the current value with
[["r", value]](W2b). - After a call that threw, the next call (the retry after the backoff, 1 s doubling to 60 s, or a change during the
backoff, which calls at once) again gets
[["r", value]], never only the changes since: a handler that applies ops incrementally would otherwise drift. A call that succeeds resets the backoff. - The source ending (
ended,retired: a document retired, a reference released) ends the routine without a report; the source failing (failed: a derive's compute threw, a provider member failed) ends it with oneroutine_failedreport. (A failed provider member that recovers after a provider reload: slice 7 carry-over.)
- The first call gets the current value with
onLive: called once when the object is live. Returning ends it (until the next reload or activation, slice 7); throwing is reported and restarts it after the same backoff (reset when a run lasted at least a minute); an abort (the object ends) is no error and not reported.- Each call's
ctx: the object's principal (remote) or the host's (local), the host's incarnation, telemetry, andctx.getreferences released when the call settles (CallContext). - Not counted in
host.activity. - Helpers, exported for apps:
sleep(ms, ctx)(rejects with the abort reason whenctxis aborted; clears its timer and listener; delays above the timer limit of 2^31-1 ms are chained) anduntilAborted(ctx)(resolves whenctxis aborted). Portable (timers andAbortSignalonly). HostErrorgainsroutine_failed { object, routine, error },routinebeing the kind and declaration position ("on #2","onLive #1").- The reload handover per object is slice 7 (carry-over); in this slice a routine only starts with the object and stops with its end.
2. Jobs (§15), in memory
Declaration
defineJob(name, { version, args, result, state?, concurrency?, accepts?, persist? }):argsclosed (like method arguments: checked atstartwith a compiled validator,invalid/args);resultnot validated (the provider's);statea singleton document definition;concurrencya positive integer;acceptschecked at declaration (shape, older versions only) and used in slice 7.persisthas its final shape now,persist: { resume: "rerun" | "abandon" }(resume required, Durable's wording), and is refused in this slice (not supported before slice 6) rather than silently kept in memory.self.job(Job, { run, finish? })in an object's factory: once per job name per object; returnsJobHandle.- The per-run state documents are Chord's: an internal keyed handle per job (named after the job, keyed by run id),
separate from the author's own
self.statehandles, so an author may also use the same definition for a document of their own without "declared twice".
Run records and running
- The job keeps its records on the owner (a map by run id, with the key index); records and state documents change
only inside transactions on the owner's line. Two hooks on
Transaction: staged record changes (create, outcome, delete, block, drop) adopted inside the commit step with the documents, and actions after the commit (start arun, settle waiters). runningis aSourceof its own (not aDerive: keyed documents publish nothing), marked changed in the commit step whenever a record or a run's state document changed, delivering the new list in that batch. Entries:{ id, version, key?, args, state, phase: "running" | "finishing" } | { id, version, phase: "blocked", reason }. An entry is a new object exactly when its record or its state document's value changed (cached per run by record and state value identity, never by record alone); the list is copied on change (O(unsettled runs)).
Start
start(args, ctx, { key? }): one transaction on the owner's line; resolves with aRunafter the commit.tx.start(job, args, { key? })insideself.txjoins the transaction's other writes and returns aRunhandle at once (a handle, not data from a draft); itsoutcome(ctx)waits for the run and rejectsreleasedif the transaction is discarded. So a caller can never miss a short run: the handle exists before the run can settle.job.outcome(id, ctx)waits by id for an unsettled run only (an id of a settled or unknown run rejectsgone; documented).- Order inside a transaction: check args; dedupe by key (an unsettled run with the key, including one started earlier
in the same transaction, returns its
Run; the args are not compared: a key names one run); concurrency: running plus finishing runs plus runs started earlier in the same transaction, excluding the run whosefinishthis transaction is (sofinishcan chain the same job atconcurrency: 1); at the limitunavailable/busy; then the record and the state document (initial(), checked once per definition, R8/D3). runstarts after the commit, outside the batch, afterself.txsettled (an action after the commit). Anabortbetween the commit and the start pre-marks the run's signal, sorunstarts aborted.- A
startreaches only a live object (methods and routines run only then). New error reasonunavailable/busy(the error table;PROTOCOL_VERSIONstays 1). Run ids: uuidv7.
Run
run(args, ctx)runs outside transactions.JobCtx=CtxplusjobIdandstate: { value; change(fn) }(only when the job has astate; typed so).ctx.state.change(fn)takes no context: it is a transaction on the owner's line drafting only the run's document, under the owner's context without the run's abort signal, so changes made whilerunstops after an abort still commit. It is accepted while the run's invocation is live (untilrunsettles) and refused afterwards withreleased(an invocation fence: it checks the run's invocation, not only the owner's liveness); it never recreates a retired document. Changes admitted beforerunsettled commit before the outcome is recorded (line order), sooutcome.stateincludes them.run's abort signal fires onabort(id), on the object's end and onhost.close. A run that throws after its signal fired endsfailed { aborted, reason: "abort" }(notthrew); a run that returns after an abort keeps its result.runthrowing (not aborted):failed { threw, error }and aHostErrorjob_failed { object, job, id, error }.
Finish
- When
runsettles: the outcome is recorded on the run (phase: "finishing"), andfinish(outcome, tx)runs in one transaction; the same commit deletes the record and the key and retires the state document. Withoutfinish, the commit only cleans up. finishthrowing: nothing changes; retried with backoff (1 s doubling to 60 s), each failure reported (job_finish_failed { object, job, id, error }); after five consecutive failures the run becomesblocked { reason: "finish_failed" }: outcome, state and key kept, not counted for concurrency, retried on the next reload (slice 7); a laterstartwith its key returns the blocked run (dedupe).Run.outcome(ctx)resolves with the outcome whenfinishcommitted (also withoutfinish); a blocked run's waiters keep waiting (their ownctxends the wait); a dropped run's waiters rejectaborted; the object's end rejects them with its error.
Abort, keep-alive, object end
abort(id): fires the signal of a running run; drops a blocked run (owner: nofinish; record, key and state document go in one transaction; reportedjob_dropped { object, job, id }; waiters rejectaborted); ignored for finishing runs and unknown ids.- Keep-alive: running and finishing runs are a count on the object, not
Holders (they are not references, so they never appear inhost.references). An object whose last reference goes while the count is above zero stays; when the count reaches zero with no holders, it ends as on its last release (after its type'slinger). Blocked runs do not count: at that end they are dropped with ajob_droppedreport each.host.activity.jobscounts running plus finishing runs. - Object end (revocation R11,
host.close, the last release): runs' signals fire; the drain waits forruninvocations like for calls (bounded bycloseTimeout); nofinish; outcomes, states and waiters end with the object (waiters reject with the object's end error).
Messages
None: jobs and routines are host-local; running is ordinary state.
Races
- Two
starts with the same key in one turn: the line serializes them; the second sees the first's record and returns its run. - A
startat the concurrency limit while a run is finishing: finishing counts;busy. tx.startin a transaction that is then discarded: no record, no run; theRunhandle it returned rejectsreleasedfromoutcome(ctx).ctx.state.changeafterrunreturned but beforefinishcommitted: refused (released); the outcome's state is the state atrun's settle. A change made afterabortwhilerunis still stopping: commits (no run abort signal on it).ctx.state.changein flight (waiting for the line) whenrunsettles: it was admitted while live, so it commits before the finish transaction (line order); the outcome's state includes it.abortwhilefinishruns: ignored.- The object ends while
finishwaits for the line: the finish transaction is refused (released); nothing is reported as a finish failure (it is the object's end). finishthrows five times: blocked; a newstartwith the same key returns the blocked run; a different key starts a new run (blocked runs do not count for concurrency).on's source changes during the backoff after a throw: the call happens at once with the newest value.- A routine call throws while the object ends: the end wins; no restart, no report of the abort.
sleepwith a huge delay and an abort: the chained timers are cleared.- A job's
runcallsstarton the same job (recursion): allowed; a new run (it is a different key or none). finishchains the same job atconcurrency: 1: the finishing run is excluded from its own transaction's check.- Two
tx.starts with the same key in one transaction: the second returns the first'sRun. - A short run started with
tx.startsettles before the caller awaitsoutcome: the handle resolves anyway. abortbetween the start's commit andrun's start:runstarts with its signal aborted.- The last run settles on an object without holders: the object ends after its
linger; a blocked run is dropped.
Cost
| Work | Cost |
|---|---|
on per change |
the shared watch's frame; folded under a busy call |
start |
one transaction: an args check (compiled), a record and a document |
state.change |
one in-memory transaction on the owner's line (prepare, adopt, flush) |
running per change |
one entry replaced; the array copied (O(unsettled runs)) |
finish |
one transaction (finish's writes plus cleanup) |
Carry-over taken
From slices.md "Slice 5": routine errors reported to onError with their own kind; W2b (on gets the acquisition
value first); C3 and C4 (decided); jobs and revocation (R11) and host.activity.jobs.
Carry-over left (to slices.md in the same change)
- Slice 6:
persist: { resume }built (record and state document in the owner's store; persisted only inlocalobjects with empty params, P3, enforced at declaration); outcomes and abort requests persisted (a recorded outcome resumes onlyfinish); the restart limit (five crash interruptions, persisted, clean closes not counted; whether a state change resets the count; what a clean close records); deferred persistence for state documents (flushed before the outcome is recorded); thefinishfailure count persisted; the job fingerprint stored with every record from the start; the run's start trace carrier stored (§29). - Slice 7: the routine handover per object on reload (bounded by
closeTimeout, calls not waiting for it); jobacceptsin use (args and result mapped forwards once per run;blocked { version }without an adapter or for a newer version); when an older run's state document may migrate while its old code still writes it, and an older run keeping its old handles valid until it settles; a native restart aborts running runs (aborted, reasonreload) andfinishruns on the new code; afinishin flight across a reload; run records and state documents belong to the object across attempts (documents belong to an attempt today); blocked runs retried on reload; a job declaration removed while runs exist; a job fingerprint over args, result and state (a schema change without a version bump); anonroutine whose source failed and recovers after a provider reload. - Slice 9: spans for routine calls and job runs.
Tests
Every rule and race above, one describe per mechanism:
on: first call with[["r", value]]; folding; no overlap; a throw reported, retried with[["r", value]]after the backoff, a change during the backoff calling at once, success resetting it; the watch survives a throwing handler; sourceended/retired(no report) andfailed(one report); native sources (ref.state); in a facet.onLive: return; throw and backoff, reset after a long run; abort without report.- Helpers:
sleepabort, chaining above 2^31-1 ms (fake timers), no timer left;untilAborted. - Routines with the drain and
host.close; a routine call admitted in the first turn next to a method call. - Jobs, start: args check; dedupe (across transactions and twice in one transaction; different args, same key);
concurrency and
busy(running plus finishing; earlier starts in the same transaction; chaining the same job fromfinishatconcurrency: 1);tx.startatomic with other writes; a discardedtx.start's handle rejecting; the handle of a short run resolving;outcome(id)for unsettled, settled and unknown ids;runstarting after the commit outside the batch; an abort beforerunstarts. - Jobs, run: with and without state;
state.changefence (after settle refused; in flight at settle committed before the outcome; after abort while stopping committed; never recreating the retired document); throw after abort isaborted; throw isthrewwithjob_failed; result after abort kept. - Jobs, finish: outcome kinds; record, key and state document gone in the finish commit; retries with backoff,
job_finish_failed,blockedafter five; dedupe returns the blocked run; abort drops it (job_dropped, waitersaborted). running: entries per phase; blocked entries without args or state; an entry replaced on a state-only change; no walk of other runs; per-principal views (example 19).- Keep-alive: an object without holders stays while runs run, ends after the last one (with
linger), dropping blocked runs;host.activity.jobs; runs not inhost.references. - Object end: revocation and
host.closemid-run (signals, drain, nofinish, waiters rejected). - Cost guards: args compiled once per definition;
runningtouches only changed runs. - GC: an ended object releases runs, documents and waiters; routines release their watches;
sleepleaves no timer; dropped and finished runs leave nothing.
Examples: 18-routines.ts (on with a retry, onLive with sleep), 19-jobs.ts (the shell job with a throttled
state document, flushed in a finally, changes awaited or caught, OutputBuffer with its required bounds,
per-principal views, abort and a chained job).
Size
About 900-1,200 source lines.
Decided on the draft (owner, 2026-10-24)
- Routines start right after the object went live, in the same turn; a method call admitted in that turn may run
before the first
oncall. abort(id)on a blocked run drops it: nofinish, its record, key and state document go, reported (job_dropped { object, job, id }), waiters reject withaborted.runningcarries every run'sargs; views filter (no opt-in).
Folded in from the spec reviews (/tmp/s5-review-{astra,fable,opus,sol}.md): routines in facets (design §16); on
over a never-throwing watch listener with its own backoff and a full value after a failure; no kind tag (resolution
at declaration); internal per-job state handles; records with transaction hooks and running as its own source;
tx.start returning a Run handle; dedupe and concurrency within one transaction and excluding the finishing run;
run started after the commit outside the batch, an early abort pre-marking it; state.change without a context;
throw after abort is aborted; job_failed; runs counted, not held, and the object ending when the last one settles;
persist's final shape.