Skip to main content

car_server_core/coder/
foreman_loop.rs

1//! Foreman delegation: verified parallel coding inside a coder session.
2//!
3//! Composition of the two systems, each keeping its own boundary:
4//!
5//! - **Foreman** (car-multi `patterns/foreman`, #274) decomposes the intent,
6//!   farms subtasks to an external CLI in per-subtask worktrees, and gates
7//!   each patch plus the integrated union (AST containment + build/test +
8//!   policy). Its repo root is the **coder session's worktree** — clean at
9//!   HEAD when delegation starts — so subtask worktrees and the staging tree
10//!   never see the user's checkout.
11//! - **The coder** applies the gate-accepted union into its session worktree
12//!   and then evaluates the **outcome contract** itself. Foreman's gate is an
13//!   inner filter; the contract stays the outer trust boundary, exactly as
14//!   with the single-session external engine.
15//!
16//! Fallback ladder (driven by [`super::rpc`]): foreman declining
17//! (`prefer_single_session`, invalid plan, nothing accepted, integration
18//! rejected) → single-session external CLI → native loop. A red contract
19//! *after* foreman applied work falls to the native loop too — which then
20//! repairs **on top of** foreman's changes rather than starting over.
21//!
22//! When the daemon's MCP listener is bound, its URL is threaded into
23//! [`car_multi::FarmOutConfig::mcp_endpoint`] so the farmed-out CLI workers'
24//! CAR-namespace tool calls (`memory_*`, `verify`, `skill_*`) route back
25//! through car-server's policy + memgine — gated and audited. The workers' own
26//! built-in tools (Edit, Bash) stay ungoverned (the residual upstream stage-4b
27//! limitation), contained by the per-worktree gate and the outer contract.
28//!
29//! Cancellation is honored between stages (plan / farm / integrate); an
30//! in-flight farm-out cannot be killed mid-stage yet (same limitation as the
31//! single-session external engine).
32
33use 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/// Why foreman declined, so the caller can fall down the ladder. Not an
46/// error: every variant has a working next step.
47#[derive(Debug)]
48pub enum ForemanFallback {
49    /// The plan says farming out buys nothing (≤1 subtask / no parallelism).
50    SingleSessionPreferred,
51    /// The planner could not produce a valid decomposition.
52    PlanInvalid(String),
53    /// No subtask survived the per-worktree gate.
54    NothingAccepted(String),
55    /// The accepted union failed the integration gate or did not apply to
56    /// the session worktree.
57    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
73/// The union gate's **goal** leg (#275 `union_verify_command`), derived from
74/// the contract: every plain exit-zero check chained with `&&`. The contract
75/// is a goal check by construction — it must only ever gate the integrated
76/// union, never a single subtask (a subtask legitimately implements part of
77/// the goal). Checks that assert on output substrings (or invert the exit
78/// code) can't be expressed as an argv exit status — they are *omitted here*
79/// and still enforced by the coder's own contract evaluation afterwards,
80/// which is the outer boundary anyway.
81fn 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
94/// The per-worktree **regression** leg (#275 `verify_command`): "does this
95/// one subtask's change still build?" — derived from the repo's detected
96/// build system, NOT from the contract. Conservative: only build systems
97/// with an unambiguous cheap check are mapped; `None` otherwise, in which
98/// case the per-worktree gate is `Inconclusive` (fail-closed, no waiver) and
99/// the session falls down the ladder to single-session delegation.
100fn 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
112/// A foreman run, and what of it actually reached the session worktree.
113///
114/// The second half is the point. `LoopOutcome` alone says whether the contract
115/// passed; it does not say which subtasks contributed to the tree being judged,
116/// and a caller making a provenance claim about the delivered commit needs
117/// exactly that (car#1322).
118pub struct ForemanRun {
119    pub outcome: LoopOutcome,
120    /// Subtasks whose patches were applied into the session worktree, with the
121    /// files each contributed. Empty on every path that did not integrate —
122    /// which is every `Err` fallback, and the budget/cancel early returns.
123    pub integrated: Vec<IntegratedSubtask>,
124}
125
126impl ForemanRun {
127    /// An outcome reached before anything could be integrated.
128    fn nothing_integrated(outcome: LoopOutcome) -> Self {
129        Self {
130            outcome,
131            integrated: Vec::new(),
132        }
133    }
134}
135
136/// Apply a gate-accepted patch into the session worktree.
137fn 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
160/// Copy the merge gate's verdicts from the session runtime log into the coder
161/// run's own journal and live event stream.
162///
163/// The gate itself writes `GateAccepted`/`GateRejected` directly into the
164/// caller-supplied session `infra.log`. This bridge preserves the coder-specific
165/// `<id>.events.jsonl` record and live narration as a second projection; it is
166/// not the gate's primary audit sink (car#1321).
167///
168/// That audit boundary matters more since a coder session can farm subtasks to
169/// peers (car#1243): the patches this gate rules on can be authored on machines
170/// this host does not control, so its decisions need a record the worker cannot
171/// produce.
172///
173/// The coder sink is an additional destination: `<id>.events.jsonl` is this
174/// coder run's durable record, `coder.subscribe` is its live stream, and both
175/// remain useful after the client session's runtime log is gone.
176///
177/// **The three surfaces are deliberately not equivalent.** The shared runtime
178/// log is the gate's primary audit. [`EventSink::record_gate_verdict`] writes a
179/// direct copy to the coder journal. The live `foreman: "gate"` event is only
180/// narration, tagged like every other bridged foreman event, and the supervised
181/// CLI can produce one (`process_stream` emits every stdout line, and
182/// `StreamEvent`'s flattened `extra` carries arbitrary keys through). An audit
183/// record the audited party can write is not an audit record — see
184/// `record_gate_verdict`'s own docs.
185///
186/// Only events stamped with this invocation's scope are projected; a cursor
187/// alone cannot exclude other concurrent runs in the shared log.
188/// `from` is a cursor into the log so a second call does not re-emit what the
189/// first already did; returns the new cursor. The log only grows.
190async 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            // The gate is not the only writer; everything else in this log
211            // belongs to the run, not to a merge decision.
212            _ => continue,
213        };
214        // The evidence the gate recorded — subtask, containment violations,
215        // build/test status, and the reasons behind a rejection — copied
216        // verbatim rather than reformatted, so the journal and the gate cannot
217        // describe the same verdict differently.
218        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
233/// Run foreman delegation to a contract-evaluated outcome, or decline with a
234/// fallback the caller can act on.
235pub 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    // Daemon MCP URL, when bound. Routes the farmed-out CLI workers'
244    // CAR-namespace tool calls through the daemon's policy + memgine.
245    // `None` degrades cleanly.
246    mcp_endpoint: Option<&str>,
247    // The session runtime's state, audit log, and policy engine. The delivery
248    // gate must use these same handles rather than an isolated replacement:
249    // runtime policy.register rules are gating inputs and gate verdicts belong
250    // in the session audit journal, exactly as they do for foreman.run.
251    infra: &car_multi::SharedInfra,
252    // The session's shared deadline. Foreman previously had NO wall bound at
253    // all, which made it the one rung uncovered — and the rung most able to
254    // burn clock, since it farms out N parallel CLI workers plus an integration
255    // gate plus a contract evaluation.
256    deadline: &Arc<SessionDeadline>,
257    // Where the subtasks run. `None` = this machine only, which is what every
258    // caller did before the fleet was reachable from a coder session.
259    //
260    // A `FleetPool` IS a `WorktreeAgent` (`car_multi::patterns::foreman::pool`),
261    // so distribution enters here as a substitution at a boundary that already
262    // existed on both sides — `foreman.run` picks between exactly these two
263    // already. Nothing downstream changes: a peer edits its own worktree and
264    // returns a patch, and this host still applies it, runs the union gate, and
265    // decides. The merge-verify gate does not move (car#1117).
266    workers: Option<&dyn car_multi::WorktreeAgent>,
267    // The session-start baseline captures differential checks compare against
268    // (car#1067); empty when the contract declares none.
269    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    // Admission before any work. Foreman has no iteration to sit between, so
282    // the gate goes at its stage boundaries instead — this one and the
283    // integration checkpoint below.
284    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    // 1. Plan — decompose the intent against the session worktree.
299    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                    // Stakes-aware routing: this decomposes the coder task into
313                    // the plan that drives real worktree edits — high-stakes by
314                    // nature, so plan it quality-first. Model is unpinned here,
315                    // so the intent actually steers the adaptive router.
316                    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    // 2. Farm out — per-subtask worktrees + per-patch gate, against the
341    //    contract-derived build/test leg.
342    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    // A shared log can contain concurrent gate decisions with the same
348    // subtask labels. Correlate this invocation at the gate's primary append,
349    // while retaining the exact session state/policy/log/budget handles.
350    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        // Regression vs goal split (#275): per-worktree gets the build-system
355        // check; the integrated union gets the contract.
356        verify_command: regression_command(&worktree),
357        union_verify_command: union_goal_command(contract),
358        // Gate + audit the farmed-out workers' CAR-namespace tool calls
359        // through the daemon when its MCP listener is bound; None degrades
360        // cleanly (the workers' own built-in tools stay ungoverned — the
361        // residual upstream stage-4b limitation).
362        mcp_endpoint: mcp_endpoint.map(String::from),
363        ..Default::default()
364    };
365    // Stream each subtask's worktree lifecycle (started / gated) so a live UI
366    // can show the parallel run advancing instead of only the run-level
367    // milestones. Bridges `ForemanProgress` → the coder's `external_event`
368    // channel, tagged `foreman` like the run-level stages.
369    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    // 3. Gate the integrated union in foreman's staging tree.
446    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        // Surface WHY the union failed (structured blame) so a UI can show which
457        // subtasks are implicated — the subtasks all gated green individually, so
458        // without this the board would read as success while the run failed.
459        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            // Same precedence as `reason` above (apply → duplicate → build_test)
470            // so when more than one cause is ever populated, the banner's reason
471            // and detail describe the SAME cause rather than two different ones.
472            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    // 4. Land the verified union in the session worktree (clean at HEAD, the
508    //    same base the staging tree gated, so application is deterministic).
509    // Built HERE, from the patches that actually reach the session worktree —
510    // not read off the pool afterwards. A `Placement` is recorded when a worker
511    // RETURNS, which is before the per-patch gate rules on what it produced, so
512    // the pool's ledger answers "which machine ran this?" and the delivered
513    // commit needs "which machine wrote what is in it". The two diverge on every
514    // path that matters: a subtask whose patch the gate rejected still has a
515    // placement, and `NothingAccepted`/`IntegrationRejected` fall all the way
516    // back to a locally-authored diff with the ledger fully populated. Crediting
517    // a peer there is a false attribution, which is worse than the missing one
518    // this set out to fix (car#1322).
519    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            // The gate's own parser, so its view of a patch and the delivered
525            // provenance cannot disagree. A subtask id is opaque model output;
526            // the files are what makes the row reviewable.
527            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    // 5. The outer boundary: the coder's own contract evaluation.
537    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    // Branching rather than a conditional `failure` field: passed-with-a-failure
541    // is the state the constructors exist to make unrepresentable.
542    let outcome = if passed {
543        LoopOutcome::green(1, last_results)
544    } else {
545        // Foreman's gate accepted a union and the coder's own contract then
546        // ruled on it: a red verdict here is about the work, not the machinery.
547        LoopOutcome::lost(LoopFailure::Verification, None, 1, last_results)
548    };
549    Ok(ForemanRun {
550        outcome,
551        integrated,
552    })
553}
554
555/// Stable per-session label fragment for foreman's staging worktree, derived
556/// from the session worktree's directory name (which embeds the session id).
557fn 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    /// The merge gate's verdicts must land in the SESSION's journal, not in a
570    /// run-local `EventLog` that is dropped when the run ends (car#1321).
571    ///
572    /// Asserted end-to-end through the journal file rather than by inspecting
573    /// the sink, because the durable record is the thing the issue is about: a
574    /// verdict a reader can find after the run is over.
575    #[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            // Something else in the same log, which must NOT be bridged: this
599            // is a merge-decision audit, not a copy of the run.
600            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        // A second drain from the returned cursor must emit nothing new.
614        let cursor2 = drain_gate_audit(&infra, &sink, cursor).await;
615        assert_eq!(cursor2, cursor);
616
617        // The journal writer is asynchronous ("no file I/O here" — it hands the
618        // line to a background writer). `JournalWriter`'s `Drop` closes the
619        // channel, drains the backlog and joins, so releasing the sink is what
620        // makes the record durable — and durability is the whole claim here.
621        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        // The gate's own evidence rides along, verbatim.
639        assert!(body.contains("containment"), "{body}");
640        // And the run's other events are NOT copied into the audit.
641        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        // Both cursors precede both decisions; subtask labels intentionally
659        // collide. Only the invocation scope can attribute these correctly.
660        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    /// A `foreman: "gate"` event arriving on the EVENT STREAM must not produce
701    /// a gate verdict in the journal.
702    ///
703    /// This is the hole the first version of this change had. Journaling the
704    /// verdict by recognizing the bridged event inside `EventSink::audit` reads
705    /// as one tidy path — but `process_stream` fires the emitter on every line
706    /// the supervised CLI prints, and `StreamEvent`'s `#[serde(flatten)] extra`
707    /// carries arbitrary top-level keys straight through
708    /// `CoderEventKind::ExternalEvent`. So one line of stdout from the model
709    /// being supervised satisfied the predicate and wrote "the gate accepted
710    /// this patch" into the audit record, in every coder session with a
711    /// journal, foreman or not. car#1243 is why that is fatal rather than
712    /// untidy: the patches this gate rules on are authored on machines this
713    /// host does not control, so the record has to be one the audited party
714    /// cannot write.
715    ///
716    /// It passes trivially now that `audit` has no such arm, which is the
717    /// point — it fails the moment someone adds one back.
718    ///
719    /// **With a positive control, because the naive version is vacuous.**
720    /// `JournalWriter` creates the file lazily on the first line it writes, so
721    /// a journal that received nothing has no file at all — and asserting
722    /// "no `Gate*` in the body" against a `read_to_string(...).unwrap_or_default()`
723    /// passes on the empty string whether the guard held or the sink was never
724    /// wired to a journal in the first place. Emitting an event `audit` DOES
725    /// journal first turns silence into signal-present-forgery-absent.
726    #[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        // The positive control: a kind `audit` demonstrably journals, so the
733        // file below exists for a reason unrelated to the forgery attempt.
734        sink.emit(CoderEventKind::StateChanged {
735            from: "created".to_string(),
736            to: "running".to_string(),
737        });
738
739        // Shaped exactly like what `external_loop` emits for a CLI stdout line
740        // whose flattened `extra` carries these keys.
741        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    /// A worker that records that it was asked, and returns nothing.
772    ///
773    /// "Returns nothing" is enough: this test is about WHICH worker the loop
774    /// dispatches to, and a subtask that produces no patch still proves the
775    /// call reached here. Producing real patches would be testing
776    /// `run_farm_out`, which car-multi already covers.
777    #[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    /// The delivery path must gate against the runtime policy engine inherited
822    /// from the client session and write its verdict to that session's audit
823    /// journal (car#1321). Before that infra was threaded in, this exact
824    /// `deny_tool` rule was absent from the fresh gate engine: both patches
825    /// passed and the runtime journal received no gate decision.
826    #[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(), // This policy fixture declares no differential checks.
916        )
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        // EventLog flushes its background journal writer on drop. Release every
949        // log owner before reading the durable record rather than racing it.
950        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    /// car#1243. The whole change is that a coder session can farm its subtasks
971    /// somewhere other than this machine, and that "somewhere" arrives as a
972    /// `WorktreeAgent` — a `FleetPool` IS one. This asserts the substitution
973    /// actually happens: given a worker, the loop must use it and NOT the local
974    /// `ForemanExternalAgent` it would otherwise construct.
975    ///
976    /// Without it the parameter could be accepted and silently ignored, which
977    /// is exactly the failure that would make a "distributed" run identical to
978    /// a local one.
979    #[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        // A generator that answers the decomposition prompt with a valid,
985        // disjoint two-subtask plan — the shape `car_multi::decompose` accepts.
986        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")), // not expressible
1055                check("inverted", "grep -q bad src/", false, Some("x")), // not expressible
1056            ],
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            // Normalize CRLF: git on Windows may check the applied file out with
1114            // `\r\n` line endings depending on core.autocrlf.
1115            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}