Skip to main content

recall_worker/
worker.rs

1//! Enrolling, then the job loop: claim, merge, report, again.
2//!
3//! Before anything else the worker reads the server's discovery document,
4//! and goes no further unless the server lists the merge queue: a worker
5//! pointed at the wrong server, or one too old to have the queue, stops
6//! there, and never asks it to approve anything.
7//!
8//! The worker enrols by the device flow, like any machine, and never with
9//! an authkey: an authkey enrols `sync` devices only, so a leaked one
10//! cannot mint a worker. The owner approves its code with the `worker`
11//! scope, after comparing the fingerprint the worker prints. Its identity
12//! file records the server it was made for, and the worker refuses to run
13//! against any other.
14//!
15//! Each claim long-polls, so an idle worker costs about two requests a
16//! minute and a merge starts within a second of the push that queued it.
17//! Every claim also carries the worker's check of its `claude` CLI, which
18//! is how `/health` still reports the CLI once it has left the API process.
19//! A worker whose CLI is not logged in claims with no kinds: it stays
20//! visible, and takes no job it would only fail.
21//!
22//! A refusal no retry will get past (a `4xx` other than `408` and `429`,
23//! or a server with no merge queue) is [`Fatal`]: the worker says what to
24//! do and stops asking. Anything else, such as a server that is restarting,
25//! is retried with a backoff.
26
27use std::future::Future;
28use std::time::{Duration, Instant};
29
30use recall_wire::devices::{
31    ACCESS_DENIED, AUTHORIZATION_PENDING, ENROLL_PATH, ENROLL_POLL_PATH, EXPIRED_TOKEN,
32    INVALID_GRANT, POLL_INTERVAL_SECONDS, SCOPE_WORKER, SLOW_DOWN,
33};
34use recall_wire::discovery::CAPABILITY_MERGE_QUEUE;
35use recall_wire::jobs::{self, KIND_MERGE};
36use recall_wire::{
37    ClaimRequest, ClaimResponse, ClaudeCliReport, Discovery, EnrollPending, EnrollPollRequest,
38    EnrollPollResponse, EnrollRequest, Job, MergeResult, ResultRequest, ResultResponse,
39    DISCOVERY_PATH,
40};
41use reqwest::StatusCode;
42
43use crate::api::{Api, ApiError};
44use crate::config::Config;
45use crate::identity::Identity;
46use crate::merge::{Merger, Status};
47
48/// How long an unsigned request (discovery, enrolment) may take.
49const UNSIGNED_TIMEOUT: Duration = Duration::from_secs(30);
50
51/// How long posting a result may take. A result carries one file.
52const RESULT_TIMEOUT: Duration = Duration::from_secs(60);
53
54/// How many times a result is posted before the worker gives up on it and
55/// lets the lease run out, which puts the job back in the queue.
56const RESULT_TRIES: u32 = 5;
57
58/// The longest the worker waits between failed attempts to reach the
59/// server.
60const MAX_BACKOFF: Duration = Duration::from_secs(60);
61
62/// How often the CLI is checked while it is not logged in, whatever the
63/// configured interval: logging in on the worker should be noticed within
64/// a minute, not half an hour.
65const NOT_LOGGED_IN_RECHECK: Duration = Duration::from_secs(60);
66
67/// For how long after a code is printed it is polled at the interval the
68/// server asks for. The owner approving it is most likely at a terminal in
69/// that first minute; after it, [`SLOW_POLL`] is plenty.
70const FAST_POLL_FOR: Duration = Duration::from_secs(60);
71
72/// How often a code nobody approved in its first minute is polled.
73const SLOW_POLL: Duration = Duration::from_secs(30);
74
75/// Why the worker stopped. Each is something only a person can fix, so the
76/// worker says what to do rather than retrying forever.
77#[derive(Debug, thiserror::Error)]
78pub enum Fatal {
79    /// The identity file could not be read or written.
80    #[error("{0}")]
81    Identity(String),
82    /// The identity was made for another server.
83    #[error(
84        "{path} was made for {made_for}, not {configured}, and a worker's key is never \
85         offered to a second server. If RECALL_WORKER_SERVER is wrong, correct it; to move \
86         this worker to {configured}, revoke it on {made_for}, delete {path} and restart"
87    )]
88    OtherServer {
89        /// The server the identity records.
90        made_for: String,
91        /// `RECALL_WORKER_SERVER`.
92        configured: String,
93        /// The identity file.
94        path: String,
95    },
96    /// An identity an earlier build wrote, part way through enrolling
97    /// with a server it did not record.
98    #[error(
99        "{0} was written by an earlier recall-worker that did not record which server it \
100         enrolled with, so it cannot be checked against RECALL_WORKER_SERVER. Revoke the \
101         device it enrolled as (or deny its code), delete {0} and restart"
102    )]
103    Unbound(String),
104    /// The server is not one this worker can work for.
105    #[error("{0}")]
106    NotRecall(String),
107    /// The owner denied the enrolment.
108    #[error("the owner denied this worker's enrolment. Delete {0} and restart to ask again")]
109    Denied(String),
110    /// The owner approved it with a scope other than `worker`.
111    #[error(
112        "this device was approved with the {0} scope, not worker, so it cannot claim jobs. \
113         Revoke it, delete {1} and restart, then approve the new code as a worker \
114         (recall devices approve <code> --worker)"
115    )]
116    WrongScope(String, String),
117    /// The server no longer knows this device, or revoked it.
118    #[error(
119        "the server refused this worker ({0}). If it was revoked on purpose, nothing is wrong; \
120         to enrol again, delete {1} and restart"
121    )]
122    Refused(String, String),
123    /// Something else that will not fix itself, such as a name another
124    /// device already has.
125    #[error("{0}")]
126    Other(String),
127}
128
129/// What one claim came to.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum Step {
132    /// No job arrived within the wait.
133    Idle,
134    /// A job was done and its result recorded.
135    Done(ResultResponse),
136    /// A job was done, and the server would not take the result: its
137    /// lease ended, or the job is gone.
138    Rejected(String),
139}
140
141/// A worker: its settings, identity and CLI.
142pub struct Worker {
143    cfg: Config,
144    api: Api,
145    id: Identity,
146    merger: Merger,
147    status: Status,
148    status_at: Option<Instant>,
149    /// The last code the approval instructions were printed for.
150    announced: Option<String>,
151}
152
153fn log(message: &str) {
154    eprintln!("recall-worker: {message}");
155}
156
157/// Whether an answer is one that asking again will not change: a redirect
158/// (never followed), or a client error other than a timeout or a rate
159/// limit.
160fn is_final(e: &ApiError) -> bool {
161    e.status().is_some_and(|s| {
162        s.is_redirection()
163            || (s.is_client_error()
164                && s != StatusCode::REQUEST_TIMEOUT
165                && s != StatusCode::TOO_MANY_REQUESTS)
166    })
167}
168
169/// How long to wait before asking again after `e`: what the server asked
170/// for, or the next step of a backoff that doubles up to [`MAX_BACKOFF`].
171fn next_wait(e: &ApiError, backoff: Duration) -> Duration {
172    match e {
173        ApiError::Status {
174            retry_after: Some(s),
175            ..
176        } => Duration::from_secs(*s),
177        _ => (backoff * 2).clamp(Duration::from_secs(1), MAX_BACKOFF),
178    }
179}
180
181impl Worker {
182    /// A worker for `cfg`, with the identity kept in its data directory,
183    /// made there if this is the first start. Refuses an identity made for
184    /// another server.
185    pub fn new(cfg: Config) -> Result<Self, Fatal> {
186        if cfg.data_dir.as_os_str().is_empty() {
187            return Err(Fatal::Other(
188                "no data directory: set RECALL_WORKER_DIR to where the worker's key is kept".into(),
189            ));
190        }
191        let path = Identity::path(&cfg.data_dir).display().to_string();
192        let mut id = Identity::load_or_create(&cfg.data_dir, &cfg.server)
193            .map_err(|e| Fatal::Identity(format!("cannot use {path}: {e}")))?;
194        match id.server.as_deref() {
195            Some(server) if server == cfg.server => {}
196            Some(server) => {
197                return Err(Fatal::OtherServer {
198                    made_for: server.to_string(),
199                    configured: cfg.server.clone(),
200                    path,
201                })
202            }
203            // An earlier build's file. With nothing enrolled yet, the key
204            // has been offered to no server, and is this one's from now on.
205            None if id.device_id.is_none() && id.enrollment_id.is_none() => {
206                id.server = Some(cfg.server.clone());
207                id.save(&cfg.data_dir)
208                    .map_err(|e| Fatal::Identity(format!("cannot write {path}: {e}")))?;
209            }
210            None => return Err(Fatal::Unbound(path)),
211        }
212        let api = Api::new(&cfg.server).map_err(|e| Fatal::Other(e.to_string()))?;
213        let merger = Merger::new(cfg.claude_bin.clone(), cfg.merge_timeout);
214        Ok(Self {
215            cfg,
216            api,
217            id,
218            merger,
219            status: Status::default(),
220            status_at: None,
221            announced: None,
222        })
223    }
224
225    /// Its identity: key fingerprint, and device id once approved.
226    pub fn identity(&self) -> &Identity {
227        &self.id
228    }
229
230    fn identity_path(&self) -> String {
231        Identity::path(&self.cfg.data_dir).display().to_string()
232    }
233
234    fn save(&self) -> Result<(), Fatal> {
235        self.id
236            .save(&self.cfg.data_dir)
237            .map_err(|e| Fatal::Identity(format!("cannot write {}: {e}", self.identity_path())))
238    }
239
240    /// Checks the server, enrols until the owner approves, then runs jobs
241    /// until `shutdown` resolves. Returns only on shutdown, or on something
242    /// a person has to fix.
243    pub async fn run(mut self, shutdown: impl Future<Output = ()>) -> Result<(), Fatal> {
244        tokio::pin!(shutdown);
245        log(&format!(
246            "{} for {}, key fingerprint {}",
247            crate::user_agent(),
248            self.cfg.server,
249            self.id.fingerprint()
250        ));
251        if let Some(warning) = self.cfg.plaintext_warning() {
252            log(&warning);
253        }
254        tokio::select! {
255            _ = &mut shutdown => return Ok(()),
256            checked = self.check_server() => checked?,
257        }
258        if self.id.device_id.is_none() {
259            tokio::select! {
260                _ = &mut shutdown => return Ok(()),
261                enrolled = self.enrol() => enrolled?,
262            }
263        }
264        log(&format!(
265            "enrolled as {}; waiting for jobs",
266            self.id.device_id.as_deref().unwrap_or("")
267        ));
268        let mut backoff = Duration::ZERO;
269        loop {
270            if !backoff.is_zero() {
271                tokio::select! {
272                    _ = &mut shutdown => return Ok(()),
273                    _ = tokio::time::sleep(backoff) => {}
274                }
275            }
276            let step = tokio::select! {
277                _ = &mut shutdown => return Ok(()),
278                step = self.step() => step,
279            };
280            backoff = match step {
281                Ok(Step::Idle) => Duration::ZERO,
282                Ok(Step::Done(outcome)) => {
283                    log(&format!(
284                        "job {}: {}{}",
285                        outcome.id,
286                        outcome.state,
287                        match (outcome.applied, &outcome.follow_up) {
288                            (true, _) => ", merged file stored".to_string(),
289                            (false, Some(next)) => {
290                                format!(", the file changed meanwhile; follow-up {next}")
291                            }
292                            (false, None) => String::new(),
293                        }
294                    ));
295                    Duration::ZERO
296                }
297                Ok(Step::Rejected(why)) => {
298                    log(&why);
299                    Duration::ZERO
300                }
301                Err(e) => {
302                    if let Some(fatal) = self.fatal(&e) {
303                        return Err(fatal);
304                    }
305                    // Not a refusal of this device but of the claim itself,
306                    // such as the 404 of a server rolled back to a release
307                    // from before the queue. A 401 is left to retry: it is
308                    // also what a signature made just before a restart gets.
309                    if is_final(&e) && e.status() != Some(StatusCode::UNAUTHORIZED) {
310                        return Err(Fatal::NotRecall(format!(
311                            "{} refused a claim ({e}), and asking again will not change that. \
312                             If the server was rolled back to a release without the merge \
313                             queue, restart the worker once it has the queue again",
314                            self.cfg.server
315                        )));
316                    }
317                    let wait = next_wait(&e, backoff);
318                    log(&format!("claim failed ({e}); trying again in {wait:?}"));
319                    wait
320                }
321            };
322        }
323    }
324
325    /// Whether a refusal is of this device, which no retry will get past.
326    fn fatal(&self, e: &ApiError) -> Option<Fatal> {
327        match e.status()? {
328            // A 401 is also what a signature made a moment before the
329            // server restarted gets, which the next request fixes; only a
330            // device the server does not know, or revoked, is final.
331            StatusCode::UNAUTHORIZED
332                if e.message().contains("unknown device") || e.message().contains("revoked") =>
333            {
334                Some(Fatal::Refused(
335                    e.message().to_string(),
336                    self.identity_path(),
337                ))
338            }
339            StatusCode::FORBIDDEN => Some(Fatal::Refused(
340                e.message().to_string(),
341                self.identity_path(),
342            )),
343            _ => None,
344        }
345    }
346
347    /// Reads the server's discovery document, and goes on only if the
348    /// server lists the merge queue. An answer that says this is not such a
349    /// server is final; one that says nothing (the server is restarting, or
350    /// not up yet) is asked again.
351    pub async fn check_server(&self) -> Result<(), Fatal> {
352        let server = &self.cfg.server;
353        let mut backoff = Duration::ZERO;
354        loop {
355            let answer: Result<Discovery, ApiError> =
356                self.api.get(DISCOVERY_PATH, UNSIGNED_TIMEOUT).await;
357            match answer {
358                Ok(doc) if doc.can(CAPABILITY_MERGE_QUEUE) => return Ok(()),
359                Ok(doc) => {
360                    return Err(Fatal::NotRecall(format!(
361                        "{server} is Recall {}, which has no merge queue, so there is nothing \
362                         for a worker to do. Upgrade it, or stop the worker",
363                        doc.server.version
364                    )))
365                }
366                Err(ApiError::Body(why)) => {
367                    return Err(Fatal::NotRecall(format!(
368                        "{server} did not answer {DISCOVERY_PATH} with Recall's discovery \
369                         document ({why}); RECALL_WORKER_SERVER must name a Recall server"
370                    )))
371                }
372                Err(e) if is_final(&e) => {
373                    return Err(Fatal::NotRecall(format!(
374                        "{server} answered {DISCOVERY_PATH} with {e}: it is not a Recall server, \
375                         or one older than 0.4.1, which has no merge queue. Check \
376                         RECALL_WORKER_SERVER"
377                    )))
378                }
379                Err(e) => {
380                    backoff = next_wait(&e, backoff);
381                    log(&format!(
382                        "cannot reach {server} ({e}); trying again in {backoff:?}"
383                    ));
384                    tokio::time::sleep(backoff).await;
385                }
386            }
387        }
388    }
389
390    /// Enrols by the device flow: asks for a code, prints it with the key
391    /// fingerprint, and polls until the owner approves it. A restart while
392    /// waiting keeps polling the same enrolment.
393    pub async fn enrol(&mut self) -> Result<(), Fatal> {
394        loop {
395            match self.id.user_code.clone() {
396                Some(code) if self.id.enrollment_id.is_some() => self.announce(&code),
397                _ => self.start_enrolment().await?,
398            }
399            match self.wait_for_approval().await? {
400                Some(device_id) => {
401                    self.id.device_id = Some(device_id);
402                    self.id.enrollment_id = None;
403                    self.id.user_code = None;
404                    self.save()?;
405                    return Ok(());
406                }
407                // The code expired before anyone approved it: ask for a
408                // new one.
409                None => {
410                    self.id.enrollment_id = None;
411                    self.id.user_code = None;
412                    self.save()?;
413                }
414            }
415        }
416    }
417
418    async fn start_enrolment(&mut self) -> Result<(), Fatal> {
419        let req = EnrollRequest {
420            name: self.cfg.name.clone(),
421            public_key: self.id.public_key(),
422            agent: crate::user_agent(),
423            authkey: None,
424        };
425        let mut backoff = Duration::ZERO;
426        let pending: EnrollPending = loop {
427            match self.api.post(ENROLL_PATH, &req, UNSIGNED_TIMEOUT).await {
428                Ok(pending) => break pending,
429                Err(e) if e.status() == Some(StatusCode::CONFLICT) => {
430                    return Err(Fatal::Other(format!(
431                        "{}. Set RECALL_WORKER_NAME to another name, or revoke the old worker",
432                        e.message()
433                    )))
434                }
435                // A 404 among them: a server without the enrolment route
436                // answers every retry the same way.
437                Err(e) if is_final(&e) => {
438                    return Err(Fatal::Other(format!(
439                        "{} refused the enrolment ({e}); asking again would be refused \
440                         the same way",
441                        self.cfg.server
442                    )))
443                }
444                Err(e) => {
445                    backoff = next_wait(&e, backoff);
446                    log(&format!(
447                        "cannot reach the server to enrol ({e}); trying again in {backoff:?}"
448                    ));
449                    tokio::time::sleep(backoff).await;
450                }
451            }
452        };
453        self.id.enrollment_id = Some(pending.enrollment_id);
454        self.id.user_code = Some(pending.user_code.clone());
455        self.save()?;
456        self.announce(&pending.user_code);
457        Ok(())
458    }
459
460    /// Says what the owner has to do, once per code.
461    fn announce(&mut self, code: &str) {
462        if self.announced.as_deref() == Some(code) {
463            return;
464        }
465        self.announced = Some(code.to_string());
466        log(&format!(
467            "waiting for approval of code {code} as a worker, key fingerprint {}",
468            self.id.fingerprint()
469        ));
470        log(&format!(
471            "approve it from an admin device: recall devices approve {code} --worker \
472             --fingerprint {}",
473            self.id.fingerprint()
474        ));
475        log(&format!(
476            "or with the operator token: POST /v1/devices/approve \
477             {{\"user_code\":\"{code}\",\"scope\":\"worker\",\"fingerprint\":\"{}\"}} \
478             (see deploy/README.md, \"The merge worker\")",
479            self.id.fingerprint()
480        ));
481    }
482
483    /// How long to wait before the next poll of a code printed `since`
484    /// ago, `slowed` being what the server's `slow_down` answers added.
485    fn poll_interval(since: Duration, slowed: Duration) -> Duration {
486        let base = if since < FAST_POLL_FOR {
487            Duration::from_secs(POLL_INTERVAL_SECONDS)
488        } else {
489            SLOW_POLL
490        };
491        base + slowed
492    }
493
494    /// Polls until approved (`Some(device_id)`), or until the code expires
495    /// (`None`).
496    async fn wait_for_approval(&mut self) -> Result<Option<String>, Fatal> {
497        let Some(enrollment_id) = self.id.enrollment_id.clone() else {
498            return Ok(None);
499        };
500        let since = Instant::now();
501        let mut slowed = Duration::ZERO;
502        let req = EnrollPollRequest { enrollment_id };
503        loop {
504            tokio::time::sleep(Self::poll_interval(since.elapsed(), slowed)).await;
505            let polled: Result<EnrollPollResponse, ApiError> = self
506                .api
507                .post(ENROLL_POLL_PATH, &req, UNSIGNED_TIMEOUT)
508                .await;
509            match polled {
510                Ok(approved) if approved.scope == SCOPE_WORKER => {
511                    return Ok(Some(approved.device_id))
512                }
513                Ok(approved) => {
514                    // Kept, so the owner can find and revoke it; the worker
515                    // itself will not run as anything but a worker.
516                    self.id.device_id = Some(approved.device_id);
517                    self.save()?;
518                    return Err(Fatal::WrongScope(approved.scope, self.identity_path()));
519                }
520                Err(e) if e.status() == Some(StatusCode::BAD_REQUEST) => match e.message() {
521                    AUTHORIZATION_PENDING => {}
522                    SLOW_DOWN => slowed += Duration::from_secs(5),
523                    EXPIRED_TOKEN | INVALID_GRANT => {
524                        log("the code expired before it was approved; asking for a new one");
525                        return Ok(None);
526                    }
527                    ACCESS_DENIED => return Err(Fatal::Denied(self.identity_path())),
528                    other => log(&format!("poll refused: {other}")),
529                },
530                Err(e) if is_final(&e) => {
531                    return Err(Fatal::Other(format!(
532                        "{} refused the poll for this worker's approval ({e})",
533                        self.cfg.server
534                    )))
535                }
536                Err(e) => log(&format!("poll failed ({e}); trying again")),
537            }
538        }
539    }
540
541    /// Re-checks the CLI when the last check is old enough, or when a
542    /// merge has just failed.
543    async fn refresh_status(&mut self) {
544        let every = if self.status.logged_in {
545            self.cfg.claude_status_interval
546        } else {
547            self.cfg.claude_status_interval.min(NOT_LOGGED_IN_RECHECK)
548        };
549        if self.status_at.is_some_and(|at| at.elapsed() < every) {
550            return;
551        }
552        // Never checked before: whatever it finds is news.
553        let (was, first) = (self.status.logged_in, self.status.checked_at.is_empty());
554        self.status = self.merger.check_status().await;
555        self.status_at = Some(Instant::now());
556        if first || self.status.logged_in != was {
557            log(&if self.status.logged_in {
558                "the claude CLI is logged in; taking merge jobs".to_string()
559            } else {
560                format!(
561                    "the claude CLI cannot merge ({}); taking no jobs until it can. \
562                     Log it in with: docker compose exec -it -u node recall-worker claude setup-token",
563                    if self.status.error.is_empty() {
564                        "not logged in"
565                    } else {
566                        &self.status.error
567                    }
568                )
569            });
570        }
571    }
572
573    /// The claim this worker sends now: every kind it can do, which is
574    /// none while its CLI cannot merge, and that CLI's last check.
575    pub fn claim_request(&self) -> ClaimRequest {
576        ClaimRequest {
577            kinds: if self.status.logged_in {
578                vec![KIND_MERGE.to_string()]
579            } else {
580                Vec::new()
581            },
582            wait_seconds: self.cfg.wait_seconds,
583            lease_seconds: self.cfg.lease_seconds,
584            claude_cli: Some(ClaudeCliReport {
585                checked_at: self.status.checked_at.clone(),
586                available: self.status.available,
587                logged_in: self.status.logged_in,
588                error: self.status.error.clone(),
589            }),
590        }
591    }
592
593    /// One claim, and the job it brought, if any.
594    pub async fn step(&mut self) -> Result<Step, ApiError> {
595        self.refresh_status().await;
596        let device_id = self.id.device_id.clone().unwrap_or_default();
597        let claim = self.claim_request();
598        let answer: ClaimResponse = self
599            .api
600            .post_signed(
601                jobs::CLAIM_PATH,
602                &claim,
603                self.id.key(),
604                &device_id,
605                Duration::from_secs(self.cfg.wait_seconds + 30),
606            )
607            .await?;
608        let Some(job) = answer.job else {
609            return Ok(Step::Idle);
610        };
611        let result = self.work(&job).await;
612        self.report(&job, &result).await
613    }
614
615    /// Does one job, and says what to report.
616    ///
617    /// A merge that fails has the CLI checked again before the next claim,
618    /// rather than at the next scheduled check: a CLI that was logged out
619    /// under the worker should stop it taking jobs now, not in half an hour.
620    pub async fn work(&mut self, job: &Job) -> ResultRequest {
621        let outcome = match (job.kind.as_str(), &job.merge) {
622            (KIND_MERGE, Some(m)) => {
623                if recall_wire::content_sha256(&m.stored.content) != m.stored.sha256
624                    || recall_wire::content_sha256(&m.incoming.content) != m.incoming.sha256
625                {
626                    Err("a version's content does not match its sha256".to_string())
627                } else if m.stored.content == m.incoming.content {
628                    // Identical inputs never reach claude: there is nothing
629                    // to reconcile, and a call would cost money to say so.
630                    Ok(m.incoming.content.clone())
631                } else {
632                    log(&format!(
633                        "job {}: merging {}/{} (attempt {})",
634                        job.id, m.project_key, m.file_path, job.attempt
635                    ));
636                    let merged = self
637                        .merger
638                        .merge(&m.stored.content, &m.incoming.content)
639                        .await;
640                    if merged.is_err() {
641                        self.status_at = None;
642                    }
643                    merged.map_err(|e| e.to_string())
644                }
645            }
646            (kind, _) => Err(format!("this worker does not do {kind} jobs")),
647        };
648        match outcome {
649            Ok(content) => ResultRequest {
650                lease_id: job.lease_id.clone(),
651                merge: Some(MergeResult { content }),
652                error: None,
653            },
654            Err(error) => {
655                log(&format!("job {}: {error}", job.id));
656                ResultRequest {
657                    lease_id: job.lease_id.clone(),
658                    merge: None,
659                    error: Some(error),
660                }
661            }
662        }
663    }
664
665    /// Posts a result, trying again when the server could not be reached.
666    /// Posting the same result twice is safe: the server records it once.
667    async fn report(&self, job: &Job, result: &ResultRequest) -> Result<Step, ApiError> {
668        let device_id = self.id.device_id.clone().unwrap_or_default();
669        let mut wait = Duration::from_secs(1);
670        let mut last = None;
671        for _ in 0..RESULT_TRIES {
672            let posted: Result<ResultResponse, ApiError> = self
673                .api
674                .post_signed(
675                    &jobs::result_path(&job.id),
676                    result,
677                    self.id.key(),
678                    &device_id,
679                    RESULT_TIMEOUT,
680                )
681                .await;
682            match posted {
683                Ok(outcome) => return Ok(Step::Done(outcome)),
684                Err(e)
685                    if matches!(
686                        e.status(),
687                        Some(
688                            StatusCode::CONFLICT | StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST
689                        )
690                    ) =>
691                {
692                    return Ok(Step::Rejected(format!(
693                        "job {}: the server did not take the result: {}",
694                        job.id,
695                        e.message()
696                    )))
697                }
698                Err(e) if self.fatal(&e).is_some() => return Err(e),
699                Err(e) => {
700                    log(&format!(
701                        "job {}: posting the result failed ({e}); trying again in {wait:?}",
702                        job.id
703                    ));
704                    last = Some(e);
705                    tokio::time::sleep(wait).await;
706                    wait *= 2;
707                }
708            }
709        }
710        Err(last.unwrap_or_else(|| ApiError::Transport("no attempt was made".into())))
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use recall_wire::{MergeInput, MergeSide};
718    use tokio::io::{AsyncReadExt, AsyncWriteExt};
719
720    fn config(dir: &std::path::Path, server: &str) -> Config {
721        Config {
722            server: server.to_string(),
723            data_dir: dir.to_path_buf(),
724            // Never run: nothing in these tests may reach a real CLI.
725            claude_bin: "definitely-not-a-real-claude".into(),
726            ..Config::default()
727        }
728    }
729
730    fn side(content: &str) -> MergeSide {
731        MergeSide {
732            sha256: recall_wire::content_sha256(content),
733            content: content.to_string(),
734            source_env: "laptop".into(),
735            updated_at: "2026-10-02T09:10:11.020Z".into(),
736        }
737    }
738
739    fn job(stored: MergeSide, incoming: MergeSide) -> Job {
740        Job {
741            id: "job_1".into(),
742            kind: KIND_MERGE.into(),
743            lease_id: "lse_1".into(),
744            lease_expires_at: "2026-10-02T09:16:03.118Z".into(),
745            attempt: 1,
746            merge: Some(MergeInput {
747                project_key: "acme/app".into(),
748                file_path: "topics/auth.md".into(),
749                stored,
750                incoming,
751            }),
752        }
753    }
754
755    /// A worker for a server nothing listens on: nothing here sends a
756    /// request.
757    fn worker() -> (tempfile::TempDir, Worker) {
758        let dir = tempfile::tempdir().unwrap();
759        let w = Worker::new(config(dir.path(), "http://127.0.0.1:9")).unwrap();
760        (dir, w)
761    }
762
763    /// Either side's content must match the hash the server sent with it,
764    /// the stored side as much as the incoming one, or the worker merges
765    /// nothing and says why.
766    #[tokio::test]
767    async fn a_version_that_does_not_match_its_hash_is_not_merged() {
768        let (_dir, mut w) = worker();
769        let mut stored = side("A");
770        stored.sha256 = recall_wire::content_sha256("not A");
771        let mut incoming = side("B");
772        incoming.sha256 = recall_wire::content_sha256("not B");
773        for (s, i) in [
774            (stored.clone(), side("B")),
775            (side("A"), incoming.clone()),
776            // Identical but for the hash: without the check this would be
777            // answered as a merge without asking claude at all.
778            (stored.clone(), {
779                let mut same = side("A");
780                same.sha256 = stored.sha256.clone();
781                same
782            }),
783        ] {
784            let result = w.work(&job(s, i)).await;
785            assert!(result.merge.is_none(), "{result:?}");
786            assert!(
787                result.error.as_deref().unwrap().contains("sha256"),
788                "{result:?}"
789            );
790            assert_eq!(result.lease_id, "lse_1");
791        }
792        // Matching hashes and the same content: answered without claude.
793        let result = w.work(&job(side("A"), side("A"))).await;
794        assert_eq!(result.merge.unwrap().content, "A");
795    }
796
797    /// A worker whose CLI cannot merge asks for no kind of job, so it is
798    /// never handed one it would only fail.
799    #[test]
800    fn a_worker_that_cannot_merge_claims_nothing() {
801        let (_dir, mut w) = worker();
802        assert!(w.claim_request().kinds.is_empty(), "never checked");
803        w.status = Status {
804            checked_at: "2026-10-02T09:13:40.002Z".into(),
805            available: true,
806            logged_in: false,
807            error: "not logged in".into(),
808        };
809        let claim = w.claim_request();
810        assert!(claim.kinds.is_empty());
811        assert_eq!(claim.claude_cli.unwrap().error, "not logged in");
812        w.status.logged_in = true;
813        assert_eq!(w.claim_request().kinds, vec![KIND_MERGE.to_string()]);
814    }
815
816    /// A merge that fails has the CLI checked again before the next claim.
817    #[tokio::test]
818    async fn a_failed_merge_has_the_cli_checked_again() {
819        let (_dir, mut w) = worker();
820        w.status.logged_in = true;
821        w.status_at = Some(Instant::now());
822        let result = w.work(&job(side("A"), side("B"))).await;
823        assert!(result.error.unwrap().contains("unavailable"));
824        assert!(w.status_at.is_none());
825    }
826
827    #[test]
828    fn a_code_is_polled_quickly_for_a_minute_then_slowly() {
829        let fast = Duration::from_secs(POLL_INTERVAL_SECONDS);
830        assert_eq!(Worker::poll_interval(Duration::ZERO, Duration::ZERO), fast);
831        assert_eq!(
832            Worker::poll_interval(Duration::from_secs(59), Duration::from_secs(5)),
833            fast + Duration::from_secs(5)
834        );
835        assert_eq!(
836            Worker::poll_interval(Duration::from_secs(61), Duration::ZERO),
837            SLOW_POLL
838        );
839        assert!(SLOW_POLL >= Duration::from_secs(30));
840    }
841
842    /// A key enrolled with one server is never offered to another.
843    #[test]
844    fn an_identity_is_bound_to_its_server() {
845        let dir = tempfile::tempdir().unwrap();
846        Worker::new(config(dir.path(), "http://recall-server:8787")).unwrap();
847        match Worker::new(config(dir.path(), "https://recall.example.com")) {
848            Err(Fatal::OtherServer {
849                made_for,
850                configured,
851                ..
852            }) => {
853                assert_eq!(made_for, "http://recall-server:8787");
854                assert_eq!(configured, "https://recall.example.com");
855            }
856            other => panic!("not refused: {:?}", other.err()),
857        }
858        assert!(Worker::new(config(dir.path(), "http://recall-server:8787")).is_ok());
859    }
860
861    /// A file an earlier build wrote records no server. Unused, it is bound
862    /// to this one; part way through an enrolment, it is refused.
863    #[test]
864    fn an_identity_from_before_servers_were_recorded() {
865        let dir = tempfile::tempdir().unwrap();
866        let mut id = Identity::from_seed([3; 32]);
867        id.save(dir.path()).unwrap();
868        Worker::new(config(dir.path(), "http://recall-server:8787")).unwrap();
869        assert_eq!(
870            Identity::load(dir.path())
871                .unwrap()
872                .unwrap()
873                .server
874                .as_deref(),
875            Some("http://recall-server:8787")
876        );
877
878        let dir = tempfile::tempdir().unwrap();
879        id.device_id = Some("dev_somewhere".into());
880        id.save(dir.path()).unwrap();
881        assert!(matches!(
882            Worker::new(config(dir.path(), "http://recall-server:8787")),
883            Err(Fatal::Unbound(_))
884        ));
885    }
886
887    #[test]
888    fn a_worker_needs_a_data_directory() {
889        assert!(matches!(
890            Worker::new(Config {
891                server: "http://127.0.0.1:9".into(),
892                ..Config::default()
893            }),
894            Err(Fatal::Other(_))
895        ));
896    }
897
898    /// A stand-in server on loopback that answers each request with what
899    /// `answer` returns for its method and path, and counts the requests.
900    async fn fake_server(
901        answer: fn(&str, &str) -> (u16, String),
902    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
903        use std::sync::atomic::{AtomicUsize, Ordering};
904        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
905        let url = format!("http://{}", listener.local_addr().unwrap());
906        let count = std::sync::Arc::new(AtomicUsize::new(0));
907        let counted = count.clone();
908        tokio::spawn(async move {
909            while let Ok((mut conn, _)) = listener.accept().await {
910                let counted = counted.clone();
911                tokio::spawn(async move {
912                    let mut buf = Vec::new();
913                    let mut chunk = [0u8; 4096];
914                    // The head, then as much body as it says there is.
915                    let head_end = loop {
916                        let n = conn.read(&mut chunk).await.unwrap_or(0);
917                        if n == 0 {
918                            return;
919                        }
920                        buf.extend_from_slice(&chunk[..n]);
921                        if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
922                            break i + 4;
923                        }
924                    };
925                    let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
926                    let length = head
927                        .lines()
928                        .find_map(|l| {
929                            let (k, v) = l.split_once(':')?;
930                            k.eq_ignore_ascii_case("content-length")
931                                .then(|| v.trim().parse::<usize>().ok())?
932                        })
933                        .unwrap_or(0);
934                    while buf.len() < head_end + length {
935                        let n = conn.read(&mut chunk).await.unwrap_or(0);
936                        if n == 0 {
937                            break;
938                        }
939                        buf.extend_from_slice(&chunk[..n]);
940                    }
941                    let mut first = head.lines().next().unwrap_or_default().split(' ');
942                    let (method, path) = (first.next().unwrap_or(""), first.next().unwrap_or(""));
943                    counted.fetch_add(1, Ordering::SeqCst);
944                    let (status, body) = answer(method, path);
945                    let resp = format!(
946                        "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\n\
947                         content-length: {}\r\nconnection: close\r\n\r\n{body}",
948                        body.len()
949                    );
950                    let _ = conn.write_all(resp.as_bytes()).await;
951                    let _ = conn.shutdown().await;
952                });
953            }
954        });
955        (url, count)
956    }
957
958    fn discovery(capabilities: &str) -> String {
959        format!(
960            r#"{{"protocol":{{"current":1,"supported":[1]}},"server":{{"version":"0.4.0","build":{{"channel":"release"}}}},"min_client":"0.1.0","auth":{{"methods":["bearer"]}},"capabilities":{{{capabilities}}}}}"#
961        )
962    }
963
964    /// Runs a worker against `url` until it stops, which must be soon.
965    async fn run_briefly(url: &str) -> Result<(), Fatal> {
966        let dir = tempfile::tempdir().unwrap();
967        let w = Worker::new(config(dir.path(), url)).unwrap();
968        tokio::time::timeout(Duration::from_secs(10), w.run(std::future::pending()))
969            .await
970            .expect("the worker kept retrying something that will not change")
971    }
972
973    /// A server that is not Recall, or has no merge queue, is never asked
974    /// to enrol anything.
975    #[tokio::test]
976    async fn a_server_without_the_merge_queue_is_never_enrolled_with() {
977        let (url, count) = fake_server(|_, path| match path {
978            DISCOVERY_PATH => (404, r#"{"error":"not found"}"#.into()),
979            _ => (500, "{}".into()),
980        })
981        .await;
982        assert!(matches!(run_briefly(&url).await, Err(Fatal::NotRecall(_))));
983        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
984
985        let (url, count) = fake_server(|_, path| match path {
986            DISCOVERY_PATH => (200, discovery(r#""devices":{}"#)),
987            _ => (500, "{}".into()),
988        })
989        .await;
990        match run_briefly(&url).await {
991            Err(Fatal::NotRecall(why)) => assert!(why.contains("no merge queue"), "{why}"),
992            other => panic!("not refused: {other:?}"),
993        }
994        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
995
996        let (url, _) = fake_server(|_, _| (200, "<html>hello</html>".into())).await;
997        assert!(matches!(run_briefly(&url).await, Err(Fatal::NotRecall(_))));
998    }
999
1000    /// An enrolment refused with a 404 is refused for good: the worker says
1001    /// so once, rather than asking again every minute.
1002    #[tokio::test]
1003    async fn an_enrolment_refused_with_404_is_final() {
1004        let (url, count) = fake_server(|_, path| match path {
1005            DISCOVERY_PATH => (200, discovery(r#""merge_queue":{}"#)),
1006            _ => (404, r#"{"error":"not found"}"#.into()),
1007        })
1008        .await;
1009        match run_briefly(&url).await {
1010            Err(Fatal::Other(why)) => assert!(why.contains("refused the enrolment"), "{why}"),
1011            other => panic!("not refused: {other:?}"),
1012        }
1013        assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1014    }
1015}