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