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