1use std::path::Path;
34use std::sync::atomic::Ordering;
35use std::sync::Arc;
36
37use serde_json::json;
38
39use super::budget::SessionDeadline;
40use super::contract::{evaluate_contract_with_baselines, BaselineCaptures, OutcomeContract};
41use super::native_loop::{LoopFailure, LoopOutcome, TurnGenerator};
42use super::session::{CancelFlag, CoderEventKind, EventSink, IntegratedSubtask};
43use super::shell_tool::{tail, WorktreeExecutor};
44
45#[derive(Debug)]
48pub enum ForemanFallback {
49 SingleSessionPreferred,
51 PlanInvalid(String),
53 NothingAccepted(String),
55 IntegrationRejected(String),
58}
59
60impl ForemanFallback {
61 pub fn reason(&self) -> String {
62 match self {
63 Self::SingleSessionPreferred => {
64 "plan prefers a single session (no parallel speedup)".into()
65 }
66 Self::PlanInvalid(e) => format!("decomposition invalid: {e}"),
67 Self::NothingAccepted(e) => format!("no subtask passed the merge gate: {e}"),
68 Self::IntegrationRejected(e) => format!("union integration rejected: {e}"),
69 }
70 }
71}
72
73fn union_goal_command(contract: &OutcomeContract) -> Option<Vec<String>> {
82 let chain: Vec<&str> = contract
83 .checks
84 .iter()
85 .filter(|c| c.expect_exit_zero && c.output_contains.is_none())
86 .map(|c| c.command.as_str())
87 .collect();
88 if chain.is_empty() {
89 return None;
90 }
91 Some(vec!["sh".into(), "-lc".into(), chain.join(" && ")])
92}
93
94fn regression_command(worktree: &Path) -> Option<Vec<String>> {
101 let candidates: [(&str, &str); 3] = [
102 ("Cargo.toml", "cargo check"),
103 ("go.mod", "go build ./..."),
104 ("Package.swift", "swift build"),
105 ];
106 candidates
107 .iter()
108 .find(|(marker, _)| worktree.join(marker).exists())
109 .map(|(_, cmd)| vec!["sh".into(), "-lc".into(), (*cmd).into()])
110}
111
112pub struct ForemanRun {
119 pub outcome: LoopOutcome,
120 pub integrated: Vec<IntegratedSubtask>,
124}
125
126impl ForemanRun {
127 fn nothing_integrated(outcome: LoopOutcome) -> Self {
129 Self {
130 outcome,
131 integrated: Vec::new(),
132 }
133 }
134}
135
136fn apply_patch(worktree: &Path, subtask_id: &str, patch: &str) -> Result<(), String> {
138 use std::io::Write;
139 let mut file = tempfile::NamedTempFile::new()
140 .map_err(|e| format!("temp patch file for {subtask_id}: {e}"))?;
141 file.write_all(patch.as_bytes())
142 .map_err(|e| format!("write patch {subtask_id}: {e}"))?;
143 let out = std::process::Command::new("git")
144 .arg("-C")
145 .arg(worktree)
146 .args(["apply", "--whitespace=nowarn"])
147 .arg(file.path())
148 .output()
149 .map_err(|e| format!("git apply {subtask_id}: {e}"))?;
150 if out.status.success() {
151 Ok(())
152 } else {
153 Err(format!(
154 "git apply {subtask_id} failed: {}",
155 String::from_utf8_lossy(&out.stderr).trim()
156 ))
157 }
158}
159
160async fn drain_gate_audit(
191 infra: &car_multi::SharedInfra,
192 sink: &Arc<EventSink>,
193 from: usize,
194) -> usize {
195 let log = infra.log.lock().await;
196 let events = log.events();
197 for event in events.iter().skip(from) {
198 if infra.gate_audit_scope.as_deref().is_none()
199 || event
200 .data
201 .get("gate_audit_scope")
202 .and_then(serde_json::Value::as_str)
203 != infra.gate_audit_scope.as_deref()
204 {
205 continue;
206 }
207 let decision = match event.kind {
208 car_eventlog::EventKind::GateAccepted => "accepted",
209 car_eventlog::EventKind::GateRejected => "rejected",
210 _ => continue,
213 };
214 sink.record_gate_verdict(event.kind.clone(), event.data.clone());
219
220 let mut raw = serde_json::Map::new();
221 raw.insert("foreman".to_string(), json!("gate"));
222 raw.insert("decision".to_string(), json!(decision));
223 for (k, v) in &event.data {
224 raw.insert(k.clone(), v.clone());
225 }
226 sink.emit(CoderEventKind::ExternalEvent {
227 raw: serde_json::Value::Object(raw),
228 });
229 }
230 events.len()
231}
232
233pub async fn run_foreman_loop(
236 adapter_id: &str,
237 intent: &str,
238 contract: &OutcomeContract,
239 executor: &WorktreeExecutor,
240 sink: &Arc<EventSink>,
241 cancel: &CancelFlag,
242 generator: &Arc<dyn TurnGenerator>,
243 mcp_endpoint: Option<&str>,
247 infra: &car_multi::SharedInfra,
252 deadline: &Arc<SessionDeadline>,
257 workers: Option<&dyn car_multi::WorktreeAgent>,
267 baseline_captures: &BaselineCaptures,
270) -> Result<ForemanRun, ForemanFallback> {
271 let worktree = executor.worktree().to_path_buf();
272 let cancelled = || {
273 LoopOutcome::lost(
274 LoopFailure::Cancelled,
275 Some("cancelled".into()),
276 0,
277 Vec::new(),
278 )
279 };
280
281 if let Some(reason) = deadline.admit() {
285 sink.emit(CoderEventKind::BudgetExhausted {
286 reason: reason.clone(),
287 elapsed_secs: deadline.elapsed_secs(),
288 iterations: 0,
289 });
290 return Ok(ForemanRun::nothing_integrated(LoopOutcome::lost(
291 LoopFailure::BudgetExhausted,
292 Some(reason),
293 0,
294 Vec::new(),
295 )));
296 }
297
298 if cancel.load(Ordering::SeqCst) {
300 return Ok(ForemanRun::nothing_integrated(cancelled()));
301 }
302 sink.emit(CoderEventKind::ExternalEvent {
303 raw: json!({ "foreman": "planning", "adapter": adapter_id }),
304 });
305 let plan_generator = generator.clone();
306 let plan = car_multi::decompose(&worktree, intent, 3, move |prompt| {
307 let generator = plan_generator.clone();
308 async move {
309 generator
310 .generate(car_inference::GenerateRequest {
311 prompt,
312 intent: car_inference::IntentHint::high_stakes_if(true),
317 ..Default::default()
318 })
319 .await
320 .map(|r| r.text)
321 }
322 })
323 .await;
324
325 if !plan.is_valid() {
326 return Err(ForemanFallback::PlanInvalid(plan.issues.join("; ")));
327 }
328 sink.emit(CoderEventKind::ExternalEvent {
329 raw: json!({
330 "foreman": "planned",
331 "subtasks": plan.subtasks.len(),
332 "levels": plan.levels.len(),
333 "prefer_single_session": plan.prefer_single_session,
334 }),
335 });
336 if plan.prefer_single_session {
337 return Err(ForemanFallback::SingleSessionPreferred);
338 }
339
340 if cancel.load(Ordering::SeqCst) {
343 return Ok(ForemanRun::nothing_integrated(cancelled()));
344 }
345 let local = car_external_agents::ForemanExternalAgent::new(adapter_id.to_string());
346 let agent: &dyn car_multi::WorktreeAgent = workers.unwrap_or(&local);
347 let scoped_infra = infra.scoped_gate_audit(uuid::Uuid::new_v4().to_string());
351 let infra = &scoped_infra;
352 let gate_audit_from = infra.log.lock().await.events().len();
353 let config = car_multi::FarmOutConfig {
354 verify_command: regression_command(&worktree),
357 union_verify_command: union_goal_command(contract),
358 mcp_endpoint: mcp_endpoint.map(String::from),
363 ..Default::default()
364 };
365 let progress_sink: car_multi::ForemanProgressSink = {
370 let sink = Arc::clone(sink);
371 Arc::new(move |ev: car_multi::ForemanProgress| {
372 let raw = match ev {
373 car_multi::ForemanProgress::SubtaskStarted {
374 subtask_id,
375 index,
376 level,
377 total,
378 } => json!({
379 "foreman": "subtask_started",
380 "subtask_id": subtask_id,
381 "index": index,
382 "level": level,
383 "total": total,
384 }),
385 car_multi::ForemanProgress::SubtaskVerifying { subtask_id } => json!({
386 "foreman": "subtask_verifying",
387 "subtask_id": subtask_id,
388 }),
389 car_multi::ForemanProgress::SubtaskGated {
390 subtask_id,
391 accepted,
392 status,
393 } => json!({
394 "foreman": "subtask_gated",
395 "subtask_id": subtask_id,
396 "accepted": accepted,
397 "status": status,
398 }),
399 };
400 sink.emit(CoderEventKind::ExternalEvent { raw });
401 })
402 };
403 let farmed = car_multi::run_farm_out_with_progress(
404 &worktree,
405 &plan.subtasks,
406 agent,
407 &config,
408 infra,
409 progress_sink,
410 )
411 .await;
412 let audited = drain_gate_audit(infra, sink, gate_audit_from).await;
413
414 let accepted: Vec<(String, String)> = farmed
415 .outcomes
416 .iter()
417 .filter(|o| o.is_accepted())
418 .filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
419 .collect();
420 sink.emit(CoderEventKind::ExternalEvent {
421 raw: json!({
422 "foreman": "farmed",
423 "accepted": accepted.len(),
424 "total": farmed.outcomes.len(),
425 }),
426 });
427 if accepted.is_empty() {
428 let detail = farmed
429 .outcomes
430 .iter()
431 .filter_map(|o| o.error.as_deref())
432 .take(3)
433 .collect::<Vec<_>>()
434 .join("; ");
435 return Err(ForemanFallback::NothingAccepted(if detail.is_empty() {
436 format!(
437 "{} subtask(s) all rejected or inconclusive",
438 farmed.outcomes.len()
439 )
440 } else {
441 detail
442 }));
443 }
444
445 if cancel.load(Ordering::SeqCst) {
447 return Ok(ForemanRun::nothing_integrated(cancelled()));
448 }
449 let label = format!("coder-{}", sink_label(&worktree));
450 let integration =
451 car_multi::integrate_and_verify(&worktree, &label, &accepted, &config, infra).await;
452 let _ = drain_gate_audit(infra, sink, audited).await;
453 let integration =
454 integration.map_err(|e| ForemanFallback::IntegrationRejected(e.to_string()))?;
455 if !integration.integrated_cleanly() {
456 if let Some(blame) = &integration.blame {
460 let reason = if !blame.apply_conflicts.is_empty() {
461 "patch conflict"
462 } else if !blame.duplicate_conflicts.is_empty() {
463 "duplicate declaration"
464 } else if blame.build_test.is_some() {
465 "build/test failed"
466 } else {
467 "rejected"
468 };
469 let detail = blame
473 .apply_conflicts
474 .first()
475 .map(|c| format!("{} did not apply", c.subtask_id))
476 .or_else(|| {
477 blame
478 .duplicate_conflicts
479 .first()
480 .map(|d| format!("duplicate `{}` in {}", d.symbol, d.file))
481 })
482 .or_else(|| blame.build_test.as_ref().map(|b| tail(&b.output_tail, 200)));
483 let implicated: Vec<String> = blame.implicated_subtasks().into_iter().collect();
484 sink.emit(CoderEventKind::ExternalEvent {
485 raw: json!({
486 "foreman": "union_rejected",
487 "reason": reason,
488 "implicated": implicated,
489 "detail": detail,
490 }),
491 });
492 }
493 return Err(ForemanFallback::IntegrationRejected(format!(
494 "applied {}, conflicts: [{}], union verdict accepting: {}",
495 integration.applied,
496 integration.apply_conflicts.join(", "),
497 integration
498 .verdict
499 .as_ref()
500 .is_some_and(|v| v.is_accepted()),
501 )));
502 }
503 sink.emit(CoderEventKind::ExternalEvent {
504 raw: json!({ "foreman": "union_verified", "applied": integration.applied }),
505 });
506
507 let mut integrated = Vec::with_capacity(accepted.len());
520 for (subtask_id, patch) in &accepted {
521 apply_patch(&worktree, subtask_id, patch).map_err(ForemanFallback::IntegrationRejected)?;
522 integrated.push(IntegratedSubtask {
523 subtask_id: subtask_id.clone(),
524 files: car_multi::files_in_patch(patch),
528 });
529 sink.emit(CoderEventKind::ToolResult {
530 tool: "foreman.apply".into(),
531 ok: true,
532 preview: format!("applied {subtask_id}"),
533 });
534 }
535
536 let last_results =
538 evaluate_contract_with_baselines(contract, executor, sink, baseline_captures).await;
539 let passed = last_results.iter().all(|r| r.passed);
540 let outcome = if passed {
543 LoopOutcome::green(1, last_results)
544 } else {
545 LoopOutcome::lost(LoopFailure::Verification, None, 1, last_results)
548 };
549 Ok(ForemanRun {
550 outcome,
551 integrated,
552 })
553}
554
555fn sink_label(worktree: &Path) -> String {
558 worktree
559 .file_name()
560 .map(|n| n.to_string_lossy().into_owned())
561 .unwrap_or_else(|| "session".into())
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::coder::contract::ContractCheck;
568
569 #[tokio::test]
576 async fn gate_verdicts_reach_the_session_journal() {
577 use std::collections::HashMap;
578
579 let dir = tempfile::tempdir().unwrap();
580 let journal = dir.path().join("s1.events.jsonl");
581 let sink = Arc::new(EventSink::new("s1", None, Some(journal.clone())));
582
583 let infra = car_multi::SharedInfra::new().scoped_gate_audit("s1-run".into());
584 {
585 let mut log = infra.log.lock().await;
586 let mut accepted = HashMap::new();
587 accepted.insert("subtask".to_string(), json!("a"));
588 accepted.insert("gate_audit_scope".to_string(), json!("s1-run"));
589 accepted.insert("build_test".to_string(), json!("passed"));
590 log.append(car_eventlog::EventKind::GateAccepted, None, None, accepted);
591
592 let mut rejected = HashMap::new();
593 rejected.insert("subtask".to_string(), json!("b"));
594 rejected.insert("gate_audit_scope".to_string(), json!("s1-run"));
595 rejected.insert("reasons".to_string(), json!(["containment"]));
596 log.append(car_eventlog::EventKind::GateRejected, None, None, rejected);
597
598 log.append(
601 car_eventlog::EventKind::RunStarted,
602 None,
603 None,
604 HashMap::new(),
605 );
606 }
607
608 let cursor = drain_gate_audit(&infra, &sink, 0).await;
609 assert_eq!(
610 cursor, 3,
611 "the cursor counts the whole log, not the matches"
612 );
613 let cursor2 = drain_gate_audit(&infra, &sink, cursor).await;
615 assert_eq!(cursor2, cursor);
616
617 drop(sink);
622
623 let body = std::fs::read_to_string(&journal).expect("the session journal exists");
624 let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect();
625 assert_eq!(
626 lines.len(),
627 2,
628 "both verdicts, once each, and nothing else: {body}"
629 );
630 assert!(
631 body.contains("gate_accepted") || body.contains("GateAccepted"),
632 "{body}"
633 );
634 assert!(
635 body.contains("gate_rejected") || body.contains("GateRejected"),
636 "{body}"
637 );
638 assert!(body.contains("containment"), "{body}");
640 assert!(!body.to_lowercase().contains("run_started"), "{body}");
642 }
643
644 #[tokio::test]
645 async fn concurrent_gate_runs_project_only_their_own_verdicts() {
646 let dir = tempfile::tempdir().unwrap();
647 let shared = car_multi::SharedInfra::new();
648 let a = shared.scoped_gate_audit("run-a".into());
649 let b = shared.scoped_gate_audit("run-b".into());
650 assert!(Arc::ptr_eq(&a.log, &b.log));
651 assert!(Arc::ptr_eq(&a.state, &b.state));
652 assert!(Arc::ptr_eq(&a.policies, &b.policies));
653 assert!(Arc::ptr_eq(&a.budget, &b.budget));
654 let a_path = dir.path().join("a.events.jsonl");
655 let b_path = dir.path().join("b.events.jsonl");
656 let a_sink = Arc::new(EventSink::new("a", None, Some(a_path.clone())));
657 let b_sink = Arc::new(EventSink::new("b", None, Some(b_path.clone())));
658 let a_from = shared.log.lock().await.events().len();
661 let b_from = a_from;
662 let command = if cfg!(windows) {
663 vec!["cmd".into(), "/C".into(), "exit 0".into()]
664 } else {
665 vec!["sh".into(), "-c".into(), "exit 0".into()]
666 };
667 let accepted =
668 car_multi::GateConfig::new("same-subtask", dir.path()).with_verify_command(command);
669 let rejected = car_multi::GateConfig::new("same-subtask", dir.path());
670 let footprint = car_multi::DeclaredFootprint::unconstrained();
671 let (a_verdict, b_verdict) = tokio::join!(
672 car_multi::verify_changes(&accepted, &[], &footprint, &a),
673 car_multi::verify_changes(&rejected, &[], &footprint, &b),
674 );
675 assert!(a_verdict.is_accepted());
676 assert!(!b_verdict.is_accepted());
677 assert_eq!(shared.log.lock().await.events().len(), 2);
678 let a_cursor = drain_gate_audit(&a, &a_sink, a_from).await;
679 let b_cursor = drain_gate_audit(&b, &b_sink, b_from).await;
680 assert_eq!(a_cursor, 2);
681 assert_eq!(b_cursor, 2);
682 drain_gate_audit(&a, &a_sink, a_cursor).await;
683 drain_gate_audit(&b, &b_sink, b_cursor).await;
684 drop(a_sink);
685 drop(b_sink);
686 let a_body = std::fs::read_to_string(a_path).unwrap();
687 let b_body = std::fs::read_to_string(b_path).unwrap();
688 assert_eq!(a_body.lines().count(), 1, "{a_body}");
689 assert_eq!(b_body.lines().count(), 1, "{b_body}");
690 assert!(
691 a_body.contains("run-a") && !a_body.contains("run-b"),
692 "{a_body}"
693 );
694 assert!(
695 b_body.contains("run-b") && !b_body.contains("run-a"),
696 "{b_body}"
697 );
698 }
699
700 #[tokio::test]
727 async fn a_gate_tagged_stream_event_cannot_forge_a_verdict() {
728 let dir = tempfile::tempdir().unwrap();
729 let journal = dir.path().join("s2.events.jsonl");
730 let sink = Arc::new(EventSink::new("s2", None, Some(journal.clone())));
731
732 sink.emit(CoderEventKind::StateChanged {
735 from: "created".to_string(),
736 to: "running".to_string(),
737 });
738
739 sink.emit(CoderEventKind::ExternalEvent {
742 raw: json!({
743 "type": "system",
744 "subtype": "init",
745 "session_id": "s2",
746 "foreman": "gate",
747 "decision": "accepted",
748 "subtask": "peer-authored-patch",
749 "build_test": "passed",
750 }),
751 });
752 drop(sink);
753
754 let body = std::fs::read_to_string(&journal)
755 .expect("the positive control wrote a line, so the journal exists");
756 let lowered = body.to_lowercase();
757 assert!(
758 lowered.contains("state_changed"),
759 "the control did not journal, so this test proves nothing: {body}"
760 );
761 assert!(
762 !lowered.contains("gate_accepted") && !lowered.contains("gateaccepted"),
763 "a stream event forged a gate verdict into the audit record: {body}"
764 );
765 assert!(
766 !lowered.contains("gate_rejected") && !lowered.contains("gaterejected"),
767 "a stream event forged a gate verdict into the audit record: {body}"
768 );
769 }
770
771 #[derive(Default)]
778 struct RecordingAgent {
779 called: std::sync::atomic::AtomicUsize,
780 }
781
782 #[async_trait::async_trait]
783 impl car_multi::WorktreeAgent for RecordingAgent {
784 async fn run_in(
785 &self,
786 _req: &car_multi::WorktreeAgentRequest<'_>,
787 ) -> Result<car_multi::AgentRunSummary, car_multi::ForemanError> {
788 self.called
789 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
790 Ok(car_multi::AgentRunSummary {
791 answer: "recorded".into(),
792 })
793 }
794 }
795
796 fn git_repo() -> tempfile::TempDir {
797 let dir = tempfile::tempdir().unwrap();
798 for args in [
799 vec!["init", "-q", "-b", "main"],
800 vec!["config", "user.email", "t@t.t"],
801 vec!["config", "user.name", "t"],
802 ] {
803 let out = std::process::Command::new("git")
804 .args(&args)
805 .current_dir(dir.path())
806 .output()
807 .expect("git");
808 assert!(out.status.success(), "git {args:?}");
809 }
810 std::fs::write(dir.path().join("seed.txt"), "seed\n").unwrap();
811 for args in [vec!["add", "-A"], vec!["commit", "-qm", "seed"]] {
812 std::process::Command::new("git")
813 .args(&args)
814 .current_dir(dir.path())
815 .output()
816 .expect("git");
817 }
818 dir
819 }
820
821 #[tokio::test]
827 async fn session_deny_tool_blocks_delivery_and_journals_rejection() {
828 struct WriteDeclaredFile;
829 #[async_trait::async_trait]
830 impl car_multi::WorktreeAgent for WriteDeclaredFile {
831 async fn run_in(
832 &self,
833 req: &car_multi::WorktreeAgentRequest<'_>,
834 ) -> Result<car_multi::AgentRunSummary, car_multi::ForemanError> {
835 let path = req.cwd.join(format!("src/{}.rs", req.subtask.id));
836 std::fs::write(&path, format!("pub fn {}() {{}}\n", req.subtask.id))
837 .map_err(|e| car_multi::ForemanError::Agent(e.to_string()))?;
838 Ok(car_multi::AgentRunSummary::default())
839 }
840 }
841
842 struct TwoFilePlan;
843 #[async_trait::async_trait]
844 impl TurnGenerator for TwoFilePlan {
845 async fn generate(
846 &self,
847 _req: car_inference::GenerateRequest,
848 ) -> Result<car_inference::InferenceResult, String> {
849 Ok(serde_json::from_value(serde_json::json!({
850 "text": r#"{"subtasks":[
851 {"id":"x","prompt":"x","writes":[{"file":"src/x.rs","symbol":"x"}]},
852 {"id":"y","prompt":"y","writes":[{"file":"src/y.rs","symbol":"y"}]}
853 ]}"#,
854 "tool_calls": [],
855 "trace_id": "shared-infra-policy-test",
856 "model_used": "scripted",
857 "latency_ms": 0,
858 }))
859 .expect("scripted InferenceResult shape"))
860 }
861 }
862
863 let repo = git_repo();
864 std::fs::create_dir_all(repo.path().join("src")).unwrap();
865 std::fs::write(
866 repo.path().join("Cargo.toml"),
867 "[package]\nname = \"shared-infra-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
868 )
869 .unwrap();
870 std::fs::write(repo.path().join("src/lib.rs"), "pub fn seed() {}\n").unwrap();
871 for args in [vec!["add", "-A"], vec!["commit", "-qm", "cargo seed"]] {
872 let out = std::process::Command::new("git")
873 .args(&args)
874 .current_dir(repo.path())
875 .output()
876 .expect("git");
877 assert!(out.status.success(), "git {args:?}");
878 }
879
880 let audit_dir = tempfile::tempdir().unwrap();
881 let journal = audit_dir.path().join("client-session.jsonl");
882 let shared_state = Arc::new(car_state::StateStore::new());
883 let shared_log = Arc::new(tokio::sync::Mutex::new(
884 car_eventlog::EventLog::with_journal(journal.clone()),
885 ));
886 let shared_policies = Arc::new(tokio::sync::RwLock::new(car_policy::PolicyEngine::new()));
887 {
888 let mut policy_engine = shared_policies.write().await;
889 car_policy::PolicyRules::from_toml(r#"deny_tool = ["foreman.integrate"]"#)
890 .unwrap()
891 .apply(&mut policy_engine);
892 }
893 let infra = car_multi::SharedInfra::with_shared(
894 Arc::clone(&shared_state),
895 Arc::clone(&shared_log),
896 Arc::clone(&shared_policies),
897 );
898
899 let sink = Arc::new(EventSink::test_sink());
900 let result = run_foreman_loop(
901 "scripted",
902 "write x and y",
903 &OutcomeContract {
904 description: "both files exist".into(),
905 checks: vec![check("goal", "true", true, None)],
906 },
907 &WorktreeExecutor::new(repo.path().to_path_buf()),
908 &sink,
909 &CancelFlag::default(),
910 &(Arc::new(TwoFilePlan) as Arc<dyn TurnGenerator>),
911 None,
912 &infra,
913 &Arc::new(SessionDeadline::new(Some(300))),
914 Some(&WriteDeclaredFile),
915 &Default::default(), )
917 .await;
918
919 assert!(
920 matches!(result, Err(ForemanFallback::NothingAccepted(_))),
921 "the session deny rule must reject every patch"
922 );
923 assert!(
924 !repo.path().join("src/x.rs").exists() && !repo.path().join("src/y.rs").exists(),
925 "a policy-rejected patch must not reach the delivery worktree"
926 );
927 {
928 let log = shared_log.lock().await;
929 assert_eq!(log.events().len(), 2, "one decision per patch");
930 assert!(
931 log.events()
932 .iter()
933 .all(|event| event.kind == car_eventlog::EventKind::GateRejected),
934 "every runtime audit decision must be a rejection"
935 );
936 assert!(
937 log.events()
938 .iter()
939 .all(
940 |event| event.data.get("reasons").is_some_and(|reasons| reasons
941 .to_string()
942 .contains("deny_tool:foreman.integrate"))
943 ),
944 "the journal must retain the loaded rule as the rejection reason"
945 );
946 }
947
948 drop(infra);
951 drop(shared_log);
952 let body = std::fs::read_to_string(&journal).expect("session audit journal exists");
953 let rows: Vec<serde_json::Value> = body
954 .lines()
955 .map(|line| serde_json::from_str(line).expect("valid journal JSONL"))
956 .collect();
957 assert_eq!(rows.len(), 2, "one durable decision per patch: {body}");
958 assert!(
959 rows.iter().all(|row| {
960 row.get("kind")
961 .is_some_and(|kind| kind.to_string().to_lowercase().contains("gate_rejected"))
962 && row.get("data").is_some_and(|data| {
963 data.to_string().contains("deny_tool:foreman.integrate")
964 })
965 }),
966 "both durable rows must be policy gate rejections: {body}"
967 );
968 }
969
970 #[tokio::test]
980 async fn the_supplied_worker_is_the_one_that_runs_the_subtasks() {
981 let repo = git_repo();
982 let recorder = RecordingAgent::default();
983
984 struct Plan;
987 #[async_trait::async_trait]
988 impl TurnGenerator for Plan {
989 async fn generate(
990 &self,
991 _req: car_inference::GenerateRequest,
992 ) -> Result<car_inference::InferenceResult, String> {
993 Ok(serde_json::from_value(serde_json::json!({
994 "text": r#"{"subtasks":[
995 {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
996 {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
997 ]}"#,
998 "tool_calls": [],
999 "trace_id": "foreman-pool-test",
1000 "model_used": "scripted",
1001 "latency_ms": 0,
1002 }))
1003 .expect("scripted InferenceResult shape"))
1004 }
1005 }
1006
1007 let sink = Arc::new(EventSink::test_sink());
1008 let contract = OutcomeContract {
1009 description: "two things".into(),
1010 checks: vec![check("c", "true", true, None)],
1011 };
1012 let executor = WorktreeExecutor::new(repo.path().to_path_buf());
1013 let _ = run_foreman_loop(
1014 "claude-code",
1015 "two things",
1016 &contract,
1017 &executor,
1018 &sink,
1019 &CancelFlag::default(),
1020 &(Arc::new(Plan) as Arc<dyn TurnGenerator>),
1021 None,
1022 &car_multi::SharedInfra::new(),
1023 &Arc::new(SessionDeadline::new(Some(300))),
1024 Some(&recorder),
1025 &BaselineCaptures::new(),
1026 )
1027 .await;
1028
1029 assert!(
1030 recorder.called.load(std::sync::atomic::Ordering::SeqCst) > 0,
1031 "the supplied worker must be the one that runs the subtasks"
1032 );
1033 }
1034
1035 fn check(name: &str, command: &str, exit_zero: bool, contains: Option<&str>) -> ContractCheck {
1036 ContractCheck {
1037 name: name.into(),
1038 command: command.into(),
1039 expect_exit_zero: exit_zero,
1040 output_contains: contains.map(String::from),
1041 timeout_secs: 60,
1042 baseline: false,
1043 differential: None,
1044 }
1045 }
1046
1047 #[test]
1048 fn union_goal_chains_plain_exit_zero_checks_only() {
1049 let contract = OutcomeContract {
1050 description: "d".into(),
1051 checks: vec![
1052 check("build", "cargo build", true, None),
1053 check("tests", "cargo test", true, None),
1054 check("output", "cat x.txt", true, Some("needle")), check("inverted", "grep -q bad src/", false, Some("x")), ],
1057 };
1058 let cmd = union_goal_command(&contract).unwrap();
1059 assert_eq!(cmd[0], "sh");
1060 assert_eq!(cmd[2], "cargo build && cargo test");
1061 }
1062
1063 #[test]
1064 fn no_expressible_checks_means_no_union_goal_command() {
1065 let contract = OutcomeContract {
1066 description: "d".into(),
1067 checks: vec![check("output", "cat x.txt", true, Some("needle"))],
1068 };
1069 assert!(union_goal_command(&contract).is_none());
1070 }
1071
1072 #[test]
1073 fn regression_command_maps_known_build_systems_only() {
1074 let dir = tempfile::tempdir().unwrap();
1075 assert!(
1076 regression_command(dir.path()).is_none(),
1077 "unknown repo → None (fail-closed)"
1078 );
1079 std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
1080 let cmd = regression_command(dir.path()).unwrap();
1081 assert_eq!(cmd[2], "cargo check");
1082 }
1083
1084 #[test]
1085 fn apply_patch_lands_changes_in_worktree() {
1086 let dir = tempfile::tempdir().unwrap();
1087 for args in [
1088 vec!["init", "-q", "-b", "main"],
1089 vec![
1090 "-c",
1091 "user.name=t",
1092 "-c",
1093 "user.email=t@t",
1094 "commit",
1095 "-q",
1096 "--allow-empty",
1097 "-m",
1098 "init",
1099 ],
1100 ] {
1101 assert!(std::process::Command::new("git")
1102 .arg("-C")
1103 .arg(dir.path())
1104 .args(&args)
1105 .output()
1106 .unwrap()
1107 .status
1108 .success());
1109 }
1110 let patch = "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+from foreman\n";
1111 apply_patch(dir.path(), "s1", patch).unwrap();
1112 assert_eq!(
1113 std::fs::read_to_string(dir.path().join("new.txt"))
1116 .unwrap()
1117 .replace("\r\n", "\n"),
1118 "from foreman\n"
1119 );
1120 }
1121
1122 #[test]
1123 fn apply_patch_conflict_is_reported_not_panicked() {
1124 let dir = tempfile::tempdir().unwrap();
1125 assert!(std::process::Command::new("git")
1126 .arg("-C")
1127 .arg(dir.path())
1128 .args(["init", "-q"])
1129 .output()
1130 .unwrap()
1131 .status
1132 .success());
1133 let err = apply_patch(dir.path(), "s1", "not a patch").unwrap_err();
1134 assert!(err.contains("git apply s1 failed"), "{err}");
1135 }
1136
1137 #[test]
1138 fn fallback_reasons_are_descriptive() {
1139 assert!(ForemanFallback::SingleSessionPreferred
1140 .reason()
1141 .contains("single session"));
1142 assert!(ForemanFallback::PlanInvalid("x".into())
1143 .reason()
1144 .contains("decomposition"));
1145 assert!(ForemanFallback::NothingAccepted("y".into())
1146 .reason()
1147 .contains("merge gate"));
1148 assert!(ForemanFallback::IntegrationRejected("z".into())
1149 .reason()
1150 .contains("integration"));
1151 }
1152}