runs: every failure and refusal becomes a run row or a standing state#174
Merged
Conversation
…#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
There was a problem hiding this comment.
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
refusedandabortedterminal run statuses; reap orphanedrunningruns toabortedat agent startup; countrefused(but notaborted) in cadence “finished run” logic. - Add standing verify alarm latch: new
destination_alarmstable with raise/clear recorded viaruns_audit, with both auto-clear and explicitsquirrel verify ack <dest>paths. - Improve rclone failure evidence: capture bounded non-JSON stderr + broaden error-level capture; ensure failed runs don’t report
errors=0viaRunResult.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.
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
…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
This was referenced Jul 24, 2026
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
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
…ng-states' into review-177
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
This was referenced Jul 25, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.statusCHECK widens to admit two terminal states:refused(a preflight safety gate declined before the transfer began) andaborted(arunningrow 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).destination_alarmstable (STRICT, per the new-table convention): the per-destination standing-alarm latch.go test ./store -update-schema;TestSchemaSnapshotpasses. (Three minimaldestination_run_idsmigration fixtures gained arunstable, which the v25 rebuild now reads — a real DB at those versions always has it.)F6/F15 — rclone failures were undiagnosable
RunResult.Stderr, and broaden JSON error capture tofatal/criticallevels (a fatal backend/auth failure is logged atfatal, which the olderror-only filter dropped — leaving the run row's ERROR column blank).exit status 1; synthesise aFailedFilesoruns.erroris never blank on a fatal failure. (kopia already recorded its full message — this matches it.)RunResult.DisplayErrors()fixes theerrors=0-on-status=failedsummary-line contradiction.F26 — preflight refusals mint no run row
refusedrun (via a newsync.ErrRefusedsentinel) instead of vanishing into arun_id=0error. A month-dead backup disk now produces visible red.refusedrun consumes the cadence window (LatestFinishedRuncounts it), so the scheduler backs off a dead destination rather than re-minting an identical refusal every tick.F14 — killed agents leave phantom
runningrunsrunningruns toaborted(store.AbortRunningRuns), loudly logged.abortedis excluded fromLatestFinishedRunso a crashed run is re-attempted, not treated as a finished cadence window.F30 — verify mismatches ring once then carry on
destination_alarms) surfaced insquirrel runs, the TUI dashboard, andsquirrel verifyoutput until cleared.squirrel verify, and an explicitsquirrel verify ack <dest>. Every raise and clear is recorded inruns_audit(tagged with the operator on ack), so deleting the derived latch row loses no forensic history — the raise/clear trail lives inruns/runs_audit, mirroring howpeer_sync_statekeeps a live overwrite-in-place row with history elsewhere.Alarm-clear semantics (please confirm)
runs_audit. This keeps it faithful to the no-loss invariant without an extra history table.Testing
go vet ./...— cleango test ./...— all pass (new tests: alarm lifecycle, reaper + cadence-scope, rclone stderr/fatal capture +DisplayErrors, marker refusal mints arefusedrun, verify mismatch latch → auto-clear)golangci-lint run(v2.12.2) — 0 issuesCloses #157
🤖 Generated with Claude Code
https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Generated by Claude Code