From cc1f49af29f0c33a6562045806528b2a614d25de Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 21:13:07 -0500 Subject: [PATCH 1/2] feat(events): an `expired` ledger status and ctx.events.expired (MODULE_API 1.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A game that ends a resource at its own deadline — a Rust zone erased when its time is up — could reach core only through ctx.events.reconcile(), which files it `orphaned`: amber, "it vanished", and still claimable for a revert. The new call files it as the plan working (Rust PLAN_FIXES F14, D170, D183): - event_run_resources.status gains `expired`, terminal like `reverted`: the sweep never takes it back, live_marker releases the target, and it joins neither HELD nor UNRESOLVED. The ENUM ALTER re-runs as a no-op on every boot (checked on MariaDB 11.8 with the stored generated column depending on it). - ctx.events.expired({ kind, ref }) marks the calling module's own pending, confirmed or orphaned rows for that target expired and logs `resource.expired`; a revert in flight is left to finish. The owner is bound by the loader, like reconcile. A finished run whose last unresolved row this was goes to cleanup `complete`, even from `incomplete`. - The run console shows it green, "ended by the game on time". Additions only, so minor. Module-uo (coreApi ^1.10.0) calls none of it and reads no ledger status; the contract test now asserts the range still holds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/modules/version.js | 6 +- client/src/routes/admin/views/EventRun.jsx | 6 +- server/db/schema.sql | 10 +- server/engagement-triggers.json | 2 +- server/src/events/cleanup.js | 59 ++++++++++ server/src/model/events/eventRunLog.db.js | 3 + .../src/model/events/eventRunResources.db.js | 38 ++++++ server/src/modules/loader.js | 21 ++++ server/src/modules/version.js | 14 ++- server/src/router/v1/admin/events.router.js | 2 +- server/swagger/swagger-output.json | 2 +- server/test/eventCleanup.test.js | 110 ++++++++++++++++++ server/test/eventModuleContract.test.js | 13 ++- server/test/moduleLoader.test.js | 33 ++++++ 14 files changed, 309 insertions(+), 10 deletions(-) diff --git a/client/src/modules/version.js b/client/src/modules/version.js index c2f0754..ca9fe7d 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,10 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status +// (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at +// its own deadline. Server-side only; the run console, which is core's own page, +// shows the new status. This file bumps for the reason at the top. // 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md // Phase 7): a module may register event actions, budget dimensions, leases and // param option sources. All four are server-side registrations and nothing on @@ -74,4 +78,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.10.0' +export const MODULE_API_VERSION = '1.11.0' diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx index 78398f6..69c3e59 100644 --- a/client/src/routes/admin/views/EventRun.jsx +++ b/client/src/routes/admin/views/EventRun.jsx @@ -59,7 +59,7 @@ const STATUS_COLOR = { completed: '#8fc79a', } -// The six ledger statuses, in the two groups that matter to a reader: green is +// The seven ledger statuses, in the two groups that matter to a reader: green is // resolved, amber wants a person. `orphaned` and `drifted` are amber rather than // red because neither is a fault — one thing vanished, the other was taken by // somebody with every right to take it — and red is reserved for "this did not @@ -71,6 +71,9 @@ const RESOURCE_COLOR = { reverting: '#d9c184', drifted: '#d9c184', orphaned: '#d9c184', + // Green, like `reverted`: the game ended it at the deadline it was given, which + // is the plan working (MODULE_API 1.11.0). `orphaned` is the amber one. + expired: '#8fc79a', } const RESOURCE_WORD = { @@ -80,6 +83,7 @@ const RESOURCE_WORD = { reverted: 'given back', orphaned: 'gone', drifted: 'someone else moved it', + expired: 'ended by the game on time', } const STEP_COLOR = { diff --git a/server/db/schema.sql b/server/db/schema.sql index 2ef8de3..560aab2 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2560,7 +2560,7 @@ CREATE TABLE IF NOT EXISTS event_run_resources ( -- defensible. This column is core's copy of that promise, for the console and -- for the boot-time check. lease_until DATETIME NULL, - status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted') + status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending', -- Bounded like a step's `attempts`, and for the same reason: a revert that can -- never succeed must become visible rather than cycling for ever. Engagement @@ -2587,6 +2587,14 @@ CREATE TABLE IF NOT EXISTS event_run_resources ( INDEX idx_evres_live (status, lease_until) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- MODULE_API 1.11.0 (Rust PLAN_FIXES D183): `expired`, the game ending a resource +-- at its own deadline, for databases created before it. Terminal, so `live_marker` +-- releases the target. MODIFY has no IF NOT EXISTS form, but re-declaring the same +-- ENUM is an idempotent no-op -- checked against MariaDB 11.8 with the stored +-- `live_marker` column depending on it -- so it is safe on every boot. +ALTER TABLE event_run_resources MODIFY COLUMN status + ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending'; + -- §K's last bound: "a scheduled definition that has never been verified is the -- case worth refusing to start". A version is immutable, so a dry run that passed -- against it stays true — which is what makes the pass a property of the VERSION diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index 1d87575..156fa8e 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.10.0", + "moduleApiVersion": "1.11.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/src/events/cleanup.js b/server/src/events/cleanup.js index b9878b2..fe886b6 100644 --- a/server/src/events/cleanup.js +++ b/server/src/events/cleanup.js @@ -427,6 +427,64 @@ async function reconcileAll() { return out } +// The bounds on what a module may name. They are the columns' own: a longer +// value cannot be a row, so it is refused rather than truncated into a match +// against somebody else's. +const MAX_KIND = 64 +const MAX_REF = 190 + +/** + * The game ended one of this module's resources by itself, as it was told to — + * `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183). + * + * **Expired is not orphaned, and the difference is the reason this exists.** A + * Rust zone is created with a deadline and the game erases it when the deadline + * passes, without being asked again — the fail-safe that makes an unattended + * world change defensible. Until a module could say so, core learned of it only + * through reconcile, which files it as `orphaned`: a thing that vanished while + * nobody was looking, amber on the console and still claimable for a revert. + * An expiry is the plan working, so it is terminal and green like `reverted`. + * + * `owner` is bound by the loader, never taken from the module's arguments, for + * `reconcile`'s reason: without the binding a module could close another + * module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`, + * because a module hears expiries of things no run ledgered too. + */ +async function expireResource(owner, { kind, ref } = {}) { + if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 } + if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 } + + const rows = await resourcesDb.expirableByTarget(owner, kind, ref) + const runs = new Set() + let expired = 0 + + for (const row of rows) { + if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue + expired += 1 + runs.add(row.run_id) + await logDb.write({ + runId: row.run_id, + kind: 'resource.expired', + detail: { module: owner, resource: `${row.kind}:${row.ref}` }, + }) + } + + // A finished run whose last unresolved row this was has nothing left to clean + // up. The sweep would settle a `pending` one by itself, but not an + // `incomplete` one — that status takes a run out of the sweep's scan, and it + // would go on saying "not finished" about a ledger that is. A run still in + // flight is left alone: its own terminal path computes the status. + for (const runId of runs) { + const run = await runsDb.getById(runId) + if (!run || !runsDb.TERMINAL.includes(run.status)) continue + if ((await resourcesDb.unresolvedCount(runId)) === 0) { + await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete']) + } + } + + return { expired } +} + module.exports = { MAX_REVERT_ATTEMPTS, classifyRevert, @@ -434,4 +492,5 @@ module.exports = { sweep, reconcileModule, reconcileAll, + expireResource, } diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js index c5b4e0d..88891ba 100644 --- a/server/src/model/events/eventRunLog.db.js +++ b/server/src/model/events/eventRunLog.db.js @@ -53,6 +53,9 @@ const KINDS = [ // question is about the world. 'resource.recorded', // a step reported what it created or borrowed, and it is ledgered 'resource.orphaned', // a module reports a ledgered resource is no longer in force + // MODULE_API 1.11.0's one (Rust PLAN_FIXES D183): the game ended a resource at + // its own deadline, which is the plan working rather than something vanishing. + 'resource.expired', // a module reports the game ended a ledgered resource by itself 'cleanup.reverted', // a group of resources came back 'cleanup.failed', // a group did not, with the reason and how it was left 'cleanup.swept', // one pass over a run's ledger, and what it found diff --git a/server/src/model/events/eventRunResources.db.js b/server/src/model/events/eventRunResources.db.js index badd823..4e68195 100644 --- a/server/src/model/events/eventRunResources.db.js +++ b/server/src/model/events/eventRunResources.db.js @@ -327,6 +327,42 @@ async function markOrphaned(id, detail = null) { ) } +/** + * The rows one module holds for one target that the game may have let go of on + * its own — what `ctx.events.expired` looks for (MODULE_API 1.11.0). + * + * `orphaned` is included on purpose: a reconcile that ran before the module heard + * the expiry filed the row as vanished, and the expiry is the better answer. + * `reverting` is not: a revert already in flight finds the thing gone, which §L + * calls a success, and it settles as `reverted` on its own. + */ +async function expirableByTarget(owner, kind, ref) { + const rows = await query( + `SELECT ${COLUMNS} FROM event_run_resources + WHERE owner_module = ? AND kind = ? AND ref = ? + AND status IN ('pending', 'confirmed', 'orphaned') + ORDER BY id`, + [owner, kind, ref], + ) + return rows.map(hydrate) +} + +/** + * The game ended it by itself, as it was told to — a zone reaching its deadline. + * Terminal like `reverted`, so teardown never tries to give it back, and unlike + * `orphaned`, which is a thing that vanished with nobody asking. Guarded on the + * status so a row a revert has just claimed is left to that revert. + */ +async function markExpired(id, detail = null) { + const result = await query( + `UPDATE event_run_resources + SET status = 'expired', last_error = ? + WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned')`, + [detail === null ? null : String(detail).slice(0, 500), id], + ) + return (result.affectedRows || 0) > 0 +} + /** * Terminal runs that still owe the world something — the cleanup leg's scan. * @@ -385,5 +421,7 @@ module.exports = { liveForModule, modulesWithLiveRows, markOrphaned, + expirableByTarget, + markExpired, runsNeedingCleanup, } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 7503186..da50330 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -261,6 +261,27 @@ function buildCtx(id, moduleRoot) { (err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) }, ) }, + // MODULE_API 1.11.0 (Rust PLAN_FIXES D183). The game ended one of this + // module's ledgered resources at its own deadline — a Rust zone erased when + // its time was up. Recorded as `expired`: terminal, never given back, and a + // different sentence on the console from `orphaned`, which is reconcile's + // word for a thing that vanished with nobody asking. + // + // `id` bound, fire-and-forget and returns undefined, all three for + // `reconcile`'s reasons directly above. A `{ kind, ref }` that names no + // live row is not an error: a module hears expiries of things no run ever + // ledgered, and telling it so would be noise it can do nothing with. + expired: (resource) => { + // eslint-disable-next-line global-require + require('../events/cleanup') + .expireResource(id, resource || {}) + .then( + (summary) => { + if (summary && summary.expired) log.info('event resource expired', { module: id, ...summary }) + }, + (err) => { log.error('ctx.events.expired failed', { module: id, message: err.message }) }, + ) + }, }, // The in-app sink (§5.1) — a module writing the inbox directly, without a // rule. Live from Phase 7; it threw until the `user_notifications` table diff --git a/server/src/modules/version.js b/server/src/modules/version.js index caa2042..f8a5b35 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,18 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource +// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that +// ends something at its own deadline — a Rust zone erased when its time is up — +// could until now reach core only through `ctx.events.reconcile()`, which files +// it `orphaned`: amber, "it vanished", and still claimable for a revert. The new +// call files it as the plan working: terminal, green, never taken back. +// +// Additions only, so minor, and checked against the one module already built on +// 1.10.0: Module-uo never calls it, reads no ledger status, and every existing +// status keeps its meaning — `expired` joins neither `HELD` nor `UNRESOLVED`, so +// the sweep, the manual retry and `cleanup_status` see exactly what they saw. + // 1.10.0 — the event contract opens to modules: `api.registerEventActions`, // `api.registerEventBudgets`, `api.registerEventLeases` and // `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md @@ -155,6 +167,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.10.0' +const MODULE_API_VERSION = '1.11.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js index 29015b1..2bbe1d6 100644 --- a/server/src/router/v1/admin/events.router.js +++ b/server/src/router/v1/admin/events.router.js @@ -237,7 +237,7 @@ eventsRouter.get( '/runs/:runId', // #swagger.tags = ['Admin · Events'] // #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key' - // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.' + // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ /* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index d3099d0..b1bc629 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4271,7 +4271,7 @@ "Admin · Events" ], "summary": "One run: its status, health, cleanup state and every step with its params and idempotency key", - "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.", + "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.", "parameters": [ { "name": "runId", diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js index b1eb4ae..27202c7 100644 --- a/server/test/eventCleanup.test.js +++ b/server/test/eventCleanup.test.js @@ -618,3 +618,113 @@ test('reconcileAll asks every module that owns a live row', async () => { // orphaned by a module that is not there to be asked. assert.equal(out.other.unanswered, 1) }) + +// ── Expiry (MODULE_API 1.11.0, Rust PLAN_FIXES D183) ───────────────────────── +// +// A game that ends something at its own deadline — a Rust zone erased when its +// time is up — says so through `ctx.events.expired`. The properties: +// +// • **expired is terminal**: the sweep never tries to give it back, and it is +// not one of the statuses that makes a run's cleanup unfinished +// • **it is not orphaned**, and it wins over orphaned: a reconcile that ran +// before the module heard the expiry filed the row as vanished +// • **a module closes only its own rows**, and only the target it named +// • **a revert already in flight is left to finish** — it finds nothing, which +// §L calls a success, and settles as `reverted` by itself +// • **a finished run whose last row this was is complete**, even one the sweep +// had given up on as `incomplete` + +function stubExpiry() { + resourcesDb.expirableByTarget = async (owner, kind, ref) => + [...store.rows.values()] + .filter((r) => r.owner_module === owner && r.kind === kind && r.ref === ref) + .filter((r) => ['pending', 'confirmed', 'orphaned'].includes(r.status)) + .map((r) => ({ ...r })) + resourcesDb.markExpired = async (id, detail = null) => { + const r = store.rows.get(id) + if (!r || !['pending', 'confirmed', 'orphaned'].includes(r.status)) return false + Object.assign(r, { status: 'expired', last_error: detail }) + return true + } + store.run = { ...RUN } + runsDb.getById = async (id) => (id === RUN.id ? store.run : null) +} + +test('an expired resource is terminal: the sweep never tries to give it back', async () => { + stubExpiry() + let reverts = 0 + registerAction({ revert: async () => { reverts += 1; return { ok: true } } }) + addStep(1, 'demo.spawn') + addResource({ kind: 'zone', ref: '3:rg-7-1' }) + + assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: '3:rg-7-1' }), { expired: 1 }) + const row = [...store.rows.values()][0] + assert.equal(row.status, 'expired') + assert.ok(store.log.some((e) => e.kind === 'resource.expired' && e.detail.resource === 'zone:3:rg-7-1')) + + const summary = await cleanup.cleanupRun(RUN) + assert.equal(summary.attempted, 0) + assert.equal(reverts, 0) + assert.equal(row.status, 'expired') + assert.equal(store.cleanupStatus, 'complete') +}) + +test('an expiry wins over orphaned, and leaves a revert in flight alone', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'a', status: 'orphaned' }) + addResource({ kind: 'zone', ref: 'b', status: 'reverting' }) + addResource({ kind: 'zone', ref: 'c', status: 'reverted' }) + + for (const ref of ['a', 'b', 'c']) await cleanup.expireResource('demo', { kind: 'zone', ref }) + const status = (ref) => [...store.rows.values()].find((r) => r.ref === ref).status + assert.equal(status('a'), 'expired') + assert.equal(status('b'), 'reverting') + assert.equal(status('c'), 'reverted') +}) + +test('a module closes only its own rows, and only the target it named', async () => { + stubExpiry() + addResource({ owner_module: 'demo', kind: 'zone', ref: 'x' }) + addResource({ owner_module: 'other', kind: 'zone', ref: 'x' }) + addResource({ owner_module: 'demo', kind: 'npc', ref: 'x' }) + + assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }), { expired: 1 }) + const rows = [...store.rows.values()] + assert.deepEqual(rows.map((r) => r.status), ['expired', 'confirmed', 'confirmed']) +}) + +test('an expiry nobody ledgered, or a malformed one, is not an error', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'x' }) + for (const bad of [undefined, {}, { kind: 'zone' }, { ref: 'x' }, { kind: 1, ref: 'x' }, { kind: 'zone', ref: 'y' }, + { kind: 'zone', ref: 'r'.repeat(191) }]) { + assert.deepEqual(await cleanup.expireResource('demo', bad), { expired: 0 }, JSON.stringify(bad)) + } + assert.equal([...store.rows.values()][0].status, 'confirmed') + assert.equal(store.log.length, 0) +}) + +test('the last row of a finished run expiring completes an incomplete cleanup', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'x' }) + store.cleanupStatus = 'incomplete' + await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }) + assert.equal(store.cleanupStatus, 'complete') +}) + +test('a run still in flight keeps its cleanup status, and so does one with rows left', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'x' }) + store.run = { ...RUN, status: 'running' } + store.cleanupStatus = 'pending' + await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }) + assert.equal(store.cleanupStatus, 'pending') + + store.rows.clear() + addResource({ kind: 'zone', ref: 'y' }) + addResource({ kind: 'zone', ref: 'z' }) + store.run = { ...RUN } + store.cleanupStatus = 'incomplete' + await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' }) + assert.equal(store.cleanupStatus, 'incomplete') +}) diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index 6c4fbd5..3d9c496 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -38,6 +38,7 @@ const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const express = require('express') +const semver = require('../src/modules/semver') const db = require('../src/utils/db') const registries = require('../src/modules/registries') @@ -99,13 +100,19 @@ beforeEach(() => { // ── The seam is open ─────────────────────────────────────────────────────── -test('the version a module declares against is 1.10.0', () => { +test('the version a module declares against satisfies ^1.10.0', () => { // Not decoration. `coreApi: "^1.10.0"` on every module below is what makes // these tests fail loudly rather than quietly if the bump is ever reverted — // the loader would refuse the manifest and every assertion would become "the // module did not register", which is the same failure the seam closing would - // produce. Asserting the number here says which of the two it was. - assert.equal(MODULE_API_VERSION, '1.10.0') + // produce. Asserting the range here says which of the two it was. + // + // A range, not the number, since 1.11.0 (`ctx.events.expired`, Rust + // PLAN_FIXES D183): an additive bump must keep every module written against + // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the + // assertion that says it does. The number itself is pinned in version.js. + assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) + assert.equal(MODULE_API_VERSION, '1.11.0') }) test('a module registers actions, budgets, leases and option sources', () => { diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index 90b5b7b..f97c5b9 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -495,6 +495,7 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ keys: Object.keys(ctx).sort(), middleware: Object.keys(ctx.middleware).sort(), + events: Object.keys(ctx.events).sort(), moduleId: ctx.moduleId, mutable, })) @@ -530,10 +531,42 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { assert.deepEqual(probe.middleware, [ 'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate', ]) + // API 1.10.0 gave `events` its `reconcile`, and 1.11.0 its `expired` (Rust + // PLAN_FIXES D183): the game ended a ledgered resource at its own deadline. + assert.deepEqual(probe.events, ['emit', 'expired', 'reconcile']) assert.equal(probe.moduleId, 'probe') assert.equal(probe.mutable, false, 'ctx members must be frozen') }) +test("ctx.events.expired closes the calling module's rows, whatever it passes", async () => { + // The owner is bound by the loader, never read from the arguments — the same + // rule `reconcile` keeps. Without it a module could mark another module's + // resources expired and take them off the teardown list. + const cleanup = require('../src/events/cleanup') + const original = cleanup.expireResource + const calls = [] + cleanup.expireResource = async (owner, resource) => { + calls.push({ owner, resource }) + return { expired: 1 } + } + try { + writeModule('zoner', { + server: `module.exports = (ctx) => { + const result = ctx.events.expired({ kind: 'zone', ref: '3:rg-1', owner: 'someone-else', owner_module: 'x' }) + if (result !== undefined) throw new Error('expired must return undefined') + }`, + }) + assert.equal(stateOf(freshLoader(tmpRoot), 'zoner').state, 'registered') + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(calls.length, 1) + assert.equal(calls[0].owner, 'zoner') + assert.equal(calls[0].resource.kind, 'zone') + assert.equal(calls[0].resource.ref, '3:rg-1') + } finally { + cleanup.expireResource = original + } +}) + // ── Lifecycle hooks ──────────────────────────────────────────────────────── test('a lifecycle hook must be a function, and may be registered once', () => { From 224e2b4dbc17abe8db1cb9da2eda53368edb2613 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 22:19:17 -0500 Subject: [PATCH 2/2] fix(events): settle a finished run whose last resource expired while it ran Found walking Rust PLAN_FIXES step 2 (run 46 on the walk core): a zone expired while its run was still going, so expireResource left the run's cleanup status to its terminal path. When the run was cancelled the sweep never selected it, because runsNeedingCleanup joins on an unresolved row and it had none, so cleanup_status sat at `pending` for ever over an empty ledger. The sweep now settles finished runs that are `pending` with no unresolved row as `complete` (never over `incomplete`, which is a human's to clear). Checked against the walk database: the query returns run 46 and nothing else. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/src/events/cleanup.js | 10 +++++++++ .../src/model/events/eventRunResources.db.js | 22 +++++++++++++++++++ server/test/eventCleanup.test.js | 18 +++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/server/src/events/cleanup.js b/server/src/events/cleanup.js index fe886b6..97951b6 100644 --- a/server/src/events/cleanup.js +++ b/server/src/events/cleanup.js @@ -320,6 +320,16 @@ async function sweep() { // cleanup, whose counters were spent deliberately. const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS) let swept = 0 + + // **A finished run with nothing left to give back is complete** (MODULE_API + // 1.11.0). The scan above joins on an unresolved row, and until `expired` no + // run could end `pending` without one. Now a zone can expire while its run is + // still going — `expireResource` leaves a live run's status to its terminal + // path — and when that run ends, nothing in the scan would ever select it: + // `pending` for ever. Found by the step-2 walk, run 46. + for (const runId of await resourcesDb.finishedRunsWithNothingLeft(CLEANUP_RUN_BATCH)) { + if (await runsDb.setCleanupStatus(runId, 'complete', ['pending'])) swept += 1 + } for (const candidate of candidates) { if (!runsDb.TERMINAL.includes(candidate.status)) continue try { diff --git a/server/src/model/events/eventRunResources.db.js b/server/src/model/events/eventRunResources.db.js index 4e68195..354f51e 100644 --- a/server/src/model/events/eventRunResources.db.js +++ b/server/src/model/events/eventRunResources.db.js @@ -402,8 +402,30 @@ async function runsNeedingCleanup(limit = 25, maxAttempts = 3) { ) } +/** + * Finished runs still marked `pending` that have no unresolved row at all — a + * run whose last resource the game expired while it was still going (MODULE_API + * 1.11.0). The sweep settles them `complete`; `runsNeedingCleanup` cannot see + * them because it joins on an unresolved row. + */ +async function finishedRunsWithNothingLeft(limit = 25) { + const rows = await query( + `SELECT r.id FROM event_runs r + WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed') + AND r.cleanup_status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM event_run_resources res + WHERE res.run_id = r.id AND res.status IN (?, ?, ?, ?, ?)) + ORDER BY r.id + LIMIT ?`, + [...UNRESOLVED, Number(limit)], + ) + return rows.map((row) => Number(row.id)) +} + module.exports = { STEP_KIND, + finishedRunsWithNothingLeft, HELD, UNRESOLVED, reserve, diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js index 27202c7..abf6eed 100644 --- a/server/test/eventCleanup.test.js +++ b/server/test/eventCleanup.test.js @@ -101,6 +101,7 @@ beforeEach(() => { ...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)), ] resourcesDb.runsNeedingCleanup = async () => store.candidates || [] + resourcesDb.finishedRunsWithNothingLeft = async () => store.nothingLeft || [] runsDb.setCleanupStatus = async (id, to, from = null) => { if (from && !from.includes(store.cleanupStatus)) return false @@ -728,3 +729,20 @@ test('a run still in flight keeps its cleanup status, and so does one with rows await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' }) assert.equal(store.cleanupStatus, 'incomplete') }) + +test('a run whose last resource expired while it ran is settled complete when it ends', async () => { + // The step-2 walk's run 46: the zone expired while the run was still going, so + // expireResource left its status to the terminal path — and the sweep's scan, + // which joins on an unresolved row, would never have selected it. `pending` + // for ever, on a ledger with nothing left in it. + store.cleanupStatus = 'pending' + store.candidates = [] + store.nothingLeft = [RUN.id] + assert.equal(await cleanup.sweep(), 1) + assert.equal(store.cleanupStatus, 'complete') + + // And never over an incomplete one — that is a human's to clear. + store.cleanupStatus = 'incomplete' + await cleanup.sweep() + assert.equal(store.cleanupStatus, 'incomplete') +})