feat(events): the runner (Phase 2) #184
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/events-phase-2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Event System Phase 2 (
EVENTS_PLAN.md). Docs half: RunicGateway/docs#210.utils/eventRunner.js— the eighth pollerSame
setInterval+unref()+stop()shape as the other seven, wired intoserver.js's start and shutdown besideengagementWorker. Its tick, in order: reclaim stale leases → sweep occurrences past their grace window intomissed→ advance each due run through its phases → drain that phase's steps inseqorder. A prune ofevent_run_logrides along on its own six-hourly clock, which is whereschema.sql's comment besideidx_evlog_atparked 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 becomesmissedrather than starting late and silently.Four decisions, all settled by the org lead 2026-09-02
runningwith a NULL lease.event_run_steps.statushas no state for "waiting on a human", and adding one is a tableALTERthatCREATE TABLE IF NOT EXISTSnever 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.await: 'human'andholdForare 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.scheduledand 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.nin §L'sretry(n)is a runner constant —EVENT_STEP_MAX_ATTEMPTS(3) with a flatEVENT_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:
claimTickcarried anOR 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, becausesetIntervalfires whether or not the last callback returned. The clause is gone; areleaseClaimhands a still-in-flight run back at the end of a tick (without it everycore.waitwould becomemax(wait, RUN_LEASE_MS)); and an in-processtickingguard skips an interval that would overlap.holdNextset the following step'sdue_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.on_failuredispositions write the STEPfailed.on_failuresays what happens to the run.skippedis 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 writeIts 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 missingokand a truthy-but-not-trueokare all{ ok: false, retry: true }.The timeout is the module contract's, not this file's opinion: every action declares
budgetMsand 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.announcepublishes through the existing announce-leg registry (§J, "reuse the legs") —discordis core's,towncrieris module-uo's, and both already carry aclassify()that knows what their transport's failures mean. It reuses that classification rather than forming a second opinion.core.waitanswersholdFor;core.cueanswersawait: 'human'.verify: truereports and sends nothing.Verify
npm test— 1682 tests, 1638 pass, 43 skipped, 1 fail. The one failure isengagementManifest.test.jsand it is pre-existing and environmental:engagement-triggers.jsonis CRLF in a Windows tree undercore.autocrlf=truewhile the generator writes LF. Content identical, green on CI, and confirmed still failing with this branch stashed.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 defaultsfoundRows: trueand a no-op UPDATE reports 1. A stub can only ever agree with whoever wrote it. Run it withDB_HOST=… DB_PORT=… DB_USER=… DB_PASSWORD=… node --test test/eventRunnerSql.test.js— 19/19 locally.npm run routes:manifestandnpm run swagger— regenerated to a zero-line diff.npm run check:modulesandscripts/checkNoExternalHosts.js— clean.No schema change. Every column and status this phase needed was landed in Phase 1.