feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.
`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.
One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.
A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
@@ -23,6 +23,16 @@ const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), r
|
||||
// only if every path a run can take reaches one of them.
|
||||
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
|
||||
|
||||
// §E's health values, worst last. Health is a HIGH-WATER MARK in this system —
|
||||
// nothing has ever cleared `degraded`, because a run whose announcement landed
|
||||
// on the second attempt did have trouble and that stays true for the rest of its
|
||||
// life — and `setHealth` enforces that rather than leaving it to every caller to
|
||||
// remember. `FIELD()` gives the same order inside the WHERE clause, 1-indexed,
|
||||
// which is what makes the guard one statement rather than a read and a write.
|
||||
const HEALTH_ORDER = ['ok', 'degraded', 'stalled']
|
||||
const HEALTH_RANK = Object.fromEntries(HEALTH_ORDER.map((h, i) => [h, i + 1]))
|
||||
const HEALTH_SQL_ORDER = HEALTH_ORDER.map((h) => `'${h}'`).join(', ')
|
||||
|
||||
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
|
||||
// pair `park()` alone produces, which means a cue waiting on a human. It is a
|
||||
// correlated subquery on an admin list bounded at 500 rows rather than a column,
|
||||
@@ -359,11 +369,20 @@ const statusOf = async (id) => {
|
||||
* same degradation does not restamp `updated_at`.
|
||||
*/
|
||||
async function setHealth(id, health) {
|
||||
const result = await query('UPDATE event_runs SET health = ? WHERE id = ? AND health <> ?', [
|
||||
health,
|
||||
id,
|
||||
health,
|
||||
])
|
||||
// **Escalation only, and this is the guard rather than a convention.** Health
|
||||
// has always been a high-water mark here — `degraded` is never cleared,
|
||||
// because a run whose announcement landed on the second attempt DID have
|
||||
// trouble and that stays true — and Phase 5 gave the column a second writer
|
||||
// for `stalled`. Without a rank, a step that retried after a stall would
|
||||
// quietly demote `stalled` to `degraded` and a run that waited ninety minutes
|
||||
// on a boss that never came would end its life claiming it merely wobbled.
|
||||
const rank = HEALTH_RANK[health]
|
||||
if (!rank) return false
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET health = ?
|
||||
WHERE id = ? AND FIELD(health, ${HEALTH_SQL_ORDER}) < ?`,
|
||||
[health, id, rank],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user