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

@@ -23,6 +23,16 @@ const KINDS = [
'phase.entered', // a phase's steps were materialised
'step.status', // a step transition, with the module's answer
'note', // a human action taken from the admin surface
// Phase 2's, all five of them answers to a question an operator asks out
// loud. `run.blocked` in particular is the whole reason this table exists
// rather than a server log line: "it did not start because run 37 holds
// invasion:Yew" is a fact with two run ids in it, and it has to be
// queryable from the run that did NOT start.
'run.blocked', // an occurrence held off: another run has its concurrency key
'run.health', // a health change, which is not a status change
'step.retry', // a step failed transiently and will be attempted again
'step.parked', // a step is waiting on a human and nothing is holding it
'phase.completed', // every step of a phase reached a terminal status
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
@@ -64,4 +74,33 @@ async function write({ runId, stepId = null, kind, phase = null, detail = null }
}
}
module.exports = { KINDS, listForRun, write }
/**
* Delete log lines belonging to runs that are both TERMINAL and older than
* `before`, a bounded number at a time.
*
* The schema comment beside `idx_evlog_at` parked this sweep here, and it is the
* rule Engagement Phase 14 arrived at applied to a second high-cardinality table:
* **only terminal rows are eligible.** A run still in flight keeps every line it
* has, however old — the log's whole job is answering "why didn't phase 3 start?"
* about a run that is, right now, not starting phase 3, and a horizon that could
* reach a live run would delete the answer while the question was still open.
*
* `LIMIT` makes one call a bounded amount of work rather than a table-sized
* transaction; the timer runs again and takes the next slice. The join is on the
* run's terminal status rather than on a precomputed id list so that a run which
* reached a terminal state between the two would not be missed.
*/
const pruneTerminal = async (before, limit = 5000) => {
const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000)
const result = await query(
`DELETE l FROM event_run_log l
JOIN event_runs r ON r.id = l.run_id
WHERE r.status IN ('completed','cancelled','failed','missed')
AND COALESCE(r.ended_at, r.updated_at) < ?
LIMIT ${n}`,
[before],
)
return Number(result?.affectedRows || 0)
}
module.exports = { KINDS, listForRun, write, pruneTerminal }