fix: slim down token usage
This commit is contained in:
@@ -6,7 +6,7 @@ use anyhow::Result;
|
||||
use crate::app::{AppEvent, ControlCommand};
|
||||
use crate::controller::{executor, goal_checker, planner, verifier};
|
||||
use crate::model::{
|
||||
ControllerPhase, GoalStatus, SessionEntry, SessionSource, SessionStream, TaskConfig,
|
||||
ControllerPhase, GoalStatus, SessionEntry, SessionSource, SessionStream, StepStatus, TaskConfig,
|
||||
};
|
||||
use crate::repo;
|
||||
use crate::storage::toon;
|
||||
@@ -30,32 +30,44 @@ pub fn runtime_loop(
|
||||
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)?;
|
||||
let _ = event_tx.send(AppEvent::Snapshot {
|
||||
goal_md,
|
||||
standards_md,
|
||||
plan: plan.clone(),
|
||||
state: state.clone(),
|
||||
});
|
||||
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.notes.push("Stopped by user".to_string());
|
||||
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)) => {
|
||||
@@ -102,8 +114,10 @@ pub fn runtime_loop(
|
||||
|
||||
if goal_checker::is_done(&plan, &state)? {
|
||||
state.phase = ControllerPhase::Done;
|
||||
state.clear_stop_reason();
|
||||
state.goal_status = GoalStatus::Done;
|
||||
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,
|
||||
@@ -115,7 +129,8 @@ pub fn runtime_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
if state.replan_required || plan.has_no_actionable_steps() {
|
||||
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,
|
||||
@@ -129,16 +144,29 @@ pub fn runtime_loop(
|
||||
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) = planner::next_step(&plan, &state) else {
|
||||
let Some(step) = resumable_step.or_else(|| planner::next_step(&plan, &state)) else {
|
||||
state.phase = ControllerPhase::Blocked;
|
||||
state.goal_status = GoalStatus::Blocked;
|
||||
state.notes.push(
|
||||
"No actionable step remained and autonomous replan produced nothing.".to_string(),
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -150,49 +178,68 @@ pub fn runtime_loop(
|
||||
body: format!("Executing {}", step.title),
|
||||
run_id: repo::next_run_id(),
|
||||
}));
|
||||
state.clear_stop_reason();
|
||||
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;
|
||||
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, &plan, &step, &event_tx)?;
|
||||
if goal_checker::needs_goal_clarification(&exec) {
|
||||
state.phase = ControllerPhase::Planning;
|
||||
state.notes.push(format!(
|
||||
"Execution requested goal clarification while processing {}",
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -200,7 +247,215 @@ pub fn runtime_loop(
|
||||
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 emit_snapshot(
|
||||
event_tx: &Sender<AppEvent>,
|
||||
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<crate::model::PlanStep> {
|
||||
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 recover_stale_execution_state(
|
||||
config: &TaskConfig,
|
||||
plan: &mut crate::model::Plan,
|
||||
state: &mut crate::model::ControllerState,
|
||||
event_tx: &Sender<AppEvent>,
|
||||
) -> Result<bool> {
|
||||
if state.current_step_id.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let active_steps = plan.active_step_ids();
|
||||
if 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;
|
||||
let reason = 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 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user