fix(events): settle a finished run whose last resource expired while it ran
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 20m48s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 22:19:17 -05:00
parent cc1f49af29
commit 224e2b4dbc
3 changed files with 50 additions and 0 deletions

View File

@@ -320,6 +320,16 @@ async function sweep() {
// cleanup, whose counters were spent deliberately. // cleanup, whose counters were spent deliberately.
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS) const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
let swept = 0 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) { for (const candidate of candidates) {
if (!runsDb.TERMINAL.includes(candidate.status)) continue if (!runsDb.TERMINAL.includes(candidate.status)) continue
try { try {

View File

@@ -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 = { module.exports = {
STEP_KIND, STEP_KIND,
finishedRunsWithNothingLeft,
HELD, HELD,
UNRESOLVED, UNRESOLVED,
reserve, reserve,

View File

@@ -101,6 +101,7 @@ beforeEach(() => {
...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)), ...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)),
] ]
resourcesDb.runsNeedingCleanup = async () => store.candidates || [] resourcesDb.runsNeedingCleanup = async () => store.candidates || []
resourcesDb.finishedRunsWithNothingLeft = async () => store.nothingLeft || []
runsDb.setCleanupStatus = async (id, to, from = null) => { runsDb.setCleanupStatus = async (id, to, from = null) => {
if (from && !from.includes(store.cleanupStatus)) return false 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' }) await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' })
assert.equal(store.cleanupStatus, 'incomplete') 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')
})