balls/claim_sync.rs
1//! Git-remote `Protocol` impl for state-branch lifecycle pushes.
2//!
3//! bl-2148 introduced this wire for the claim path: local claim
4//! commit on `balls/tasks` must land on origin before the worktree
5//! is created, with non-fast-forward rejections driving a
6//! fetch + field-level merge + retry. bl-2bf7 generalizes it to
7//! review and close — same wire, same retry-merge loop, only
8//! `post_merge`'s claim-vs-lost ownership check is event-specific.
9//!
10//! Wired in per event only when the corresponding
11//! `require_remote_on_<event>` policy is true; otherwise the event
12//! stays local-only. The propose-merge-retry primitive itself lives
13//! in `crate::negotiation`; this module supplies the wire hooks and
14//! `push_claim` / `push_state_for` wrappers.
15//!
16//! The remote is resolved (not hardcoded) through the
17//! per-clone/committed `state_remote` seam (bl-19aa); default
18//! `origin`. This is where a client repo retargets `balls/tasks` at
19//! a shared task hub.
20
21// Remote resolution + push-outcome classification live in
22// `claim_push` to keep this file under the 300-line cap. The public
23// `push_state_classified` is re-exported so its API path is stable.
24pub use crate::claim_push::push_state_classified;
25
26use crate::claim_push::{state_remote, STATE_BRANCH};
27use crate::error::{BallError, Result};
28use crate::git;
29use crate::negotiation::{AttemptClass, FailurePolicy, Protocol};
30use crate::participant::{self, Event, EventCtx, Participant, Projection};
31use crate::store::Store;
32use std::path::PathBuf;
33
34const MAX_RETRIES: usize = 5;
35
36/// Outcome of the claim-sync negotiation.
37#[derive(Debug, PartialEq, Eq)]
38pub enum SyncedClaimResult {
39 /// Our claim landed on the remote.
40 Pushed,
41 /// Another agent got there first; their claim is now reflected in
42 /// the local task file (via the auto-resolved merge). The caller
43 /// must NOT create a worktree.
44 Lost { winner: String },
45}
46
47/// `Protocol` implementation for the git-remote state-branch lifecycle
48/// push. One concrete struct serves every event because the wire is
49/// the same `git push <state_remote> balls/tasks` either way; the only
50/// branching is `post_merge`'s claim-only ownership check. `remote` is
51/// resolved once at construction so every attempt in one negotiation
52/// targets a stable peer.
53pub struct GitRemoteClaimProtocol<'a> {
54 event: Event,
55 store: &'a Store,
56 task_id: &'a str,
57 identity: &'a str,
58 state_dir: PathBuf,
59}
60
61impl<'a> GitRemoteClaimProtocol<'a> {
62 pub fn new(event: Event, store: &'a Store, task_id: &'a str, identity: &'a str) -> Self {
63 let state_dir = store.state_worktree_dir();
64 Self { event, store, task_id, identity, state_dir }
65 }
66}
67
68impl Protocol for GitRemoteClaimProtocol<'_> {
69 type Outcome = SyncedClaimResult;
70
71 fn propose(&mut self) -> Result<AttemptClass> {
72 let remote = state_remote(self.store)?;
73 push_state_classified(&self.state_dir, &remote)
74 }
75
76 fn fetch_remote_view(&mut self) -> Result<()> {
77 let remote = state_remote(self.store)?;
78 let _ = git::git_fetch(&self.state_dir, &remote);
79 let merge = git::git_merge(&self.state_dir, &format!("{remote}/{STATE_BRANCH}"))?;
80 if matches!(merge, git::MergeResult::Conflict) {
81 crate::sync_resolve::auto_resolve_task_conflicts(&self.state_dir)?;
82 git::git_commit(&self.state_dir, "state: auto-resolve lifecycle conflicts")?;
83 }
84 Ok(())
85 }
86
87 fn post_merge(&mut self) -> Result<Option<SyncedClaimResult>> {
88 // Only the claim event has a "lost" semantics — the
89 // claim-race resolution turns the merge into a definitive
90 // win/lose outcome. Review and close just retry the push
91 // after the field-level merge resolves any divergence.
92 if self.event != Event::Claim {
93 return Ok(None);
94 }
95 let claimer = self.store.load_task(self.task_id)?.claimed_by;
96 if claimer.as_deref() == Some(self.identity) {
97 return Ok(None);
98 }
99 let winner = claimer.unwrap_or_else(|| "(unknown)".into());
100 // Best-effort post-merge push so the remote sees the resolved
101 // state. Failure here doesn't change the outcome — we already
102 // know we lost.
103 let remote = state_remote(self.store)?;
104 let _ = push_state_classified(&self.state_dir, &remote);
105 Ok(Some(SyncedClaimResult::Lost { winner }))
106 }
107
108 fn pushed(&mut self) -> SyncedClaimResult {
109 SyncedClaimResult::Pushed
110 }
111
112 fn retry_budget(&self) -> usize {
113 MAX_RETRIES
114 }
115}
116
117/// The git origin remote as a SPEC §5 `Participant`. Carries no wire
118/// state itself — the per-event `Protocol` owns state for one
119/// negotiation. Subscriptions are caller-controlled: `for_claim()` is
120/// the bl-2148 shape (claim only); `for_lifecycle(events)` is the
121/// bl-2bf7 generalization for review and close. The caller's policy
122/// resolution (per-event `require_remote_on_*` plus non-stealth) is
123/// what decides whether to subscribe at all.
124pub struct GitRemoteParticipant {
125 projection: Projection,
126 subscriptions: Vec<Event>,
127}
128
129impl GitRemoteParticipant {
130 /// Subscribe only to `claim`. Equivalent to
131 /// `for_lifecycle(&[Event::Claim])`; kept as a named constructor
132 /// because the bl-2148 call sites read more clearly that way.
133 pub fn for_claim() -> Self {
134 Self::for_lifecycle(&[Event::Claim])
135 }
136
137 /// Subscribe to the supplied lifecycle events. Failure on any
138 /// subscribed event is `Required` — the rollback semantics in
139 /// the lifecycle paths depend on the negotiation surfacing the
140 /// failure as `Err`. Callers that want best-effort behavior
141 /// should not subscribe at all (i.e. don't construct the
142 /// participant for that event).
143 pub fn for_lifecycle(events: &[Event]) -> Self {
144 Self {
145 projection: Projection::full(),
146 subscriptions: events.to_vec(),
147 }
148 }
149}
150
151impl Default for GitRemoteParticipant {
152 fn default() -> Self {
153 Self::for_claim()
154 }
155}
156
157impl Participant for GitRemoteParticipant {
158 type Outcome = SyncedClaimResult;
159 type Protocol<'a>
160 = GitRemoteClaimProtocol<'a>
161 where
162 Self: 'a;
163
164 fn name(&self) -> &'static str {
165 "git-remote"
166 }
167
168 fn subscriptions(&self) -> &[Event] {
169 &self.subscriptions
170 }
171
172 fn projection(&self) -> &Projection {
173 &self.projection
174 }
175
176 fn failure_policy(&self, event: Event) -> FailurePolicy {
177 // Subscribed events are always Required: the call site only
178 // wires the participant when policy says the remote must
179 // succeed for this transition. Unsubscribed events never
180 // reach this method through `participant::run` (the
181 // subscription gate short-circuits first), so the fallback
182 // is just a safe answer.
183 if self.subscriptions.contains(&event) {
184 FailurePolicy::Required
185 } else {
186 FailurePolicy::BestEffort
187 }
188 }
189
190 fn protocol<'a>(
191 &'a self,
192 event: Event,
193 ctx: EventCtx<'a>,
194 ) -> Option<Self::Protocol<'a>> {
195 match event {
196 Event::Claim | Event::Review | Event::Close => Some(
197 GitRemoteClaimProtocol::new(event, ctx.store, ctx.task_id, ctx.identity),
198 ),
199 _ => None,
200 }
201 }
202}
203
204/// Push the freshly-committed claim through `origin/balls/tasks`.
205/// Caller has already (a) committed the claim locally on the state
206/// branch and (b) released no locks that the merge step needs. The
207/// negotiation runs through the SPEC §5 `Participant` surface so the
208/// claim path shares one set of semantics with future participants.
209pub fn push_claim(
210 store: &Store,
211 task_id: &str,
212 identity: &str,
213) -> Result<SyncedClaimResult> {
214 push_state_for(store, task_id, identity, Event::Claim, "claim --sync")
215}
216
217/// Push the freshly-committed state-branch transition for `event`
218/// through `<state_remote>/balls/tasks`. Required-policy generalization of
219/// `push_claim` for review and close: same wire, same retry-merge
220/// loop, same unreachable-aborts-loud stance. The `error_prefix`
221/// is folded into the `Err` message so callers don't all wrap the
222/// same way.
223pub fn push_state_for(
224 store: &Store,
225 task_id: &str,
226 identity: &str,
227 event: Event,
228 error_prefix: &str,
229) -> Result<SyncedClaimResult> {
230 let state_dir = store.state_worktree_dir();
231 let remote = state_remote(store)?;
232 if !git::git_fetch(&state_dir, &remote)? {
233 return Err(BallError::Other(format!(
234 "{error_prefix}: cannot reach remote `{remote}` (fetch failed)"
235 )));
236 }
237 let participant = GitRemoteParticipant::for_lifecycle(&[event]);
238 let ctx = EventCtx::new(event, store, task_id, identity);
239 participant::run_strict(&participant, event, ctx)
240 .map_err(|e| BallError::Other(format!("{error_prefix}: {e}")))
241}
242
243#[cfg(test)]
244#[path = "claim_sync_tests.rs"]
245mod tests;