Skip to content

conflicts: converge divergent edits via contested-freeze, notify both edges (#158)#180

Merged
mbertschler merged 3 commits into
claude/issue-157-run-rows-standing-statesfrom
claude/issue-158-contested-freeze
Jul 24, 2026
Merged

conflicts: converge divergent edits via contested-freeze, notify both edges (#158)#180
mbertschler merged 3 commits into
claude/issue-157-run-rows-standing-statesfrom
claude/issue-158-contested-freeze

Conversation

@mbertschler

Copy link
Copy Markdown
Owner

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

  • Schema v26 (contested_paths, STRICT): migrateV25ToV26 adds the per-(volume, path) freeze latch — derived standing state like destination_alarms, so clearing it loses no history. Snapshot regenerated (go test ./store -update-schema; TestSchemaSnapshot passes).
  • Receiver freeze: a conflict raises the latch in preStageConflicts (idempotent — raised once per episode). The /plan classifier answers a divergent re-assertion from any peer with a new contested disposition instead of minting another copy; the frozen winner re-asserting its own bytes is still allowed through so a dropped /close can re-land it.
  • Version gate: contested is gated behind ProtocolVersionContested (3). An older initiator negotiates ≤2 and transparently falls back to the legacy conflict — it never receives a disposition it can't interpret.
  • Notify both edges: conflict + contested counts flow into SyncRunReport, the initiators' run rows (the CONFLICTS column, via CountContestedRaisedByRun), a scheduler.conflict warn line, and a "Contested paths" TUI badge. Every node mirrors the receiver-reported freeze into its own contested_paths latch, so the losing edge answers "am I safe?" from its local index — not only the hub.
  • Question/change command pair (separate files per cobra convention): 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. Resolve keeps the live version and points at the preserved copy; adopting the other version stays a deliberate restore, never a silent content move ("irreversible acts remain human").
  • Invariant preserved: both versions stay reachable throughout (winner live at the path, loser under .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).
  • New tests: store latch lifecycle (idempotent raise / clear / count / unknown-digest); agent freeze-stops-ping-pong and version-gate at the /plan level; cmd/squirrel conflicts list + resolve + error paths; sync end-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

  • A conflict now freezes even a one-time divergence (not just a repeating ping-pong) and blocks new divergent edits to that path until 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.
  • resolve is clear-only: it unfreezes and keeps the current live version; it does not itself swap in the preserved version (that stays a deliberate restore). This keeps resolve safe and honours no-loss; the acceptance ("resolve unfreezes") holds.
  • Edge markers clear explicitly, not automatically (no reliable auto-clear under the Merkle walk, and auto-clear would risk silently dropping the signal), so each machine's badge is acknowledged per-node — consistent with the "no fleet view yet" reality (ux-principles §3 open problem).

New schema version: 26.

Stacked on #157 (PR #174) — base is claude/issue-157-run-rows-standing-states so the diff shows only this delta; it retargets to main when #157 merges.

Closes #158

🤖 Generated with Claude Code

https://claude.ai/code/session_014Skm2Qm6wWUB7dD4CpPTz7


Generated by Claude Code

… 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
Copilot AI review requested due to automatic review settings July 24, 2026 11:58

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 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_paths standing-state latch, plus store APIs and audit transitions to raise/clear freezes.
  • Extend sync protocol and receiver /plan classification with version-gated contested disposition 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.

Comment thread sync/node.go
Comment thread cmd/squirrel/sync.go Outdated
Comment thread cmd/squirrel/runs.go Outdated
claude added 2 commits July 24, 2026 12:10
… 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
mbertschler merged commit 024f981 into claude/issue-157-run-rows-standing-states Jul 24, 2026
3 checks passed
@mbertschler
mbertschler deleted the claude/issue-158-contested-freeze branch July 24, 2026 21:56
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.

3 participants