Skip to main content

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;