1use std::collections::HashMap;
54use std::path::{Path, PathBuf};
55use std::sync::atomic::{AtomicBool, Ordering};
56use std::sync::Arc;
57use std::time::Duration;
58
59use serde::Deserialize;
60use serde_json::json;
61
62use crate::claims::{self, ClaimState};
63use crate::events::EventEmitter;
64use crate::loop_dispatch::{fno_cmd, retry_etxtbsy};
65use crate::loop_runtime::{
66 CloseOutcome, Evidence, GlobalJournalPath, Journal, ProjectJournalPath, UnitResult,
67};
68use crate::loopcheck::TerminationReason;
69
70#[derive(Debug, Default)]
79pub struct CircuitBreaker {
80 failure_limit: u32,
81 failures: HashMap<String, u32>,
82}
83
84impl CircuitBreaker {
85 pub fn new(failure_limit: u32) -> Self {
88 Self {
89 failure_limit: failure_limit.max(1),
90 failures: HashMap::new(),
91 }
92 }
93
94 pub fn record_failure(&mut self, node: &str) -> bool {
97 let n = self.failures.entry(node.to_string()).or_insert(0);
98 *n += 1;
99 *n >= self.failure_limit
100 }
101
102 pub fn record_success(&mut self, node: &str) {
104 self.failures.remove(node);
105 }
106
107 pub fn reset(&mut self, node: &str) {
110 self.failures.remove(node);
111 }
112
113 pub fn consecutive_failures(&self, node: &str) -> u32 {
115 self.failures.get(node).copied().unwrap_or(0)
116 }
117}
118
119#[derive(Debug, Clone)]
126pub struct DrainConfig {
127 pub cwd: PathBuf,
131 pub fno_bin: String,
133 pub mission: String,
135 pub failure_limit: u32,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum DrainOutcome {
144 Dispatched { node: String },
146 Parked { node: String, failures: u32 },
148 NoWork,
150 Skipped { reason: String },
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum MissionDispatch {
158 Continue,
160 Retire,
162}
163
164fn defer_node(fno_bin: &str, cwd: &Path, node: &str, reason: &str) -> bool {
179 match retry_etxtbsy(|| {
180 fno_cmd(fno_bin)
181 .current_dir(cwd)
182 .args(["backlog", "defer", node, "--reason", reason])
183 .output()
184 }) {
185 Ok(out) if out.status.success() => true,
186 Ok(out) => {
187 eprintln!(
188 "active-backlog: defer of {node} failed (exit {:?}): {}",
189 out.status.code(),
190 String::from_utf8_lossy(&out.stderr).trim()
191 );
192 false
193 }
194 Err(e) => {
195 eprintln!("active-backlog: defer of {node} could not run: {e}");
196 false
197 }
198 }
199}
200
201fn node_has_pr_ref(cfg: &DrainConfig, node_id: &str) -> bool {
206 let Ok(out) = retry_etxtbsy(|| {
210 fno_cmd(&cfg.fno_bin)
211 .args(["backlog", "get", node_id])
212 .current_dir(&cfg.cwd)
213 .output()
214 }) else {
215 return true;
216 };
217 if !out.status.success() {
218 return true;
219 }
220 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
221 return true;
222 };
223 if v.get("pr_number").and_then(|n| n.as_u64()).is_some() {
227 return true;
228 }
229 if v.get("pr_url")
230 .and_then(|u| u.as_str())
231 .is_some_and(|u| !u.trim().is_empty())
232 {
233 return true;
234 }
235 v.get("additional_prs")
236 .and_then(|a| a.as_array())
237 .is_some_and(|a| !a.is_empty())
238}
239
240const PR_STAMP_GRACE_TICKS: u32 = 3;
247
248fn map_outcome(
254 cfg: &DrainConfig,
255 breaker: &mut CircuitBreaker,
256 journal: &Journal,
257 reason: &TerminationReason,
258 last_unit: Option<&crate::loop_runtime::UnitResult>,
259) -> DrainOutcome {
260 let Some(last) = last_unit else {
261 return match reason {
263 TerminationReason::NoWork => DrainOutcome::NoWork,
264 other => {
265 let _ = journal.append(
266 "active_backlog_skip",
267 json!({"reason": "no-close", "termination": format!("{other:?}")}),
268 );
269 DrainOutcome::Skipped {
270 reason: format!("{other:?}"),
271 }
272 }
273 };
274 };
275
276 let node = last.unit_id.clone();
277
278 if matches!(last.evidence.reason, TerminationReason::DoneBatched) {
284 breaker.record_success(&node);
285 let _ = journal.append(
286 "active_backlog_dispatched",
287 json!({"node_id": node, "termination": "DoneBatched", "batched": true}),
288 );
289 return DrainOutcome::Dispatched { node };
290 }
291
292 if matches!(last.evidence.reason, TerminationReason::DoneAwaitingMerge) {
298 breaker.record_success(&node);
299 let _ = journal.append(
300 "active_backlog_dispatched",
301 json!({"node_id": node, "termination": "DoneAwaitingMerge", "awaiting_merge": true}),
302 );
303 return DrainOutcome::Dispatched { node };
304 }
305
306 match &last.close {
307 CloseOutcome::Closed => {
308 breaker.record_success(&node);
309 let _ = journal.append(
310 "active_backlog_dispatched",
311 json!({"node_id": node, "termination": format!("{:?}", last.evidence.reason)}),
312 );
313 DrainOutcome::Dispatched { node }
314 }
315 CloseOutcome::AwaitingMerge => {
323 breaker.record_success(&node);
324 let _ = journal.append(
325 "active_backlog_dispatched",
326 json!({"node_id": node, "awaiting_merge": true, "close": "awaiting-merge"}),
327 );
328 DrainOutcome::Dispatched { node }
329 }
330 CloseOutcome::Parked(detail) | CloseOutcome::Refused(detail) => {
331 let tripped = breaker.record_failure(&node);
332 if tripped {
333 let reason_str = format!(
337 "auto-failure: {} consecutive failed drains",
338 cfg.failure_limit
339 );
340 let deferred = defer_node(&cfg.fno_bin, &cfg.cwd, &node, &reason_str);
345 breaker.reset(&node);
346 let _ = journal.append(
347 "active_backlog_parked",
348 json!({"node_id": node, "consecutive_failures": cfg.failure_limit, "detail": detail, "deferred": deferred}),
349 );
350 DrainOutcome::Parked {
351 node,
352 failures: cfg.failure_limit,
353 }
354 } else {
355 let _ = journal.append(
356 "active_backlog_skip",
357 json!({
358 "reason": "node-not-closed",
359 "node_id": node,
360 "close": detail,
361 "consecutive_failures": breaker.consecutive_failures(&node),
362 }),
363 );
364 DrainOutcome::Skipped {
365 reason: format!("node {node} not closed: {detail}"),
366 }
367 }
368 }
369 }
370}
371
372#[derive(Debug, Clone)]
391pub struct PendingDispatch {
392 node_id: String,
393 session_id: Option<String>,
397 ticks: u32,
401 stamp_waits: u32,
404}
405
406const BOOT_GRACE_TICKS: u32 = 3;
409
410fn is_done_reason(r: &TerminationReason) -> bool {
414 matches!(
415 r,
416 TerminationReason::DonePRGreen
417 | TerminationReason::DoneAdvisory
418 | TerminationReason::DoneDelivery
419 )
420}
421
422fn reconcile_pending(
426 cfg: &DrainConfig,
427 breaker: &mut CircuitBreaker,
428 pending: &mut Vec<PendingDispatch>,
429 journal: &Journal,
430) {
431 pending.retain_mut(|p| {
432 p.ticks += 1;
433 let (state, rec) = claims::status(&format!("node:{}", p.node_id), None);
439 if let Some(sid) = rec
440 .as_ref()
441 .and_then(|r| r.holder.strip_prefix("target-session:"))
442 {
443 p.session_id = Some(sid.to_string());
444 }
445 let worker_live = matches!(state, ClaimState::Live | ClaimState::Suspect);
448
449 if let Some(sid) = p.session_id.clone() {
452 match journal.find_termination(&sid) {
453 Ok(Some(ev)) => {
454 if matches!(ev.reason, TerminationReason::DonePRGreen)
457 && !node_has_pr_ref(cfg, &p.node_id)
458 && p.stamp_waits < PR_STAMP_GRACE_TICKS
459 {
460 p.stamp_waits += 1;
461 return true;
462 }
463 resolve_dispatch(cfg, breaker, journal, &p.node_id, ev);
464 return false;
465 }
466 Ok(None) if !worker_live => {
467 resolve_crash(cfg, breaker, journal, &p.node_id);
470 return false;
471 }
472 _ => {} }
474 } else if !worker_live && p.ticks >= BOOT_GRACE_TICKS {
475 resolve_crash(cfg, breaker, journal, &p.node_id);
478 return false;
479 }
480 true
481 });
482}
483
484fn resolve_dispatch(
487 cfg: &DrainConfig,
488 breaker: &mut CircuitBreaker,
489 journal: &Journal,
490 node_id: &str,
491 ev: Evidence,
492) {
493 let close = if matches!(ev.reason, TerminationReason::DonePRGreen)
505 && !node_has_pr_ref(cfg, node_id)
506 {
507 CloseOutcome::Parked(
508 "DonePRGreen terminal with no PR ref on the node (zero-artifact dispatch)".to_string(),
509 )
510 } else if is_done_reason(&ev.reason) {
511 match retry_etxtbsy(|| {
512 fno_cmd(&cfg.fno_bin)
513 .args(["backlog", "done", node_id])
514 .current_dir(&cfg.cwd)
515 .output()
516 }) {
517 Ok(o) if o.status.success() => CloseOutcome::Closed,
518 Ok(o) if o.status.code() == Some(5) => CloseOutcome::AwaitingMerge,
519 Ok(o) => {
520 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
521 CloseOutcome::Parked(if stderr.is_empty() {
522 format!("fno backlog done {node_id} failed (exit {})", o.status)
523 } else {
524 stderr
525 })
526 }
527 Err(e) => CloseOutcome::Parked(format!("fno backlog done {node_id} spawn failed: {e}")),
528 }
529 } else {
530 CloseOutcome::Parked(format!("session terminated: {:?}", ev.reason))
531 };
532 let reason = ev.reason.clone();
533 let ur = UnitResult {
534 unit_id: node_id.to_string(),
535 evidence: ev,
536 close,
537 };
538 map_outcome(cfg, breaker, journal, &reason, Some(&ur));
539}
540
541fn resolve_crash(
546 cfg: &DrainConfig,
547 breaker: &mut CircuitBreaker,
548 journal: &Journal,
549 node_id: &str,
550) {
551 let message = "worker exited with no termination event (fire-and-forget crash floor)";
552 let ur = UnitResult {
553 unit_id: node_id.to_string(),
554 evidence: Evidence {
555 reason: TerminationReason::NoProgress,
556 message: message.to_string(),
557 },
558 close: CloseOutcome::Parked(message.to_string()),
559 };
560 map_outcome(
561 cfg,
562 breaker,
563 journal,
564 &TerminationReason::NoProgress,
565 Some(&ur),
566 );
567}
568
569#[derive(Debug, Default, Deserialize)]
573struct AdvanceEpicReceipt {
574 #[serde(default)]
575 deactivated: bool,
576 #[serde(default)]
577 all_done: bool,
578 #[serde(default)]
581 dispatched: Vec<String>,
582}
583
584fn dispatch_mission(
593 cfg: &DrainConfig,
594 pending: &mut Vec<PendingDispatch>,
595 journal: &Journal,
596) -> MissionDispatch {
597 let out = match retry_etxtbsy(|| {
598 fno_cmd(&cfg.fno_bin)
599 .args([
602 "backlog",
603 "advance",
604 "--epic",
605 &cfg.mission,
606 "--continuation",
607 "--json",
608 ])
609 .current_dir(&cfg.cwd)
610 .output()
611 }) {
612 Ok(o) if o.status.success() => o,
613 Ok(o) => {
614 let detail = String::from_utf8_lossy(&o.stderr).trim().to_string();
615 let _ = journal.append(
616 "active_backlog_skip",
617 json!({"reason": "advance-epic-failed", "mission": cfg.mission, "detail": detail}),
618 );
619 return MissionDispatch::Continue;
620 }
621 Err(e) => {
622 let _ = journal.append(
623 "active_backlog_skip",
624 json!({"reason": "advance-epic-failed", "mission": cfg.mission, "detail": format!("{e}")}),
625 );
626 return MissionDispatch::Continue;
627 }
628 };
629 let receipt: AdvanceEpicReceipt = match serde_json::from_slice(&out.stdout) {
630 Ok(r) => r,
631 Err(e) => {
632 let _ = journal.append(
633 "active_backlog_skip",
634 json!({"reason": "advance-epic-unparseable", "mission": cfg.mission, "detail": format!("{e}")}),
635 );
636 return MissionDispatch::Continue;
637 }
638 };
639 if receipt.deactivated || receipt.all_done {
640 return MissionDispatch::Retire;
641 }
642 let mut new_ids = Vec::new();
643 for node_id in &receipt.dispatched {
644 if pending.iter().any(|p| p.node_id == *node_id) {
648 continue;
649 }
650 pending.push(PendingDispatch {
651 node_id: node_id.clone(),
652 session_id: None,
653 ticks: 0,
654 stamp_waits: 0,
655 });
656 new_ids.push(node_id.clone());
657 }
658 if !new_ids.is_empty() {
659 let _ = journal.append(
660 "active_backlog_dispatched",
661 json!({"mission": cfg.mission, "dispatched": new_ids, "fire_and_forget": true}),
662 );
663 }
664 MissionDispatch::Continue
665}
666
667pub fn mission_drain_tick(
672 cfg: &DrainConfig,
673 breaker: &mut CircuitBreaker,
674 pending: &mut Vec<PendingDispatch>,
675 journal: &Journal,
676) -> MissionDispatch {
677 reconcile_pending(cfg, breaker, pending, journal);
678 dispatch_mission(cfg, pending, journal)
679}
680
681#[derive(Debug, Clone, Deserialize)]
686pub struct ResolvedTarget {
687 pub project: String,
689 pub cwd: String,
691 pub interval_seconds: u64,
692 pub failure_limit: u32,
693 #[serde(default)]
697 pub mission: Option<String>,
698}
699
700pub fn resolve_targets(fno_bin: &str) -> Vec<ResolvedTarget> {
704 match fno_cmd(fno_bin)
705 .args(["config", "active-backlog", "--json"])
706 .output()
707 {
708 Ok(o) if o.status.success() => serde_json::from_slice(&o.stdout).unwrap_or_default(),
709 _ => Vec::new(),
710 }
711}
712
713#[derive(Debug, Clone, serde::Deserialize)]
717struct FanoutTarget {
718 pub project: String,
719 pub cwd: String,
720 pub interval_seconds: u64,
721}
722
723fn resolve_fanout_targets(fno_bin: &str) -> Vec<FanoutTarget> {
727 match fno_cmd(fno_bin)
728 .args(["config", "status-sinks", "--json"])
729 .output()
730 {
731 Ok(o) if o.status.success() => serde_json::from_slice(&o.stdout).unwrap_or_default(),
732 _ => Vec::new(),
733 }
734}
735
736const TICK_CHILD_CAP: Duration = Duration::from_secs(300);
746
747async fn output_with_cap(mut cmd: tokio::process::Command, cap: Duration) -> bool {
753 cmd.kill_on_drop(true);
754 match tokio::time::timeout(cap, cmd.output()).await {
755 Ok(Ok(_)) => false,
756 Ok(Err(e)) => {
759 eprintln!("fanout tick failed to execute: {e}");
760 false
761 }
762 Err(_) => true,
763 }
764}
765
766async fn per_project_fanout_loop(target: FanoutTarget, fno_bin: String, shutdown: Arc<AtomicBool>) {
767 let project = target.project.clone();
768 loop {
769 if shutdown.load(Ordering::SeqCst) {
770 break;
771 }
772 let interval = match resolve_fanout_targets(&fno_bin)
775 .into_iter()
776 .find(|t| t.project == project)
777 {
778 Some(t) => Duration::from_secs(t.interval_seconds.max(1)),
779 None => break, };
781 let mut cmd = tokio::process::Command::new(&fno_bin);
782 cmd.args(["status-fanout", "tick"]).current_dir(&target.cwd);
783 if output_with_cap(cmd, TICK_CHILD_CAP).await {
786 eprintln!(
787 "fanout tick for {project} exceeded {TICK_CHILD_CAP:?}; killed, retrying next tick"
788 );
789 }
790 sleep_interruptible(interval, &shutdown).await;
791 }
792}
793
794fn journal_for(cwd: &Path) -> Journal {
797 let project_events = cwd.join(".fno").join("events.jsonl");
798 let home = std::env::var("HOME")
799 .map(PathBuf::from)
800 .unwrap_or_else(|_| PathBuf::from("/tmp"));
801 let global_events = home.join(".fno").join("events.jsonl");
802 Journal::new(
803 ProjectJournalPath(project_events),
804 GlobalJournalPath(global_events),
805 )
806}
807
808fn drain_config_for(target: &ResolvedTarget, fno_bin: &str) -> Option<DrainConfig> {
813 let mission = target.mission.clone()?;
814 Some(DrainConfig {
815 cwd: PathBuf::from(&target.cwd),
816 fno_bin: fno_bin.to_string(),
817 mission,
818 failure_limit: target.failure_limit,
819 })
820}
821
822fn nudge_sentinel_path() -> PathBuf {
827 let home = std::env::var("HOME")
828 .map(PathBuf::from)
829 .unwrap_or_else(|_| PathBuf::from("/tmp"));
830 home.join(".fno").join(".active-backlog-nudge")
831}
832
833async fn nudge_mtime() -> Option<std::time::SystemTime> {
838 tokio::task::spawn_blocking(|| {
839 std::fs::metadata(nudge_sentinel_path())
840 .and_then(|m| m.modified())
841 .ok()
842 })
843 .await
844 .ok()
845 .flatten()
846}
847
848async fn wait_for_wake(
854 total: Duration,
855 shutdown: &Arc<AtomicBool>,
856 last: &mut Option<std::time::SystemTime>,
857) {
858 let step = Duration::from_millis(500);
859 let mut elapsed = Duration::ZERO;
860 while elapsed < total {
861 if shutdown.load(Ordering::SeqCst) {
862 return;
863 }
864 let current = nudge_mtime().await;
865 if current != *last {
866 *last = current;
867 return; }
869 let chunk = step.min(total - elapsed);
870 tokio::time::sleep(chunk).await;
871 elapsed += chunk;
872 }
873}
874
875pub async fn run_supervisor(
886 fno_bin: String,
887 emitter: EventEmitter,
888 live: Arc<AtomicBool>,
889 shutdown: Arc<AtomicBool>,
890) {
891 let mut tasks: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
893 let mut fanout_tasks: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
897 let recheck = Duration::from_secs(60);
898
899 loop {
900 if shutdown.load(Ordering::SeqCst) {
901 break;
902 }
903 tasks.retain(|_, h| !h.is_finished());
905 fanout_tasks.retain(|_, h| !h.is_finished());
906
907 let targets = resolve_targets(&fno_bin);
908 let fanout_targets = resolve_fanout_targets(&fno_bin);
909 live.store(
914 !targets.is_empty() || !fanout_targets.is_empty(),
915 Ordering::SeqCst,
916 );
917
918 for target in targets {
919 let Some(mission) = target.mission.clone() else {
922 continue;
923 };
924 if let std::collections::hash_map::Entry::Vacant(slot) = tasks.entry(mission) {
927 slot.insert(tokio::spawn(mission_drain_loop(
928 target,
929 fno_bin.clone(),
930 emitter.clone(),
931 Arc::clone(&shutdown),
932 )));
933 }
934 }
935
936 for ft in fanout_targets {
937 if let std::collections::hash_map::Entry::Vacant(slot) =
940 fanout_tasks.entry(ft.project.clone())
941 {
942 slot.insert(tokio::spawn(per_project_fanout_loop(
943 ft,
944 fno_bin.clone(),
945 Arc::clone(&shutdown),
946 )));
947 }
948 }
949
950 sleep_interruptible(recheck, &shutdown).await;
951 }
952
953 for (_, h) in tasks {
954 h.abort();
955 }
956 for (_, h) in fanout_tasks {
957 h.abort();
958 }
959 live.store(false, Ordering::SeqCst);
960}
961
962async fn sleep_interruptible(total: Duration, shutdown: &Arc<AtomicBool>) {
965 let step = Duration::from_millis(500);
966 let mut elapsed = Duration::ZERO;
967 while elapsed < total {
968 if shutdown.load(Ordering::SeqCst) {
969 return;
970 }
971 let chunk = step.min(total - elapsed);
972 tokio::time::sleep(chunk).await;
973 elapsed += chunk;
974 }
975}
976
977async fn mission_drain_loop(
983 target: ResolvedTarget,
984 fno_bin: String,
985 emitter: EventEmitter,
986 shutdown: Arc<AtomicBool>,
987) {
988 let mission = target.mission.clone().unwrap_or_default();
992 let mut breaker = CircuitBreaker::new(target.failure_limit);
993 let mut pending: Vec<PendingDispatch> = Vec::new();
997 let mut last_nudge = nudge_mtime().await;
998 let mut backoff = Duration::from_secs(1);
999
1000 loop {
1001 if shutdown.load(Ordering::SeqCst) {
1002 break;
1003 }
1004
1005 let current = resolve_targets(&fno_bin)
1009 .into_iter()
1010 .find(|t| t.mission.as_deref() == Some(mission.as_str()));
1011 let Some(t) = current else {
1012 break;
1013 };
1014 let interval = Duration::from_secs(t.interval_seconds.max(1));
1015
1016 let Some(cfg) = drain_config_for(&t, &fno_bin) else {
1017 sleep_interruptible(interval, &shutdown).await;
1019 continue;
1020 };
1021 let journal = journal_for(&cfg.cwd);
1022
1023 let taken_b = std::mem::take(&mut breaker);
1027 let taken_p = std::mem::take(&mut pending);
1028 let handle = tokio::task::spawn_blocking(move || {
1029 let mut b = taken_b;
1030 let mut p = taken_p;
1031 let outcome = mission_drain_tick(&cfg, &mut b, &mut p, &journal);
1032 (outcome, b, p)
1033 });
1034 match handle.await {
1035 Ok((outcome, b, p)) => {
1036 breaker = b;
1037 pending = p;
1038 backoff = Duration::from_secs(1);
1039 if outcome == MissionDispatch::Retire {
1040 let _ = emitter.emit(
1041 "active_backlog_mission_retired",
1042 &json!({"mission": mission}),
1043 );
1044 break;
1045 }
1046 }
1047 Err(join_err) => {
1048 let _ = emitter.emit(
1049 "active_backlog_task_crashed",
1050 &json!({"mission": mission, "error": join_err.to_string()}),
1051 );
1052 breaker = CircuitBreaker::new(t.failure_limit);
1057 pending = Vec::new();
1058 sleep_interruptible(backoff, &shutdown).await;
1059 backoff = (backoff * 2).min(Duration::from_secs(60));
1060 continue;
1061 }
1062 }
1063
1064 wait_for_wake(interval, &shutdown, &mut last_nudge).await;
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 #[test]
1073 fn status_fanout_targets_parse_from_json() {
1074 let json = br#"[{"project":"fno","cwd":"/repo/fno","interval_seconds":5}]"#;
1075 let targets: Vec<FanoutTarget> = serde_json::from_slice(json).unwrap();
1076 assert_eq!(targets.len(), 1);
1077 assert_eq!(targets[0].project, "fno");
1078 assert_eq!(targets[0].cwd, "/repo/fno");
1079 assert_eq!(targets[0].interval_seconds, 5);
1080 }
1081
1082 #[test]
1083 fn status_fanout_targets_empty_on_garbage() {
1084 let targets: Vec<FanoutTarget> = serde_json::from_slice(b"not json").unwrap_or_default();
1085 assert!(targets.is_empty());
1086 }
1087
1088 #[tokio::test]
1089 async fn tick_child_killed_at_cap() {
1090 let mut cmd = tokio::process::Command::new("sleep");
1093 cmd.arg("60");
1094 let start = std::time::Instant::now();
1095 let timed_out = output_with_cap(cmd, Duration::from_millis(150)).await;
1096 assert!(timed_out, "a hung child must report timed-out");
1097 assert!(
1098 start.elapsed() < Duration::from_secs(5),
1099 "must return near the cap, not wait on the 60s child"
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn tick_child_within_cap_reports_ok() {
1105 let cmd = tokio::process::Command::new("true");
1107 let timed_out = output_with_cap(cmd, Duration::from_secs(30)).await;
1108 assert!(!timed_out, "a fast child must not be reported as timed-out");
1109 }
1110
1111 #[test]
1112 fn advance_epic_receipt_parses_dispatched_and_liveness() {
1113 let r: AdvanceEpicReceipt = serde_json::from_slice(
1115 br#"{"epic_id":"x-e","error":null,"activated":true,"deactivated":false,
1116 "all_done":false,"dispatched":["x-a","x-b"],"children":[]}"#,
1117 )
1118 .unwrap();
1119 assert_eq!(r.dispatched, vec!["x-a", "x-b"]);
1120 assert!(!r.deactivated);
1121 assert!(!r.all_done);
1122 }
1123
1124 #[test]
1125 fn advance_epic_receipt_defaults_on_partial_json() {
1126 let r: AdvanceEpicReceipt = serde_json::from_slice(br#"{"epic_id":"x-e"}"#).unwrap();
1129 assert!(r.dispatched.is_empty());
1130 assert!(!r.deactivated && !r.all_done);
1131 }
1132
1133 #[test]
1134 fn is_done_reason_includes_generic_delivery() {
1135 assert!(is_done_reason(&TerminationReason::DonePRGreen));
1138 assert!(is_done_reason(&TerminationReason::DoneAdvisory));
1139 assert!(is_done_reason(&TerminationReason::DoneDelivery));
1140 assert!(!is_done_reason(&TerminationReason::DoneBatched));
1141 assert!(!is_done_reason(&TerminationReason::DoneAwaitingMerge));
1142 assert!(!is_done_reason(&TerminationReason::NoProgress));
1143 }
1144
1145 use std::os::unix::fs::PermissionsExt;
1155
1156 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1172 crate::claims::test_env_lock()
1173 .lock()
1174 .unwrap_or_else(|e| e.into_inner())
1175 }
1176
1177 fn stub_fno(dir: &std::path::Path, record: &std::path::Path) -> String {
1180 std::fs::create_dir_all(dir).unwrap();
1181 let p = dir.join("fno");
1182 std::fs::write(
1183 &p,
1184 format!(
1185 "#!/usr/bin/env bash\necho \"$@\" >> \"{}\"\nexit 0\n",
1186 record.display()
1187 ),
1188 )
1189 .unwrap();
1190 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1191 p.display().to_string()
1192 }
1193
1194 fn stub_fno_defer_fails(dir: &std::path::Path, record: &std::path::Path) -> String {
1198 std::fs::create_dir_all(dir).unwrap();
1199 let p = dir.join("fno");
1200 std::fs::write(
1201 &p,
1202 format!(
1203 "#!/usr/bin/env bash\n\
1204 echo \"$@\" >> \"{}\"\n\
1205 if [ \"$2\" = \"defer\" ]; then echo 'node not found' >&2; exit 1; fi\n\
1206 exit 0\n",
1207 record.display()
1208 ),
1209 )
1210 .unwrap();
1211 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1212 p.display().to_string()
1213 }
1214
1215 fn stub_fno_get(dir: &std::path::Path, record: &std::path::Path, node_json: &str) -> String {
1219 std::fs::create_dir_all(dir).unwrap();
1220 let p = dir.join("fno");
1221 std::fs::write(
1222 &p,
1223 format!(
1224 "#!/usr/bin/env bash\n\
1225 if [ \"$2\" = \"get\" ]; then printf '%s' '{}'; exit 0; fi\n\
1226 echo \"$@\" >> \"{}\"\nexit 0\n",
1227 node_json,
1228 record.display()
1229 ),
1230 )
1231 .unwrap();
1232 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1233 p.display().to_string()
1234 }
1235
1236 fn test_cfg(tmp: &std::path::Path, fno_bin: String, failure_limit: u32) -> DrainConfig {
1237 DrainConfig {
1238 cwd: tmp.to_path_buf(),
1239 fno_bin,
1240 mission: "x-epic".to_string(),
1241 failure_limit,
1242 }
1243 }
1244
1245 fn test_journal(tmp: &std::path::Path) -> (Journal, PathBuf) {
1246 let project = tmp.join(".fno").join("events.jsonl");
1247 let global = tmp.join("global-events.jsonl");
1248 std::fs::create_dir_all(project.parent().unwrap()).unwrap();
1249 (Journal::new_raw(project.clone(), global), project)
1250 }
1251
1252 fn journal_lines(p: &std::path::Path) -> Vec<String> {
1253 std::fs::read_to_string(p)
1254 .unwrap_or_default()
1255 .lines()
1256 .map(str::to_string)
1257 .collect()
1258 }
1259
1260 #[test]
1261 fn resolve_dispatch_done_records_success_and_marks_done() {
1262 let _env = env_guard();
1263 let tmp = tempfile::TempDir::new().unwrap();
1264 let record = tmp.path().join("fno-calls.txt");
1265 let fno = stub_fno(&tmp.path().join("bin"), &record);
1266 let cfg = test_cfg(tmp.path(), fno, 3);
1267 let (journal, project_journal) = test_journal(tmp.path());
1268 let mut breaker = CircuitBreaker::new(3);
1269 breaker.record_failure("x-suc0001"); resolve_dispatch(
1272 &cfg,
1273 &mut breaker,
1274 &journal,
1275 "x-suc0001",
1276 Evidence {
1277 reason: TerminationReason::DonePRGreen,
1278 message: "done".to_string(),
1279 },
1280 );
1281
1282 assert_eq!(
1283 breaker.consecutive_failures("x-suc0001"),
1284 0,
1285 "success resets the streak"
1286 );
1287 let calls = std::fs::read_to_string(&record).unwrap_or_default();
1289 assert!(calls.contains("backlog done x-suc0001"), "calls: {calls}");
1290 assert!(journal_lines(&project_journal)
1291 .iter()
1292 .any(|l| l.contains("active_backlog_dispatched") && l.contains("x-suc0001")));
1293 }
1294
1295 #[test]
1296 fn resolve_dispatch_done_pr_green_without_pr_ref_is_a_failure() {
1297 let _env = env_guard();
1298 let tmp = tempfile::TempDir::new().unwrap();
1303 let record = tmp.path().join("fno-calls.txt");
1304 let fno = stub_fno_get(
1305 &tmp.path().join("bin"),
1306 &record,
1307 r#"{"id":"x-dead0001","status":"in_review"}"#,
1308 );
1309 let cfg = test_cfg(tmp.path(), fno, 3);
1310 let (journal, project_journal) = test_journal(tmp.path());
1311 let mut breaker = CircuitBreaker::new(3);
1312
1313 resolve_dispatch(
1314 &cfg,
1315 &mut breaker,
1316 &journal,
1317 "x-dead0001",
1318 Evidence {
1319 reason: TerminationReason::DonePRGreen,
1320 message: "promised".to_string(),
1321 },
1322 );
1323
1324 assert_eq!(
1325 breaker.consecutive_failures("x-dead0001"),
1326 1,
1327 "a zero-artifact DonePRGreen counts toward the streak"
1328 );
1329 let calls = std::fs::read_to_string(&record).unwrap_or_default();
1330 assert!(
1331 !calls.contains("backlog done"),
1332 "must not close a node whose terminal lied: {calls}"
1333 );
1334 assert!(journal_lines(&project_journal)
1335 .iter()
1336 .any(|l| l.contains("active_backlog_skip") && l.contains("x-dead0001")));
1337 }
1338
1339 #[test]
1340 fn resolve_dispatch_done_pr_green_with_pr_ref_is_success() {
1341 let _env = env_guard();
1342 let tmp = tempfile::TempDir::new().unwrap();
1345 let record = tmp.path().join("fno-calls.txt");
1346 let fno = stub_fno_get(
1347 &tmp.path().join("bin"),
1348 &record,
1349 r#"{"id":"x-live0001","pr_number":477}"#,
1350 );
1351 let cfg = test_cfg(tmp.path(), fno, 3);
1352 let (journal, _pj) = test_journal(tmp.path());
1353 let mut breaker = CircuitBreaker::new(3);
1354
1355 resolve_dispatch(
1356 &cfg,
1357 &mut breaker,
1358 &journal,
1359 "x-live0001",
1360 Evidence {
1361 reason: TerminationReason::DonePRGreen,
1362 message: String::new(),
1363 },
1364 );
1365
1366 assert_eq!(breaker.consecutive_failures("x-live0001"), 0);
1367 let calls = std::fs::read_to_string(&record).unwrap_or_default();
1368 assert!(calls.contains("backlog done x-live0001"), "calls: {calls}");
1369 }
1370
1371 #[test]
1372 fn zero_artifact_check_fails_open_on_unreadable_node() {
1373 let _env = env_guard();
1374 let tmp = tempfile::TempDir::new().unwrap();
1378 let record = tmp.path().join("fno-calls.txt");
1379 let fno = stub_fno(&tmp.path().join("bin"), &record);
1380 let cfg = test_cfg(tmp.path(), fno, 3);
1381
1382 assert!(
1383 node_has_pr_ref(&cfg, "x-unknown1"),
1384 "unreadable node must fail open"
1385 );
1386 }
1387
1388 #[test]
1389 fn pr_ref_read_unions_additional_prs() {
1390 let _env = env_guard();
1391 let tmp = tempfile::TempDir::new().unwrap();
1394 let record = tmp.path().join("fno-calls.txt");
1395 let fno = stub_fno_get(
1396 &tmp.path().join("bin"),
1397 &record,
1398 r#"{"id":"x-addl0001","additional_prs":[{"number":12}]}"#,
1399 );
1400 let cfg = test_cfg(tmp.path(), fno, 3);
1401
1402 assert!(node_has_pr_ref(&cfg, "x-addl0001"));
1403 }
1404
1405 #[test]
1406 fn empty_pr_url_is_not_a_ref() {
1407 let _env = env_guard();
1408 let tmp = tempfile::TempDir::new().unwrap();
1411 let record = tmp.path().join("fno-calls.txt");
1412 let fno = stub_fno_get(
1413 &tmp.path().join("bin"),
1414 &record,
1415 r#"{"id":"x-empt0001","pr_url":" "}"#,
1416 );
1417 let cfg = test_cfg(tmp.path(), fno, 3);
1418
1419 assert!(!node_has_pr_ref(&cfg, "x-empt0001"));
1420 }
1421
1422 #[test]
1423 fn resolve_dispatch_advisory_without_pr_ref_is_still_success() {
1424 let _env = env_guard();
1425 let tmp = tempfile::TempDir::new().unwrap();
1428 let record = tmp.path().join("fno-calls.txt");
1429 let fno = stub_fno_get(
1430 &tmp.path().join("bin"),
1431 &record,
1432 r#"{"id":"x-doc00001","status":"in_review"}"#,
1433 );
1434 let cfg = test_cfg(tmp.path(), fno, 3);
1435 let (journal, _pj) = test_journal(tmp.path());
1436 let mut breaker = CircuitBreaker::new(3);
1437
1438 resolve_dispatch(
1439 &cfg,
1440 &mut breaker,
1441 &journal,
1442 "x-doc00001",
1443 Evidence {
1444 reason: TerminationReason::DoneAdvisory,
1445 message: String::new(),
1446 },
1447 );
1448
1449 assert_eq!(breaker.consecutive_failures("x-doc00001"), 0);
1450 let calls = std::fs::read_to_string(&record).unwrap_or_default();
1451 assert!(calls.contains("backlog done x-doc00001"), "calls: {calls}");
1452 }
1453
1454 #[test]
1455 fn resolve_dispatch_awaiting_merge_is_success_without_done() {
1456 let _env = env_guard();
1457 let tmp = tempfile::TempDir::new().unwrap();
1460 let record = tmp.path().join("fno-calls.txt");
1461 let fno = stub_fno(&tmp.path().join("bin"), &record);
1462 let cfg = test_cfg(tmp.path(), fno, 3);
1463 let (journal, _pj) = test_journal(tmp.path());
1464 let mut breaker = CircuitBreaker::new(3);
1465 breaker.record_failure("x-awm0001");
1466
1467 resolve_dispatch(
1468 &cfg,
1469 &mut breaker,
1470 &journal,
1471 "x-awm0001",
1472 Evidence {
1473 reason: TerminationReason::DoneAwaitingMerge,
1474 message: String::new(),
1475 },
1476 );
1477
1478 assert_eq!(breaker.consecutive_failures("x-awm0001"), 0);
1479 let calls = std::fs::read_to_string(&record).unwrap_or_default();
1480 assert!(
1481 !calls.contains("backlog done"),
1482 "awaiting-merge must not mark done: {calls}"
1483 );
1484 }
1485
1486 #[test]
1487 fn resolve_dispatch_failed_done_records_failure_not_false_success() {
1488 let _env = env_guard();
1492 let tmp = tempfile::TempDir::new().unwrap();
1493 let bin = tmp.path().join("bin");
1494 std::fs::create_dir_all(&bin).unwrap();
1495 let fno = bin.join("fno");
1496 std::fs::write(
1497 &fno,
1498 "#!/usr/bin/env bash\nif [[ \"$1\" == backlog && \"$2\" == done ]]; then echo 'node has open blockers' >&2; exit 1; fi\nexit 0\n",
1499 )
1500 .unwrap();
1501 std::fs::set_permissions(&fno, std::fs::Permissions::from_mode(0o755)).unwrap();
1502 let cfg = test_cfg(tmp.path(), fno.display().to_string(), 3);
1503 let (journal, _pj) = test_journal(tmp.path());
1504 let mut breaker = CircuitBreaker::new(3);
1505
1506 resolve_dispatch(
1507 &cfg,
1508 &mut breaker,
1509 &journal,
1510 "x-donefail",
1511 Evidence {
1512 reason: TerminationReason::DonePRGreen,
1513 message: "done".to_string(),
1514 },
1515 );
1516
1517 assert_eq!(
1518 breaker.consecutive_failures("x-donefail"),
1519 1,
1520 "a failed `backlog done` must count as a failure, not a false success"
1521 );
1522 }
1523
1524 #[test]
1525 fn resolve_dispatch_done_exit5_is_awaiting_merge_success() {
1526 let _env = env_guard();
1531 let tmp = tempfile::TempDir::new().unwrap();
1532 let bin = tmp.path().join("bin");
1533 std::fs::create_dir_all(&bin).unwrap();
1534 let fno = bin.join("fno");
1535 std::fs::write(
1536 &fno,
1537 "#!/usr/bin/env bash\nif [[ \"$1\" == backlog && \"$2\" == done ]]; then echo 'awaiting merge: PR OPEN' >&2; exit 5; fi\nexit 0\n",
1538 )
1539 .unwrap();
1540 std::fs::set_permissions(&fno, std::fs::Permissions::from_mode(0o755)).unwrap();
1541 let cfg = test_cfg(tmp.path(), fno.display().to_string(), 3);
1542 let (journal, project_journal) = test_journal(tmp.path());
1543 let mut breaker = CircuitBreaker::new(3);
1544 breaker.record_failure("x-awm5001"); resolve_dispatch(
1547 &cfg,
1548 &mut breaker,
1549 &journal,
1550 "x-awm5001",
1551 Evidence {
1552 reason: TerminationReason::DonePRGreen,
1553 message: "done".to_string(),
1554 },
1555 );
1556
1557 assert_eq!(
1558 breaker.consecutive_failures("x-awm5001"),
1559 0,
1560 "done exit 5 (awaiting merge) is a success, never a failure"
1561 );
1562 assert!(journal_lines(&project_journal)
1563 .iter()
1564 .any(|l| l.contains("active_backlog_dispatched") && l.contains("awaiting_merge")));
1565 }
1566
1567 #[test]
1568 fn resolve_crash_at_limit_defers_and_parks() {
1569 let _env = env_guard();
1570 let tmp = tempfile::TempDir::new().unwrap();
1573 let record = tmp.path().join("fno-calls.txt");
1574 let fno = stub_fno(&tmp.path().join("bin"), &record);
1575 let cfg = test_cfg(tmp.path(), fno, 2);
1576 let (journal, project_journal) = test_journal(tmp.path());
1577 let mut breaker = CircuitBreaker::new(2);
1578
1579 resolve_crash(&cfg, &mut breaker, &journal, "x-cra0001"); assert_eq!(breaker.consecutive_failures("x-cra0001"), 1);
1581 resolve_crash(&cfg, &mut breaker, &journal, "x-cra0001"); assert_eq!(breaker.consecutive_failures("x-cra0001"), 0);
1585 let calls = std::fs::read_to_string(&record).unwrap_or_default();
1586 assert!(calls.contains("backlog defer x-cra0001"), "calls: {calls}");
1587 let parked = journal_lines(&project_journal)
1588 .into_iter()
1589 .find(|l| l.contains("active_backlog_parked") && l.contains("x-cra0001"))
1590 .expect("parked event");
1591 assert!(parked.contains("\"deferred\":true"), "parked: {parked}");
1593 }
1594
1595 #[test]
1596 fn park_records_a_defer_that_did_not_land() {
1597 let _env = env_guard();
1598 let tmp = tempfile::TempDir::new().unwrap();
1605 let record = tmp.path().join("fno-calls.txt");
1606 let fno = stub_fno_defer_fails(&tmp.path().join("bin"), &record);
1607 let cfg = test_cfg(tmp.path(), fno, 2);
1608 let (journal, project_journal) = test_journal(tmp.path());
1609 let mut breaker = CircuitBreaker::new(2);
1610
1611 resolve_crash(&cfg, &mut breaker, &journal, "x-cra0002");
1612 resolve_crash(&cfg, &mut breaker, &journal, "x-cra0002"); let calls = std::fs::read_to_string(&record).unwrap_or_default();
1615 assert!(calls.contains("backlog defer x-cra0002"), "calls: {calls}");
1616 let parked = journal_lines(&project_journal)
1617 .into_iter()
1618 .find(|l| l.contains("active_backlog_parked") && l.contains("x-cra0002"))
1619 .expect("parked event still emitted on a failed defer");
1620 assert!(
1621 parked.contains("\"deferred\":false"),
1622 "a defer that exited non-zero must be recorded as not landed: {parked}"
1623 );
1624 }
1625
1626 #[test]
1627 fn reconcile_boot_grace_then_crash_floor() {
1628 let _env = env_guard();
1629 let tmp = tempfile::TempDir::new().unwrap();
1633 let record = tmp.path().join("fno-calls.txt");
1634 let fno = stub_fno(&tmp.path().join("bin"), &record);
1635 let cfg = test_cfg(tmp.path(), fno, 3);
1636 let (journal, _pj) = test_journal(tmp.path());
1637 let mut breaker = CircuitBreaker::new(3);
1638 let mut pending = vec![PendingDispatch {
1639 node_id: "x-bootgrace-never-real".to_string(),
1640 session_id: None,
1641 ticks: 0,
1642 stamp_waits: 0,
1643 }];
1644
1645 for _ in 1..BOOT_GRACE_TICKS {
1647 reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1648 assert_eq!(
1649 pending.len(),
1650 1,
1651 "must keep the dispatch during the boot window"
1652 );
1653 assert_eq!(breaker.consecutive_failures("x-bootgrace-never-real"), 0);
1654 }
1655 reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1657 assert!(
1658 pending.is_empty(),
1659 "the never-booted dispatch is retired as a crash"
1660 );
1661 assert_eq!(breaker.consecutive_failures("x-bootgrace-never-real"), 1);
1662 }
1663
1664 #[test]
1665 fn refless_done_pr_green_waits_for_the_stamp_before_parking() {
1666 let _env = env_guard();
1667 let tmp = tempfile::TempDir::new().unwrap();
1672 let record = tmp.path().join("fno-calls.txt");
1673 let fno = stub_fno_get(
1674 &tmp.path().join("bin"),
1675 &record,
1676 r#"{"id":"x-grace-never-real"}"#,
1677 );
1678 let cfg = test_cfg(tmp.path(), fno, 3);
1679 let (journal, project_journal) = test_journal(tmp.path());
1680 std::fs::write(
1681 &project_journal,
1682 "{\"type\":\"termination\",\"data\":{\"session_id\":\"sid-grace\",\"reason\":\"DonePRGreen\"}}\n",
1683 )
1684 .unwrap();
1685 let mut breaker = CircuitBreaker::new(3);
1686 let mut pending = vec![PendingDispatch {
1687 node_id: "x-grace-never-real".to_string(),
1688 session_id: Some("sid-grace".to_string()),
1689 ticks: 0,
1690 stamp_waits: 0,
1691 }];
1692
1693 for _ in 0..PR_STAMP_GRACE_TICKS {
1694 reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1695 assert_eq!(pending.len(), 1, "held while the stamp may still land");
1696 assert_eq!(breaker.consecutive_failures("x-grace-never-real"), 0);
1697 }
1698
1699 reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1700 assert!(
1701 pending.is_empty(),
1702 "grace exhausted: the dispatch is retired"
1703 );
1704 assert_eq!(
1705 breaker.consecutive_failures("x-grace-never-real"),
1706 1,
1707 "a still-ref-less DonePRGreen counts toward the streak"
1708 );
1709 }
1710
1711 #[test]
1712 fn resolved_target_parses_mission_target() {
1713 let t: ResolvedTarget = serde_json::from_str(
1716 r#"{"project":"fno","cwd":"/x","interval_seconds":60,"failure_limit":3,"mission":"x-epic"}"#,
1717 )
1718 .unwrap();
1719 assert_eq!(t.mission.as_deref(), Some("x-epic"));
1720 let no_mission: ResolvedTarget = serde_json::from_str(
1721 r#"{"project":"p","cwd":"/x","interval_seconds":60,"failure_limit":3}"#,
1722 )
1723 .unwrap();
1724 assert_eq!(no_mission.mission, None);
1725 }
1726
1727 #[test]
1728 fn breaker_trips_at_limit() {
1729 let mut b = CircuitBreaker::new(3);
1730 assert!(!b.record_failure("n1"));
1731 assert_eq!(b.consecutive_failures("n1"), 1);
1732 assert!(!b.record_failure("n1"));
1733 assert_eq!(b.consecutive_failures("n1"), 2);
1734 assert!(b.record_failure("n1"));
1736 assert_eq!(b.consecutive_failures("n1"), 3);
1737 }
1738
1739 #[test]
1740 fn breaker_success_resets_streak() {
1741 let mut b = CircuitBreaker::new(2);
1742 b.record_failure("n1");
1743 assert_eq!(b.consecutive_failures("n1"), 1);
1744 b.record_success("n1");
1745 assert_eq!(b.consecutive_failures("n1"), 0);
1746 assert!(!b.record_failure("n1"));
1748 assert!(b.record_failure("n1"));
1749 }
1750
1751 #[test]
1752 fn breaker_reset_gives_fresh_attempts() {
1753 let mut b = CircuitBreaker::new(2);
1756 assert!(!b.record_failure("n1"));
1757 assert!(b.record_failure("n1")); b.reset("n1"); assert_eq!(b.consecutive_failures("n1"), 0);
1760 assert!(!b.record_failure("n1")); assert!(b.record_failure("n1")); }
1763
1764 #[test]
1765 fn breaker_tracks_nodes_independently() {
1766 let mut b = CircuitBreaker::new(2);
1767 b.record_failure("a");
1768 b.record_failure("b");
1769 assert_eq!(b.consecutive_failures("a"), 1);
1770 assert_eq!(b.consecutive_failures("b"), 1);
1771 assert!(b.record_failure("a")); assert_eq!(b.consecutive_failures("b"), 1); }
1774
1775 #[test]
1776 fn zero_limit_is_clamped_to_one() {
1777 let mut b = CircuitBreaker::new(0);
1778 assert!(b.record_failure("n1"));
1780 }
1781
1782 fn stub_fno_advance(dir: &std::path::Path, receipt_json: &str) -> String {
1785 std::fs::create_dir_all(dir).unwrap();
1786 let p = dir.join("fno");
1787 std::fs::write(
1788 &p,
1789 format!(
1790 "#!/usr/bin/env bash\nif [[ \"$1\" == backlog && \"$2\" == advance ]]; then \
1791 cat <<'JSON'\n{receipt_json}\nJSON\nfi\nexit 0\n"
1792 ),
1793 )
1794 .unwrap();
1795 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1796 p.display().to_string()
1797 }
1798
1799 #[test]
1800 fn dispatch_mission_records_dispatched_and_continues() {
1801 let _env = env_guard();
1802 let tmp = tempfile::TempDir::new().unwrap();
1803 let fno = stub_fno_advance(
1804 &tmp.path().join("bin"),
1805 r#"{"epic_id":"x-epic","deactivated":false,"all_done":false,"dispatched":["x-a","x-b"]}"#,
1806 );
1807 let cfg = test_cfg(tmp.path(), fno, 3);
1808 let (journal, project_journal) = test_journal(tmp.path());
1809 let mut pending = Vec::new();
1810
1811 let outcome = dispatch_mission(&cfg, &mut pending, &journal);
1812 assert_eq!(outcome, MissionDispatch::Continue);
1813 assert_eq!(
1814 pending
1815 .iter()
1816 .map(|p| p.node_id.clone())
1817 .collect::<Vec<_>>(),
1818 vec!["x-a", "x-b"]
1819 );
1820 assert!(journal_lines(&project_journal)
1821 .iter()
1822 .any(|l| l.contains("active_backlog_dispatched") && l.contains("x-a")));
1823 }
1824
1825 #[test]
1826 fn dispatch_mission_retires_on_deactivated() {
1827 let _env = env_guard();
1828 let tmp = tempfile::TempDir::new().unwrap();
1829 let fno = stub_fno_advance(
1830 &tmp.path().join("bin"),
1831 r#"{"epic_id":"x-epic","deactivated":true,"all_done":false,"dispatched":[]}"#,
1832 );
1833 let cfg = test_cfg(tmp.path(), fno, 3);
1834 let (journal, _pj) = test_journal(tmp.path());
1835 let mut pending = Vec::new();
1836 assert_eq!(
1837 dispatch_mission(&cfg, &mut pending, &journal),
1838 MissionDispatch::Retire
1839 );
1840 }
1841
1842 #[test]
1843 fn dispatch_mission_retires_on_all_done() {
1844 let _env = env_guard();
1845 let tmp = tempfile::TempDir::new().unwrap();
1846 let fno = stub_fno_advance(
1847 &tmp.path().join("bin"),
1848 r#"{"epic_id":"x-epic","deactivated":false,"all_done":true,"dispatched":[]}"#,
1849 );
1850 let cfg = test_cfg(tmp.path(), fno, 3);
1851 let (journal, _pj) = test_journal(tmp.path());
1852 let mut pending = Vec::new();
1853 assert_eq!(
1854 dispatch_mission(&cfg, &mut pending, &journal),
1855 MissionDispatch::Retire
1856 );
1857 }
1858
1859 #[test]
1860 fn dispatch_mission_dedups_already_pending() {
1861 let _env = env_guard();
1862 let tmp = tempfile::TempDir::new().unwrap();
1864 let fno = stub_fno_advance(
1865 &tmp.path().join("bin"),
1866 r#"{"epic_id":"x-epic","dispatched":["x-a"]}"#,
1867 );
1868 let cfg = test_cfg(tmp.path(), fno, 3);
1869 let (journal, _pj) = test_journal(tmp.path());
1870 let mut pending = vec![PendingDispatch {
1871 node_id: "x-a".to_string(),
1872 session_id: None,
1873 ticks: 2,
1874 stamp_waits: 0,
1875 }];
1876 dispatch_mission(&cfg, &mut pending, &journal);
1877 assert_eq!(pending.len(), 1, "x-a already pending must not be re-added");
1878 }
1879
1880 #[test]
1881 fn dispatch_mission_unparseable_receipt_continues() {
1882 let _env = env_guard();
1883 let tmp = tempfile::TempDir::new().unwrap();
1886 let fno = stub_fno_advance(&tmp.path().join("bin"), "wedged python traceback");
1887 let cfg = test_cfg(tmp.path(), fno, 3);
1888 let (journal, project_journal) = test_journal(tmp.path());
1889 let mut pending = Vec::new();
1890 assert_eq!(
1891 dispatch_mission(&cfg, &mut pending, &journal),
1892 MissionDispatch::Continue
1893 );
1894 assert!(pending.is_empty());
1895 assert!(journal_lines(&project_journal)
1896 .iter()
1897 .any(|l| l.contains("advance-epic-unparseable")));
1898 }
1899}