Files
website/server/test/eventRunner.test.js
wtclaude 2e964cfeee
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 5m29s
feat(events): the runner (Phase 2)
`utils/eventRunner.js`, the eighth poller, wired into server.js beside
engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their
grace window into `missed`, advances each due run through its phases, and drains
that phase's steps in `seq` order. The three core actions from Phase 1 get real
bodies, so a published event started from the existing run route now announces,
waits and completes on its own.

No routes are added: a runner has no surface, and the live controls stay Phase
3's.

Four things the org lead settled (2026-09-02): a parked step is `running` with a
NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members
rather than special cases keyed on an action id; a run whose concurrency key is
held stays `scheduled` and lets its grace window decide; and `n` in §L's
`retry(n)` is a runner constant.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 06:32:24 -05:00

690 lines
28 KiB
JavaScript

// ── The event runner (EVENTS_PLAN.md Phase 2) ──────────────────────────────
//
// The phase's shipped claim, first: **a manually started event that broadcasts,
// waits, and completes.** Then the properties around it that are not behaviour
// so much as promises — the ones §E and §L make, and the two this codebase has
// already paid for once:
//
// • a reclaim never resets `attempts` (Engagement Phase 14's defect)
// • a parked GM cue is not stale, however long it waits
// • no shape a failure can take reads as success (§F)
// • a step naming an unregistered action fails terminal with the module named
// and degrades the run — never a silent skip (§L)
// • the three `on_failure` dispositions do three different things to the RUN
// • the idempotency key does not vary by attempt (§E)
//
// **The three tables are stubbed at the `.db` layer** and the runner's own logic
// runs for real against them — the shape `engagementEngine.test.js` uses. What a
// stub cannot prove is the raw SQL whose correctness IS a server contract: the
// two CAS claims, the lease reclaim's two-statement order, and `holdNext`'s
// guard. Those run against a real MariaDB in `eventRunnerSql.test.js`, which
// skips when there is none. A stub reproduces the reading, not the server.
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const runner = require('../src/utils/eventRunner')
const { classify } = require('../src/events/dispatch')
const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const versionsDb = require('../src/model/events/eventVersions.db')
const db = require('../src/utils/db')
after(() => db.close())
const T0 = new Date('2026-09-02T12:00:00Z')
const later = (ms) => new Date(T0.getTime() + ms)
// ── In-memory stand-ins for the three tables ───────────────────────────────
let store
const originals = {}
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
const TERMINAL_RUN = ['completed', 'cancelled', 'failed', 'missed']
const clone = (o) => JSON.parse(JSON.stringify(o, (k, v) => v))
function installStubs() {
store = {
runs: new Map(),
steps: new Map(),
log: [],
versions: new Map(),
definitions: new Map(),
nextStepId: 1,
}
// Snapshots, not live references. A SQL SELECT hands back a copy, and the
// runner reads `step.attempts` as the value BEFORE its own claim incremented
// it — returning references here would make the retry budget off by one in the
// stub only, which is exactly the class of thing a stub must not invent.
const snapRun = (r) => ({ ...r })
const snapStep = (s) => ({ ...s, params: { ...(s.params || {}) } })
runsDb.findDue = async (now) =>
[...store.runs.values()]
.filter((r) => ['scheduled', 'starting', 'running', 'ending'].includes(r.status) && r.scheduled_for <= now)
.sort((a, b) => a.scheduled_for - b.scheduled_for || a.id - b.id)
.map(snapRun)
runsDb.findMissed = async (now) =>
[...store.runs.values()]
.filter((r) => {
const grace = store.definitions.get(r.definition_id)?.grace_seconds ?? 900
return r.status === 'scheduled' && r.scheduled_for.getTime() + grace * 1000 < now.getTime()
})
.map(snapRun)
runsDb.claimStart = async (id, owner, lease) => {
const r = store.runs.get(id)
if (!r || r.status !== 'scheduled') return false
Object.assign(r, { status: 'starting', claimed_by: owner, claim_expires_at: lease, started_at: r.started_at || T0 })
return true
}
runsDb.claimTick = async (id, owner, lease, now) => {
const r = store.runs.get(id)
if (!r || !['starting', 'running', 'ending'].includes(r.status)) return false
// No owner-matches escape: a live lease is not re-enterable, not even by the
// process that took it. The stub agrees with the statement on purpose.
if (r.claim_expires_at && r.claim_expires_at >= now) return false
Object.assign(r, { claimed_by: owner, claim_expires_at: lease })
return true
}
runsDb.releaseClaim = async (id, owner) => {
const r = store.runs.get(id)
if (!r || r.claimed_by !== owner) return false
Object.assign(r, { claimed_by: null, claim_expires_at: null })
return true
}
runsDb.transition = async (id, from, to, opts = {}) => {
const r = store.runs.get(id)
const froms = Array.isArray(from) ? from : [from]
if (!r || !froms.includes(r.status)) return false
r.status = to
if (opts.phase !== undefined) r.current_phase = opts.phase
if (opts.error !== undefined) r.last_error = opts.error
if (TERMINAL_RUN.includes(to)) {
r.ended_at = r.ended_at || T0
r.claimed_by = null
r.claim_expires_at = null
} else if (opts.clearClaim) {
r.claimed_by = null
r.claim_expires_at = null
}
return true
}
runsDb.setHealth = async (id, health) => {
const r = store.runs.get(id)
if (!r || r.health === health) return false
r.health = health
return true
}
runsDb.concurrencyHolder = async (key, exceptId) => {
if (!key) return null
const held = [...store.runs.values()].find(
(r) => r.concurrency_key === key && r.id !== exceptId && ['starting', 'running', 'paused', 'ending'].includes(r.status),
)
return held ? { id: held.id, status: held.status, definition_id: held.definition_id } : null
}
runsDb.reclaimStale = async (now) => {
let n = 0
for (const r of store.runs.values()) {
if (['starting', 'running', 'ending'].includes(r.status) && r.claim_expires_at && r.claim_expires_at < now) {
r.claimed_by = null
r.claim_expires_at = null
n += 1
}
}
return n
}
stepsDb.materialisePhase = async (runId, phase, steps) => {
steps.forEach((s, i) => {
// INSERT IGNORE against uq_evstep_slot (run_id, phase, seq).
const exists = [...store.steps.values()].find((x) => x.run_id === runId && x.phase === phase && x.seq === i)
if (exists) return
const id = store.nextStepId++
store.steps.set(id, {
id,
run_id: runId,
phase,
seq: i,
action_id: s.actionId,
params: s.params || {},
action_version: s.actionVersion || 1,
status: 'pending',
due_at: null,
attempts: 0,
on_failure: s.onFailure || 'pause',
idempotency_key: stepsDb.idempotencyKey(runId, id),
claimed_by: null,
claim_expires_at: null,
last_error: null,
})
})
return [...store.steps.values()].filter((s) => s.run_id === runId).map(snapStep)
}
stepsDb.nextOpenStep = async (runId, phase) => {
const s = [...store.steps.values()]
.filter((x) => x.run_id === runId && x.phase === phase && ['pending', 'running'].includes(x.status))
.sort((a, b) => a.seq - b.seq || a.id - b.id)[0]
return s ? snapStep(s) : null
}
stepsDb.claim = async (id, owner, lease, now) => {
const s = store.steps.get(id)
if (!s || s.status !== 'pending') return false
if (s.due_at && s.due_at > now) return false
Object.assign(s, { status: 'running', attempts: s.attempts + 1, claimed_by: owner, claim_expires_at: lease })
return true
}
stepsDb.park = async (id) => {
const s = store.steps.get(id)
if (s && s.status === 'running') s.claim_expires_at = null
}
stepsDb.reschedule = async (id, dueAt, error) => {
const s = store.steps.get(id)
if (!s || s.status !== 'running') return
// `attempts` is untouched: the claim already incremented it, and nothing else
// may. This is the stub agreeing with the statement, not with the runner.
Object.assign(s, { status: 'pending', due_at: dueAt, claimed_by: null, claim_expires_at: null, last_error: error })
}
stepsDb.finish = async (id, status, error) => {
const s = store.steps.get(id)
if (!s || s.status !== 'running') return
Object.assign(s, { status, last_error: error, claimed_by: null, claim_expires_at: null, finished_at: T0 })
}
stepsDb.holdNext = async (runId, phase, afterSeq, dueAt) => {
const s = [...store.steps.values()]
.filter((x) => x.run_id === runId && x.phase === phase && x.seq > afterSeq && x.status === 'pending')
.filter((x) => !x.due_at || x.due_at < dueAt)
.sort((a, b) => a.seq - b.seq)[0]
if (!s) return false
s.due_at = dueAt
return true
}
stepsDb.reclaimStale = async (now, maxAttempts = 0) => {
let failed = 0
let reclaimed = 0
// Give up first, reclaim second — the order the statement uses, and the
// reason `MAX_ATTEMPTS` is reachable at all.
for (const s of store.steps.values()) {
if (s.status === 'running' && s.claim_expires_at && s.claim_expires_at < now && maxAttempts > 0 && s.attempts >= maxAttempts) {
Object.assign(s, { status: 'failed', last_error: 'gave up after repeated interruptions', claimed_by: null, claim_expires_at: null })
failed += 1
}
}
for (const s of store.steps.values()) {
if (s.status === 'running' && s.claim_expires_at && s.claim_expires_at < now) {
// NOT reset: attempts survives the reclaim.
Object.assign(s, { status: 'pending', claimed_by: null, claim_expires_at: null })
reclaimed += 1
}
}
return { failed, reclaimed }
}
stepsDb.cancelPending = async (runId) => {
let n = 0
for (const s of store.steps.values()) {
if (s.run_id === runId && s.status === 'pending') {
s.status = 'cancelled'
n += 1
}
}
return n
}
stepsDb.listForRun = async (runId) =>
[...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq).map(snapStep)
logDb.write = async (line) => {
store.log.push(line)
return true
}
logDb.pruneTerminal = async () => 0
versionsDb.getById = async (id) => store.versions.get(id) || null
}
// ── Fixtures ───────────────────────────────────────────────────────────────
let nextRunId = 1
function seedRun(phases, { scheduledFor = T0, graceSeconds = 900, concurrencyKey = null, status = 'scheduled' } = {}) {
const id = nextRunId++
store.definitions.set(id, { id, grace_seconds: graceSeconds })
store.versions.set(id, { id, spec: { schedule: { kind: 'manual' }, phases } })
store.runs.set(id, {
id,
definition_id: id,
version_id: id,
scope: '',
status,
health: 'ok',
cleanup_status: 'not_required',
current_phase: null,
scheduled_for: scheduledFor,
concurrency_key: concurrencyKey,
params: null,
rehearsal: 0,
claimed_by: null,
claim_expires_at: null,
last_error: null,
started_at: null,
ended_at: null,
})
// Phase 1's `create()` materialises the FIRST phase at creation rather than at
// start, so a seeded run has to as well — otherwise every test here would be
// exercising a shape the admin route cannot produce.
const first = phases[0]
if (first) void stepsDb.materialisePhase(id, first.key, first.steps || [])
return id
}
const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params, onFailure, actionVersion: 1 })
const run = (id) => store.runs.get(id)
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
// A registered test action whose behaviour the test dictates.
let scripted
beforeEach(() => {
registries._reset()
installStubs()
nextRunId = 1
scripted = {}
})
afterEach(() => {
restoreOriginals()
registries._reset()
})
/** Register actions the way a module does, through the real staging area. */
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
registries.apply(api.staged)
}
// ── Registering actions the tests drive ────────────────────────────────────
//
// Registered through the real registry rather than by stubbing `eventAction`,
// because the shape check at registration is part of what the runner relies on:
// an action that would not register is not one the runner has to survive.
const scriptedAction = (id, extra = {}) => ({
id,
label: id,
risk: 'notify',
reversible: 'none',
version: 1,
budgetMs: 1000,
params: [],
perform: async (envelope) => {
;(scripted[id] ||= { calls: [] }).calls.push(envelope)
const answer = scripted[id].answers?.shift() ?? scripted[id].answer
if (typeof answer === 'function') return answer(envelope)
return answer ?? { ok: true }
},
...extra,
})
test('a manually started event announces, waits and completes', async () => {
register([scriptedAction('test.announce'), scriptedAction('test.wait')])
scripted['test.wait'] = { calls: [], answer: { ok: true, holdFor: 300 } }
const id = seedRun([
{ key: 'main', label: 'Main', steps: [step('test.announce'), step('test.wait'), step('test.announce')] },
])
// Tick one: announce, then wait, then stop against the held third step.
await runner.tick(T0)
let s = stepsOf(id)
assert.equal(s[0].status, 'done')
assert.equal(s[1].status, 'done')
assert.equal(s[2].status, 'pending', 'the step after a wait must not run in the same tick')
assert.equal(s[2].due_at.getTime(), later(300_000).getTime(), 'the wait is the NEXT step due_at')
assert.equal(run(id).status, 'running')
assert.equal(run(id).claimed_by, null, 'a run left in flight gives its lease back')
// Tick two, still inside the wait: nothing moves.
await runner.tick(later(120_000))
assert.equal(stepsOf(id)[2].status, 'pending')
assert.equal(run(id).status, 'running')
// Tick three, past it: the last step runs and the run completes.
await runner.tick(later(301_000))
assert.equal(stepsOf(id)[2].status, 'done')
assert.equal(run(id).status, 'completed')
assert.equal(run(id).health, 'ok')
assert.ok(kinds(id).includes('phase.completed'))
assert.ok(kinds(id).includes('run.status'))
})
test('a run passes through `ending` on its way to completed', async () => {
register([scriptedAction('test.noop')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }])
await runner.tick(T0)
const transitions = store.log.filter((l) => l.runId === id && l.kind === 'run.status').map((l) => l.detail.to)
assert.deepEqual(transitions, ['starting', 'running', 'ending', 'completed'])
})
test('phases run in order and the next one is materialised on entry', async () => {
register([scriptedAction('test.noop')])
const id = seedRun([
{ key: 'opening', label: 'Opening', steps: [step('test.noop')] },
{ key: 'closing', label: 'Closing', steps: [step('test.noop'), step('test.noop')] },
])
await runner.tick(T0)
assert.equal(run(id).status, 'completed')
assert.deepEqual(stepsOf(id).map((s) => s.phase), ['opening', 'closing', 'closing'])
assert.ok(stepsOf(id).every((s) => s.status === 'done'))
})
test('a wait as the last step of a phase holds the NEXT phase, rather than meaning nothing', async () => {
register([scriptedAction('test.noop'), scriptedAction('test.wait')])
scripted['test.wait'] = { calls: [], answer: { ok: true, holdFor: 300 } }
const id = seedRun([
{ key: 'opening', label: 'Opening', steps: [step('test.noop'), step('test.wait')] },
{ key: 'closing', label: 'Closing', steps: [step('test.noop')] },
])
await runner.tick(T0)
const closing = stepsOf(id).filter((x) => x.phase === 'closing')
assert.equal(closing.length, 1, 'the next phase is materialised')
assert.equal(closing[0].status, 'pending')
assert.equal(
closing[0].due_at.getTime(),
later(300_000).getTime(),
'the hold crosses the phase boundary; dropping it would start the next phase at once',
)
assert.equal(run(id).status, 'running')
await runner.tick(later(301_000))
assert.equal(run(id).status, 'completed')
})
test('a GM cue parks: the step stays running with no lease, and the reclaim leaves it alone', async () => {
register([scriptedAction('test.cue'), scriptedAction('test.after')])
scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } }
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue'), step('test.after')] }])
await runner.tick(T0)
const cue = stepsOf(id)[0]
assert.equal(cue.status, 'running')
assert.equal(cue.claim_expires_at, null, 'a parked step carries no lease')
assert.equal(stepsOf(id)[1].status, 'pending', 'nothing after a cue proceeds')
assert.ok(kinds(id).includes('step.parked'))
// A week later the reclaim has still not touched it, and the cue has been
// dispatched exactly once. This is the whole point of a NULL lease.
await runner.tick(later(7 * 24 * 60 * 60 * 1000))
assert.equal(stepsOf(id)[0].status, 'running')
assert.equal(stepsOf(id)[0].attempts, 1)
assert.equal(scripted['test.cue'].calls.length, 1)
assert.equal(run(id).status, 'running')
})
test('a reclaim returns a stale step without resetting attempts', async () => {
register([scriptedAction('test.slow')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.slow')] }])
// Simulate a process that claimed the step and died: running, lease in the past.
await runner.tick(T0)
const s = stepsOf(id)[0]
Object.assign(store.steps.get(s.id), { status: 'running', attempts: 2, claim_expires_at: later(-1000) })
await stepsDb.reclaimStale(T0, runner.MAX_ATTEMPTS)
assert.equal(store.steps.get(s.id).status, 'pending')
assert.equal(store.steps.get(s.id).attempts, 2, 'Engagement Phase 14: a reclaim must never reset attempts')
})
test('a step whose attempts are spent leaves `running` as failed rather than being handed back', async () => {
register([scriptedAction('test.slow')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.slow')] }])
await runner.tick(T0)
const s = stepsOf(id)[0]
Object.assign(store.steps.get(s.id), { status: 'running', attempts: runner.MAX_ATTEMPTS, claim_expires_at: later(-1000) })
const { failed, reclaimed } = await stepsDb.reclaimStale(T0, runner.MAX_ATTEMPTS)
assert.equal(failed, 1)
assert.equal(reclaimed, 0, 'a row that gave up must not also be reclaimed, or it retries forever')
assert.equal(store.steps.get(s.id).status, 'failed')
})
test('a transient failure retries on a flat backoff with the same idempotency key, then applies on_failure', async () => {
register([scriptedAction('test.flaky')])
scripted['test.flaky'] = { calls: [], answer: { ok: false, retry: true, error: 'relay is down' } }
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.flaky', {}, 'skip')] }])
const key = () => stepsOf(id)[0].idempotency_key
await runner.tick(T0)
const firstKey = key()
assert.equal(stepsOf(id)[0].status, 'pending')
assert.equal(stepsOf(id)[0].attempts, 1)
assert.equal(stepsOf(id)[0].due_at.getTime(), later(runner.RETRY_MS).getTime())
assert.equal(run(id).health, 'degraded', 'degraded from the first retry, not from the eventual failure')
await runner.tick(later(runner.RETRY_MS))
assert.equal(stepsOf(id)[0].attempts, 2)
await runner.tick(later(2 * runner.RETRY_MS))
assert.equal(stepsOf(id)[0].attempts, runner.MAX_ATTEMPTS)
assert.equal(stepsOf(id)[0].status, 'failed', 'all three dispositions write the step failed')
assert.equal(run(id).status, 'completed', 'on_failure: skip lets the run finish')
assert.equal(run(id).health, 'degraded')
assert.equal(key(), firstKey, 'the idempotency key does not vary by attempt')
assert.equal(new Set(scripted['test.flaky'].calls.map((c) => c.idempotencyKey)).size, 1)
})
test('on_failure: pause stops the run and the tick never picks it up again', async () => {
register([scriptedAction('test.bad'), scriptedAction('test.after')])
scripted['test.bad'] = { calls: [], answer: { ok: false, retry: false, error: 'the world is half changed' } }
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.bad', {}, 'pause'), step('test.after')] }])
await runner.tick(T0)
assert.equal(run(id).status, 'paused')
assert.equal(stepsOf(id)[0].status, 'failed')
assert.equal(stepsOf(id)[1].status, 'pending', 'a paused run leaves its remaining steps alone')
await runner.tick(later(60_000))
assert.equal(run(id).status, 'paused', 'only Phase 3 resume moves a paused run')
assert.equal(scripted['test.after']?.calls?.length ?? 0, 0)
})
test('on_failure: abort_run fails the run and cancels what has not started', async () => {
register([scriptedAction('test.bad'), scriptedAction('test.after')])
scripted['test.bad'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.bad', {}, 'abort_run'), step('test.after')] }])
await runner.tick(T0)
assert.equal(run(id).status, 'failed')
assert.equal(stepsOf(id)[0].status, 'failed')
assert.equal(stepsOf(id)[1].status, 'cancelled')
})
test('a step naming an unregistered action fails terminal with the module named, and degrades the run', async () => {
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('gone.verb', {}, 'skip')] }])
await runner.tick(T0)
const s = stepsOf(id)[0]
assert.equal(s.status, 'failed', 'never a silent skip (§L)')
assert.equal(s.attempts, 1, 'a dormant action is terminal, so it is not retried')
assert.match(s.last_error, /gone\.verb/)
assert.equal(run(id).health, 'degraded')
})
test('a held concurrency key holds the run at scheduled, and logs the reason once', async () => {
register([scriptedAction('test.noop'), scriptedAction('test.cue')])
scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } }
// The holder is parked on a cue, which is what keeps it genuinely in flight. A
// holder with no steps would complete itself on this same tick — correct
// behaviour, and a fixture that proved nothing.
const holder = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue')] }], {
concurrencyKey: 'invasion:Yew',
})
const waiting = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { concurrencyKey: 'invasion:Yew' })
await runner.tick(T0)
assert.equal(run(waiting).status, 'scheduled')
assert.match(run(waiting).last_error, new RegExp(`run ${holder}`))
assert.equal(kinds(waiting).filter((k) => k === 'run.blocked').length, 1)
// Still held, and still one line: a line per tick would bury the one that matters.
await runner.tick(later(15_000))
assert.equal(kinds(waiting).filter((k) => k === 'run.blocked').length, 1)
// The holder finishes, and the next tick starts the run that was waiting.
store.runs.get(holder).status = 'completed'
store.runs.get(holder).claim_expires_at = null
await runner.tick(later(30_000))
assert.equal(run(waiting).status, 'completed')
})
test('an occurrence past its own grace window is missed, never a late silent start', async () => {
register([scriptedAction('test.noop')])
const late = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { graceSeconds: 600 })
const inside = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { graceSeconds: 3600 })
// Both are due; the process has been down for half an hour.
await runner.tick(later(30 * 60 * 1000))
assert.equal(run(late).status, 'missed')
assert.equal(stepsOf(late)[0].status, 'cancelled')
assert.equal(run(inside).status, 'completed', 'inside its window it starts late and says so')
})
test('a run in `ending` when the process died is completed by the next tick', async () => {
const id = seedRun([{ key: 'main', label: 'Main', steps: [] }], { status: 'ending' })
store.runs.get(id).current_phase = 'main'
await runner.tick(T0)
assert.equal(run(id).status, 'completed')
})
test('a live lease is not re-enterable, not even by the process that took it', async () => {
register([scriptedAction('test.cue')])
scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } }
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue')] }])
await runner.tick(T0)
// Put a live lease back on the run, as an overrunning tick would have.
Object.assign(store.runs.get(id), { claimed_by: runner.OWNER, claim_expires_at: later(60_000) })
const taken = await runsDb.claimTick(id, runner.OWNER, later(120_000), T0)
assert.equal(taken, false, 'the CAS is what protects a tick that overran into the next one')
})
// ── §F: no shape a failure can take reads as success ───────────────────────
test('classify: every failure shape is a failure', () => {
assert.equal(classify(undefined, 'a').outcome, 'retry')
assert.equal(classify(null, 'a').outcome, 'retry')
assert.equal(classify('ok', 'a').outcome, 'retry')
assert.equal(classify(['ok'], 'a').outcome, 'retry')
assert.equal(classify({}, 'a').outcome, 'retry', 'a missing ok is not a success')
assert.equal(classify({ ok: 'yes' }, 'a').outcome, 'retry', 'ok must be true, not truthy')
assert.equal(classify({ ok: false }, 'a').outcome, 'retry')
assert.equal(classify({ ok: false, retry: false }, 'a').outcome, 'terminal')
assert.equal(classify({ __timedOut: true, error: 'slow' }, 'a').outcome, 'retry')
})
test('classify: the two success shapes that are not "finished"', () => {
assert.equal(classify({ ok: true }, 'a').outcome, 'done')
assert.equal(classify({ ok: true }, 'a').holdSeconds, 0)
assert.equal(classify({ ok: true, await: 'human' }, 'a').outcome, 'parked')
assert.equal(classify({ ok: true, holdFor: 90 }, 'a').holdSeconds, 90)
assert.equal(classify({ ok: true, holdFor: '90' }, 'a').holdSeconds, 90)
assert.equal(classify({ ok: true, holdFor: -1 }, 'a').outcome, 'terminal', 'a bad holdFor is not a silent zero')
assert.equal(classify({ ok: true, holdFor: 'soon' }, 'a').outcome, 'terminal')
assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded')
})
test('an action that throws is a transient failure, not a crashed tick', async () => {
register([
scriptedAction('test.thrower', {
perform: async () => {
throw new Error('boom')
},
}),
])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.thrower', {}, 'skip')] }])
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'pending')
assert.match(stepsOf(id)[0].last_error, /boom/)
assert.equal(run(id).status, 'running', 'one bad action does not stop the deployment')
})
test('an action that never answers is cut off at its declared budget', async () => {
register([
scriptedAction('test.hang', { budgetMs: 30, perform: () => new Promise(() => {}) }),
])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.hang', {}, 'skip')] }])
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'pending', 'a timeout is transient')
assert.match(stepsOf(id)[0].last_error, /budget/)
})
test('the dispatch envelope carries what §F says it carries', async () => {
register([scriptedAction('test.echo')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.echo', {})] }])
store.runs.get(id).scope = 'atlantic'
await runner.tick(T0)
const [envelope] = scripted['test.echo'].calls
assert.equal(envelope.runId, id)
assert.equal(envelope.scope, 'atlantic')
assert.equal(envelope.verify, false)
assert.equal(typeof envelope.idempotencyKey, 'string')
assert.equal(envelope.idempotencyKey.length, 40)
assert.deepEqual(Object.keys(envelope).sort(), ['actor', 'idempotencyKey', 'params', 'runId', 'scope', 'stepId', 'verify'])
})