From 224e2b4dbc17abe8db1cb9da2eda53368edb2613 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 22:19:17 -0500 Subject: [PATCH] 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') +})