Skip to content

github-backup(state): plan.json, state.json and MANIFEST.json are written non-atomically and loaded with bare json.load — one torn write wedges the daily fetch, the drainer and manifest regeneration #3240

Open

Description

@dezren39

Problem

Every generated-state file in the github-backup system is written by truncating the live file and dumping into it, and every reader loads those files with a bare json.load that raises on a torn file. There is no temp-file + rename, no fsync, and no backup copy anywhere in infra/github-backup/bin/ (no os.replace/os.rename/fsync in any of the five scripts).

Writers (all open(path, "w") + json.dump on the live path):

  • reconcile.py:39 — plan.json (the weekly sweep's output, consumed by everything else)
  • fetch_mirrors.py:69 — state.json (fetch timestamps / failure history)
  • drain_queue.py:74 — state.json again (the 5-minute webhook drainer shares the file with the daily fetch)
  • manifest.py:67 — MANIFEST.json, the file the runbook calls the disaster-recovery source of truth

Readers that crash on a torn file:

  • fetch_mirrors.py:51 — json.load(open(state_path)) at startup: a torn state.json kills the daily fetch before it fetches anything.
  • drain_queue.py:44 and :64 — the drainer loads state.json and (per event) plan.json the same way: one torn write wedges the 5-minute freshness loop too.
  • clone_all.py:24 — json.load(open(plan.json)) at startup: a torn plan.json kills the clone pass.
  • manifest.py:31 — json.load(open(plan_path)) (the os.path.exists guard does not help): a torn plan.json also kills manifest regeneration, so the DR record stops updating.

The writers and readers run on timers on a laptop that sleeps, reboots, and loses power — the same interruption class as #3233/#3238. A crash, kill, or sleep during any of these writes (they grow with the estate; plan.json/state.json cover every mirror) leaves a truncated JSON file, and from then on every subsequent run of the affected scripts dies at startup with a JSONDecodeError traceback until a human repairs the file by hand. Nothing distinguishes "corrupt state" from any other crash, and the file holds data that cannot be regenerated from the tree alone (state.json's last_fetch coalescing timestamps and fail_days chronic-failure history).

Evidence (verified this run, main 357ec7f)

Laptop probe ~/git/fleet/probe-2026-10-31-laneA-validmirror/ (on the laptop), driving the real scripts (sha256 f0115270… fetch_mirrors.py, 59a0abd6… clone_all.py, a489798e… manifest.py — identical in the sandbox clone, the laptop clone, and the probe copies), with backup_root pointed at a scratch tree:

  1. Torn state.json (truncated mid-object, exactly what a killed json.dump leaves) → fetch_mirrors.main() crashes: JSONDecodeError: Expecting ',' delimiter: line 1 column 40. The daily fetch never runs.
  2. Torn plan.json → clone_all.main() crashes: JSONDecodeError: Unterminated string starting at: line 1 column 11.
  3. Torn plan.json → manifest.build() crashes with the same JSONDecodeError — MANIFEST.json regeneration is wedged by the same file.
  4. Control — valid plan.json + state.json: fetch_mirrors.main() runs clean ({"results": {"ok": 0, "fail": 0, "skip": 0, "missing": 1}, "chronic_failures": []}, return 0) and regenerates MANIFEST.json/FAMILIES.md.

Why it matters

This is a backup system: its state files are part of the product, and its whole purpose is surviving interruptions. Today a single ill-timed laptop sleep during a state write silently converts into a persistent outage of the daily fetch, the webhook drainer, and the DR manifest — the failure surfaces only as a traceback in a timer log, and recovery is manual file surgery on files whose history (fetch coalescing, chronic-failure tracking) is lost in the repair. Atomic replace (write temp file in the same directory, fsync, os.replace) is a few lines per writer and makes a torn live file impossible; tolerant loading (keep a last-good copy, or treat a torn file as empty-with-a-loud-warning) is the complementary half.

Acceptance criteria

  • All generated-state writes (plan.json, state.json, MANIFEST.json, and the markdown reports if in scope) are atomic: write to a temp file in the same directory, fsync, then os.replace — a reader never observes a partial file.
  • Readers survive a pre-existing torn file instead of dying at startup: on JSONDecodeError they fall back (empty state / last-good copy) and surface a loud, named warning (log line + non-zero exit or report section), so the corruption is visible rather than silent.
  • The shared state.json writers (fetch_mirrors daily + drain_queue every 5 min) cannot interleave into a corrupt file under the atomic scheme (replace is per-writer atomic; any remaining lost-update window between the two writers is either closed or explicitly documented).
  • Regression tests: (a) torn state.json → fetch run proceeds with fallback + warning (fails on current code with JSONDecodeError), (b) torn plan.json → clone pass and manifest build proceed likewise, (c) a writer killed mid-dump leaves the previous complete file intact, (d) valid files → behaviour unchanged (control passes before and after).

Dedup (this run): title/keyword searches for github-backup + atomic/torn/state.json/plan.json return no covering issue. Related but distinct: #3233 (partial clone destination wedge), #3238 (valid_mirror false positive), #3229 (inventory null owner), #3219 (drain ack path), #3215 (umbrella estate issue).

Activity

  1. added
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    app/github-backupinfra/github-backup estate mirror system
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    effort/sSmall: expected under 2 hours of work
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Design-architect pass 29 (2026-10-31) — agent note: this issue is ADOPTED as a phase of design #3282 ("a mirror is what it can prove — one validity, outcome and alarm contract for the github-backup estate mirror", design doc: files/designs/code-github-backup-mirror-contract.md in the keep-the-four-repos-healthy goal workspace), linked as a sub-issue of #3282 and labelled source/design-architect. Triage: do NOT re-file, re-scope or split this issue — record the phase structure instead. Phase mapping: Phase 1 (validity & destinations) = #3238 + #3233; Phase 2 (recorded outcomes & the alarm) = #3250 + #3219 + #3270 + #3257; Phase 3 (durable state & plan retention) = #3240 + #3229 + closing audit #3283 (gated on Phases 1–2). The design records the pins that span the phases: validity = bare + HEAD resolves, with no emptiness exception inside the plan (build_plan excludes zero-commit repos); destinations are absent / valid / skeleton (remove + clone) / debris (quarantine to .partial-<timestamp> + clone); every fetch attempt ends in one recorded outcome shared by the daily loop and the drainer, and the drainer acks only on ok; the chronic alarm is a streak of 3 consecutive unavailable days (fail, timeout and missing all count); generated state is written atomically only through ghb_common helpers; reconcile retains a failed owner's previously planned entries. Where an origin's acceptance left a choice open (the streak-vs-window rule, missing-counts-vs-immediate, report-vs-retain, skeleton handling), the design doc records the decision — triage follows the design's pin, not the open alternative. Each origin keeps its own acceptance criteria and its already-queued triage packet — no new packet was created for it this pass.

  4. removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  5. 27 remaining items

  6. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — holder: repo-worker-code (OpenChamber scheduled lane), at=2026-11-02T15:00:35Z. event: parked; task: 5683; queue: repo-health-code-pilot. Per the packet hard gate, #3238 and #3270 remain OPEN; the other prerequisites are CLOSED. Parked for 24 hours with no code/worktree changes. Resume only after both open prerequisites close and fresh validation confirms the full gate.

  7. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    queue/queuedHas a queued task in the Supabase agent queue
    and removed
    queue/claimedQueue worker lifecycle state
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 2, 2026
  8. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  9. added
    queue/queuedHas a queued task in the Supabase agent queue
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Nov 3, 2026
  10. developing-today commented on Nov 3, 2026

    @developing-today
    Contributor

    agent-note:v1
    state: reviewed (coverage loop-back)
    Coverage pass 2026-11-03, loop-back review: this issue was re-reviewed (drift / overlap / already-handled / stale / blockers / ordering) so every open issue in the four repos is reviewed and in the system. Verdict: still live; in the system as task 5683 (small_fix, parked, priority 40) in repo-health-code-pilot. Labels audited and synced. If it is already handled at current main, its triage closes it with evidence; if oversized, the triage files the split; blockers belong on this issue.
    Found via: coverage loop-back 2026-11-03

  11. removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 4, 2026
  12. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    OpenCode claimant launched the bound worker session ses_e5e5824dbc1ffe04VFG3rR8OpF for queue task 5683. The worker will use --worker repo-worker-code and finish task 5683 with execution lane=opencode and this session id.

  13. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: parked
    task: 5683
    queue: repo-health-code-pilot
    worker: repo-worker-code
    execution: lane=opencode session_id=ses_e5e5824dbc1ffe04VFG3rR8OpF
    verified_at: 2026-11-04T18:42Z

    Fresh Phase 1/2 gate check for #3240: Phase 1 — #3238 OPEN, #3233 CLOSED. Phase 2 — #3250 CLOSED, #3219 CLOSED, #3270 OPEN, #3257 CLOSED. Per the task's hard gate, parked 24h with no source edits or task worktree changes. Resume only after #3238 and #3270 are both CLOSED and the full gate is freshly revalidated.
    Links: #3240 · #3238 · #3270

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/github-backupinfra/github-backup estate mirror systemeffort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)queue/parkedQueue task is parked (waiting on CI, time, or an event)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobstatus/parkedDeliberately paused with a recorded resume conditiontask/triageQueue packet kind: verify, reproduce, and route this itemwork/assignedQueue worker lifecycle state

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions