Skip to main content

weave_cli/
queue.rs

1//! `weave land --queue` — a landing queue kept in the shared bare repository.
2//! Experimental: the protocol and its refs may change.
3//!
4//! Many agents, one origin, one branch: every candidate is landed by exactly
5//! one process at a time, in submission order, so no push can race another.
6//!
7//! # Why refs and not a daemon
8//!
9//! The bare origin is the one thing every agent container already shares, and
10//! `git push --atomic` with `--force-with-lease` on every ref is a
11//! compare-and-swap over several refs at once. A daemon would need an
12//! address every container can reach, a process someone keeps alive, and its
13//! own crash story; the refs need none of that and survive any process dying.
14//!
15//! # Protocol
16//!
17//! Three append-only commit chains under `refs/weave/<branch>/` (each commit's
18//! tree is empty, its first parent the previous head, its message JSON). Only
19//! ref creations and fast-forwards are ever pushed, so an origin with
20//! `receive.denyNonFastForwards` and `receive.denyDeletes` works unchanged
21//! (its pre-receive hook must admit `refs/weave/*`).
22//!
23//! - `queue`: one commit per submission. Its second parent is the candidate
24//!   (so the candidate travels with it and stays reachable); the ticket
25//!   number is the chain depth (0, 1, 2, …). Enqueue = fast-forward with a
26//!   lease on the old head; losing the race means retrying on the new head.
27//! - `done`: one commit per processed ticket, in ticket order. Its message is
28//!   the result (landed / refused, reason, files, conflict regions, report);
29//!   a landed result's second parent is the commit published.
30//! - `lock`: who lands. A commit whose `holder` is null means free. Acquire
31//!   = append a commit naming yourself with a lease on the free head; the
32//!   holder appends a renewal every `ttl/3`. A waiter that sees the same lock
33//!   head unchanged for `ttl` by its own clock (skew-free) takes it over with
34//!   a lease on that head.
35//!
36//! Landing ticket `n` (the next after `done`'s head) is ONE atomic push:
37//! `main` (lease: the tip that was merged and checked), `done` (lease: its
38//! head) and a lock renewal (lease: the holder's last lock head). A lander
39//! whose lock was taken over has a stale lock lease, so its push fails as a
40//! whole: main never moves except by the current holder, and only to a tree
41//! that [`crate::onto::prepare`] gated and verified against that exact tip.
42//!
43//! A submitter enqueues, then waits for its ticket's result in `done`. While
44//! waiting it takes the lock when it is free (or stale) and lands tickets in
45//! order up to its own, so the queue needs no process of its own and keeps
46//! moving while any submitter is alive. A ticket outlives its submitter.
47
48use std::collections::HashMap;
49use std::io::Write;
50use std::path::{Path, PathBuf};
51use std::process::Command;
52use std::sync::atomic::{AtomicBool, Ordering};
53use std::sync::{Arc, Mutex};
54use std::time::{Duration, Instant};
55
56use serde_json::{json, Value};
57use weave_core::host::Host;
58
59use crate::gitscan;
60use crate::onto::{self, Options, Prep};
61
62type R<T> = Result<T, Box<dyn std::error::Error>>;
63
64const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
65
66/// What `--queue` was asked to do.
67pub struct QueueOptions {
68    pub onto: Options,
69    /// A lock head unchanged this long belongs to a dead lander.
70    pub lease_ttl: Duration,
71    /// Give up waiting for the result after this long (the ticket stays).
72    pub wait_timeout: Duration,
73    /// How often a waiter looks at origin.
74    pub poll: Duration,
75}
76
77/// The result of one ticket, as recorded in `done`.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct TicketResult {
80    pub ticket: u64,
81    pub landed: Option<String>,
82    pub reason: String,
83    pub files: Vec<String>,
84    pub regions: Vec<String>,
85    pub report: String,
86    pub tip: String,
87}
88
89/// How a `--queue` run ended.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum QueueOutcome {
92    Landed(String),
93    Refused(String),
94}
95
96/// Plumbing over one clone and its remote.
97struct Ctx {
98    dir: PathBuf,
99    remote: String,
100    branch: String,
101    me: String,
102}
103
104fn cmd_git(dir: &Path, args: &[&str]) -> Command {
105    let mut c = Command::new("git");
106    c.args(args)
107        .current_dir(dir)
108        .env("GIT_AUTHOR_NAME", "weave-queue")
109        .env("GIT_AUTHOR_EMAIL", "weave-queue@localhost")
110        .env("GIT_COMMITTER_NAME", "weave-queue")
111        .env("GIT_COMMITTER_EMAIL", "weave-queue@localhost");
112    c
113}
114
115/// `(success, stdout, stderr)`.
116fn run(dir: &Path, args: &[&str], limit: Duration) -> R<(bool, String, String)> {
117    let (status, out, err) =
118        gitscan::run_bounded(cmd_git(dir, args), &format!("git {}", args[0]), None, limit)?;
119    Ok((
120        status.success(),
121        String::from_utf8_lossy(&out).to_string(),
122        String::from_utf8_lossy(&err).to_string(),
123    ))
124}
125
126fn git(dir: &Path, args: &[&str]) -> R<String> {
127    match run(dir, args, Duration::from_secs(300))? {
128        (true, out, _) => Ok(out.trim().to_string()),
129        (false, _, err) => Err(format!("git {} failed: {}", args.join(" "), err.trim()).into()),
130    }
131}
132
133/// A push lost to another writer (a lease or fast-forward refusal), as
134/// opposed to origin being unreachable.
135fn lost_race(err: &str) -> bool {
136    // a hook or a receive.deny* setting refusing the ref is not a race:
137    // retrying would spin forever
138    if ["hook declined", "denied", "deny", "not allowed"]
139        .iter()
140        .any(|w| err.contains(w))
141    {
142        return false;
143    }
144    [
145        "incorrect old value",
146        "stale info",
147        "rejected",
148        "fetch first",
149        "non-fast-forward",
150        "atomic push failed",
151        "failed to update ref",
152        "cannot lock ref",
153        "already exists",
154    ]
155    .iter()
156    .any(|w| err.contains(w))
157}
158
159fn jitter(base: Duration) -> Duration {
160    let n = std::time::SystemTime::now()
161        .duration_since(std::time::UNIX_EPOCH)
162        .map(|d| d.subsec_nanos())
163        .unwrap_or(0) as u64;
164    let pid = std::process::id() as u64;
165    let ms = base.as_millis() as u64;
166    Duration::from_millis(ms + (n ^ pid.wrapping_mul(2654435761)) % (ms.max(1)))
167}
168
169impl Ctx {
170    fn r(&self, name: &str) -> String {
171        format!("refs/weave/{}/{name}", self.branch)
172    }
173    fn local(&self, name: &str) -> String {
174        format!("refs/weave-seen/{}/{}/{name}", self.remote, self.branch)
175    }
176
177    /// Heads of `queue`, `done`, `lock` and the branch on origin.
178    fn heads(&self) -> R<HashMap<String, String>> {
179        let main = format!("refs/heads/{}", self.branch);
180        let pat = format!("refs/weave/{}/*", self.branch);
181        let (ok, out, err) = run(
182            &self.dir,
183            &["ls-remote", &self.remote, &pat, &main],
184            Duration::from_secs(120),
185        )?;
186        if !ok {
187            return Err(format!("git ls-remote {} failed: {}", self.remote, err.trim()).into());
188        }
189        let mut m = HashMap::new();
190        for line in out.lines() {
191            if let Some((sha, name)) = line.split_once('\t') {
192                let key = if name == main {
193                    "tip".to_string()
194                } else {
195                    name.rsplit('/').next().unwrap_or(name).to_string()
196                };
197                m.insert(key, sha.to_string());
198            }
199        }
200        Ok(m)
201    }
202
203    /// Bring the queue refs (and the commits they reach) and the branch tip
204    /// into this clone, under `refs/weave-seen/`.
205    fn fetch(&self) -> R<()> {
206        let specs = [
207            format!(
208                "+refs/weave/{}/*:refs/weave-seen/{}/{}/*",
209                self.branch, self.remote, self.branch
210            ),
211            format!("+refs/heads/{}:{}", self.branch, self.local("tip")),
212        ];
213        let mut args = vec![
214            "fetch",
215            "-q",
216            "--no-tags",
217            "--no-write-fetch-head",
218            &self.remote,
219        ];
220        args.extend(specs.iter().map(String::as_str));
221        let (ok, _, err) = run(&self.dir, &args, Duration::from_secs(300))?;
222        if !ok {
223            return Err(format!("git fetch {} failed: {}", self.remote, err.trim()).into());
224        }
225        Ok(())
226    }
227
228    fn message(&self, sha: &str) -> R<Value> {
229        let body = git(&self.dir, &["show", "-s", "--format=%B", sha])?;
230        Ok(serde_json::from_str(body.trim()).unwrap_or(Value::Null))
231    }
232
233    /// A chain commit: empty tree, `parents` in order, `msg` as JSON.
234    fn chain_commit(&self, parents: &[&str], msg: &Value) -> R<String> {
235        let _ = git(&self.dir, &["mktree"]); // the empty tree must exist locally
236        let text = msg.to_string();
237        let mut args = vec!["commit-tree", EMPTY_TREE];
238        for p in parents {
239            args.push("-p");
240            args.push(p);
241        }
242        args.push("-m");
243        args.push(&text);
244        git(&self.dir, &args)
245    }
246
247    /// The parentless first commit of a chain, so that every real entry has
248    /// the chain as its first parent and its payload as its second.
249    fn genesis(&self, chain: &str) -> R<String> {
250        self.chain_commit(&[], &json!({"kind": "genesis", "chain": chain}))
251    }
252
253    /// One atomic push of `(new, ref, expected old)`; `Ok(false)` when another
254    /// writer got there first.
255    fn cas(&self, updates: &[(&str, String, Option<&str>)]) -> R<bool> {
256        let mut args: Vec<String> = vec![
257            "push".into(),
258            "-q".into(),
259            "--atomic".into(),
260            self.remote.clone(),
261        ];
262        for (new, refname, old) in updates {
263            args.push(format!(
264                "--force-with-lease={refname}:{}",
265                old.unwrap_or("")
266            ));
267            args.push(format!("{new}:{refname}"));
268        }
269        let argv: Vec<&str> = args.iter().map(String::as_str).collect();
270        let (ok, _, err) = run(&self.dir, &argv, Duration::from_secs(300))?;
271        if ok {
272            return Ok(true);
273        }
274        if lost_race(&err) {
275            return Ok(false);
276        }
277        Err(format!("git push to {} failed: {}", self.remote, err.trim()).into())
278    }
279
280    fn ticket_of(&self, sha: &str) -> R<u64> {
281        self.message(sha)?
282            .get("ticket")
283            .and_then(Value::as_u64)
284            .ok_or_else(|| format!("{sha} is not a weave queue entry").into())
285    }
286
287    /// Append `candidate` to the queue; its ticket number.
288    fn enqueue(&self, candidate: &str, meta: &Value) -> R<u64> {
289        loop {
290            let heads = self.heads()?;
291            let old = heads.get("queue").cloned();
292            let n = match &old {
293                Some(q) => {
294                    self.fetch()?;
295                    self.ticket_of(q)? + 1
296                }
297                None => 0,
298            };
299            let mut msg = meta.clone();
300            msg["kind"] = json!("ticket");
301            msg["ticket"] = json!(n);
302            msg["candidate"] = json!(candidate);
303            msg["submitter"] = json!(self.me);
304            let first = match &old {
305                Some(q) => q.clone(),
306                None => self.genesis("queue")?,
307            };
308            let entry = self.chain_commit(&[&first, candidate], &msg)?;
309            if self.cas(&[(&entry, self.r("queue"), old.as_deref())])? {
310                return Ok(n);
311            }
312            std::thread::sleep(jitter(Duration::from_millis(50)));
313        }
314    }
315
316    /// The chain entry for ticket `n`, walking back from `head` (ticket
317    /// `head_n`) along first parents.
318    fn entry(&self, head: &str, head_n: u64, n: u64) -> R<String> {
319        if n > head_n {
320            return Err(format!("ticket {n} is not in the chain (head is {head_n})").into());
321        }
322        let back = head_n - n;
323        let e = git(&self.dir, &["rev-parse", &format!("{head}~{back}")])?;
324        let got = self.ticket_of(&e)?;
325        if got != n {
326            return Err(format!("chain entry {e} is ticket {got}, expected {n}").into());
327        }
328        Ok(e)
329    }
330
331    /// The recorded result of ticket `n`, if `done` has it.
332    fn result(&self, n: u64, heads: &HashMap<String, String>) -> R<Option<TicketResult>> {
333        let Some(done) = heads.get("done") else {
334            return Ok(None);
335        };
336        if git(&self.dir, &["cat-file", "-e", done]).is_err() {
337            self.fetch()?;
338        }
339        let dn = self.ticket_of(done)?;
340        if dn < n {
341            return Ok(None);
342        }
343        let e = self.entry(done, dn, n)?;
344        let m = self.message(&e)?;
345        let strs = |k: &str| -> Vec<String> {
346            m.get(k)
347                .and_then(Value::as_array)
348                .map(|a| {
349                    a.iter()
350                        .filter_map(|v| v.as_str().map(String::from))
351                        .collect()
352                })
353                .unwrap_or_default()
354        };
355        let s = |k: &str| m.get(k).and_then(Value::as_str).unwrap_or("").to_string();
356        Ok(Some(TicketResult {
357            ticket: n,
358            landed: m.get("landed").and_then(Value::as_str).map(String::from),
359            reason: s("reason"),
360            files: strs("files"),
361            regions: strs("regions"),
362            report: s("report"),
363            tip: s("tip"),
364        }))
365    }
366}
367
368/// The lock while this process holds it: the head it last wrote, renewed by
369/// a heartbeat thread. Every push that must only happen while holding the
370/// lock goes through [`Held::with`], which serializes it with the heartbeat.
371struct Held {
372    head: Mutex<String>,
373    lost: AtomicBool,
374    stop: AtomicBool,
375}
376
377impl Held {
378    fn is_lost(&self) -> bool {
379        self.lost.load(Ordering::SeqCst)
380    }
381}
382
383fn lock_msg(ctx: &Ctx, holder: Option<&str>) -> Value {
384    json!({"kind": "lock", "holder": holder, "by": ctx.me,
385           "at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
386                 .map(|d| d.as_millis() as u64).unwrap_or(0)})
387}
388
389fn lock_is_free(ctx: &Ctx, head: Option<&String>) -> R<bool> {
390    match head {
391        None => Ok(true),
392        Some(h) => {
393            if git(&ctx.dir, &["cat-file", "-e", h]).is_err() {
394                ctx.fetch()?;
395            }
396            Ok(ctx.message(h)?.get("holder").is_none_or(Value::is_null))
397        }
398    }
399}
400
401/// Try to take the lock over `expected` (None: it does not exist yet).
402fn acquire(ctx: &Ctx, expected: Option<&str>) -> R<Option<Arc<Held>>> {
403    let parents: Vec<&str> = expected.into_iter().collect();
404    let new = ctx.chain_commit(&parents, &lock_msg(ctx, Some(&ctx.me)))?;
405    if !ctx.cas(&[(&new, ctx.r("lock"), expected)])? {
406        return Ok(None);
407    }
408    Ok(Some(Arc::new(Held {
409        head: Mutex::new(new),
410        lost: AtomicBool::new(false),
411        stop: AtomicBool::new(false),
412    })))
413}
414
415/// Renew the lock every `ttl/3` until stopped or lost.
416fn heartbeat(
417    dir: PathBuf,
418    remote: String,
419    branch: String,
420    me: String,
421    held: Arc<Held>,
422    ttl: Duration,
423) {
424    let ctx = Ctx {
425        dir,
426        remote,
427        branch,
428        me,
429    };
430    let every = ttl / 3;
431    loop {
432        let mut slept = Duration::ZERO;
433        while slept < every {
434            if held.stop.load(Ordering::SeqCst) || held.is_lost() {
435                return;
436            }
437            let step = Duration::from_millis(100).min(every - slept);
438            std::thread::sleep(step);
439            slept += step;
440        }
441        let mut head = held.head.lock().unwrap();
442        if held.stop.load(Ordering::SeqCst) || held.is_lost() {
443            return;
444        }
445        let renewed = ctx
446            .chain_commit(&[&head], &lock_msg(&ctx, Some(&ctx.me)))
447            .and_then(|new| Ok((ctx.cas(&[(&new, ctx.r("lock"), Some(&head))])?, new)));
448        match renewed {
449            Ok((true, new)) => *head = new,
450            Ok((false, _)) => held.lost.store(true, Ordering::SeqCst),
451            Err(_) => {} // origin unreachable for a moment: try again next beat
452        }
453    }
454}
455
456fn release(ctx: &Ctx, held: &Held) {
457    held.stop.store(true, Ordering::SeqCst);
458    let head = held.head.lock().unwrap();
459    if held.is_lost() {
460        return;
461    }
462    if let Ok(new) = ctx.chain_commit(&[&head], &lock_msg(ctx, None)) {
463        let _ = ctx.cas(&[(&new, ctx.r("lock"), Some(&head))]);
464    }
465}
466
467/// The persistent detached worktree the lander merges and verifies in,
468/// reset to `candidate`.
469fn lander_tree(ctx: &Ctx, candidate: &str) -> R<PathBuf> {
470    let common = PathBuf::from(git(
471        &ctx.dir,
472        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
473    )?);
474    let safe: String = ctx
475        .branch
476        .chars()
477        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
478        .collect();
479    let wt = common
480        .join("weave-queue")
481        .join(format!("{}-{safe}", ctx.remote));
482    if !wt.join(".git").exists() {
483        let _ = git(&ctx.dir, &["worktree", "prune"]);
484        std::fs::create_dir_all(wt.parent().unwrap())?;
485        git(
486            &ctx.dir,
487            &[
488                "worktree",
489                "add",
490                "-q",
491                "-f",
492                "--detach",
493                wt.to_str().unwrap(),
494                candidate,
495            ],
496        )?;
497        return Ok(wt);
498    }
499    // We hold the queue lock, so no other lander of this clone is in here —
500    // but a killed lander's git child may still be running. A lock file is
501    // removed only when no running process has it open.
502    let wgd = PathBuf::from(git(&wt, &["rev-parse", "--absolute-git-dir"])?);
503    let mut said = Vec::new();
504    if let Some(why) = crate::mergestate::clear_stale_lock(&wgd, &mut said)? {
505        return Err(format!("the lander's worktree {}: {why}", wt.display()).into());
506    }
507    if wgd.join("MERGE_HEAD").exists() {
508        let _ = git(&wt, &["merge", "--abort"]);
509    }
510    git(&wt, &["reset", "-q", "--hard"])?;
511    crate::mergestate::clear_record(&wgd);
512    git(&wt, &["checkout", "-q", "-f", "--detach", candidate])?;
513    git(&wt, &["clean", "-q", "-f", "-d"])?;
514    Ok(wt)
515}
516
517/// Land tickets in order, from the one after `done`'s head, until `mine` is
518/// done, the queue is empty, or the lock is lost. Each landing is one
519/// atomic push fenced by the lock.
520fn land_tickets(
521    ctx: &Ctx,
522    q: &QueueOptions,
523    host: &Host,
524    held: &Held,
525    mine: u64,
526    log: &mut dyn Write,
527) -> R<()> {
528    loop {
529        if held.is_lost() {
530            return Ok(());
531        }
532        ctx.fetch()?;
533        let seen =
534            |name: &str| git(&ctx.dir, &["rev-parse", "-q", "--verify", &ctx.local(name)]).ok();
535        let (Some(queue), Some(tip)) = (seen("queue"), seen("tip")) else {
536            return Ok(());
537        };
538        let done = git(
539            &ctx.dir,
540            &["rev-parse", "-q", "--verify", &ctx.local("done")],
541        )
542        .ok();
543        let qn = ctx.ticket_of(&queue)?;
544        let next = match &done {
545            Some(d) => ctx.ticket_of(d)? + 1,
546            None => 0,
547        };
548        if next > qn {
549            return Ok(());
550        }
551        let entry = ctx.entry(&queue, qn, next)?;
552        let meta = ctx.message(&entry)?;
553        let candidate = git(&ctx.dir, &["rev-parse", &format!("{entry}^2")])?;
554        let submitter = meta
555            .get("submitter")
556            .and_then(Value::as_str)
557            .unwrap_or("?")
558            .to_string();
559
560        let opts = Options {
561            remote: ctx.remote.clone(),
562            branch: ctx.branch.clone(),
563            resolver: None,
564            verify_cmd: meta
565                .get("verify_cmd")
566                .and_then(Value::as_str)
567                .map(String::from),
568            verify_timeout: Duration::from_secs(
569                meta.get("verify_timeout")
570                    .and_then(Value::as_u64)
571                    .unwrap_or(1800),
572            ),
573            // the submitter's --check, as resolved where it was submitted
574            check: (meta.get("check").and_then(Value::as_str) == Some("sem")).then(|| {
575                crate::semcheck::Check {
576                    checkers: meta
577                        .get("checkers")
578                        .and_then(Value::as_str)
579                        .map(String::from),
580                }
581            }),
582            attempts: 1,
583            certificate_dir: q.onto.certificate_dir.clone(),
584        };
585        let mut report: Vec<u8> = Vec::new();
586        let mut checked: Option<Value> = None;
587        let prepared = lander_tree(ctx, &candidate).and_then(|wt| {
588            let n = next.to_string();
589            let env = [
590                ("WEAVE_QUEUE_TICKET", n.as_str()),
591                ("WEAVE_QUEUE_CANDIDATE", candidate.as_str()),
592                ("WEAVE_LAND_REPO", ctx.dir.to_str().unwrap_or(".")),
593            ];
594            let p = onto::prepare(&wt, &opts, host, &mut report, &tip, &env);
595            if opts.check.is_some() {
596                if let Ok(tree) = git(&wt, &["rev-parse", "HEAD^{tree}"]) {
597                    checked = onto::recorded_check(&wt, &tree);
598                }
599            }
600            p
601        });
602        let report_text = String::from_utf8_lossy(&report).to_string();
603        let tail: String = {
604            let lines: Vec<&str> = report_text.lines().collect();
605            lines[lines.len().saturating_sub(200)..].join("\n")
606        };
607        let mut result = json!({
608            "kind": "result", "ticket": next, "candidate": candidate, "submitter": submitter,
609            "lander": ctx.me, "tip": tip, "report": tail,
610        });
611        if let Some(c) = checked {
612            // sem check's certificate travels with the ticket's result (its
613            // diagnostics are in the report)
614            result["check"] = json!({
615                "verdict": c["verdict"], "reason": c["reason"], "tree": c["tree"],
616                "certificate": c["sem"]["certificate"],
617            });
618        }
619        let landed = match prepared {
620            Ok(Prep::Ready(head)) => {
621                result["landed"] = json!(head);
622                result["reason"] = json!("landed");
623                Some(head)
624            }
625            Ok(Prep::Refused(r)) => {
626                result["landed"] = Value::Null;
627                result["reason"] = json!(r.reason);
628                result["files"] = json!(r.files);
629                result["regions"] = json!(r.regions);
630                None
631            }
632            // A failure inside one ticket's checks must not wedge the queue:
633            // record it as that ticket's refusal and move on.
634            Err(e) => {
635                result["landed"] = Value::Null;
636                result["reason"] = json!("land failed");
637                result["report"] = json!(format!("{tail}\nland: the checks could not run: {e}"));
638                None
639            }
640        };
641        let first = match &done {
642            Some(d) => d.clone(),
643            None => ctx.genesis("done")?,
644        };
645        let mut parents = vec![first.as_str()];
646        if let Some(h) = &landed {
647            parents.push(h.as_str());
648        }
649        let done_new = ctx.chain_commit(&parents, &result)?;
650
651        let pushed = {
652            let mut head = held.head.lock().unwrap();
653            if held.is_lost() {
654                return Ok(());
655            }
656            let lock_new = ctx.chain_commit(&[&head], &lock_msg(ctx, Some(&ctx.me)))?;
657            let mut ups: Vec<(&str, String, Option<&str>)> = vec![
658                (&lock_new, ctx.r("lock"), Some(head.as_str())),
659                (&done_new, ctx.r("done"), done.as_deref()),
660            ];
661            let main_ref = format!("refs/heads/{}", ctx.branch);
662            if let Some(h) = &landed {
663                ups.push((h, main_ref, Some(&tip)));
664            }
665            let ok = ctx.cas(&ups)?;
666            if ok {
667                *head = lock_new;
668            }
669            ok
670        };
671        if !pushed {
672            // Either the lock was taken over (stop: the new holder redoes this
673            // ticket) or the branch moved outside the queue (redo it).
674            let h = ctx.heads()?;
675            if h.get("lock") != Some(&held.head.lock().unwrap().clone()) {
676                held.lost.store(true, Ordering::SeqCst);
677                return Ok(());
678            }
679            continue;
680        }
681        let _ = writeln!(
682            log,
683            "queue: ticket {next} ({submitter}): {}",
684            match &landed {
685                Some(h) => format!("landed {}", &h[..h.len().min(12)]),
686                None => format!("refused ({})", result["reason"].as_str().unwrap_or("?")),
687            }
688        );
689        if next >= mine {
690            return Ok(());
691        }
692    }
693}
694
695/// Submit HEAD to the queue and wait until it is landed or refused, landing
696/// other tickets ahead of it while holding the lock.
697pub fn submit(dir: &Path, q: &QueueOptions, host: &Host, out: &mut dyn Write) -> R<QueueOutcome> {
698    let top = PathBuf::from(gitscan::git(dir, &["rev-parse", "--show-toplevel"])?.trim());
699    let gd = PathBuf::from(gitscan::git(&top, &["rev-parse", "--absolute-git-dir"])?.trim());
700    let nanos = std::time::SystemTime::now()
701        .duration_since(std::time::UNIX_EPOCH)
702        .map(|d| d.as_nanos())
703        .unwrap_or(0);
704    let host_name = std::env::var("HOSTNAME").unwrap_or_else(|_| "host".into());
705    let ctx = Ctx {
706        dir: top.clone(),
707        remote: q.onto.remote.clone(),
708        branch: q.onto.branch.clone(),
709        me: format!("{host_name}-{}-{nanos}", std::process::id()),
710    };
711    let target = format!("{}/{}", ctx.remote, ctx.branch);
712
713    if gd.join("rebase-merge").exists() || gd.join("rebase-apply").exists() {
714        writeln!(
715            out,
716            "land: a rebase is in progress; finish it first. land merges; it does not rebase."
717        )?;
718        return Ok(QueueOutcome::Refused("rebase in progress".into()));
719    }
720    // A merge in progress is the person's resolution of an earlier refusal:
721    // judge it here, commit it, and submit the result.
722    if gd.join("MERGE_HEAD").exists() {
723        writeln!(
724            out,
725            "land: checking your resolution of the merge in progress …"
726        )?;
727        match onto::check_resolution(&top, &q.onto, host, out)? {
728            true => {}
729            false => return Ok(QueueOutcome::Refused("resolution refused".into())),
730        }
731    }
732    if !git(&top, &["status", "--porcelain", "--untracked-files=no"])?.is_empty() {
733        writeln!(
734            out,
735            "land: you have uncommitted changes to tracked files; commit them first."
736        )?;
737        return Ok(QueueOutcome::Refused("dirty".into()));
738    }
739
740    for attempt in 1..=q.onto.attempts.max(1) {
741        let candidate = git(&top, &["rev-parse", "HEAD"])?;
742        let (ok, _, err) = run(
743            &top,
744            &["fetch", "-q", &ctx.remote, &ctx.branch],
745            Duration::from_secs(300),
746        )?;
747        if !ok {
748            return Err(format!("git fetch {target} failed: {}", err.trim()).into());
749        }
750        if git(&top, &["rev-list", "FETCH_HEAD..HEAD"])?.is_empty() {
751            writeln!(out, "land: nothing to land: your branch has no commits that are not already on {target}.")?;
752            return Ok(QueueOutcome::Refused("nothing to land".into()));
753        }
754        let meta = json!({
755            "verify_cmd": q.onto.verify_cmd,
756            "verify_timeout": q.onto.verify_timeout.as_secs(),
757            "check": q.onto.check.as_ref().map(|_| "sem"),
758            "checkers": q.onto.check.as_ref().and_then(|c| c.checkers.clone()),
759        });
760        let ticket = ctx.enqueue(&candidate, &meta)?;
761        writeln!(
762            out,
763            "land: queued {} for {target} as ticket {ticket}; waiting for it to land …",
764            &candidate[..12]
765        )?;
766        let res = wait(&ctx, q, host, ticket, out)?;
767        let Some(res) = res else {
768            writeln!(
769                out,
770                "land: ticket {ticket} is still queued after {}s; it will still be landed or \
771                 refused. Run `weave land --queue` again later to see the result.",
772                q.wait_timeout.as_secs()
773            )?;
774            return Ok(QueueOutcome::Refused("still queued".into()));
775        };
776        if let Some(h) = &res.landed {
777            if !res.report.is_empty() {
778                writeln!(out, "{}", res.report)?;
779            }
780            writeln!(
781                out,
782                "land: LANDED {} on {target} (ticket {ticket}).",
783                &h[..h.len().min(12)]
784            )?;
785            return Ok(QueueOutcome::Landed(h.clone()));
786        }
787        writeln!(out, "{}", res.report)?;
788        writeln!(
789            out,
790            "land: ticket {ticket} REFUSED ({}); nothing was published.",
791            res.reason
792        )?;
793        if !res.files.is_empty() {
794            writeln!(out, "land: refused files:\n{}", indent(&res.files))?;
795        }
796        if !res.regions.is_empty() {
797            writeln!(
798                out,
799                "land: conflict regions (merged onto {}):\n{}",
800                &res.tip[..res.tip.len().min(12)],
801                indent(&res.regions)
802            )?;
803        }
804        if res.reason != "merge refused" {
805            return Ok(QueueOutcome::Refused(res.reason));
806        }
807        // Reproduce the merge here, against the branch as it is now, so the
808        // conflicts are in the person's own tree to resolve in place.
809        let (ok, _, err) = run(
810            &top,
811            &["fetch", "-q", &ctx.remote, &ctx.branch],
812            Duration::from_secs(300),
813        )?;
814        if !ok {
815            return Err(format!("git fetch {target} failed: {}", err.trim()).into());
816        }
817        let now = git(&top, &["rev-parse", "FETCH_HEAD"])?;
818        let mut local_opts = clone_opts(&q.onto);
819        local_opts.verify_cmd = None; // the lander verifies; here only the merge
820        local_opts.check = None;
821        match onto::prepare(&top, &local_opts, host, out, &now, &[])? {
822            Prep::Refused(r) => {
823                if r.reason == "merge refused" {
824                    writeln!(
825                        out,
826                        "\nland: the merge with {target} is left in progress in your tree. Resolve \
827                         the files above in place (keep both sides' changes; remove every conflict \
828                         marker), then run `weave land --queue` again — it checks your resolution, \
829                         commits the merge and queues it."
830                    )?;
831                }
832                return Ok(QueueOutcome::Refused(r.reason.into()));
833            }
834            // The branch has moved on and the merge now passes: queue again.
835            Prep::Ready(_) => {
836                writeln!(out, "land: the merge with the newer {target} passes; queueing again (attempt {attempt}).")?;
837            }
838        }
839    }
840    writeln!(
841        out,
842        "land: still refused after {} attempts.",
843        q.onto.attempts.max(1)
844    )?;
845    Ok(QueueOutcome::Refused("attempts exhausted".into()))
846}
847
848fn clone_opts(o: &Options) -> Options {
849    Options {
850        remote: o.remote.clone(),
851        branch: o.branch.clone(),
852        resolver: o.resolver.clone(),
853        verify_cmd: o.verify_cmd.clone(),
854        verify_timeout: o.verify_timeout,
855        check: o.check.clone(),
856        attempts: o.attempts,
857        certificate_dir: o.certificate_dir.clone(),
858    }
859}
860
861fn indent(lines: &[String]) -> String {
862    lines
863        .iter()
864        .map(|l| format!("  {l}"))
865        .collect::<Vec<_>>()
866        .join("\n")
867}
868
869/// Wait for ticket `n`'s result, landing tickets whenever the lock is free or
870/// its holder is dead. `None` on timeout.
871fn wait(
872    ctx: &Ctx,
873    q: &QueueOptions,
874    host: &Host,
875    n: u64,
876    out: &mut dyn Write,
877) -> R<Option<TicketResult>> {
878    let start = Instant::now();
879    let mut seen: Option<(String, Instant)> = None;
880    loop {
881        let heads = ctx.heads()?;
882        if let Some(r) = ctx.result(n, &heads)? {
883            return Ok(Some(r));
884        }
885        if start.elapsed() > q.wait_timeout {
886            return Ok(None);
887        }
888        let lock = heads.get("lock").cloned();
889        let take = if lock_is_free(ctx, lock.as_ref())? {
890            true
891        } else {
892            let l = lock.clone().unwrap_or_default();
893            match &seen {
894                Some((v, t)) if *v == l => t.elapsed() >= q.lease_ttl,
895                _ => {
896                    seen = Some((l, Instant::now()));
897                    false
898                }
899            }
900        };
901        if take {
902            let stale = !lock_is_free(ctx, lock.as_ref())?;
903            if let Some(held) = acquire(ctx, lock.as_deref())? {
904                if stale {
905                    writeln!(
906                        out,
907                        "land: the lander holding the queue went quiet for {}s; taking over.",
908                        q.lease_ttl.as_secs()
909                    )?;
910                }
911                let hb = {
912                    let (d, r, b, m, h, t) = (
913                        ctx.dir.clone(),
914                        ctx.remote.clone(),
915                        ctx.branch.clone(),
916                        ctx.me.clone(),
917                        held.clone(),
918                        q.lease_ttl,
919                    );
920                    std::thread::spawn(move || heartbeat(d, r, b, m, h, t))
921                };
922                let res = land_tickets(ctx, q, host, &held, n, out);
923                release(ctx, &held);
924                let _ = hb.join();
925                res?;
926                seen = None;
927                continue;
928            }
929        }
930        std::thread::sleep(jitter(q.poll));
931    }
932}