// ── Dispatching one step to one action ───────────────────────────────────── // // EVENTS.md §F. This is the boundary between the runner and code core did not // write, and it exists as its own file because it has exactly one job: call // `perform()` and turn whatever comes back — an envelope, a lie, a throw, a // promise that never settles — into one of four classifications the runner knows // how to act on. // // **§F's load-bearing rule, and the reason none of this is inlined into the // runner: no shape a failure can take may read as success.** A rejected promise, // a throw, a timeout, a non-object and a missing `ok` are all // `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s // default, deliberately — a team provider that refuses leaves core showing what // it already had, because staleness is cheap, whereas an action that half-ran and // was recorded as `done` is a world change nothing will ever come back for. // // **The timeout is the module contract's, not this file's opinion.** Every action // declares `budgetMs` at registration and the registry bounds it there; here it // is enforced. Without it a module whose `perform()` awaits a socket that never // answers holds a step's claim until the lease expires, and the reclaim then // re-dispatches it — which is how one wedged sidecar becomes an infinite loop // rather than a failed step. const registries = require('../modules/registries') const log = require('../utils/logger')('events') // What a classification can be. `parked` is Phase 2's addition and it is the one // outcome that is neither terminal nor a retry: the action succeeded, and the // step is not finished, because something outside this system has to happen next. const OUTCOMES = ['done', 'parked', 'retry', 'terminal'] // The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction, // not a lease, so this is generous — but it is bounded, because an action that // answers `holdFor: 1e9` would park the phase past the heat death of the shard // and the step that did it would look, in the console, exactly like one that // worked. const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60 // The upper bound on a module's `detail`, in bytes of serialised JSON. It lands // in `event_run_log.detail` and is read back by the run console, so it is a // diagnostic line rather than a data channel — a module with more to say than // this has a table of its own to say it in. Dropped rather than truncated when it // is over: a truncated JSON object is not a JSON object, and a console that // rendered half of one would be a second bug on top of the first. const MAX_DETAIL_BYTES = 4096 /** * A module's own account of what a successful step actually did. * * Optional, module-opaque, and **never interpreted by core** — it is carried to * the run log and rendered, and nothing here or in the runner reads a key out of * it. That is the whole contract: a module knows things about its own verb that * core cannot compute and has no other way to say. `uo.item.grant` is the case * that forced it — a grant reaches the players a run's ledger holds, and *which * of them missed out* is knowable only to the module and reported nowhere else, * so an operator saw a step marked `done` and never learned four of twelve got * nothing. * * **Anything wrong with it is dropped and logged, never a failure.** A step that * did what it was asked must not be re-run because its module's commentary was * malformed — that would be a world write repeated for a log line. Same posture * `participants` takes, and for the same reason. */ function safeDetail(detail, actionId) { if (detail === undefined || detail === null) return null // Objects only. The column is JSON and the console renders keys, so a bare // string or a number has nothing to render under — and core inventing a key to // put it beneath would be core interpreting it after all. if (typeof detail !== 'object' || Array.isArray(detail)) { log.warn('action detail is not an object', { action: actionId, type: typeof detail }) return null } let encoded try { encoded = JSON.stringify(detail) } catch (err) { // A circular reference, or a `toJSON` that throws. Reaching the runner would // make the INSERT throw instead, inside the one write that is documented // never to. log.warn('action detail could not be serialised', { action: actionId, message: err.message }) return null } if (encoded === undefined) { log.warn('action detail serialised to nothing', { action: actionId }) return null } if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) { log.warn('action detail is too large', { action: actionId, bytes: Buffer.byteLength(encoded, 'utf8'), max: MAX_DETAIL_BYTES, }) return null } // Re-parsed rather than passed through, so what the runner writes is a plain // JSON value with no getters, no prototype and no live reference into whatever // the module still holds. return JSON.parse(encoded) } /** * Run `fn()` under a deadline. * * The loser of the race is not cancelled — JavaScript has no such thing, and a * `perform()` still awaiting a socket keeps awaiting it. What the deadline buys * is that the RUNNER stops waiting, which is the half that matters: the step is * classified, the claim is released, and the tick moves on. A late answer from * the abandoned call lands on a step that has already been written, and the * idempotency key is what makes the retry that follows safe on the game side. */ function withDeadline(fn, ms, actionId) { let timer = null const deadline = new Promise((resolve) => { timer = setTimeout( () => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }), ms, ) if (timer.unref) timer.unref() }) return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => { if (timer) clearTimeout(timer) }) } /** * Turn a raw `perform()` answer into * `{ outcome, error?, holdSeconds?, resources?, participants? }`. * * Exported and pure, so the classification rules are testable without a registry, * a database or a clock — which matters because they are the rules that decide * whether a world change is recorded as having happened. */ function classify(result, actionId) { if (result && result.__timedOut) { // Transient by default: a timeout says nothing about whether the action ran. // That ambiguity is exactly what the idempotency key exists to resolve, and // resolving it on the game side is Phase 11's protocol work — until then a // retry is the honest choice and the risk class decides what happens when the // retries run out. return { outcome: 'retry', error: result.error } } if (result === null || typeof result !== 'object' || Array.isArray(result)) { return { outcome: 'retry', error: `${actionId} answered with no envelope` } } if (result.ok !== true) { // `retry` must be opted into. An action that means "this will never work" // says `retry: false`, and an envelope that forgot to say anything gets the // benefit of the doubt on the transient question but not on the success one. const retry = result.retry !== false return { outcome: retry ? 'retry' : 'terminal', error: result.error ? String(result.error) : `${actionId} refused`, } } // ── The two success shapes that are not "finished" ── // // Both were settled by the org lead on 2026-09-02, and both are envelope // members rather than special cases keyed on an action id, so that the runner // never names a verb. `core.cue` and `core.wait` reach them through the same // door Phase 7 opens to a module's own long-running action. if (result.await === 'human') { return { outcome: 'parked', error: null, resources: result.resources || [], participants: result.participants || [], detail: safeDetail(result.detail, actionId), } } let holdSeconds = 0 if (result.holdFor !== undefined && result.holdFor !== null) { const n = Number(result.holdFor) if (!Number.isFinite(n) || n < 0) { return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` } } holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS) } // `participants` rides beside `resources` and on the same two success shapes // (Phase 10). It is carried rather than interpreted here: what a member key // means is the module's business, and this file's whole job is to know // nothing about the verb it just called. return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [], participants: result.participants || [], // On the same two success shapes as `resources` and `participants`, and for // the same reason: `await: 'human'` is a success, and a cue's confirm // finishes the step without a second dispatch, so this is the only moment // its module could ever have said anything. detail: safeDetail(result.detail, actionId), } } /** * Dispatch one step. Never throws. * * `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's * route): `verify === true` means validate and report, change nothing. It is * passed from here rather than being a separate code path so that the dry run * exercises the real dispatcher — a dry run down a second path is a dry run of * the second path. */ async function dispatchStep(step, { run, actor = null, verify = false } = {}) { const action = registries.eventAction(step.action_id) if (!action) { // §L, verbatim: "a step naming one fails terminal with the module named, and // the run degrades rather than claiming success. Never a silent skip." The // module was uninstalled or failed to boot between publish and now — publish // refuses a dormant step, so this cannot be an authoring mistake. return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true } } const envelope = { runId: run.id, stepId: step.id, idempotencyKey: step.idempotency_key, scope: run.scope || '', params: step.params || {}, actor, verify: Boolean(verify), } let raw try { raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id) } catch (err) { // A module should not throw, and if one does it is a transient failure rather // than a crashed tick — announceWorker's posture with its legs, and the // reason one bad module cannot stop every other run on the deployment. log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message }) return { outcome: 'retry', error: err.message } } const classification = classify(raw, action.id) if (step.action_version && action.version !== step.action_version) { // Not a refusal: the step was authored against an older declaration and the // module has moved on. The editor is where that becomes a warning (§F); here // it is recorded, so a run that behaved oddly can be explained afterwards by // reading the log rather than by guessing. classification.actionVersionDrift = { authored: step.action_version, registered: action.version } } return classification } module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }