feat(events): the resource ledger, leases and cleanup (Phase 8)
Some checks failed
PR Checks / client-build (pull_request) Successful in 3m15s
PR Checks / server-tests (pull_request) Failing after 8m21s
PR Checks / bot-tests (pull_request) Successful in 11m12s

Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN.

One table, one core action, one route, one body field, and two members added to
MODULE_API 1.10.0 in place. The safety property the whole world-write half
depends on: core now remembers what a run changed in the world, and gives it
back on every terminal path.

Four decisions settled by the org lead on 2026-09-03, all as recommended:

- A lease is acquired by a new CORE action, `core.lease`. Section F puts the
  duration bound and the two-events-one-target conflict check on core's side of
  the seam, and a lease verb per module would be both re-implemented once per
  module, advisory everywhere.
- Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A
  spawn's ref does not exist until the module answers, so what core writes
  before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never
  comes it stands, and cleanup calls revert() with the key and no resources --
  which is why section F's revert takes the key at all.
- Cleanup is one sweep over the ledger, not synthetic step rows. The
  step-shaped version costs a second retry counter beside `revert_attempts`.
- `reconcile` is declared here and TRIGGERED BY THE MODULE, through
  `ctx.events.reconcile()`. Core has no concept of the game being up, so it
  cannot decide when to ask; it asks once at its own boot.

MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`;
while it is on `edge` it is amended in place, so the whole module contract
reaches an author as one version they read once.

Verify

- `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the
  pre-existing engagementManifest CRLF failure, in a file this branch does not
  touch (`edge` before: 1950/1876/73/1). +75 tests.
- The unique key was proved against a REAL MariaDB, because nothing else can
  prove it: whether multiple NULLs collide in a unique index, whether a STORED
  generated column is recomputed on UPDATE, and whether the SET NULL foreign key
  survives beside it are properties of the server. eventRunnerSql.test.js gained
  16 tests; 65 pass against the container. The real schema.sql was applied to a
  fresh database and to an existing one.
- Client: 362 pass, and it builds. routes:manifest and swagger -- one route
  added, none moved.

The live walk found three defects, and two of them are the phase's real finding

Driven by a throwaway `rig` module in website/modules/, deleted before commit.

1. A lease was never given back at all. `core.lease` reserves its own ledger
   row, so it never went through the ledger's dirty-marking, so a run holding
   only a lease kept `cleanup_status = 'not_required'` and the cleanup leg --
   which selected on `pending` -- never looked at it.
2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing
   sweep moved the run to `incomplete`, which took it out of the leg's own scan
   for ever. The test covering the bound asserted `<= 3` and was satisfied by 1:
   a bound has two halves, and a test that only asserts the ceiling passes
   against a floor.
3. The first fix for (2) made the console lie. Spending every row's
   `revert_attempts` was a tidy way to take a `cleanup: false` run out of a
   counter-bounded scan, and the run page then rendered "3 attempts" beside
   resources nothing had ever tried. Found by opening the page.

Both (1) and (2) are the same mistake: deriving "is there anything to do" from a
summary column instead of from the rows. Neither was visible to a unit test,
because a test that calls the sweep directly never asks what would have selected
the run.

The two properties that need the process to die were walked as the plan asks.
With the module's perform() hanging, the placeholder existed while the dispatch
was in flight and nothing was named; after taskkill and a restart the reclaim
re-dispatched the same idempotency key, the retry re-used its own placeholder,
and everything was given back. Then, with the module reporting one of two
resources as no longer in force, the boot-time reconcile marked the other
`orphaned` -- never `reverted`.

This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as
Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-03 21:19:27 -05:00
parent 57d183e921
commit fdc118166c
29 changed files with 3928 additions and 72 deletions

View File

@@ -15,10 +15,13 @@
// 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.
// **`cleanup` is the eighth, and Phase 8 is what gave it a ledger to work over.**
// It re-runs the teardown across every resource a run has not given back, and it
// is `admin` where the other seven are `admin` + `moderator`: it is not incident
// response, it is asking core to write to the world again. Its partner is
// cancel's new `cleanup: false`, which is §L's "cancelling WITHOUT cleanup is a
// separate, logged, admin-only action" — deliberately the flag that has to be
// asked for, because the safe default is to give back what the run took.
//
// **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
@@ -37,6 +40,7 @@ const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const gatesDb = require('./eventPhaseGates.db')
const resourcesDb = require('./eventRunResources.db')
const gates = require('../../events/gates')
const MAX_REASON = 500
@@ -152,11 +156,23 @@ async function resume(runId, options = {}, userId = null) {
* and a second writer on that row would race the process that owns it. It
* finishes into a cancelled run, which is honest.
*/
async function cancel(runId, { reason } = {}, userId = null) {
async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { isAdmin = true } = {}) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is already ${run.status}`)
// §L: cancelling WITHOUT cleanup is a separate, logged, ADMIN-only action. The
// route itself is `admin` + `moderator`, so the narrower gate cannot live in
// middleware — which of the two you have to be depends on what is in the body,
// exactly as the authoring role floor does (§K).
if (cleanup === false && !isAdmin) {
return {
ok: false,
status: 403,
errors: ['leaving a run\'s world changes in place is an administrator\'s decision'],
}
}
const note = clean(reason)
const from = ['scheduled', 'starting', 'running', 'paused', 'ending']
if (!(await runsDb.transition(run.id, from, 'cancelled', { error: note || 'cancelled by staff' }))) {
@@ -168,9 +184,83 @@ async function cancel(runId, { reason } = {}, userId = null) {
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: { from: run.status, to: 'cancelled', control: 'cancel', by: userId, reason: note, cancelledSteps: closed },
detail: {
from: run.status,
to: 'cancelled',
control: 'cancel',
by: userId,
reason: note,
cancelledSteps: closed,
cleanup: cleanup !== false,
},
})
return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
// **The teardown is not done here, and the request does not wait for it.**
// Cleanup is one leg of the runner's tick over terminal runs (§L), which is
// what makes it survive a process that dies halfway through it — and a cancel
// pressed at two in the morning must answer at once rather than after a dozen
// round trips to a shard that may be the reason it is being cancelled. The run
// is terminal the moment this returns, so the very next tick picks its ledger
// up.
//
// `cleanup: false` is the operator saying leave it. The resources stay
// unresolved and the run carries `incomplete`, which is the truthful value: the
// world changes are still up, they are listed on the console, and the log line
// above records who decided that.
let cleanupStatus = run.cleanup_status
if (cleanup === false && (await resourcesDb.unresolvedCount(run.id)) > 0) {
// `incomplete` is what takes the run out of the cleanup leg's scan, and it is
// the truthful value: the world changes are still up, they are listed on the
// console, and the log line above records who decided that.
//
// **The first draft spent every row's `revert_attempts` instead**, to stop the
// sweep by the same mechanism a failed retry does. It worked and it made the
// console lie: the run page rendered "3 attempts" beside resources nothing had
// ever tried, which reads as "core tried three times and could not". Found by
// opening the page. A counter that means two things is a counter a screen
// cannot render.
await runsDb.setCleanupStatus(run.id, 'incomplete')
cleanupStatus = 'incomplete'
}
return {
ok: true,
run: await runsDb.getById(run.id),
cancelledSteps: closed,
cleanup: cleanup !== false,
cleanupStatus,
}
}
/**
* Re-run cleanup over everything a run has not given back.
*
* The manual retry §L promises, and the only thing that clears
* `revert_attempts`. That licence is the same one a human's step retry has, and
* it is deliberately not extended to the automatic sweep: Engagement Phase 14's
* defect was exactly a sweep that reset every stale row, which made the attempt
* ceiling unreachable and left the row cycling for ever.
*
* Legal on a TERMINAL run only. A run still in flight has a ledger that is still
* growing, and reverting a resource the next step is about to use would be core
* undoing an event while it is happening.
*/
async function cleanupRun(runId, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (!runsDb.TERMINAL.includes(run.status)) {
return conflict(`this run is still ${run.status}; cancel it before cleaning up after it`)
}
if (run.cleanup_status === 'not_required') {
return conflict('this run recorded no resources, so there is nothing to give back')
}
// eslint-disable-next-line global-require
const summary = await require('../../events/cleanup').cleanupRun(run, {
resetAttempts: true,
actor: userId,
})
return { ok: true, run: await runsDb.getById(run.id), summary }
}
/**
@@ -359,4 +449,4 @@ async function retryStep(runId, stepId, options = {}, userId = null) {
}
}
module.exports = { pause, resume, cancel, advancePhase, confirmStep, skipStep, retryStep }
module.exports = { pause, resume, cancel, cleanupRun, advancePhase, confirmStep, skipStep, retryStep }