Skip to content

runs: every failure and refusal becomes a run row or a standing state#174

Merged
mbertschler merged 24 commits into
mainfrom
claude/issue-157-run-rows-standing-states
Jul 25, 2026
Merged

runs: every failure and refusal becomes a run row or a standing state#174
mbertschler merged 24 commits into
mainfrom
claude/issue-157-run-rows-standing-states

Conversation

@mbertschler

Copy link
Copy Markdown
Owner

Summary

The audit trail is the trust contract, but several failure classes never reached it. This makes each one a visible run row or a standing state — the audit-trail foundation for #157 (F6, F14, F15, F26, F30).

New schema version: v25.

Changes

Schema (store/migrations.go, migrateV24ToV25)

  • runs.status CHECK widens to admit two terminal states: refused (a preflight safety gate declined before the transfer began) and aborted (a running row reaped after the process that owned it died). Reuses the FK-off rebuild recipe (runs is referenced by files, hook_runs, packs, remote_objects, remote_packs, runs_audit).
  • New destination_alarms table (STRICT, per the new-table convention): the per-destination standing-alarm latch.
  • Snapshot regenerated via go test ./store -update-schema; TestSchemaSnapshot passes. (Three minimal destination_run_ids migration fixtures gained a runs table, which the v25 rebuild now reads — a real DB at those versions always has it.)

F6/F15 — rclone failures were undiagnosable

  • Capture rclone's non-JSON stderr (bounded to 4 KiB) into RunResult.Stderr, and broaden JSON error capture to fatal/critical levels (a fatal backend/auth failure is logged at fatal, which the old error-only filter dropped — leaving the run row's ERROR column blank).
  • Fold the stderr tail into the returned error, so the scheduler log and the run row carry the actual reason (auth, host key, path) instead of a bare exit status 1; synthesise a FailedFile so runs.error is never blank on a fatal failure. (kopia already recorded its full message — this matches it.)
  • RunResult.DisplayErrors() fixes the errors=0-on-status=failed summary-line contradiction.

F26 — preflight refusals mint no run row

  • Marker, kopia-connect, and layout-guard refusals now write a terminal refused run (via a new sync.ErrRefused sentinel) instead of vanishing into a run_id=0 error. A month-dead backup disk now produces visible red.
  • A refused run consumes the cadence window (LatestFinishedRun counts it), so the scheduler backs off a dead destination rather than re-minting an identical refusal every tick.

F14 — killed agents leave phantom running runs

  • Agent startup reaps its own orphaned running runs to aborted (store.AbortRunningRuns), loudly logged. aborted is excluded from LatestFinishedRun so a crashed run is re-attempted, not treated as a finished cadence window.

F30 — verify mismatches ring once then carry on

  • A verify mismatch latches a per-destination alarm (destination_alarms) surfaced in squirrel runs, the TUI dashboard, and squirrel verify output until cleared.
  • Both clear paths implemented: an auto-clear on a subsequent clean squirrel verify, and an explicit squirrel verify ack <dest>. Every raise and clear is recorded in runs_audit (tagged with the operator on ack), so deleting the derived latch row loses no forensic history — the raise/clear trail lives in runs/runs_audit, mirroring how peer_sync_state keeps a live overwrite-in-place row with history elsewhere.

Alarm-clear semantics (please confirm)

  • The alarm latch is derived standing state, cleared in place (DELETE on clear) with the raise/clear recorded in the append-only runs_audit. This keeps it faithful to the no-loss invariant without an extra history table.
  • Raising is idempotent — a repeated mismatch keeps the original "in alarm since" timestamp and run.
  • Deliberately out of scope: blocking pushes to an alarmed destination. F30 asks for the latch to be surfaced until cleared; fail-closing subsequent syncs on a latched alarm is a larger behavioural change (and risks freezing a household's backups on a false positive) — the loud, standing surfacing is the first-class fix here, and enforcement can stack on this foundation.

Testing

  • go vet ./... — clean
  • go test ./... — all pass (new tests: alarm lifecycle, reaper + cadence-scope, rclone stderr/fatal capture + DisplayErrors, marker refusal mints a refused run, verify mismatch latch → auto-clear)
  • golangci-lint run (v2.12.2) — 0 issues

Closes #157

🤖 Generated with Claude Code

https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7


Generated by Claude Code

…#157)

The audit trail is the trust contract, but several failure classes never
reached it. This makes each one visible.

Schema v24→v25 (store/migrations.go):
- runs.status widens to admit 'refused' (a preflight safety gate declined
  before the transfer began) and 'aborted' (a 'running' row reaped after
  the owning process died). Both terminal; neither a success.
- new STRICT destination_alarms table: the per-destination standing-alarm
  latch. Snapshot regenerated (go test ./store -update-schema).

F6/F15 — rclone failures were undiagnosable: capture rclone's non-JSON
stderr (bounded, 4 KiB) plus 'fatal'/'critical' JSON levels, fold the tail
into the returned error (so the scheduler log and run row carry the real
reason), and synthesise a FailedFile so the run row's error is never blank.
RunResult.DisplayErrors() fixes the errors=0-on-status=failed summary
contradiction.

F26 — preflight refusals (marker, kopia-connect, layout guard) now mint a
terminal 'refused' run row via ErrRefused instead of vanishing into a
run_id=0 error. A refused run consumes the cadence window (LatestFinishedRun
counts it) so the scheduler backs off a dead destination instead of
re-minting an identical refusal every tick.

F14 — agent startup reaps its own orphaned 'running' runs to 'aborted'
(store.AbortRunningRuns), loudly logged; aborted runs are excluded from
LatestFinishedRun so a crashed run is re-attempted.

F30 — a verify mismatch latches a per-destination alarm (destination_alarms)
that survives in `squirrel runs`, the TUI dashboard, and verify output until
cleared. Both clear paths implemented: an auto-clear on a subsequent clean
`squirrel verify`, and an explicit `squirrel verify ack <dest>`. Every raise
and clear is recorded in runs_audit, so deleting the derived latch loses no
history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Copilot AI review requested due to automatic review settings July 24, 2026 11:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR strengthens Squirrel’s audit-trail guarantees for runs by ensuring previously “silent” failure/refusal modes are persisted as terminal run rows and by introducing a per-destination standing alarm latch for verify mismatches. It expands run status semantics (refused, aborted), improves diagnosability for rclone failures (capturing non-JSON stderr and fatal/critical JSON levels), and surfaces standing alarms across CLI and TUI.

Changes:

  • Extend run lifecycle tracking: add refused and aborted terminal run statuses; reap orphaned running runs to aborted at agent startup; count refused (but not aborted) in cadence “finished run” logic.
  • Add standing verify alarm latch: new destination_alarms table with raise/clear recorded via runs_audit, with both auto-clear and explicit squirrel verify ack <dest> paths.
  • Improve rclone failure evidence: capture bounded non-JSON stderr + broaden error-level capture; ensure failed runs don’t report errors=0 via RunResult.DisplayErrors().

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tui/styles.go Color mapping updated to represent new run terminal statuses in the dashboard.
tui/format.go Glyph mapping updated for refused and aborted statuses.
tui/dashboard.go Dashboard now loads and renders standing destination alarms near the top.
sync/verify_remote.go Verify run finalization now raises/clears destination alarms based on outcome.
sync/verify_remote_test.go Adds coverage for alarm latch lifecycle via verify runs.
sync/sync.go Introduces refusal sentinel + refusal run recording; improves rclone fatal error evidence handling.
sync/sync_test.go Asserts marker refusals wrap ErrRefused and mint a refused run row.
sync/rclone.go Captures non-JSON stderr, broadens JSON error levels, adds DisplayErrors(), refactors stats handling.
sync/rclone_test.go Adds tests for fatal/raw stderr capture, stderr bounding, and DisplayErrors().
sync/packed.go Layout guard refusal now wraps ErrRefused.
sync/packed_test.go Updates expectations for layout-guard refusals to be refused.
sync/kopia.go Connect-without-init refusal now wraps ErrRefused.
sync/kopia_test.go Updates expectations for kopia connect refusal to be refused.
sync/handler.go Ensures handler-run finish logic derives terminal status via ErrRefused sentinel.
sync/content_addressed.go Layout guard refusal now wraps ErrRefused.
sync/content_addressed_test.go Updates expectations for layout-guard refusals to be refused.
store/schema.sql Regenerated schema snapshot for v25 including destination_alarms and widened runs.status CHECK.
store/runs.go Adds new statuses, updates terminal-status logic, cadence selection logic, and adds AbortRunningRuns().
store/runs_audit.go Adds new audit transition constants for abort and alarm raise/clear.
store/migrations.go Adds v25 migration: rebuild runs table to widen status CHECK; create destination_alarms.
store/destination_run_ids_test.go Updates minimal migration fixtures to include runs table needed by v25 rebuild.
store/destination_alarms.go Implements destination alarm latch CRUD and audit recording.
store/destination_alarms_test.go Adds tests for alarm lifecycle and audit trail behavior.
store/abort_runs_test.go Adds tests for aborting orphaned runs and cadence finished-run scope.
cmd/squirrel/verify.go Surfaces alarm transitions in verify output; wires in verify ack subcommand.
cmd/squirrel/verify_ack.go New command to explicitly clear a standing verify alarm with operator attribution.
cmd/squirrel/sync.go Uses DisplayErrors() in sync summary output to avoid errors=0-on-failure contradiction.
cmd/squirrel/runs.go Prints active standing alarms below the runs listing.
cmd/squirrel/agent.go Reaps orphaned running runs to aborted at startup and logs loudly.
Comments suppressed due to low confidence (1)

sync/rclone.go:359

  • rcloneExitError() and its doc refer to a captured stderr tail, but stderr capture is currently a bounded prefix (see appendStderr and tests). Renaming the parameter/local variable and updating the comment would keep terminology consistent and reduce confusion when debugging failures.
// rcloneExitError wraps a non-zero rclone exit, folding in the captured
// stderr tail when present so the scheduler log and the run row carry the
// actual reason (auth, host key, path) rather than a bare "exit status 1"
// (#157, F6/F15).
func rcloneExitError(waitErr error, stderrTail string) error {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sync/rclone.go
Comment thread sync/sync.go
Comment thread sync/verify_remote.go Outdated
claude added 2 commits July 24, 2026 11:32
FIX 1 (TOCTOU): RaiseDestinationAlarm now returns whether it actually
created the latch, derived from the atomic INSERT ... ON CONFLICT DO
NOTHING RowsAffected. applyVerifyAlarm keys rep.AlarmRaised off that
return and no longer does a separate GetDestinationAlarm pre-read, so
concurrent verify runs can't both report "newly raised". The lifecycle
test now asserts the first raise reports raised=true and the idempotent
second raise reports false.

FIX 2 (stderr tail): appendStderr now retains the LAST maxStderrCapture
bytes instead of the first, matching the "tail" wording already used on
RunResult.Stderr, rcloneExitError, and the runRcloneOperation comment —
rclone prints its fatal line last, so the tail is the diagnostic slice.
The bound test now feeds many distinct lines and asserts the final line
survives while an early one is trimmed.

go vet, go test ./..., gofmt -l, and golangci-lint all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
…nel (#159)

The two standing questions no surface could answer — "is every configured
target caught up?" (F16) and "what is durable where, and what could I
offload?" (F17/F23) — now have one answer, built once and shown two ways.

New `status` package: a read-only query layer that produces, per configured
volume and per (volume × target), the facts both surfaces render:

- last successful sync + age, coloured against THAT pair's own sync_every
  (late is relative to the pair's cadence, not a global constant);
- the #157 standing states — alarm (latched verify mismatch, red), refused
  (a gate that regressed from a prior success, red), and needs-bootstrap (a
  first-use `--init` refusal on a pair that never succeeded, amber);
- durability: whether the target's vector covers this node's local content
  origin, the verify method behind it, and evidence age vs
  offload_max_evidence_age (stale flagged);
- offload readiness: bytes/files currently passing the gate per volume
  ("N GB offloadable now" — the F17 decision-support without the side
  effects of `offload --dry-run`).

Each cell carries a Level (neutral/ok/amber/red); the report's worst level
is the single "am I safe?" answer.

Step 1 — `squirrel status [volume]`: renders the grid and exits with the
worst level (0 green, 1 amber, 2 red) so the same command scripts a health
check. A read-only *question* per ux-principle 2; it mutates nothing.

Step 2 — the TUI dashboard's top panel is now this grid (replacing the
single LAST SYNC cell that hid a week-behind target behind a fresh check),
built from the SAME query layer, with the STATE and DURABLE cells coloured.

Offload readiness reuses the real gate (offload.Readiness shares loadGate +
selectCandidates + gate.check), so the totals match what an offload would
move; it reads only the index — no run, no disk, no marker check.

No schema change: this is a query/display layer over existing tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
claude added 4 commits July 24, 2026 11:56
…ng-states' into claude/issue-159-status-command
Merging #157's latest brought its TOCTOU follow-up, which changed
store.RaiseDestinationAlarm to return (raised bool, err error). The alarm
Build test called the old single-return form; discard the raised bool it
does not assert on. No production code called RaiseDestinationAlarm, so the
merge is the only other change. go vet, go test ./..., and golangci-lint
(2.12.2) all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
… edges (#158)

Divergent edits of the same path on two peers used to ping-pong forever:
every cadence tick re-asserted and minted a fresh .squirrel-conflicts/run-N/
copy, the conflict store grew unboundedly, and no human was told (F27).

A conflict now raises a contested_paths latch (schema v26, STRICT). While it
stands the /plan classifier answers a divergent re-assertion from any peer
with a new `contested` disposition — version-gated (ProtocolVersionContested)
so older peers fall back to the legacy `conflict` — instead of minting
another copy. The flip-flop stops at the first conflict; both versions stay
preserved (winner live, loser under .squirrel-conflicts/). The frozen winner
re-asserting its own bytes is still allowed through.

Both initiators learn of it on their own machines, not just the hub: conflict
and contested counts flow into SyncRunReport, the initiators' run rows (the
CONFLICTS column), a scheduler.conflict warn line, and a "Contested paths"
TUI badge. Every node mirrors the freeze into its own contested_paths latch.

New question/change command pair: `squirrel conflicts` lists frozen paths
(versions, preserved location, when, who); `squirrel conflicts resolve
<volume> <path>` clears the latch so syncs flow again — the explicit human
act (squirrel never resolves on its own). Resolve keeps the live version and
points at the preserved copy; adopting the other version stays a deliberate
restore, never a silent content move.

Schema v26: migrateV25ToV26 adds the contested_paths table; snapshot
regenerated. Closes F27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Small, independently-landable output fixes surfaced by the reference-setup
walk. No behavioural change to what gets synced or stored — reporting only.

F7 — sync summaries report already_correct so an in-sync no-op is
distinguishable from an empty one (transferred=0 alone is ambiguous). For
rclone bucket pushes and restores it is rclone's already-matching count;
for peer syncs the handler derives it as present-total minus the paths the
sync acted on (the Merkle walk never sends identical folders to /plan).

F8 — a first index of an empty tree warns ("no files found — new volume,
empty directory, or wrong mount?"); a wrong mount otherwise looks identical
to a healthy no-op. index.Report.SawFiles() carries the decision.

F11a — the index summary names the volume, so back-to-back runs are legible
instead of three anonymous count lines.

F11b — a kopia-only sync skips the rclone preamble; "rclone.conf updated"
no longer prints when kopia never touches rclone.

F11c — kopia's ANSI escape sequences are stripped before its stderr tail is
folded into an error, so they don't leak into the structured log.

F11d — the per-pair summary line is suppressed when a run never reached a
terminal state (an error before its runs row was allocated), instead of
printing "status= … run=0". Refused rows minted by #157 still print.

F18 — peer-sync rows label direction (→ outbound / ← inbound) in `runs` and
the TUI. Only the initiator records runs.shallow, so a set flag marks the
pushing side; no schema change.

F19 — `runs --failed` and `runs --changes` filters; TUI folds consecutive
no-op rows behind a marker (f toggles); the `runs` help no longer claims to
list only index runs.

F24 — after a burst of new files following a long quiet gap, the index
summary adds a catch-up note ("404 new files after 21 days"), so the moment
squirrel earns the most trust isn't rendered as routine noise.

Restore arrow — the restore summary flips to destination → volume, matching
the byte-flow direction instead of the sync-shaped volume → destination.

Stacked on #157 (PR #174).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
The scheduler was a single serial worker with no per-run time bound: a
hung rclone froze every destination, every volume, the peer syncs, and
the index cadences until an operator killed the process by hand (F25).

Split sync execution off the tick goroutine into a per-destination
dispatcher. The concurrency unit is the destination: at most one sync
per destination is in flight (two volumes targeting the same endpoint
serialise on it), different destinations run concurrently, and an
overall semaphore (default 4) bounds total parallelism. A slow or wedged
destination is now confined to its own worker and can never delay the
tick loop, index runs, peer syncs, or LAN pairs. The per-pair
`skipped reason="in-flight sync run"` discipline and the pre-sync index
ordering are preserved.

Bound every automatic transfer two ways. rclone carries explicit
--contimeout/--timeout on each invocation. A squirrel-side no-progress
guard (stallGuard) watches rclone's per-second stats and, if
transferred+checked+bytes stops advancing for StallTimeout (default
10m), kills the child and fails the run with a diagnosable "stalled"
error that composes with #157's stderr capture. Checks count as
progress so a long verification pass is never mistaken for a stall; a
slow-but-moving transfer of a large volume is never killed. The guard is
off for the foreground CLI (a human can interrupt) and set by the agent.

In-flight visibility is already per-pair via the runs table (the TUI's
active-runs block); the timeouts make those rows truthful by failing a
stuck run instead of leaving it "running" forever.

No schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
claude and others added 15 commits July 24, 2026 12:07
Five review comments, all in one round:

1. PARTIAL terminal outcome (status/labels.go StateLabel, status/build.go
   syncLevel): a 'partial' latest terminal run is terminal and degraded, but
   both sites only special-cased 'failed' and fell through to freshness
   (ok/late). A latest 'partial' now renders "partial"/amber in both — it is
   more recent than any success, so freshness must not mask it.

2. Never-indexed volume offload policy (status/build.go buildVolume): the
   never-indexed early return left Offload.Applicable=false, so a volume that
   declares offload_requires printed "offload: no policy". Applicable is now
   set from the config policy up front (present offload_requires), independent
   of whether a tally ran.

3. Single-pass readiness (offload/readiness.go): Readiness built and sorted a
   full candidate slice via selectCandidates and then iterated it again;
   ordering is irrelevant to totals. It now walks the index map once,
   applying the same present + not-under-reserved-subtree predicate and the
   same gate.check. tallyReadiness removed.

4. TUI readiness cost (tui/dashboard.go): the 1s dashboard tick called
   status.Build → offload.Readiness (whole-index gate pass) every second,
   risking deadline misses on large volumes. Added status.Options
   {SkipOffloadReadiness} + BuildWithOptions; the CLI keeps the full Build.
   The dashboard rebuilds the coverage/durability grid every tick with
   readiness skipped and refreshes the offload tally on a slower cadence
   (readinessRefreshTicks) into a per-volume cache, overlaid at render, so
   "N offloadable now" stays visible without the per-tick cost.

Tests: partial in StateLabel + a syncLevel terminal-outcome table; never-
indexed-reports-policy and skip-vs-full readiness Build tests; dashboard
test updated for the new loadDashboardData arity. go vet, go test ./...,
golangci-lint (2.12.2) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
… hint, scope reminder (#158)

FIX 1 (observability): the initiator now mirrors the freeze into its own
contested_paths latch via a deferred call armed right after /plan, so the
losing edge's badge/`squirrel conflicts` signal shows even when the transfer,
verify, or close later fails — the receiver already froze the path during
/plan pre-stage, so the conflict is real regardless of whether our bytes land.
New test drives a conflict then a forced transfer failure (bogus rclone
binary, no rclone needed) and asserts the local latch is set.

FIX 2: the `conflicts resolve` hint printed after a contested sync now quotes
the volume and path (%q), so a contested path containing whitespace stays a
single shell word when copy-pasted.

FIX 3: the contested reminder under `squirrel runs` renders volumes by name
(via the shared label map, never the internal id) and, under --volume, scopes
to that volume instead of listing unrelated freezes. New CLI test covers both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
…179)

`gatherRuns` fetched an unbounded run slice under --failed/--changes and
counted conflicts across the whole thing, feeding every peer-sync id into one
IN (...) query — on a large history that overflows SQLite's bound-parameter
cap ("too many SQL variables"), and --failed never needed the counts at all.

- Conflict counts are now loaded only where needed: --failed consults none
  for filtering (an attention-worthy run is kept unconditionally); --changes
  counts only the ambiguous rows (a clean 0-file success); and the
  CONFLICTS-column counts are loaded for just the displayed rows, after
  filtering and truncation, not the unbounded fetch.
- CountFilesFirstSeenByRunWithPathPrefix batches the id set (chunks of 400,
  each query binding chunk+2 params) so no caller can overflow the cap
  regardless of history size. Added a store test that passes 5000+ ids.

filterRuns stays a pure predicate (unit-tested); the load strategy lives in
gatherRuns/conflictCountsForFilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Address Copilot review on #181.

Fix 1 (starvation): the dispatcher skipped a sync when its destination was
already busy. Because the tick iterates volumes by name, two volumes due to
the same destination on one tick meant the later one was skipped every tick
forever — never a run row, never consuming its cadence, just skip-log spam.
In the reference setup this is the common case (photos and docs both push
to cloudbox / s3archive / kopia-mirror), and it defeated the per-destination
QUEUES that #160 option 4b specified. Replace skip-on-busy with a real
per-destination FIFO queue: an idle destination starts its worker at once, a
busy one takes the pair onto its queue (deduped so re-evaluation can't queue
it twice) and runs it when the current transfer finishes. At most one sync
per destination, overall parallelism still bounded, pre-sync-index ordering
and per-pair semantics preserved. The genuine same-pair in-flight skip stays
(the DB HasRunningRun check in the scheduler).

Fix 2/3 (flaky tests): the stallGuard tests used tight real-time bounds.
Loosen them — the fire test waits for the guard rather than racing a
deadline; the reset test drives advance() off a ticker at 20x the bound's
resolution across a span twice the stall window, so scheduler jitter can't
fake a stall.

Tests: TestSyncDispatcherSerializesSameDestination (two volumes, one
destination: both run, FIFO, max one concurrent) and
TestSchedulerTwoVolumesSameDestinationBothRun (two volumes due to the same
destination on one tick both produce a sync run — neither starved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
The v24→v25 migration is the highest-risk change in #174: it rebuilds the
FK-referenced runs table under PRAGMA foreign_keys=OFF to widen the status
CHECK. The existing migration fixtures create an empty runs table, so no
test drove real run rows (or a referencing FK row) through the rebuild.

Add a focused test that builds a v24 DB with runs rows in every pre-v25
terminal state plus an in-flight 'running' row and a child row with a
foreign key into runs, migrates to v25, and asserts: every row carried
over with its id and status intact, the child FK still resolves (and
PRAGMA foreign_key_check is clean), and the widened CHECK now admits
'refused'/'aborted' while still rejecting an unknown status.

Test-only; no production change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
The status command sets SilenceErrors so its scriptable green/amber/red
exit-code signal (an exitCodeError) doesn't leak a cobra "Error:" line.
But SilenceErrors is unconditional: it also silenced genuine failures —
an unknown volume, a missing config, a store error — which then reached
neither cobra's printer nor main.go's, leaving the user a bare exit 1
with no message. The existing tests missed it because they assert on the
returned error value, not on user-visible output.

Print non-exitCodeError errors from the command's RunE (the exit-code
signal stays silent), and extend the unknown-volume and missing-config
tests to assert the message actually reaches stderr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Async dispatch (this PR) freed the tick loop to keep firing every 30s
while a dispatched sync runs in the background. But maybeRunSync ran the
pre-sync index before the per-pair in-flight check, and syncDue stays
true for the whole duration of an in-flight sync — the finished-run
watermark only advances when the sync completes. So a slow multi-hour
push made the scheduler re-index the source on every tick, burning I/O
and flooding the never-pruned runs audit trail with a kind='index' row
per tick. The old serial scheduler never hit this: the tick blocked
inside the sync, so it could not re-evaluate until the sync finished.

Filter each volume's due destinations down to the pairs that are not
already syncing before running the pre-sync index, and skip the index
entirely when none remain (there is no new push for it to precede). The
in-flight signal reads from two sources that compose to close every
window: the dispatcher's in-memory queue (set the instant a pair is
enqueued, before its run row is visible) and the runs table
(authoritative once the row exists, and the signal that also catches a
stale row or a concurrent CLI `squirrel sync`). The ordering invariant
holds — a volume syncing to one in-flight dest and one free dest still
indexes once and dispatches the free one — and the per-pair
scheduler.skipped log is unchanged.

runSync becomes syncInFlight (a bool predicate, no dispatch); maybeRunSync
is decomposed into dueSyncDests/dispatchableSyncDests to stay small.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Textual conflicts:
- sync/sync.go: main broadened the marker gate from local-only to every
  destination (reading and writing the marker through rclone, hence the
  new signature); this branch wrapped the refusal in recordSyncRefusal so
  it mints a terminal 'refused' run (#157, F26). Keep both — every
  destination is now gated, and a refusal from any of them lands in the
  audit trail.
- cmd/squirrel/sync.go: this branch extracted the summary line into
  printSyncSummaryLine (behind a status != "" guard, with the restore
  arrow reversed); main edited the same switch in place. Keep the
  extraction and graft main's one new element — the packed dry-run
  preview line — into the helper.

go vet, go test ./..., and gofmt are clean; the schema snapshot golden
test passes at v25.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQUzRmQHqbP2kV9kFrbBHL
…ontested-freeze

This branch's base advanced by 17 commits plus a merge of main, so the
conflicts are mostly this branch's edits landing on code that moved
underneath them.

- agent/scheduler.go + agent/dispatcher.go: the inline sync dispatch this
  branch had edited became the dispatcher's runOne, behind a queue and a
  syncInFlight probe. Took the refactored form and moved this branch's
  conflict signalling — the conflicts/contested attributes on
  scheduler.finished and the scheduler.conflict warn line — to where a
  sync's completion is now logged.
- tui/dashboard.go: keep both new dashboard fields, the contested-paths
  latch and the coverage report. Dropped the stale latestByVol wiring:
  the dashboard no longer builds that map, the shared status coverage
  report replaced it (tui/volumes.go keeps its own, unaffected).
- sync/node.go: both sides appended to the same two spots — keep this
  branch's local contested mirror and its helpers alongside the
  already-correct tally (F7) and its helper.
- cmd/squirrel/runs.go: the contested reminder scoped itself with
  opts.VolumeID, but the listing's ListRunsOpts moved inside gatherRuns.
  Extracted resolveVolumeFilter so the listing query and the reminders
  beneath it share one resolved filter instead of looking the volume up
  twice.

go vet, go test ./..., and gofmt are clean; the schema snapshot golden
test passes at v26 (v25 from the base, v26 for contested_paths).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQUzRmQHqbP2kV9kFrbBHL
main now carries #171, so its bootstrap-helper and fresh-start work meets
this branch's refusal/audit work.

- sync/content_addressed.go, sync/packed.go: the layout guards had both
  changed — main added the fresh-start-on-empty-root recognition and the
  `destination reset` hint, this branch wrapped the refusal in ErrRefused
  so it records a terminal 'refused' run. Composed: an empty root is still
  a fresh start, and a genuine layout conflict refuses with both the reset
  hint and the sentinel.
- store/runs_audit.go, cmd/squirrel/agent.go: both sides appended at the
  same spot — keep this branch's abort/alarm/contested transitions and the
  startup reaper alongside main's reset-destination transition and its
  listener-less helpers.

The reaper still runs in runAgent ahead of serveAgent, so it covers both
the HTTP and the new listener-less path.

go vet, go test ./..., and gofmt are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQUzRmQHqbP2kV9kFrbBHL
main now carries #172's verify and durability-refresh cadences.

Textual conflicts:
- agent/scheduler.go, agent/scheduler_test.go: this branch replaced the
  scheduler's syncRun field with the dispatcher; main added the
  verify/pull cadence fields beside it. Keep the dispatcher and graft the
  cadence fields on.
- cmd/squirrel/agent.go: keep the startup reaper and drop agentHasWork —
  main replaced it with the shared agent.ScheduledWorkInConfig this file
  already calls. In resolveSchedulerRclone, keep the stall-timeout guard
  and put main's sync-only gate around the version preflight (a
  verify-only schedule reads provider checksums and needs no `--hash
  blake3` check, but every automatic transfer still wants the guard).
- store/runs_audit.go: both sides added transitions; keep all of them.

Semantic conflict: #172 gave loadGate a `cadenced` argument, which this
branch's offload.Readiness (added for the status surface) did not pass —
the two files merged cleanly and stopped compiling. Readiness must feed
the gate exactly what a real offload feeds it: `cadenced` widens the gate
for locally-advanced fingerprint-verified components, so omitting it
would make "N GB offloadable now" under-report against what an offload
would actually move — the precise contract Readiness documents.

The set is now needed in two packages, so the resolver moved out of
cmd/squirrel to config.Config.VerifyCadencedTargets, beside the
CanEverGateOffload predicate it complements; the offload command and the
status readiness query now share one implementation.

go vet, go test ./..., and gofmt are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQUzRmQHqbP2kV9kFrbBHL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runs: every failure and refusal becomes a run row or a standing state

3 participants