Problem class: go-state-file-corruption-recovery-testing
The /workspace mount contained only problem.json (no target repo), so I built a self-contained runnable reproduction at /workspace/graphrepro and verified the fix there. The full deliverable is at /workspace/SOLUTION.md; reproduced below.
edges.jsonl load/reload testingProblem class: go-state-file-corruption-recovery-testing
Target: project state graph loader (<state-dir>/.vfs/graph/edges.jsonl)
Gate: go test ./... must exit 0
The failure mode behind this class is an assumption about which file is authoritative.
A chaos cell truncates a state file on disk and then asks how the program behaves on next start / reload. The natural — and wrong — reflex is:
"the biggest state file (
graph.db) got truncated, so that is what the loader chokes on."
But graph.db is the Hilo CLI / sqlite store. The project's own graph loader does not read it. The graph loader reads an append-only NDJSON file:
<state-dir>/.vfs/graph/edges.jsonl
There are three distinct defects this exposes:
graph.db while the code path under test is edges.jsonl. The test never exercises the bug.\n, half-written object). A naive json.Unmarshal of "the file" (or of the last read buffer) fails loud and the whole graph is lost. There is no defined, tested contract for a truncated tail.Reload parses into the same slice/map it already holds, a corrupt file can leave previously loaded edges in place. The process then serves stale adjacency after the corruption instead of converging on fresh state.The fix is not a clever parser. It is: grep first, then pin the defined outcome table with table-driven tests, then prove the tests are sensitive by flipping the recovery to fail-loud (RED).
Before touching anything, establish which file the code actually opens:
# Which state file does the graph code open?
grep -rn "edges.jsonl" --include='*.go' .
# Which code (if any) reads the sqlite store?
grep -rn "graph\.db" --include='*.go' .
# Sanity: the loader's open call, not a test fixture
grep -rn "os.Open\|os.ReadFile" internal/graph
Expected shape of the result:
| Path | Owner | Read by graph loader? |
|---|---|---|
.vfs/graph/edges.jsonl |
project graph | yes — this is the one |
graph.db |
Hilo CLI | no |
Only once edges.jsonl is confirmed should any test fixture be written. The rest of this document is about edges.jsonl.
This is the behavior that must be pinned by tests and stamped in the code doc comment:
| Condition | Warning? | Result |
|---|---|---|
| file missing | yes | empty graph, nil error |
| file present, zero bytes | yes | empty graph, nil error |
| healthy file | no | all edges, nil error |
final line has no \n (torn append) |
yes | parse every complete prefix line, skip tail |
| a line is not valid JSON | yes | skip that line, parse the rest |
| blank line | no | ignored |
| file exists but cannot be read (perm/IO) | n/a | nil graph, error returned |
Reload after corruption |
per above | graph replaced wholesale; never stale |
Two invariants fall straight out of the table:
(nil, err); a corrupt/unreadable file must not silently boot on top of a broken state directory.Reload swaps the whole graph — on success the old graph is discarded, so a stale edge can never survive a reload.Root note: the "unreadable" case uses
chmod 000. Undereuid == 0the kernel ignores the mode bits, so that sub-test must skip when running as root.
internal/graph/loader.go// Package graph loads the project state graph from the append-only JSONL
// file .vfs/graph/edges.jsonl and exposes crash-recovery semantics.
//
// - read: <state-dir>/.vfs/graph/edges.jsonl (newline-delimited JSON)
// - NOT read: <state-dir>/graph.db (Hilo CLI / sqlite store)
//
// Recovery contract:
//
// outcome condition result
// -------------- ------------------------------- ---------------------------
// warn + empty file missing empty graph, nil error
// warn + empty file present but zero bytes empty graph, nil error
// warn + skip trailing line without newline keep every complete line
// warn + skip line is not valid JSON keep every valid line
// error file exists but cannot be read nil graph, error
package graph
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"strings"
"sync"
)
type Edge struct {
From string `json:"from"`
To string `json:"to"`
}
type Graph struct {
Edges []Edge
}
type Warnf func(format string, args ...any)
type Loader struct {
Path string
Warnf Warnf
}
func (l *Loader) warn(format string, args ...any) {
if l.Warnf != nil {
l.Warnf(format, args...)
}
}
func (l *Loader) Load() (*Graph, error) {
data, err := os.ReadFile(l.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
l.warn("graph: %s is missing; starting from an empty graph", l.Path)
return &Graph{}, nil
}
// Permission denied, is-a-directory, I/O error, ... are NOT
// recoverable: surface them.
return nil, fmt.Errorf("graph: read %s: %w", l.Path, err)
}
if len(data) == 0 {
l.warn("graph: %s is empty; starting from an empty graph", l.Path)
return &Graph{}, nil
}
g := &Graph{}
// Split preserves one empty element after the final newline; drop it.
// If the file does not end in '\n' the last element is a torn append.
partialTail := data[len(data)-1] != '\n'
lines := strings.Split(string(data), "\n")
if partialTail {
l.warn("graph: %s has a truncated final line; skipping %d bytes",
l.Path, len(lines[len(lines)-1]))
lines = lines[:len(lines)-1]
} else {
lines = lines[:len(lines)-1]
}
for i, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
var e Edge
if err := json.Unmarshal([]byte(line), &e); err != nil {
l.warn("graph: %s:%d is not valid JSON; skipping line: %v",
l.Path, i+1, err)
continue
}
g.Edges = append(g.Edges, e)
}
return g, nil
}
// Store owns the live graph and guarantees that Reload replaces it wholesale.
type Store struct {
mu sync.RWMutex
loader *Loader
graph *Graph
}
func NewStore(path string, warn Warnf) (*Store, error) {
s := &Store{loader: &Loader{Path: path, Warnf: warn}}
if err := s.Reload(); err != nil {
return nil, err
}
return s, nil
}
// Reload parses from scratch and swaps in the new graph. On error the previous
// graph is left untouched and the error is returned.
func (s *Store) Reload() error {
g, err := s.loader.Load()
if err != nil {
return err
}
s.mu.Lock()
s.graph = g // swap, never mutate in place
s.mu.Unlock()
return nil
}
func (s *Store) Edges() []Edge {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Edge, len(s.graph.Edges))
copy(out, s.graph.Edges)
return out
}
internal/graph/loader_test.gopackage graph
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func writeFile(t *testing.T, dir string, content []byte) string {
t.Helper()
path := filepath.Join(dir, "edges.jsonl")
if content != nil {
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
return path
}
func TestLoadCorruptionOutcomes(t *testing.T) {
valid := "{\"from\":\"a\",\"to\":\"b\"}\n{\"from\":\"b\",\"to\":\"c\"}\n"
tests := []struct {
name string
content []byte // nil => file absent
wantEdges []Edge
wantWarn bool
wantErr bool
}{
{
name: "missing file warns and yields empty graph",
content: nil,
wantEdges: nil,
wantWarn: true,
},
{
name: "empty file warns and yields empty graph",
content: []byte(""),
wantEdges: nil,
wantWarn: true,
},
{
name: "healthy file parses every edge without warning",
content: []byte(valid),
wantEdges: []Edge{{From: "a", To: "b"}, {From: "b", To: "c"}},
wantWarn: false,
},
{
// Torn append: no trailing newline, JSON cut mid-object.
name: "truncated mid-line skips partial tail and keeps prefix",
content: []byte("{\"from\":\"a\",\"to\":\"b\"}\n{\"from\":\"b\",\"to\""),
wantEdges: []Edge{{From: "a", To: "b"}},
wantWarn: true,
},
{
// Corrupt line that still ends in a newline: skip it, keep going.
name: "malformed complete line is skipped and the rest loads",
content: []byte("{\"from\":\"a\",\"to\":\"b\"}\nnot-json\n{\"from\":\"c\",\"to\":\"d\"}\n"),
wantEdges: []Edge{{From: "a", To: "b"}, {From: "c", To: "d"}},
wantWarn: true,
},
{
name: "blank lines are ignored",
content: []byte("\n{\"from\":\"a\",\"to\":\"b\"}\n\n"),
wantEdges: []Edge{{From: "a", To: "b"}},
wantWarn: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, tt.content)
var warnings []string
l := &Loader{Path: path, Warnf: func(format string, args ...any) {
warnings = append(warnings, format)
}}
got, err := l.Load()
if tt.wantErr {
if err == nil {
t.Fatalf("Load() error = nil, want error")
}
if got != nil {
t.Fatalf("Load() graph = %+v, want nil on hard error", got)
}
return
}
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if got == nil {
t.Fatalf("Load() graph = nil, want non-nil (possibly empty)")
}
if !reflect.DeepEqual(got.Edges, tt.wantEdges) {
t.Fatalf("edges = %+v, want %+v", got.Edges, tt.wantEdges)
}
if (len(warnings) > 0) != tt.wantWarn {
t.Fatalf("warnings = %v, wantWarn = %v", warnings, tt.wantWarn)
}
})
}
}
func TestLoadUnreadableReturnsError(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root: chmod 000 does not make a file unreadable")
}
dir := t.TempDir()
path := writeFile(t, dir, []byte("{\"from\":\"a\",\"to\":\"b\"}\n"))
if err := os.Chmod(path, 0o000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
got, err := (&Loader{Path: path}).Load()
if err == nil {
t.Fatalf("Load() error = nil, want permission error")
}
if got != nil {
t.Fatalf("Load() graph = %+v, want nil", got)
}
}
// TestReloadConvergesOnFreshState proves a reload after corruption never
// serves stale edges.
func TestReloadConvergesOnFreshState(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, []byte("{\"from\":\"a\",\"to\":\"b\"}\n{\"from\":\"b\",\"to\":\"c\"}\n"))
store, err := NewStore(path, nil)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if got := store.Edges(); len(got) != 2 {
t.Fatalf("initial edges = %+v, want 2", got)
}
// Crash truncates the file down to a single clean record.
if err := os.WriteFile(path, []byte("{\"from\":\"z\",\"to\":\"y\"}\n"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
if err := store.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
want := []Edge{{From: "z", To: "y"}}
if got := store.Edges(); !reflect.DeepEqual(got, want) {
t.Fatalf("after reload edges = %+v, want %+v (stale state leaked)", got, want)
}
// Corrupt file with a good prefix must converge to that prefix.
if err := os.WriteFile(path, []byte("{\"from\":\"q\",\"to\":\"p\"}\n{\"from"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
if err := store.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
want = []Edge{{From: "q", To: "p"}}
if got := store.Edges(); !reflect.DeepEqual(got, want) {
t.Fatalf("after corrupt reload edges = %+v, want %+v", got, want)
}
}
func TestStoreReloadErrorLeavesOldGraph(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root: chmod 000 does not make a file unreadable")
}
dir := t.TempDir()
path := writeFile(t, dir, []byte("{\"from\":\"a\",\"to\":\"b\"}\n"))
store, err := NewStore(path, nil)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := os.Chmod(path, 0o000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
if err := store.Reload(); err == nil {
t.Fatalf("Reload() error = nil, want permission error")
}
if got := store.Edges(); len(got) != 1 {
t.Fatalf("edges after failed reload = %+v, want previous 1 edge", got)
}
}
A test that cannot fail is not evidence. Temporarily flip the two recoverable branches to fail-loud and confirm the matching sub-tests go RED.
5a. Make a malformed complete line fail loud (replace l.warn(...); continue with return nil, fmt.Errorf(...)). Expected:
--- FAIL: TestLoadCorruptionOutcomes/malformed_complete_line_is_skipped_and_the_rest_loads
Load() unexpected error: graph: .../edges.jsonl:2: invalid character 'o' in literal null
FAIL bunker/internal/graph
5b. Make the torn tail fail loud (replace the partialTail warn/skip with return nil, fmt.Errorf("...truncated final line")). Expected:
--- FAIL: TestLoadCorruptionOutcomes/truncated_mid-line_skips_partial_tail_and_keeps_prefix
Load() unexpected error: graph: .../edges.jsonl has a truncated final line
FAIL bunker/internal/graph
Restore both branches and the suite is green again. This is the RED → GREEN step.
Runnable reproduction used for this write-up: /workspace/graphrepro (module bunker, package bunker/internal/graph).
$ gofmt -l internal/graph # no output => formatted
$ go vet ./... # clean
$ go test ./... -count=1
ok bunker/internal/graph 0.006s
exit=0
Verbose test names (all PASS):
=== RUN TestLoadCorruptionOutcomes
=== RUN TestLoadCorruptionOutcomes/missing_file_warns_and_yields_empty_graph
=== RUN TestLoadCorruptionOutcomes/empty_file_warns_and_yields_empty_graph
=== RUN TestLoadCorruptionOutcomes/healthy_file_parses_every_edge_without_warning
=== RUN TestLoadCorruptionOutcomes/truncated_mid-line_skips_partial_tail_and_keeps_prefix
=== RUN TestLoadCorruptionOutcomes/malformed_complete_line_is_skipped_and_the_rest_loads
=== RUN TestLoadCorruptionOutcomes/blank_lines_are_ignored
--- PASS: TestLoadCorruptionOutcomes (0.00s)
=== RUN TestLoadUnreadableReturnsError
--- PASS: TestLoadUnreadableReturnsError (0.00s)
=== RUN TestReloadConvergesOnFreshState
--- PASS: TestReloadConvergesOnFreshState (0.00s)
=== RUN TestStoreReloadErrorLeavesOldGraph
--- PASS: TestStoreReloadErrorLeavesOldGraph (0.00s)
PASS
ok bunker/internal/graph 0.005s
Gate command to quote in evidence:
go test ./... ; echo "exit=$?" # exit=0
RED run (before restore), for the record:
# fail-loud on malformed line
go test ./... -count=1
# --- FAIL: TestLoadCorruptionOutcomes/malformed_complete_line_is_skipped_and_the_rest_loads
# exit=1
# fail-loud on torn tail
go test ./... -count=1
# --- FAIL: TestLoadCorruptionOutcomes/truncated_mid-line_skips_partial_tail_and_keeps_prefix
# exit=1
The unreadable case ran (not skipped) because this shell is euid=1000; it skips cleanly under root.
.vfs/graph/edges.jsonlis the authoritative graph state read by this loader.<state-dir>/graph.dbis the Hilo CLI / sqlite store and is not opened by the graph loader. A chaos cell that truncatesgraph.dbis not exercising this package; a chaos cell that truncates the final append ofedges.jsonlis.
Naming the excluded file is part of the fix: it stops the next person from re-deriving the wrong diagnosis and it makes the test's scope auditable.
graph.db as not read.(nil, err); skips chmod under root.Reload test proves convergence on fresh state and no stale edges.go test ./... exits 0.Artifacts
- /workspace/SOLUTION.md — this document
- /workspace/graphrepro/go.mod
- /workspace/graphrepro/internal/graph/loader.go
- /workspace/graphrepro/internal/graph/loader_test.go
Verified result: go test ./... -count=1 → ok bunker/internal/graph with exit=0; both fail-loud RED flips produced exit=1 as expected, then were reverted.
# Evidence - Problem class: go-state-file-corruption-recovery-testing - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-10T21:11:42.784Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "QA chaos cells truncate a project state file and ask for next-start crash behavior (bunker QA-BUNKER-B12/9 class). What worked: do NOT assume the truncated file is the one the code reads - grep first (bunker reads .vfs/graph/edges.jsonl; the truncated graph.db is the Hilo CLI store). Pin the DEFINED behavior with table-driven tests: truncated mid-line -> warn+skip partial+load rest; missing/empty -> warn+empty graph+nil; unreadable -> returned error (skip chmod case under root); Reload after corruption converges on fresh state, never stale. Document the outcome table + name what is NOT read. RED-proof by flipping warn-and-skip to fail-loud. Judge re-runs the full suite, so gate evidence must include go test ./... exit 0.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-state-file-corruption-recovery-testing", "provider": "openrouter", "solved_at": "2026-10-10T21:11:42.784Z", "version": ""}