fix(core): the last fixes' core half (W1 W2 W5 W19)
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's 2026-10-22 decisions. W19: one module per site at boot, as at install. The first module directory by name holds the site, whether or not it then loads; every other is recorded startup_failed at the manifest stage, before a line of its code is required, with a reason naming the module that holds the site. Module-uo and Module-Rust side by side used to fail the second with a slot collision. Loader, lifecycle, schema and event-contract tests that loaded several fixture modules now load each case alone. W1: a reconcile files a resource the game no longer has, and that is past its `until`, as expired rather than orphaned, with the same log line expired() writes. No MODULE_API bump: `until` and reconcile's answer are unchanged. W5: a dry run that passes on the draft is kept with the fingerprint of the validated spec (three nullable columns on event_definitions). Publish stamps the new version verified, with that dry run's reviewer and time, when the spec it freezes has the same fingerprint, and writes version.verified on the occurrences it re-pins. Verify answers draftRecorded and publish answers verified; OpenAPI regenerated. W2: the run console says "Phase p1: steps done, waiting to advance" and "Why this phase has not advanced", not "has not started". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -357,13 +357,16 @@ async function sweep() {
|
||||
* Reverting it would be core recording that it put something back when what
|
||||
* actually happened is that the thing vanished while nobody was looking, and the
|
||||
* two are different sentences to the operator reading the console afterwards.
|
||||
* One gone after its `until` is neither: the game ended it on time, so it is
|
||||
* `expired`, the line `expired()` would have written (W1).
|
||||
*
|
||||
* A module with no `reconcile()` on the action is not broken: core keeps
|
||||
* believing its own ledger, which is precisely the behaviour before this phase.
|
||||
*/
|
||||
async function reconcileModule(owner) {
|
||||
async function reconcileModule(owner, now = new Date()) {
|
||||
const rows = await resourcesDb.liveForModule(owner)
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 }
|
||||
const settled = new Set()
|
||||
if (!rows.length) return summary
|
||||
|
||||
const byStep = new Map()
|
||||
@@ -411,6 +414,19 @@ async function reconcileModule(owner) {
|
||||
summary.inForce += 1
|
||||
continue
|
||||
}
|
||||
// **Gone after its deadline is the deadline working** (W1, Rust
|
||||
// PLAN_LAST_FIXES). A resource that carries `until` is one the game ends
|
||||
// by itself, and on a reconnect the module's reconcile can run before the
|
||||
// game's backlog says so: the run log read `resource.orphaned` five seconds
|
||||
// before `resource.expired`. Past its `until`, gone is expired. Before it,
|
||||
// gone is still nobody-was-looking, which is what orphaned means.
|
||||
if (row.lease_until && new Date(row.lease_until).getTime() <= now.getTime()) {
|
||||
if (await settleExpired(owner, row)) {
|
||||
summary.expired += 1
|
||||
settled.add(row.run_id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
|
||||
summary.orphaned += 1
|
||||
await logDb.write({
|
||||
@@ -420,9 +436,42 @@ async function reconcileModule(owner) {
|
||||
})
|
||||
}
|
||||
}
|
||||
await closeSettledRuns(settled)
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* One row the game ended at its deadline: `expired`, and its log line. The same
|
||||
* line whichever way core learned it, from the module's `expired()` or from a
|
||||
* reconcile that found it gone past its `until`, so the console reads one way.
|
||||
*/
|
||||
async function settleExpired(owner, row) {
|
||||
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) return false
|
||||
await logDb.write({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.expired',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished run whose last unresolved row just expired 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.
|
||||
*/
|
||||
async function closeSettledRuns(runIds) {
|
||||
for (const runId of runIds) {
|
||||
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'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask every module that owns a live row. Core's own boot-time sweep. */
|
||||
async function reconcileAll() {
|
||||
const owners = await resourcesDb.modulesWithLiveRows()
|
||||
@@ -469,28 +518,11 @@ async function expireResource(owner, { kind, ref } = {}) {
|
||||
let expired = 0
|
||||
|
||||
for (const row of rows) {
|
||||
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue
|
||||
if (!(await settleExpired(owner, row))) 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'])
|
||||
}
|
||||
}
|
||||
await closeSettledRuns(runs)
|
||||
|
||||
return { expired }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
||||
// computes there.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
|
||||
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
|
||||
@@ -730,6 +732,31 @@ function publishable(spec) {
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
|
||||
/** JSON with every object's keys sorted, so equal specs serialise equally. */
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null'
|
||||
}
|
||||
|
||||
/**
|
||||
* A spec's fingerprint: the sha256 of its canonical JSON (W5, Rust
|
||||
* PLAN_LAST_FIXES).
|
||||
*
|
||||
* What lets a dry run that passed on a draft count for the version published
|
||||
* from it. The draft is fingerprinted when the dry run passes and the version
|
||||
* when it is published, both from `validate`'s output, so an edit in between is
|
||||
* a different fingerprint and nothing carries over. Key order is not part of it:
|
||||
* a spec that round-trips through the database with its keys reordered is the
|
||||
* same plan.
|
||||
*/
|
||||
function fingerprint(spec) {
|
||||
return crypto.createHash('sha256').update(canonical(spec)).digest('hex')
|
||||
}
|
||||
|
||||
/** An empty, valid spec — what a newly created draft carries. */
|
||||
const emptySpec = () => ({
|
||||
schedule: { kind: 'manual' },
|
||||
@@ -742,6 +769,7 @@ module.exports = {
|
||||
// 1.13.0): eventDefinitions.model `buildFromTemplate`.
|
||||
checkParams,
|
||||
publishable,
|
||||
fingerprint,
|
||||
actionIdsIn,
|
||||
emptySpec,
|
||||
defaultOnFailure,
|
||||
|
||||
Reference in New Issue
Block a user