Skip to content

Commit 35c9ddb

Browse files
fix(runs): saveCurrentRun persists the RESERVED run number (no 4-store split)
CRITICAL from the bug hunt: saveCurrentRun re-derived the run number via getNextRunNumber() (= max(index.runs)+1) instead of using the number startFreshRun already reserved as state.activeRunNumber. A concurrent delete/save that shifted max(index.runs) made the snapshot dir, index entry, SQLite row, and state.activeRunNumber split across two numbers (one run → two identities). Root-cause fix: - Persist into state.activeRunNumber (fall back to getNextRunNumber only when no run is reserved, e.g. the first-boot "Initial Audit" save). - Index write is now find-or-update on that number: re-saving the same run updates its entry in place instead of pushing a duplicate. One run = one number = one dir = one entry. - Add a busy guard to POST /api/runs/save (block only status==='running' — saving during a pause stays allowed, matching the SAVE button which hides only while running) so a manual save can't race the live results array. Adversarially reviewed: the two high-risk paths (re-saving a loaded run, spend regression in the dbRunId-mismatch case) are non-reachable — C-1(b) fires first when dbRunId matches, and after a load live spend equals stored spend. Strictly safer than the prior duplicate-push. Test suite green (188/31/45/35).
1 parent be64d79 commit 35c9ddb

1 file changed

Lines changed: 24 additions & 11 deletions

File tree

server.js

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,14 @@ function saveCurrentRun(label) {
621621
}
622622
}
623623

624-
const num = getNextRunNumber();
624+
// Persist into the run's RESERVED number (startFreshRun pinned it as
625+
// state.activeRunNumber) — NOT a freshly re-derived getNextRunNumber(). One run
626+
// = one number = one dir = one index entry. Re-deriving here let a concurrent
627+
// delete/save shift max(index.runs) so the snapshot dir, index entry, SQLite
628+
// row, and state.activeRunNumber ended up split across two numbers. Fall back
629+
// to getNextRunNumber only when no run is reserved (e.g. the first-boot
630+
// "Initial Audit" save before activeRunNumber is resolved).
631+
const num = state.activeRunNumber != null ? state.activeRunNumber : getNextRunNumber();
625632
const runDir = path.join(RUNS_DIR, `test-${String(num).padStart(3, '0')}`);
626633
_mkd(runDir, { recursive: true });
627634

@@ -658,9 +665,13 @@ function saveCurrentRun(label) {
658665
try { _cp(state.auditLogPath, path.join(runDir, 'audit.log')); } catch { }
659666
}
660667

661-
// Update index
668+
// Update index — find-or-update on the reserved number so re-saving the SAME
669+
// run updates its entry in place instead of pushing a duplicate (one run = one
670+
// entry). spentUdvpn/dbRunId persist the run's net spend + SQLite id so loading
671+
// it later restores Net Spend deterministically; auditLog lets delete purge the
672+
// raw log.
662673
const index = loadRunsIndex();
663-
index.runs.push({
674+
const entryData = {
664675
number: num,
665676
label: label || 'Full Audit',
666677
date: new Date().toISOString(),
@@ -669,17 +680,13 @@ function saveCurrentRun(label) {
669680
failed: failed.length,
670681
pass10: pass10.length,
671682
sdk: state.activeSDK,
672-
// Persist this run's net spend (raw udvpn) so loading it later can
673-
// restore the header's Net Spend. rehydrateState only recomputes
674-
// per-node counts, so without this it resets to 0 / -- on load.
675683
spentUdvpn: Number(state.spentUdvpn) || 0,
676-
// SQLite runs.id for this run, so loading it later can look up spend
677-
// deterministically via getRun(dbRunId) instead of the getRunSpendByFinish
678-
// time+count heuristic.
679684
dbRunId: state.activeDbRunId || null,
680-
// Raw execution-log filename, so deleting this run also purges its log.
681685
auditLog: state.auditLogPath ? path.basename(state.auditLogPath) : null,
682-
});
686+
};
687+
const _existingIdx = index.runs.findIndex(r => r.number === num);
688+
if (_existingIdx !== -1) index.runs[_existingIdx] = entryData;
689+
else index.runs.push(entryData);
683690
index.activeRun = num;
684691
saveRunsIndex(index);
685692

@@ -2894,6 +2901,12 @@ app.get('/api/runs', adminOnly, (req, res) => {
28942901
});
28952902

28962903
app.post('/api/runs/save', adminOnly, (req, res) => {
2904+
// Don't snapshot while the pipeline is actively writing rows — that races the
2905+
// live results array and can persist a half-written run. (A paused run is fine
2906+
// to save; the SAVE button is shown during pause but hidden while running.)
2907+
if (state.status === 'running') {
2908+
return res.status(409).json({ error: 'RUN_ACTIVE', message: 'Stop or wait for the run before saving.' });
2909+
}
28972910
const label = req.body?.label || '';
28982911
const num = saveCurrentRun(label);
28992912
if (num) {

0 commit comments

Comments
 (0)