Skip to main content

kranz_engine/
work.rs

1//! Host-callable queue drain core (roadmap f-1-1): the drain/claim/skip loop
2//! that used to live only inside `kranz` CLI's `cmd_work`, hoisted here so any
3//! surface (CLI, REST, Slack) can drain a repo's queue.
4//!
5//! [`drain_queue`] owns the loop — recover dead claims, then peek/claim/run
6//! one mission at a time — but does NOT run missions itself: the caller
7//! injects a `run_mission` closure, because each host supplies its own runner
8//! (the CLI tails events to stderr; a headless caller does not) and only the
9//! caller knows how to restore its own git checkout.
10
11use crate::backend_readiness::{self, DrainDecision};
12use crate::deps;
13use crate::event_log::EventLog;
14use crate::paths::MissionPaths;
15use crate::queue::{self, QueueEntry};
16use crate::reducer;
17use crate::ticket::{Ticket, TicketState};
18use crate::types::MissionStatus;
19use anyhow::Result;
20use std::future::Future;
21use std::path::Path;
22
23// ---------------------------------------------------------------------------
24// work dispatcher — pure decision helper
25// ---------------------------------------------------------------------------
26
27/// The dispatcher's next action given the queue front and repo-busy state.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum WorkAction {
30    /// Nothing queued: the dispatcher exits.
31    Empty,
32    /// The repo is busy running `mission_id`: wait (default) or exit (`--once`).
33    Busy { mission_id: String },
34    /// Free to run the front mission.
35    Run {
36        mission_id: String,
37        ticket_slug: Option<String>,
38    },
39}
40
41/// Decide the dispatcher's next step from the queue front + busy state.
42/// Pure: `front` is `queue::peek`, `busy_with` is `queue::is_repo_busy`.
43pub fn next_work_action(front: Option<&QueueEntry>, busy_with: Option<&str>) -> WorkAction {
44    match front {
45        None => WorkAction::Empty,
46        Some(_) if busy_with.is_some() => WorkAction::Busy {
47            mission_id: busy_with.expect("checked is_some").to_string(),
48        },
49        Some(entry) => WorkAction::Run {
50            mission_id: entry.mission_id.clone(),
51            ticket_slug: entry.ticket_slug.clone(),
52        },
53    }
54}
55
56/// Work-time re-check for a claimed queue entry with a ticket: `Some(blocker)`
57/// when one of the ticket's unsatisfied `blocked-by` entries is unsatisfied
58/// because that blocker's own ticket ended up Failed (its mission reached a
59/// terminal non-Complete state — Failed/Abandoned/Blocked — after
60/// batch-approval queued this entry alongside it). The dispatcher must skip
61/// such an entry rather than run it: re-driving a mission whose dependency
62/// failed can never succeed, and retrying forever would hot-loop.
63pub fn work_skip_for_failed_blocker(repo_root: &Path, slug: &str) -> Result<Option<String>> {
64    let unsatisfied = deps::unsatisfied_blockers(repo_root, slug)?;
65    for blocker in unsatisfied {
66        if Ticket::read_state(repo_root, &blocker) == TicketState::Failed {
67            return Ok(Some(blocker));
68        }
69    }
70    Ok(None)
71}
72
73/// Map a terminal (or blocked) mission status to the ticket state recorded
74/// after a run. Only called by [`reconcile_ticket_for_mission`] for
75/// terminal/blocked statuses — live statuses are gated out before this runs.
76pub fn ticket_state_for_mission(status: MissionStatus) -> TicketState {
77    match status {
78        MissionStatus::Complete => TicketState::Done,
79        MissionStatus::Failed => TicketState::Failed,
80        MissionStatus::Abandoned => TicketState::Failed,
81        // Blocked is needs-input, not a failure: the mission is waiting on a
82        // human, so the ticket should resurface as NeedsContext, not Failed.
83        MissionStatus::Blocked => TicketState::NeedsContext,
84        _ => TicketState::Failed,
85    }
86}
87
88/// The single authoritative reconcile helper: given a mission id, reverse-
89/// looks-up its linked ticket and, if the mission's folded status is
90/// terminal-or-blocked, writes the mapped [`TicketState`] to the ticket's
91/// `.status` sidecar. LIVE statuses (Running/Validating/Paused/Approved/
92/// Planning) are a no-op — the ticket is still mid-flight and must not be
93/// clobbered. Every path that can drive a mission to a terminal (or blocked)
94/// state — `kranz run`, `kranz exec`, REST `/start`, the drain loop — should
95/// call this instead of writing the ticket state itself, so the stale-
96/// "Failed" heal case and the Blocked-to-NeedsContext mapping live in one
97/// place.
98///
99/// Defensive by design (mirrors [`crate::merged::ticket_merged`]): an
100/// unlinked or unloadable mission is `Ok(None)`, never an error — reconcile
101/// must never fail the caller's terminal-state transition.
102pub fn reconcile_ticket_for_mission(
103    repo_root: &Path,
104    mission_id: &str,
105) -> crate::error::Result<Option<(String, TicketState)>> {
106    let Some(slug) = Ticket::slug_for_mission(repo_root, mission_id) else {
107        return Ok(None);
108    };
109
110    let paths = MissionPaths::new(repo_root, mission_id);
111    if !paths.events_file().is_file() {
112        return Ok(None);
113    }
114    let Ok(events) = EventLog::read_events(&paths.events_file()) else {
115        return Ok(None);
116    };
117    let Ok(state) = reducer::fold(&events) else {
118        return Ok(None);
119    };
120
121    let status = state.mission.status;
122    if !matches!(
123        status,
124        MissionStatus::Complete
125            | MissionStatus::Failed
126            | MissionStatus::Abandoned
127            | MissionStatus::Blocked
128    ) {
129        return Ok(None);
130    }
131
132    let mapped = ticket_state_for_mission(status);
133    let current = Ticket::read_state(repo_root, &slug);
134    if current == mapped {
135        return Ok(None);
136    }
137    Ticket::write_state(repo_root, &slug, mapped, None)?;
138    Ok(Some((slug, mapped)))
139}
140
141// ---------------------------------------------------------------------------
142// drain_queue — the shared drain/claim/skip loop
143// ---------------------------------------------------------------------------
144
145/// Outcome of a [`drain_queue`] call: the mission ids that ran to a terminal
146/// state vs. those skipped because a blocker failed.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct DrainReport {
149    pub ran: Vec<String>,
150    pub skipped: Vec<String>,
151    /// Claimed then parked because backend readiness failed hard (ticket
152    /// marked [`TicketState::Parked`] with a readiness note when linked).
153    pub parked: Vec<String>,
154    /// `once` stopped the drain while the repo was busy with another mission
155    /// (nothing here was claimed or run). Callers that restore their own git
156    /// checkout on exit must NOT do so when this is set — the repo is still
157    /// mid-mission under a sibling dispatcher, and switching branches out
158    /// from under it would corrupt that run's working tree.
159    pub stopped_busy: bool,
160    /// An expectation-guarded one-shot drain found a different mission at
161    /// the front. The claim was released without readiness probing or run.
162    pub expected_mismatch: Option<String>,
163}
164
165/// After this many consecutive rate-limit delays on the same mission id in one
166/// drain, park instead of starving the rest of the queue forever.
167const RATE_LIMIT_ROTATE_CAP: u32 = 3;
168
169/// Drain the per-repo queue one mission at a time. Recover dead claims once up
170/// front; then atomically claim the front entry with the repo-wide busy guard;
171/// on `Busy` either return (`once`) or sleep 5s and retry; on a lost claim race,
172/// retry after a brief sleep; on `Claimed`, run backend readiness under the
173/// claim (park → finish_claim; rate-limit → release + rotate/delay; ok → run),
174/// skip a ticket-born entry whose blocker failed, otherwise write ticket
175/// Running, invoke the injected `run_mission`, then finish/release the claim,
176/// write the terminal ticket state, and honor `once`.
177///
178/// Readiness is probed **after** claim so a sibling drain cannot race a peek
179/// + `queue::remove` into marking a live run's ticket Failed.
180///
181/// Does NOT do checkout restoration or event printing — those stay with the
182/// caller, which is exactly why `run_mission` is injected rather than run
183/// inside this core: the CLI keeps its live event tail, while a headless
184/// caller (REST, Slack) can drive the same loop with no terminal attached.
185pub async fn drain_queue<R, Fut>(
186    repo_root: &Path,
187    once: bool,
188    run_mission: R,
189) -> Result<DrainReport>
190where
191    R: Fn(String) -> Fut,
192    Fut: Future<Output = Result<i32>>,
193{
194    drain_queue_expected(repo_root, once, None, run_mission).await
195}
196
197/// Drain with an optional atomic front-entry expectation. When `expected` is
198/// set and a sibling dispatcher changed the front before the claim landed,
199/// the claimed entry is released and returned in
200/// [`DrainReport::expected_mismatch`] without running it. This is the
201/// supervised-adapter guard for producers that must translate one specific
202/// queued mission's outcome back to an external system.
203pub async fn drain_queue_expected<R, Fut>(
204    repo_root: &Path,
205    once: bool,
206    expected: Option<&str>,
207    run_mission: R,
208) -> Result<DrainReport>
209where
210    R: Fn(String) -> Fut,
211    Fut: Future<Output = Result<i32>>,
212{
213    drain_queue_with_probe_expected(
214        repo_root,
215        once,
216        expected,
217        run_mission,
218        backend_readiness::probe_mission,
219    )
220    .await
221}
222
223/// [`drain_queue`] with an injectable readiness probe. Engine and host tests
224/// use this seam to script proceed / park / rate-limit decisions without
225/// shelling out to whichever agent CLIs happen to be installed on the test
226/// machine.
227pub async fn drain_queue_with_probe<R, Fut, P>(
228    repo_root: &Path,
229    once: bool,
230    run_mission: R,
231    probe: P,
232) -> Result<DrainReport>
233where
234    R: Fn(String) -> Fut,
235    Fut: Future<Output = Result<i32>>,
236    P: Fn(&Path, &str) -> crate::error::Result<backend_readiness::ReadinessReport>,
237{
238    drain_queue_with_probe_expected(repo_root, once, None, run_mission, probe).await
239}
240
241async fn drain_queue_with_probe_expected<R, Fut, P>(
242    repo_root: &Path,
243    once: bool,
244    expected: Option<&str>,
245    run_mission: R,
246    probe: P,
247) -> Result<DrainReport>
248where
249    R: Fn(String) -> Fut,
250    Fut: Future<Output = Result<i32>>,
251    P: Fn(&Path, &str) -> crate::error::Result<backend_readiness::ReadinessReport>,
252{
253    let mut report = DrainReport::default();
254    let mut rate_limit_hits: std::collections::HashMap<String, u32> =
255        std::collections::HashMap::new();
256    queue::recover_dead_claims(repo_root);
257    loop {
258        match queue::claim_front_when_repo_free(repo_root)? {
259            queue::ClaimFront::Empty => return Ok(report),
260            queue::ClaimFront::LostRace => {
261                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
262                continue;
263            }
264            queue::ClaimFront::Busy { .. } => {
265                if once {
266                    report.stopped_busy = true;
267                    return Ok(report);
268                }
269                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
270                continue;
271            }
272            queue::ClaimFront::Claimed(claim) => {
273                let mission_id = claim.entry.mission_id.clone();
274                if expected.is_some_and(|expected| expected != mission_id) {
275                    queue::release_claim(claim);
276                    report.expected_mismatch = Some(mission_id);
277                    return Ok(report);
278                }
279                let ticket_slug = claim
280                    .entry
281                    .ticket_slug
282                    .clone()
283                    .or_else(|| Ticket::slug_for_mission(repo_root, &mission_id));
284
285                // Backend readiness under the claim (no peek/remove race).
286                match probe(repo_root, &mission_id) {
287                    Ok(readiness) => match readiness.drain_decision() {
288                        DrainDecision::Proceed { warnings } => {
289                            for w in warnings {
290                                tracing::warn!(
291                                    mission = %mission_id,
292                                    warning = %w,
293                                    "backend readiness warning; proceeding"
294                                );
295                            }
296                        }
297                        DrainDecision::Park { reason } => {
298                            tracing::warn!(
299                                mission = %mission_id,
300                                reason = %reason,
301                                "parking claimed mission: backend not ready"
302                            );
303                            let stop = settle_parked_claim(
304                                repo_root,
305                                claim,
306                                ticket_slug.as_deref(),
307                                format!("parked (backend not ready): {reason}"),
308                            )?;
309                            report.parked.push(mission_id);
310                            if once || stop {
311                                return Ok(report);
312                            }
313                            continue;
314                        }
315                        DrainDecision::RequeueDelay { reason, delay } => {
316                            let hits = rate_limit_hits.entry(mission_id.clone()).or_insert(0);
317                            *hits += 1;
318                            let hits = *hits;
319                            tracing::warn!(
320                                mission = %mission_id,
321                                reason = %reason,
322                                delay_secs = delay.as_secs(),
323                                hits,
324                                "backend rate-limited after claim"
325                            );
326                            if hits >= RATE_LIMIT_ROTATE_CAP {
327                                let stop = settle_parked_claim(
328                                    repo_root,
329                                    claim,
330                                    ticket_slug.as_deref(),
331                                    format!("parked (rate-limited {hits}×): {reason}"),
332                                )?;
333                                report.parked.push(mission_id);
334                                if once || stop {
335                                    return Ok(report);
336                                }
337                                continue;
338                            }
339                            // Release back to the queue, then rotate behind
340                            // other same-priority work so one limited head
341                            // cannot starve the drain forever.
342                            let entry = claim.entry.clone();
343                            queue::release_claim(claim);
344                            rotate_entry_to_back(repo_root, &entry)?;
345                            if once {
346                                return Ok(report);
347                            }
348                            // Production delay is typically 60s; tests clamp so
349                            // rate-limit rotate/park coverage stays hermetic.
350                            #[cfg(test)]
351                            let delay = delay.min(std::time::Duration::from_millis(1));
352                            tokio::time::sleep(delay).await;
353                            continue;
354                        }
355                    },
356                    Err(e) => {
357                        tracing::warn!(
358                            mission = %mission_id,
359                            error = %e,
360                            "backend readiness probe failed; proceeding"
361                        );
362                    }
363                }
364
365                // Disk-footprint preflight (ticket mission-build-footprint):
366                // refuse to START a mission whose build ladder won't fit in
367                // the free space under the repo, naming the estimate, rather
368                // than dying mid-feature with os error 28. Unmeasurable free
369                // space degrades to proceed (never a fabricated refusal).
370                if let crate::disk_preflight::DiskPreflight::Insufficient {
371                    free_bytes,
372                    estimate_bytes,
373                } = crate::disk_preflight::check(repo_root)
374                {
375                    let reason = format!(
376                        "insufficient disk for the mission build ladder: {} free < {} estimated \
377                         (free space or raise the estimate)",
378                        crate::disk_preflight::gib(free_bytes),
379                        crate::disk_preflight::gib(estimate_bytes)
380                    );
381                    tracing::warn!(mission = %mission_id, reason = %reason, "parking claimed mission: disk preflight");
382                    let stop = settle_parked_claim(
383                        repo_root,
384                        claim,
385                        ticket_slug.as_deref(),
386                        format!("parked (disk): {reason}"),
387                    )?;
388                    report.parked.push(mission_id);
389                    if once || stop {
390                        return Ok(report);
391                    }
392                    continue;
393                }
394
395                if let Some(slug) = &ticket_slug {
396                    if let Some(blocker) = work_skip_for_failed_blocker(repo_root, slug)? {
397                        queue::finish_claim(claim);
398                        Ticket::write_state(
399                            repo_root,
400                            slug,
401                            TicketState::Failed,
402                            Some(format!("skipped: blocked-by {blocker} failed")),
403                        )?;
404                        report.skipped.push(mission_id);
405                        continue;
406                    }
407                    Ticket::write_state(repo_root, slug, TicketState::Running, None)?;
408                }
409
410                let status = run_mission(mission_id.clone()).await;
411
412                match &status {
413                    Ok(_) => queue::finish_claim(claim),
414                    Err(_) if ticket_slug.is_some() => queue::finish_claim(claim),
415                    Err(_) => queue::release_claim(claim),
416                }
417
418                if ticket_slug.is_some() {
419                    reconcile_ticket_for_mission(repo_root, &mission_id)?;
420                } else {
421                    status?;
422                }
423                report.ran.push(mission_id);
424
425                if once {
426                    return Ok(report);
427                }
428            }
429        }
430    }
431}
432
433/// Ticket-backed work has a durable Parked projection, so its queue entry can
434/// retire. A raw `exec --enqueue` mission has no ticket projection: releasing
435/// its claim is the only durable not-yet-run state. Stop this drain after the
436/// release so a persistent preflight failure cannot hot-loop on the same head.
437fn settle_parked_claim(
438    repo_root: &Path,
439    claim: queue::Claim,
440    ticket_slug: Option<&str>,
441    note: String,
442) -> Result<bool> {
443    if let Some(slug) = ticket_slug {
444        queue::finish_claim(claim);
445        Ticket::write_state(repo_root, slug, TicketState::Parked, Some(note))?;
446        Ok(false)
447    } else {
448        queue::release_claim(claim);
449        Ok(true)
450    }
451}
452
453/// Drop `entry` from the queue (if still present) and re-enqueue so it gets a
454/// fresh seq and sorts behind other same-priority work.
455fn rotate_entry_to_back(repo_root: &Path, entry: &QueueEntry) -> Result<()> {
456    queue::remove(repo_root, &entry.mission_id);
457    let _ = queue::enqueue(
458        repo_root,
459        QueueEntry {
460            mission_id: entry.mission_id.clone(),
461            ticket_slug: entry.ticket_slug.clone(),
462            priority: entry.priority,
463            // seq overwritten by enqueue
464            seq: 0,
465        },
466    )?;
467    Ok(())
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::events::{Event, EventKind};
474    use crate::types::MissionConfig;
475    use chrono::Utc;
476    use std::sync::atomic::{AtomicUsize, Ordering};
477    use std::sync::Arc;
478
479    #[test]
480    fn next_work_action_empty_when_no_front() {
481        assert_eq!(next_work_action(None, None), WorkAction::Empty);
482    }
483
484    #[test]
485    fn next_work_action_busy_takes_priority_over_front() {
486        let entry = QueueEntry {
487            mission_id: "m1".to_string(),
488            ticket_slug: None,
489            priority: 2,
490            seq: 0,
491        };
492        assert_eq!(
493            next_work_action(Some(&entry), Some("busy-mission")),
494            WorkAction::Busy {
495                mission_id: "busy-mission".to_string()
496            }
497        );
498    }
499
500    #[test]
501    fn next_work_action_runs_the_front_when_idle() {
502        let entry = QueueEntry {
503            mission_id: "m1".to_string(),
504            ticket_slug: Some("slug-a".to_string()),
505            priority: 2,
506            seq: 0,
507        };
508        assert_eq!(
509            next_work_action(Some(&entry), None),
510            WorkAction::Run {
511                mission_id: "m1".to_string(),
512                ticket_slug: Some("slug-a".to_string()),
513            }
514        );
515    }
516
517    #[test]
518    fn ticket_state_for_mission_maps_terminal_status() {
519        assert_eq!(
520            ticket_state_for_mission(MissionStatus::Complete),
521            TicketState::Done
522        );
523        assert_eq!(
524            ticket_state_for_mission(MissionStatus::Blocked),
525            TicketState::NeedsContext
526        );
527        assert_eq!(
528            ticket_state_for_mission(MissionStatus::Failed),
529            TicketState::Failed
530        );
531        assert_eq!(
532            ticket_state_for_mission(MissionStatus::Abandoned),
533            TicketState::Failed
534        );
535    }
536
537    fn write_ticket(repo: &Path, slug: &str, body: &str) {
538        let dir = Ticket::tickets_dir(repo);
539        std::fs::create_dir_all(&dir).unwrap();
540        std::fs::write(dir.join(format!("{slug}.md")), body).unwrap();
541    }
542
543    /// Write a hand-built events.jsonl for `mission_id` under `repo_root`
544    /// (mirrors `merged_test.rs::write_events`).
545    fn write_events(repo_root: &Path, mission_id: &str, kinds: Vec<EventKind>) {
546        let dir = repo_root.join(".kranz").join("missions").join(mission_id);
547        std::fs::create_dir_all(&dir).unwrap();
548        let mut lines = String::new();
549        for (i, kind) in kinds.into_iter().enumerate() {
550            let event = Event {
551                seq: (i + 1) as u64,
552                ts: Utc::now(),
553                mission_id: mission_id.to_string(),
554                kind,
555            };
556            lines.push_str(&serde_json::to_string(&event).unwrap());
557            lines.push('\n');
558        }
559        std::fs::write(dir.join("events.jsonl"), lines).unwrap();
560    }
561
562    fn created() -> EventKind {
563        EventKind::MissionCreated {
564            goal: "fixture mission".to_string(),
565            base_branch: "main".to_string(),
566            mission_branch: "kranz/mission-fixture".to_string(),
567            config: MissionConfig::default(),
568        }
569    }
570
571    fn scaffold_ticket(repo_root: &Path, slug: &str, mission_id: &str, state: TicketState) {
572        Ticket::scaffold(repo_root, slug, "fixture ticket", None, None).unwrap();
573        Ticket::record_mission(repo_root, slug, mission_id).unwrap();
574        Ticket::write_state(repo_root, slug, state, None).unwrap();
575    }
576
577    fn plan_with_one_milestone() -> crate::types::Plan {
578        crate::types::Plan {
579            goal: "fixture goal".to_string(),
580            validation_contract: vec![],
581            milestones: vec![crate::types::PlanMilestone {
582                title: "milestone one".to_string(),
583                features: vec![crate::types::PlanFeature {
584                    title: "feature one".to_string(),
585                    spec: "spec".to_string(),
586                    validation_criteria: vec!["works".to_string()],
587                }],
588            }],
589            considered_alternatives: None,
590            command_grants: vec![],
591            touch_set: vec![],
592            standards_manifest: None,
593            reviewer_independence: None,
594        }
595    }
596
597    #[test]
598    fn reconcile_on_terminal_maps_complete_to_done() {
599        let tmp = tempfile::tempdir().unwrap();
600        let repo = tmp.path();
601        scaffold_ticket(repo, "my-ticket", "m1", TicketState::Running);
602        write_events(repo, "m1", vec![created(), EventKind::MissionCompleted {}]);
603
604        let result = reconcile_ticket_for_mission(repo, "m1").unwrap();
605        assert_eq!(result, Some(("my-ticket".to_string(), TicketState::Done)));
606        assert_eq!(Ticket::read_state(repo, "my-ticket"), TicketState::Done);
607    }
608
609    #[test]
610    fn reconcile_heals_failed_to_done() {
611        let tmp = tempfile::tempdir().unwrap();
612        let repo = tmp.path();
613        scaffold_ticket(repo, "my-ticket", "m1", TicketState::Failed);
614        write_events(repo, "m1", vec![created(), EventKind::MissionCompleted {}]);
615
616        let result = reconcile_ticket_for_mission(repo, "m1").unwrap();
617        assert_eq!(result, Some(("my-ticket".to_string(), TicketState::Done)));
618        assert_eq!(Ticket::read_state(repo, "my-ticket"), TicketState::Done);
619    }
620
621    #[test]
622    fn blocked_reconciles_to_needs_you() {
623        let tmp = tempfile::tempdir().unwrap();
624        let repo = tmp.path();
625        scaffold_ticket(repo, "my-ticket", "m1", TicketState::Running);
626        write_events(
627            repo,
628            "m1",
629            vec![
630                created(),
631                EventKind::PlanApproved {
632                    plan: plan_with_one_milestone(),
633                    base_sha: None,
634                },
635                EventKind::MilestoneBlocked {
636                    block_context: None,
637                    milestone_id: "ms-1".to_string(),
638                    reason: "needs input".to_string(),
639                },
640            ],
641        );
642
643        let result = reconcile_ticket_for_mission(repo, "m1").unwrap();
644        assert_eq!(
645            result,
646            Some(("my-ticket".to_string(), TicketState::NeedsContext))
647        );
648        assert_eq!(
649            Ticket::read_state(repo, "my-ticket"),
650            TicketState::NeedsContext
651        );
652    }
653
654    #[test]
655    fn reconcile_returns_none_when_no_linked_ticket() {
656        let tmp = tempfile::tempdir().unwrap();
657        let repo = tmp.path();
658        write_events(repo, "m1", vec![created(), EventKind::MissionCompleted {}]);
659
660        assert_eq!(reconcile_ticket_for_mission(repo, "m1").unwrap(), None);
661    }
662
663    #[test]
664    fn reconcile_returns_none_when_mission_status_is_live() {
665        let tmp = tempfile::tempdir().unwrap();
666        let repo = tmp.path();
667        scaffold_ticket(repo, "my-ticket", "m1", TicketState::Running);
668        write_events(repo, "m1", vec![created()]);
669
670        assert_eq!(reconcile_ticket_for_mission(repo, "m1").unwrap(), None);
671        assert_eq!(Ticket::read_state(repo, "my-ticket"), TicketState::Running);
672    }
673
674    fn proceed_report(mission_id: &str) -> backend_readiness::ReadinessReport {
675        backend_readiness::ReadinessReport {
676            mission_id: mission_id.to_string(),
677            roles: vec![],
678            overall: backend_readiness::ReadinessStatus::Ok,
679            warnings: vec![],
680        }
681    }
682
683    fn always_proceed(
684        _repo: &Path,
685        id: &str,
686    ) -> crate::error::Result<backend_readiness::ReadinessReport> {
687        Ok(proceed_report(id))
688    }
689
690    fn park_report(mission_id: &str) -> backend_readiness::ReadinessReport {
691        backend_readiness::ReadinessReport {
692            mission_id: mission_id.to_string(),
693            roles: vec![backend_readiness::RoleReadiness {
694                role: "worker".into(),
695                backend: "claude".into(),
696                status: backend_readiness::ReadinessStatus::Missing,
697                detail: "no binary".into(),
698                next_action: "install".into(),
699            }],
700            overall: backend_readiness::ReadinessStatus::Missing,
701            warnings: vec![],
702        }
703    }
704
705    fn rate_limited_report(mission_id: &str) -> backend_readiness::ReadinessReport {
706        backend_readiness::ReadinessReport {
707            mission_id: mission_id.to_string(),
708            roles: vec![backend_readiness::RoleReadiness {
709                role: "orchestrator".into(),
710                backend: "claude".into(),
711                status: backend_readiness::ReadinessStatus::RateLimited,
712                detail: "429".into(),
713                next_action: "wait".into(),
714            }],
715            overall: backend_readiness::ReadinessStatus::RateLimited,
716            warnings: vec![],
717        }
718    }
719
720    #[tokio::test]
721    async fn drain_queue_runs_a_queued_mission_and_retires_its_claim() {
722        let tmp = tempfile::tempdir().unwrap();
723        let repo = tmp.path();
724        queue::enqueue(
725            repo,
726            QueueEntry {
727                mission_id: "mission-1".to_string(),
728                ticket_slug: None,
729                priority: 2,
730                seq: 0,
731            },
732        )
733        .unwrap();
734
735        let ran = Arc::new(AtomicUsize::new(0));
736        let ran_clone = ran.clone();
737        let report = drain_queue_with_probe(
738            repo,
739            false,
740            move |mission_id| {
741                let ran = ran_clone.clone();
742                async move {
743                    assert_eq!(mission_id, "mission-1");
744                    ran.fetch_add(1, Ordering::SeqCst);
745                    Ok(0)
746                }
747            },
748            always_proceed,
749        )
750        .await
751        .unwrap();
752
753        assert_eq!(ran.load(Ordering::SeqCst), 1);
754        assert_eq!(report.ran, vec!["mission-1".to_string()]);
755        assert!(report.skipped.is_empty());
756        // The claim file was retired: nothing left on disk under the queue dir.
757        assert!(queue::list(repo).is_empty());
758        let dir = queue::queue_dir(repo);
759        let leftover: Vec<_> = std::fs::read_dir(&dir)
760            .unwrap()
761            .flatten()
762            .filter(|e| {
763                e.path()
764                    .file_name()
765                    .and_then(|n| n.to_str())
766                    .is_some_and(|n| n.contains(".claimed."))
767            })
768            .collect();
769        assert!(leftover.is_empty(), "claim file was not retired");
770    }
771
772    #[tokio::test]
773    async fn drain_queue_skips_ticket_whose_blocker_failed() {
774        let tmp = tempfile::tempdir().unwrap();
775        let repo = tmp.path();
776
777        write_ticket(
778            repo,
779            "blocker",
780            "---\ntitle: blocker\npriority: 2\nschedule: once\n---\n\n## Goal\nblock\n",
781        );
782        write_ticket(
783            repo,
784            "dependent",
785            "---\ntitle: dependent\npriority: 2\nschedule: once\nblocked-by: [blocker]\n---\n\n## Goal\ndepend\n",
786        );
787        Ticket::write_state(repo, "blocker", TicketState::Failed, None).unwrap();
788
789        queue::enqueue(
790            repo,
791            QueueEntry {
792                mission_id: "mission-dep".to_string(),
793                ticket_slug: Some("dependent".to_string()),
794                priority: 2,
795                seq: 0,
796            },
797        )
798        .unwrap();
799
800        let ran = Arc::new(AtomicUsize::new(0));
801        let ran_clone = ran.clone();
802        let report = drain_queue_with_probe(
803            repo,
804            false,
805            move |_mission_id| {
806                let ran = ran_clone.clone();
807                async move {
808                    ran.fetch_add(1, Ordering::SeqCst);
809                    Ok(0)
810                }
811            },
812            always_proceed,
813        )
814        .await
815        .unwrap();
816
817        assert_eq!(
818            ran.load(Ordering::SeqCst),
819            0,
820            "the doomed entry must not run"
821        );
822        assert_eq!(report.skipped, vec!["mission-dep".to_string()]);
823        assert!(report.ran.is_empty());
824        assert_eq!(Ticket::read_state(repo, "dependent"), TicketState::Failed);
825        // The claim was finished, not re-queued (no infinite re-claim).
826        assert!(queue::list(repo).is_empty());
827    }
828
829    #[tokio::test]
830    async fn drain_queue_honors_once() {
831        let tmp = tempfile::tempdir().unwrap();
832        let repo = tmp.path();
833        for i in 0..2 {
834            queue::enqueue(
835                repo,
836                QueueEntry {
837                    mission_id: format!("mission-{i}"),
838                    ticket_slug: None,
839                    priority: 2,
840                    seq: 0,
841                },
842            )
843            .unwrap();
844        }
845
846        let ran = Arc::new(AtomicUsize::new(0));
847        let ran_clone = ran.clone();
848        let report = drain_queue_with_probe(
849            repo,
850            true,
851            move |_mission_id| {
852                let ran = ran_clone.clone();
853                async move {
854                    ran.fetch_add(1, Ordering::SeqCst);
855                    Ok(0)
856                }
857            },
858            always_proceed,
859        )
860        .await
861        .unwrap();
862
863        assert_eq!(
864            ran.load(Ordering::SeqCst),
865            1,
866            "--once must run exactly one entry"
867        );
868        assert_eq!(report.ran.len(), 1);
869        // One entry remains queued.
870        assert_eq!(queue::list(repo).len(), 1);
871    }
872
873    #[tokio::test]
874    async fn expected_front_mismatch_releases_claim_without_running() {
875        let tmp = tempfile::tempdir().unwrap();
876        let repo = tmp.path();
877        for mission_id in ["mission-front", "mission-expected"] {
878            queue::enqueue(
879                repo,
880                QueueEntry {
881                    mission_id: mission_id.to_string(),
882                    ticket_slug: None,
883                    priority: 2,
884                    seq: 0,
885                },
886            )
887            .unwrap();
888        }
889
890        let ran = Arc::new(AtomicUsize::new(0));
891        let ran_clone = ran.clone();
892        let report = drain_queue_with_probe_expected(
893            repo,
894            true,
895            Some("mission-expected"),
896            move |_mission_id| {
897                let ran = ran_clone.clone();
898                async move {
899                    ran.fetch_add(1, Ordering::SeqCst);
900                    Ok(0)
901                }
902            },
903            always_proceed,
904        )
905        .await
906        .unwrap();
907
908        assert_eq!(ran.load(Ordering::SeqCst), 0);
909        assert_eq!(report.expected_mismatch.as_deref(), Some("mission-front"));
910        assert_eq!(
911            queue::list(repo)
912                .into_iter()
913                .map(|entry| entry.mission_id)
914                .collect::<Vec<_>>(),
915            vec!["mission-front", "mission-expected"]
916        );
917    }
918
919    #[tokio::test]
920    async fn concurrent_drain_queue_once_reports_busy_without_running_second_entry() {
921        let tmp = tempfile::tempdir().unwrap();
922        let repo = tmp.path();
923        for i in 1..=2 {
924            queue::enqueue(
925                repo,
926                QueueEntry {
927                    mission_id: format!("mission-{i}"),
928                    ticket_slug: None,
929                    priority: 2,
930                    seq: 0,
931                },
932            )
933            .unwrap();
934        }
935
936        let first_runs = Arc::new(AtomicUsize::new(0));
937        let release_first = Arc::new(tokio::sync::Notify::new());
938        let first_repo = repo.to_path_buf();
939        let first = {
940            let first_runs = first_runs.clone();
941            let release_first = release_first.clone();
942            tokio::spawn(async move {
943                drain_queue_with_probe(
944                    &first_repo,
945                    true,
946                    move |mission_id| {
947                        let first_runs = first_runs.clone();
948                        let release_first = release_first.clone();
949                        async move {
950                            assert_eq!(mission_id, "mission-1");
951                            first_runs.fetch_add(1, Ordering::SeqCst);
952                            release_first.notified().await;
953                            Ok(0)
954                        }
955                    },
956                    always_proceed,
957                )
958                .await
959            })
960        };
961
962        for _ in 0..50 {
963            if first_runs.load(Ordering::SeqCst) == 1 {
964                break;
965            }
966            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
967        }
968        assert_eq!(
969            first_runs.load(Ordering::SeqCst),
970            1,
971            "first drainer should be holding the repo guard"
972        );
973
974        let second_runs = Arc::new(AtomicUsize::new(0));
975        let second_runs_clone = second_runs.clone();
976        let second = drain_queue_with_probe(
977            repo,
978            true,
979            move |_mission_id| {
980                let second_runs = second_runs_clone.clone();
981                async move {
982                    second_runs.fetch_add(1, Ordering::SeqCst);
983                    Ok(0)
984                }
985            },
986            always_proceed,
987        )
988        .await
989        .unwrap();
990        assert!(second.stopped_busy);
991        assert!(second.ran.is_empty());
992        assert_eq!(
993            second_runs.load(Ordering::SeqCst),
994            0,
995            "busy loser must not run the next queued mission"
996        );
997        assert!(
998            queue::contains(repo, "mission-2"),
999            "busy loser releases its temporary claim"
1000        );
1001
1002        release_first.notify_waiters();
1003        let first = first.await.unwrap().unwrap();
1004        assert_eq!(first.ran, vec!["mission-1".to_string()]);
1005        assert!(queue::contains(repo, "mission-2"));
1006    }
1007
1008    #[tokio::test]
1009    async fn drain_queue_runs_a_ticket_born_entry_to_done() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let repo = tmp.path();
1012
1013        write_ticket(
1014            repo,
1015            "satisfiable",
1016            "---\ntitle: satisfiable\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1017        );
1018        Ticket::record_mission(repo, "satisfiable", "mission-ticket").unwrap();
1019
1020        queue::enqueue(
1021            repo,
1022            QueueEntry {
1023                mission_id: "mission-ticket".to_string(),
1024                ticket_slug: Some("satisfiable".to_string()),
1025                priority: 2,
1026                seq: 0,
1027            },
1028        )
1029        .unwrap();
1030
1031        let ran = Arc::new(AtomicUsize::new(0));
1032        let ran_clone = ran.clone();
1033        let repo_path = repo.to_path_buf();
1034        let report = drain_queue_with_probe(
1035            repo,
1036            false,
1037            move |mission_id| {
1038                let ran = ran_clone.clone();
1039                let repo_path = repo_path.clone();
1040                async move {
1041                    assert_eq!(mission_id, "mission-ticket");
1042                    ran.fetch_add(1, Ordering::SeqCst);
1043                    write_events(
1044                        &repo_path,
1045                        &mission_id,
1046                        vec![created(), EventKind::MissionCompleted {}],
1047                    );
1048                    Ok(0)
1049                }
1050            },
1051            always_proceed,
1052        )
1053        .await
1054        .unwrap();
1055
1056        assert_eq!(ran.load(Ordering::SeqCst), 1);
1057        assert_eq!(report.ran, vec!["mission-ticket".to_string()]);
1058        assert!(report.skipped.is_empty());
1059        assert_eq!(Ticket::read_state(repo, "satisfiable"), TicketState::Done);
1060        // The claim was retired: nothing left queued or claimed on disk.
1061        assert!(queue::list(repo).is_empty());
1062        let dir = queue::queue_dir(repo);
1063        let leftover: Vec<_> = std::fs::read_dir(&dir)
1064            .unwrap()
1065            .flatten()
1066            .filter(|e| {
1067                e.path()
1068                    .file_name()
1069                    .and_then(|n| n.to_str())
1070                    .is_some_and(|n| n.contains(".claimed."))
1071            })
1072            .collect();
1073        assert!(leftover.is_empty(), "claim file was not retired");
1074    }
1075
1076    /// The mission-id approve path (Slack `/kranz approve m-…`) enqueues a bare
1077    /// entry with `ticket_slug: None`; drain must resolve the linked ticket via
1078    /// the reverse lookup so its pipeline state still advances to Done. Pins the
1079    /// `.or_else(Ticket::slug_for_mission)` fallback — without it, a bare entry
1080    /// runs but the ticket is left stuck in Review.
1081    #[tokio::test]
1082    async fn drain_queue_resolves_a_bare_entry_to_its_linked_ticket() {
1083        let tmp = tempfile::tempdir().unwrap();
1084        let repo = tmp.path();
1085
1086        write_ticket(
1087            repo,
1088            "linked",
1089            "---\ntitle: linked\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1090        );
1091        // Link the ticket to the mission and park it mid-pipeline, exactly as
1092        // the Slack approve-by-mission-id flow leaves it.
1093        Ticket::record_mission(repo, "linked", "m-linked").unwrap();
1094        Ticket::write_state(repo, "linked", TicketState::Queued, None).unwrap();
1095
1096        // A sibling ticket linked to a DIFFERENT mission must not be resolved.
1097        write_ticket(
1098            repo,
1099            "other",
1100            "---\ntitle: other\npriority: 2\nschedule: once\n---\n\n## Goal\nnope\n",
1101        );
1102        Ticket::record_mission(repo, "other", "m-other").unwrap();
1103
1104        queue::enqueue(
1105            repo,
1106            QueueEntry {
1107                mission_id: "m-linked".to_string(),
1108                ticket_slug: None, // bare: the fallback must find "linked"
1109                priority: 2,
1110                seq: 0,
1111            },
1112        )
1113        .unwrap();
1114
1115        let repo_path = repo.to_path_buf();
1116        let report = drain_queue_with_probe(
1117            repo,
1118            false,
1119            move |mission_id| {
1120                let repo_path = repo_path.clone();
1121                async move {
1122                    assert_eq!(mission_id, "m-linked");
1123                    write_events(
1124                        &repo_path,
1125                        &mission_id,
1126                        vec![created(), EventKind::MissionCompleted {}],
1127                    );
1128                    Ok(0)
1129                }
1130            },
1131            always_proceed,
1132        )
1133        .await
1134        .unwrap();
1135
1136        assert_eq!(report.ran, vec!["m-linked".to_string()]);
1137        assert_eq!(
1138            Ticket::read_state(repo, "linked"),
1139            TicketState::Done,
1140            "the linked ticket must advance via the reverse lookup"
1141        );
1142        assert_eq!(
1143            Ticket::read_state(repo, "other"),
1144            TicketState::Drafting,
1145            "an unrelated ticket must be untouched (record_mission left it Drafting)"
1146        );
1147        assert!(queue::list(repo).is_empty());
1148    }
1149
1150    #[tokio::test]
1151    async fn drain_queue_parks_ticket_when_readiness_fails_after_claim() {
1152        let tmp = tempfile::tempdir().unwrap();
1153        let repo = tmp.path();
1154
1155        write_ticket(
1156            repo,
1157            "need-auth",
1158            "---\ntitle: need-auth\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1159        );
1160        queue::enqueue(
1161            repo,
1162            QueueEntry {
1163                mission_id: "m-park".to_string(),
1164                ticket_slug: Some("need-auth".to_string()),
1165                priority: 2,
1166                seq: 0,
1167            },
1168        )
1169        .unwrap();
1170
1171        let ran = Arc::new(AtomicUsize::new(0));
1172        let ran_clone = ran.clone();
1173        let report = drain_queue_with_probe(
1174            repo,
1175            false,
1176            move |_mission_id| {
1177                let ran = ran_clone.clone();
1178                async move {
1179                    ran.fetch_add(1, Ordering::SeqCst);
1180                    Ok(0)
1181                }
1182            },
1183            |_repo, id| Ok(park_report(id)),
1184        )
1185        .await
1186        .unwrap();
1187
1188        assert_eq!(ran.load(Ordering::SeqCst), 0);
1189        assert_eq!(report.parked, vec!["m-park".to_string()]);
1190        assert!(report.ran.is_empty());
1191        assert_eq!(Ticket::read_state(repo, "need-auth"), TicketState::Parked);
1192        assert!(queue::list(repo).is_empty());
1193    }
1194
1195    #[tokio::test]
1196    async fn drain_queue_preserves_ticketless_entry_when_readiness_parks() {
1197        let tmp = tempfile::tempdir().unwrap();
1198        let repo = tmp.path();
1199        queue::enqueue(
1200            repo,
1201            QueueEntry {
1202                mission_id: "m-external".to_string(),
1203                ticket_slug: None,
1204                priority: 2,
1205                seq: 0,
1206            },
1207        )
1208        .unwrap();
1209
1210        let report = drain_queue_with_probe(
1211            repo,
1212            false,
1213            |_mission_id| async { panic!("parked ticketless mission must not run") },
1214            |_repo, id| Ok(park_report(id)),
1215        )
1216        .await
1217        .unwrap();
1218
1219        assert_eq!(report.parked, vec!["m-external".to_string()]);
1220        assert_eq!(
1221            queue::list(repo)
1222                .into_iter()
1223                .map(|entry| entry.mission_id)
1224                .collect::<Vec<_>>(),
1225            vec!["m-external"]
1226        );
1227    }
1228
1229    #[tokio::test]
1230    async fn drain_queue_rate_limit_rotates_then_parks_after_cap() {
1231        let tmp = tempfile::tempdir().unwrap();
1232        let repo = tmp.path();
1233
1234        write_ticket(
1235            repo,
1236            "limited",
1237            "---\ntitle: limited\npriority: 2\nschedule: once\n---\n\n## Goal\na\n",
1238        );
1239        write_ticket(
1240            repo,
1241            "sibling",
1242            "---\ntitle: sibling\npriority: 2\nschedule: once\n---\n\n## Goal\nb\n",
1243        );
1244        Ticket::record_mission(repo, "sibling", "m-sibling").unwrap();
1245        queue::enqueue(
1246            repo,
1247            QueueEntry {
1248                mission_id: "m-limited".to_string(),
1249                ticket_slug: Some("limited".to_string()),
1250                priority: 2,
1251                seq: 0,
1252            },
1253        )
1254        .unwrap();
1255        queue::enqueue(
1256            repo,
1257            QueueEntry {
1258                mission_id: "m-sibling".to_string(),
1259                ticket_slug: Some("sibling".to_string()),
1260                priority: 2,
1261                seq: 0,
1262            },
1263        )
1264        .unwrap();
1265
1266        // Rate-limit sleep is clamped under cfg(test) in drain_queue_with_probe.
1267        let probe = |_repo: &Path, id: &str| {
1268            if id == "m-limited" {
1269                Ok(rate_limited_report(id))
1270            } else {
1271                Ok(proceed_report(id))
1272            }
1273        };
1274
1275        let ran = Arc::new(AtomicUsize::new(0));
1276        let ran_clone = ran.clone();
1277        let repo_path = repo.to_path_buf();
1278        let report = drain_queue_with_probe(
1279            repo,
1280            false,
1281            move |mission_id| {
1282                let ran = ran_clone.clone();
1283                let repo_path = repo_path.clone();
1284                async move {
1285                    assert_eq!(mission_id, "m-sibling");
1286                    ran.fetch_add(1, Ordering::SeqCst);
1287                    write_events(
1288                        &repo_path,
1289                        &mission_id,
1290                        vec![created(), EventKind::MissionCompleted {}],
1291                    );
1292                    Ok(0)
1293                }
1294            },
1295            probe,
1296        )
1297        .await
1298        .unwrap();
1299
1300        assert_eq!(ran.load(Ordering::SeqCst), 1);
1301        assert_eq!(report.ran, vec!["m-sibling".to_string()]);
1302        assert_eq!(report.parked, vec!["m-limited".to_string()]);
1303        assert_eq!(Ticket::read_state(repo, "sibling"), TicketState::Done);
1304        assert_eq!(Ticket::read_state(repo, "limited"), TicketState::Parked);
1305        assert!(queue::list(repo).is_empty());
1306    }
1307
1308    /// Pins the drain loop's terminal ticket write to `reconcile_ticket_for_mission`
1309    /// (not a local exit-code mapping): a mission that folds to Complete leaves
1310    /// its ticket Done, while one that folds to Blocked leaves it NeedsContext —
1311    /// never Failed, even though `run_mission` still returns `Ok(0)` in both cases.
1312    #[tokio::test]
1313    async fn drain_reconciles_terminal_ticket_via_helper() {
1314        let tmp = tempfile::tempdir().unwrap();
1315        let repo = tmp.path();
1316
1317        write_ticket(
1318            repo,
1319            "done-ticket",
1320            "---\ntitle: done\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1321        );
1322        write_ticket(
1323            repo,
1324            "blocked-ticket",
1325            "---\ntitle: blocked\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1326        );
1327        Ticket::record_mission(repo, "done-ticket", "m-done").unwrap();
1328        Ticket::record_mission(repo, "blocked-ticket", "m-blocked").unwrap();
1329
1330        queue::enqueue(
1331            repo,
1332            QueueEntry {
1333                mission_id: "m-done".to_string(),
1334                ticket_slug: Some("done-ticket".to_string()),
1335                priority: 2,
1336                seq: 0,
1337            },
1338        )
1339        .unwrap();
1340        queue::enqueue(
1341            repo,
1342            QueueEntry {
1343                mission_id: "m-blocked".to_string(),
1344                ticket_slug: Some("blocked-ticket".to_string()),
1345                priority: 2,
1346                seq: 1,
1347            },
1348        )
1349        .unwrap();
1350
1351        let repo_path = repo.to_path_buf();
1352        let report = drain_queue_with_probe(
1353            repo,
1354            false,
1355            move |mission_id| {
1356                let repo_path = repo_path.clone();
1357                async move {
1358                    if mission_id == "m-done" {
1359                        write_events(
1360                            &repo_path,
1361                            &mission_id,
1362                            vec![created(), EventKind::MissionCompleted {}],
1363                        );
1364                    } else {
1365                        write_events(
1366                            &repo_path,
1367                            &mission_id,
1368                            vec![
1369                                created(),
1370                                EventKind::PlanApproved {
1371                                    plan: plan_with_one_milestone(),
1372                                    base_sha: None,
1373                                },
1374                                EventKind::MilestoneBlocked {
1375                                    block_context: None,
1376                                    milestone_id: "ms-1".to_string(),
1377                                    reason: "needs input".to_string(),
1378                                },
1379                            ],
1380                        );
1381                    }
1382                    // Both missions return Ok(0): the exit code must not
1383                    // determine the ticket's terminal state any more.
1384                    Ok(0)
1385                }
1386            },
1387            always_proceed,
1388        )
1389        .await
1390        .unwrap();
1391
1392        assert_eq!(report.ran.len(), 2);
1393        assert_eq!(Ticket::read_state(repo, "done-ticket"), TicketState::Done);
1394        assert_eq!(
1395            Ticket::read_state(repo, "blocked-ticket"),
1396            TicketState::NeedsContext
1397        );
1398    }
1399}