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:
@@ -22,6 +22,7 @@
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const engine = require('../engagement/engine')
|
||||
const eventGates = require('../events/gates')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
@@ -276,6 +277,20 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
// await the delivery decision, and the tests use it directly.
|
||||
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
// **The trigger stream's second subscriber** (EVENTS.md §E, Phase 5). An event
|
||||
// run whose phase is waiting on `{ on: '<triggerId>', count: n }` counts this
|
||||
// firing here, at the moment it fires, because nothing observable survives to
|
||||
// the runner's next tick. Same seam, same posture: not awaited, never allowed
|
||||
// to reject, and it knows nothing about who emitted.
|
||||
//
|
||||
// It is a SECOND subscriber and not a leg of `dispatch` because the two
|
||||
// decide different things — who gets told, and whether a phase may proceed —
|
||||
// and neither must be able to fail the other. A rules lookup that throws must
|
||||
// not lose the count, and a gate write that throws must not lose the mail.
|
||||
eventGates
|
||||
.observe(event)
|
||||
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user