Skip to main content

chump_orchestrator/
lib.rs

1//! chump-orchestrator — AUTO-013 MVP steps 1+2.
2//!
3//! See `docs/AUTO-013-ORCHESTRATOR-DESIGN.md` for the full design. Step 1
4//! shipped the gap-picker (`pickable_gaps`) + dry-run binary. Step 2 (this
5//! PR) adds [`dispatch`] — subprocess-spawn for dispatched subagents.
6//! Monitor loop + reflection writes are steps 3-4.
7//!
8//! INFRA-DISPATCH-POLICY adds [`pick_gap`] — the policy-aware single-gap
9//! selector used by `chump --pick-gap`. Unlike [`pickable_gaps`] (which is
10//! stateless), `pick_gap` reads live lease state and the `CHUMP_DISPATCH_CAPACITY`
11//! cap, then sorts eligible gaps by priority ASC / effort ASC.
12
13pub mod dispatch;
14pub mod monitor;
15pub mod reflect;
16pub mod self_test;
17
18use anyhow::{Context, Result};
19use serde::Deserialize;
20use std::collections::HashSet;
21use std::path::Path;
22
23/// A minimal view of a gap entry from `docs/gaps.yaml`.
24///
25/// We only deserialize the fields the picker needs. Extra fields in the YAML
26/// (description, source_doc, closed_date, etc.) are ignored by serde so the
27/// schema can evolve without breaking us.
28#[derive(Debug, Clone, Deserialize)]
29pub struct Gap {
30    pub id: String,
31    #[serde(default)]
32    pub title: String,
33    #[serde(default)]
34    pub priority: String,
35    #[serde(default)]
36    pub effort: String,
37    #[serde(default)]
38    pub status: String,
39    #[serde(default)]
40    pub depends_on: Option<Vec<String>>,
41}
42
43#[derive(Debug, Deserialize)]
44struct GapsFile {
45    #[serde(default)]
46    gaps: Vec<Gap>,
47}
48
49/// Parse a gaps.yaml file from disk. Tolerant of unknown fields.
50pub fn load_gaps(path: &Path) -> Result<Vec<Gap>> {
51    let text = std::fs::read_to_string(path)
52        .with_context(|| format!("reading gaps file at {}", path.display()))?;
53    let parsed: GapsFile = serde_yaml::from_str(&text)
54        .with_context(|| format!("parsing YAML at {}", path.display()))?;
55    Ok(parsed.gaps)
56}
57
58/// Collect IDs of gaps already shipped (status == "done").
59pub fn done_ids(all: &[Gap]) -> HashSet<String> {
60    all.iter()
61        .filter(|g| g.status == "done")
62        .map(|g| g.id.clone())
63        .collect()
64}
65
66/// MVP picker. Filters open gaps to those a robot orchestrator can safely
67/// auto-dispatch, in input order, capped at `n`.
68///
69/// Rules (simplest possible heuristic — design doc Q-and-A doesn't lock this
70/// down for the MVP and reflection-driven tuning lands in AUTO-013-A):
71///
72/// 1. status == "open"
73/// 2. priority is "P1" or "P2" (skip P3+ until the loop is trusted)
74/// 3. effort != "xl" (XL gaps need human breakdown — see design doc §4)
75/// 4. all `depends_on` IDs are in `done_ids`
76/// 5. take first N in declared order
77///
78/// This is deliberately stupid. Reflection-driven priority tuning is AUTO-013-A.
79pub fn pickable_gaps<'a>(all: &'a [Gap], n: usize, done_ids: &HashSet<String>) -> Vec<&'a Gap> {
80    all.iter()
81        .filter(|g| g.status == "open")
82        .filter(|g| g.priority == "P1" || g.priority == "P2")
83        .filter(|g| g.effort != "xl")
84        .filter(|g| g.depends_on.iter().flatten().all(|d| done_ids.contains(d)))
85        .take(n)
86        .collect()
87}
88
89// ── INFRA-DISPATCH-POLICY: policy-aware single-gap picker ────────────────────
90
91/// Numeric rank for a priority string. Lower = higher urgency.
92/// Unknown strings sort last (u8::MAX).
93fn priority_rank(p: &str) -> u8 {
94    match p {
95        "P1" => 1,
96        "P2" => 2,
97        "P3" => 3,
98        "P4" => 4,
99        _ => u8::MAX,
100    }
101}
102
103/// Numeric rank for an effort string. Lower = smaller / faster.
104/// Unknown strings sort last (u8::MAX).
105fn effort_rank(e: &str) -> u8 {
106    match e {
107        "xs" => 0,
108        "s" => 1,
109        "m" => 2,
110        "l" => 3,
111        "xl" => 4,
112        _ => u8::MAX,
113    }
114}
115
116/// Read `CHUMP_DISPATCH_CAPACITY` from env, defaulting to 3.
117pub fn dispatch_capacity() -> usize {
118    std::env::var("CHUMP_DISPATCH_CAPACITY")
119        .ok()
120        .and_then(|s| s.trim().parse::<usize>().ok())
121        .unwrap_or(3)
122}
123
124/// Policy-aware single-gap picker — the heart of `chump --pick-gap`.
125///
126/// # Arguments
127///
128/// * `all` — all gaps loaded from `docs/gaps.yaml`
129/// * `done_ids` — set of gap IDs whose `status == "done"` (satisfied deps)
130/// * `live_claimed` — set of gap IDs currently held by a live lease in
131///   `.chump-locks/`. The caller is responsible for scanning the lease
132///   files and passing the live `gap_id` values here.
133/// * `active_count` — number of currently active (live-leased) dispatches
134///   that count against the capacity cap.
135/// * `capacity` — maximum concurrent dispatches allowed (`CHUMP_DISPATCH_CAPACITY`).
136///
137/// # Selection rules (in order)
138///
139/// 1. `status == "open"` — done/closed gaps are ineligible.
140/// 2. `live_claimed` skip — gap already has a live lease in `.chump-locks/`.
141/// 3. Dependency check — every ID in `depends_on` must be in `done_ids`.
142/// 4. Capacity cap — if `active_count >= capacity`, return `None`.
143/// 5. Sort by `priority` ASC (P1 first) then `effort` ASC (small first).
144/// 6. Return the first gap after sorting, or `None` if none are eligible.
145pub fn pick_gap<'a>(
146    all: &'a [Gap],
147    done_ids: &HashSet<String>,
148    live_claimed: &HashSet<String>,
149    active_count: usize,
150    capacity: usize,
151) -> Option<&'a Gap> {
152    // Rule 4: capacity gate — bail before any sorting work.
153    if active_count >= capacity {
154        return None;
155    }
156
157    let mut eligible: Vec<&Gap> = all
158        .iter()
159        // Rule 1: open only
160        .filter(|g| g.status == "open")
161        // Rule 2: not live-claimed
162        .filter(|g| !live_claimed.contains(&g.id))
163        // Rule 3: all dependencies done
164        .filter(|g| {
165            g.depends_on
166                .iter()
167                .flatten()
168                .all(|dep| done_ids.contains(dep))
169        })
170        .collect();
171
172    // Rule 5: sort by priority ASC, then effort ASC (prefer small+urgent)
173    eligible.sort_by_key(|g| (priority_rank(&g.priority), effort_rank(&g.effort)));
174
175    // Rule 6: return top candidate
176    eligible.into_iter().next()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use serial_test::serial;
183
184    fn g(id: &str, prio: &str, effort: &str, status: &str, deps: Option<Vec<&str>>) -> Gap {
185        Gap {
186            id: id.into(),
187            title: format!("title for {id}"),
188            priority: prio.into(),
189            effort: effort.into(),
190            status: status.into(),
191            depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
192        }
193    }
194
195    #[test]
196    fn picks_open_p1_first_n() {
197        let gaps = vec![
198            g("A", "P1", "m", "open", None),
199            g("B", "P1", "m", "open", None),
200            g("C", "P1", "m", "open", None),
201        ];
202        let done = HashSet::new();
203        let picked = pickable_gaps(&gaps, 2, &done);
204        assert_eq!(picked.len(), 2);
205        assert_eq!(picked[0].id, "A");
206        assert_eq!(picked[1].id, "B");
207    }
208
209    #[test]
210    fn skips_done_and_p3_and_xl() {
211        let gaps = vec![
212            g("DONE", "P1", "m", "done", None),
213            g("P3-LO", "P3", "m", "open", None),
214            g("XL", "P1", "xl", "open", None),
215            g("OK", "P2", "l", "open", None),
216        ];
217        let done = done_ids(&gaps);
218        let picked = pickable_gaps(&gaps, 5, &done);
219        assert_eq!(picked.len(), 1);
220        assert_eq!(picked[0].id, "OK");
221    }
222
223    #[test]
224    fn respects_unmet_dependency() {
225        let gaps = vec![
226            g("BLOCKER", "P1", "m", "open", None),
227            g("DEPENDENT", "P1", "m", "open", Some(vec!["BLOCKER"])),
228        ];
229        let done = done_ids(&gaps);
230        let picked = pickable_gaps(&gaps, 5, &done);
231        // BLOCKER is open (not done) so DEPENDENT is filtered out; only BLOCKER picks.
232        assert_eq!(picked.len(), 1);
233        assert_eq!(picked[0].id, "BLOCKER");
234    }
235
236    #[test]
237    fn met_dependency_unblocks() {
238        let gaps = vec![
239            g("BLOCKER", "P1", "m", "done", None),
240            g("DEPENDENT", "P1", "m", "open", Some(vec!["BLOCKER"])),
241        ];
242        let done = done_ids(&gaps);
243        let picked = pickable_gaps(&gaps, 5, &done);
244        assert_eq!(picked.len(), 1);
245        assert_eq!(picked[0].id, "DEPENDENT");
246    }
247
248    #[test]
249    fn n_zero_returns_empty() {
250        let gaps = vec![g("A", "P1", "m", "open", None)];
251        let picked = pickable_gaps(&gaps, 0, &HashSet::new());
252        assert!(picked.is_empty());
253    }
254
255    #[test]
256    fn empty_input_returns_empty() {
257        let picked = pickable_gaps(&[], 5, &HashSet::new());
258        assert!(picked.is_empty());
259    }
260
261    #[test]
262    fn multiple_unmet_deps_all_required() {
263        let gaps = vec![
264            g("A", "P1", "m", "done", None),
265            g("B", "P1", "m", "open", None), // open, not done
266            g("C", "P1", "m", "open", Some(vec!["A", "B"])),
267        ];
268        let done = done_ids(&gaps);
269        let picked = pickable_gaps(&gaps, 5, &done);
270        // C requires both A and B done; B is still open → C filtered.
271        let ids: Vec<&str> = picked.iter().map(|g| g.id.as_str()).collect();
272        assert_eq!(ids, vec!["B"]);
273    }
274
275    // ── INFRA-DISPATCH-POLICY: pick_gap tests ────────────────────────────
276
277    fn no_live() -> HashSet<String> {
278        HashSet::new()
279    }
280
281    #[test]
282    fn pick_gap_returns_none_when_capacity_full() {
283        let gaps = vec![g("A", "P1", "s", "open", None)];
284        let done = HashSet::new();
285        // active_count == capacity → blocked
286        let result = pick_gap(&gaps, &done, &no_live(), 3, 3);
287        assert!(
288            result.is_none(),
289            "capacity=3 active=3 should block dispatch"
290        );
291    }
292
293    #[test]
294    fn pick_gap_returns_none_when_all_live_claimed() {
295        let gaps = vec![
296            g("A", "P1", "s", "open", None),
297            g("B", "P2", "m", "open", None),
298        ];
299        let done = HashSet::new();
300        let live: HashSet<String> = ["A".to_string(), "B".to_string()].into();
301        let result = pick_gap(&gaps, &done, &live, 0, 3);
302        assert!(result.is_none(), "all gaps live-claimed → none available");
303    }
304
305    #[test]
306    fn pick_gap_skips_live_claimed_gap() {
307        let gaps = vec![
308            g("A", "P1", "s", "open", None),
309            g("B", "P2", "m", "open", None),
310        ];
311        let done = HashSet::new();
312        let live: HashSet<String> = ["A".to_string()].into();
313        let result = pick_gap(&gaps, &done, &live, 0, 3).expect("B should be picked");
314        assert_eq!(result.id, "B", "A is live-claimed; B should be selected");
315    }
316
317    #[test]
318    fn pick_gap_dependency_blocking() {
319        // C depends on B which is still open — C must not be picked.
320        let gaps = vec![
321            g("B", "P1", "m", "open", None),
322            g("C", "P1", "s", "open", Some(vec!["B"])),
323        ];
324        let done: HashSet<String> = HashSet::new(); // B not done
325        let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("B should be picked");
326        assert_eq!(result.id, "B", "C has unmet dep; B should be selected");
327    }
328
329    #[test]
330    fn pick_gap_dependency_unblocked_when_dep_done() {
331        let gaps = vec![
332            g("B", "P1", "m", "done", None),
333            g("C", "P1", "s", "open", Some(vec!["B"])),
334        ];
335        let done = done_ids(&gaps); // B is done
336        let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("C should be picked");
337        assert_eq!(result.id, "C", "B is done; C's dep is met");
338    }
339
340    #[test]
341    fn pick_gap_priority_ordering() {
342        // P2 and P1 — P1 should win regardless of insertion order.
343        let gaps = vec![
344            g("LOW", "P2", "s", "open", None),
345            g("HIGH", "P1", "l", "open", None),
346        ];
347        let done = HashSet::new();
348        let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("should pick");
349        assert_eq!(result.id, "HIGH", "P1 should beat P2");
350    }
351
352    #[test]
353    fn pick_gap_effort_ordering_within_same_priority() {
354        // Two P1 gaps — the smaller effort (s) should win.
355        let gaps = vec![
356            g("BIG", "P1", "l", "open", None),
357            g("SMALL", "P1", "s", "open", None),
358        ];
359        let done = HashSet::new();
360        let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("should pick");
361        assert_eq!(result.id, "SMALL", "s effort beats l within same priority");
362    }
363
364    #[test]
365    fn pick_gap_none_when_all_done() {
366        let gaps = vec![g("A", "P1", "s", "done", None)];
367        let done = done_ids(&gaps);
368        let result = pick_gap(&gaps, &done, &no_live(), 0, 3);
369        assert!(result.is_none(), "all done → nothing to pick");
370    }
371
372    #[test]
373    fn pick_gap_capacity_allows_when_below_cap() {
374        let gaps = vec![g("A", "P1", "s", "open", None)];
375        let done = HashSet::new();
376        // active_count=2, capacity=3 — still one slot free
377        let result = pick_gap(&gaps, &done, &no_live(), 2, 3);
378        assert!(
379            result.is_some(),
380            "active=2 < capacity=3 should allow dispatch"
381        );
382        assert_eq!(result.unwrap().id, "A");
383    }
384
385    #[test]
386    #[serial(dispatch_capacity)]
387    fn dispatch_capacity_default_is_3() {
388        std::env::remove_var("CHUMP_DISPATCH_CAPACITY");
389        assert_eq!(dispatch_capacity(), 3);
390    }
391
392    #[test]
393    #[serial(dispatch_capacity)]
394    fn dispatch_capacity_respects_env() {
395        std::env::set_var("CHUMP_DISPATCH_CAPACITY", "5");
396        let cap = dispatch_capacity();
397        std::env::remove_var("CHUMP_DISPATCH_CAPACITY");
398        assert_eq!(cap, 5);
399    }
400}