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 mcp_config_dir: Option<&std::path::Path>,
254 infra: &car_multi::SharedInfra,
259 deadline: &Arc<SessionDeadline>,
264 workers: Option<&dyn car_multi::WorktreeAgent>,
274 baseline_captures: &BaselineCaptures,
277) -> Result<ForemanRun, ForemanFallback> {
278 let worktree = executor.worktree().to_path_buf();
279 let cancelled = || {
280 LoopOutcome::lost(
281 LoopFailure::Cancelled,
282 Some("cancelled".into()),
283 0,
284 Vec::new(),
285 )
286 };
287
288 if let Some(reason) = deadline.admit() {
292 sink.emit(CoderEventKind::BudgetExhausted {
293 reason: reason.clone(),
294 elapsed_secs: deadline.elapsed_secs(),
295 iterations: 0,
296 });
297 return Ok(ForemanRun::nothing_integrated(LoopOutcome::lost(
298 LoopFailure::BudgetExhausted,
299 Some(reason),
300 0,
301 Vec::new(),
302 )));
303 }
304
305 if cancel.load(Ordering::SeqCst) {
307 return Ok(ForemanRun::nothing_integrated(cancelled()));
308 }
309 sink.emit(CoderEventKind::ExternalEvent {
310 raw: json!({ "foreman": "planning", "adapter": adapter_id }),
311 });
312 let plan_generator = generator.clone();
313 let plan = car_multi::decompose(&worktree, intent, 3, move |prompt| {
314 let generator = plan_generator.clone();
315 async move {
316 generator
317 .generate(car_inference::GenerateRequest {
318 prompt,
319 intent: car_inference::IntentHint::high_stakes_if(true),
324 ..Default::default()
325 })
326 .await
327 .map(|r| r.text)
328 }
329 })
330 .await;
331
332 if !plan.is_valid() {
333 return Err(ForemanFallback::PlanInvalid(plan.issues.join("; ")));
334 }
335 sink.emit(CoderEventKind::ExternalEvent {
336 raw: json!({
337 "foreman": "planned",
338 "subtasks": plan.subtasks.len(),
339 "levels": plan.levels.len(),
340 "prefer_single_session": plan.prefer_single_session,
341 }),
342 });
343 if plan.prefer_single_session {
344 return Err(ForemanFallback::SingleSessionPreferred);
345 }
346
347 if cancel.load(Ordering::SeqCst) {
350 return Ok(ForemanRun::nothing_integrated(cancelled()));
351 }
352 let local = car_external_agents::ForemanExternalAgent::new(adapter_id.to_string());
353 let agent: &dyn car_multi::WorktreeAgent = workers.unwrap_or(&local);
354 let scoped_infra = infra.scoped_gate_audit(uuid::Uuid::new_v4().to_string());
358 let infra = &scoped_infra;
359 let gate_audit_from = infra.log.lock().await.events().len();
360 let config = car_multi::FarmOutConfig {
361 verify_command: regression_command(&worktree),
364 union_verify_command: union_goal_command(contract),
365 mcp_endpoint: mcp_endpoint.map(String::from),
370 mcp_config_dir: mcp_config_dir.map(std::path::Path::to_path_buf),
375 ..Default::default()
376 };
377 let progress_sink: car_multi::ForemanProgressSink = {
382 let sink = Arc::clone(sink);
383 Arc::new(move |ev: car_multi::ForemanProgress| {
384 let raw = match ev {
385 car_multi::ForemanProgress::SubtaskStarted {
386 subtask_id,
387 index,
388 level,
389 total,
390 } => json!({
391 "foreman": "subtask_started",
392 "subtask_id": subtask_id,
393 "index": index,
394 "level": level,
395 "total": total,
396 }),
397 car_multi::ForemanProgress::SubtaskVerifying { subtask_id } => json!({
398 "foreman": "subtask_verifying",
399 "subtask_id": subtask_id,
400 }),
401 car_multi::ForemanProgress::SubtaskGated {
402 subtask_id,
403 accepted,
404 status,
405 } => json!({
406 "foreman": "subtask_gated",
407 "subtask_id": subtask_id,
408 "accepted": accepted,
409 "status": status,
410 }),
411 };
412 sink.emit(CoderEventKind::ExternalEvent { raw });
413 })
414 };
415 let farmed = car_multi::run_farm_out_with_progress(
416 &worktree,
417 &plan.subtasks,
418 agent,
419 &config,
420 infra,
421 progress_sink,
422 )
423 .await;
424 let audited = drain_gate_audit(infra, sink, gate_audit_from).await;
425
426 let accepted: Vec<(String, String)> = farmed
427 .outcomes
428 .iter()
429 .filter(|o| o.is_accepted())
430 .filter_map(|o| o.patch.clone().map(|p| (o.subtask_id.clone(), p)))
431 .collect();
432 sink.emit(CoderEventKind::ExternalEvent {
433 raw: json!({
434 "foreman": "farmed",
435 "accepted": accepted.len(),
436 "total": farmed.outcomes.len(),
437 }),
438 });
439 if accepted.is_empty() {
440 let detail = farmed
441 .outcomes
442 .iter()
443 .filter_map(|o| o.error.as_deref())
444 .take(3)
445 .collect::<Vec<_>>()
446 .join("; ");
447 return Err(ForemanFallback::NothingAccepted(if detail.is_empty() {
448 format!(
449 "{} subtask(s) all rejected or inconclusive",
450 farmed.outcomes.len()
451 )
452 } else {
453 detail
454 }));
455 }
456
457 if cancel.load(Ordering::SeqCst) {
459 return Ok(ForemanRun::nothing_integrated(cancelled()));
460 }
461 let label = format!("coder-{}", sink_label(&worktree));
462 let integration =
463 car_multi::integrate_and_verify(&worktree, &label, &accepted, &config, infra).await;
464 let _ = drain_gate_audit(infra, sink, audited).await;
465 let integration =
466 integration.map_err(|e| ForemanFallback::IntegrationRejected(e.to_string()))?;
467 if !integration.integrated_cleanly() {
468 if let Some(blame) = &integration.blame {
472 let reason = if !blame.apply_conflicts.is_empty() {
473 "patch conflict"
474 } else if !blame.duplicate_conflicts.is_empty() {
475 "duplicate declaration"
476 } else if blame.build_test.is_some() {
477 "build/test failed"
478 } else {
479 "rejected"
480 };
481 let detail = blame
485 .apply_conflicts
486 .first()
487 .map(|c| format!("{} did not apply", c.subtask_id))
488 .or_else(|| {
489 blame
490 .duplicate_conflicts
491 .first()
492 .map(|d| format!("duplicate `{}` in {}", d.symbol, d.file))
493 })
494 .or_else(|| blame.build_test.as_ref().map(|b| tail(&b.output_tail, 200)));
495 let implicated: Vec<String> = blame.implicated_subtasks().into_iter().collect();
496 sink.emit(CoderEventKind::ExternalEvent {
497 raw: json!({
498 "foreman": "union_rejected",
499 "reason": reason,
500 "implicated": implicated,
501 "detail": detail,
502 }),
503 });
504 }
505 return Err(ForemanFallback::IntegrationRejected(format!(
506 "applied {}, conflicts: [{}], union verdict accepting: {}",
507 integration.applied,
508 integration.apply_conflicts.join(", "),
509 integration
510 .verdict
511 .as_ref()
512 .is_some_and(|v| v.is_accepted()),
513 )));
514 }
515 sink.emit(CoderEventKind::ExternalEvent {
516 raw: json!({ "foreman": "union_verified", "applied": integration.applied }),
517 });
518
519 let mut integrated = Vec::with_capacity(accepted.len());
532 for (subtask_id, patch) in &accepted {
533 apply_patch(&worktree, subtask_id, patch).map_err(ForemanFallback::IntegrationRejected)?;
534 integrated.push(IntegratedSubtask {
535 subtask_id: subtask_id.clone(),
536 files: car_multi::files_in_patch(patch),
540 });
541 sink.emit(CoderEventKind::ToolResult {
542 tool: "foreman.apply".into(),
543 ok: true,
544 preview: format!("applied {subtask_id}"),
545 });
546 }
547
548 let last_results =
550 evaluate_contract_with_baselines(contract, executor, sink, baseline_captures).await;
551 let passed = last_results.iter().all(|r| r.passed);
552 let outcome = if passed {
555 LoopOutcome::green(1, last_results)
556 } else {
557 LoopOutcome::lost(LoopFailure::Verification, None, 1, last_results)
560 };
561 Ok(ForemanRun {
562 outcome,
563 integrated,
564 })
565}
566
567fn sink_label(worktree: &Path) -> String {
570 worktree
571 .file_name()
572 .map(|n| n.to_string_lossy().into_owned())
573 .unwrap_or_else(|| "session".into())
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::coder::contract::ContractCheck;
580
581 #[tokio::test]
588 async fn gate_verdicts_reach_the_session_journal() {
589 use std::collections::HashMap;
590
591 let dir = tempfile::tempdir().unwrap();
592 let journal = dir.path().join("s1.events.jsonl");
593 let sink = Arc::new(EventSink::new("s1", None, Some(journal.clone())));
594
595 let infra = car_multi::SharedInfra::new().scoped_gate_audit("s1-run".into());
596 {
597 let mut log = infra.log.lock().await;
598 let mut accepted = HashMap::new();
599 accepted.insert("subtask".to_string(), json!("a"));
600 accepted.insert("gate_audit_scope".to_string(), json!("s1-run"));
601 accepted.insert("build_test".to_string(), json!("passed"));
602 log.append(car_eventlog::EventKind::GateAccepted, None, None, accepted);
603
604 let mut rejected = HashMap::new();
605 rejected.insert("subtask".to_string(), json!("b"));
606 rejected.insert("gate_audit_scope".to_string(), json!("s1-run"));
607 rejected.insert("reasons".to_string(), json!(["containment"]));
608 log.append(car_eventlog::EventKind::GateRejected, None, None, rejected);
609
610 log.append(
613 car_eventlog::EventKind::RunStarted,
614 None,
615 None,
616 HashMap::new(),
617 );
618 }
619
620 let cursor = drain_gate_audit(&infra, &sink, 0).await;
621 assert_eq!(
622 cursor, 3,
623 "the cursor counts the whole log, not the matches"
624 );
625 let cursor2 = drain_gate_audit(&infra, &sink, cursor).await;
627 assert_eq!(cursor2, cursor);
628
629 drop(sink);
634
635 let body = std::fs::read_to_string(&journal).expect("the session journal exists");
636 let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect();
637 assert_eq!(
638 lines.len(),
639 2,
640 "both verdicts, once each, and nothing else: {body}"
641 );
642 assert!(
643 body.contains("gate_accepted") || body.contains("GateAccepted"),
644 "{body}"
645 );
646 assert!(
647 body.contains("gate_rejected") || body.contains("GateRejected"),
648 "{body}"
649 );
650 assert!(body.contains("containment"), "{body}");
652 assert!(!body.to_lowercase().contains("run_started"), "{body}");
654 }
655
656 #[tokio::test]
657 async fn concurrent_gate_runs_project_only_their_own_verdicts() {
658 let dir = tempfile::tempdir().unwrap();
659 let shared = car_multi::SharedInfra::new();
660 let a = shared.scoped_gate_audit("run-a".into());
661 let b = shared.scoped_gate_audit("run-b".into());
662 assert!(Arc::ptr_eq(&a.log, &b.log));
663 assert!(Arc::ptr_eq(&a.state, &b.state));
664 assert!(Arc::ptr_eq(&a.policies, &b.policies));
665 assert!(Arc::ptr_eq(&a.budget, &b.budget));
666 let a_path = dir.path().join("a.events.jsonl");
667 let b_path = dir.path().join("b.events.jsonl");
668 let a_sink = Arc::new(EventSink::new("a", None, Some(a_path.clone())));
669 let b_sink = Arc::new(EventSink::new("b", None, Some(b_path.clone())));
670 let a_from = shared.log.lock().await.events().len();
673 let b_from = a_from;
674 let command = if cfg!(windows) {
675 vec!["cmd".into(), "/C".into(), "exit 0".into()]
676 } else {
677 vec!["sh".into(), "-c".into(), "exit 0".into()]
678 };
679 let accepted =
680 car_multi::GateConfig::new("same-subtask", dir.path()).with_verify_command(command);
681 let rejected = car_multi::GateConfig::new("same-subtask", dir.path());
682 let footprint = car_multi::DeclaredFootprint::unconstrained();
683 let (a_verdict, b_verdict) = tokio::join!(
684 car_multi::verify_changes(&accepted, &[], &footprint, &a),
685 car_multi::verify_changes(&rejected, &[], &footprint, &b),
686 );
687 assert!(a_verdict.is_accepted());
688 assert!(!b_verdict.is_accepted());
689 assert_eq!(shared.log.lock().await.events().len(), 2);
690 let a_cursor = drain_gate_audit(&a, &a_sink, a_from).await;
691 let b_cursor = drain_gate_audit(&b, &b_sink, b_from).await;
692 assert_eq!(a_cursor, 2);
693 assert_eq!(b_cursor, 2);
694 drain_gate_audit(&a, &a_sink, a_cursor).await;
695 drain_gate_audit(&b, &b_sink, b_cursor).await;
696 drop(a_sink);
697 drop(b_sink);
698 let a_body = std::fs::read_to_string(a_path).unwrap();
699 let b_body = std::fs::read_to_string(b_path).unwrap();
700 assert_eq!(a_body.lines().count(), 1, "{a_body}");
701 assert_eq!(b_body.lines().count(), 1, "{b_body}");
702 assert!(
703 a_body.contains("run-a") && !a_body.contains("run-b"),
704 "{a_body}"
705 );
706 assert!(
707 b_body.contains("run-b") && !b_body.contains("run-a"),
708 "{b_body}"
709 );
710 }
711
712 #[tokio::test]
739 async fn a_gate_tagged_stream_event_cannot_forge_a_verdict() {
740 let dir = tempfile::tempdir().unwrap();
741 let journal = dir.path().join("s2.events.jsonl");
742 let sink = Arc::new(EventSink::new("s2", None, Some(journal.clone())));
743
744 sink.emit(CoderEventKind::StateChanged {
747 from: "created".to_string(),
748 to: "running".to_string(),
749 });
750
751 sink.emit(CoderEventKind::ExternalEvent {
754 raw: json!({
755 "type": "system",
756 "subtype": "init",
757 "session_id": "s2",
758 "foreman": "gate",
759 "decision": "accepted",
760 "subtask": "peer-authored-patch",
761 "build_test": "passed",
762 }),
763 });
764 drop(sink);
765
766 let body = std::fs::read_to_string(&journal)
767 .expect("the positive control wrote a line, so the journal exists");
768 let lowered = body.to_lowercase();
769 assert!(
770 lowered.contains("state_changed"),
771 "the control did not journal, so this test proves nothing: {body}"
772 );
773 assert!(
774 !lowered.contains("gate_accepted") && !lowered.contains("gateaccepted"),
775 "a stream event forged a gate verdict into the audit record: {body}"
776 );
777 assert!(
778 !lowered.contains("gate_rejected") && !lowered.contains("gaterejected"),
779 "a stream event forged a gate verdict into the audit record: {body}"
780 );
781 }
782
783 #[derive(Default)]
790 struct RecordingAgent {
791 called: std::sync::atomic::AtomicUsize,
792 }
793
794 #[async_trait::async_trait]
795 impl car_multi::WorktreeAgent for RecordingAgent {
796 async fn run_in(
797 &self,
798 _req: &car_multi::WorktreeAgentRequest<'_>,
799 ) -> Result<car_multi::AgentRunSummary, car_multi::ForemanError> {
800 self.called
801 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
802 Ok(car_multi::AgentRunSummary {
803 answer: "recorded".into(),
804 })
805 }
806 }
807
808 fn git_repo() -> tempfile::TempDir {
809 let dir = tempfile::tempdir().unwrap();
810 for args in [
811 vec!["init", "-q", "-b", "main"],
812 vec!["config", "user.email", "t@t.t"],
813 vec!["config", "user.name", "t"],
814 ] {
815 let out = std::process::Command::new("git")
816 .args(&args)
817 .current_dir(dir.path())
818 .output()
819 .expect("git");
820 assert!(out.status.success(), "git {args:?}");
821 }
822 std::fs::write(dir.path().join("seed.txt"), "seed\n").unwrap();
823 for args in [vec!["add", "-A"], vec!["commit", "-qm", "seed"]] {
824 std::process::Command::new("git")
825 .args(&args)
826 .current_dir(dir.path())
827 .output()
828 .expect("git");
829 }
830 dir
831 }
832
833 #[tokio::test]
839 async fn session_deny_tool_blocks_delivery_and_journals_rejection() {
840 struct WriteDeclaredFile;
841 #[async_trait::async_trait]
842 impl car_multi::WorktreeAgent for WriteDeclaredFile {
843 async fn run_in(
844 &self,
845 req: &car_multi::WorktreeAgentRequest<'_>,
846 ) -> Result<car_multi::AgentRunSummary, car_multi::ForemanError> {
847 let path = req.cwd.join(format!("src/{}.rs", req.subtask.id));
848 std::fs::write(&path, format!("pub fn {}() {{}}\n", req.subtask.id))
849 .map_err(|e| car_multi::ForemanError::Agent(e.to_string()))?;
850 Ok(car_multi::AgentRunSummary::default())
851 }
852 }
853
854 struct TwoFilePlan;
855 #[async_trait::async_trait]
856 impl TurnGenerator for TwoFilePlan {
857 async fn generate(
858 &self,
859 _req: car_inference::GenerateRequest,
860 ) -> Result<car_inference::InferenceResult, String> {
861 Ok(serde_json::from_value(serde_json::json!({
862 "text": r#"{"subtasks":[
863 {"id":"x","prompt":"x","writes":[{"file":"src/x.rs","symbol":"x"}]},
864 {"id":"y","prompt":"y","writes":[{"file":"src/y.rs","symbol":"y"}]}
865 ]}"#,
866 "tool_calls": [],
867 "trace_id": "shared-infra-policy-test",
868 "model_used": "scripted",
869 "latency_ms": 0,
870 }))
871 .expect("scripted InferenceResult shape"))
872 }
873 }
874
875 let repo = git_repo();
876 std::fs::create_dir_all(repo.path().join("src")).unwrap();
877 std::fs::write(
878 repo.path().join("Cargo.toml"),
879 "[package]\nname = \"shared-infra-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
880 )
881 .unwrap();
882 std::fs::write(repo.path().join("src/lib.rs"), "pub fn seed() {}\n").unwrap();
883 for args in [vec!["add", "-A"], vec!["commit", "-qm", "cargo seed"]] {
884 let out = std::process::Command::new("git")
885 .args(&args)
886 .current_dir(repo.path())
887 .output()
888 .expect("git");
889 assert!(out.status.success(), "git {args:?}");
890 }
891
892 let audit_dir = tempfile::tempdir().unwrap();
893 let journal = audit_dir.path().join("client-session.jsonl");
894 let shared_state = Arc::new(car_state::StateStore::new());
895 let shared_log = Arc::new(tokio::sync::Mutex::new(
896 car_eventlog::EventLog::with_journal(journal.clone()),
897 ));
898 let shared_policies = Arc::new(tokio::sync::RwLock::new(car_policy::PolicyEngine::new()));
899 {
900 let mut policy_engine = shared_policies.write().await;
901 car_policy::PolicyRules::from_toml(r#"deny_tool = ["foreman.integrate"]"#)
902 .unwrap()
903 .apply(&mut policy_engine);
904 }
905 let infra = car_multi::SharedInfra::with_shared(
906 Arc::clone(&shared_state),
907 Arc::clone(&shared_log),
908 Arc::clone(&shared_policies),
909 );
910
911 let sink = Arc::new(EventSink::test_sink());
912 let result = run_foreman_loop(
913 "scripted",
914 "write x and y",
915 &OutcomeContract {
916 allow_credentials: false,
917 description: "both files exist".into(),
918 checks: vec![check("goal", "true", true, None)],
919 },
920 &WorktreeExecutor::new(repo.path().to_path_buf()),
921 &sink,
922 &CancelFlag::default(),
923 &(Arc::new(TwoFilePlan) as Arc<dyn TurnGenerator>),
924 None, None, &infra,
927 &Arc::new(SessionDeadline::new(Some(300))),
928 Some(&WriteDeclaredFile),
929 &Default::default(), )
931 .await;
932
933 assert!(
934 matches!(result, Err(ForemanFallback::NothingAccepted(_))),
935 "the session deny rule must reject every patch"
936 );
937 assert!(
938 !repo.path().join("src/x.rs").exists() && !repo.path().join("src/y.rs").exists(),
939 "a policy-rejected patch must not reach the delivery worktree"
940 );
941 {
942 let log = shared_log.lock().await;
943 assert_eq!(log.events().len(), 2, "one decision per patch");
944 assert!(
945 log.events()
946 .iter()
947 .all(|event| event.kind == car_eventlog::EventKind::GateRejected),
948 "every runtime audit decision must be a rejection"
949 );
950 assert!(
951 log.events()
952 .iter()
953 .all(
954 |event| event.data.get("reasons").is_some_and(|reasons| reasons
955 .to_string()
956 .contains("deny_tool:foreman.integrate"))
957 ),
958 "the journal must retain the loaded rule as the rejection reason"
959 );
960 }
961
962 drop(infra);
965 drop(shared_log);
966 let body = std::fs::read_to_string(&journal).expect("session audit journal exists");
967 let rows: Vec<serde_json::Value> = body
968 .lines()
969 .map(|line| serde_json::from_str(line).expect("valid journal JSONL"))
970 .collect();
971 assert_eq!(rows.len(), 2, "one durable decision per patch: {body}");
972 assert!(
973 rows.iter().all(|row| {
974 row.get("kind")
975 .is_some_and(|kind| kind.to_string().to_lowercase().contains("gate_rejected"))
976 && row.get("data").is_some_and(|data| {
977 data.to_string().contains("deny_tool:foreman.integrate")
978 })
979 }),
980 "both durable rows must be policy gate rejections: {body}"
981 );
982 }
983
984 #[tokio::test]
994 async fn the_supplied_worker_is_the_one_that_runs_the_subtasks() {
995 let repo = git_repo();
996 let recorder = RecordingAgent::default();
997
998 struct Plan;
1001 #[async_trait::async_trait]
1002 impl TurnGenerator for Plan {
1003 async fn generate(
1004 &self,
1005 _req: car_inference::GenerateRequest,
1006 ) -> Result<car_inference::InferenceResult, String> {
1007 Ok(serde_json::from_value(serde_json::json!({
1008 "text": r#"{"subtasks":[
1009 {"id":"x","prompt":"x","writes":[{"file":"x.rs","symbol":"x"}]},
1010 {"id":"y","prompt":"y","writes":[{"file":"y.rs","symbol":"y"}]}
1011 ]}"#,
1012 "tool_calls": [],
1013 "trace_id": "foreman-pool-test",
1014 "model_used": "scripted",
1015 "latency_ms": 0,
1016 }))
1017 .expect("scripted InferenceResult shape"))
1018 }
1019 }
1020
1021 let sink = Arc::new(EventSink::test_sink());
1022 let contract = OutcomeContract {
1023 allow_credentials: false,
1024 description: "two things".into(),
1025 checks: vec![check("c", "true", true, None)],
1026 };
1027 let executor = WorktreeExecutor::new(repo.path().to_path_buf());
1028 let _ = run_foreman_loop(
1029 "claude-code",
1030 "two things",
1031 &contract,
1032 &executor,
1033 &sink,
1034 &CancelFlag::default(),
1035 &(Arc::new(Plan) as Arc<dyn TurnGenerator>),
1036 None, None, &car_multi::SharedInfra::new(),
1039 &Arc::new(SessionDeadline::new(Some(300))),
1040 Some(&recorder),
1041 &BaselineCaptures::new(),
1042 )
1043 .await;
1044
1045 assert!(
1046 recorder.called.load(std::sync::atomic::Ordering::SeqCst) > 0,
1047 "the supplied worker must be the one that runs the subtasks"
1048 );
1049 }
1050
1051 fn check(name: &str, command: &str, exit_zero: bool, contains: Option<&str>) -> ContractCheck {
1052 ContractCheck {
1053 name: name.into(),
1054 command: command.into(),
1055 expect_exit_zero: exit_zero,
1056 output_contains: contains.map(String::from),
1057 timeout_secs: 60,
1058 baseline: false,
1059 differential: None,
1060 }
1061 }
1062
1063 #[test]
1064 fn union_goal_chains_plain_exit_zero_checks_only() {
1065 let contract = OutcomeContract {
1066 allow_credentials: false,
1067 description: "d".into(),
1068 checks: vec![
1069 check("build", "cargo build", true, None),
1070 check("tests", "cargo test", true, None),
1071 check("output", "cat x.txt", true, Some("needle")), check("inverted", "grep -q bad src/", false, Some("x")), ],
1074 };
1075 let cmd = union_goal_command(&contract).unwrap();
1076 assert_eq!(cmd[0], "sh");
1077 assert_eq!(cmd[2], "cargo build && cargo test");
1078 }
1079
1080 #[test]
1081 fn no_expressible_checks_means_no_union_goal_command() {
1082 let contract = OutcomeContract {
1083 allow_credentials: false,
1084 description: "d".into(),
1085 checks: vec![check("output", "cat x.txt", true, Some("needle"))],
1086 };
1087 assert!(union_goal_command(&contract).is_none());
1088 }
1089
1090 #[test]
1091 fn regression_command_maps_known_build_systems_only() {
1092 let dir = tempfile::tempdir().unwrap();
1093 assert!(
1094 regression_command(dir.path()).is_none(),
1095 "unknown repo → None (fail-closed)"
1096 );
1097 std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
1098 let cmd = regression_command(dir.path()).unwrap();
1099 assert_eq!(cmd[2], "cargo check");
1100 }
1101
1102 #[test]
1103 fn apply_patch_lands_changes_in_worktree() {
1104 let dir = tempfile::tempdir().unwrap();
1105 for args in [
1106 vec!["init", "-q", "-b", "main"],
1107 vec![
1108 "-c",
1109 "user.name=t",
1110 "-c",
1111 "user.email=t@t",
1112 "commit",
1113 "-q",
1114 "--allow-empty",
1115 "-m",
1116 "init",
1117 ],
1118 ] {
1119 assert!(std::process::Command::new("git")
1120 .arg("-C")
1121 .arg(dir.path())
1122 .args(&args)
1123 .output()
1124 .unwrap()
1125 .status
1126 .success());
1127 }
1128 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";
1129 apply_patch(dir.path(), "s1", patch).unwrap();
1130 assert_eq!(
1131 std::fs::read_to_string(dir.path().join("new.txt"))
1134 .unwrap()
1135 .replace("\r\n", "\n"),
1136 "from foreman\n"
1137 );
1138 }
1139
1140 #[test]
1141 fn apply_patch_conflict_is_reported_not_panicked() {
1142 let dir = tempfile::tempdir().unwrap();
1143 assert!(std::process::Command::new("git")
1144 .arg("-C")
1145 .arg(dir.path())
1146 .args(["init", "-q"])
1147 .output()
1148 .unwrap()
1149 .status
1150 .success());
1151 let err = apply_patch(dir.path(), "s1", "not a patch").unwrap_err();
1152 assert!(err.contains("git apply s1 failed"), "{err}");
1153 }
1154
1155 #[test]
1156 fn fallback_reasons_are_descriptive() {
1157 assert!(ForemanFallback::SingleSessionPreferred
1158 .reason()
1159 .contains("single session"));
1160 assert!(ForemanFallback::PlanInvalid("x".into())
1161 .reason()
1162 .contains("decomposition"));
1163 assert!(ForemanFallback::NothingAccepted("y".into())
1164 .reason()
1165 .contains("merge gate"));
1166 assert!(ForemanFallback::IntegrationRejected("z".into())
1167 .reason()
1168 .contains("integration"));
1169 }
1170}