docs(events): Phase 2 as built - the runner

The docs half of Event System Phase 2. Pairs with `RunicGateway/website`
`feat/events-phase-2`.

EVENTS.md gains what the runner settled: the parked step (`running` with a NULL
lease), the two success-envelope members `await: 'human'` and `holdFor`, the
answer for a run whose concurrency key is held, `n` in §L's `retry(n)`, the rule
that all three `on_failure` dispositions write the step `failed`, and the health
transition on the first retry rather than the eventual failure.

EVENTS_PLAN.md marks Phase 2 complete and records the four org-lead decisions
and the three things the build settled on its own.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 06:32:15 -05:00
parent e4b7fa3d7e
commit 76b2276d36
2 changed files with 127 additions and 2 deletions

View File

@@ -195,6 +195,60 @@ the same rule.
### Phase 2 — The runner (`website`)
> **Complete.** `edge` in `website`. The eighth poller, the two CAS claims, the lease and its
> reclaim, the grace window, and the three core actions given real bodies. **A published event
> started from the existing run route now announces, waits and completes on its own** — the phase's
> shipped claim, and it adds no routes to do it.
>
> **Four things the org lead settled that the plan and §E had left open** (2026-09-02), each written
> into `EVENTS.md`:
>
> - **A parked step is `running` with a NULL lease.** `event_run_steps.status` has no state for
> "waiting on a human", and adding one would be a table ALTER that `CREATE TABLE IF NOT EXISTS`
> never delivers to an existing deployment. So the reclaim was written to take back only a lease
> that is **non-NULL and expired**, and a NULL one means parked. A cue posted on Friday is still
> waiting on Monday.
> - **Two success-envelope members, not two special cases.** `{ ok: true, await: 'human' }` parks;
> `{ ok: true, holdFor: <seconds> }` finishes and delays what follows. The runner never names an
> action id, and Phase 7 hands a module the same door.
> - **A run whose concurrency key is held stays `scheduled`** and lets its own grace window decide,
> rather than failing at once or queueing indefinitely.
> - **`n` in §L's `retry(n)` is a runner constant** — `EVENT_STEP_MAX_ATTEMPTS`, 3, with a flat
> 60s backoff — rather than a column or a spec field.
>
> **Three things the build settled on its own, all worth a look:**
>
> - **All three `on_failure` dispositions write the STEP `failed`.** The disposition governs the RUN.
> `skipped` is left 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.
> - **A live lease is not re-enterable, not even by the process that took it.** The first draft of
> `claimTick` carried an `OR claimed_by = ?` escape for a tick re-entering its own claim — which is
> precisely the overrun this phase's CAS is meant to protect against, since `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, leaseMs)`), and an in-process `ticking` guard skips an interval that would overlap.
> - **A wait as the last step of a phase holds the NEXT phase.** The first implementation set the
> following step's `due_at` and stopped there, so a trailing wait — "announce, wait five minutes,
> then phase 2" — silently meant nothing, because the next phase's steps are not materialised until
> the run enters it. The instant is now carried across the boundary. Found by writing the test, and
> the test was re-run against the unfixed code to confirm it fails.
>
> **What "materialise" means here.** The spec validator accepts `kind: 'manual'` alone until Phase 4,
> so there is no recurrence to expand — this leg builds the half that is already real, the grace
> window, and Phase 4 adds the expansion above it.
>
> **Verify, as run.** `npm test` — **1682 tests, 1638 pass, 43 skipped, 1 fail**, and that one is
> `engagementManifest.test.js`, 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, confirmed still failing with this branch stashed). 39 new tests across `eventRunner.test.js` and
> `eventRunnerSql.test.js`; the Phase 1 test asserting core's placeholders refused is replaced rather
> than deleted, because half of what it proved still holds. `routes:manifest` and `swagger`
> regenerated to a **zero-line diff** — the runner has no surface. `check:modules` clean.
>
> **Trap for anyone running the suite on this machine:** `server/modules/uo` is installed here, so the
> core suite and both generators need an empty `MODULES_DIR`. Without it `routeManifest.test.js` fails
> on a difference that is the module's, not the branch'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`.
@@ -213,6 +267,17 @@ built exactly as `EVENTS.md` §E specifies, and they are still the point of the
protects a tick that overruns into the next one, and what recovers a step whose process died
mid-dispatch. Test both in-process. The decision is not licence to drop a CAS.
> **As built:** the in-process half is `eventRunner.test.js`, and the statements themselves are proved
> against a real MariaDB in `eventRunnerSql.test.js` — which SKIPS when there is none, so CI stays
> green without a database. That second file exists because of what engagement Phase 4a found: a
> cooldown claim that was green against its stub and always allowed the send against a real server,
> because the connector defaults `foundRows: true` and a no-op UPDATE reports 1 rather than 0. A stub
> can only ever agree with whoever wrote it. Run it with:
>
> ```bash
> DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=… node --test test/eventRunnerSql.test.js
> ```
**Two traps, both already paid for once in this codebase.**
- **A reclaim must not reset `attempts`.** Engagement Phase 14's defect: a sweep that returned every
stale row to its start state made `MAX_ATTEMPTS` unreachable, so the row cycled forever, never