feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s

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:
2026-09-02 22:11:20 -05:00
parent 9c23c5fd0e
commit 9bc0bf5a3d
26 changed files with 2646 additions and 51 deletions

View File

@@ -12,6 +12,14 @@
// 3. **advance** — claim each due run and move it through its phases
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
//
// **What a phase advances on, as of Phase 5.** Every step terminal, and — if the
// phase authored one — its GATE open as well. The gate is an ADDITIONAL
// condition and never a replacement: a phase whose steps are still running is
// not advanced by a boss that spawned early. `{ after: '30m' }` is closed by
// this file when its deadline passes; `{ on: '<trigger>', count: n }` is closed
// by the EMIT PATH, because a firing between two ticks is not observable from
// either of them. See `events/gates.js` for that division.
//
// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
// The first half EXPANDS: every `ready` definition's recurrence is computed in
// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
@@ -55,7 +63,10 @@ const logDb = require('../model/events/eventRunLog.db')
const versionsDb = require('../model/events/eventVersions.db')
const definitionsDb = require('../model/events/eventDefinitions.db')
const runsModel = require('../model/events/eventRuns.model')
const gatesDb = require('../model/events/eventPhaseGates.db')
const recurrence = require('../events/recurrence')
const gates = require('../events/gates')
const spec = require('../events/spec')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
const log = require('./logger')('event-runner')
@@ -267,6 +278,89 @@ async function drainStep(run, step, now, carry = {}) {
return applyFailure(run, step, result.error)
}
/**
* Open a phase's advance gate, if it authored one.
*
* Called at phase entry, immediately after `materialisePhase` and BEFORE the
* transition that makes the phase current — which is the safe order rather than
* the tidy one. A process that died between the transition and this call would
* leave a phase whose gate does not exist, and a missing gate does not hold a
* phase: it advances on its steps alone, silently ignoring the condition its
* author wrote. Opening first risks only a row for a phase this tick did not win,
* which the winner's INSERT IGNORE then finds already correct.
*/
async function openGate(runId, phase, now) {
const advance = phase?.advance
if (!advance) return false
const created =
advance.after !== undefined
? await gatesDb.open({
runId,
phase: phase.key,
kind: 'after',
// Re-derived from the authored string rather than stored beside it:
// `events/spec.js` owns what `'2h'` means, and this is the one caller
// that needs the number.
afterSeconds: spec.parseAfter(advance.after),
now,
})
: await gatesDb.open({
runId,
phase: phase.key,
kind: 'on',
triggerId: advance.on,
conditions: advance.where ?? null,
needed: advance.count || 1,
now,
})
if (created) {
await logDb.write({
runId,
kind: 'phase.gate',
phase: phase.key,
detail:
advance.after !== undefined
? { kind: 'after', after: advance.after }
: { kind: 'on', trigger: advance.on, needed: advance.count || 1, where: gates.phrase(advance.where ?? null) },
})
}
return created
}
/**
* A run whose phase has waited past `EVENT_PHASE_STALL_MS` is `stalled`.
*
* §E's third health value, and the first thing in this system ever to write it.
* It is VISIBILITY and not a timeout: nothing advances, nothing fails, and a
* human decides — which is the org lead's answer of 2026-09-02 and the reason
* there is no authored deadline in the spec. What it must not be is quiet,
* because a held run also holds its concurrency key, so every later occurrence
* of the same definition goes `missed` behind it.
*
* `setHealth` only ever escalates, so this cannot undo a `degraded` a retry
* earned, and the `run.health` line is written once because `setHealth` answers
* whether it changed anything.
*/
async function noteStall(run, gate, now) {
const described = gates.describe(gate, now)
if (!described?.stalled) return false
if (!(await runsDb.setHealth(run.id, 'stalled'))) return false
await logDb.write({
runId: run.id,
kind: 'run.health',
phase: gate.phase,
detail: {
to: 'stalled',
because: `waiting ${described.elapsedSeconds}s on ${gate.trigger_id}`,
seen: gate.tally,
needed: gate.needed,
},
})
return true
}
/**
* Advance one claimed run as far as it will go this tick.
*
@@ -297,6 +391,7 @@ async function advanceRun(run, now) {
// died between the claim and here uneventful.
const first = phases[0]
await stepsDb.materialisePhase(run.id, first.key, first.steps || [])
await openGate(run.id, first, now)
if (!(await runsDb.transition(run.id, 'starting', 'running', { phase: first.key }))) return 'taken'
phaseKey = first.key
await logDb.write({ runId: run.id, kind: 'run.status', phase: first.key, detail: { from: 'starting', to: 'running' } })
@@ -342,7 +437,39 @@ async function advanceRun(run, now) {
return outcome === 'taken' ? 'taken' : 'stopped'
}
// Every step of this phase is terminal.
// Every step of this phase is terminal — which is the whole of the advance
// test for a phase with no gate, and half of it for a phase with one.
const { open, gate } = await gates.check(run.id, phaseKey, now)
if (!open) {
await noteStall(run, gate, now)
// **Nothing is logged per tick here, deliberately.** The gate row IS the
// state — the tally, the deadline, the last related event — and the run
// console reads it directly. A `phase.waiting` line every fifteen seconds
// would bury `condition.evaluated`, which is the line that actually says
// something happened.
return 'waiting'
}
// **`phase.advanced` is written by whoever made the DECISION, and a forced
// gate's decision was not this tick's.** The emit path closes an `on` gate
// and logs only `condition.evaluated`, so the line for that one is the
// runner's; `gates.check` closes an `after` gate here, so that one is too.
// A human closing it through the advance control already wrote the line,
// with the actor and the reason — things this tick does not have — and a
// second line from here made the console show the phase advancing twice,
// the less informative one last. Found in the live walk.
if (gate && gate.satisfied_by !== 'forced') {
await logDb.write({
runId: run.id,
kind: 'phase.advanced',
phase: phaseKey,
detail: {
because: gate.satisfied_by,
waitedSeconds: Math.max(0, Math.round((new Date(gate.satisfied_at) - new Date(gate.entered_at)) / 1000)),
...(gate.kind === 'on' ? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed } : { after: gate.after_seconds }),
},
})
}
await logDb.write({ runId: run.id, kind: 'phase.completed', phase: phaseKey, detail: { index: phaseIndex } })
const next = phases[phaseIndex + 1]
@@ -358,6 +485,7 @@ async function advanceRun(run, now) {
}
await stepsDb.materialisePhase(run.id, next.key, next.steps || [])
await openGate(run.id, next, now)
if (carry.holdUntil) {
// `seq > -1` is the first step of the phase just created. Applied after
// materialisation because that is the first moment there is a row to hold.