// ── 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 /** * 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? }`. * * 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 || [] } } 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) } return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] } } /** * 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, OUTCOMES, MAX_HOLD_SECONDS }