conflicts: converge divergent edits via contested-freeze, notify both edges (#158)#180
Merged
mbertschler merged 3 commits intoJul 24, 2026
Conversation
… 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
There was a problem hiding this comment.
Pull request overview
This PR implements the “contested-freeze” mechanism for peer-sync divergent edits (F27 / #158), preventing infinite conflict ping-pong by freezing contested paths, version-gating the new contested disposition, and surfacing conflict signals on both initiator and receiver machines (CLI, scheduler logs, runs, and TUI).
Changes:
- Add schema v26
contested_pathsstanding-state latch, plus store APIs and audit transitions to raise/clear freezes. - Extend sync protocol and receiver
/planclassification with version-gatedcontesteddisposition and wire details; initiators mirror freezes locally and surface counts. - Add user-facing surfaces: TUI dashboard badge,
squirrel conflicts+conflicts resolve, and enhanced run/sync reporting.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tui/dashboard.go | Display contested-path freezes as a dashboard badge and load them from the store. |
| syncproto/syncproto.go | Add DispositionContested, ProtocolVersionContested, and PlanResponse.Contested wire format. |
| sync/sync.go | Extend sync report to carry contested details from the receiver. |
| sync/node.go | Negotiate protocol v3, plumb contested details into reports, and mirror freezes locally. |
| sync/node_test.go | Add end-to-end test ensuring freeze stops repeated conflict dir creation and resolve unfreezes. |
| store/schema.sql | Regenerate schema snapshot to v26 including contested_paths. |
| store/runs_audit.go | Add audit transition constants for contested raise/clear. |
| store/migrations.go | Bump SchemaVersion to 26 and add v25→v26 migration creating contested_paths. |
| store/contested_paths.go | Implement contested latch CRUD + listing + per-run counts. |
| store/contested_paths_test.go | Add unit tests for latch lifecycle, idempotency, and “unknown digest” behavior. |
| design/friction-log.md | Mark F27 resolved and document the contested-freeze mechanism. |
| cmd/squirrel/sync.go | Print contested refusal details and provide a resolve hint. |
| cmd/squirrel/runs.go | Surface a contested reminder under squirrel runs and count contested latches for initiator runs. |
| cmd/squirrel/root.go | Register the new conflicts command. |
| cmd/squirrel/conflicts.go | Add squirrel conflicts list command showing contested paths and metadata. |
| cmd/squirrel/conflicts_test.go | Add CLI tests for list + resolve + error paths. |
| cmd/squirrel/conflicts_resolve.go | Add squirrel conflicts resolve <volume> <path> to clear freezes explicitly. |
| cmd/squirrel/agent.go | Include conflict/contested counts in agent.SyncRunReport. |
| agent/sync.go | Negotiate protocol v3, classify contested, collect contested details, and raise freeze on conflict pre-stage. |
| agent/scheduler.go | Log conflict/contested counts and emit a warn-level scheduler conflict signal. |
| agent/contested_test.go | Add receiver-side tests for freeze stopping ping-pong and protocol version gating. |
| agent/agent.go | Extend SyncRunReport with conflict/contested counts for scheduler reporting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… 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
…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
mbertschler
merged commit Jul 24, 2026
024f981
into
claude/issue-157-run-rows-standing-states
3 checks passed
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
Divergent edits of the same path on two peers used to ping-pong forever (F27): every cadence tick re-asserted its version and minted a fresh
.squirrel-conflicts/run-N/copy on the receiver, the conflict store grew unboundedly, and no human was ever told — the initiators' logs carried no conflict signal, and the losing edge's own TUI showed all-green 0-file syncs while its file silently diverged.This implements Martin's option 2a: contested-freeze. A conflict now freezes the path; automated re-supersedes are refused (preserved once, not every tick); both initiators get a signal on their machines; and a human resolves explicitly.
Changes
contested_paths, STRICT):migrateV25ToV26adds the per-(volume, path)freeze latch — derived standing state likedestination_alarms, so clearing it loses no history. Snapshot regenerated (go test ./store -update-schema;TestSchemaSnapshotpasses).preStageConflicts(idempotent — raised once per episode). The/planclassifier answers a divergent re-assertion from any peer with a newcontesteddisposition instead of minting another copy; the frozen winner re-asserting its own bytes is still allowed through so a dropped/closecan re-land it.contestedis gated behindProtocolVersionContested(3). An older initiator negotiates ≤2 and transparently falls back to the legacyconflict— it never receives a disposition it can't interpret.SyncRunReport, the initiators' run rows (theCONFLICTScolumn, viaCountContestedRaisedByRun), ascheduler.conflictwarn line, and a "Contested paths" TUI badge. Every node mirrors the receiver-reported freeze into its owncontested_pathslatch, so the losing edge answers "am I safe?" from its local index — not only the hub.squirrel conflictslists frozen paths (versions, preserved location, when, who);squirrel conflicts resolve <volume> <path>clears the latch so syncs flow again — the explicit human act. Resolve keeps the live version and points at the preserved copy; adopting the other version stays a deliberaterestore, never a silent content move ("irreversible acts remain human")..squirrel-conflicts/); nothing is ever deleted.design/friction-log.md: F27 marked resolved with the mechanism.Testing
go vet ./...clean;go test ./...green;golangci-lint run ./...→ 0 issues (rebuilt v2 with the module toolchain to clear the packaged-binary Go-version mismatch).storelatch lifecycle (idempotent raise / clear / count / unknown-digest);agentfreeze-stops-ping-pong and version-gate at the/planlevel;cmd/squirrelconflicts list + resolve + error paths;syncend-to-end freeze→refuse→resolve-unfreezes (skips without rclone, like its siblings, runs in CI). Maps to testbed scenario 6 / reference-setup lifecycle checkpoint 6: one conflict dir, immediate freeze, badges on both edges, resolve unfreezes.Notes for the maintainer to verify
resolve— the winner re-syncing its unchanged bytes is unaffected. This is the intended "scary moments are first-class / human resolves" behaviour, but it is a behaviour change worth a look.resolveis clear-only: it unfreezes and keeps the current live version; it does not itself swap in the preserved version (that stays a deliberaterestore). This keeps resolve safe and honours no-loss; the acceptance ("resolve unfreezes") holds.New schema version: 26.
Stacked on #157 (PR #174) — base is
claude/issue-157-run-rows-standing-statesso the diff shows only this delta; it retargets tomainwhen #157 merges.Closes #158
🤖 Generated with Claude Code
https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7
Generated by Claude Code