fix: handle whole-file JSON documents that are not objects (#176) - #178
fix: handle whole-file JSON documents that are not objects (#176)#178dmccoystephenson wants to merge 1 commit into
Conversation
…ts (#176) Six readers of a whole JSON file guarded only against json.JSONDecodeError, so a file whose top level parses as a valid array, scalar, or null crashed the reader instead of being treated as corrupt. state_manager.py is the sharpest case: the quarantine branch that moves a bad state.json aside and starts fresh was bypassed, because dict() coercion raised before the rename could run — leaving the bad file in place for every subsequent start to trip over. A top-level [] was worse still, coercing silently to {} and reporting a successful load of an empty snapshot. Each site now checks isinstance(parsed, dict) and routes a non-object document to that site's existing degradation path: quarantine for state_manager, None for doctor, skip-the-instance for the web listing, 0 for the runner's thought count. The two experiment readers whose annotations promise a mapping raise a ValueError naming the path, matching how json.JSONDecodeError already propagates from them, rather than fabricating zeroed metrics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review rubricScored adversarially against the diff and command output, not judgment.
Findings from the read-through
Anchor caveatThe test suite could not be executed locally: the only interpreter available here is Python 3.8.10 without This review was performed and posted during a Gardener session (https://github.com/Stephenson-Software/gardener). drafted by Claude on behalf of Daniel Stephenson |
Summary
Six readers of a whole JSON file guarded only against
json.JSONDecodeError, so a document whose top level parses as a valid JSON array, scalar, ornullcrashed the reader rather than being handled as corrupt. This is the whole-file companion to the JSONL-line case fixed in #177, and it is closed here the same way: parse, then checkisinstance(parsed, dict), then route a non-object document into whatever degradation path that site already has.persistence/state_manager.pyis the sharpest of the six. The recovery branch there exists precisely so a corruptstate.jsonis quarantined tostate.json.corruptand the instance starts fresh instead of failing — butdict()coercion raised before the rename could run, so the instance neither recovered nor moved the bad file aside, and every subsequent start failed identically. A top-level[]was worse still:dict([])returns{}silently, so an empty snapshot was reported as a successful load while the bad file stayed on disk.Per-site resolution, each matching the behavior that site already gives an undecodable document:
persistence/state_manager.pydict()raised before the quarantine branchstate.json.corrupt,Nonereturned, next start is cleanscripts/doctor.pyTypeError/ValueErrorescaped the guarded(OSError, JSONDecodeError)tuple and aborted the surveyNonereturned; the instance is still listed, with the placeholders a missingstate.jsonproducesinterfaces/web/server.pystate.get(...)raisedAttributeError, failing the whole/instanceslistingexperiments/runner.pyTypeError, which the guarded tuple did not catch0returned, matching the existing missing/undecodable degradationexperiments/metrics.py(load_state)AttributeErrorsurfaced later inside a metric functionValueErrornaming the pathexperiments/compare.py(_read_json)ValueErrornaming the pathThe two experiment readers were given a raise rather than an empty mapping, departing from the resolution sketched in the issue. The reasoning:
json.JSONDecodeErroralready propagates from both functions, so a non-object document is now reported the same way malformed JSON is, and the error names the offending file. Returning{}instead would have routed a badmetrics.jsonsilently into the recompute-from-journal fallback and rendered a badstate.jsonas a run with no mood at all — fabricated analysis output from a research tool, which is worse than a stopped comparison.One correction to the issue body, noted for the record: the string case coerces via
ValueError(dictionary update sequence element #0 has length 1; 2 is required), notTypeError. Both escape the guards at every affected site, so the described consequence holds.experiments/runner.py:_read_thought_countadditionally gainedTypeErrorin its inner guard, sinceint()of a well-formed object's list-valuedthought_countraises there too — the same class of defect one layer in.Related issue
Closes #176
Testing
python -m compileall core llm memory persistence interfaces scripts experiments tests— clean.python -m pytest tests/ -qcould not be executed in this environment: the only interpreter available is Python 3.8.10 with neitherpytestnorfastapiinstalled, and the project floor is 3.11 (asyncio.to_thread, used byStateManager.load, does not exist before 3.9). The test suite is therefore UNVERIFIED locally, and thetestsworkflow on this PR's head SHA is the real anchor. This PR must not be merged on a local green that was never obtained.to_threadshim) to confirm each site: all six non-object document shapes are quarantined byStateManager.load, a secondload()on the same directory then returnsNonecleanly,metrics.load_stateraises with the path named, and_read_thought_countreturns0for every unreadable shape and42for a valid one.dict()raisesTypeErroron[1, 2]/123/null/true, raisesValueErroron"a bare string", and silently yields{}on[].New coverage: 8 test functions (parametrized across the five non-object shapes, plus
[]where the silent-coercion case applies) intest_consciousness.py,test_doctor.py,test_web_server.py,test_experiment_metrics.py,test_experiment_compare.py, andtest_experiment_runner.py.Scope note
Twelve files are touched, above the ten-file soft ceiling. Six are test files; the non-test change is ~64 net LOC across six one-shape-per-site guards. The six sites were kept in one PR because they are a single defect with a single shape, and splitting them would leave the invariant half-applied.
Checklist
INDICATORS.mdupdated if this changes which Butlin et al. indicators are implemented — not applicable; this is a robustness fix with no capability shift.state_manager.pycarries an AE-1/HOT-3 theory-mapping docstring, which is unchanged and still accurate: identity persistence across run boundaries now survives a strictly larger set of corrupt snapshots.consciousness-sim/README.mdand the rootREADME.mdmake no claim about corrupt-file handling.CLAUDE.md's invariant list carries "JSONL readers skip corrupted lines with a warning rather than crashing", which remains accurate as written; adding a whole-file sibling invariant was deliberately left out, because editing agent-loaded config requires separate maintainer authorization.Backlog deferred this cycle
Recorded for auditability, per the loop's skip-reason rule:
scripts/doctor.pyandscripts/stop.py.This PR description was drafted during a Gardener session (https://github.com/Stephenson-Software/gardener).
drafted by Claude on behalf of Daniel Stephenson