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    // Where the farmed-out CLI workers write their MCP config file, when the
248    // adapter writes one. The session's own state directory, created and
249    // hardened at `coder.start` — so the file does not depend on the `$TMPDIR`
250    // the daemon inherited and never checked (car#1494 / car#1534). `None` is a
251    // caller with no state directory of its own, which keeps the adapter's
252    // original `$TMPDIR` behaviour.
253    mcp_config_dir: Option<&std::path::Path>,
254    // The session runtime's state, audit log, and policy engine. The delivery
255    // gate must use these same handles rather than an isolated replacement:
256    // runtime policy.register rules are gating inputs and gate verdicts belong
257    // in the session audit journal, exactly as they do for foreman.run.
258    infra: &car_multi::SharedInfra,
259    // The session's shared deadline. Foreman previously had NO wall bound at
260    // all, which made it the one rung uncovered — and the rung most able to
261    // burn clock, since it farms out N parallel CLI workers plus an integration
262    // gate plus a contract evaluation.
263    deadline: &Arc<SessionDeadline>,
264    // Where the subtasks run. `None` = this machine only, which is what every
265    // caller did before the fleet was reachable from a coder session.
266    //
267    // A `FleetPool` IS a `WorktreeAgent` (`car_multi::patterns::foreman::pool`),
268    // so distribution enters here as a substitution at a boundary that already
269    // existed on both sides — `foreman.run` picks between exactly these two
270    // already. Nothing downstream changes: a peer edits its own worktree and
271    // returns a patch, and this host still applies it, runs the union gate, and
272    // decides. The merge-verify gate does not move (car#1117).
273    workers: Option<&dyn car_multi::WorktreeAgent>,
274    // The session-start baseline captures differential checks compare against
275    // (car#1067); empty when the contract declares none.
276    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    // Admission before any work. Foreman has no iteration to sit between, so
289    // the gate goes at its stage boundaries instead — this one and the
290    // integration checkpoint below.
291    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    // 1. Plan — decompose the intent against the session worktree.
306    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                    // Stakes-aware routing: this decomposes the coder task into
320                    // the plan that drives real worktree edits — high-stakes by
321                    // nature, so plan it quality-first. Model is unpinned here,
322                    // so the intent actually steers the adaptive router.
323                    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    // 2. Farm out — per-subtask worktrees + per-patch gate, against the
348    //    contract-derived build/test leg.
349    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    // A shared log can contain concurrent gate decisions with the same
355    // subtask labels. Correlate this invocation at the gate's primary append,
356    // while retaining the exact session state/policy/log/budget handles.
357    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        // Regression vs goal split (#275): per-worktree gets the build-system
362        // check; the integrated union gets the contract.
363        verify_command: regression_command(&worktree),
364        union_verify_command: union_goal_command(contract),
365        // Gate + audit the farmed-out workers' CAR-namespace tool calls
366        // through the daemon when its MCP listener is bound; None degrades
367        // cleanly (the workers' own built-in tools stay ungoverned — the
368        // residual upstream stage-4b limitation).
369        mcp_endpoint: mcp_endpoint.map(String::from),
370        // Same reason as the endpoint above, and the same directory the
371        // session's single-session external loop already uses: the coder
372        // state dir the daemon created private at `coder.start`, so no
373        // second hardening step is needed here.
374        mcp_config_dir: mcp_config_dir.map(std::path::Path::to_path_buf),
375        ..Default::default()
376    };
377    // Stream each subtask's worktree lifecycle (started / gated) so a live UI
378    // can show the parallel run advancing instead of only the run-level
379    // milestones. Bridges `ForemanProgress` → the coder's `external_event`
380    // channel, tagged `foreman` like the run-level stages.
381    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    // 3. Gate the integrated union in foreman's staging tree.
458    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        // Surface WHY the union failed (structured blame) so a UI can show which
469        // subtasks are implicated — the subtasks all gated green individually, so
470        // without this the board would read as success while the run failed.
471        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            // Same precedence as `reason` above (apply → duplicate → build_test)
482            // so when more than one cause is ever populated, the banner's reason
483            // and detail describe the SAME cause rather than two different ones.
484            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    // 4. Land the verified union in the session worktree (clean at HEAD, the
520    //    same base the staging tree gated, so application is deterministic).
521    // Built HERE, from the patches that actually reach the session worktree —
522    // not read off the pool afterwards. A `Placement` is recorded when a worker
523    // RETURNS, which is before the per-patch gate rules on what it produced, so
524    // the pool's ledger answers "which machine ran this?" and the delivered
525    // commit needs "which machine wrote what is in it". The two diverge on every
526    // path that matters: a subtask whose patch the gate rejected still has a
527    // placement, and `NothingAccepted`/`IntegrationRejected` fall all the way
528    // back to a locally-authored diff with the ledger fully populated. Crediting
529    // a peer there is a false attribution, which is worse than the missing one
530    // this set out to fix (car#1322).
531    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            // The gate's own parser, so its view of a patch and the delivered
537            // provenance cannot disagree. A subtask id is opaque model output;
538            // the files are what makes the row reviewable.
539            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    // 5. The outer boundary: the coder's own contract evaluation.
549    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    // Branching rather than a conditional `failure` field: passed-with-a-failure
553    // is the state the constructors exist to make unrepresentable.
554    let outcome = if passed {
555        LoopOutcome::green(1, last_results)
556    } else {
557        // Foreman's gate accepted a union and the coder's own contract then
558        // ruled on it: a red verdict here is about the work, not the machinery.
559        LoopOutcome::lost(LoopFailure::Verification, None, 1, last_results)
560    };
561    Ok(ForemanRun {
562        outcome,
563        integrated,
564    })
565}
566
567/// Stable per-session label fragment for foreman's staging worktree, derived
568/// from the session worktree's directory name (which embeds the session id).
569fn 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    /// The merge gate's verdicts must land in the SESSION's journal, not in a
582    /// run-local `EventLog` that is dropped when the run ends (car#1321).
583    ///
584    /// Asserted end-to-end through the journal file rather than by inspecting
585    /// the sink, because the durable record is the thing the issue is about: a
586    /// verdict a reader can find after the run is over.
587    #[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            // Something else in the same log, which must NOT be bridged: this
611            // is a merge-decision audit, not a copy of the run.
612            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        // A second drain from the returned cursor must emit nothing new.
626        let cursor2 = drain_gate_audit(&infra, &sink, cursor).await;
627        assert_eq!(cursor2, cursor);
628
629        // The journal writer is asynchronous ("no file I/O here" — it hands the
630        // line to a background writer). `JournalWriter`'s `Drop` closes the
631        // channel, drains the backlog and joins, so releasing the sink is what
632        // makes the record durable — and durability is the whole claim here.
633        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        // The gate's own evidence rides along, verbatim.
651        assert!(body.contains("containment"), "{body}");
652        // And the run's other events are NOT copied into the audit.
653        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        // Both cursors precede both decisions; subtask labels intentionally
671        // collide. Only the invocation scope can attribute these correctly.
672        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    /// A `foreman: "gate"` event arriving on the EVENT STREAM must not produce
713    /// a gate verdict in the journal.
714    ///
715    /// This is the hole the first version of this change had. Journaling the
716    /// verdict by recognizing the bridged event inside `EventSink::audit` reads
717    /// as one tidy path — but `process_stream` fires the emitter on every line
718    /// the supervised CLI prints, and `StreamEvent`'s `#[serde(flatten)] extra`
719    /// carries arbitrary top-level keys straight through
720    /// `CoderEventKind::ExternalEvent`. So one line of stdout from the model
721    /// being supervised satisfied the predicate and wrote "the gate accepted
722    /// this patch" into the audit record, in every coder session with a
723    /// journal, foreman or not. car#1243 is why that is fatal rather than
724    /// untidy: the patches this gate rules on are authored on machines this
725    /// host does not control, so the record has to be one the audited party
726    /// cannot write.
727    ///
728    /// It passes trivially now that `audit` has no such arm, which is the
729    /// point — it fails the moment someone adds one back.
730    ///
731    /// **With a positive control, because the naive version is vacuous.**
732    /// `JournalWriter` creates the file lazily on the first line it writes, so
733    /// a journal that received nothing has no file at all — and asserting
734    /// "no `Gate*` in the body" against a `read_to_string(...).unwrap_or_default()`
735    /// passes on the empty string whether the guard held or the sink was never
736    /// wired to a journal in the first place. Emitting an event `audit` DOES
737    /// journal first turns silence into signal-present-forgery-absent.
738    #[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        // The positive control: a kind `audit` demonstrably journals, so the
745        // file below exists for a reason unrelated to the forgery attempt.
746        sink.emit(CoderEventKind::StateChanged {
747            from: "created".to_string(),
748            to: "running".to_string(),
749        });
750
751        // Shaped exactly like what `external_loop` emits for a CLI stdout line
752        // whose flattened `extra` carries these keys.
753        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    /// A worker that records that it was asked, and returns nothing.
784    ///
785    /// "Returns nothing" is enough: this test is about WHICH worker the loop
786    /// dispatches to, and a subtask that produces no patch still proves the
787    /// call reached here. Producing real patches would be testing
788    /// `run_farm_out`, which car-multi already covers.
789    #[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    /// The delivery path must gate against the runtime policy engine inherited
834    /// from the client session and write its verdict to that session's audit
835    /// journal (car#1321). Before that infra was threaded in, this exact
836    /// `deny_tool` rule was absent from the fresh gate engine: both patches
837    /// passed and the runtime journal received no gate decision.
838    #[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, // no MCP listener
925            None, // and so no MCP config directory
926            &infra,
927            &Arc::new(SessionDeadline::new(Some(300))),
928            Some(&WriteDeclaredFile),
929            &Default::default(), // This policy fixture declares no differential checks.
930        )
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        // EventLog flushes its background journal writer on drop. Release every
963        // log owner before reading the durable record rather than racing it.
964        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    /// car#1243. The whole change is that a coder session can farm its subtasks
985    /// somewhere other than this machine, and that "somewhere" arrives as a
986    /// `WorktreeAgent` — a `FleetPool` IS one. This asserts the substitution
987    /// actually happens: given a worker, the loop must use it and NOT the local
988    /// `ForemanExternalAgent` it would otherwise construct.
989    ///
990    /// Without it the parameter could be accepted and silently ignored, which
991    /// is exactly the failure that would make a "distributed" run identical to
992    /// a local one.
993    #[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        // A generator that answers the decomposition prompt with a valid,
999        // disjoint two-subtask plan — the shape `car_multi::decompose` accepts.
1000        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, // no MCP listener
1037            None, // and so no MCP config directory
1038            &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")), // not expressible
1072                check("inverted", "grep -q bad src/", false, Some("x")), // not expressible
1073            ],
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            // Normalize CRLF: git on Windows may check the applied file out with
1132            // `\r\n` line endings depending on core.autocrlf.
1133            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}