feat(events): the runner (Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 5m29s

`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:
2026-09-02 06:32:24 -05:00
parent d88906e43c
commit 2e964cfeee
10 changed files with 2527 additions and 41 deletions

View File

@@ -13,28 +13,20 @@
// a human to go and do something. A deployment with no game module installed has
// a working event system made of exactly these.
//
// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar,
// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js`
// and is what calls `perform()`. The bodies below therefore answer with the
// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`,
// which is the one wrong answer a placeholder can give: `ok: true` on an action
// that did nothing is a recorded world change that did not occur, which is the
// exact mistake the envelope's failure default exists to prevent. `retry: false`
// because a missing runner is not a transient condition.
// **Phase 2 gave all three real bodies**, and between them they exercise every
// shape §F's envelope can take: `core.announce` does work and finishes,
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
// without finishing at all. The runner learns nothing about any of them by id —
// each says what it needs in the envelope, through the same two members Phase 7
// hands to a module.
//
// **This file must not touch the database.** It is required from `registerCore()`,
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
// (MODULE_API.md §2.2). It is pure data plus three functions that are not called.
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
// looked up inside `perform()`, per call, which is also what makes a leg
// registered by a module that booted later reachable at all.
// A factory rather than one shared function, because `perform`'s argument is
// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params,
// actor, verify }` — and it does not carry the action's own id. Closing over it
// is what lets the refusal name which action refused.
const notWiredYet = (actionId) => async () => ({
ok: false,
retry: false,
error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`,
})
const registries = require('../modules/registries')
const ACTIONS = [
{
@@ -81,7 +73,52 @@ const ACTIONS = [
},
],
perform: notWiredYet('core.announce'),
/**
* Publish through the announce leg the step names.
*
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
* `discord` is core's and `towncrier` is module-uo's, both already registered,
* both already carrying a `classify()` that knows what their transport's
* failures mean. An event announcement that went out by some other path would
* be a second delivery mechanism with its own bugs.
*
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
* — so an event announcement is presented as one. `excerpt` is the body
* because it is the field every leg renders as prose, and `image_url` is null
* because an event announcement has no article behind it to illustrate.
* Widening the leg contract to carry a second payload shape is a
* MODULE_API change, and Phase 7 is where those are made.
*
* The leg id is checked HERE rather than at authoring time, and that is not
* laxness: legs are registered by modules, and a spec is validated in a
* process that may have booted before the module that owns the leg.
*/
async perform({ params, verify }) {
const registered = registries.announceLeg(params.leg)
if (!registered) {
// Terminal, not transient. A leg nobody registers will not appear
// between two attempts sixty seconds apart, and the honest cause — a
// module removed, or a typo the authoring form could not catch — is a
// thing a human fixes.
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
}
// A dry run reports what it WOULD do and sends nothing (§I). Answering
// before the dispatch rather than inside the leg is what keeps that true
// for legs written by people who never read this file.
if (verify) return { ok: true }
const result = await registered.dispatch({
title: params.title || null,
excerpt: params.body,
image_url: null,
})
// The leg's own classification, not a second opinion. `retry` vs
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
// already makes, and making it twice is how the two drift.
const { outcome, error } = registered.classify(result)
if (outcome === 'done') return { ok: true }
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
},
},
{
@@ -105,11 +142,18 @@ const ACTIONS = [
},
],
// A wait is a genuine no-op at dispatch, and it will stay one: the delay is
// the NEXT step's `due_at`, which the runner owns, not something this
// function sleeps through. A `perform` that slept would hold a step's claim
// for the duration and turn a five-minute pause into a five-minute lease.
perform: notWiredYet('core.wait'),
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
// NEXT step's `due_at`, which the runner owns, not something this function
// sleeps through. A `perform` that slept would hold a step's claim for the
// duration and turn a five-minute pause into a five-minute lease — and the
// reclaim would then re-dispatch it, so a long enough wait would never end.
//
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
// why the runner can honour this without knowing what `core.wait` is.
async perform({ params, verify }) {
if (verify) return { ok: true }
return { ok: true, holdFor: params.seconds }
},
},
{
@@ -142,11 +186,26 @@ const ACTIONS = [
},
],
// Phase 2 gives this its parking semantics — a cue step does not complete
// when `perform` answers, it completes when a human presses confirm, and the
// control that does so is Phase 3's. Both of those are what make this the
// one action whose runtime shape is deliberately not decided here.
perform: notWiredYet('core.cue'),
/**
* Post the instruction and PARK. The step does not complete here.
*
* `await: 'human'` is the envelope member that says so (org lead,
* 2026-09-02), and the runner's answer to it is to leave the step `running`
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
* The step ends when someone presses confirm, which is Phase 3's control.
*
* **Nothing is delivered from here in Phase 2, and that is visible rather
* than pretended.** The instruction is carried by the step's own params and
* shown on the run console; routing it to Discord or to a staff inbox is
* Phase 10's integration work, through the engagement triggers that own every
* other notification on this platform. An action that grew its own delivery
* path would be the second one.
*/
async perform({ verify }) {
if (verify) return { ok: true }
return { ok: true, await: 'human' }
},
},
]