feat(events): the runner (Phase 2) #184

Merged
whitlocktech merged 1 commits from feat/events-phase-2 into edge 2026-09-02 11:35:24 +00:00
Member

Event System Phase 2 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#210.

A published event started from the existing run route now announces, waits and completes on its own. That is the phase's shipped claim, and it adds no routes to do it — a runner has no surface, and the live controls stay Phase 3's.

utils/eventRunner.js — the eighth poller

Same setInterval + unref() + stop() shape as the other seven, wired into server.js's start and shutdown beside engagementWorker. Its tick, in order: reclaim stale leases → sweep occurrences past their grace window into missedadvance each due run through its phases → drain that phase's steps in seq order. A prune of event_run_log rides along on its own six-hourly clock, which is where schema.sql's comment beside idx_evlog_at parked it.

What "materialise" means here. §E's tick materialises occurrences from a recurrence; the spec validator accepts kind: 'manual' alone until Phase 4, so there is nothing to expand and this leg builds the half that is already real — the grace window. A run whose instant passed while the process was down becomes missed rather than starting late and silently.

Four decisions, all settled by the org lead 2026-09-02

  1. A parked step is running with a NULL lease. event_run_steps.status has no state for "waiting on a human", and adding one is a table ALTER that CREATE TABLE IF NOT EXISTS never delivers to an existing deployment. So the reclaim takes back only a lease that is non-NULL and expired — a cue posted on Friday is still waiting on Monday.
  2. await: 'human' and holdFor are ordinary success-envelope members, not special cases keyed on an action id. The runner never names a verb, and Phase 7 hands a module the same door for its own long-running action.
  3. A run whose concurrency key is held stays scheduled and lets its own grace window decide. Failing it says the system broke when it correctly declined to overlap two events; queueing it lets an 8pm event start at 11pm.
  4. n in §L's retry(n) is a runner constantEVENT_STEP_MAX_ATTEMPTS (3) with a flat EVENT_STEP_RETRY_MS (60s) — rather than a column no authoring surface would show.

Three things the build settled on its own

Two of these are corrections, and both are worth a look:

  • A live lease is not re-enterable, not even by the process that took it. The first claimTick carried an OR claimed_by = ? escape so a tick could re-enter its own claim — which is precisely the overrun the plan says this phase's CAS exists to protect against, because setInterval fires whether or not the last callback returned. The clause is gone; a releaseClaim hands a still-in-flight run back at the end of a tick (without it every core.wait would become max(wait, RUN_LEASE_MS)); and an in-process ticking guard skips an interval that would overlap.
  • A wait as the last step of a phase used to mean nothing. holdNext set the following step's due_at, but the next phase's steps are not materialised until the run enters it — so "announce, wait five minutes, then phase 2" started phase 2 at once. The instant now crosses the boundary. Found by writing the test, and the test was re-run against the unfixed code to confirm it fails.
  • All three on_failure dispositions write the STEP failed. on_failure says what happens to the run. skipped is reserved for a human's skip control in Phase 3, because a status meaning both "nobody ran this" and "this failed and we moved on" makes the console's summary line unreadable.

events/dispatch.js — the boundary with code core did not write

Its own file because it has one job: call perform() and turn whatever comes back into one of four classifications. §F's rule is enforced here — no shape a failure can take reads as success: a rejected promise, a throw, a timeout, a non-object, a missing ok and a truthy-but-not-true ok are all { ok: false, retry: true }.

The timeout is the module contract's, not this file's opinion: every action declares budgetMs and the step's lease is computed from it plus a minute. Without that, a module awaiting a socket that never answers holds its claim until the lease expires and the reclaim re-dispatches it — one wedged sidecar becoming an infinite loop rather than a failed step.

The three core actions get real bodies

core.announce publishes through the existing announce-leg registry (§J, "reuse the legs") — discord is core's, towncrier is module-uo's, and both already carry a classify() that knows what their transport's failures mean. It reuses that classification rather than forming a second opinion. core.wait answers holdFor; core.cue answers await: 'human'. verify: true reports and sends nothing.

Verify

  • npm test1682 tests, 1638 pass, 43 skipped, 1 fail. The one failure is engagementManifest.test.js and it is pre-existing and environmental: engagement-triggers.json is CRLF in a Windows tree under core.autocrlf=true while the generator writes LF. Content identical, green on CI, and confirmed still failing with this branch stashed.
  • 39 new tests. eventRunner.test.js (20) stubs the three tables and runs the runner's logic for real against them. eventRunnerSql.test.js (19) proves the five statements whose correctness is a server contract against a real MariaDB, and skips when there is none so CI stays green without a database. That file exists because of what engagement Phase 4a found: a cooldown claim green against its stub that always allowed the send against a real server, because the connector defaults foundRows: true and a no-op UPDATE reports 1. A stub can only ever agree with whoever wrote it. Run it with DB_HOST=… DB_PORT=… DB_USER=… DB_PASSWORD=… node --test test/eventRunnerSql.test.js — 19/19 locally.
  • The Phase 1 test asserting core's placeholders refused is replaced rather than deleted: half of what it proved (a dry run sends nothing; an unregistered leg is a terminal refusal) still holds.
  • npm run routes:manifest and npm run swagger — regenerated to a zero-line diff.
  • npm run check:modules and scripts/checkNoExternalHosts.js — clean.

Running the suite locally: server/modules/uo is installed on a dev machine, so the core suite and both generators need an empty MODULES_DIR. Without it routeManifest.test.js fails on a difference that belongs to the module, not to this branch — it fails the same way on edge.

No schema change. Every column and status this phase needed was landed in Phase 1.


  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 2** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#210**. > **A published event started from the existing run route now announces, waits and completes on its own.** That is the phase's shipped claim, and it adds **no routes** to do it — a runner has no surface, and the live controls stay Phase 3's. ## `utils/eventRunner.js` — the eighth poller Same `setInterval` + `unref()` + `stop()` shape as the other seven, wired into `server.js`'s start and shutdown beside `engagementWorker`. Its tick, in order: **reclaim** stale leases → sweep occurrences past their grace window into **`missed`** → **advance** each due run through its phases → **drain** that phase's steps in `seq` order. A **prune** of `event_run_log` rides along on its own six-hourly clock, which is where `schema.sql`'s comment beside `idx_evlog_at` parked it. **What "materialise" means here.** §E's tick materialises occurrences from a recurrence; the spec validator accepts `kind: 'manual'` alone until Phase 4, so there is nothing to expand and this leg builds the half that is already real — the grace window. A run whose instant passed while the process was down becomes `missed` rather than starting late and silently. ## Four decisions, all settled by the org lead 2026-09-02 1. **A parked step is `running` with a NULL lease.** `event_run_steps.status` has no state for "waiting on a human", and adding one is a table `ALTER` that `CREATE TABLE IF NOT EXISTS` never delivers to an existing deployment. So the reclaim takes back only a lease that is **non-NULL and expired** — a cue posted on Friday is still waiting on Monday. 2. **`await: 'human'` and `holdFor` are ordinary success-envelope members**, not special cases keyed on an action id. The runner never names a verb, and Phase 7 hands a module the same door for its own long-running action. 3. **A run whose concurrency key is held stays `scheduled`** and lets its own grace window decide. Failing it says the system broke when it correctly declined to overlap two events; queueing it lets an 8pm event start at 11pm. 4. **`n` in §L's `retry(n)` is a runner constant** — `EVENT_STEP_MAX_ATTEMPTS` (3) with a flat `EVENT_STEP_RETRY_MS` (60s) — rather than a column no authoring surface would show. ## Three things the build settled on its own Two of these are corrections, and both are worth a look: - **A live lease is not re-enterable, not even by the process that took it.** The first `claimTick` carried an `OR claimed_by = ?` escape so a tick could re-enter its own claim — which is *precisely* the overrun the plan says this phase's CAS exists to protect against, because `setInterval` fires whether or not the last callback returned. The clause is gone; a `releaseClaim` hands a still-in-flight run back at the end of a tick (without it every `core.wait` would become `max(wait, RUN_LEASE_MS)`); and an in-process `ticking` guard skips an interval that would overlap. - **A wait as the last step of a phase used to mean nothing.** `holdNext` set the *following* step's `due_at`, but the next phase's steps are not materialised until the run enters it — so "announce, wait five minutes, then phase 2" started phase 2 at once. The instant now crosses the boundary. Found by writing the test, and the test was re-run against the unfixed code to confirm it fails. - **All three `on_failure` dispositions write the STEP `failed`.** `on_failure` says what happens to the *run*. `skipped` is reserved for a human's skip control in Phase 3, because a status meaning both "nobody ran this" and "this failed and we moved on" makes the console's summary line unreadable. ## `events/dispatch.js` — the boundary with code core did not write Its own file because it has one job: call `perform()` and turn whatever comes back into one of four classifications. **§F's rule is enforced here — no shape a failure can take reads as success**: a rejected promise, a throw, a timeout, a non-object, a missing `ok` and a truthy-but-not-`true` `ok` are all `{ ok: false, retry: true }`. The **timeout is the module contract's**, not this file's opinion: every action declares `budgetMs` and the step's lease is computed from it plus a minute. Without that, a module awaiting a socket that never answers holds its claim until the lease expires and the reclaim re-dispatches it — one wedged sidecar becoming an infinite loop rather than a failed step. ## The three core actions get real bodies `core.announce` publishes through the **existing announce-leg registry** (§J, "reuse the legs") — `discord` is core's, `towncrier` is module-uo's, and both already carry a `classify()` that knows what their transport's failures mean. It reuses that classification rather than forming a second opinion. `core.wait` answers `holdFor`; `core.cue` answers `await: 'human'`. `verify: true` reports and sends nothing. ## Verify - `npm test` — **1682 tests, 1638 pass, 43 skipped, 1 fail.** The one failure is `engagementManifest.test.js` and it is **pre-existing and environmental**: `engagement-triggers.json` is CRLF in a Windows tree under `core.autocrlf=true` while the generator writes LF. Content identical, green on CI, and confirmed still failing with this branch stashed. - **39 new tests.** `eventRunner.test.js` (20) stubs the three tables and runs the runner's logic for real against them. `eventRunnerSql.test.js` (19) proves the five statements whose correctness is a *server* contract against a **real MariaDB**, and **skips when there is none** so CI stays green without a database. That file exists because of what engagement Phase 4a found: a cooldown claim green against its stub that always allowed the send against a real server, because the connector defaults `foundRows: true` and a no-op UPDATE reports 1. A stub can only ever agree with whoever wrote it. Run it with `DB_HOST=… DB_PORT=… DB_USER=… DB_PASSWORD=… node --test test/eventRunnerSql.test.js` — 19/19 locally. - The Phase 1 test asserting core's placeholders refused is **replaced rather than deleted**: half of what it proved (a dry run sends nothing; an unregistered leg is a terminal refusal) still holds. - `npm run routes:manifest` and `npm run swagger` — regenerated to a **zero-line diff**. - `npm run check:modules` and `scripts/checkNoExternalHosts.js` — clean. > **Running the suite locally:** `server/modules/uo` is installed on a dev machine, so the core suite and both generators need an empty `MODULES_DIR`. Without it `routeManifest.test.js` fails on a difference that belongs to the module, not to this branch — it fails the same way on `edge`. **No schema change.** Every column and status this phase needed was landed in Phase 1. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-02 11:33:49 +00:00
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
2e964cfeee
`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>
whitlocktech merged commit 2ba397eff7 into edge 2026-09-02 11:35:24 +00:00
whitlocktech deleted branch feat/events-phase-2 2026-09-02 11:35:25 +00:00
Sign in to join this conversation.
No description provided.