use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use anyhow::Result; use crate::app::{AppEvent, ControlCommand}; use crate::controller::{executor, goal_checker, planner, verifier}; use crate::model::{ ControllerPhase, GoalStatus, PlannerResponse, SessionEntry, SessionSource, SessionStream, StepStatus, TaskConfig, }; use crate::prompt; use crate::repo; use crate::storage::toon; pub fn runtime_loop( repo_root: PathBuf, config: TaskConfig, control_rx: Receiver, event_tx: Sender, ) -> Result<()> { toon::ensure_controller_files(&config)?; let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Controller, stream: SessionStream::Status, title: "Session".to_string(), tag: Some(config.controller_id()), body: format!("Controller task loaded from {}", config.plan_file.display()), run_id: repo::next_run_id(), })); loop { let mut plan = toon::read_plan(&config.plan_file)?; let mut state = toon::read_state(&config.state_file)?; if recover_stale_execution_state(&config, &mut plan, &mut state, &event_tx)? { continue; } let goal_md = toon::read_markdown(&config.goal_file)?; let standards_md = toon::read_markdown(&config.standards_file)?; refresh_usage_state(&mut state); if matches!(state.phase, ControllerPhase::Planning) && state.planning_session.pending_input.is_some() { let pending_input = state .planning_session .pending_input .as_deref() .unwrap_or_default() .to_string(); process_planning_submission(&repo_root, &config, &pending_input, &event_tx)?; continue; } emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); match control_rx.try_recv() { Ok(ControlCommand::Quit) => break, Ok(ControlCommand::Pause) => { crate::controller::state::pause(&mut state); toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } Ok(ControlCommand::Resume) => { crate::controller::state::resume(&mut state); toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } Ok(ControlCommand::Stop) => { state.phase = ControllerPhase::Blocked; state.goal_status = GoalStatus::Blocked; state.set_stop_reason("Stopped by user."); let reason = state .phase_notice() .unwrap_or_else(|| "Stopped by user.".to_string()); toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Warning, stream: SessionStream::Status, title: "Notice".to_string(), tag: Some(config.controller_id()), body: reason, run_id: repo::next_run_id(), })); continue; } Ok(ControlCommand::Submit(_text)) => { if matches!(state.phase, ControllerPhase::Planning) { let persisted_state = toon::read_state(&config.state_file)?; if let Some(pending_input) = persisted_state.planning_session.pending_input.as_deref() { process_planning_submission( &repo_root, &config, pending_input, &event_tx, )?; } } else { let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Warning, stream: SessionStream::Status, title: "Execution".to_string(), tag: Some(config.controller_id()), body: "Execution is autonomous. Use /pause, /resume, /stop, /status, /diff, /tests, or /goal update." .to_string(), run_id: repo::next_run_id(), })); } continue; } Err(TryRecvError::Disconnected) => break, Err(TryRecvError::Empty) => {} } match state.phase { ControllerPhase::Planning | ControllerPhase::Paused | ControllerPhase::Blocked | ControllerPhase::Done => { std::thread::sleep(std::time::Duration::from_millis(100)); continue; } ControllerPhase::Executing => {} } if goal_checker::is_done(&plan, &state)? { state.phase = ControllerPhase::Done; state.clear_stop_reason(); state.goal_status = GoalStatus::Done; let completion_summary = build_completion_summary(&plan); state.set_completion_summary(completion_summary.clone()); state.history.push(crate::model::HistoryEvent { timestamp: repo::now_timestamp(), kind: "goal-complete".to_string(), detail: completion_summary.clone(), }); toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Controller, stream: SessionStream::Status, title: "Goal".to_string(), tag: Some(config.controller_id()), body: completion_summary, run_id: repo::next_run_id(), })); continue; } let resumable_step = resumable_step(&plan, &state); if resumable_step.is_none() && (state.replan_required || plan.has_no_actionable_steps()) { let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Planner, stream: SessionStream::Status, title: "Planner".to_string(), tag: Some(config.controller_id()), body: "Refining plan".to_string(), run_id: repo::next_run_id(), })); plan = planner::refine_without_user_input(&repo_root, &config, &plan, &state, &event_tx)?; state.replan_required = false; toon::write_plan(&config.plan_file, &plan)?; toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } let Some(step) = resumable_step.or_else(|| planner::next_step(&plan, &state)) else { state.phase = ControllerPhase::Blocked; state.goal_status = GoalStatus::Blocked; state.set_stop_reason( "No actionable step remained and autonomous replan produced nothing.", ); let reason = state .phase_notice() .unwrap_or_else(|| "Controller is blocked.".to_string()); toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Warning, stream: SessionStream::Status, title: "Notice".to_string(), tag: Some(config.controller_id()), body: reason, run_id: repo::next_run_id(), })); continue; }; let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Executor, stream: SessionStream::Status, title: "Step".to_string(), tag: Some(step.id.clone()), body: format!("Executing {}", step.title), run_id: repo::next_run_id(), })); state.clear_stop_reason(); state.clear_completion_summary(); state.replan_required = false; state .blocked_steps .retain(|blocked_step| blocked_step != &step.id); plan.mark_active(&step.id); state.current_step_id = Some(step.id.clone()); state.iteration += 1; refresh_usage_state(&mut state); toon::write_plan(&config.plan_file, &plan)?; toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); let exec = executor::implement(&repo_root, &config, &state, &plan, &step, &event_tx)?; if goal_checker::needs_goal_clarification(&exec) { state.phase = ControllerPhase::Planning; state.set_stop_reason(format!( "Execution requested goal clarification while processing {}.", step.id )); toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } let verification = verifier::verify_step(&repo_root, &exec, &event_tx)?; if !verification.passed { plan.mark_blocked(&step.id); plan.append_step_note(&step.id, verification.summary.as_str()); state.last_verification = Some(verification); state.blocked_steps.push(step.id.clone()); state.replan_required = true; state.set_stop_reason(format!("Verification failed for {}.", step.id)); toon::write_plan(&config.plan_file, &plan)?; toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } let cleanup = verifier::verify_cleanup(&config, &step, &exec)?; if !cleanup.passed { plan.mark_todo(&step.id); plan.append_step_note(&step.id, cleanup.summary.as_str()); state.last_cleanup_summary = Some(cleanup); state.set_stop_reason(format!( "Cleanup requirements were not satisfied for {}.", step.id )); toon::write_plan(&config.plan_file, &plan)?; toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } let tests = verifier::run_tests(&repo_root, &exec, &event_tx)?; if !tests.passed { plan.mark_todo(&step.id); plan.append_step_note(&step.id, tests.summary.as_str()); state.last_full_test_summary = Some(tests); state.set_stop_reason(format!("Tests failed for {}.", step.id)); toon::write_plan(&config.plan_file, &plan)?; toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); continue; } plan.append_step_note(&step.id, completion_note(&exec)); plan.mark_done(&step.id); state.complete_step(&step, verification, cleanup, tests); toon::write_plan(&config.plan_file, &plan)?; toon::write_state(&config.state_file, &state)?; emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); } Ok(()) } fn refresh_usage_state(state: &mut crate::model::ControllerState) { let snapshot = crate::process::refresh_usage_snapshot(state); crate::process::persist_usage_snapshot(state, &snapshot); } fn process_planning_submission( repo_root: &std::path::Path, config: &TaskConfig, latest_user_input: &str, event_tx: &Sender, ) -> Result { let response = crate::planning::session::advance( repo_root, config, latest_user_input, event_tx, )?; if let Some(question) = response.question.clone() { let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Planner, stream: SessionStream::Status, title: "Question".to_string(), tag: Some(config.controller_id()), body: question, run_id: repo::next_run_id(), })); } Ok(response) } fn emit_snapshot( event_tx: &Sender, goal_md: &str, standards_md: &str, plan: &crate::model::Plan, state: &crate::model::ControllerState, ) { let _ = event_tx.send(AppEvent::Snapshot { goal_md: goal_md.to_string(), standards_md: standards_md.to_string(), plan: plan.clone(), state: state.clone(), }); } fn resumable_step( plan: &crate::model::Plan, state: &crate::model::ControllerState, ) -> Option { let current_step_id = state.current_step_id.as_deref()?; plan.steps .iter() .find(|step| { step.id == current_step_id && matches!( step.status, StepStatus::Todo | StepStatus::Active | StepStatus::Blocked ) }) .cloned() } fn completion_note(exec: &crate::model::ExecutionResponse) -> String { let mut parts = Vec::new(); let summary = exec.summary.trim(); if !summary.is_empty() { parts.push(prompt::truncate_text(summary, 180)); } let notes = exec .notes .iter() .map(|note| note.trim()) .filter(|note| !note.is_empty()) .map(|note| prompt::truncate_text(note, 120)) .collect::>(); if !notes.is_empty() { parts.push(format!("Agent notes: {}", notes.join("; "))); } if parts.is_empty() { "Completed the step.".to_string() } else { prompt::truncate_text(&parts.join(" "), 240) } } fn build_completion_summary(plan: &crate::model::Plan) -> String { let completed_steps = plan .steps .iter() .filter(|step| step.status.is_done()) .collect::>(); if completed_steps.is_empty() { return "Goal complete.".to_string(); } let mut details = completed_steps .iter() .take(4) .map(|step| { let mut item = format!( "{}: {}", step.id, prompt::truncate_text(&step.title, 80) ); if !step.notes.trim().is_empty() { item.push_str(" - "); item.push_str(&prompt::truncate_text(&step.notes, 120)); } item }) .collect::>(); let omitted = completed_steps.len().saturating_sub(details.len()); if omitted > 0 { details.push(format!( "... and {} more completed step{}", omitted, if omitted == 1 { "" } else { "s" } )); } prompt::truncate_text( &format!( "Completed {} step{}: {}", completed_steps.len(), if completed_steps.len() == 1 { "" } else { "s" }, details.join("; ") ), 320, ) } fn recover_stale_execution_state( config: &TaskConfig, plan: &mut crate::model::Plan, state: &mut crate::model::ControllerState, event_tx: &Sender, ) -> Result { let current_step_id = state.current_step_id.clone(); let has_stale_current_step = if let Some(current_step_id) = ¤t_step_id { !plan.steps.iter().any(|step| { step.id == *current_step_id && matches!( step.status, StepStatus::Todo | StepStatus::Active | StepStatus::Blocked ) }) } else { false }; if !has_stale_current_step && state.current_step_id.is_some() { return Ok(false); } let active_steps = plan.active_step_ids(); if !has_stale_current_step && active_steps.is_empty() { return Ok(false); } for step_id in &active_steps { plan.mark_todo(step_id); plan.append_step_note( step_id, "Controller recovered this step from stale active state and returned it to todo.", ); } state.phase = ControllerPhase::Executing; state.goal_status = GoalStatus::InProgress; state.clear_stop_reason(); state.replan_required = false; state.current_step_id = None; let reason = if has_stale_current_step && !active_steps.is_empty() { format!( "Recovered stale execution state for {}. Cleared current_step_id {}. Reset {} to todo.", config.controller_id(), current_step_id.unwrap_or_default(), active_steps.join(", ") ) } else if has_stale_current_step { format!( "Recovered stale execution state for {}. Cleared current_step_id {}.", config.controller_id(), current_step_id.unwrap_or_default() ) } else { format!( "Recovered stale active step state for {}. Reset {} to todo.", config.controller_id(), active_steps.join(", ") ) }; state.notes.push(reason.clone()); toon::write_plan(&config.plan_file, plan)?; toon::write_state(&config.state_file, state)?; let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Controller, stream: SessionStream::Status, title: "Notice".to_string(), tag: Some(config.controller_id()), body: reason, run_id: repo::next_run_id(), })); Ok(true) } #[cfg(test)] mod tests { use std::sync::mpsc; use tempfile::tempdir; use super::*; use crate::model::{ControllerState, Plan, PlanStep, StepStatus}; use crate::storage::toon; #[test] fn recovers_stale_active_step_without_current_step() { let temp = tempdir().expect("tempdir"); let mut config = TaskConfig::default_for("stale-active"); let root = temp.path().join(".agent/controllers/stale-active"); config.goal_file = root.join("goal.md"); config.plan_file = root.join("plan.toon"); config.state_file = root.join("state.toon"); config.standards_file = root.join("standards.md"); let mut plan = Plan { version: 1, goal_summary: "goal".to_string(), steps: vec![PlanStep { id: "s1".to_string(), title: "Scope".to_string(), status: StepStatus::Active, attempts: 1, ..PlanStep::default() }], }; let mut state = ControllerState { phase: ControllerPhase::Blocked, goal_status: GoalStatus::Blocked, ..ControllerState::default() }; state .set_stop_reason("No actionable step remained and autonomous replan produced nothing."); toon::ensure_controller_files(&config).expect("ensure files"); let (event_tx, event_rx) = mpsc::channel(); let recovered = recover_stale_execution_state(&config, &mut plan, &mut state, &event_tx) .expect("recover"); assert!(recovered); assert!(matches!(plan.steps[0].status, StepStatus::Todo)); assert!(plan.steps[0].notes.contains("stale active state")); assert!(matches!(state.phase, ControllerPhase::Executing)); assert!(matches!(state.goal_status, GoalStatus::InProgress)); assert!(state.stop_reason.is_none()); let event = event_rx.recv().expect("notice event"); match event { AppEvent::Session(entry) => assert!(entry.body.contains("Reset s1 to todo")), other => panic!("unexpected event: {other:?}"), } } #[test] fn recovers_stale_current_step_reference() { let temp = tempdir().expect("tempdir"); let mut config = TaskConfig::default_for("stale-current"); let root = temp.path().join(".agent/controllers/stale-current"); config.goal_file = root.join("goal.md"); config.plan_file = root.join("plan.toon"); config.state_file = root.join("state.toon"); config.standards_file = root.join("standards.md"); let mut plan = Plan { version: 1, goal_summary: "goal".to_string(), steps: vec![PlanStep { id: "s1".to_string(), title: "Scope".to_string(), status: StepStatus::Done, ..PlanStep::default() }], }; let mut state = ControllerState { phase: ControllerPhase::Blocked, goal_status: GoalStatus::Blocked, current_step_id: Some("s1".to_string()), ..ControllerState::default() }; toon::ensure_controller_files(&config).expect("ensure files"); let (event_tx, event_rx) = mpsc::channel(); let recovered = recover_stale_execution_state(&config, &mut plan, &mut state, &event_tx) .expect("recover"); assert!(recovered); assert!(matches!(state.current_step_id, None)); assert!(matches!(state.phase, ControllerPhase::Executing)); assert!(matches!(state.goal_status, GoalStatus::InProgress)); assert!(state.stop_reason.is_none()); let event = event_rx.recv().expect("notice event"); match event { AppEvent::Session(entry) => { assert!(entry.body.contains("Cleared current_step_id s1")); } other => panic!("unexpected event: {other:?}"), } } #[test] fn resumable_step_prefers_current_blocked_or_active_step() { let plan = Plan { version: 1, goal_summary: "goal".to_string(), steps: vec![ PlanStep { id: "s1".to_string(), title: "Scope".to_string(), status: StepStatus::Blocked, ..PlanStep::default() }, PlanStep { id: "s2".to_string(), title: "Other".to_string(), status: StepStatus::Done, ..PlanStep::default() }, ], }; let state = ControllerState { phase: ControllerPhase::Blocked, goal_status: GoalStatus::Blocked, current_step_id: Some("s1".to_string()), replan_required: true, ..ControllerState::default() }; let resumed = resumable_step(&plan, &state).expect("expected resumable step"); assert_eq!(resumed.id, "s1"); } #[test] fn emit_snapshot_clones_current_plan_and_state() { let (event_tx, event_rx) = mpsc::channel(); let plan = Plan { version: 1, goal_summary: "goal".to_string(), steps: vec![PlanStep { id: "s1".to_string(), title: "Scope".to_string(), status: StepStatus::Active, ..PlanStep::default() }], }; let state = ControllerState { phase: ControllerPhase::Executing, current_step_id: Some("s1".to_string()), ..ControllerState::default() }; emit_snapshot(&event_tx, "goal body", "standards body", &plan, &state); let event = event_rx.recv().expect("snapshot event"); match event { AppEvent::Snapshot { goal_md, standards_md, plan, state, } => { assert_eq!(goal_md, "goal body"); assert_eq!(standards_md, "standards body"); assert!(matches!(plan.steps[0].status, StepStatus::Active)); assert_eq!(state.current_step_id.as_deref(), Some("s1")); } other => panic!("unexpected event: {other:?}"), } } #[test] fn completion_note_uses_execution_summary_and_notes() { let note = completion_note(&crate::model::ExecutionResponse { summary: "Implemented the board note flow".to_string(), notes: vec![ "Kept the change localized to completion handling".to_string(), "Verified the board still renders done steps".to_string(), ], ..crate::model::ExecutionResponse::default() }); assert!(note.contains("Implemented the board note flow")); assert!(note.contains("Agent notes:")); assert!(note.contains("Kept the change localized")); } #[test] fn build_completion_summary_lists_done_steps() { let plan = Plan { version: 1, goal_summary: "goal".to_string(), steps: vec![ PlanStep { id: "s1".to_string(), title: "First".to_string(), notes: "Finished the first change.".to_string(), status: StepStatus::Done, ..PlanStep::default() }, PlanStep { id: "s2".to_string(), title: "Second".to_string(), notes: "Finished the second change.".to_string(), status: StepStatus::Done, ..PlanStep::default() }, ], }; let summary = build_completion_summary(&plan); assert!(summary.contains("Completed 2 steps")); assert!(summary.contains("s1: First")); assert!(summary.contains("s2: Second")); } }