Skip to main content

car_server_core/coder/
session.rs

1//! Coder session state machine, event stream, and persistence.
2//!
3//! A session moves `Created → ContractProposed → ContractConfirmed → Running →
4//! NeedsApproval → Merged`, with `Failed`/`Abandoned` as the other terminal
5//! states. Every transition is validated, emitted as a [`CoderEvent`], audited
6//! to the event log, and snapshotted as JSON under the state dir so a daemon
7//! restart can at least report orphaned sessions (full resume is out of scope).
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15
16use car_eventlog::{EventKind, EventLog};
17use car_multi::{AgentWorkspace, WorkspaceConfig};
18
19use super::contract::{CheckResult, OutcomeContract};
20use super::router::EngineChoice;
21
22/// Cooperative cancellation flag, checked between turns and checks.
23pub type CancelFlag = Arc<AtomicBool>;
24
25/// Callback receiving every [`CoderEvent`] (WS fanout, CLI rendering, tests).
26pub type EventEmitter = Arc<dyn Fn(CoderEvent) + Send + Sync>;
27
28/// Mid-session user-input rendezvous.
29///
30/// When a loop wants to ask the user a question, it parks a oneshot sender here
31/// and awaits the receiver; `coder.respond` takes the sender and fulfills it.
32/// At most one request is pending at a time — a loop runs single-threaded, so
33/// it cannot have two questions in flight, and `coder.respond` errors cleanly
34/// when nothing is parked. The sender is dropped (which surfaces as a closed
35/// channel to the waiter) if the session is cancelled or torn down before the
36/// user answers.
37#[derive(Default)]
38pub struct UserInputGate {
39    pub steering: Arc<super::steering::SteeringInbox>,
40    pending: Mutex<Option<tokio::sync::oneshot::Sender<String>>>,
41    /// The prompt of the currently-parked question, so a board can render
42    /// *what* is being asked from a session summary without replaying the
43    /// event stream. Cleared whenever the gate is.
44    prompt: Mutex<Option<String>>,
45}
46
47impl UserInputGate {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Park a fresh oneshot for a new question, returning the receiver the
53    /// caller awaits. Any previously-parked (unanswered) sender is dropped,
54    /// which closes its receiver — the prior waiter, if somehow still alive,
55    /// then unblocks with an error rather than hanging forever.
56    pub fn park(&self, prompt: &str) -> tokio::sync::oneshot::Receiver<String> {
57        let (tx, rx) = tokio::sync::oneshot::channel();
58        *self.pending.lock().expect("user-input gate poisoned") = Some(tx);
59        *self.prompt.lock().expect("user-input gate poisoned") = Some(prompt.to_string());
60        rx
61    }
62
63    /// The prompt of the currently-parked question, if any.
64    pub fn pending_prompt(&self) -> Option<String> {
65        self.prompt
66            .lock()
67            .expect("user-input gate poisoned")
68            .clone()
69    }
70
71    /// Fulfill the parked request with `answer`. Returns `Err` when nothing is
72    /// pending (so `coder.respond` can report "no pending request") or when the
73    /// waiter has already gone away (cancelled/timed-out).
74    pub fn fulfill(&self, answer: String) -> Result<(), String> {
75        let tx = self
76            .pending
77            .lock()
78            .expect("user-input gate poisoned")
79            .take()
80            .ok_or("no pending user-input request for this session")?;
81        *self.prompt.lock().expect("user-input gate poisoned") = None;
82        tx.send(answer)
83            .map_err(|_| "the session is no longer waiting for input".to_string())
84    }
85
86    /// Drop any parked sender (cancellation/teardown): unblocks a waiter with a
87    /// closed channel.
88    pub fn clear(&self) {
89        *self.pending.lock().expect("user-input gate poisoned") = None;
90        *self.prompt.lock().expect("user-input gate poisoned") = None;
91    }
92
93    /// Whether a request is currently parked.
94    pub fn is_pending(&self) -> bool {
95        self.pending
96            .lock()
97            .expect("user-input gate poisoned")
98            .is_some()
99    }
100}
101
102/// `coder` under the CAR state root (`~/.car/coder` unless `CAR_HOME` moves the
103/// root) — session snapshots, event journals, and worktrees. This is only the
104/// default; `CAR_CODER_STATE_DIR` still overrides it outright in
105/// `coder_state_dir`.
106pub fn default_state_dir() -> Result<PathBuf, String> {
107    let root = car_home::root()
108        .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
109    Ok(root.join("coder"))
110}
111
112pub(crate) fn now_secs() -> u64 {
113    std::time::SystemTime::now()
114        .duration_since(std::time::UNIX_EPOCH)
115        .map(|d| d.as_secs())
116        .unwrap_or(0)
117}
118
119fn proactive_maintenance_event_data(
120    report: &car_memgine::ProactiveMaintenanceReport,
121) -> HashMap<String, Value> {
122    let mut data = proactive_trigger_event_data(&report.trigger);
123    data.insert(
124        "saved_count".to_string(),
125        Value::from(report.saved.len() as u64),
126    );
127    data.insert(
128        "skipped_existing".to_string(),
129        Value::from(report.skipped_existing as u64),
130    );
131    data.insert(
132        "status_updated".to_string(),
133        Value::from(report.status.is_some()),
134    );
135    data
136}
137
138fn proactive_intervention_event_data(
139    decision: &car_memgine::ProactiveMemoryDecision,
140) -> HashMap<String, Value> {
141    let mut data = HashMap::new();
142    match decision {
143        car_memgine::ProactiveMemoryDecision::Inject {
144            selected,
145            candidates,
146            bank,
147            ..
148        } => {
149            data.insert("decision".to_string(), Value::from("inject"));
150            data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
151            data.insert(
152                "selected_kind".to_string(),
153                Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
154            );
155            data.insert(
156                "candidate_count".to_string(),
157                Value::from(candidates.len() as u64),
158            );
159            data.insert(
160                "bank_knowledge".to_string(),
161                Value::from(bank.knowledge as u64),
162            );
163            data.insert(
164                "bank_procedural".to_string(),
165                Value::from(bank.procedural as u64),
166            );
167            data.insert(
168                "bank_open_subgoals".to_string(),
169                Value::from(bank.open_subgoals as u64),
170            );
171        }
172        car_memgine::ProactiveMemoryDecision::Silent {
173            reason,
174            candidates,
175            bank,
176        } => {
177            data.insert("decision".to_string(), Value::from("silent"));
178            data.insert("reason".to_string(), Value::from(reason.clone()));
179            data.insert(
180                "candidate_count".to_string(),
181                Value::from(candidates.len() as u64),
182            );
183            data.insert(
184                "bank_knowledge".to_string(),
185                Value::from(bank.knowledge as u64),
186            );
187            data.insert(
188                "bank_procedural".to_string(),
189                Value::from(bank.procedural as u64),
190            );
191            data.insert(
192                "bank_open_subgoals".to_string(),
193                Value::from(bank.open_subgoals as u64),
194            );
195        }
196    }
197    data
198}
199
200fn proactive_trigger_event_data(
201    trigger: &car_memgine::ProactiveMemoryTrigger,
202) -> HashMap<String, Value> {
203    HashMap::from([
204        (
205            "repeated_failures".to_string(),
206            Value::from(trigger.repeated_failures as u64),
207        ),
208        ("tool_error".to_string(), Value::from(trigger.tool_error)),
209        (
210            "explicit_uncertainty".to_string(),
211            Value::from(trigger.explicit_uncertainty),
212        ),
213        (
214            "high_risk_action".to_string(),
215            Value::from(trigger.high_risk_action),
216        ),
217        (
218            "context_shift".to_string(),
219            Value::from(trigger.context_shift),
220        ),
221    ])
222}
223
224/// Session lifecycle states.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum CoderState {
228    Created,
229    ContractProposed,
230    ContractConfirmed,
231    Running,
232    NeedsApproval,
233    Merged,
234    /// The session finished correctly **without a diff**: it investigated and
235    /// established that no code should change. A non-failure terminal, and the
236    /// only one that is not reached through a green contract over a diff.
237    ///
238    /// Reachable two ways, both gated outside inference — see
239    /// [`NoChangeVerification`]. A model can *nominate* this outcome; it can
240    /// never transition into it.
241    Reported,
242    Failed,
243    Abandoned,
244}
245
246impl CoderState {
247    pub fn is_terminal(&self) -> bool {
248        matches!(
249            self,
250            Self::Merged | Self::Reported | Self::Failed | Self::Abandoned
251        )
252    }
253
254    pub fn as_str(&self) -> &'static str {
255        match self {
256            Self::Created => "created",
257            Self::ContractProposed => "contract_proposed",
258            Self::ContractConfirmed => "contract_confirmed",
259            Self::Running => "running",
260            Self::NeedsApproval => "needs_approval",
261            Self::Merged => "merged",
262            Self::Reported => "reported",
263            Self::Failed => "failed",
264            Self::Abandoned => "abandoned",
265        }
266    }
267}
268
269/// What a session is waiting on a *human* for, right now.
270///
271/// Computed server-side and shipped on every session summary so every client
272/// (the `car board` TUI, CarHost, milo) says the same words about the same
273/// state — the same precedent as `DiffReady::overlap_disclosure`, where
274/// hand-rolling the sentence per renderer had already produced two divergent
275/// copies of one sentence.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum NeedsYou {
279    /// A drafted outcome contract is waiting for confirm/reject/revise.
280    Contract,
281    /// The loop asked a mid-session question and is parked on the answer.
282    Question,
283    /// The work is done and the diff is waiting for merge approval.
284    Approval,
285    /// A no-change finding is waiting to be accepted or rejected. Distinct from
286    /// [`NeedsYou::Approval`] on purpose: there is no diff, and a board that
287    /// says "diff ready for approval" over an empty worktree is lying to the
288    /// operator about what they are being asked to look at.
289    Finding,
290    /// The run is blocked on sign-in and is waiting for a credential.
291    Auth,
292}
293
294impl NeedsYou {
295    pub fn as_str(&self) -> &'static str {
296        match self {
297            Self::Contract => "contract",
298            Self::Question => "question",
299            Self::Approval => "approval",
300            Self::Finding => "finding",
301            Self::Auth => "auth",
302        }
303    }
304
305    /// The fixed operator-facing wording. The daemon owns it so two boards
306    /// never disagree about what the same session needs.
307    pub fn label(&self) -> &'static str {
308        match self {
309            Self::Contract => "contract awaiting confirmation",
310            Self::Question => "question waiting",
311            Self::Approval => "diff ready for approval",
312            Self::Finding => "finding ready for review",
313            Self::Auth => "sign-in needed",
314        }
315    }
316
317    /// Parse the wire form back (used when reading a persisted snapshot's
318    /// last-known value for a non-live session).
319    pub fn parse(s: &str) -> Option<Self> {
320        match s {
321            "contract" => Some(Self::Contract),
322            "question" => Some(Self::Question),
323            "approval" => Some(Self::Approval),
324            "finding" => Some(Self::Finding),
325            "auth" => Some(Self::Auth),
326            _ => None,
327        }
328    }
329}
330
331/// Which gate a `NeedsApproval` session is sitting on.
332///
333/// `NeedsApproval` is deliberately reused for findings rather than growing a
334/// second pending state — it already *is* the pending state, and a parallel one
335/// would duplicate the gate and complicate every FFI consumer. This field is
336/// how a client tells the two apart, so nothing has to infer the gate type from
337/// whether a diff happens to exist.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum ApprovalKind {
341    /// A diff is waiting to be merged.
342    Merge,
343    /// A no-change finding is waiting to be accepted.
344    Finding,
345}
346
347impl ApprovalKind {
348    pub fn as_str(&self) -> &'static str {
349        match self {
350            Self::Merge => "merge",
351            Self::Finding => "finding",
352        }
353    }
354
355    pub fn parse(s: &str) -> Option<Self> {
356        match s {
357            "merge" => Some(Self::Merge),
358            "finding" => Some(Self::Finding),
359            _ => None,
360        }
361    }
362}
363
364/// Why a session concluded no code should change.
365///
366/// The runtime can only independently verify the first of these, and only in a
367/// narrow operational sense — see [`NoChangeVerification::RuntimeBaselineGreen`].
368/// The other two rest on judgement a green test run cannot supply, so they
369/// always reach a human.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "snake_case")]
372pub enum NoChangeKind {
373    /// The reported problem does not exist — the code already handles the case.
374    PremiseWrong,
375    /// The behaviour is intentional. Passing tests establish what the code does
376    /// now, never that maintainers intended it or still want it, so this is not
377    /// runtime-verifiable at all.
378    DeliberateBehavior,
379    /// The fix is real but is not a code change — a migration, an operator
380    /// decision, a rollout. No diff substitutes for it.
381    NonCodeDecision,
382}
383
384impl NoChangeKind {
385    pub fn as_str(&self) -> &'static str {
386        match self {
387            Self::PremiseWrong => "premise_wrong",
388            Self::DeliberateBehavior => "deliberate_behavior",
389            Self::NonCodeDecision => "non_code_decision",
390        }
391    }
392
393    pub fn parse(s: &str) -> Option<Self> {
394        match s {
395            "premise_wrong" => Some(Self::PremiseWrong),
396            "deliberate_behavior" => Some(Self::DeliberateBehavior),
397            "non_code_decision" => Some(Self::NonCodeDecision),
398            _ => None,
399        }
400    }
401}
402
403/// What makes a [`NoChangeKind`] verdict trustworthy.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "snake_case")]
406pub enum NoChangeVerification {
407    /// Every check of a **trusted** contract passed against the unmodified
408    /// baseline. The claim this licenses is narrow and should be read literally:
409    /// *the contract was already satisfied before any work*. It is NOT a claim
410    /// that the model's explanation of why is true.
411    ///
412    /// "Trusted" is the load-bearing word and it is defined in exactly one
413    /// place, [`ContractProvenance::is_trusted`]. Widening it is how this gate
414    /// stops working.
415    RuntimeBaselineGreen,
416    /// A human read the finding and accepted it.
417    HumanApproved,
418}
419
420impl NoChangeVerification {
421    pub fn as_str(&self) -> &'static str {
422        match self {
423            Self::RuntimeBaselineGreen => "runtime_baseline_green",
424            Self::HumanApproved => "human_approved",
425        }
426    }
427}
428
429/// Where an outcome contract's bytes came from.
430///
431/// This exists because the autonomous no-change path is gated on it, which
432/// makes it a security boundary rather than bookkeeping. A model that can
433/// author its own contract and then cite that contract passing as proof it need
434/// not work has an escape hatch from every hard task.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum ContractProvenance {
438    /// Supplied as bytes by the operator (`--contract-file`).
439    OperatorSupplied,
440    /// A human confirmed it through the contract gate before work began.
441    HumanConfirmed,
442    /// Generated by the runtime from telemetry it collected itself, before this
443    /// session existed — e.g. a reproduction attached to a report CAR filed from
444    /// a `DurableFixProposal`. External to the session in the sense that matters:
445    /// neither this session's model nor a member of the public wrote it.
446    RuntimeGenerated,
447    /// Derived by this session's model, typically from an issue body. **Never
448    /// trusted**, at any tier. An issue body is untrusted input — on a public
449    /// tracker it is attacker-controlled — so a contract derived from one is a
450    /// stranger's definition of done.
451    ModelDerived,
452}
453
454impl ContractProvenance {
455    /// The single definition of "trusted". Every caller must route through this
456    /// rather than matching the variants itself, so widening it is one visible
457    /// edit rather than a drift across call sites.
458    pub fn is_trusted(&self) -> bool {
459        match self {
460            Self::OperatorSupplied | Self::HumanConfirmed | Self::RuntimeGenerated => true,
461            Self::ModelDerived => false,
462        }
463    }
464
465    pub fn as_str(&self) -> &'static str {
466        match self {
467            Self::OperatorSupplied => "operator_supplied",
468            Self::HumanConfirmed => "human_confirmed",
469            Self::RuntimeGenerated => "runtime_generated",
470            Self::ModelDerived => "model_derived",
471        }
472    }
473}
474
475/// What the model actually said when it called `report_no_change`.
476///
477/// Raw, unjudged, and carried out of the loop untouched. The loop does not have
478/// the baseline results, the contract's provenance, or the mutation ledger, and
479/// giving it those so it could self-adjudicate is precisely the design this
480/// avoids: nomination and adjudication live in different layers on purpose.
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482pub struct NoChangeNomination {
483    pub kind: NoChangeKind,
484    pub summary: String,
485    pub evidence: String,
486}
487
488/// A nominated (or accepted) "no code should change" conclusion.
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490pub struct NoChangeFinding {
491    pub kind: NoChangeKind,
492    /// One-line conclusion.
493    pub summary: String,
494    /// What was examined to reach it.
495    pub evidence: String,
496    /// `None` while the finding is still nominated and awaiting a human.
497    pub verification: Option<NoChangeVerification>,
498    pub proposed_at: u64,
499    pub resolved_at: Option<u64>,
500    pub resolver_comment: Option<String>,
501    /// The baseline check results as they stood when the finding was nominated,
502    /// captured so the verdict can be audited later without re-running anything.
503    pub baseline_checks: Vec<CheckResult>,
504}
505
506/// Derive [`NeedsYou`] from the three facts that decide it. Split out from the
507/// live registry so the table in `docs/proposals/coder-board-wire-contract.md`
508/// §1 is directly testable without a daemon.
509///
510/// `auth_outstanding` means "an `auth_required` event is the latest unresolved
511/// auth event" — the caller clears it on any subsequent non-auth event or state
512/// change (see `coder::rpc::AttentionState`).
513pub fn needs_you_from(
514    state: CoderState,
515    question_pending: bool,
516    auth_outstanding: bool,
517    approval_kind: Option<ApprovalKind>,
518) -> Option<NeedsYou> {
519    match state {
520        CoderState::ContractProposed => Some(NeedsYou::Contract),
521        // Which gate is decided by `approval_kind`, never by guessing from the
522        // presence of a diff. Absent (an older snapshot) reads as a merge gate,
523        // which is what every pre-finding session was.
524        CoderState::NeedsApproval => match approval_kind {
525            Some(ApprovalKind::Finding) => Some(NeedsYou::Finding),
526            Some(ApprovalKind::Merge) | None => Some(NeedsYou::Approval),
527        },
528        // Question wins over auth: a parked question is a literal prompt on
529        // screen with a waiter behind it, while an outstanding auth event only
530        // means the loop is polling for a credential.
531        CoderState::Running if question_pending => Some(NeedsYou::Question),
532        CoderState::Running if auth_outstanding => Some(NeedsYou::Auth),
533        _ => None,
534    }
535}
536
537/// Whether `from → to` is a legal transition. Any non-terminal state may move
538/// to `Failed` (errors happen anywhere) or `Abandoned` (user cancel); terminal
539/// states never move.
540pub fn can_transition(from: CoderState, to: CoderState) -> bool {
541    use CoderState::*;
542    if from.is_terminal() {
543        return false;
544    }
545    matches!(to, Failed | Abandoned)
546        || matches!(
547            (from, to),
548            (Created, ContractProposed)
549                | (ContractProposed, ContractProposed) // re-propose after edit
550                | (ContractProposed, ContractConfirmed)
551                | (ContractConfirmed, Running)
552                | (Running, NeedsApproval)
553                | (NeedsApproval, Merged)
554                // The autonomous no-change path, gated on a trusted contract
555                // being green against an unmodified baseline.
556                | (Running, Reported)
557                // A human accepted a nominated finding.
558                | (NeedsApproval, Reported)
559                // A human REJECTED a nominated finding. The first backward edge
560                // in this table, and deliberate: a rejected nomination must not
561                // become a scored loss. The session goes back to work and can
562                // still reach a diff.
563                | (NeedsApproval, Running)
564        )
565}
566
567/// One event in a session's stream. `seq` is monotonically increasing per
568/// session so clients can resume from a cursor after reconnect.
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct CoderEvent {
571    pub session_id: String,
572    pub seq: u64,
573    pub ts: u64,
574    #[serde(flatten)]
575    pub kind: CoderEventKind,
576}
577
578/// What happened. Serialized with `"type": "snake_case_name"` for WS clients.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580#[serde(tag = "type", rename_all = "snake_case")]
581pub enum CoderEventKind {
582    StateChanged {
583        from: String,
584        to: String,
585    },
586    ContractProposed {
587        contract: OutcomeContract,
588    },
589    EngineSelected {
590        engine: String,
591        reason: String,
592    },
593    EngineFallback {
594        from: String,
595        to: String,
596        reason: String,
597    },
598    /// The preferred inference lane was skipped and a different model served
599    /// the call. Distinct from `EngineFallback`, which is about the coder
600    /// ENGINE (native vs an external CLI), not the model behind it.
601    ///
602    /// Emitted at most once per PHASE — contract derivation, each contract
603    /// revision, and the run loop announce independently, and a session that
604    /// degrades in more than one of them emits more than one. The guard is
605    /// against narrating every routing decision inside a phase, not against a
606    /// second phase reporting a degrade the operator has not seen resolved.
607    ModelFallback {
608        /// The lane that was skipped.
609        from: String,
610        /// The model that actually served the call.
611        to: String,
612        /// Why, in words an operator can act on.
613        reason: String,
614    },
615    IterationStarted {
616        n: u32,
617        max: u32,
618    },
619    /// A native model call is still pending; not evidence of tool execution.
620    InferenceWaiting {
621        elapsed_secs: u64,
622    },
623    /// Status: queued, applied (included in the next turn), or not_applied.
624    OperatorGuidance {
625        text: String,
626        status: String,
627    },
628    /// Sanitized provider retry metadata from the inference engine.
629    InferenceRetry {
630        model: String,
631        attempt: u32,
632        reason: String,
633        backoff_ms: u64,
634    },
635    /// The run is blocked on **sign-in** and is waiting for the human, rather
636    /// than failing. Not terminal: if a credential appears within `wait_secs`
637    /// the session resumes from where it stopped, worktree intact.
638    ///
639    /// Distinct from `Error` on purpose. A client should surface this as an
640    /// action the user can take ("sign in to continue"), because it is the one
641    /// failure mode a person standing at the machine can clear in seconds — and
642    /// previously it read as `no inference backend is available`, which points
643    /// at models and accounts instead of at the sign-in it actually needs.
644    AuthRequired {
645        /// The underlying auth error, for diagnosis.
646        message: String,
647        /// How long the session will wait before giving up.
648        wait_secs: u64,
649    },
650    /// The session hit its wall-clock ceiling and the next iteration was not
651    /// admitted. Terminal, and the session ends `Failed` — it never reaches the
652    /// merge gate, which requires green checks. The worktree IS retained for
653    /// postmortem (the budget path forces `keep_workspace_on_failure`), so the
654    /// partial work survives on disk at `workspace_path`.
655    BudgetExhausted {
656        /// Human-readable, naming both the elapsed time and the ceiling.
657        reason: String,
658        elapsed_secs: u64,
659        /// Iterations completed before the ceiling was reached.
660        iterations: u32,
661    },
662    /// A worker invocation died mid-run (timeout / I/O) with the contract still
663    /// red, and the same hypothesis is being re-invoked.
664    ///
665    /// Distinct from `IterationStarted` on purpose: a retry costs no hypothesis,
666    /// so folding it in would make that event's `n`/`max` misreport the budget.
667    /// A chronically flaky CLI is otherwise indistinguishable from a fast clean
668    /// one in the A/B's wall-clock.
669    InvocationRetried {
670        /// The hypothesis being retried (`IterationStarted.n`).
671        hypothesis: u32,
672        /// Transport error that ended the invocation.
673        reason: String,
674        /// Transient retries left for this session.
675        retries_remaining: u32,
676    },
677    PlanText {
678        text: String,
679    },
680    ToolCall {
681        tool: String,
682        params_preview: String,
683    },
684    ToolResult {
685        tool: String,
686        ok: bool,
687        preview: String,
688    },
689    CheckStarted {
690        name: String,
691    },
692    CheckCompleted {
693        result: CheckResult,
694    },
695    /// The contract evaluated against the **unmodified** worktree at session
696    /// start (car#707). Distinct from `CheckStarted`/`CheckCompleted`, which
697    /// mean "the contract is being evaluated on the work" — replaying those for
698    /// a baseline would show checks going green before a line was written.
699    /// `gates_nothing` is true when every check already passed, i.e. the
700    /// contract verifies nothing for this task.
701    ContractBaseline {
702        results: Vec<CheckResult>,
703        gates_nothing: bool,
704    },
705    ExternalEvent {
706        raw: Value,
707    },
708    /// The loop nominated a "no code should change" conclusion. A nomination,
709    /// not a verdict: the runtime decides what happens next.
710    FindingProposed {
711        finding: NoChangeFinding,
712    },
713    /// A nominated finding was accepted or rejected.
714    FindingResolved {
715        accepted: bool,
716        comment: Option<String>,
717    },
718    DiffReady {
719        stat: String,
720        /// The patch body, tail-capped to the configured budget. Named for what
721        /// it is; `patch_truncated` (the bool) says whether it is partial.
722        patch: String,
723        /// True when `patch` is a tail. A UI must be able to say "you are
724        /// approving against a partial diff" without string-matching the
725        /// `…[truncated]…` marker (car#706).
726        patch_truncated: bool,
727        /// Size of the untruncated patch.
728        patch_full_bytes: usize,
729        /// How many distinct paths the diff touches. Named `paths`, not
730        /// `files`, because a rename contributes BOTH of its endpoints — one
731        /// file moved is two paths touched, and for a reviewer asking "what did
732        /// this session reach into" that is the honest number. `stat` carries it
733        /// too, but only as prose a client must parse; scope explosion is what a
734        /// reviewer most needs stated plainly before deciding whether to read
735        /// the patch at all.
736        changed_paths: usize,
737        /// Contract checks whose commands execute a path this diff modified.
738        /// Disclosure, never denial: editing tests is frequently the task, and
739        /// the human at the gate is who should judge which case this is.
740        contract_overlap: Vec<super::overlap::CheckOverlap>,
741        /// The rendered disclosure sentence, or `None` when nothing overlaps.
742        ///
743        /// On the wire so every surface prints the SAME words. Hand-rolling it
744        /// per renderer had already lost "that is often legitimate" from both
745        /// the CLI and the host app while the log kept it — dropping the
746        /// non-accusatory half of a sentence whose entire design posture is
747        /// disclosure rather than accusation, and leaving the only tested copy
748        /// the one no human reads. `contract_overlap` stays alongside it for
749        /// machine consumers that want the structure.
750        overlap_disclosure: Option<String>,
751    },
752    UserInputRequested {
753        prompt: String,
754    },
755    /// The mid-session question's answer window closed server-side without an
756    /// answer. The loop carried on without one; the prompt is DEAD.
757    ///
758    /// Its own event because a client has no other way to learn: the gate
759    /// simply stops being pending, which is a state a board can only discover
760    /// by asking again. Without this, a board kept rendering the question as
761    /// live — and counting it under "needs you" — until the operator happened
762    /// to refresh. It is also what drives the `coder.session_changed` fanout
763    /// that drops `needs_you` back to null.
764    UserInputExpired {
765        /// The question that went unanswered, so a client can match it to the
766        /// prompt it is showing.
767        prompt: String,
768        /// How long the daemon waited.
769        waited_secs: u64,
770    },
771    /// A `coder.revise_contract` request could NOT be honored: the redraft did
772    /// not validate, or the request was not expressible as checks. The session
773    /// stays at the gate with the PREVIOUS contract intact.
774    ///
775    /// Its own event rather than a generic `Error` because the operator needs
776    /// to know the contract they are still looking at is the old one — a
777    /// revision that silently passes as applied is the failure mode this
778    /// exists to make impossible.
779    ContractRevisionRejected {
780        /// The operator's plain-English request, verbatim.
781        request: String,
782        /// Why it could not be honored (the derivation/validation failure).
783        reason: String,
784    },
785    MergeCompleted {
786        branch: String,
787    },
788    Error {
789        message: String,
790    },
791}
792
793/// Per-session event fanout + audit. Emits to the registered emitter (WS
794/// subscribers) and journals the audit-relevant subset to a JSONL event log.
795pub struct EventSink {
796    session_id: String,
797    seq: AtomicU64,
798    /// Latest emitted event time, published before the event enters fanout.
799    /// The watchdog reads this lock-free because fanout deliberately holds the
800    /// replay-buffer lock across bounded subscriber writes.
801    last_event_at: AtomicU64,
802    emitter: Option<EventEmitter>,
803    journal: Option<Mutex<EventLog>>,
804    #[cfg(test)]
805    journal_path: Option<PathBuf>,
806}
807
808impl EventSink {
809    pub(super) fn resume_at(self, next_seq: u64) -> Self {
810        self.seq.store(next_seq, Ordering::SeqCst);
811        self
812    }
813
814    pub(super) fn next_sequence(&self) -> u64 {
815        self.seq.load(Ordering::SeqCst)
816    }
817
818    pub fn new(
819        session_id: impl Into<String>,
820        emitter: Option<EventEmitter>,
821        journal_path: Option<PathBuf>,
822    ) -> Self {
823        #[cfg(test)]
824        let test_journal_path = journal_path.clone();
825        Self {
826            session_id: session_id.into(),
827            seq: AtomicU64::new(0),
828            last_event_at: AtomicU64::new(0),
829            emitter,
830            journal: journal_path.map(|p| Mutex::new(EventLog::with_journal(p))),
831            #[cfg(test)]
832            journal_path: test_journal_path,
833        }
834    }
835
836    /// Drain the background journal writer without polling the filesystem.
837    ///
838    /// Dropping an [`EventLog`] joins its writer thread after the channel is
839    /// drained. Tests that inspect JSONL use that existing completion boundary,
840    /// then reload the log so later appends still have a live writer. Running
841    /// the join on Tokio's blocking pool keeps the async test executor free.
842    #[cfg(test)]
843    pub async fn flush_journal_for_test(self: &Arc<Self>) -> Result<(), String> {
844        let sink = Arc::clone(self);
845        tokio::task::spawn_blocking(move || {
846            let Some(journal) = &sink.journal else {
847                return Ok(());
848            };
849            let path = sink
850                .journal_path
851                .as_ref()
852                .ok_or_else(|| "journal path missing for configured event log".to_string())?;
853            let mut log = journal
854                .lock()
855                .map_err(|_| "event journal lock poisoned".to_string())?;
856            let pending = std::mem::replace(&mut *log, EventLog::new());
857            drop(pending);
858            *log = EventLog::load(path)
859                .map_err(|error| format!("reload flushed event journal: {error}"))?;
860            Ok(())
861        })
862        .await
863        .map_err(|error| format!("join event journal flush task: {error}"))?
864    }
865
866    /// A sink that drops everything — unit tests that don't assert on events.
867    pub fn test_sink() -> Self {
868        Self::new("coder-test", None, None)
869    }
870
871    /// Collect events into a shared Vec — tests that DO assert on events.
872    pub fn collecting(session_id: &str) -> (Self, Arc<Mutex<Vec<CoderEvent>>>) {
873        let collected: Arc<Mutex<Vec<CoderEvent>>> = Arc::new(Mutex::new(Vec::new()));
874        let sink_copy = collected.clone();
875        let emitter: EventEmitter = Arc::new(move |e| {
876            sink_copy.lock().expect("collector poisoned").push(e);
877        });
878        (Self::new(session_id, Some(emitter), None), collected)
879    }
880
881    pub fn emit(&self, kind: CoderEventKind) -> CoderEvent {
882        let event = CoderEvent {
883            session_id: self.session_id.clone(),
884            seq: self.seq.fetch_add(1, Ordering::SeqCst),
885            ts: now_secs(),
886            kind,
887        };
888        // Publish liveness before audit/fanout. In production the emitter queues
889        // the event for a drain that can hold the replay-buffer lock for a
890        // bounded subscriber send; the watchdog must see progress throughout.
891        self.last_event_at.fetch_max(event.ts, Ordering::SeqCst);
892        self.audit(&event);
893        if let Some(emitter) = &self.emitter {
894            emitter(event.clone());
895        }
896        event
897    }
898
899    /// Latest event timestamp without taking the replay-buffer lock. Zero means
900    /// this sink has not emitted an event yet.
901    pub(crate) fn last_event_at(&self) -> u64 {
902        self.last_event_at.load(Ordering::SeqCst)
903    }
904
905    /// Append a durable `TurnCompleted` audit record for a coder-loop terminal.
906    ///
907    /// The coder loop has no `Runtime` in scope (only this sink), so this mirrors
908    /// [`car_engine::Runtime::record_turn_completed`] directly onto the coder
909    /// session journal — the same `EventKind::TurnCompleted` + data shape the
910    /// assistant path emits (via the shared `car_engine::goal::turn_completed_data`),
911    /// so the coder-path false-success / truncation / turn-budget-burn signal is
912    /// captured in the exact form the harness miners already understand.
913    ///
914    /// Consumption is a separate follow-up, NOT done here: these events land in
915    /// the coder session journal (`<state_dir>/<session_id>.events.jsonl`), a
916    /// durable record read offline / via the FFI `diagnose_from_jsonl`. The
917    /// in-process daemon miners (`harness_adapt::diagnose`,
918    /// `evolution::failed_trace_events`) run over `session.runtime.log` (the
919    /// assistant path), so they do NOT yet consume this coder journal — wiring it
920    /// into the daemon evolution path is tracked separately. Journal-only: not a
921    /// WS-streamed `CoderEvent`, matching how P0b kept `TurnCompleted` off the
922    /// live `AssistantEvent` stream (no WS/FFI surface change).
923    pub fn record_turn_completed(
924        &self,
925        decision: &str,
926        stop_reason: Option<&str>,
927        was_truncated: bool,
928        turns: u32,
929        model: &str,
930        // Every distinct model that served a turn in this iteration, first-seen
931        // order. `model` is whichever one reached the terminal; these are all of
932        // them, which is a different question when the chain routes per request.
933        models_served: &[String],
934    ) {
935        let Some(journal) = &self.journal else { return };
936        let mut data = car_engine::goal::turn_completed_data(
937            decision,
938            stop_reason,
939            was_truncated,
940            turns,
941            model,
942        );
943        // Added here rather than in `turn_completed_data`, which the assistant
944        // path shares: this is a coder-loop fact, and widening the shared
945        // helper's signature would make every caller answer a question only
946        // this one has. Additive on the journal record — a reader that does not
947        // know the key sees exactly what it saw before.
948        if !models_served.is_empty() {
949            data.insert(
950                "models_served".to_string(),
951                Value::Array(
952                    models_served
953                        .iter()
954                        .map(|m| Value::String(m.clone()))
955                        .collect(),
956                ),
957            );
958        }
959        if let Ok(mut log) = journal.lock() {
960            log.append(EventKind::TurnCompleted, Some(&self.session_id), None, data);
961        }
962    }
963
964    /// Journal one mid-run backbone change.
965    ///
966    /// **Written directly rather than recognized in [`EventSink::audit`], once
967    /// per distinct hop rather than once per session.** The live
968    /// `ModelFallback` event is behind a once-per-phase latch so the stream
969    /// does not narrate every routing decision — right for a stream, wrong for
970    /// a record. A run that degraded twice has two facts, and journaling off
971    /// the latched emit would keep only the first.
972    ///
973    /// The caller supplies the deduplication, because the fact worth recording
974    /// is a distinct transition and a persistent condition re-reports the same
975    /// one on every turn. This method itself always appends: it is the record,
976    /// not the policy.
977    ///
978    /// car#1333 recorded WHO wrote each turn (`models_served` on the turn
979    /// terminal). This records WHY the backbone moved, which is a genuinely
980    /// different fact rather than the same one twice: without it, a run that
981    /// silently degraded to another model looks — to `harness_adapt`-style
982    /// mining and to a human reading the journal — like the code under test
983    /// behaving badly (car#1351).
984    ///
985    /// Journal-only, no WS/FFI surface, same as the two above.
986    pub fn record_model_fallback(&self, from: &str, to: &str, reason: &str) {
987        let Some(journal) = &self.journal else { return };
988        let mut data = std::collections::HashMap::new();
989        data.insert("from".to_string(), Value::String(from.to_string()));
990        data.insert("to".to_string(), Value::String(to.to_string()));
991        data.insert("reason".to_string(), Value::String(reason.to_string()));
992        if let Ok(mut log) = journal.lock() {
993            log.append(EventKind::ModelFallback, Some(&self.session_id), None, data);
994        }
995    }
996
997    /// Journal one merge-gate verdict.
998    ///
999    /// **Written here rather than derived from the event stream, deliberately.**
1000    /// The obvious shape is to bridge the verdict out as a `foreman: "gate"`
1001    /// `CoderEvent` and have [`EventSink::audit`] recognize it — one path, live
1002    /// and durable together. That is forgeable. `process_stream` fires the
1003    /// emitter on every line the supervised CLI prints, and `StreamEvent`'s
1004    /// `#[serde(flatten)] extra` absorbs arbitrary top-level keys and re-emits
1005    /// them at top level — so a single line of stdout from the model being
1006    /// supervised satisfies any predicate `audit` could key on, and writes "the
1007    /// gate accepted this patch" into the audit record with the gate never
1008    /// having run. It would not even be scoped to foreman sessions: `audit` runs
1009    /// for every coder session with a journal.
1010    ///
1011    /// car#1243 is what makes that fatal rather than untidy. The patches this
1012    /// gate rules on are authored on peers this host does not control, which is
1013    /// an argument for a record the audited party CANNOT write to. So the live
1014    /// `foreman: "gate"` event stays narration — spoofable, and only ever read
1015    /// as narration — and the durable record is written straight to the journal
1016    /// from the foreman loop, on the same shape as
1017    /// [`EventSink::record_turn_completed`] above and for the same reason.
1018    ///
1019    /// `kind` is the gate's own [`EventKind::GateAccepted`] / [`EventKind::GateRejected`],
1020    /// and `data` is its payload verbatim. Note the gate treats `Inconclusive`
1021    /// (verify timed out, or not configured) as not-accepted, so "we don't know"
1022    /// journals as `GateRejected` carrying `outcome: "inconclusive"` — the kind
1023    /// is binary, the payload is not.
1024    ///
1025    /// Journal-only, no WS/FFI surface: same as `record_turn_completed`.
1026    pub fn record_gate_verdict(
1027        &self,
1028        kind: EventKind,
1029        data: std::collections::HashMap<String, Value>,
1030    ) {
1031        let Some(journal) = &self.journal else { return };
1032        if let Ok(mut log) = journal.lock() {
1033            log.append(kind, Some(&self.session_id), None, data);
1034        }
1035    }
1036
1037    /// Every model that completed a turn in this session, from the journal,
1038    /// distinct and in first-seen order.
1039    ///
1040    /// `record_turn_completed` already writes `model_id` on every terminal
1041    /// native path, so this reads what is there rather than adding a second
1042    /// record that could disagree with it.
1043    ///
1044    /// **A set, not the last one.** `TurnCompleted` is a PER-ITERATION
1045    /// terminal, and an unpinned session is free to route each iteration
1046    /// differently — `strict_model` is `cfg.model.is_some()`, so an unpinned
1047    /// chain may also degrade to another model on an outage. A reviewer judges
1048    /// the accumulated worktree diff, not the last iteration, so taking the
1049    /// last name would let the model that wrote most of the change review it
1050    /// as long as something else finished the run.
1051    ///
1052    /// **Mid-iteration models count too** (car#1333). A terminal names only the
1053    /// model that reached it, so a model that wrote turns 1-3 of an iteration
1054    /// another model finished used to leave no record at all — and could then
1055    /// sit on the panel reviewing what it had written. `models_served` carries
1056    /// the rest, and both are folded here.
1057    ///
1058    /// A record written before that field existed simply has no `models_served`,
1059    /// and folds to its `model_id` alone — the same answer it gave before.
1060    ///
1061    /// Empty for a run with no native turns — a foreman or external session
1062    /// farms to a coding CLI whose backbone CAR never resolved, so there is no
1063    /// honest answer. Empty means "nobody asked CAR's own loop to write this",
1064    /// NOT "nobody wrote it".
1065    pub fn authoring_models(&self) -> Vec<String> {
1066        let Some(journal) = &self.journal else {
1067            return Vec::new();
1068        };
1069        // Under the journal lock rather than `events().to_vec()`: a session
1070        // that made hundreds of tool calls has hundreds of records, and cloning
1071        // all of them to read one field each runs while the session mutex is
1072        // also held by `finalize_outcome`.
1073        let Ok(log) = journal.lock() else {
1074            return Vec::new();
1075        };
1076        let mut models: Vec<String> = Vec::new();
1077        for event in log.events() {
1078            if event.kind != EventKind::TurnCompleted {
1079                continue;
1080            }
1081            let served = event
1082                .data
1083                .get("models_served")
1084                .and_then(|v| v.as_array())
1085                .map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
1086                .unwrap_or_default();
1087            let terminal = event.data.get("model_id").and_then(|v| v.as_str());
1088            for model in served.into_iter().chain(terminal) {
1089                let model = model.trim();
1090                if model.is_empty() {
1091                    continue;
1092                }
1093                if !models.iter().any(|m| m == model) {
1094                    models.push(model.to_string());
1095                }
1096            }
1097        }
1098        models
1099    }
1100
1101    pub fn events(&self) -> Vec<car_eventlog::Event> {
1102        let Some(journal) = &self.journal else {
1103            return Vec::new();
1104        };
1105        journal
1106            .lock()
1107            .map(|log| log.events().to_vec())
1108            .unwrap_or_default()
1109    }
1110
1111    pub fn record_proactive_memory(
1112        &self,
1113        maintenance: &car_memgine::ProactiveMaintenanceReport,
1114        decision: &car_memgine::ProactiveMemoryDecision,
1115    ) {
1116        let Some(journal) = &self.journal else {
1117            return;
1118        };
1119        if let Ok(mut log) = journal.lock() {
1120            log.append(
1121                EventKind::ProactiveMemoryMaintained,
1122                Some(&self.session_id),
1123                None,
1124                proactive_maintenance_event_data(maintenance),
1125            );
1126            log.append(
1127                EventKind::ProactiveMemoryIntervention,
1128                Some(&self.session_id),
1129                None,
1130                proactive_intervention_event_data(decision),
1131            );
1132        }
1133    }
1134
1135    /// Journal the audit-relevant subset (transitions, tool calls, checks,
1136    /// errors). Narration-only events (plan text, iteration markers, diffs)
1137    /// live in the WS stream and the session snapshot instead.
1138    fn audit(&self, event: &CoderEvent) {
1139        let Some(journal) = &self.journal else { return };
1140        let (kind, mut data): (EventKind, HashMap<String, Value>) = match &event.kind {
1141            CoderEventKind::StateChanged { from, to } => (
1142                EventKind::StateChanged,
1143                HashMap::from([
1144                    ("from".to_string(), Value::String(from.clone())),
1145                    ("to".to_string(), Value::String(to.clone())),
1146                ]),
1147            ),
1148            CoderEventKind::ToolCall {
1149                tool,
1150                params_preview,
1151            } => (
1152                EventKind::ActionExecuting,
1153                HashMap::from([
1154                    ("tool".to_string(), Value::String(tool.clone())),
1155                    ("params".to_string(), Value::String(params_preview.clone())),
1156                ]),
1157            ),
1158            CoderEventKind::ToolResult { tool, ok, preview } => (
1159                if *ok {
1160                    EventKind::ActionSucceeded
1161                } else {
1162                    EventKind::ActionFailed
1163                },
1164                HashMap::from([
1165                    ("tool".to_string(), Value::String(tool.clone())),
1166                    ("result".to_string(), Value::String(preview.clone())),
1167                ]),
1168            ),
1169            CoderEventKind::CheckCompleted { result } => (
1170                if result.passed {
1171                    EventKind::ActionSucceeded
1172                } else {
1173                    EventKind::ActionFailed
1174                },
1175                HashMap::from([
1176                    ("check".to_string(), Value::String(result.name.clone())),
1177                    (
1178                        "exit_code".to_string(),
1179                        result.exit_code.map(Value::from).unwrap_or(Value::Null),
1180                    ),
1181                ]),
1182            ),
1183            // Journalled as an observation, never as a failure: an all-green
1184            // baseline is a fact about the contract, not a failed action, and
1185            // recording it as `ActionFailed` would poison `harness_adapt`'s
1186            // failure-mechanism diagnosis with a non-failure.
1187            CoderEventKind::ContractBaseline {
1188                results,
1189                gates_nothing,
1190            } => (
1191                EventKind::ActionSucceeded,
1192                HashMap::from([
1193                    ("baseline_checks".to_string(), Value::from(results.len())),
1194                    (
1195                        "baseline_passed".to_string(),
1196                        Value::from(results.iter().filter(|r| r.passed).count()),
1197                    ),
1198                    (
1199                        "contract_gates_nothing".to_string(),
1200                        Value::Bool(*gates_nothing),
1201                    ),
1202                ]),
1203            ),
1204            CoderEventKind::Error { message } => (
1205                EventKind::ActionFailed,
1206                HashMap::from([("error".to_string(), Value::String(message.clone()))]),
1207            ),
1208            CoderEventKind::MergeCompleted { branch } => (
1209                EventKind::ActionSucceeded,
1210                HashMap::from([("branch".to_string(), Value::String(branch.clone()))]),
1211            ),
1212            // `ActionSkipped`, which is literally what happened: the next
1213            // iteration was not admitted. Deliberately NOT `ActionFailed` — a
1214            // session that ran out of clock did not fail an action, and filing
1215            // it as one would poison `harness_adapt`'s failure-mechanism
1216            // diagnosis with a non-failure, the same trap `ContractBaseline`
1217            // avoids.
1218            CoderEventKind::BudgetExhausted {
1219                reason,
1220                elapsed_secs,
1221                iterations,
1222            } => (
1223                EventKind::ActionSkipped,
1224                HashMap::from([
1225                    ("reason".to_string(), Value::String(reason.clone())),
1226                    ("elapsed_secs".to_string(), Value::from(*elapsed_secs)),
1227                    ("iterations".to_string(), Value::from(*iterations)),
1228                ]),
1229            ),
1230            // Journaled so `harness_adapt::diagnose` can see a CLI that keeps
1231            // dying under us. Without this arm a chronically flaky engine is
1232            // indistinguishable from a fast clean one in the run record.
1233            CoderEventKind::InvocationRetried {
1234                hypothesis,
1235                reason,
1236                retries_remaining,
1237            } => (
1238                // `ActionRetrying`, not `ActionFailed`: `harness_adapt` tallies
1239                // the two separately, and a retried invocation is not a failed
1240                // action — filing it as one would inflate the failure tally
1241                // that drives intervention thresholds.
1242                EventKind::ActionRetrying,
1243                HashMap::from([
1244                    ("hypothesis".to_string(), Value::from(*hypothesis)),
1245                    ("reason".to_string(), Value::String(reason.clone())),
1246                    (
1247                        "retries_remaining".to_string(),
1248                        Value::from(*retries_remaining),
1249                    ),
1250                ]),
1251            ),
1252            _ => return,
1253        };
1254        data.insert(
1255            "coder_event".to_string(),
1256            Value::String(coder_event_name(&event.kind).to_string()),
1257        );
1258        data.insert("seq".to_string(), Value::from(event.seq));
1259        // The event's `action_id` identifies WHICH action, keyed by the tool or
1260        // check name so `harness_adapt::diagnose` can tally failures per-tool
1261        // (`run_command` failing 4× → a targeted intervention) instead of lumping
1262        // every failure under the session id. The session is already the journal
1263        // file's identity; other events fall back to it.
1264        let action_id: String = match &event.kind {
1265            CoderEventKind::ToolCall { tool, .. } | CoderEventKind::ToolResult { tool, .. } => {
1266                tool.clone()
1267            }
1268            CoderEventKind::CheckCompleted { result } => format!("check:{}", result.name),
1269            // Its own bucket, for the reason stated above: falling through to
1270            // the session id would pool transport retries with every other
1271            // coder error against one `min_occurrences` threshold, so neither
1272            // signal would mean what `diagnose` reads it as.
1273            CoderEventKind::InvocationRetried { .. } => "invocation_retry".to_string(),
1274            CoderEventKind::BudgetExhausted { .. } => "session_budget".to_string(),
1275            _ => self.session_id.clone(),
1276        };
1277        if let Ok(mut log) = journal.lock() {
1278            log.append(kind, Some(&action_id), None, data);
1279        }
1280    }
1281}
1282
1283fn coder_event_name(kind: &CoderEventKind) -> &'static str {
1284    match kind {
1285        CoderEventKind::StateChanged { .. } => "coder.state_changed",
1286        CoderEventKind::ContractProposed { .. } => "coder.contract_proposed",
1287        CoderEventKind::EngineSelected { .. } => "coder.engine_selected",
1288        CoderEventKind::EngineFallback { .. } => "coder.engine_fallback",
1289        CoderEventKind::ModelFallback { .. } => "coder.model_fallback",
1290        CoderEventKind::IterationStarted { .. } => "coder.iteration_started",
1291        CoderEventKind::InferenceWaiting { .. } => "coder.inference_waiting",
1292        CoderEventKind::OperatorGuidance { .. } => "coder.operator_guidance",
1293        CoderEventKind::InferenceRetry { .. } => "coder.inference_retry",
1294        CoderEventKind::AuthRequired { .. } => "coder.auth_required",
1295        CoderEventKind::BudgetExhausted { .. } => "coder.budget_exhausted",
1296        CoderEventKind::InvocationRetried { .. } => "coder.invocation_retried",
1297        CoderEventKind::PlanText { .. } => "coder.plan_text",
1298        CoderEventKind::ToolCall { .. } => "coder.tool_call",
1299        CoderEventKind::ToolResult { .. } => "coder.tool_result",
1300        CoderEventKind::CheckStarted { .. } => "coder.check_started",
1301        CoderEventKind::CheckCompleted { .. } => "coder.check_completed",
1302        CoderEventKind::ContractBaseline { .. } => "coder.contract_baseline",
1303        CoderEventKind::ExternalEvent { .. } => "coder.external_event",
1304        CoderEventKind::FindingProposed { .. } => "coder.finding_proposed",
1305        CoderEventKind::FindingResolved { .. } => "coder.finding_resolved",
1306        CoderEventKind::DiffReady { .. } => "coder.diff_ready",
1307        CoderEventKind::UserInputRequested { .. } => "coder.user_input_requested",
1308        CoderEventKind::UserInputExpired { .. } => "coder.user_input_expired",
1309        CoderEventKind::ContractRevisionRejected { .. } => "coder.contract_revision_rejected",
1310        CoderEventKind::MergeCompleted { .. } => "coder.merge_completed",
1311        CoderEventKind::Error { .. } => "coder.error",
1312    }
1313}
1314
1315/// The phase an Agent-project build is currently executing.
1316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1317#[serde(rename_all = "snake_case")]
1318pub enum AgentBuildPhase {
1319    GeneratingSpec,
1320    Repairing,
1321    RunningScenario,
1322}
1323
1324/// Live progress for an Agent-project build.
1325///
1326/// Persisted with the session so `coder.get` does not lose the last known phase
1327/// across a daemon restart. While the session is live, the RPC refreshes
1328/// `elapsed_secs` from `started_at` on every poll.
1329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1330pub struct AgentBuildProgress {
1331    pub phase: AgentBuildPhase,
1332    pub attempt: u32,
1333    pub max_attempts: u32,
1334    #[serde(default, skip_serializing_if = "Option::is_none")]
1335    pub scenario: Option<u32>,
1336    #[serde(default, skip_serializing_if = "Option::is_none")]
1337    pub scenarios_total: Option<u32>,
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub model: Option<String>,
1340    pub started_at: u64,
1341    pub elapsed_secs: u64,
1342}
1343
1344impl AgentBuildProgress {
1345    pub fn refresh_elapsed(&mut self) {
1346        self.elapsed_secs = now_secs().saturating_sub(self.started_at);
1347    }
1348}
1349
1350/// A coding session. Serializes to the JSON snapshot persisted on every
1351/// transition; the live worktree handle is process-only (`#[serde(skip)]`).
1352#[derive(Debug, Serialize, Deserialize)]
1353pub struct CoderSession {
1354    pub id: String,
1355    /// The user's repository root (never written to directly).
1356    pub repo: PathBuf,
1357    pub intent: String,
1358    /// The engine that was RESOLVED for this session — never the raw request.
1359    /// `--engine auto` that picks claude-code and an explicit
1360    /// `--engine external:claude-code` both land here as
1361    /// `External("claude-code")`, which is why `requested_engine` below had to
1362    /// exist. Read by `placement_for` and by self-heal's re-start, both of
1363    /// which want the resolved choice, so nothing may overwrite it with the
1364    /// engine that actually ran.
1365    pub engine: EngineChoice,
1366    /// What the caller ASKED for at `coder.start`, before resolution
1367    /// (car#1534). The only durable record of whether the operator named an
1368    /// engine, and therefore the input to the fallback policy: an explicit
1369    /// `external:`/`foreman:` request is never silently replaced by the native
1370    /// engine. Resolution's prose reason ("explicitly requested") carries the
1371    /// same fact, but prose is not a contract — this codebase already burned
1372    /// once on an `e != "cancelled"` compare.
1373    ///
1374    /// `None` on a snapshot written before this field existed; treated as *not
1375    /// explicit*, which is the pre-car#1534 behaviour for those sessions.
1376    #[serde(default, skip_serializing_if = "Option::is_none")]
1377    pub requested_engine: Option<EngineChoice>,
1378    /// The engine that actually produced the outcome (car#1534). `Native` when
1379    /// an external engine fell back, otherwise whichever engine finished.
1380    /// `None` until the loop ends, and on a snapshot older than this field — a
1381    /// client that sees `null` falls back to showing `engine`.
1382    ///
1383    /// Separate from `engine` because the two answer different questions, and
1384    /// collapsing them is the defect: a session that asked for one engine and
1385    /// was run by another looked identical to one that got what it asked for.
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub engine_ran: Option<EngineChoice>,
1388    pub state: CoderState,
1389    #[serde(default, skip_serializing_if = "Option::is_none")]
1390    pub contract: Option<OutcomeContract>,
1391    /// Where the throwaway worktree lives (kept in the snapshot so orphaned
1392    /// sessions after a daemon restart can still report it).
1393    #[serde(default, skip_serializing_if = "Option::is_none")]
1394    pub workspace_path: Option<PathBuf>,
1395    /// When this session works on a CAR-managed project (vs. a raw repo path),
1396    /// the project slug + kind. Drives delivery (commit straight to the
1397    /// project's `main` instead of publishing a `car/coder/<id>` branch) and,
1398    /// for `Agent` projects, the scenario-based contract + agent registration
1399    /// on approve. `None` = raw-repo session (the original behavior).
1400    #[serde(default, skip_serializing_if = "Option::is_none")]
1401    pub project: Option<String>,
1402    #[serde(default, skip_serializing_if = "Option::is_none")]
1403    pub project_kind: Option<super::project::ProjectKind>,
1404    /// Registered identity an Agent-project edit replaces. `None` means a new
1405    /// agent whose id is derived from the project slug.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub existing_agent_id: Option<String>,
1408    /// The user-authored Agent Builder answers staged for the generated spec.
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
1411    /// For an `Agent` project: the declarative agent spec the build loop
1412    /// produced, stashed so `approve_merge` can register it. Persisted so
1413    /// `coder.get` can show what was built.
1414    #[serde(default, skip_serializing_if = "Option::is_none")]
1415    pub built_agent: Option<car_registry::declarative::DeclarativeAgentSpec>,
1416    /// Present only for Agent-project builds. Additive on `coder.get`; older
1417    /// hosts ignore it and older snapshots deserialize with `None`.
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub agent_build_progress: Option<AgentBuildProgress>,
1420    pub iterations: u32,
1421    pub max_iterations: u32,
1422    /// Metered inference spend, when anything reported it. `None` is
1423    /// **unknown**, not free — the native loop does not meter.
1424    #[serde(default, skip_serializing_if = "Option::is_none")]
1425    pub cost_usd: Option<f64>,
1426    /// Per-session external-engine hypothesis budget. `None` = engine default.
1427    #[serde(default, skip_serializing_if = "Option::is_none")]
1428    pub repair_invokes: Option<u32>,
1429    /// Per-session external-engine availability budget. `None` = engine default.
1430    #[serde(default, skip_serializing_if = "Option::is_none")]
1431    pub transient_retries: Option<u32>,
1432    /// When a session ends `Failed`, keep the throwaway worktree on disk (and
1433    /// its handle in-process) so the operator can inspect it for a postmortem
1434    /// instead of having it reaped on the terminal transition. Sourced from
1435    /// `~/.car/coder.toml` (`keep_workspace_on_failure`); default `false`.
1436    #[serde(default)]
1437    pub keep_workspace_on_failure: bool,
1438    /// Cancellation preserves unfinished work, including a racing failure or restart.
1439    #[serde(default)]
1440    pub keep_workspace_on_cancel: bool,
1441    /// Pin the native loop's inference model (e.g. `"parslee/reasoning"`).
1442    /// `None` = adaptive routing. Sourced from `~/.car/coder.toml` (`model`).
1443    #[serde(default, skip_serializing_if = "Option::is_none")]
1444    pub model: Option<String>,
1445    #[serde(default)]
1446    pub last_check_results: Vec<CheckResult>,
1447    /// The contract's red-green baseline — how each check fared against the
1448    /// **unmodified** worktree (car#707). Stored rather than recomputed because
1449    /// it is part of how the current draft READS: a board renders the contract
1450    /// with its baseline beside it, so a `coder.revise_contract` that could not
1451    /// be honored has to hand back both, or the contract it swore was unchanged
1452    /// visibly changes anyway when the baseline blanks out.
1453    #[serde(default)]
1454    pub baseline: Vec<CheckResult>,
1455    /// Whether every baseline check already passed — i.e. the contract gates
1456    /// nothing for this task. Travels with `baseline` for the same reason.
1457    #[serde(default)]
1458    pub baseline_gates_nothing: bool,
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub result_branch: Option<String>,
1461    /// Immutable delivered revision for conversational follow-up. Branch names
1462    /// can move; never infer this value from a branch during recovery.
1463    #[serde(default, skip_serializing_if = "Option::is_none")]
1464    pub result_commit: Option<String>,
1465    /// Checkout identity captured before task execution; required for local
1466    /// delivery. `None` whenever the worktree does NOT start from the
1467    /// checkout's HEAD (an explicit `base`, a follow-up on a prior branch
1468    /// delivery, a continuation whose checkout has since moved) — a patch
1469    /// computed against a tree the checkout does not have would apply
1470    /// cleanly and leave it holding this task's changes without the base's.
1471    #[serde(default, skip_serializing_if = "Option::is_none")]
1472    pub checkout_identity: Option<super::merge::CheckoutIdentity>,
1473    /// The private `refs/car/coder-inputs/<id>` commit this task starts from,
1474    /// when it was started from a dirty checkout (carried forward by a
1475    /// continuation of the same worktree). Its tree holds the user's
1476    /// uncommitted work, so branch delivery must rebuild its commit on the
1477    /// checkout's HEAD rather than publishing that snapshot's contents.
1478    #[serde(default, skip_serializing_if = "Option::is_none")]
1479    pub inputs_snapshot: Option<String>,
1480    #[serde(default, skip_serializing_if = "Option::is_none")]
1481    pub result_delivery: Option<String>,
1482    // NOTE: there is deliberately no persisted `needs_you` here. It only ever
1483    // stored `"contract"` / `"approval"` — exactly what `needs_you_from` already
1484    // derives from `state` — and a non-live session is now reported as not
1485    // actionable regardless, so the field earned nothing and is gone.
1486    /// Why a `failed` session failed, as a machine-readable kind:
1487    /// `"budget_exhausted"` | `"auth_required"` | `"configuration"` |
1488    /// `"infrastructure"` | `"stalled"` | `"error"`. Persisted so a summary read from disk
1489    /// after a daemon restart still distinguishes "ran out of clock" from
1490    /// "nobody signed in" from "routing excludes every model" from "the
1491    /// machinery broke" from "the work was judged and rejected" — a live-only
1492    /// derivation would go blank exactly when the operator comes back to look.
1493    ///
1494    /// `"infrastructure"` is the one a scorer must act on: nothing was judged,
1495    /// so the session is not a task loss. See `rpc::failure_kind_for` for how it
1496    /// is chosen and why it is not folded into `"error"`.
1497    #[serde(default, skip_serializing_if = "Option::is_none")]
1498    pub failure_kind: Option<String>,
1499    /// The `coder.discuss` conversation this run was distilled from, when the
1500    /// operator went through a discussion. Provenance only — the run itself is
1501    /// independent of the discussion's lifetime.
1502    #[serde(default, skip_serializing_if = "Option::is_none")]
1503    pub discussion_id: Option<String>,
1504    /// Constraints captured when the task starts; revisions must recheck them.
1505    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1506    pub discussion_constraints: Vec<String>,
1507    /// Accepted guidance, retained with unfinished work. Acceptance does not
1508    /// claim the loop applied it before cancellation or failure.
1509    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1510    pub steering_messages: Vec<String>,
1511    /// Prior task whose retained native worktree this attempt continues.
1512    #[serde(default, skip_serializing_if = "Option::is_none")]
1513    pub resumed_from: Option<String>,
1514    /// Set after the native execution loop returns or cancellation joins it. A crash
1515    /// orphan does not establish this and cannot be automatically reopened.
1516    #[serde(default)]
1517    pub execution_stopped: bool,
1518    /// Identity of the staged result shown at review. Absent on older snapshots
1519    /// and no-change findings. Does not by itself restore a live approval gate.
1520    #[serde(default, skip_serializing_if = "Option::is_none")]
1521    pub review_identity: Option<super::merge::ReviewIdentity>,
1522    /// Next event sequence reserved by a durable state transition. Restored
1523    /// review events must not collide with events shown before the restart.
1524    #[serde(default)]
1525    pub event_cursor: u64,
1526    /// Review-only restoration; prior activity is unavailable and checks are
1527    /// historical. No execution loop is restarted by opening this task.
1528    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1529    pub review_restored: bool,
1530    /// The commit the worktree was provisioned at, when the caller named one
1531    /// (`coder.start { base }`) instead of taking the repository's `HEAD`.
1532    /// Resolved to a full SHA before provisioning and persisted in the session
1533    /// snapshot, so it names exactly what the session started from even after
1534    /// the ref it came from moves. Reported on the `coder.start` reply.
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub base: Option<String>,
1537    /// The "no code should change" finding this session reached, if any:
1538    /// nominated by the model through `report_no_change`, or observed by the
1539    /// runtime when the run finished green with an unchanged worktree.
1540    /// `verification` is `None` while it waits at the approval gate and
1541    /// `HumanApproved` once `coder.approve_merge` accepted it.
1542    #[serde(default, skip_serializing_if = "Option::is_none")]
1543    pub no_change_finding: Option<NoChangeFinding>,
1544    /// The commit the worktree was at the moment it was provisioned — before
1545    /// the contract baseline ran any check in it. A no-change conclusion is
1546    /// judged against this, so a check or a model that commits inside the
1547    /// worktree cannot pass the result off as an untouched tree. `None` for a
1548    /// worktree this session did not provision (a reopened one), which makes
1549    /// every no-change conclusion fail closed.
1550    #[serde(default, skip_serializing_if = "Option::is_none")]
1551    pub start_commit: Option<String>,
1552    /// Whether this session explicitly opted into the assistant's browser tool
1553    /// surface. False by default and persisted so contract review cannot change
1554    /// which tools the later confirmed run receives.
1555    #[serde(default)]
1556    pub browser: bool,
1557    /// Farm subtasks across reachable CAR instances rather than this machine
1558    /// alone. Only the `foreman` engine reads it.
1559    ///
1560    /// Persisted, and reported on the `coder.list` row, so a finished run can
1561    /// say which way it ran. That is the whole reason — a session does NOT
1562    /// resume across a daemon restart (`adopt_orphaned_sessions` rewrites every
1563    /// non-terminal orphan to `Failed`), so this is not protecting a resumed
1564    /// run from silently becoming local.
1565    #[serde(default)]
1566    pub distributed: bool,
1567    /// Instances a distributed run is restricted to. Empty = every instance
1568    /// that can serve the repository.
1569    ///
1570    /// The operator's FILTER, not the resolved pool — see [`Self::pool_workers`]
1571    /// for what the run actually got.
1572    #[serde(default)]
1573    pub workers: Vec<String>,
1574    /// The peers the pool actually resolved to, recorded when it is built.
1575    ///
1576    /// `workers` above is what the operator asked to restrict to; this is what
1577    /// answering that restriction against reachability, enrollment and repo
1578    /// eligibility produced. It was emitted as a `foreman: "pool"` event and
1579    /// then dropped, so a subscriber that was not attached saw nothing and the
1580    /// snapshot carried no record at all.
1581    ///
1582    /// Written BEFORE any subtask runs, which is what makes it the durable
1583    /// answer to "which machines was this farmed to?". [`Self::placements`]
1584    /// cannot answer it on its own: a placement is recorded when a worker
1585    /// RETURNS, and foreman runs a level under `join_all` rather than spawning,
1586    /// so `coder.cancel`'s abort drops every in-flight future before it records
1587    /// — the subtasks running at the moment an operator gives up are exactly
1588    /// the ones the ledger omits (car#1346). Empty on a local run.
1589    #[serde(default)]
1590    pub pool_workers: Vec<String>,
1591    /// Where each of a distributed run's subtasks actually ran, including the
1592    /// workers that failed it first.
1593    ///
1594    /// Empty for a local run, and for a distributed one that fell off the
1595    /// foreman rung before farming anything out.
1596    ///
1597    /// **Not a proxy for "a distributed run completed."** A cancelled run
1598    /// carries this populated with [`Self::integrated_subtasks`] empty, and so
1599    /// does a run whose foreman union the gate rejected — three different
1600    /// states, separated by `state` and by nothing else. And it is a floor, not
1601    /// a census: subtasks still in flight at a cancel never reach it, which is
1602    /// what [`Self::pool_workers`] is for.
1603    ///
1604    /// `foreman.run` has reported this all along; the pipeline that DELIVERS had
1605    /// strictly less provenance than the one that only reports, because
1606    /// `fleet_pool_for` erased `FleetPool` to `Arc<dyn WorktreeAgent>` and
1607    /// `placements()` is on the concrete type (car#1322). CAR's position is that
1608    /// receipts decide completion, and a delivered commit whose hunks were
1609    /// authored on unnamed machines is the wrong artifact for that claim — the
1610    /// question arrives the first time a distributed run produces something
1611    /// surprising.
1612    #[serde(default)]
1613    pub placements: Vec<car_multi::Placement>,
1614    /// Which of those subtasks actually LANDED in the worktree, and what each
1615    /// wrote. The diagnostic record above says what happened; this says what is
1616    /// in the tree, and only this may back a claim in the delivered commit.
1617    #[serde(default)]
1618    pub integrated_subtasks: Vec<IntegratedSubtask>,
1619    /// Foreman's union was integrated and then the native loop repaired on top
1620    /// of it, so some delivered hunks were written locally by no listed worker.
1621    ///
1622    /// Without this the commit body would credit the fleet for a diff it only
1623    /// partly wrote — the same false attribution as crediting it for one it did
1624    /// not write at all, in a milder form.
1625    #[serde(default)]
1626    pub repaired_locally: bool,
1627    /// Every model that AUTHORED part of this session's work, as opposed to
1628    /// [`Self::model`], which is the pin the caller asked for.
1629    ///
1630    /// They differ exactly when it matters: unpinned, the router chooses, and
1631    /// on a machine with one reachable credential that choice can also be a
1632    /// review-panel seat — the self-review the gate refuses when a coder is
1633    /// pinned, permitted by default because nothing knew who wrote the change
1634    /// (car#1299).
1635    ///
1636    /// A projection of the journal ([`EventSink::authoring_models`]), not a
1637    /// second record of the same fact — two records can disagree and then the
1638    /// question is which one the gate believes. Empty for a session with no
1639    /// native turns (foreman/external), which is the DEFAULT engine: read it as
1640    /// "CAR's own loop did not write this", not "nobody did".
1641    #[serde(default)]
1642    pub authored_by: Vec<String>,
1643    pub created_at: u64,
1644    pub updated_at: u64,
1645    #[serde(default, skip_serializing_if = "Option::is_none")]
1646    pub error: Option<String>,
1647    /// RAII worktree handle. Dropping it removes the worktree, so terminal
1648    /// transitions release it explicitly.
1649    #[serde(skip)]
1650    pub workspace: Option<AgentWorkspace>,
1651    /// Where snapshots/journals/worktrees go; `None` disables persistence.
1652    #[serde(skip)]
1653    pub state_dir: Option<PathBuf>,
1654}
1655
1656impl CoderSession {
1657    /// The execution request includes user requirements even when generated
1658    /// checks do not cover them. Keep the concise original intent for labels.
1659    pub fn execution_intent(&self) -> String {
1660        let mut intent = self.intent.clone();
1661        if !self.discussion_constraints.is_empty() {
1662            intent.push_str("\n\nUser constraints carried from the conversation:\n");
1663            for constraint in &self.discussion_constraints {
1664                intent.push_str(&format!("- {constraint}\n"));
1665            }
1666        }
1667        if !self.steering_messages.is_empty() {
1668            intent.push_str(
1669                "\nAccepted user guidance (in order; later corrections take precedence):\n",
1670            );
1671            for guidance in &self.steering_messages {
1672                intent.push_str(&format!("- {guidance}\n"));
1673            }
1674        }
1675        intent
1676    }
1677
1678    pub fn new(
1679        repo: impl Into<PathBuf>,
1680        intent: impl Into<String>,
1681        engine: EngineChoice,
1682        max_iterations: u32,
1683        state_dir: Option<PathBuf>,
1684    ) -> Self {
1685        let now = now_secs();
1686        Self {
1687            id: format!("coder-{}", uuid::Uuid::new_v4().simple()),
1688            repo: repo.into(),
1689            intent: intent.into(),
1690            engine,
1691            // Set by `coder.start` from the caller's raw `EngineChoice`; the
1692            // constructor only sees the resolved one, so it cannot fill this in.
1693            requested_engine: None,
1694            engine_ran: None,
1695            state: CoderState::Created,
1696            contract: None,
1697            cost_usd: None,
1698            repair_invokes: None,
1699            transient_retries: None,
1700            workspace_path: None,
1701            project: None,
1702            project_kind: None,
1703            existing_agent_id: None,
1704            builder_draft: None,
1705            built_agent: None,
1706            agent_build_progress: None,
1707            iterations: 0,
1708            max_iterations: max_iterations.max(1),
1709            keep_workspace_on_failure: false,
1710            keep_workspace_on_cancel: false,
1711            model: None,
1712            last_check_results: Vec::new(),
1713            baseline: Vec::new(),
1714            baseline_gates_nothing: false,
1715            browser: false,
1716            distributed: false,
1717            workers: Vec::new(),
1718            pool_workers: Vec::new(),
1719            authored_by: Vec::new(),
1720            placements: Vec::new(),
1721            integrated_subtasks: Vec::new(),
1722            repaired_locally: false,
1723            result_branch: None,
1724            result_commit: None,
1725            checkout_identity: None,
1726            inputs_snapshot: None,
1727            result_delivery: None,
1728            failure_kind: None,
1729            discussion_id: None,
1730            discussion_constraints: Vec::new(),
1731            steering_messages: Vec::new(),
1732            resumed_from: None,
1733            execution_stopped: false,
1734            review_identity: None,
1735            event_cursor: 0,
1736            review_restored: false,
1737            base: None,
1738            no_change_finding: None,
1739            start_commit: None,
1740            created_at: now,
1741            updated_at: now,
1742            error: None,
1743            workspace: None,
1744            state_dir,
1745        }
1746    }
1747
1748    /// Mark this session as working on a managed project (builder so existing
1749    /// call sites and tests stay green).
1750    pub fn with_project(mut self, project: super::project::CoderProject) -> Self {
1751        self.project = Some(project.slug);
1752        self.project_kind = Some(project.kind);
1753        self.existing_agent_id = project.existing_agent_id;
1754        self.builder_draft = project.builder_draft;
1755        self
1756    }
1757
1758    /// Short suffix for branch names and worktree dirs.
1759    pub fn short_id(&self) -> &str {
1760        // "coder-<32 hex>" → last 8 chars are plenty unique per repo.
1761        &self.id[self.id.len().saturating_sub(8)..]
1762    }
1763
1764    /// Provision the throwaway git worktree under the state dir (NOT inside
1765    /// the user's repo, so their `git status` stays clean).
1766    pub fn provision_workspace(&mut self) -> Result<PathBuf, String> {
1767        let state_dir = self
1768            .state_dir
1769            .clone()
1770            .ok_or("session has no state dir; cannot provision a worktree")?;
1771        let mut config = WorkspaceConfig::git_worktree_at(&self.repo, state_dir.join("worktrees"));
1772        if let Some(base) = &self.base {
1773            config = config.with_rev(base.clone());
1774        }
1775        let workspace = AgentWorkspace::provision(&config, &self.id)?;
1776        let path = workspace.path().to_path_buf();
1777        self.start_commit = super::no_change::head_commit(&path);
1778        self.workspace_path = Some(path.clone());
1779        self.workspace = Some(workspace);
1780        Ok(path)
1781    }
1782
1783    /// Validated state transition: updates timestamps, emits `StateChanged`,
1784    /// persists the snapshot, and releases the worktree on terminal states.
1785    pub fn transition(&mut self, to: CoderState, sink: &EventSink) -> Result<(), String> {
1786        if !can_transition(self.state, to) {
1787            return Err(format!(
1788                "illegal coder transition {} → {}",
1789                self.state.as_str(),
1790                to.as_str()
1791            ));
1792        }
1793        let from = self.state;
1794        self.state = to;
1795        self.updated_at = now_secs();
1796        // A finding still pending when the session ends any other way than
1797        // `reported` (cancel, abandon, failure, heal teardown) was never
1798        // decided. Resolve it here, the one place every terminal passes, so no
1799        // snapshot shows a finished session with a finding that looks pending.
1800        if to.is_terminal() && to != CoderState::Reported {
1801            if let Some(finding) = self
1802                .no_change_finding
1803                .as_mut()
1804                .filter(|f| f.resolved_at.is_none())
1805            {
1806                finding.resolved_at = Some(self.updated_at);
1807                finding.resolver_comment.get_or_insert_with(|| {
1808                    format!("session ended {} without a decision", to.as_str())
1809                });
1810            }
1811        }
1812        sink.emit(CoderEventKind::StateChanged {
1813            from: from.as_str().to_string(),
1814            to: to.as_str().to_string(),
1815        });
1816        self.event_cursor = sink.next_sequence();
1817        if to.is_terminal() {
1818            // Drop the RAII handle to remove completed/discarded work. Retain
1819            // unfinished work when `keep_workspace_on_failure` is set (operator config) and the
1820            // terminal state is `Failed`, or an unfinished conversation task
1821            // stops, or cancellation requests preservation. The worktree
1822            // survives on disk for a postmortem. `workspace_path` is always kept
1823            // in the snapshot regardless, so a dropped tree still reports where
1824            // it *was*; with the flag set the tree is actually still there.
1825            let keep_unfinished = (self.discussion_id.is_some() || self.keep_workspace_on_cancel)
1826                && matches!(to, CoderState::Failed | CoderState::Abandoned);
1827            if keep_unfinished || (to == CoderState::Failed && self.keep_workspace_on_failure) {
1828                // Suppress the RAII `Drop` so the git worktree survives on disk
1829                // for a postmortem. The cost is a leaked `git worktree`
1830                // registration in the user's repo; it's reaped on next
1831                // provision (AgentWorkspace::provision self-heals stale entries)
1832                // or by `git worktree prune`. `workspace_path` stays in the
1833                // snapshot so the operator knows exactly where to look.
1834                if let Some(ws) = self.workspace.take() {
1835                    std::mem::forget(ws);
1836                }
1837            } else {
1838                if let Some(workspace) = self.workspace.as_mut() {
1839                    workspace.enable_cleanup();
1840                }
1841                self.workspace = None;
1842            }
1843        }
1844        if let Err(e) = self.persist() {
1845            tracing::warn!(session = %self.id, "coder snapshot persist failed: {e}");
1846        }
1847        Ok(())
1848    }
1849
1850    /// Write the JSON snapshot to `<state_dir>/<id>.json` (no-op without a
1851    /// state dir, e.g. in unit tests).
1852    pub fn persist(&self) -> Result<(), String> {
1853        let Some(dir) = &self.state_dir else {
1854            return Ok(());
1855        };
1856        std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
1857        let path = dir.join(format!("{}.json", self.id));
1858        use std::io::Write;
1859        let json = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?;
1860        let mut file = tempfile::NamedTempFile::new_in(dir)
1861            .map_err(|e| format!("create snapshot in {}: {e}", dir.display()))?;
1862        file.write_all(&json)
1863            .and_then(|_| file.as_file().sync_all())
1864            .map_err(|e| format!("sync {}: {e}", path.display()))?;
1865        file.persist(&path)
1866            .map_err(|e| format!("publish {}: {e}", path.display()))?;
1867        #[cfg(unix)]
1868        std::fs::File::open(dir)
1869            .and_then(|dir| dir.sync_all())
1870            .map_err(|e| format!("sync snapshot directory: {e}"))?;
1871        Ok(())
1872    }
1873
1874    /// Load a snapshot from disk. The worktree handle is NOT restored — a
1875    /// loaded session is history until explicitly admitted for recovery.
1876    pub fn load(path: &Path) -> Result<Self, String> {
1877        let text =
1878            std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
1879        serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
1880    }
1881
1882    /// All persisted sessions under `state_dir`, newest first.
1883    pub fn list(state_dir: &Path) -> Vec<CoderSession> {
1884        let Ok(entries) = std::fs::read_dir(state_dir) else {
1885            return Vec::new();
1886        };
1887        let mut sessions: Vec<CoderSession> = entries
1888            .flatten()
1889            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
1890            .filter_map(|e| Self::load(&e.path()).ok())
1891            .collect();
1892        sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
1893        sessions
1894    }
1895}
1896
1897/// Best-effort unlink. `true` when the file is gone because this call removed
1898/// it — a missing file is not a collection, so it does not count as one.
1899fn unlink(path: &Path) -> bool {
1900    match std::fs::remove_file(path) {
1901        Ok(()) => true,
1902        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1903        Err(e) => {
1904            tracing::warn!(path = %path.display(), "coder retention unlink failed: {e}");
1905            false
1906        }
1907    }
1908}
1909
1910/// Remove a test fixture's retained worktree and its Git registration.
1911#[cfg(test)]
1912fn reap_worktree(repo: &Path, path: &Path) {
1913    if !path.is_dir() {
1914        return;
1915    }
1916    let _ = std::process::Command::new("git")
1917        .arg("-C")
1918        .arg(repo)
1919        .args(["worktree", "remove", "--force"])
1920        .arg(path)
1921        .output();
1922    if let Err(e) = std::fs::remove_dir_all(path) {
1923        if e.kind() != std::io::ErrorKind::NotFound {
1924            tracing::warn!(path = %path.display(), "could not reap a stranded coder worktree: {e}");
1925        }
1926    }
1927}
1928
1929/// One subtask whose patch reached the session worktree, and the files it wrote.
1930///
1931/// Distinct from a [`car_multi::Placement`], which records that a worker RAN a
1932/// subtask — recorded when the worker returns, before the per-patch gate rules
1933/// on what it produced. Only this says what is in the delivered tree, which is
1934/// the claim a commit body makes (car#1322).
1935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1936pub struct IntegratedSubtask {
1937    pub subtask_id: String,
1938    /// Repo-relative paths the subtask's patch touched, from the gate's own
1939    /// parser. A subtask id is opaque model output; these are what make the row
1940    /// reviewable.
1941    #[serde(default)]
1942    pub files: Vec<String>,
1943}
1944
1945/// Retention policy for the coder state dir, from `~/.car/coder.toml`.
1946///
1947/// Mirrors [`RunStore`](crate::run_store::RunStore)'s `[runs]` caps, which
1948/// solve this exact shape for run traces: a count cap, an age cap, and an
1949/// exemption for work that is not finished.
1950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1951pub struct SessionRetention {
1952    /// Keep at most this many collectable snapshots. `0` = unlimited.
1953    pub max_sessions: usize,
1954    /// Drop a collectable snapshot older than this. `0` = unlimited.
1955    pub max_age_days: u64,
1956}
1957
1958/// Delete session snapshots (and their journals) beyond the retention caps.
1959///
1960/// Nothing pruned this directory before car#1310, so `<id>.json` and the
1961/// larger `<id>.events.jsonl` beside it accumulated for the life of the
1962/// installation — and `coder.list` pays a read, a `serde_json` parse and a
1963/// `stat` for every one of them on every call. car#1262 bounded the in-memory
1964/// registry and explicitly left this alone; this is the disk arm.
1965///
1966/// **Two exemptions, both about not destroying the only record of something
1967/// that still exists:**
1968///
1969/// - A session that is not [terminal](CoderState::is_terminal) is never
1970///   collected. Same rule as `RunStore`'s in-progress exemption. Note this
1971///   covers `NeedsApproval`, which is deliberately non-terminal — a snapshot
1972///   waiting on a human is not garbage however old it is.
1973/// - A session whose `workspace_path` is still a directory is never collected.
1974///   The snapshot is the only thing that names that worktree; deleting it
1975///   turns a directory an operator kept (`keep_workspace_on_failure`) or a
1976///   preserved `needs_approval` orphan into an unattributable leak.
1977///
1978/// **The count cap ranks only COLLECTABLE sessions**, matching `RunStore`'s
1979/// `completed_rank`: an exempt session neither dies nor consumes a keeper slot.
1980/// So `max_sessions` is a bound on what retention manages, NOT on the size of
1981/// the directory — an install that sets `keep_workspace_on_failure` holds every
1982/// failed session's snapshot, journal AND worktree on top of the cap, by the
1983/// operator's own request. Restart-orphaned worktrees are also retained: a
1984/// crash must not erase unfinished edits merely to satisfy history caps.
1985/// Once a retained worktree is explicitly removed, its history is collectable.
1986///
1987/// The age cap reads `updated_at`, which is stamped on every transition, so it
1988/// measures time since the session last did anything rather than since it
1989/// started.
1990///
1991/// The journal is unlinked BEFORE the snapshot, and the collection is counted
1992/// on the snapshot. Only `*.json` is enumerated, so a journal whose snapshot is
1993/// already gone is invisible to the candidate pass — removing the snapshot
1994/// first would strand the larger file permanently on any unlink error. (The
1995/// pass at the end sweeps journals already stranded that way, including by a
1996/// `coder.start` that created the sink and died before its first `persist`.)
1997///
1998/// Best-effort: an unreadable snapshot is skipped, and a failed unlink is
1999/// logged rather than propagated — this runs at boot and must not block it.
2000/// Returns the number of sessions collected.
2001/// Which process state a [`gc_sessions`] sweep is running in.
2002///
2003/// Not an `Option<&HashSet>`: that carries two orthogonal bits in one type and
2004/// only one of them is about the set. `None` would have to mean BOTH "filter
2005/// nothing" AND "sweep orphan journals" — so a caller with no live set to hand
2006/// over, the natural reading of `None`, would silently re-enable a deletion
2007/// pass that is safe only where no live sink can own a journal.
2008pub enum SweepScope<'a> {
2009    /// Daemon construction, where the session registry is provably empty. Also
2010    /// sweeps orphan journals, which is safe only here.
2011    Boot,
2012    /// Mid-lifetime, carrying the ids the in-process registry still holds.
2013    Live(&'a std::collections::HashSet<String>),
2014}
2015
2016pub fn gc_sessions(state_dir: &Path, retention: &SessionRetention, scope: SweepScope<'_>) -> usize {
2017    match gc_sessions_with_age_floor(state_dir, retention, scope, 0) {
2018        Ok(collected) => collected,
2019        Err(error) => {
2020            tracing::warn!(state_dir = %state_dir.display(), "coder retention sweep skipped: {error}");
2021            0
2022        }
2023    }
2024}
2025
2026/// [`gc_sessions`] with a minimum age for every deletion candidate.
2027///
2028/// The age floor is a cross-process safety boundary, not a retention setting:
2029/// a terminal snapshot younger than `min_age_secs` is excluded before either
2030/// the count or age cap is evaluated. Every sweep takes an exclusive advisory
2031/// lock on `<state_dir>/.sweep.lock`, so daemon and CLI sweepers cannot classify
2032/// and unlink the same state concurrently.
2033///
2034/// Unlike [`gc_sessions`], lock/open failures are returned so an entry point
2035/// adding a second deleting process can refuse to proceed rather than silently
2036/// running without the coordination it promised.
2037pub fn gc_sessions_with_age_floor(
2038    state_dir: &Path,
2039    retention: &SessionRetention,
2040    scope: SweepScope<'_>,
2041    min_age_secs: u64,
2042) -> Result<usize, String> {
2043    let lock_path = state_dir.join(".sweep.lock");
2044    let lock = std::fs::OpenOptions::new()
2045        .create(true)
2046        .read(true)
2047        .write(true)
2048        .truncate(false)
2049        .open(&lock_path)
2050        .map_err(|error| format!("open {}: {error}", lock_path.display()))?;
2051    lock.lock()
2052        .map_err(|error| format!("lock {}: {error}", lock_path.display()))?;
2053
2054    let Ok(entries) = std::fs::read_dir(state_dir) else {
2055        return Ok(0);
2056    };
2057    let sweep_started_at = now_secs();
2058    // The DirEntry's own path, not one rebuilt from the parsed id: those can
2059    // disagree (a `foo.bak.json` copy names the id inside it), and a deleter
2060    // must remove what it classified.
2061    let mut candidates: Vec<(u64, PathBuf)> = Vec::new();
2062    let mut snapshots: Vec<PathBuf> = Vec::new();
2063    let mut journals: Vec<PathBuf> = Vec::new();
2064    for entry in entries.flatten() {
2065        let path = entry.path();
2066        if path.to_string_lossy().ends_with(".events.jsonl") {
2067            journals.push(path);
2068            continue;
2069        }
2070        if path.extension().is_none_or(|x| x != "json") {
2071            continue;
2072        }
2073        snapshots.push(path.clone());
2074        let Ok(session) = CoderSession::load(&path) else {
2075            continue;
2076        };
2077        if !session.state.is_terminal() {
2078            continue;
2079        }
2080        // `is_dir`, the same predicate `adopt_orphaned_sessions` asks this
2081        // question with. A stray FILE at a worktree path is not a worktree, and
2082        // exempting a session forever over one would be the leak this guards
2083        // against, arrived at backwards.
2084        if session.workspace_path.as_ref().is_some_and(|p| p.is_dir()) {
2085            continue;
2086        }
2087        // Never collect a snapshot the in-memory registry still holds an entry
2088        // for. `prune_finished_sessions` treats "snapshot missing on disk" as
2089        // "keep the entry rather than lose the session", so deleting one out
2090        // from under a live entry converts it into a permanent memory pin —
2091        // re-opening the leak car#1262 closed, through the door that was added
2092        // to bound the disk (car#1339). Terminal-and-registered is the ordinary
2093        // window between a loop finishing and the next `coder.start` pruning it.
2094        if matches!(&scope, SweepScope::Live(ids) if ids.contains(&session.id)) {
2095            continue;
2096        }
2097        // A second process cannot see this process's live-id registry. Its
2098        // recently-terminal snapshot is therefore protected by time: exclude
2099        // it before count ranking as well as age retention, or a tight count
2100        // cap could delete a run that finished while its owner was still
2101        // delivering the result.
2102        if sweep_started_at.saturating_sub(session.updated_at) < min_age_secs {
2103            continue;
2104        }
2105        candidates.push((session.updated_at, path));
2106    }
2107
2108    // Newest first, so the count cap keeps the head and drops the tail.
2109    candidates.sort_by(|a, b| b.0.cmp(&a.0));
2110
2111    let age_cutoff = (retention.max_age_days > 0)
2112        .then(|| now_secs().saturating_sub(retention.max_age_days.saturating_mul(24 * 60 * 60)));
2113
2114    let mut collected = 0;
2115    for (rank, (updated_at, snapshot)) in candidates.iter().enumerate() {
2116        let over_count = retention.max_sessions > 0 && rank >= retention.max_sessions;
2117        let too_old = age_cutoff.is_some_and(|cut| *updated_at < cut);
2118        if !over_count && !too_old {
2119            continue;
2120        }
2121        // Journal first. It is the larger artifact and it is only reachable
2122        // through its snapshot, so removing the snapshot first and then failing
2123        // here would strand it for good.
2124        let journal = snapshot.with_extension("events.jsonl");
2125        unlink(&journal);
2126        if unlink(snapshot) {
2127            snapshots.retain(|p| p != snapshot);
2128            collected += 1;
2129        }
2130    }
2131
2132    // Journals with no snapshot beside them. A `coder.start` creates the
2133    // `EventSink`'s journal before anything calls `persist()`, so a start that
2134    // dies in between leaves one that no candidate pass can ever reach. Safe
2135    // ONLY at boot, where the session registry is empty and no live sink can
2136    // own one — the same invariant `adopt_orphaned_sessions` relies on.
2137    //
2138    // Mid-lifetime the pass is unsafe for a reason the live set cannot fix, and
2139    // it is NOT the ordering inside one `coder.start`: that registers its entry
2140    // before it emits anything, so its own journal does not exist yet. The race
2141    // is with a CONCURRENT start, which registers after this sweep's caller
2142    // snapshotted the live set, then emits and so opens its journal. Its
2143    // journal exists, its snapshot does not, and its id is in no set we hold.
2144    // A snapshot caught that way is saved by carrying a non-terminal state; a
2145    // journal carries no state at all, so nothing can exempt it. Skip the pass
2146    // rather than filter it — which is why the scope is an enum, not the set.
2147    if matches!(scope, SweepScope::Live(_)) {
2148        return Ok(collected);
2149    }
2150    for journal in &journals {
2151        let snapshot = journal
2152            .to_string_lossy()
2153            .strip_suffix(".events.jsonl")
2154            .map(|stem| PathBuf::from(format!("{stem}.json")));
2155        if snapshot.is_some_and(|p| snapshots.contains(&p)) {
2156            continue;
2157        }
2158        unlink(journal);
2159    }
2160    Ok(collected)
2161}
2162
2163/// What [`adopt_orphaned_sessions`] decided about one on-disk snapshot.
2164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2165pub enum AdoptionOutcome {
2166    /// A non-terminal orphan was rewritten to `Failed` ("daemon restarted
2167    /// mid-session"), so `coder.list`/`coder.get` stop reporting it as live.
2168    Failed,
2169    /// A `needs_approval` orphan whose worktree still exists — left untouched
2170    /// so the user can inspect the diff and approve-by-hand. NOT auto-published.
2171    Preserved,
2172}
2173
2174/// Adopt crash/restart-orphaned coder sessions at daemon boot.
2175///
2176/// A daemon restart drops the in-memory `CoderSessionEntry` registry; only the
2177/// JSON snapshot under `state_dir` survives (the worktree under
2178/// `state_dir/worktrees` survives too). Any snapshot left in a **non-terminal**
2179/// state (`created`/`contract_proposed`/`contract_confirmed`/`running`/
2180/// `needs_approval`) therefore has no live loop driving it and would otherwise
2181/// report its stale state — "running" forever — to `coder.list`/`coder.get`.
2182///
2183/// This runs once at [`ServerState`](crate::session::ServerState) construction,
2184/// where the in-memory registry is always empty, so every non-terminal on-disk
2185/// snapshot is necessarily a prior process's orphan (no live writer can race).
2186///
2187/// Policy:
2188/// - A `needs_approval` orphan whose worktree directory **still exists** is
2189///   PRESERVED untouched: the diff is real and the snapshot stays inspectable
2190///   on disk. Adoption itself does not restore a live approval gate. Opening
2191///   the task can separately admit a stopped native review after checking its
2192///   durable review identity and retained workspace. Legacy or unsupported
2193///   snapshots remain available for manual review. We never auto-publish.
2194/// - Every other non-terminal orphan — including `needs_approval` whose
2195///   worktree is gone — is rewritten to `Failed` with
2196///   a restart error and re-persisted. Any surviving worktree is retained,
2197///   regardless of how the task was started, and the error names its path.
2198///   Retention never removes that task's provenance while the worktree exists.
2199///
2200/// Full live re-attach (resuming the loop where it left off) is explicitly OUT
2201/// OF SCOPE: the generator, sink, cancel flag, and RAII worktree handle are all
2202/// process-local and cannot be reconstructed from the snapshot. This only stops
2203/// the snapshots from lying about their state.
2204///
2205/// Best-effort: an unreadable or unwritable snapshot is skipped rather than
2206/// failing startup. Returns one [`AdoptionOutcome`] per snapshot it acted on.
2207pub fn adopt_orphaned_sessions(state_dir: &Path) -> Vec<(String, AdoptionOutcome)> {
2208    let Ok(entries) = std::fs::read_dir(state_dir) else {
2209        return Vec::new();
2210    };
2211    let mut outcomes = Vec::new();
2212    for entry in entries.flatten() {
2213        let path = entry.path();
2214        if path.extension().is_none_or(|x| x != "json") {
2215            continue;
2216        }
2217        let Ok(mut session) = CoderSession::load(&path) else {
2218            continue;
2219        };
2220        if session.state.is_terminal() {
2221            continue;
2222        }
2223        // A live worktree keeps a needs_approval orphan inspectable/approvable.
2224        let worktree_alive = session.state == CoderState::NeedsApproval
2225            && session.workspace_path.as_ref().is_some_and(|p| p.is_dir());
2226        if worktree_alive {
2227            outcomes.push((session.id.clone(), AdoptionOutcome::Preserved));
2228            continue;
2229        }
2230        // A restart is not permission to discard unfinished edits. Preserve
2231        // every surviving worktree, including standalone/legacy tasks without
2232        // a discussion link or an explicit keep flag. Existing retention keeps
2233        // the snapshot while its worktree exists, so its provenance survives.
2234        // Do not set execution_stopped: external child processes may outlive
2235        // the old daemon, and automatic continuation needs separate stop proof.
2236        session.state = CoderState::Failed;
2237        session.error = Some(match session.workspace_path.as_ref().filter(|path| path.is_dir()) {
2238            Some(path) => format!(
2239                "daemon restarted mid-session; unfinished work is preserved at {}. Execution stop is unconfirmed; inspect the retained work before continuing",
2240                path.display()
2241            ),
2242            None => "daemon restarted mid-session".to_string(),
2243        });
2244        // The machinery died, nothing was judged — without this the summary
2245        // defaults to `"error"` and a board renders a restart as work that came
2246        // back red.
2247        session.failure_kind = Some("infrastructure".to_string());
2248        session.updated_at = now_secs();
2249        // load() drops the (serde-skipped) state_dir; restore it so persist()
2250        // writes back to the same snapshot instead of no-op'ing.
2251        session.state_dir = Some(state_dir.to_path_buf());
2252        if session.persist().is_ok() {
2253            outcomes.push((session.id.clone(), AdoptionOutcome::Failed));
2254        }
2255    }
2256    outcomes
2257}
2258
2259#[cfg(test)]
2260mod tests {
2261    use super::*;
2262
2263    /// A run that degrades TWICE has two facts, and the journal must hold
2264    /// both.
2265    ///
2266    /// This is the half car#1333 did not cover. `models_served` answers WHO
2267    /// wrote each turn; this answers WHY the backbone moved, and the two are
2268    /// different facts rather than one recorded twice. Without it, mining and
2269    /// humans alike read a degraded run as the code under test behaving badly.
2270    ///
2271    /// Every transition, deliberately. The live `ModelFallback` event is behind
2272    /// a once-per-phase latch so the stream does not narrate every routing
2273    /// decision — right for a stream, wrong for a record. Journaling off the
2274    /// latched emit would keep only the first.
2275    #[test]
2276    fn every_backbone_change_is_journaled_not_just_the_first() {
2277        let dir = tempfile::tempdir().unwrap();
2278        let journal = dir.path().join("coder-mf.events.jsonl");
2279        let sink = EventSink::new("coder-mf", None, Some(journal.clone()));
2280
2281        sink.record_model_fallback("parslee/reasoning", "openai/gpt-5.6", "rate_limited");
2282        sink.record_model_fallback("openai/gpt-5.6", "anthropic/claude-opus-5", "timed_out");
2283        // JournalWriter hands lines to a background thread; Drop flushes and
2284        // joins, and durability is the whole claim here.
2285        drop(sink);
2286
2287        let body = std::fs::read_to_string(&journal).expect("the journal exists");
2288        let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect();
2289        assert_eq!(
2290            lines.len(),
2291            2,
2292            "both transitions, not just the first: {body}"
2293        );
2294
2295        // The reason is the point — a degrade with no cause recorded is the
2296        // gap this closes, and the two causes need different responses.
2297        assert!(body.contains("rate_limited"), "{body}");
2298        assert!(body.contains("timed_out"), "{body}");
2299        // And the chain is readable end to end: the second hop starts where
2300        // the first landed.
2301        assert!(body.contains("parslee/reasoning"), "{body}");
2302        assert!(body.contains("anthropic/claude-opus-5"), "{body}");
2303    }
2304
2305    #[test]
2306    fn coder_journal_is_diagnosable_per_tool() {
2307        // Proves the coder-A/B loop's premise: the coder's action journal is a
2308        // real car_eventlog log whose failures `harness_adapt::diagnose` can
2309        // attribute PER TOOL (not lumped under the session id).
2310        let dir = tempfile::tempdir().unwrap();
2311        let journal = dir.path().join("coder-x.events.jsonl");
2312        let sink = EventSink::new("coder-x", None, Some(journal.clone()));
2313        // The same tool fails twice → a diagnosable pattern at min=2.
2314        for _ in 0..2 {
2315            sink.emit(CoderEventKind::ToolResult {
2316                tool: "run_command".into(),
2317                ok: false,
2318                preview: "exit 1 at runtime".into(),
2319            });
2320        }
2321        // A successful tool must not create a failure pattern.
2322        sink.emit(CoderEventKind::ToolResult {
2323            tool: "edit_file".into(),
2324            ok: true,
2325            preview: "ok".into(),
2326        });
2327        // The journal writer runs on its own thread and flushes on drain/drop, so
2328        // drop the sink before reading — otherwise the read races the async write.
2329        drop(sink);
2330        let jsonl = std::fs::read_to_string(&journal).unwrap();
2331        let report = car_eventlog::harness_adapt::diagnose_from_jsonl(&jsonl, 2);
2332        assert!(
2333            report
2334                .interventions
2335                .iter()
2336                .any(|i| i.target == "run_command"),
2337            "diagnose must tally run_command failures per-tool: {:?}",
2338            report.interventions
2339        );
2340        assert!(
2341            !report.interventions.iter().any(|i| i.target == "edit_file"),
2342            "a succeeding tool must not be flagged"
2343        );
2344    }
2345
2346    fn session() -> (CoderSession, EventSink) {
2347        (
2348            CoderSession::new("/tmp/repo", "do it", EngineChoice::Native, 8, None),
2349            EventSink::test_sink(),
2350        )
2351    }
2352
2353    fn init_repo(dir: &Path) {
2354        for args in [
2355            vec!["init", "-q", "-b", "main"],
2356            vec![
2357                "-c",
2358                "user.name=t",
2359                "-c",
2360                "user.email=t@t",
2361                "commit",
2362                "-q",
2363                "--allow-empty",
2364                "-m",
2365                "init",
2366            ],
2367        ] {
2368            let out = std::process::Command::new("git")
2369                .arg("-C")
2370                .arg(dir)
2371                .args(&args)
2372                .output()
2373                .unwrap();
2374            assert!(
2375                out.status.success(),
2376                "{}",
2377                String::from_utf8_lossy(&out.stderr)
2378            );
2379        }
2380    }
2381
2382    /// `keep_workspace_on_failure = true`: a Failed terminal transition leaves
2383    /// the git worktree on disk (RAII drop suppressed) for a postmortem; the
2384    /// snapshot still records its path.
2385    #[test]
2386    fn failed_with_keep_flag_retains_worktree() {
2387        let repo = tempfile::tempdir().unwrap();
2388        init_repo(repo.path());
2389        let state_dir = tempfile::tempdir().unwrap();
2390
2391        let mut s = CoderSession::new(
2392            repo.path(),
2393            "x",
2394            EngineChoice::Native,
2395            2,
2396            Some(state_dir.path().to_path_buf()),
2397        );
2398        s.keep_workspace_on_failure = true;
2399        let worktree = s.provision_workspace().unwrap();
2400        assert!(worktree.is_dir());
2401
2402        let sink = EventSink::test_sink();
2403        s.transition(CoderState::Failed, &sink).unwrap();
2404        // Handle taken out of the session, but Drop suppressed → tree survives.
2405        assert!(s.workspace.is_none());
2406        assert!(
2407            worktree.is_dir(),
2408            "worktree should be retained for postmortem"
2409        );
2410        assert_eq!(s.workspace_path.as_deref(), Some(worktree.as_path()));
2411
2412        // Clean up the leaked worktree registration so the temp repo can drop.
2413        let _ = std::process::Command::new("git")
2414            .arg("-C")
2415            .arg(repo.path())
2416            .args(["worktree", "remove", "--force"])
2417            .arg(&worktree)
2418            .output();
2419    }
2420
2421    #[test]
2422    fn retained_edits_survive_failure_cancellation_and_restart() {
2423        for conversation in [true, false] {
2424            for stop in [Some(CoderState::Failed), Some(CoderState::Abandoned), None] {
2425                let repo = tempfile::tempdir().unwrap();
2426                init_repo(repo.path());
2427                let state_dir = tempfile::tempdir().unwrap();
2428                let mut session = CoderSession::new(
2429                    repo.path(),
2430                    "unfinished edit",
2431                    EngineChoice::Native,
2432                    2,
2433                    Some(state_dir.path().into()),
2434                );
2435                if conversation {
2436                    session.discussion_id = Some("conversation".into());
2437                } else {
2438                    session.keep_workspace_on_cancel = true;
2439                }
2440                session.state = CoderState::Running;
2441                let path = session.provision_workspace().unwrap();
2442                std::fs::write(path.join("unfinished.txt"), "retain this edit").unwrap();
2443                if let Some(stop) = stop {
2444                    session.transition(stop, &EventSink::test_sink()).unwrap();
2445                } else {
2446                    session.persist().unwrap();
2447                    // Simulate process loss without RAII cleanup.
2448                    std::mem::forget(session.workspace.take().unwrap());
2449                    adopt_orphaned_sessions(state_dir.path());
2450                }
2451                let saved =
2452                    CoderSession::load(&state_dir.path().join(format!("{}.json", session.id)))
2453                        .unwrap();
2454                assert!(saved.state.is_terminal());
2455                assert_eq!(saved.workspace_path.as_deref(), Some(path.as_path()));
2456                assert_eq!(
2457                    std::fs::read_to_string(path.join("unfinished.txt")).unwrap(),
2458                    "retain this edit"
2459                );
2460                assert!(!repo.path().join("unfinished.txt").exists());
2461                reap_worktree(repo.path(), &path);
2462            }
2463        }
2464    }
2465
2466    /// Default (`keep_workspace_on_failure = false`): a Failed transition reaps
2467    /// the worktree, same as every other terminal state.
2468    #[test]
2469    fn failed_without_keep_flag_reaps_worktree() {
2470        let repo = tempfile::tempdir().unwrap();
2471        init_repo(repo.path());
2472        let state_dir = tempfile::tempdir().unwrap();
2473
2474        let mut s = CoderSession::new(
2475            repo.path(),
2476            "x",
2477            EngineChoice::Native,
2478            2,
2479            Some(state_dir.path().to_path_buf()),
2480        );
2481        // keep_workspace_on_failure defaults to false.
2482        let worktree = s.provision_workspace().unwrap();
2483        assert!(worktree.is_dir());
2484
2485        let sink = EventSink::test_sink();
2486        s.transition(CoderState::Failed, &sink).unwrap();
2487        assert!(s.workspace.is_none());
2488        assert!(
2489            !worktree.exists(),
2490            "worktree should be reaped on failure by default"
2491        );
2492    }
2493
2494    #[test]
2495    fn happy_path_transitions_are_legal() {
2496        let (mut s, sink) = session();
2497        for to in [
2498            CoderState::ContractProposed,
2499            CoderState::ContractProposed, // re-propose
2500            CoderState::ContractConfirmed,
2501            CoderState::Running,
2502            CoderState::NeedsApproval,
2503            CoderState::Merged,
2504        ] {
2505            s.transition(to, &sink).unwrap();
2506        }
2507        assert!(s.state.is_terminal());
2508    }
2509
2510    #[test]
2511    fn illegal_jumps_are_rejected() {
2512        let (mut s, sink) = session();
2513        assert!(s.transition(CoderState::Running, &sink).is_err());
2514        assert!(s.transition(CoderState::Merged, &sink).is_err());
2515        assert!(s.transition(CoderState::NeedsApproval, &sink).is_err());
2516        // State unchanged after rejections.
2517        assert_eq!(s.state, CoderState::Created);
2518    }
2519
2520    #[test]
2521    fn any_non_terminal_state_can_fail_or_abandon() {
2522        for terminal in [CoderState::Failed, CoderState::Abandoned] {
2523            let (mut s, sink) = session();
2524            s.transition(CoderState::ContractProposed, &sink).unwrap();
2525            s.transition(terminal, &sink).unwrap();
2526            // Terminal is sticky.
2527            assert!(s.transition(CoderState::Running, &sink).is_err());
2528            assert!(s.transition(CoderState::Failed, &sink).is_err());
2529        }
2530    }
2531
2532    #[test]
2533    fn event_seq_is_monotonic_and_session_tagged() {
2534        let (sink, collected) = EventSink::collecting("coder-seq");
2535        for _ in 0..5 {
2536            sink.emit(CoderEventKind::PlanText { text: "x".into() });
2537        }
2538        let events = collected.lock().unwrap();
2539        assert_eq!(events.len(), 5);
2540        for (i, e) in events.iter().enumerate() {
2541            assert_eq!(e.seq, i as u64);
2542            assert_eq!(e.session_id, "coder-seq");
2543        }
2544    }
2545
2546    /// Persist one snapshot (and a journal beside it) in a given terminal
2547    /// state, stamped `age_days` old.
2548    fn retained(
2549        state_dir: &Path,
2550        state: CoderState,
2551        age_days: u64,
2552        workspace: Option<PathBuf>,
2553    ) -> String {
2554        let mut s = CoderSession::new(
2555            "/tmp/repo",
2556            "intent",
2557            EngineChoice::Auto,
2558            4,
2559            Some(state_dir.to_path_buf()),
2560        );
2561        s.state = state;
2562        s.workspace_path = workspace;
2563        s.updated_at = now_secs().saturating_sub(age_days * 24 * 60 * 60);
2564        s.persist().unwrap();
2565        std::fs::write(state_dir.join(format!("{}.events.jsonl", s.id)), "{}\n").unwrap();
2566        s.id
2567    }
2568
2569    fn on_disk(state_dir: &Path, id: &str) -> bool {
2570        state_dir.join(format!("{id}.json")).exists()
2571    }
2572
2573    const KEEP_ALL: SessionRetention = SessionRetention {
2574        max_sessions: 0,
2575        max_age_days: 0,
2576    };
2577
2578    #[test]
2579    fn gc_evicts_beyond_the_count_cap_oldest_first() {
2580        let dir = tempfile::tempdir().unwrap();
2581        // Ages 0..5 days; the cap keeps the three most recently updated.
2582        let ids: Vec<String> = (0..5)
2583            .map(|age| retained(dir.path(), CoderState::Merged, age, None))
2584            .collect();
2585
2586        let collected = gc_sessions(
2587            dir.path(),
2588            &SessionRetention {
2589                max_sessions: 3,
2590                ..KEEP_ALL
2591            },
2592            SweepScope::Boot,
2593        );
2594
2595        assert_eq!(collected, 2);
2596        for id in &ids[..3] {
2597            assert!(on_disk(dir.path(), id), "newest three must survive");
2598        }
2599        for id in &ids[3..] {
2600            assert!(!on_disk(dir.path(), id), "oldest two must be collected");
2601        }
2602    }
2603
2604    #[test]
2605    fn gc_evicts_snapshots_past_the_age_cap() {
2606        let dir = tempfile::tempdir().unwrap();
2607        let fresh = retained(dir.path(), CoderState::Reported, 3, None);
2608        let stale = retained(dir.path(), CoderState::Merged, 40, None);
2609
2610        let collected = gc_sessions(
2611            dir.path(),
2612            &SessionRetention {
2613                max_age_days: 30,
2614                ..KEEP_ALL
2615            },
2616            SweepScope::Boot,
2617        );
2618
2619        assert_eq!(collected, 1);
2620        assert!(on_disk(dir.path(), &fresh));
2621        assert!(!on_disk(dir.path(), &stale));
2622    }
2623
2624    /// The two exemptions, and the reason each exists: collecting either would
2625    /// destroy the only record of something that still exists.
2626    #[test]
2627    fn gc_never_collects_an_unfinished_session() {
2628        let dir = tempfile::tempdir().unwrap();
2629        // NeedsApproval is deliberately NOT terminal — a session waiting on a
2630        // human is not garbage however old it is.
2631        for state in [
2632            CoderState::Created,
2633            CoderState::ContractProposed,
2634            CoderState::ContractConfirmed,
2635            CoderState::Running,
2636            CoderState::NeedsApproval,
2637        ] {
2638            // TWO of them, and `max_sessions: 1`. With one the count arm is
2639            // inert (`rank 0 >= 1` is false) and only the age arm is ever
2640            // tested — the exemption could be missing from the count path and
2641            // this would stay green.
2642            let a = retained(dir.path(), state, 9999, None);
2643            let b = retained(dir.path(), state, 9998, None);
2644            let collected = gc_sessions(
2645                dir.path(),
2646                &SessionRetention {
2647                    max_sessions: 1,
2648                    max_age_days: 1,
2649                },
2650                SweepScope::Boot,
2651            );
2652            assert_eq!(collected, 0, "{state:?} must be exempt");
2653            for id in [&a, &b] {
2654                assert!(on_disk(dir.path(), id), "{state:?} must survive");
2655                std::fs::remove_file(dir.path().join(format!("{id}.json"))).unwrap();
2656                std::fs::remove_file(dir.path().join(format!("{id}.events.jsonl"))).unwrap();
2657            }
2658        }
2659    }
2660
2661    /// An exempt session neither dies nor consumes a keeper slot — the
2662    /// `completed_rank` semantics `RunStore` uses, and the reason `max_sessions`
2663    /// is NOT a bound on the size of the directory.
2664    #[test]
2665    fn exempt_sessions_do_not_spend_the_count_budget() {
2666        let dir = tempfile::tempdir().unwrap();
2667        let live = tempfile::tempdir().unwrap();
2668        let waiting = retained(dir.path(), CoderState::NeedsApproval, 500, None);
2669        let kept = retained(
2670            dir.path(),
2671            CoderState::Failed,
2672            500,
2673            Some(live.path().to_path_buf()),
2674        );
2675        let newest = retained(dir.path(), CoderState::Merged, 1, None);
2676        let older = retained(dir.path(), CoderState::Merged, 2, None);
2677
2678        let collected = gc_sessions(
2679            dir.path(),
2680            &SessionRetention {
2681                max_sessions: 1,
2682                max_age_days: 0,
2683            },
2684            SweepScope::Boot,
2685        );
2686
2687        // One collectable session over the cap of one — the exempt pair did not
2688        // fill it.
2689        assert_eq!(collected, 1);
2690        assert!(on_disk(dir.path(), &waiting));
2691        assert!(on_disk(dir.path(), &kept));
2692        assert!(on_disk(dir.path(), &newest));
2693        assert!(!on_disk(dir.path(), &older));
2694        // And the directory holds three sessions under a cap of one, which is
2695        // the documented behaviour rather than an accident.
2696        assert_eq!(CoderSession::list(dir.path()).len(), 3);
2697    }
2698
2699    #[test]
2700    fn a_journal_with_no_snapshot_is_swept() {
2701        let dir = tempfile::tempdir().unwrap();
2702        // `coder.start` opens the sink's journal before anything persists a
2703        // snapshot, so a start that dies in between leaves exactly this — and
2704        // the candidate pass enumerates only `*.json`, so nothing else can ever
2705        // reach it.
2706        let orphan = dir.path().join("coder-died-before-persist.events.jsonl");
2707        std::fs::write(&orphan, "{}\n").unwrap();
2708        let live = retained(dir.path(), CoderState::Merged, 0, None);
2709        let live_journal = dir.path().join(format!("{live}.events.jsonl"));
2710
2711        gc_sessions(dir.path(), &KEEP_ALL, SweepScope::Boot);
2712
2713        assert!(!orphan.exists(), "an unreachable journal must be swept");
2714        assert!(
2715            live_journal.exists(),
2716            "a journal whose snapshot is retained must be left alone"
2717        );
2718    }
2719
2720    /// Restart recovery must preserve edits even for a standalone task with
2721    /// neither a discussion link nor an explicit keep flag. Age retention
2722    /// cannot remove the only record identifying that work.
2723    #[test]
2724    fn adoption_preserves_unfinished_edits_without_claiming_execution_stopped() {
2725        let dir = tempfile::tempdir().unwrap();
2726        let stranded = dir.path().join("worktrees/coder-crashed");
2727        std::fs::create_dir_all(&stranded).unwrap();
2728        let edited = stranded.join("unfinished.txt");
2729        std::fs::write(&edited, "unfinished implementation\n").unwrap();
2730        let id = retained(
2731            dir.path(),
2732            CoderState::Running,
2733            9999,
2734            Some(stranded.clone()),
2735        );
2736        let outcomes = adopt_orphaned_sessions(dir.path());
2737        assert_eq!(outcomes, vec![(id.clone(), AdoptionOutcome::Failed)]);
2738        assert_eq!(
2739            std::fs::read_to_string(&edited).unwrap(),
2740            "unfinished implementation\n"
2741        );
2742        let mut session = CoderSession::load(&dir.path().join(format!("{id}.json"))).unwrap();
2743        assert_eq!(session.state, CoderState::Failed);
2744        assert!(
2745            !session.execution_stopped,
2746            "a restart does not prove child tools stopped"
2747        );
2748        assert!(session
2749            .error
2750            .as_ref()
2751            .unwrap()
2752            .contains(stranded.to_str().unwrap()));
2753        assert!(session
2754            .error
2755            .as_ref()
2756            .unwrap()
2757            .contains("Execution stop is unconfirmed"));
2758        session.updated_at = now_secs().saturating_sub(9999 * 24 * 60 * 60);
2759        session.state_dir = Some(dir.path().to_path_buf());
2760        session.persist().unwrap();
2761        let retention = SessionRetention {
2762            max_sessions: 0,
2763            max_age_days: 30,
2764        };
2765        assert_eq!(gc_sessions(dir.path(), &retention, SweepScope::Boot), 0);
2766        assert!(on_disk(dir.path(), &id));
2767        assert!(edited.exists());
2768        // Once the owner removes the retained worktree, ordinary retention
2769        // can collect its old record again.
2770        std::fs::remove_dir_all(&stranded).unwrap();
2771        assert_eq!(gc_sessions(dir.path(), &retention, SweepScope::Boot), 1);
2772        assert!(!on_disk(dir.path(), &id));
2773    }
2774
2775    /// A mid-lifetime sweep must never collect a snapshot the in-memory
2776    /// registry still holds an entry for.
2777    ///
2778    /// `prune_finished_sessions` reads "snapshot missing on disk" as "keep the
2779    /// entry rather than lose the session". So deleting one out from under a
2780    /// registered entry does not free anything — it converts that entry into a
2781    /// permanent memory pin, reopening the leak car#1262 closed through the
2782    /// door opened to bound the disk (car#1339). Terminal-and-still-registered
2783    /// is the ordinary window between a loop finishing and the next
2784    /// `coder.start` pruning it, not a rare race.
2785    #[test]
2786    fn a_mid_lifetime_sweep_never_collects_a_session_the_registry_still_holds() {
2787        let dir = tempfile::tempdir().unwrap();
2788        let mut ids = Vec::new();
2789        for _ in 0..3 {
2790            let mut s = CoderSession::new(
2791                Path::new("/tmp/repo"),
2792                "x",
2793                EngineChoice::Native,
2794                2,
2795                Some(dir.path().to_path_buf()),
2796            );
2797            s.state = CoderState::Merged;
2798            s.updated_at = now_secs().saturating_sub(9999 * 24 * 60 * 60);
2799            s.persist().unwrap();
2800            ids.push(s.id.clone());
2801        }
2802
2803        // Boot form: nothing is live, every one of them is over the age cap.
2804        let retention = SessionRetention {
2805            max_sessions: 0,
2806            max_age_days: 30,
2807        };
2808        let live: std::collections::HashSet<String> = ids.iter().cloned().collect();
2809
2810        let collected = gc_sessions(dir.path(), &retention, SweepScope::Live(&live));
2811        assert_eq!(
2812            collected, 0,
2813            "every id is registered; a mid-lifetime sweep must collect none"
2814        );
2815        for id in &ids {
2816            assert!(
2817                on_disk(dir.path(), id),
2818                "{id} was collected out from under a live entry"
2819            );
2820        }
2821
2822        // Drop one from the registry and it becomes collectable — proving the
2823        // filter is what held it, not some other exemption.
2824        let mut partial = live.clone();
2825        partial.remove(&ids[0]);
2826        let collected = gc_sessions(dir.path(), &retention, SweepScope::Live(&partial));
2827        assert_eq!(collected, 1, "the unregistered one is collectable");
2828        assert!(!on_disk(dir.path(), &ids[0]));
2829        assert!(on_disk(dir.path(), &ids[1]));
2830    }
2831
2832    /// The orphan-journal sweep is boot-only, and the live set cannot make it
2833    /// safe: `coder.start` opens the journal BEFORE inserting the entry, so a
2834    /// start racing a mid-lifetime sweep is journal-without-snapshot AND absent
2835    /// from any set the sweep could be handed.
2836    #[test]
2837    fn a_mid_lifetime_sweep_leaves_orphan_journals_alone() {
2838        let dir = tempfile::tempdir().unwrap();
2839        let journal = dir.path().join("just-started.events.jsonl");
2840        std::fs::write(&journal, "{}\n").unwrap();
2841
2842        let retention = SessionRetention {
2843            max_sessions: 0,
2844            max_age_days: 30,
2845        };
2846        gc_sessions(
2847            dir.path(),
2848            &retention,
2849            SweepScope::Live(&std::collections::HashSet::new()),
2850        );
2851        assert!(
2852            journal.exists(),
2853            "a start that has not persisted yet must keep its journal"
2854        );
2855
2856        // At boot the same file IS garbage — no live sink can own it there.
2857        gc_sessions(dir.path(), &retention, SweepScope::Boot);
2858        assert!(
2859            !journal.exists(),
2860            "boot must still sweep a stranded journal"
2861        );
2862    }
2863
2864    #[test]
2865    fn adoption_keeps_the_worktree_the_operator_asked_to_keep() {
2866        let dir = tempfile::tempdir().unwrap();
2867        let kept = dir.path().join("worktrees").join("coder-postmortem");
2868        std::fs::create_dir_all(&kept).unwrap();
2869
2870        let mut s = CoderSession::new(
2871            "/tmp/repo",
2872            "intent",
2873            EngineChoice::Auto,
2874            4,
2875            Some(dir.path().to_path_buf()),
2876        );
2877        s.state = CoderState::Running;
2878        s.workspace_path = Some(kept.clone());
2879        s.keep_workspace_on_failure = true;
2880        s.persist().unwrap();
2881
2882        adopt_orphaned_sessions(dir.path());
2883
2884        assert!(
2885            kept.is_dir(),
2886            "`keep_workspace_on_failure` is the operator asking for exactly this"
2887        );
2888        // And it stays exempt, which is the point of asking.
2889        assert_eq!(
2890            gc_sessions(
2891                dir.path(),
2892                &SessionRetention {
2893                    max_sessions: 0,
2894                    max_age_days: 1,
2895                },
2896                SweepScope::Boot,
2897            ),
2898            0
2899        );
2900    }
2901
2902    #[test]
2903    fn gc_never_collects_a_session_whose_worktree_still_exists() {
2904        let dir = tempfile::tempdir().unwrap();
2905        let live = tempfile::tempdir().unwrap();
2906        // `keep_workspace_on_failure` left this directory behind; the snapshot
2907        // is the only thing that names it.
2908        let kept = retained(
2909            dir.path(),
2910            CoderState::Failed,
2911            9999,
2912            Some(live.path().to_path_buf()),
2913        );
2914        let reaped = retained(
2915            dir.path(),
2916            CoderState::Failed,
2917            9999,
2918            Some(dir.path().join("worktrees").join("gone")),
2919        );
2920        // A stray FILE at a worktree path is not a worktree. `exists()` would
2921        // exempt this session forever — the leak this guard exists to prevent,
2922        // reached backwards.
2923        let stray = dir.path().join("not-a-worktree");
2924        std::fs::write(&stray, "").unwrap();
2925        let not_a_worktree = retained(dir.path(), CoderState::Failed, 9999, Some(stray));
2926
2927        let collected = gc_sessions(
2928            dir.path(),
2929            &SessionRetention {
2930                max_sessions: 0,
2931                max_age_days: 1,
2932            },
2933            SweepScope::Boot,
2934        );
2935
2936        assert_eq!(collected, 2);
2937        assert!(on_disk(dir.path(), &kept), "a live worktree is not garbage");
2938        assert!(!on_disk(dir.path(), &reaped));
2939        assert!(!on_disk(dir.path(), &not_a_worktree));
2940    }
2941
2942    #[test]
2943    fn gc_takes_the_journal_with_the_snapshot() {
2944        let dir = tempfile::tempdir().unwrap();
2945        let id = retained(dir.path(), CoderState::Merged, 99, None);
2946        let journal = dir.path().join(format!("{id}.events.jsonl"));
2947        assert!(journal.exists());
2948
2949        gc_sessions(
2950            dir.path(),
2951            &SessionRetention {
2952                max_age_days: 30,
2953                ..KEEP_ALL
2954            },
2955            SweepScope::Boot,
2956        );
2957
2958        assert!(!on_disk(dir.path(), &id));
2959        assert!(
2960            !journal.exists(),
2961            "the journal is the larger artifact; retaining it alone keeps the \
2962             bytes and drops the index that explains them"
2963        );
2964    }
2965
2966    #[test]
2967    fn gc_with_both_caps_disabled_keeps_everything() {
2968        let dir = tempfile::tempdir().unwrap();
2969        let ids: Vec<String> = (0..4)
2970            .map(|i| retained(dir.path(), CoderState::Merged, i * 1000, None))
2971            .collect();
2972
2973        assert_eq!(gc_sessions(dir.path(), &KEEP_ALL, SweepScope::Boot), 0);
2974        for id in &ids {
2975            assert!(on_disk(dir.path(), id));
2976        }
2977    }
2978
2979    #[test]
2980    fn gc_on_a_missing_state_dir_is_not_an_error() {
2981        let dir = tempfile::tempdir().unwrap();
2982        let missing = dir.path().join("never-created");
2983        assert_eq!(
2984            gc_sessions(
2985                &missing,
2986                &SessionRetention {
2987                    max_sessions: 1,
2988                    max_age_days: 1
2989                },
2990                SweepScope::Boot,
2991            ),
2992            0
2993        );
2994    }
2995
2996    #[test]
2997    fn authoring_models_are_every_distinct_model_that_completed_a_turn() {
2998        let dir = tempfile::tempdir().unwrap();
2999        let sink = EventSink::new(
3000            "coder-authors",
3001            None,
3002            Some(dir.path().join("coder-authors.events.jsonl")),
3003        );
3004
3005        // `TurnCompleted` is a PER-ITERATION terminal and an unpinned session
3006        // routes per request, so a reviewer judging the accumulated diff has
3007        // to be checked against all of them. Taking the last would clear the
3008        // model that wrote most of this change.
3009        sink.record_turn_completed("empty_tool_calls", None, false, 3, "model-a", &[]);
3010        sink.record_turn_completed("empty_tool_calls", None, false, 5, "model-b", &[]);
3011        sink.record_turn_completed("max_turns", None, false, 9, "model-a", &[]);
3012        // Neither blank nor whitespace is an attribution.
3013        sink.record_turn_completed("empty_tool_calls", None, false, 1, "   ", &[]);
3014
3015        assert_eq!(sink.authoring_models(), vec!["model-a", "model-b"]);
3016    }
3017
3018    /// A model that wrote turns inside an iteration another model FINISHED.
3019    ///
3020    /// The terminal names only whoever reached it, so before car#1333 this
3021    /// model left no record anywhere — and could then sit on the review panel
3022    /// judging a change it had largely written, which is exactly the collision
3023    /// the gate exists to refuse.
3024    #[test]
3025    fn a_model_replaced_before_the_terminal_is_still_an_author() {
3026        let dir = tempfile::tempdir().unwrap();
3027        let sink = EventSink::new(
3028            "coder-midturn",
3029            None,
3030            Some(dir.path().join("coder-midturn.events.jsonl")),
3031        );
3032
3033        // `writer` served turns 1-3; the chain degraded and `finisher` declared
3034        // done. Only `finisher` reaches the terminal.
3035        sink.record_turn_completed(
3036            "empty_tool_calls",
3037            None,
3038            false,
3039            4,
3040            "finisher",
3041            &["writer".to_string(), "finisher".to_string()],
3042        );
3043
3044        let authors = sink.authoring_models();
3045        assert!(
3046            authors.contains(&"writer".to_string()),
3047            "the model that wrote most of the change must be named: {authors:?}"
3048        );
3049        assert!(authors.contains(&"finisher".to_string()), "{authors:?}");
3050    }
3051
3052    /// A journal written before `models_served` existed folds to exactly the
3053    /// answer it gave before — the field is additive, not a new requirement.
3054    #[test]
3055    fn a_record_without_models_served_still_attributes_its_terminal() {
3056        let dir = tempfile::tempdir().unwrap();
3057        let sink = EventSink::new(
3058            "coder-legacy",
3059            None,
3060            Some(dir.path().join("coder-legacy.events.jsonl")),
3061        );
3062        sink.record_turn_completed("empty_tool_calls", None, false, 2, "only-model", &[]);
3063        assert_eq!(sink.authoring_models(), vec!["only-model"]);
3064    }
3065
3066    #[test]
3067    fn a_session_with_no_journal_attributes_nothing() {
3068        // Foreman and external runs farm to a CLI whose backbone CAR never
3069        // resolved. Empty means "CAR's own loop did not write this", and the
3070        // gate reads it that way rather than as a fault.
3071        let sink = EventSink::test_sink();
3072        assert!(sink.authoring_models().is_empty());
3073    }
3074
3075    #[test]
3076    fn snapshot_round_trips_without_workspace_handle() {
3077        let dir = tempfile::tempdir().unwrap();
3078        let mut s = CoderSession::new(
3079            "/tmp/repo",
3080            "intent",
3081            EngineChoice::Auto,
3082            4,
3083            Some(dir.path().to_path_buf()),
3084        );
3085        s.contract = Some(OutcomeContract {
3086            allow_credentials: false,
3087            description: "d".into(),
3088            checks: vec![],
3089        });
3090        s.persist().unwrap();
3091        let loaded = CoderSession::load(&dir.path().join(format!("{}.json", s.id))).unwrap();
3092        assert_eq!(loaded.id, s.id);
3093        assert_eq!(loaded.state, CoderState::Created);
3094        assert!(loaded.workspace.is_none());
3095        assert!(loaded.contract.is_some());
3096
3097        let listed = CoderSession::list(dir.path());
3098        assert_eq!(listed.len(), 1);
3099        assert_eq!(listed[0].id, s.id);
3100    }
3101
3102    #[test]
3103    fn event_json_shape_is_ws_friendly() {
3104        let e = CoderEvent {
3105            session_id: "coder-x".into(),
3106            seq: 3,
3107            ts: 1,
3108            kind: CoderEventKind::CheckStarted {
3109                name: "tests".into(),
3110            },
3111        };
3112        let v = serde_json::to_value(&e).unwrap();
3113        assert_eq!(v["type"], "check_started");
3114        assert_eq!(v["name"], "tests");
3115        assert_eq!(v["seq"], 3);
3116    }
3117
3118    // --- daemon-restart orphan adoption -----------------------------------
3119
3120    /// Write a snapshot directly in `state` (bypassing the transition guard,
3121    /// which is exactly the situation a daemon crash leaves on disk).
3122    fn write_snapshot(dir: &Path, state: CoderState, workspace_path: Option<PathBuf>) -> String {
3123        let mut s = CoderSession::new(
3124            "/tmp/repo",
3125            "intent",
3126            EngineChoice::Native,
3127            4,
3128            Some(dir.to_path_buf()),
3129        );
3130        s.state = state;
3131        s.workspace_path = workspace_path;
3132        s.persist().unwrap();
3133        s.id
3134    }
3135
3136    fn reload(dir: &Path, id: &str) -> CoderSession {
3137        CoderSession::load(&dir.join(format!("{id}.json"))).unwrap()
3138    }
3139
3140    #[test]
3141    fn adoption_fails_running_and_confirmed_orphans() {
3142        let dir = tempfile::tempdir().unwrap();
3143        let running = write_snapshot(dir.path(), CoderState::Running, None);
3144        let confirmed = write_snapshot(dir.path(), CoderState::ContractConfirmed, None);
3145        let created = write_snapshot(dir.path(), CoderState::Created, None);
3146        let proposed = write_snapshot(dir.path(), CoderState::ContractProposed, None);
3147
3148        let outcomes = adopt_orphaned_sessions(dir.path());
3149        assert_eq!(outcomes.len(), 4);
3150        assert!(outcomes.iter().all(|(_, o)| *o == AdoptionOutcome::Failed));
3151
3152        for id in [&running, &confirmed, &created, &proposed] {
3153            let s = reload(dir.path(), id);
3154            assert_eq!(s.state, CoderState::Failed, "{id} should be failed");
3155            assert_eq!(s.error.as_deref(), Some("daemon restarted mid-session"));
3156        }
3157    }
3158
3159    #[test]
3160    fn adoption_preserves_needs_approval_with_live_worktree() {
3161        let dir = tempfile::tempdir().unwrap();
3162        // A real directory standing in for the surviving worktree.
3163        let worktree = dir.path().join("worktrees").join("wt-1");
3164        std::fs::create_dir_all(&worktree).unwrap();
3165        let id = write_snapshot(
3166            dir.path(),
3167            CoderState::NeedsApproval,
3168            Some(worktree.clone()),
3169        );
3170
3171        let outcomes = adopt_orphaned_sessions(dir.path());
3172        assert_eq!(outcomes, vec![(id.clone(), AdoptionOutcome::Preserved)]);
3173
3174        let s = reload(dir.path(), &id);
3175        // Untouched: still inspectable/approvable-by-hand, worktree path intact.
3176        assert_eq!(s.state, CoderState::NeedsApproval);
3177        assert!(s.error.is_none());
3178        assert_eq!(s.workspace_path.as_deref(), Some(worktree.as_path()));
3179    }
3180
3181    #[test]
3182    fn adoption_fails_needs_approval_when_worktree_gone() {
3183        let dir = tempfile::tempdir().unwrap();
3184        // Worktree path recorded but never created (or already reaped).
3185        let gone = dir.path().join("worktrees").join("vanished");
3186        let id = write_snapshot(dir.path(), CoderState::NeedsApproval, Some(gone));
3187
3188        let outcomes = adopt_orphaned_sessions(dir.path());
3189        assert_eq!(outcomes, vec![(id.clone(), AdoptionOutcome::Failed)]);
3190
3191        let s = reload(dir.path(), &id);
3192        assert_eq!(s.state, CoderState::Failed);
3193        assert_eq!(s.error.as_deref(), Some("daemon restarted mid-session"));
3194    }
3195
3196    #[test]
3197    fn adoption_leaves_terminal_snapshots_alone() {
3198        let dir = tempfile::tempdir().unwrap();
3199        let merged = write_snapshot(dir.path(), CoderState::Merged, None);
3200        let failed = write_snapshot(dir.path(), CoderState::Failed, None);
3201        let abandoned = write_snapshot(dir.path(), CoderState::Abandoned, None);
3202
3203        let outcomes = adopt_orphaned_sessions(dir.path());
3204        assert!(
3205            outcomes.is_empty(),
3206            "terminal snapshots must not be adopted"
3207        );
3208
3209        // Merged stays merged, no spurious error stamped on it.
3210        assert_eq!(reload(dir.path(), &merged).state, CoderState::Merged);
3211        assert_eq!(reload(dir.path(), &failed).state, CoderState::Failed);
3212        assert_eq!(reload(dir.path(), &abandoned).state, CoderState::Abandoned);
3213    }
3214
3215    /// A daemon restart is the most common way a session dies, and it is the
3216    /// machinery dying — no check ever judged the work. Without an explicit
3217    /// kind the summary defaults to `"error"`, so a board (and any scorer
3218    /// reading the snapshot) renders a restart as a red verdict on the task.
3219    #[test]
3220    fn adopted_after_restart_carries_the_infrastructure_failure_kind() {
3221        let dir = tempfile::tempdir().unwrap();
3222        let running = write_snapshot(dir.path(), CoderState::Running, None);
3223        // …including the needs_approval orphan whose worktree is gone.
3224        let gone = dir.path().join("worktrees").join("vanished");
3225        let approval = write_snapshot(dir.path(), CoderState::NeedsApproval, Some(gone));
3226
3227        adopt_orphaned_sessions(dir.path());
3228
3229        for id in [&running, &approval] {
3230            let s = reload(dir.path(), id);
3231            assert_eq!(s.state, CoderState::Failed, "{id}");
3232            assert_eq!(
3233                s.failure_kind.as_deref(),
3234                Some("infrastructure"),
3235                "{id}: a restart is not a judged loss"
3236            );
3237        }
3238    }
3239
3240    #[test]
3241    fn adoption_is_a_noop_on_missing_dir() {
3242        let dir = tempfile::tempdir().unwrap();
3243        let missing = dir.path().join("never-created");
3244        assert!(adopt_orphaned_sessions(&missing).is_empty());
3245    }
3246
3247    // --- needs_you derivation (wire contract §1) --------------------------
3248
3249    /// Every row of the §1 table, including the `null` default. The four kinds
3250    /// are what a board renders as "this one wants you"; getting one wrong
3251    /// either hides a blocked session or nags about a busy one.
3252    #[test]
3253    fn needs_you_covers_all_four_kinds_and_null() {
3254        use CoderState::*;
3255        // contract: the gate is decided by state alone.
3256        assert_eq!(
3257            needs_you_from(ContractProposed, false, false, None),
3258            Some(NeedsYou::Contract)
3259        );
3260        // approval: likewise.
3261        assert_eq!(
3262            needs_you_from(NeedsApproval, false, false, None),
3263            Some(NeedsYou::Approval)
3264        );
3265        // question: running + a parked question.
3266        assert_eq!(
3267            needs_you_from(Running, true, false, None),
3268            Some(NeedsYou::Question)
3269        );
3270        // auth: running + an unresolved auth_required.
3271        assert_eq!(
3272            needs_you_from(Running, false, true, None),
3273            Some(NeedsYou::Auth)
3274        );
3275        // null: running with neither, and every other state.
3276        assert_eq!(needs_you_from(Running, false, false, None), None);
3277        for state in [Created, ContractConfirmed, Merged, Failed, Abandoned] {
3278            assert_eq!(needs_you_from(state, false, false, None), None, "{state:?}");
3279            // A stale gate flag must not resurrect a terminal session as
3280            // "waiting on you" — the state is what decides.
3281            assert_eq!(needs_you_from(state, true, true, None), None, "{state:?}");
3282        }
3283    }
3284
3285    /// A parked question outranks an outstanding sign-in: the question is a
3286    /// literal prompt with a waiter behind it, while an auth event only means
3287    /// the loop is polling for a credential.
3288    #[test]
3289    fn a_parked_question_outranks_an_outstanding_sign_in() {
3290        assert_eq!(
3291            needs_you_from(CoderState::Running, true, true, None),
3292            Some(NeedsYou::Question)
3293        );
3294    }
3295
3296    /// The daemon owns the wording so two clients cannot describe one state
3297    /// differently — the `overlap_disclosure` precedent.
3298    #[test]
3299    fn needs_you_labels_and_wire_values_round_trip() {
3300        for (kind, wire, label) in [
3301            (
3302                NeedsYou::Contract,
3303                "contract",
3304                "contract awaiting confirmation",
3305            ),
3306            (NeedsYou::Question, "question", "question waiting"),
3307            (NeedsYou::Approval, "approval", "diff ready for approval"),
3308            (NeedsYou::Auth, "auth", "sign-in needed"),
3309        ] {
3310            assert_eq!(kind.as_str(), wire);
3311            assert_eq!(kind.label(), label);
3312            assert_eq!(NeedsYou::parse(wire), Some(kind));
3313        }
3314        assert_eq!(NeedsYou::parse("nonsense"), None);
3315    }
3316
3317    /// The gate carries the prompt so a summary can render *what* is being
3318    /// asked without replaying the event stream — and drops it the moment the
3319    /// question is answered or cleared.
3320    #[test]
3321    fn the_input_gate_carries_and_releases_its_prompt() {
3322        let gate = UserInputGate::new();
3323        assert!(!gate.is_pending());
3324        assert_eq!(gate.pending_prompt(), None);
3325
3326        let _rx = gate.park("Which database should this target?");
3327        assert!(gate.is_pending());
3328        assert_eq!(
3329            gate.pending_prompt().as_deref(),
3330            Some("Which database should this target?")
3331        );
3332
3333        gate.fulfill("postgres".into()).unwrap();
3334        assert!(!gate.is_pending());
3335        assert_eq!(gate.pending_prompt(), None);
3336
3337        let _rx = gate.park("again?");
3338        gate.clear();
3339        assert_eq!(gate.pending_prompt(), None);
3340    }
3341
3342    /// An OLD on-disk snapshot — written before `failure_kind` / `needs_you` /
3343    /// `discussion_id` existed — must still deserialize. A daemon upgrade that
3344    /// bricked `coder.list` on every pre-upgrade session would be a far worse
3345    /// bug than the missing fields it was adding.
3346    #[test]
3347    fn an_old_format_snapshot_still_deserializes() {
3348        let dir = tempfile::tempdir().unwrap();
3349        let path = dir.path().join("coder-legacy.json");
3350        // Verbatim shape of a pre-board snapshot: no failure_kind, no
3351        // needs_you, no discussion_id.
3352        std::fs::write(
3353            &path,
3354            r#"{
3355              "id": "coder-legacy",
3356              "repo": "/tmp/repo",
3357              "intent": "make it work",
3358              "engine": "native",
3359              "state": "failed",
3360              "iterations": 3,
3361              "max_iterations": 8,
3362              "keep_workspace_on_failure": false,
3363              "last_check_results": [],
3364              "created_at": 100,
3365              "updated_at": 200,
3366              "error": "contract not satisfied after 3 iteration(s)"
3367            }"#,
3368        )
3369        .unwrap();
3370
3371        let loaded = CoderSession::load(&path).expect("legacy snapshot must still load");
3372        assert_eq!(loaded.id, "coder-legacy");
3373        assert_eq!(loaded.state, CoderState::Failed);
3374        // The new fields default rather than failing the parse.
3375        assert_eq!(loaded.failure_kind, None);
3376        assert_eq!(loaded.discussion_id, None);
3377        assert!(loaded.discussion_constraints.is_empty());
3378        assert_eq!(loaded.base, None);
3379        // ...and `list` (what coder.list reads) picks it up unchanged.
3380        let listed = CoderSession::list(dir.path());
3381        assert_eq!(listed.len(), 1);
3382        assert_eq!(listed[0].id, "coder-legacy");
3383    }
3384
3385    /// The new fields survive a write→read round trip, which is what makes a
3386    /// post-restart summary able to say *why* a session failed.
3387    #[test]
3388    fn attention_fields_survive_a_snapshot_round_trip() {
3389        let dir = tempfile::tempdir().unwrap();
3390        let mut s = CoderSession::new(
3391            "/tmp/repo",
3392            "intent",
3393            EngineChoice::Native,
3394            4,
3395            Some(dir.path().to_path_buf()),
3396        );
3397        s.state = CoderState::Failed;
3398        s.failure_kind = Some("budget_exhausted".into());
3399        s.discussion_id = Some("disc-abc".into());
3400        s.discussion_constraints = vec!["Preserve personal.txt".into()];
3401        s.result_branch = Some("car/coder/ab12cd34".into());
3402        s.result_commit = Some("123456789abcdef0123456789abcdef0123456789a".into());
3403        s.model = Some("parslee/reasoning".into());
3404        s.base = Some("0123456789abcdef0123456789abcdef01234567".into());
3405        s.persist().unwrap();
3406
3407        let loaded = CoderSession::load(&dir.path().join(format!("{}.json", s.id))).unwrap();
3408        assert_eq!(loaded.failure_kind.as_deref(), Some("budget_exhausted"));
3409        assert_eq!(loaded.discussion_id.as_deref(), Some("disc-abc"));
3410        assert_eq!(loaded.discussion_constraints, s.discussion_constraints);
3411        assert_eq!(loaded.result_branch.as_deref(), Some("car/coder/ab12cd34"));
3412        assert_eq!(loaded.result_commit, s.result_commit);
3413        assert_eq!(loaded.model.as_deref(), Some("parslee/reasoning"));
3414        assert_eq!(
3415            loaded.base.as_deref(),
3416            Some("0123456789abcdef0123456789abcdef01234567")
3417        );
3418    }
3419
3420    #[test]
3421    fn contract_revision_rejected_is_named_and_ws_shaped() {
3422        let kind = CoderEventKind::ContractRevisionRejected {
3423            request: "also verify the Windows path".into(),
3424            reason: "the redrafted contract is invalid: contract has no checks".into(),
3425        };
3426        assert_eq!(coder_event_name(&kind), "coder.contract_revision_rejected");
3427        let v = serde_json::to_value(CoderEvent {
3428            session_id: "coder-x".into(),
3429            seq: 4,
3430            ts: 1,
3431            kind,
3432        })
3433        .unwrap();
3434        assert_eq!(v["type"], "contract_revision_rejected");
3435        assert_eq!(v["request"], "also verify the Windows path");
3436        assert!(v["reason"].as_str().unwrap().contains("no checks"));
3437    }
3438}