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(), ¬_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}