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:
@@ -7,15 +7,18 @@
|
||||
// needs no control, and a run that paused on a failed world write is the one
|
||||
// that does.
|
||||
//
|
||||
// **Two of §I's six run-level controls are deliberately not here.**
|
||||
// `advance` — force a phase forward — has no honest meaning yet: a phase today
|
||||
// advances when its steps go terminal, and the per-step skip already does that
|
||||
// one step at a time. Phase 5 is what gives a phase an `advance` CONDITION, and
|
||||
// that is the first moment "force it anyway" means something an operator could
|
||||
// predict. `cleanup` needs Phase 8's resource ledger; there is nothing to
|
||||
// revert, so cancel takes `{ reason }` and gains `cleanup` when there is
|
||||
// something for it to do. Both are absent rather than inert, which is the
|
||||
// posture Phase 1 set and Phase 2 kept.
|
||||
// **`advance` is the seventh, and it arrived in Phase 5 rather than Phase 3
|
||||
// because that is when it started meaning something.** A phase used to advance
|
||||
// when its steps went terminal and on nothing else, so "force it anyway" named
|
||||
// no state an operator could be in; a phase with a gate can wait for a boss that
|
||||
// will never spawn, and then it names exactly one. It is the other half of the
|
||||
// diagnosis panel: a screen that explains why a phase has not started, beside a
|
||||
// control that does something about it.
|
||||
//
|
||||
// **One of §I's controls is still not here.** `cleanup` needs Phase 8's resource
|
||||
// ledger; there is nothing to revert, so cancel takes `{ reason }` and gains
|
||||
// `cleanup` when there is something for it to do. Absent rather than inert,
|
||||
// which is the posture Phase 1 set and every phase since has kept.
|
||||
//
|
||||
// **Every control is guarded on the status it may act from, and the guard is a
|
||||
// WHERE clause rather than a read-then-write.** A run console rendered thirty
|
||||
@@ -33,6 +36,8 @@
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const stepsDb = require('./eventRunSteps.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const gatesDb = require('./eventPhaseGates.db')
|
||||
const gates = require('../../events/gates')
|
||||
|
||||
const MAX_REASON = 500
|
||||
|
||||
@@ -168,6 +173,67 @@ async function cancel(runId, { reason } = {}, userId = null) {
|
||||
return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a phase forward: open its gate without the condition that would have.
|
||||
*
|
||||
* **It is legal only when the phase is actually waiting on a gate**, and the
|
||||
* three refusals are the whole design. A run that is not `running` is not
|
||||
* waiting on anything (409 naming what it is). A phase with no gate advances on
|
||||
* its steps and always has, so forcing it would be a control that duplicated the
|
||||
* runner rather than overriding it. And a phase whose steps have not all gone
|
||||
* terminal is not being held by its gate — it is being held by a step, and the
|
||||
* step-level skip is the honest control for that, one step at a time. A force
|
||||
* that swept past pending steps would be a cancel of half a phase under a button
|
||||
* labelled advance.
|
||||
*
|
||||
* **It satisfies the gate and stops.** The next tick advances the run, exactly
|
||||
* as it does after `resume` — the phase transition, the next phase's
|
||||
* materialisation, its own gate and the log lines are one sequence in
|
||||
* `advanceRun`, and a second copy of it here would be a second opinion about
|
||||
* what a phase boundary is. The response says which phase was released, so the
|
||||
* console can say so before the tick lands.
|
||||
*/
|
||||
async function advancePhase(runId, { reason } = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (run.status !== 'running') return conflict(`a ${run.status} run has no phase to advance`)
|
||||
if (!run.current_phase) return conflict('this run has not entered a phase yet')
|
||||
|
||||
const gate = await gatesDb.forPhase(run.id, run.current_phase)
|
||||
if (!gate) return conflict(`phase "${run.current_phase}" has no advance condition; skip its steps instead`)
|
||||
if (gate.satisfied_at) return conflict(`phase "${run.current_phase}" is already past its advance condition`)
|
||||
|
||||
const openStep = await stepsDb.nextOpenStep(run.id, run.current_phase)
|
||||
if (openStep) {
|
||||
return conflict(
|
||||
`phase "${run.current_phase}" is waiting on step ${openStep.seq} (${openStep.action_id}), not on its advance condition`,
|
||||
)
|
||||
}
|
||||
|
||||
const note = clean(reason)
|
||||
if (!(await gatesDb.satisfy(gate.id, 'forced', { userId }))) {
|
||||
return conflict('this phase stopped waiting on its advance condition')
|
||||
}
|
||||
|
||||
const described = gates.describe(gate)
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'phase.advanced',
|
||||
phase: run.current_phase,
|
||||
detail: {
|
||||
because: 'forced',
|
||||
control: 'advance',
|
||||
by: userId,
|
||||
reason: note,
|
||||
waitedSeconds: described.elapsedSeconds,
|
||||
...(gate.kind === 'on'
|
||||
? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed }
|
||||
: { after: gate.after_seconds }),
|
||||
},
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id), phase: run.current_phase }
|
||||
}
|
||||
|
||||
// ── Step-level ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -293,4 +359,4 @@ async function retryStep(runId, stepId, options = {}, userId = null) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { pause, resume, cancel, confirmStep, skipStep, retryStep }
|
||||
module.exports = { pause, resume, cancel, advancePhase, confirmStep, skipStep, retryStep }
|
||||
|
||||
Reference in New Issue
Block a user