use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use car_eventlog::{EventKind, EventLog};
use car_multi::{AgentWorkspace, WorkspaceConfig};
use super::contract::{CheckResult, OutcomeContract};
use super::router::EngineChoice;
pub type CancelFlag = Arc<AtomicBool>;
pub type EventEmitter = Arc<dyn Fn(CoderEvent) + Send + Sync>;
#[derive(Default)]
pub struct UserInputGate {
pub steering: Arc<super::steering::SteeringInbox>,
pending: Mutex<Option<tokio::sync::oneshot::Sender<String>>>,
prompt: Mutex<Option<String>>,
}
impl UserInputGate {
pub fn new() -> Self {
Self::default()
}
pub fn park(&self, prompt: &str) -> tokio::sync::oneshot::Receiver<String> {
let (tx, rx) = tokio::sync::oneshot::channel();
*self.pending.lock().expect("user-input gate poisoned") = Some(tx);
*self.prompt.lock().expect("user-input gate poisoned") = Some(prompt.to_string());
rx
}
pub fn pending_prompt(&self) -> Option<String> {
self.prompt
.lock()
.expect("user-input gate poisoned")
.clone()
}
pub fn fulfill(&self, answer: String) -> Result<(), String> {
let tx = self
.pending
.lock()
.expect("user-input gate poisoned")
.take()
.ok_or("no pending user-input request for this session")?;
*self.prompt.lock().expect("user-input gate poisoned") = None;
tx.send(answer)
.map_err(|_| "the session is no longer waiting for input".to_string())
}
pub fn clear(&self) {
*self.pending.lock().expect("user-input gate poisoned") = None;
*self.prompt.lock().expect("user-input gate poisoned") = None;
}
pub fn is_pending(&self) -> bool {
self.pending
.lock()
.expect("user-input gate poisoned")
.is_some()
}
}
pub fn default_state_dir() -> Result<PathBuf, String> {
let root = car_home::root()
.ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
Ok(root.join("coder"))
}
pub(crate) fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn proactive_maintenance_event_data(
report: &car_memgine::ProactiveMaintenanceReport,
) -> HashMap<String, Value> {
let mut data = proactive_trigger_event_data(&report.trigger);
data.insert(
"saved_count".to_string(),
Value::from(report.saved.len() as u64),
);
data.insert(
"skipped_existing".to_string(),
Value::from(report.skipped_existing as u64),
);
data.insert(
"status_updated".to_string(),
Value::from(report.status.is_some()),
);
data
}
fn proactive_intervention_event_data(
decision: &car_memgine::ProactiveMemoryDecision,
) -> HashMap<String, Value> {
let mut data = HashMap::new();
match decision {
car_memgine::ProactiveMemoryDecision::Inject {
selected,
candidates,
bank,
..
} => {
data.insert("decision".to_string(), Value::from("inject"));
data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
data.insert(
"selected_kind".to_string(),
Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
);
data.insert(
"candidate_count".to_string(),
Value::from(candidates.len() as u64),
);
data.insert(
"bank_knowledge".to_string(),
Value::from(bank.knowledge as u64),
);
data.insert(
"bank_procedural".to_string(),
Value::from(bank.procedural as u64),
);
data.insert(
"bank_open_subgoals".to_string(),
Value::from(bank.open_subgoals as u64),
);
}
car_memgine::ProactiveMemoryDecision::Silent {
reason,
candidates,
bank,
} => {
data.insert("decision".to_string(), Value::from("silent"));
data.insert("reason".to_string(), Value::from(reason.clone()));
data.insert(
"candidate_count".to_string(),
Value::from(candidates.len() as u64),
);
data.insert(
"bank_knowledge".to_string(),
Value::from(bank.knowledge as u64),
);
data.insert(
"bank_procedural".to_string(),
Value::from(bank.procedural as u64),
);
data.insert(
"bank_open_subgoals".to_string(),
Value::from(bank.open_subgoals as u64),
);
}
}
data
}
fn proactive_trigger_event_data(
trigger: &car_memgine::ProactiveMemoryTrigger,
) -> HashMap<String, Value> {
HashMap::from([
(
"repeated_failures".to_string(),
Value::from(trigger.repeated_failures as u64),
),
("tool_error".to_string(), Value::from(trigger.tool_error)),
(
"explicit_uncertainty".to_string(),
Value::from(trigger.explicit_uncertainty),
),
(
"high_risk_action".to_string(),
Value::from(trigger.high_risk_action),
),
(
"context_shift".to_string(),
Value::from(trigger.context_shift),
),
])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoderState {
Created,
ContractProposed,
ContractConfirmed,
Running,
NeedsApproval,
Merged,
Reported,
Failed,
Abandoned,
}
impl CoderState {
pub fn is_terminal(&self) -> bool {
matches!(
self,
Self::Merged | Self::Reported | Self::Failed | Self::Abandoned
)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Created => "created",
Self::ContractProposed => "contract_proposed",
Self::ContractConfirmed => "contract_confirmed",
Self::Running => "running",
Self::NeedsApproval => "needs_approval",
Self::Merged => "merged",
Self::Reported => "reported",
Self::Failed => "failed",
Self::Abandoned => "abandoned",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NeedsYou {
Contract,
Question,
Approval,
Finding,
Auth,
}
impl NeedsYou {
pub fn as_str(&self) -> &'static str {
match self {
Self::Contract => "contract",
Self::Question => "question",
Self::Approval => "approval",
Self::Finding => "finding",
Self::Auth => "auth",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Contract => "contract awaiting confirmation",
Self::Question => "question waiting",
Self::Approval => "diff ready for approval",
Self::Finding => "finding ready for review",
Self::Auth => "sign-in needed",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"contract" => Some(Self::Contract),
"question" => Some(Self::Question),
"approval" => Some(Self::Approval),
"finding" => Some(Self::Finding),
"auth" => Some(Self::Auth),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalKind {
Merge,
Finding,
}
impl ApprovalKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Merge => "merge",
Self::Finding => "finding",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"merge" => Some(Self::Merge),
"finding" => Some(Self::Finding),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NoChangeKind {
PremiseWrong,
DeliberateBehavior,
NonCodeDecision,
}
impl NoChangeKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::PremiseWrong => "premise_wrong",
Self::DeliberateBehavior => "deliberate_behavior",
Self::NonCodeDecision => "non_code_decision",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"premise_wrong" => Some(Self::PremiseWrong),
"deliberate_behavior" => Some(Self::DeliberateBehavior),
"non_code_decision" => Some(Self::NonCodeDecision),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NoChangeVerification {
RuntimeBaselineGreen,
HumanApproved,
}
impl NoChangeVerification {
pub fn as_str(&self) -> &'static str {
match self {
Self::RuntimeBaselineGreen => "runtime_baseline_green",
Self::HumanApproved => "human_approved",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContractProvenance {
OperatorSupplied,
HumanConfirmed,
RuntimeGenerated,
ModelDerived,
}
impl ContractProvenance {
pub fn is_trusted(&self) -> bool {
match self {
Self::OperatorSupplied | Self::HumanConfirmed | Self::RuntimeGenerated => true,
Self::ModelDerived => false,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::OperatorSupplied => "operator_supplied",
Self::HumanConfirmed => "human_confirmed",
Self::RuntimeGenerated => "runtime_generated",
Self::ModelDerived => "model_derived",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoChangeNomination {
pub kind: NoChangeKind,
pub summary: String,
pub evidence: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NoChangeFinding {
pub kind: NoChangeKind,
pub summary: String,
pub evidence: String,
pub verification: Option<NoChangeVerification>,
pub proposed_at: u64,
pub resolved_at: Option<u64>,
pub resolver_comment: Option<String>,
pub baseline_checks: Vec<CheckResult>,
}
pub fn needs_you_from(
state: CoderState,
question_pending: bool,
auth_outstanding: bool,
approval_kind: Option<ApprovalKind>,
) -> Option<NeedsYou> {
match state {
CoderState::ContractProposed => Some(NeedsYou::Contract),
CoderState::NeedsApproval => match approval_kind {
Some(ApprovalKind::Finding) => Some(NeedsYou::Finding),
Some(ApprovalKind::Merge) | None => Some(NeedsYou::Approval),
},
CoderState::Running if question_pending => Some(NeedsYou::Question),
CoderState::Running if auth_outstanding => Some(NeedsYou::Auth),
_ => None,
}
}
pub fn can_transition(from: CoderState, to: CoderState) -> bool {
use CoderState::*;
if from.is_terminal() {
return false;
}
matches!(to, Failed | Abandoned)
|| matches!(
(from, to),
(Created, ContractProposed)
| (ContractProposed, ContractProposed) | (ContractProposed, ContractConfirmed)
| (ContractConfirmed, Running)
| (Running, NeedsApproval)
| (NeedsApproval, Merged)
| (Running, Reported)
| (NeedsApproval, Reported)
| (NeedsApproval, Running)
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoderEvent {
pub session_id: String,
pub seq: u64,
pub ts: u64,
#[serde(flatten)]
pub kind: CoderEventKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CoderEventKind {
StateChanged {
from: String,
to: String,
},
ContractProposed {
contract: OutcomeContract,
},
EngineSelected {
engine: String,
reason: String,
},
EngineFallback {
from: String,
to: String,
reason: String,
},
ModelFallback {
from: String,
to: String,
reason: String,
},
IterationStarted {
n: u32,
max: u32,
},
InferenceWaiting {
elapsed_secs: u64,
},
OperatorGuidance {
text: String,
status: String,
},
InferenceRetry {
model: String,
attempt: u32,
reason: String,
backoff_ms: u64,
},
AuthRequired {
message: String,
wait_secs: u64,
},
BudgetExhausted {
reason: String,
elapsed_secs: u64,
iterations: u32,
},
InvocationRetried {
hypothesis: u32,
reason: String,
retries_remaining: u32,
},
PlanText {
text: String,
},
ToolCall {
tool: String,
params_preview: String,
},
ToolResult {
tool: String,
ok: bool,
preview: String,
},
CheckStarted {
name: String,
},
CheckCompleted {
result: CheckResult,
},
ContractBaseline {
results: Vec<CheckResult>,
gates_nothing: bool,
},
ExternalEvent {
raw: Value,
},
FindingProposed {
finding: NoChangeFinding,
},
FindingResolved {
accepted: bool,
comment: Option<String>,
},
DiffReady {
stat: String,
patch: String,
patch_truncated: bool,
patch_full_bytes: usize,
changed_paths: usize,
contract_overlap: Vec<super::overlap::CheckOverlap>,
overlap_disclosure: Option<String>,
},
UserInputRequested {
prompt: String,
},
UserInputExpired {
prompt: String,
waited_secs: u64,
},
ContractRevisionRejected {
request: String,
reason: String,
},
MergeCompleted {
branch: String,
},
Error {
message: String,
},
}
pub struct EventSink {
session_id: String,
seq: AtomicU64,
last_event_at: AtomicU64,
emitter: Option<EventEmitter>,
journal: Option<Mutex<EventLog>>,
#[cfg(test)]
journal_path: Option<PathBuf>,
}
impl EventSink {
pub(super) fn resume_at(self, next_seq: u64) -> Self {
self.seq.store(next_seq, Ordering::SeqCst);
self
}
pub(super) fn next_sequence(&self) -> u64 {
self.seq.load(Ordering::SeqCst)
}
pub fn new(
session_id: impl Into<String>,
emitter: Option<EventEmitter>,
journal_path: Option<PathBuf>,
) -> Self {
#[cfg(test)]
let test_journal_path = journal_path.clone();
Self {
session_id: session_id.into(),
seq: AtomicU64::new(0),
last_event_at: AtomicU64::new(0),
emitter,
journal: journal_path.map(|p| Mutex::new(EventLog::with_journal(p))),
#[cfg(test)]
journal_path: test_journal_path,
}
}
#[cfg(test)]
pub async fn flush_journal_for_test(self: &Arc<Self>) -> Result<(), String> {
let sink = Arc::clone(self);
tokio::task::spawn_blocking(move || {
let Some(journal) = &sink.journal else {
return Ok(());
};
let path = sink
.journal_path
.as_ref()
.ok_or_else(|| "journal path missing for configured event log".to_string())?;
let mut log = journal
.lock()
.map_err(|_| "event journal lock poisoned".to_string())?;
let pending = std::mem::replace(&mut *log, EventLog::new());
drop(pending);
*log = EventLog::load(path)
.map_err(|error| format!("reload flushed event journal: {error}"))?;
Ok(())
})
.await
.map_err(|error| format!("join event journal flush task: {error}"))?
}
pub fn test_sink() -> Self {
Self::new("coder-test", None, None)
}
pub fn collecting(session_id: &str) -> (Self, Arc<Mutex<Vec<CoderEvent>>>) {
let collected: Arc<Mutex<Vec<CoderEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink_copy = collected.clone();
let emitter: EventEmitter = Arc::new(move |e| {
sink_copy.lock().expect("collector poisoned").push(e);
});
(Self::new(session_id, Some(emitter), None), collected)
}
pub fn emit(&self, kind: CoderEventKind) -> CoderEvent {
let event = CoderEvent {
session_id: self.session_id.clone(),
seq: self.seq.fetch_add(1, Ordering::SeqCst),
ts: now_secs(),
kind,
};
self.last_event_at.fetch_max(event.ts, Ordering::SeqCst);
self.audit(&event);
if let Some(emitter) = &self.emitter {
emitter(event.clone());
}
event
}
pub(crate) fn last_event_at(&self) -> u64 {
self.last_event_at.load(Ordering::SeqCst)
}
pub fn record_turn_completed(
&self,
decision: &str,
stop_reason: Option<&str>,
was_truncated: bool,
turns: u32,
model: &str,
models_served: &[String],
) {
let Some(journal) = &self.journal else { return };
let mut data = car_engine::goal::turn_completed_data(
decision,
stop_reason,
was_truncated,
turns,
model,
);
if !models_served.is_empty() {
data.insert(
"models_served".to_string(),
Value::Array(
models_served
.iter()
.map(|m| Value::String(m.clone()))
.collect(),
),
);
}
if let Ok(mut log) = journal.lock() {
log.append(EventKind::TurnCompleted, Some(&self.session_id), None, data);
}
}
pub fn record_model_fallback(&self, from: &str, to: &str, reason: &str) {
let Some(journal) = &self.journal else { return };
let mut data = std::collections::HashMap::new();
data.insert("from".to_string(), Value::String(from.to_string()));
data.insert("to".to_string(), Value::String(to.to_string()));
data.insert("reason".to_string(), Value::String(reason.to_string()));
if let Ok(mut log) = journal.lock() {
log.append(EventKind::ModelFallback, Some(&self.session_id), None, data);
}
}
pub fn record_gate_verdict(
&self,
kind: EventKind,
data: std::collections::HashMap<String, Value>,
) {
let Some(journal) = &self.journal else { return };
if let Ok(mut log) = journal.lock() {
log.append(kind, Some(&self.session_id), None, data);
}
}
pub fn authoring_models(&self) -> Vec<String> {
let Some(journal) = &self.journal else {
return Vec::new();
};
let Ok(log) = journal.lock() else {
return Vec::new();
};
let mut models: Vec<String> = Vec::new();
for event in log.events() {
if event.kind != EventKind::TurnCompleted {
continue;
}
let served = event
.data
.get("models_served")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
let terminal = event.data.get("model_id").and_then(|v| v.as_str());
for model in served.into_iter().chain(terminal) {
let model = model.trim();
if model.is_empty() {
continue;
}
if !models.iter().any(|m| m == model) {
models.push(model.to_string());
}
}
}
models
}
pub fn events(&self) -> Vec<car_eventlog::Event> {
let Some(journal) = &self.journal else {
return Vec::new();
};
journal
.lock()
.map(|log| log.events().to_vec())
.unwrap_or_default()
}
pub fn record_proactive_memory(
&self,
maintenance: &car_memgine::ProactiveMaintenanceReport,
decision: &car_memgine::ProactiveMemoryDecision,
) {
let Some(journal) = &self.journal else {
return;
};
if let Ok(mut log) = journal.lock() {
log.append(
EventKind::ProactiveMemoryMaintained,
Some(&self.session_id),
None,
proactive_maintenance_event_data(maintenance),
);
log.append(
EventKind::ProactiveMemoryIntervention,
Some(&self.session_id),
None,
proactive_intervention_event_data(decision),
);
}
}
fn audit(&self, event: &CoderEvent) {
let Some(journal) = &self.journal else { return };
let (kind, mut data): (EventKind, HashMap<String, Value>) = match &event.kind {
CoderEventKind::StateChanged { from, to } => (
EventKind::StateChanged,
HashMap::from([
("from".to_string(), Value::String(from.clone())),
("to".to_string(), Value::String(to.clone())),
]),
),
CoderEventKind::ToolCall {
tool,
params_preview,
} => (
EventKind::ActionExecuting,
HashMap::from([
("tool".to_string(), Value::String(tool.clone())),
("params".to_string(), Value::String(params_preview.clone())),
]),
),
CoderEventKind::ToolResult { tool, ok, preview } => (
if *ok {
EventKind::ActionSucceeded
} else {
EventKind::ActionFailed
},
HashMap::from([
("tool".to_string(), Value::String(tool.clone())),
("result".to_string(), Value::String(preview.clone())),
]),
),
CoderEventKind::CheckCompleted { result } => (
if result.passed {
EventKind::ActionSucceeded
} else {
EventKind::ActionFailed
},
HashMap::from([
("check".to_string(), Value::String(result.name.clone())),
(
"exit_code".to_string(),
result.exit_code.map(Value::from).unwrap_or(Value::Null),
),
]),
),
CoderEventKind::ContractBaseline {
results,
gates_nothing,
} => (
EventKind::ActionSucceeded,
HashMap::from([
("baseline_checks".to_string(), Value::from(results.len())),
(
"baseline_passed".to_string(),
Value::from(results.iter().filter(|r| r.passed).count()),
),
(
"contract_gates_nothing".to_string(),
Value::Bool(*gates_nothing),
),
]),
),
CoderEventKind::Error { message } => (
EventKind::ActionFailed,
HashMap::from([("error".to_string(), Value::String(message.clone()))]),
),
CoderEventKind::MergeCompleted { branch } => (
EventKind::ActionSucceeded,
HashMap::from([("branch".to_string(), Value::String(branch.clone()))]),
),
CoderEventKind::BudgetExhausted {
reason,
elapsed_secs,
iterations,
} => (
EventKind::ActionSkipped,
HashMap::from([
("reason".to_string(), Value::String(reason.clone())),
("elapsed_secs".to_string(), Value::from(*elapsed_secs)),
("iterations".to_string(), Value::from(*iterations)),
]),
),
CoderEventKind::InvocationRetried {
hypothesis,
reason,
retries_remaining,
} => (
EventKind::ActionRetrying,
HashMap::from([
("hypothesis".to_string(), Value::from(*hypothesis)),
("reason".to_string(), Value::String(reason.clone())),
(
"retries_remaining".to_string(),
Value::from(*retries_remaining),
),
]),
),
_ => return,
};
data.insert(
"coder_event".to_string(),
Value::String(coder_event_name(&event.kind).to_string()),
);
data.insert("seq".to_string(), Value::from(event.seq));
let action_id: String = match &event.kind {
CoderEventKind::ToolCall { tool, .. } | CoderEventKind::ToolResult { tool, .. } => {
tool.clone()
}
CoderEventKind::CheckCompleted { result } => format!("check:{}", result.name),
CoderEventKind::InvocationRetried { .. } => "invocation_retry".to_string(),
CoderEventKind::BudgetExhausted { .. } => "session_budget".to_string(),
_ => self.session_id.clone(),
};
if let Ok(mut log) = journal.lock() {
log.append(kind, Some(&action_id), None, data);
}
}
}
fn coder_event_name(kind: &CoderEventKind) -> &'static str {
match kind {
CoderEventKind::StateChanged { .. } => "coder.state_changed",
CoderEventKind::ContractProposed { .. } => "coder.contract_proposed",
CoderEventKind::EngineSelected { .. } => "coder.engine_selected",
CoderEventKind::EngineFallback { .. } => "coder.engine_fallback",
CoderEventKind::ModelFallback { .. } => "coder.model_fallback",
CoderEventKind::IterationStarted { .. } => "coder.iteration_started",
CoderEventKind::InferenceWaiting { .. } => "coder.inference_waiting",
CoderEventKind::OperatorGuidance { .. } => "coder.operator_guidance",
CoderEventKind::InferenceRetry { .. } => "coder.inference_retry",
CoderEventKind::AuthRequired { .. } => "coder.auth_required",
CoderEventKind::BudgetExhausted { .. } => "coder.budget_exhausted",
CoderEventKind::InvocationRetried { .. } => "coder.invocation_retried",
CoderEventKind::PlanText { .. } => "coder.plan_text",
CoderEventKind::ToolCall { .. } => "coder.tool_call",
CoderEventKind::ToolResult { .. } => "coder.tool_result",
CoderEventKind::CheckStarted { .. } => "coder.check_started",
CoderEventKind::CheckCompleted { .. } => "coder.check_completed",
CoderEventKind::ContractBaseline { .. } => "coder.contract_baseline",
CoderEventKind::ExternalEvent { .. } => "coder.external_event",
CoderEventKind::FindingProposed { .. } => "coder.finding_proposed",
CoderEventKind::FindingResolved { .. } => "coder.finding_resolved",
CoderEventKind::DiffReady { .. } => "coder.diff_ready",
CoderEventKind::UserInputRequested { .. } => "coder.user_input_requested",
CoderEventKind::UserInputExpired { .. } => "coder.user_input_expired",
CoderEventKind::ContractRevisionRejected { .. } => "coder.contract_revision_rejected",
CoderEventKind::MergeCompleted { .. } => "coder.merge_completed",
CoderEventKind::Error { .. } => "coder.error",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentBuildPhase {
GeneratingSpec,
Repairing,
RunningScenario,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentBuildProgress {
pub phase: AgentBuildPhase,
pub attempt: u32,
pub max_attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenario: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenarios_total: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub started_at: u64,
pub elapsed_secs: u64,
}
impl AgentBuildProgress {
pub fn refresh_elapsed(&mut self) {
self.elapsed_secs = now_secs().saturating_sub(self.started_at);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoderSession {
pub id: String,
pub repo: PathBuf,
pub intent: String,
pub engine: EngineChoice,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_engine: Option<EngineChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine_ran: Option<EngineChoice>,
pub state: CoderState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contract: Option<OutcomeContract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_kind: Option<super::project::ProjectKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub existing_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub built_agent: Option<car_registry::declarative::DeclarativeAgentSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_build_progress: Option<AgentBuildProgress>,
pub iterations: u32,
pub max_iterations: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repair_invokes: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transient_retries: Option<u32>,
#[serde(default)]
pub keep_workspace_on_failure: bool,
#[serde(default)]
pub keep_workspace_on_cancel: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub last_check_results: Vec<CheckResult>,
#[serde(default)]
pub baseline: Vec<CheckResult>,
#[serde(default)]
pub baseline_gates_nothing: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkout_identity: Option<super::merge::CheckoutIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inputs_snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_delivery: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discussion_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub discussion_constraints: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steering_messages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_from: Option<String>,
#[serde(default)]
pub execution_stopped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_identity: Option<super::merge::ReviewIdentity>,
#[serde(default)]
pub event_cursor: u64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub review_restored: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_change_finding: Option<NoChangeFinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_commit: Option<String>,
#[serde(default)]
pub browser: bool,
#[serde(default)]
pub distributed: bool,
#[serde(default)]
pub workers: Vec<String>,
#[serde(default)]
pub pool_workers: Vec<String>,
#[serde(default)]
pub placements: Vec<car_multi::Placement>,
#[serde(default)]
pub integrated_subtasks: Vec<IntegratedSubtask>,
#[serde(default)]
pub repaired_locally: bool,
#[serde(default)]
pub authored_by: Vec<String>,
pub created_at: u64,
pub updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip)]
pub workspace: Option<AgentWorkspace>,
#[serde(skip)]
pub state_dir: Option<PathBuf>,
}
impl CoderSession {
pub fn execution_intent(&self) -> String {
let mut intent = self.intent.clone();
if !self.discussion_constraints.is_empty() {
intent.push_str("\n\nUser constraints carried from the conversation:\n");
for constraint in &self.discussion_constraints {
intent.push_str(&format!("- {constraint}\n"));
}
}
if !self.steering_messages.is_empty() {
intent.push_str(
"\nAccepted user guidance (in order; later corrections take precedence):\n",
);
for guidance in &self.steering_messages {
intent.push_str(&format!("- {guidance}\n"));
}
}
intent
}
pub fn new(
repo: impl Into<PathBuf>,
intent: impl Into<String>,
engine: EngineChoice,
max_iterations: u32,
state_dir: Option<PathBuf>,
) -> Self {
let now = now_secs();
Self {
id: format!("coder-{}", uuid::Uuid::new_v4().simple()),
repo: repo.into(),
intent: intent.into(),
engine,
requested_engine: None,
engine_ran: None,
state: CoderState::Created,
contract: None,
cost_usd: None,
repair_invokes: None,
transient_retries: None,
workspace_path: None,
project: None,
project_kind: None,
existing_agent_id: None,
builder_draft: None,
built_agent: None,
agent_build_progress: None,
iterations: 0,
max_iterations: max_iterations.max(1),
keep_workspace_on_failure: false,
keep_workspace_on_cancel: false,
model: None,
last_check_results: Vec::new(),
baseline: Vec::new(),
baseline_gates_nothing: false,
browser: false,
distributed: false,
workers: Vec::new(),
pool_workers: Vec::new(),
authored_by: Vec::new(),
placements: Vec::new(),
integrated_subtasks: Vec::new(),
repaired_locally: false,
result_branch: None,
result_commit: None,
checkout_identity: None,
inputs_snapshot: None,
result_delivery: None,
failure_kind: None,
discussion_id: None,
discussion_constraints: Vec::new(),
steering_messages: Vec::new(),
resumed_from: None,
execution_stopped: false,
review_identity: None,
event_cursor: 0,
review_restored: false,
base: None,
no_change_finding: None,
start_commit: None,
created_at: now,
updated_at: now,
error: None,
workspace: None,
state_dir,
}
}
pub fn with_project(mut self, project: super::project::CoderProject) -> Self {
self.project = Some(project.slug);
self.project_kind = Some(project.kind);
self.existing_agent_id = project.existing_agent_id;
self.builder_draft = project.builder_draft;
self
}
pub fn short_id(&self) -> &str {
&self.id[self.id.len().saturating_sub(8)..]
}
pub fn provision_workspace(&mut self) -> Result<PathBuf, String> {
let state_dir = self
.state_dir
.clone()
.ok_or("session has no state dir; cannot provision a worktree")?;
let mut config = WorkspaceConfig::git_worktree_at(&self.repo, state_dir.join("worktrees"));
if let Some(base) = &self.base {
config = config.with_rev(base.clone());
}
let workspace = AgentWorkspace::provision(&config, &self.id)?;
let path = workspace.path().to_path_buf();
self.start_commit = super::no_change::head_commit(&path);
self.workspace_path = Some(path.clone());
self.workspace = Some(workspace);
Ok(path)
}
pub fn transition(&mut self, to: CoderState, sink: &EventSink) -> Result<(), String> {
if !can_transition(self.state, to) {
return Err(format!(
"illegal coder transition {} → {}",
self.state.as_str(),
to.as_str()
));
}
let from = self.state;
self.state = to;
self.updated_at = now_secs();
if to.is_terminal() && to != CoderState::Reported {
if let Some(finding) = self
.no_change_finding
.as_mut()
.filter(|f| f.resolved_at.is_none())
{
finding.resolved_at = Some(self.updated_at);
finding.resolver_comment.get_or_insert_with(|| {
format!("session ended {} without a decision", to.as_str())
});
}
}
sink.emit(CoderEventKind::StateChanged {
from: from.as_str().to_string(),
to: to.as_str().to_string(),
});
self.event_cursor = sink.next_sequence();
if to.is_terminal() {
let keep_unfinished = (self.discussion_id.is_some() || self.keep_workspace_on_cancel)
&& matches!(to, CoderState::Failed | CoderState::Abandoned);
if keep_unfinished || (to == CoderState::Failed && self.keep_workspace_on_failure) {
if let Some(ws) = self.workspace.take() {
std::mem::forget(ws);
}
} else {
if let Some(workspace) = self.workspace.as_mut() {
workspace.enable_cleanup();
}
self.workspace = None;
}
}
if let Err(e) = self.persist() {
tracing::warn!(session = %self.id, "coder snapshot persist failed: {e}");
}
Ok(())
}
pub fn persist(&self) -> Result<(), String> {
let Some(dir) = &self.state_dir else {
return Ok(());
};
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.json", self.id));
use std::io::Write;
let json = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?;
let mut file = tempfile::NamedTempFile::new_in(dir)
.map_err(|e| format!("create snapshot in {}: {e}", dir.display()))?;
file.write_all(&json)
.and_then(|_| file.as_file().sync_all())
.map_err(|e| format!("sync {}: {e}", path.display()))?;
file.persist(&path)
.map_err(|e| format!("publish {}: {e}", path.display()))?;
#[cfg(unix)]
std::fs::File::open(dir)
.and_then(|dir| dir.sync_all())
.map_err(|e| format!("sync snapshot directory: {e}"))?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self, String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
pub fn list(state_dir: &Path) -> Vec<CoderSession> {
let Ok(entries) = std::fs::read_dir(state_dir) else {
return Vec::new();
};
let mut sessions: Vec<CoderSession> = entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| Self::load(&e.path()).ok())
.collect();
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
sessions
}
}
fn unlink(path: &Path) -> bool {
match std::fs::remove_file(path) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
tracing::warn!(path = %path.display(), "coder retention unlink failed: {e}");
false
}
}
}
#[cfg(test)]
fn reap_worktree(repo: &Path, path: &Path) {
if !path.is_dir() {
return;
}
let _ = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "remove", "--force"])
.arg(path)
.output();
if let Err(e) = std::fs::remove_dir_all(path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), "could not reap a stranded coder worktree: {e}");
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegratedSubtask {
pub subtask_id: String,
#[serde(default)]
pub files: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionRetention {
pub max_sessions: usize,
pub max_age_days: u64,
}
pub enum SweepScope<'a> {
Boot,
Live(&'a std::collections::HashSet<String>),
}
pub fn gc_sessions(state_dir: &Path, retention: &SessionRetention, scope: SweepScope<'_>) -> usize {
match gc_sessions_with_age_floor(state_dir, retention, scope, 0) {
Ok(collected) => collected,
Err(error) => {
tracing::warn!(state_dir = %state_dir.display(), "coder retention sweep skipped: {error}");
0
}
}
}
pub fn gc_sessions_with_age_floor(
state_dir: &Path,
retention: &SessionRetention,
scope: SweepScope<'_>,
min_age_secs: u64,
) -> Result<usize, String> {
let lock_path = state_dir.join(".sweep.lock");
let lock = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|error| format!("open {}: {error}", lock_path.display()))?;
lock.lock()
.map_err(|error| format!("lock {}: {error}", lock_path.display()))?;
let Ok(entries) = std::fs::read_dir(state_dir) else {
return Ok(0);
};
let sweep_started_at = now_secs();
let mut candidates: Vec<(u64, PathBuf)> = Vec::new();
let mut snapshots: Vec<PathBuf> = Vec::new();
let mut journals: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.to_string_lossy().ends_with(".events.jsonl") {
journals.push(path);
continue;
}
if path.extension().is_none_or(|x| x != "json") {
continue;
}
snapshots.push(path.clone());
let Ok(session) = CoderSession::load(&path) else {
continue;
};
if !session.state.is_terminal() {
continue;
}
if session.workspace_path.as_ref().is_some_and(|p| p.is_dir()) {
continue;
}
if matches!(&scope, SweepScope::Live(ids) if ids.contains(&session.id)) {
continue;
}
if sweep_started_at.saturating_sub(session.updated_at) < min_age_secs {
continue;
}
candidates.push((session.updated_at, path));
}
candidates.sort_by(|a, b| b.0.cmp(&a.0));
let age_cutoff = (retention.max_age_days > 0)
.then(|| now_secs().saturating_sub(retention.max_age_days.saturating_mul(24 * 60 * 60)));
let mut collected = 0;
for (rank, (updated_at, snapshot)) in candidates.iter().enumerate() {
let over_count = retention.max_sessions > 0 && rank >= retention.max_sessions;
let too_old = age_cutoff.is_some_and(|cut| *updated_at < cut);
if !over_count && !too_old {
continue;
}
let journal = snapshot.with_extension("events.jsonl");
unlink(&journal);
if unlink(snapshot) {
snapshots.retain(|p| p != snapshot);
collected += 1;
}
}
if matches!(scope, SweepScope::Live(_)) {
return Ok(collected);
}
for journal in &journals {
let snapshot = journal
.to_string_lossy()
.strip_suffix(".events.jsonl")
.map(|stem| PathBuf::from(format!("{stem}.json")));
if snapshot.is_some_and(|p| snapshots.contains(&p)) {
continue;
}
unlink(journal);
}
Ok(collected)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdoptionOutcome {
Failed,
Preserved,
}
pub fn adopt_orphaned_sessions(state_dir: &Path) -> Vec<(String, AdoptionOutcome)> {
let Ok(entries) = std::fs::read_dir(state_dir) else {
return Vec::new();
};
let mut outcomes = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|x| x != "json") {
continue;
}
let Ok(mut session) = CoderSession::load(&path) else {
continue;
};
if session.state.is_terminal() {
continue;
}
let worktree_alive = session.state == CoderState::NeedsApproval
&& session.workspace_path.as_ref().is_some_and(|p| p.is_dir());
if worktree_alive {
outcomes.push((session.id.clone(), AdoptionOutcome::Preserved));
continue;
}
session.state = CoderState::Failed;
session.error = Some(match session.workspace_path.as_ref().filter(|path| path.is_dir()) {
Some(path) => format!(
"daemon restarted mid-session; unfinished work is preserved at {}. Execution stop is unconfirmed; inspect the retained work before continuing",
path.display()
),
None => "daemon restarted mid-session".to_string(),
});
session.failure_kind = Some("infrastructure".to_string());
session.updated_at = now_secs();
session.state_dir = Some(state_dir.to_path_buf());
if session.persist().is_ok() {
outcomes.push((session.id.clone(), AdoptionOutcome::Failed));
}
}
outcomes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_backbone_change_is_journaled_not_just_the_first() {
let dir = tempfile::tempdir().unwrap();
let journal = dir.path().join("coder-mf.events.jsonl");
let sink = EventSink::new("coder-mf", None, Some(journal.clone()));
sink.record_model_fallback("parslee/reasoning", "openai/gpt-5.6", "rate_limited");
sink.record_model_fallback("openai/gpt-5.6", "anthropic/claude-opus-5", "timed_out");
drop(sink);
let body = std::fs::read_to_string(&journal).expect("the journal exists");
let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(
lines.len(),
2,
"both transitions, not just the first: {body}"
);
assert!(body.contains("rate_limited"), "{body}");
assert!(body.contains("timed_out"), "{body}");
assert!(body.contains("parslee/reasoning"), "{body}");
assert!(body.contains("anthropic/claude-opus-5"), "{body}");
}
#[test]
fn coder_journal_is_diagnosable_per_tool() {
let dir = tempfile::tempdir().unwrap();
let journal = dir.path().join("coder-x.events.jsonl");
let sink = EventSink::new("coder-x", None, Some(journal.clone()));
for _ in 0..2 {
sink.emit(CoderEventKind::ToolResult {
tool: "run_command".into(),
ok: false,
preview: "exit 1 at runtime".into(),
});
}
sink.emit(CoderEventKind::ToolResult {
tool: "edit_file".into(),
ok: true,
preview: "ok".into(),
});
drop(sink);
let jsonl = std::fs::read_to_string(&journal).unwrap();
let report = car_eventlog::harness_adapt::diagnose_from_jsonl(&jsonl, 2);
assert!(
report
.interventions
.iter()
.any(|i| i.target == "run_command"),
"diagnose must tally run_command failures per-tool: {:?}",
report.interventions
);
assert!(
!report.interventions.iter().any(|i| i.target == "edit_file"),
"a succeeding tool must not be flagged"
);
}
fn session() -> (CoderSession, EventSink) {
(
CoderSession::new("/tmp/repo", "do it", EngineChoice::Native, 8, None),
EventSink::test_sink(),
)
}
fn init_repo(dir: &Path) {
for args in [
vec!["init", "-q", "-b", "main"],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn failed_with_keep_flag_retains_worktree() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let state_dir = tempfile::tempdir().unwrap();
let mut s = CoderSession::new(
repo.path(),
"x",
EngineChoice::Native,
2,
Some(state_dir.path().to_path_buf()),
);
s.keep_workspace_on_failure = true;
let worktree = s.provision_workspace().unwrap();
assert!(worktree.is_dir());
let sink = EventSink::test_sink();
s.transition(CoderState::Failed, &sink).unwrap();
assert!(s.workspace.is_none());
assert!(
worktree.is_dir(),
"worktree should be retained for postmortem"
);
assert_eq!(s.workspace_path.as_deref(), Some(worktree.as_path()));
let _ = std::process::Command::new("git")
.arg("-C")
.arg(repo.path())
.args(["worktree", "remove", "--force"])
.arg(&worktree)
.output();
}
#[test]
fn retained_edits_survive_failure_cancellation_and_restart() {
for conversation in [true, false] {
for stop in [Some(CoderState::Failed), Some(CoderState::Abandoned), None] {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let state_dir = tempfile::tempdir().unwrap();
let mut session = CoderSession::new(
repo.path(),
"unfinished edit",
EngineChoice::Native,
2,
Some(state_dir.path().into()),
);
if conversation {
session.discussion_id = Some("conversation".into());
} else {
session.keep_workspace_on_cancel = true;
}
session.state = CoderState::Running;
let path = session.provision_workspace().unwrap();
std::fs::write(path.join("unfinished.txt"), "retain this edit").unwrap();
if let Some(stop) = stop {
session.transition(stop, &EventSink::test_sink()).unwrap();
} else {
session.persist().unwrap();
std::mem::forget(session.workspace.take().unwrap());
adopt_orphaned_sessions(state_dir.path());
}
let saved =
CoderSession::load(&state_dir.path().join(format!("{}.json", session.id)))
.unwrap();
assert!(saved.state.is_terminal());
assert_eq!(saved.workspace_path.as_deref(), Some(path.as_path()));
assert_eq!(
std::fs::read_to_string(path.join("unfinished.txt")).unwrap(),
"retain this edit"
);
assert!(!repo.path().join("unfinished.txt").exists());
reap_worktree(repo.path(), &path);
}
}
}
#[test]
fn failed_without_keep_flag_reaps_worktree() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let state_dir = tempfile::tempdir().unwrap();
let mut s = CoderSession::new(
repo.path(),
"x",
EngineChoice::Native,
2,
Some(state_dir.path().to_path_buf()),
);
let worktree = s.provision_workspace().unwrap();
assert!(worktree.is_dir());
let sink = EventSink::test_sink();
s.transition(CoderState::Failed, &sink).unwrap();
assert!(s.workspace.is_none());
assert!(
!worktree.exists(),
"worktree should be reaped on failure by default"
);
}
#[test]
fn happy_path_transitions_are_legal() {
let (mut s, sink) = session();
for to in [
CoderState::ContractProposed,
CoderState::ContractProposed, CoderState::ContractConfirmed,
CoderState::Running,
CoderState::NeedsApproval,
CoderState::Merged,
] {
s.transition(to, &sink).unwrap();
}
assert!(s.state.is_terminal());
}
#[test]
fn illegal_jumps_are_rejected() {
let (mut s, sink) = session();
assert!(s.transition(CoderState::Running, &sink).is_err());
assert!(s.transition(CoderState::Merged, &sink).is_err());
assert!(s.transition(CoderState::NeedsApproval, &sink).is_err());
assert_eq!(s.state, CoderState::Created);
}
#[test]
fn any_non_terminal_state_can_fail_or_abandon() {
for terminal in [CoderState::Failed, CoderState::Abandoned] {
let (mut s, sink) = session();
s.transition(CoderState::ContractProposed, &sink).unwrap();
s.transition(terminal, &sink).unwrap();
assert!(s.transition(CoderState::Running, &sink).is_err());
assert!(s.transition(CoderState::Failed, &sink).is_err());
}
}
#[test]
fn event_seq_is_monotonic_and_session_tagged() {
let (sink, collected) = EventSink::collecting("coder-seq");
for _ in 0..5 {
sink.emit(CoderEventKind::PlanText { text: "x".into() });
}
let events = collected.lock().unwrap();
assert_eq!(events.len(), 5);
for (i, e) in events.iter().enumerate() {
assert_eq!(e.seq, i as u64);
assert_eq!(e.session_id, "coder-seq");
}
}
fn retained(
state_dir: &Path,
state: CoderState,
age_days: u64,
workspace: Option<PathBuf>,
) -> String {
let mut s = CoderSession::new(
"/tmp/repo",
"intent",
EngineChoice::Auto,
4,
Some(state_dir.to_path_buf()),
);
s.state = state;
s.workspace_path = workspace;
s.updated_at = now_secs().saturating_sub(age_days * 24 * 60 * 60);
s.persist().unwrap();
std::fs::write(state_dir.join(format!("{}.events.jsonl", s.id)), "{}\n").unwrap();
s.id
}
fn on_disk(state_dir: &Path, id: &str) -> bool {
state_dir.join(format!("{id}.json")).exists()
}
const KEEP_ALL: SessionRetention = SessionRetention {
max_sessions: 0,
max_age_days: 0,
};
#[test]
fn gc_evicts_beyond_the_count_cap_oldest_first() {
let dir = tempfile::tempdir().unwrap();
let ids: Vec<String> = (0..5)
.map(|age| retained(dir.path(), CoderState::Merged, age, None))
.collect();
let collected = gc_sessions(
dir.path(),
&SessionRetention {
max_sessions: 3,
..KEEP_ALL
},
SweepScope::Boot,
);
assert_eq!(collected, 2);
for id in &ids[..3] {
assert!(on_disk(dir.path(), id), "newest three must survive");
}
for id in &ids[3..] {
assert!(!on_disk(dir.path(), id), "oldest two must be collected");
}
}
#[test]
fn gc_evicts_snapshots_past_the_age_cap() {
let dir = tempfile::tempdir().unwrap();
let fresh = retained(dir.path(), CoderState::Reported, 3, None);
let stale = retained(dir.path(), CoderState::Merged, 40, None);
let collected = gc_sessions(
dir.path(),
&SessionRetention {
max_age_days: 30,
..KEEP_ALL
},
SweepScope::Boot,
);
assert_eq!(collected, 1);
assert!(on_disk(dir.path(), &fresh));
assert!(!on_disk(dir.path(), &stale));
}
#[test]
fn gc_never_collects_an_unfinished_session() {
let dir = tempfile::tempdir().unwrap();
for state in [
CoderState::Created,
CoderState::ContractProposed,
CoderState::ContractConfirmed,
CoderState::Running,
CoderState::NeedsApproval,
] {
let a = retained(dir.path(), state, 9999, None);
let b = retained(dir.path(), state, 9998, None);
let collected = gc_sessions(
dir.path(),
&SessionRetention {
max_sessions: 1,
max_age_days: 1,
},
SweepScope::Boot,
);
assert_eq!(collected, 0, "{state:?} must be exempt");
for id in [&a, &b] {
assert!(on_disk(dir.path(), id), "{state:?} must survive");
std::fs::remove_file(dir.path().join(format!("{id}.json"))).unwrap();
std::fs::remove_file(dir.path().join(format!("{id}.events.jsonl"))).unwrap();
}
}
}
#[test]
fn exempt_sessions_do_not_spend_the_count_budget() {
let dir = tempfile::tempdir().unwrap();
let live = tempfile::tempdir().unwrap();
let waiting = retained(dir.path(), CoderState::NeedsApproval, 500, None);
let kept = retained(
dir.path(),
CoderState::Failed,
500,
Some(live.path().to_path_buf()),
);
let newest = retained(dir.path(), CoderState::Merged, 1, None);
let older = retained(dir.path(), CoderState::Merged, 2, None);
let collected = gc_sessions(
dir.path(),
&SessionRetention {
max_sessions: 1,
max_age_days: 0,
},
SweepScope::Boot,
);
assert_eq!(collected, 1);
assert!(on_disk(dir.path(), &waiting));
assert!(on_disk(dir.path(), &kept));
assert!(on_disk(dir.path(), &newest));
assert!(!on_disk(dir.path(), &older));
assert_eq!(CoderSession::list(dir.path()).len(), 3);
}
#[test]
fn a_journal_with_no_snapshot_is_swept() {
let dir = tempfile::tempdir().unwrap();
let orphan = dir.path().join("coder-died-before-persist.events.jsonl");
std::fs::write(&orphan, "{}\n").unwrap();
let live = retained(dir.path(), CoderState::Merged, 0, None);
let live_journal = dir.path().join(format!("{live}.events.jsonl"));
gc_sessions(dir.path(), &KEEP_ALL, SweepScope::Boot);
assert!(!orphan.exists(), "an unreachable journal must be swept");
assert!(
live_journal.exists(),
"a journal whose snapshot is retained must be left alone"
);
}
#[test]
fn adoption_preserves_unfinished_edits_without_claiming_execution_stopped() {
let dir = tempfile::tempdir().unwrap();
let stranded = dir.path().join("worktrees/coder-crashed");
std::fs::create_dir_all(&stranded).unwrap();
let edited = stranded.join("unfinished.txt");
std::fs::write(&edited, "unfinished implementation\n").unwrap();
let id = retained(
dir.path(),
CoderState::Running,
9999,
Some(stranded.clone()),
);
let outcomes = adopt_orphaned_sessions(dir.path());
assert_eq!(outcomes, vec![(id.clone(), AdoptionOutcome::Failed)]);
assert_eq!(
std::fs::read_to_string(&edited).unwrap(),
"unfinished implementation\n"
);
let mut session = CoderSession::load(&dir.path().join(format!("{id}.json"))).unwrap();
assert_eq!(session.state, CoderState::Failed);
assert!(
!session.execution_stopped,
"a restart does not prove child tools stopped"
);
assert!(session
.error
.as_ref()
.unwrap()
.contains(stranded.to_str().unwrap()));
assert!(session
.error
.as_ref()
.unwrap()
.contains("Execution stop is unconfirmed"));
session.updated_at = now_secs().saturating_sub(9999 * 24 * 60 * 60);
session.state_dir = Some(dir.path().to_path_buf());
session.persist().unwrap();
let retention = SessionRetention {
max_sessions: 0,
max_age_days: 30,
};
assert_eq!(gc_sessions(dir.path(), &retention, SweepScope::Boot), 0);
assert!(on_disk(dir.path(), &id));
assert!(edited.exists());
std::fs::remove_dir_all(&stranded).unwrap();
assert_eq!(gc_sessions(dir.path(), &retention, SweepScope::Boot), 1);
assert!(!on_disk(dir.path(), &id));
}
#[test]
fn a_mid_lifetime_sweep_never_collects_a_session_the_registry_still_holds() {
let dir = tempfile::tempdir().unwrap();
let mut ids = Vec::new();
for _ in 0..3 {
let mut s = CoderSession::new(
Path::new("/tmp/repo"),
"x",
EngineChoice::Native,
2,
Some(dir.path().to_path_buf()),
);
s.state = CoderState::Merged;
s.updated_at = now_secs().saturating_sub(9999 * 24 * 60 * 60);
s.persist().unwrap();
ids.push(s.id.clone());
}
let retention = SessionRetention {
max_sessions: 0,
max_age_days: 30,
};
let live: std::collections::HashSet<String> = ids.iter().cloned().collect();
let collected = gc_sessions(dir.path(), &retention, SweepScope::Live(&live));
assert_eq!(
collected, 0,
"every id is registered; a mid-lifetime sweep must collect none"
);
for id in &ids {
assert!(
on_disk(dir.path(), id),
"{id} was collected out from under a live entry"
);
}
let mut partial = live.clone();
partial.remove(&ids[0]);
let collected = gc_sessions(dir.path(), &retention, SweepScope::Live(&partial));
assert_eq!(collected, 1, "the unregistered one is collectable");
assert!(!on_disk(dir.path(), &ids[0]));
assert!(on_disk(dir.path(), &ids[1]));
}
#[test]
fn a_mid_lifetime_sweep_leaves_orphan_journals_alone() {
let dir = tempfile::tempdir().unwrap();
let journal = dir.path().join("just-started.events.jsonl");
std::fs::write(&journal, "{}\n").unwrap();
let retention = SessionRetention {
max_sessions: 0,
max_age_days: 30,
};
gc_sessions(
dir.path(),
&retention,
SweepScope::Live(&std::collections::HashSet::new()),
);
assert!(
journal.exists(),
"a start that has not persisted yet must keep its journal"
);
gc_sessions(dir.path(), &retention, SweepScope::Boot);
assert!(
!journal.exists(),
"boot must still sweep a stranded journal"
);
}
#[test]
fn adoption_keeps_the_worktree_the_operator_asked_to_keep() {
let dir = tempfile::tempdir().unwrap();
let kept = dir.path().join("worktrees").join("coder-postmortem");
std::fs::create_dir_all(&kept).unwrap();
let mut s = CoderSession::new(
"/tmp/repo",
"intent",
EngineChoice::Auto,
4,
Some(dir.path().to_path_buf()),
);
s.state = CoderState::Running;
s.workspace_path = Some(kept.clone());
s.keep_workspace_on_failure = true;
s.persist().unwrap();
adopt_orphaned_sessions(dir.path());
assert!(
kept.is_dir(),
"`keep_workspace_on_failure` is the operator asking for exactly this"
);
assert_eq!(
gc_sessions(
dir.path(),
&SessionRetention {
max_sessions: 0,
max_age_days: 1,
},
SweepScope::Boot,
),
0
);
}
#[test]
fn gc_never_collects_a_session_whose_worktree_still_exists() {
let dir = tempfile::tempdir().unwrap();
let live = tempfile::tempdir().unwrap();
let kept = retained(
dir.path(),
CoderState::Failed,
9999,
Some(live.path().to_path_buf()),
);
let reaped = retained(
dir.path(),
CoderState::Failed,
9999,
Some(dir.path().join("worktrees").join("gone")),
);
let stray = dir.path().join("not-a-worktree");
std::fs::write(&stray, "").unwrap();
let not_a_worktree = retained(dir.path(), CoderState::Failed, 9999, Some(stray));
let collected = gc_sessions(
dir.path(),
&SessionRetention {
max_sessions: 0,
max_age_days: 1,
},
SweepScope::Boot,
);
assert_eq!(collected, 2);
assert!(on_disk(dir.path(), &kept), "a live worktree is not garbage");
assert!(!on_disk(dir.path(), &reaped));
assert!(!on_disk(dir.path(), ¬_a_worktree));
}
#[test]
fn gc_takes_the_journal_with_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let id = retained(dir.path(), CoderState::Merged, 99, None);
let journal = dir.path().join(format!("{id}.events.jsonl"));
assert!(journal.exists());
gc_sessions(
dir.path(),
&SessionRetention {
max_age_days: 30,
..KEEP_ALL
},
SweepScope::Boot,
);
assert!(!on_disk(dir.path(), &id));
assert!(
!journal.exists(),
"the journal is the larger artifact; retaining it alone keeps the \
bytes and drops the index that explains them"
);
}
#[test]
fn gc_with_both_caps_disabled_keeps_everything() {
let dir = tempfile::tempdir().unwrap();
let ids: Vec<String> = (0..4)
.map(|i| retained(dir.path(), CoderState::Merged, i * 1000, None))
.collect();
assert_eq!(gc_sessions(dir.path(), &KEEP_ALL, SweepScope::Boot), 0);
for id in &ids {
assert!(on_disk(dir.path(), id));
}
}
#[test]
fn gc_on_a_missing_state_dir_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("never-created");
assert_eq!(
gc_sessions(
&missing,
&SessionRetention {
max_sessions: 1,
max_age_days: 1
},
SweepScope::Boot,
),
0
);
}
#[test]
fn authoring_models_are_every_distinct_model_that_completed_a_turn() {
let dir = tempfile::tempdir().unwrap();
let sink = EventSink::new(
"coder-authors",
None,
Some(dir.path().join("coder-authors.events.jsonl")),
);
sink.record_turn_completed("empty_tool_calls", None, false, 3, "model-a", &[]);
sink.record_turn_completed("empty_tool_calls", None, false, 5, "model-b", &[]);
sink.record_turn_completed("max_turns", None, false, 9, "model-a", &[]);
sink.record_turn_completed("empty_tool_calls", None, false, 1, " ", &[]);
assert_eq!(sink.authoring_models(), vec!["model-a", "model-b"]);
}
#[test]
fn a_model_replaced_before_the_terminal_is_still_an_author() {
let dir = tempfile::tempdir().unwrap();
let sink = EventSink::new(
"coder-midturn",
None,
Some(dir.path().join("coder-midturn.events.jsonl")),
);
sink.record_turn_completed(
"empty_tool_calls",
None,
false,
4,
"finisher",
&["writer".to_string(), "finisher".to_string()],
);
let authors = sink.authoring_models();
assert!(
authors.contains(&"writer".to_string()),
"the model that wrote most of the change must be named: {authors:?}"
);
assert!(authors.contains(&"finisher".to_string()), "{authors:?}");
}
#[test]
fn a_record_without_models_served_still_attributes_its_terminal() {
let dir = tempfile::tempdir().unwrap();
let sink = EventSink::new(
"coder-legacy",
None,
Some(dir.path().join("coder-legacy.events.jsonl")),
);
sink.record_turn_completed("empty_tool_calls", None, false, 2, "only-model", &[]);
assert_eq!(sink.authoring_models(), vec!["only-model"]);
}
#[test]
fn a_session_with_no_journal_attributes_nothing() {
let sink = EventSink::test_sink();
assert!(sink.authoring_models().is_empty());
}
#[test]
fn snapshot_round_trips_without_workspace_handle() {
let dir = tempfile::tempdir().unwrap();
let mut s = CoderSession::new(
"/tmp/repo",
"intent",
EngineChoice::Auto,
4,
Some(dir.path().to_path_buf()),
);
s.contract = Some(OutcomeContract {
allow_credentials: false,
description: "d".into(),
checks: vec![],
});
s.persist().unwrap();
let loaded = CoderSession::load(&dir.path().join(format!("{}.json", s.id))).unwrap();
assert_eq!(loaded.id, s.id);
assert_eq!(loaded.state, CoderState::Created);
assert!(loaded.workspace.is_none());
assert!(loaded.contract.is_some());
let listed = CoderSession::list(dir.path());
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, s.id);
}
#[test]
fn event_json_shape_is_ws_friendly() {
let e = CoderEvent {
session_id: "coder-x".into(),
seq: 3,
ts: 1,
kind: CoderEventKind::CheckStarted {
name: "tests".into(),
},
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["type"], "check_started");
assert_eq!(v["name"], "tests");
assert_eq!(v["seq"], 3);
}
fn write_snapshot(dir: &Path, state: CoderState, workspace_path: Option<PathBuf>) -> String {
let mut s = CoderSession::new(
"/tmp/repo",
"intent",
EngineChoice::Native,
4,
Some(dir.to_path_buf()),
);
s.state = state;
s.workspace_path = workspace_path;
s.persist().unwrap();
s.id
}
fn reload(dir: &Path, id: &str) -> CoderSession {
CoderSession::load(&dir.join(format!("{id}.json"))).unwrap()
}
#[test]
fn adoption_fails_running_and_confirmed_orphans() {
let dir = tempfile::tempdir().unwrap();
let running = write_snapshot(dir.path(), CoderState::Running, None);
let confirmed = write_snapshot(dir.path(), CoderState::ContractConfirmed, None);
let created = write_snapshot(dir.path(), CoderState::Created, None);
let proposed = write_snapshot(dir.path(), CoderState::ContractProposed, None);
let outcomes = adopt_orphaned_sessions(dir.path());
assert_eq!(outcomes.len(), 4);
assert!(outcomes.iter().all(|(_, o)| *o == AdoptionOutcome::Failed));
for id in [&running, &confirmed, &created, &proposed] {
let s = reload(dir.path(), id);
assert_eq!(s.state, CoderState::Failed, "{id} should be failed");
assert_eq!(s.error.as_deref(), Some("daemon restarted mid-session"));
}
}
#[test]
fn adoption_preserves_needs_approval_with_live_worktree() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path().join("worktrees").join("wt-1");
std::fs::create_dir_all(&worktree).unwrap();
let id = write_snapshot(
dir.path(),
CoderState::NeedsApproval,
Some(worktree.clone()),
);
let outcomes = adopt_orphaned_sessions(dir.path());
assert_eq!(outcomes, vec![(id.clone(), AdoptionOutcome::Preserved)]);
let s = reload(dir.path(), &id);
assert_eq!(s.state, CoderState::NeedsApproval);
assert!(s.error.is_none());
assert_eq!(s.workspace_path.as_deref(), Some(worktree.as_path()));
}
#[test]
fn adoption_fails_needs_approval_when_worktree_gone() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("worktrees").join("vanished");
let id = write_snapshot(dir.path(), CoderState::NeedsApproval, Some(gone));
let outcomes = adopt_orphaned_sessions(dir.path());
assert_eq!(outcomes, vec![(id.clone(), AdoptionOutcome::Failed)]);
let s = reload(dir.path(), &id);
assert_eq!(s.state, CoderState::Failed);
assert_eq!(s.error.as_deref(), Some("daemon restarted mid-session"));
}
#[test]
fn adoption_leaves_terminal_snapshots_alone() {
let dir = tempfile::tempdir().unwrap();
let merged = write_snapshot(dir.path(), CoderState::Merged, None);
let failed = write_snapshot(dir.path(), CoderState::Failed, None);
let abandoned = write_snapshot(dir.path(), CoderState::Abandoned, None);
let outcomes = adopt_orphaned_sessions(dir.path());
assert!(
outcomes.is_empty(),
"terminal snapshots must not be adopted"
);
assert_eq!(reload(dir.path(), &merged).state, CoderState::Merged);
assert_eq!(reload(dir.path(), &failed).state, CoderState::Failed);
assert_eq!(reload(dir.path(), &abandoned).state, CoderState::Abandoned);
}
#[test]
fn adopted_after_restart_carries_the_infrastructure_failure_kind() {
let dir = tempfile::tempdir().unwrap();
let running = write_snapshot(dir.path(), CoderState::Running, None);
let gone = dir.path().join("worktrees").join("vanished");
let approval = write_snapshot(dir.path(), CoderState::NeedsApproval, Some(gone));
adopt_orphaned_sessions(dir.path());
for id in [&running, &approval] {
let s = reload(dir.path(), id);
assert_eq!(s.state, CoderState::Failed, "{id}");
assert_eq!(
s.failure_kind.as_deref(),
Some("infrastructure"),
"{id}: a restart is not a judged loss"
);
}
}
#[test]
fn adoption_is_a_noop_on_missing_dir() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("never-created");
assert!(adopt_orphaned_sessions(&missing).is_empty());
}
#[test]
fn needs_you_covers_all_four_kinds_and_null() {
use CoderState::*;
assert_eq!(
needs_you_from(ContractProposed, false, false, None),
Some(NeedsYou::Contract)
);
assert_eq!(
needs_you_from(NeedsApproval, false, false, None),
Some(NeedsYou::Approval)
);
assert_eq!(
needs_you_from(Running, true, false, None),
Some(NeedsYou::Question)
);
assert_eq!(
needs_you_from(Running, false, true, None),
Some(NeedsYou::Auth)
);
assert_eq!(needs_you_from(Running, false, false, None), None);
for state in [Created, ContractConfirmed, Merged, Failed, Abandoned] {
assert_eq!(needs_you_from(state, false, false, None), None, "{state:?}");
assert_eq!(needs_you_from(state, true, true, None), None, "{state:?}");
}
}
#[test]
fn a_parked_question_outranks_an_outstanding_sign_in() {
assert_eq!(
needs_you_from(CoderState::Running, true, true, None),
Some(NeedsYou::Question)
);
}
#[test]
fn needs_you_labels_and_wire_values_round_trip() {
for (kind, wire, label) in [
(
NeedsYou::Contract,
"contract",
"contract awaiting confirmation",
),
(NeedsYou::Question, "question", "question waiting"),
(NeedsYou::Approval, "approval", "diff ready for approval"),
(NeedsYou::Auth, "auth", "sign-in needed"),
] {
assert_eq!(kind.as_str(), wire);
assert_eq!(kind.label(), label);
assert_eq!(NeedsYou::parse(wire), Some(kind));
}
assert_eq!(NeedsYou::parse("nonsense"), None);
}
#[test]
fn the_input_gate_carries_and_releases_its_prompt() {
let gate = UserInputGate::new();
assert!(!gate.is_pending());
assert_eq!(gate.pending_prompt(), None);
let _rx = gate.park("Which database should this target?");
assert!(gate.is_pending());
assert_eq!(
gate.pending_prompt().as_deref(),
Some("Which database should this target?")
);
gate.fulfill("postgres".into()).unwrap();
assert!(!gate.is_pending());
assert_eq!(gate.pending_prompt(), None);
let _rx = gate.park("again?");
gate.clear();
assert_eq!(gate.pending_prompt(), None);
}
#[test]
fn an_old_format_snapshot_still_deserializes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("coder-legacy.json");
std::fs::write(
&path,
r#"{
"id": "coder-legacy",
"repo": "/tmp/repo",
"intent": "make it work",
"engine": "native",
"state": "failed",
"iterations": 3,
"max_iterations": 8,
"keep_workspace_on_failure": false,
"last_check_results": [],
"created_at": 100,
"updated_at": 200,
"error": "contract not satisfied after 3 iteration(s)"
}"#,
)
.unwrap();
let loaded = CoderSession::load(&path).expect("legacy snapshot must still load");
assert_eq!(loaded.id, "coder-legacy");
assert_eq!(loaded.state, CoderState::Failed);
assert_eq!(loaded.failure_kind, None);
assert_eq!(loaded.discussion_id, None);
assert!(loaded.discussion_constraints.is_empty());
assert_eq!(loaded.base, None);
let listed = CoderSession::list(dir.path());
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "coder-legacy");
}
#[test]
fn attention_fields_survive_a_snapshot_round_trip() {
let dir = tempfile::tempdir().unwrap();
let mut s = CoderSession::new(
"/tmp/repo",
"intent",
EngineChoice::Native,
4,
Some(dir.path().to_path_buf()),
);
s.state = CoderState::Failed;
s.failure_kind = Some("budget_exhausted".into());
s.discussion_id = Some("disc-abc".into());
s.discussion_constraints = vec!["Preserve personal.txt".into()];
s.result_branch = Some("car/coder/ab12cd34".into());
s.result_commit = Some("123456789abcdef0123456789abcdef0123456789a".into());
s.model = Some("parslee/reasoning".into());
s.base = Some("0123456789abcdef0123456789abcdef01234567".into());
s.persist().unwrap();
let loaded = CoderSession::load(&dir.path().join(format!("{}.json", s.id))).unwrap();
assert_eq!(loaded.failure_kind.as_deref(), Some("budget_exhausted"));
assert_eq!(loaded.discussion_id.as_deref(), Some("disc-abc"));
assert_eq!(loaded.discussion_constraints, s.discussion_constraints);
assert_eq!(loaded.result_branch.as_deref(), Some("car/coder/ab12cd34"));
assert_eq!(loaded.result_commit, s.result_commit);
assert_eq!(loaded.model.as_deref(), Some("parslee/reasoning"));
assert_eq!(
loaded.base.as_deref(),
Some("0123456789abcdef0123456789abcdef01234567")
);
}
#[test]
fn contract_revision_rejected_is_named_and_ws_shaped() {
let kind = CoderEventKind::ContractRevisionRejected {
request: "also verify the Windows path".into(),
reason: "the redrafted contract is invalid: contract has no checks".into(),
};
assert_eq!(coder_event_name(&kind), "coder.contract_revision_rejected");
let v = serde_json::to_value(CoderEvent {
session_id: "coder-x".into(),
seq: 4,
ts: 1,
kind,
})
.unwrap();
assert_eq!(v["type"], "contract_revision_rejected");
assert_eq!(v["request"], "also verify the Windows path");
assert!(v["reason"].as_str().unwrap().contains("no checks"));
}
}