Skip to content

conflicts: decided on what the box holds now, addressed by type and id - #272

Merged
udaychandra merged 2 commits into
mainfrom
agent/sail-conflict-resolve-current
Sep 21, 2026
Merged

udaychandra merged 2 commits into
mainfrom
agent/sail-conflict-resolve-current

Conversation

@udaychandra

Copy link
Copy Markdown
Contributor

Spec: sail-conflict-resolve-current. Two defects in sail conflicts resolve, both latent since conflicts shipped, both sail-only.

What was wrong

  1. Resolve wrote the snapshot recorded at detection, not the row. Every strategy applies a recorded snapshot (--merge's template is pre-filled from one), so anything this box wrote to the entity after the conflict was recorded was silently reverted — a spec body edited after parking on title, or a run parked while running going back to running after it completed. 0.44.3's per-round re-record narrowed the window; it did not close it.
  2. A conflict was looked up by entity id alone, returning null unless exactly one matched. A spec and its room share an id by construction, so parked together both were unresolvable, reported as "No open conflict".

What changed

  • One staleness rule, all three strategies. Inside the BEGIN IMMEDIATE transaction ConflictOperations.resolve already opens, the store's live currentForSync snapshot is compared with the conflict's recorded local side, ignoring the store's latestWinsFields() and _ metadata (new pure ConflictDetector.drift). Any drift refuses — nothing written, conflict stays pending:

    'auth' changed on this box after this conflict was recorded (body).
    Run 'sail sync' to refresh it, then resolve.
    

    409 over both the web and local-socket APIs, same message. No --force.

  • Conflicts are addressed by (type, id). ConflictOperations.find(type, id); the exactly-one-or-null lookup is deleted. sail conflicts show|resolve <id> [--type <type>]; GET /v1/conflicts/{id} and POST /v1/conflicts/{id}/resolve take ?type=. Without a type, one match resolves as before; several are refused naming them — 'auth' has open conflicts as spec and room: pass --type — as a 400 (ErrorCode.BAD_REQUEST; a bare IllegalArgumentException maps to 422 on the web router). The CLI resolves with the type of the conflict it displayed.

  • Main's side gets no guard — it gets tests. The recorded remote only becomes the merge base; the next round three-ways against main's current row.

  • Deleted the dead second resolve path in ConflictsCommand (findUnique, resolverFor, choose, apply): test-only callers, and apply was an unguarded copy of defect 1.

Tests

  • ConflictOperationsTest (new), conflicts parked over a real paged session (SyncBox.connect + SyncRpcServer):
    • a local edit after detection refuses each of --mine/--theirs/--merge with the exact message and 409, leaving the row, its rev and the conflict row untouched;
    • after a round re-records, the same resolve succeeds and the newer edit is what reaches main;
    • main moved a disjoint field after detection → resolve --mine + a round merges both; same field → parks again with fresh base/local/remote; --theirs + a round lands main's newer value. Main's value is never lost;
    • a spec and its room parked together: refusal names both types, each resolves with its type, and the survivor resolves by id alone again;
    • a run's heartbeat stamping on does not refuse; its lifecycle completing does, and the run stays completed.
  • ApiRouterTest: the same spec+room pair through real ConflictOperations400 without ?type= (GET and POST), ?type= selects, 409 on a stale resolve, 404 for a type with no conflict. LocalApiRouterTest: ?type= is carried and 409/400 survive the local lane.
  • ConflictDetectorTest: drift unit cases.
  • Mutation check: with the requireCurrent call removed, 5 of the new tests fail.
  • NativeFleetIT is untouched (it resolves immediately after a sync).

Left alone, on purpose

  • A round that re-records a conflict while --merge's $EDITOR is open (or between show and resolve --theirs) still lets a decision land on snapshots newer than the ones displayed. Closing that needs a "what was shown" token (the conflict row id) threaded from show to resolve — a design change beyond this spec's rule; worth its own spec.
  • --mine on a run rewrites the recorded heartbeat, which may be a few seconds older than the live one; the next stamp moves it forward again.

Resolve wrote the snapshot recorded at detection over the row, so any
local write after the conflict was recorded was silently reverted by all
three strategies. Resolve now compares the store's live sync snapshot
with the recorded local side inside its transaction, ignoring
latest-wins fields, and refuses (409) when they differ; a sync round
re-records the conflict and the same resolve then applies. Main's side
needs no guard: the recorded remote only becomes the merge base, pinned
by tests over a real paged session.

A conflict was looked up by entity id alone, so a spec and its room
parked together were both reported as not found. Conflicts are now
addressed by (type, id): --type on the CLI, ?type= over the API, and an
id parked under several types is refused naming them (400).

Drops the dead, unguarded second resolve path in ConflictsCommand.
The new --type flag was exercised below the command line only: Resolve.call
and Show.call opened their operations through a static factory no test could
reach, so a wiring mistake between the flag and the lookup would have passed
every test (Resolve.call stood at 9 of 51 lines covered).

The three verbs now take their operations through a supplier, as
SyncCommand.Status does, and ConflictsCommandTest drives them through
picocli over a real control plane: an id parked as spec and room is refused
by name until --type says which; --type settles the named conflict and
leaves its twin parked; a conflict the box has since written over is refused
with the remedy; --merge-file settles a spec and is refused for a room.
Dropping --type from the lookup turns three of them red.

The entity id and --type were declared twice; they are one picocli mixin.
@udaychandra
udaychandra merged commit 0e62e01 into main Sep 21, 2026
3 checks passed
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.

1 participant