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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user