`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.
`module-uo` believed it, twice, since Phase 12b:
* `uo.item.grant` answers `{ granted, missed, why }`
* `uo.world.save` answers `{ started: true }`
The grant is the one that matters. A grant reaches the players a run's
participation 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.
Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.
## What this adds
`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.
**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.
* objects only — the column is JSON and the console renders keys, so a bare
string has nothing to render under, and core inventing a key would be core
interpreting it after all;
* 4KB of serialised JSON, dropped rather than truncated, because half a JSON
object is not a JSON object;
* unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
runner would make the log INSERT throw, inside the one write documented
never to;
* re-parsed rather than passed through, so core holds no live reference into
a module's object;
* **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 is a world write repeated for a log line.
The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.
## The renderer, which is half the fix
`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.
uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true
**`module-uo` needs no change**: the code it already shipped starts working.
MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
254 lines
11 KiB
JavaScript
254 lines
11 KiB
JavaScript
// ── 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 }
|