pi-mono/packages/chord/docs/next/slice-5.md
Mario Zechner 93029a0f1e feat(chord): Chord v2 in src/next, experimental and not exported yet
The next object runtime moves from the private repository into pi: src/next (contracts, hosts and
objects, documents, derives, links and relays, routines and jobs, the store) with its tests and
examples in test/next, src/next-node (Node compression), and its design in docs/next (design.md, the
decision log decisions.md, the slice plan slices.md, slice specs, books and benchmarks). AGENTS.md
gets its code style. Not exported: the API changes with every slice until it has an entry point.
2026-10-28 10:30:19 +00:00

281 lines
20 KiB
Markdown

# 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
```ts
// 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)` and `self.onLive(fn)`, declared only while the factory runs (as `state`, `derive`, `get`,
`open`), in objects of both kinds and in facets (design §16: a facet holds documents, references, routines and
contributions).
- `on` over a JSON `Watchable` calls `fn(value, ops, ctx)`; over a `NativeWatchable` (a registry value, `ref.state`)
`fn(value, ctx)`. Overloads by the source's type. At runtime the source is resolved at declaration with
`resolveWatchable` (Chord's own sources, constants, reference members); anything else that is a `NativeWatchable`
(its `watch` yields a `NativeWatch`) 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 `on` call. Stop: when the object ends, their `ctx` is aborted with the object's end error; the
drain (§6) waits for routine calls like for method calls, bounded by `closeTimeout`.
- `on` is implemented over the shared `Watch` with 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 one
`routine_failed` report. (A failed provider member that recovers after a provider reload: slice 7 carry-over.)
- `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, and
`ctx.get` references released when the call settles (`CallContext`).
- Not counted in `host.activity`.
- Helpers, exported for apps: `sleep(ms, ctx)` (rejects with the abort reason when `ctx` is aborted; clears its timer
and listener; delays above the timer limit of 2^31-1 ms are chained) and `untilAborted(ctx)` (resolves when `ctx`
is aborted). Portable (timers and `AbortSignal` only).
- `HostError` gains `routine_failed { object, routine, error }`, `routine` being 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? })`: `args` closed (like method
arguments: checked at `start` with a compiled validator, `invalid/args`); `result` not validated (the provider's);
`state` a singleton document definition; `concurrency` a positive integer; `accepts` checked at declaration (shape,
older versions only) and used in slice 7. `persist` has 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; returns `JobHandle`.
- 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.state` handles, 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 a
`run`, settle waiters).
- `running` is a `Source` of its own (not a `Derive`: 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 a `Run` after the commit.
`tx.start(job, args, { key? })` inside `self.tx` joins the transaction's other writes and returns a `Run` handle at
once (a handle, not data from a draft); its `outcome(ctx)` waits for the run and rejects `released` if 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 rejects `gone`;
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 whose `finish` this
transaction is** (so `finish` can chain the same job at `concurrency: 1`); at the limit `unavailable/busy`; then the
record and the state document (`initial()`, checked once per definition, R8/D3).
- `run` starts after the commit, outside the batch, after `self.tx` settled (an action after the commit). An `abort`
between the commit and the start pre-marks the run's signal, so `run` starts aborted.
- A `start` reaches only a live object (methods and routines run only then). New error reason `unavailable/busy` (the
error table; `PROTOCOL_VERSION` stays 1). Run ids: uuidv7.
### Run
- `run(args, ctx)` runs outside transactions. `JobCtx` = `Ctx` plus `jobId` and `state: { value; change(fn) }` (only
when the job has a `state`; 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 while `run` stops after an
abort still commit. It is accepted while the run's invocation is live (until `run` settles) and refused afterwards
with `released` (an invocation fence: it checks the run's invocation, not only the owner's liveness); it never
recreates a retired document. Changes admitted before `run` settled commit before the outcome is recorded (line
order), so `outcome.state` includes them.
- `run`'s abort signal fires on `abort(id)`, on the object's end and on `host.close`. A run that throws after its
signal fired ends `failed { aborted, reason: "abort" }` (not `threw`); a run that returns after an abort keeps its
result.
- `run` throwing (not aborted): `failed { threw, error }` and a `HostError` `job_failed { object, job, id, error }`.
### Finish
- When `run` settles: the outcome is recorded on the run (`phase: "finishing"`), and `finish(outcome, tx)` runs in
one transaction; the same commit deletes the record and the key and retires the state document. Without `finish`,
the commit only cleans up.
- `finish` throwing: 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 becomes
`blocked { reason: "finish_failed" }`: outcome, state and key kept, not counted for concurrency, retried on the
next reload (slice 7); a later `start` with its key returns the blocked run (dedupe).
- `Run.outcome(ctx)` resolves with the outcome when `finish` committed (also without `finish`); a blocked run's
waiters keep waiting (their own `ctx` ends the wait); a dropped run's waiters reject `aborted`; 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: no `finish`; record, key and state
document go in one transaction; reported `job_dropped { object, job, id }`; waiters reject `aborted`); ignored for
finishing runs and unknown ids.
- Keep-alive: running and finishing runs are a **count** on the object, not `Holder`s (they are not references, so
they never appear in `host.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's `linger`). Blocked runs
do not count: at that end they are dropped with a `job_dropped` report each. `host.activity.jobs` counts running
plus finishing runs.
- Object end (revocation R11, `host.close`, the last release): runs' signals fire; the drain waits for `run`
invocations like for calls (bounded by `closeTimeout`); no `finish`; 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
1. Two `start`s with the same key in one turn: the line serializes them; the second sees the first's record and
returns its run.
2. A `start` at the concurrency limit while a run is finishing: finishing counts; `busy`.
3. `tx.start` in a transaction that is then discarded: no record, no run; the `Run` handle it returned rejects
`released` from `outcome(ctx)`.
4. `ctx.state.change` after `run` returned but before `finish` committed: refused (`released`); the outcome's state is
the state at `run`'s settle. A change made after `abort` while `run` is still stopping: commits (no run abort
signal on it).
5. `ctx.state.change` in flight (waiting for the line) when `run` settles: it was admitted while live, so it commits
before the finish transaction (line order); the outcome's state includes it.
6. `abort` while `finish` runs: ignored.
7. The object ends while `finish` waits for the line: the finish transaction is refused (`released`); nothing is
reported as a finish failure (it is the object's end).
8. `finish` throws five times: blocked; a new `start` with the same key returns the blocked run; a different key
starts a new run (blocked runs do not count for concurrency).
9. `on`'s source changes during the backoff after a throw: the call happens at once with the newest value.
10. A routine call throws while the object ends: the end wins; no restart, no report of the abort.
11. `sleep` with a huge delay and an abort: the chained timers are cleared.
12. A job's `run` calls `start` on the same job (recursion): allowed; a new run (it is a different key or none).
13. `finish` chains the same job at `concurrency: 1`: the finishing run is excluded from its own transaction's check.
14. Two `tx.start`s with the same key in one transaction: the second returns the first's `Run`.
15. A short run started with `tx.start` settles before the caller awaits `outcome`: the handle resolves anyway.
16. `abort` between the start's commit and `run`'s start: `run` starts with its signal aborted.
17. 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 in `local`
objects with empty params, P3, enforced at declaration); outcomes and abort requests persisted (a recorded outcome
resumes only `finish`); 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); the `finish` failure 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); job
`accepts` in 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`, reason `reload`)
and `finish` runs on the new code; a `finish` in 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); an
`on` routine 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; source `ended`/`retired` (no report) and `failed` (one report); native sources (`ref.state`); in a facet.
- `onLive`: return; throw and backoff, reset after a long run; abort without report.
- Helpers: `sleep` abort, 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 from
`finish` at `concurrency: 1`); `tx.start` atomic with other writes; a discarded `tx.start`'s handle rejecting; the
handle of a short run resolving; `outcome(id)` for unsettled, settled and unknown ids; `run` starting after the
commit outside the batch; an abort before `run` starts.
- Jobs, run: with and without state; `state.change` fence (after settle refused; in flight at settle committed before
the outcome; after abort while stopping committed; never recreating the retired document); throw after abort is
`aborted`; throw is `threw` with `job_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`, `blocked` after five; dedupe returns the blocked run; abort drops it (`job_dropped`, waiters
`aborted`).
- `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 in `host.references`.
- Object end: revocation and `host.close` mid-run (signals, drain, no `finish`, waiters rejected).
- Cost guards: args compiled once per definition; `running` touches only changed runs.
- GC: an ended object releases runs, documents and waiters; routines release their watches; `sleep` leaves 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)
1. Routines start right after the object went live, in the same turn; a method call admitted in that turn may run
before the first `on` call.
2. `abort(id)` on a blocked run drops it: no `finish`, its record, key and state document go, reported
(`job_dropped { object, job, id }`), waiters reject with `aborted`.
3. `running` carries every run's `args`; 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.