feat(events): the runner (Phase 2)
`utils/eventRunner.js`, the eighth poller, wired into server.js beside engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their grace window into `missed`, advances each due run through its phases, and drains that phase's steps in `seq` order. The three core actions from Phase 1 get real bodies, so a published event started from the existing run route now announces, waits and completes on its own. No routes are added: a runner has no surface, and the live controls stay Phase 3's. Four things the org lead settled (2026-09-02): a parked step is `running` with a NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members rather than special cases keyed on an action id; a run whose concurrency key is held stays `scheduled` and lets its grace window decide; and `n` in §L's `retry(n)` is a runner constant. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
166
server/src/events/dispatch.js
Normal file
166
server/src/events/dispatch.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// ── 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 }
|
||||
Reference in New Issue
Block a user