diff options
Diffstat (limited to 'makima/src/orchestration/directive.rs')
| -rw-r--r-- | makima/src/orchestration/directive.rs | 346 |
1 files changed, 343 insertions, 3 deletions
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 8b3ae7e..22279e8 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -56,7 +56,7 @@ impl DirectiveOrchestrator { "Directive needs planning — spawning planning task" ); - let plan = build_planning_prompt(&directive, &[], 1, &[]); + let plan = build_planning_prompt(&directive, &[], 1, &[], None); if let Err(e) = self .spawn_orchestrator_task( @@ -477,8 +477,20 @@ impl DirectiveOrchestrator { let goal_history = repository::get_directive_goal_history(&self.pool, directive.id, 3).await?; - let plan = - build_planning_prompt(&directive, &existing_steps, generation, &goal_history); + // If steps are currently running (or recently completed), build a + // WORK IN PROGRESS summary for the planner so it doesn't re-issue + // already-running work. We only include this section when there + // really is work in flight — pure pending plans don't need it. + let progress_summary = + summarize_in_progress_steps(&existing_steps); + + let plan = build_planning_prompt( + &directive, + &existing_steps, + generation, + &goal_history, + progress_summary.as_deref(), + ); if let Err(e) = self .spawn_orchestrator_task( @@ -1390,15 +1402,97 @@ pub async fn trigger_completion_task( Ok(task.id) } +/// Summarize currently-running and recently-completed steps for the planner. +/// +/// Returns `None` when there is no in-flight or recently-completed work to +/// report; otherwise returns a multi-line block listing running steps first +/// (these are the ones the planner most needs to be aware of so it doesn't +/// re-issue them) followed by recently completed steps. +fn summarize_in_progress_steps( + steps: &[crate::db::models::DirectiveStep], +) -> Option<String> { + let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + + for step in steps { + match step.status.as_str() { + "running" => running.push(step), + "completed" => completed.push(step), + _ => {} + } + } + + if running.is_empty() && completed.is_empty() { + return None; + } + + let mut out = String::new(); + if !running.is_empty() { + out.push_str("Currently running:\n"); + for step in &running { + out.push_str(&format!( + " • {} (id: {}){}\n", + step.name, + step.id, + step.description + .as_deref() + .map(|d| format!(" — {}", d)) + .unwrap_or_default() + )); + } + } + if !completed.is_empty() { + if !running.is_empty() { + out.push('\n'); + } + out.push_str("Recently completed (work already done — do not re-issue):\n"); + for step in &completed { + out.push_str(&format!( + " • {} (id: {}){}\n", + step.name, + step.id, + step.description + .as_deref() + .map(|d| format!(" — {}", d)) + .unwrap_or_default() + )); + } + } + + Some(out) +} + /// Build the planning prompt for a directive. +/// +/// `progress_summary` — when supplied, a `WORK IN PROGRESS` section is rendered +/// near the top of the prompt so the (re)planning task knows what step work is +/// currently in flight or recently completed. This is used by replanning when +/// steps are running but the planner has finished, so the new plan can take +/// in-progress work into account instead of re-issuing it. fn build_planning_prompt( directive: &crate::db::models::Directive, existing_steps: &[crate::db::models::DirectiveStep], generation: i32, goal_history: &[crate::db::models::DirectiveGoalHistory], + progress_summary: Option<&str>, ) -> String { let mut prompt = String::new(); + if let Some(progress) = progress_summary { + let trimmed = progress.trim(); + if !trimmed.is_empty() { + prompt.push_str("── WORK IN PROGRESS ──\n"); + prompt.push_str( + "Steps from the previous plan are already executing or recently completed. \ + Take this into account when revising the plan: do not re-issue work that is \ + already underway, and prefer to extend / refine the in-flight chain rather \ + than rebuild it.\n\n", + ); + prompt.push_str(trimmed); + prompt.push_str("\n\n"); + } + } + if !existing_steps.is_empty() { // ── RE-PLANNING header ────────────────────────────────────── prompt.push_str(&format!( @@ -2364,3 +2458,249 @@ Do NOT ask questions for trivial decisions — use your best judgment. prompt } + +// ============================================================================= +// Goal-edit classification (small vs large) and interrupt helpers +// ============================================================================= + +/// Classification of a goal change for the goal-edit interrupt cycle. +/// +/// When a user edits a directive's goal while a planning/replanning task is +/// already running, we want to differentiate between: +/// • Small edits (typo fixes, clarifications, small additions) → interrupt +/// the current planner with a `SendMessage` so it can adjust its in-flight +/// plan rather than throwing away its work. +/// • Large edits (substantial rewrites, completely different objective) → +/// fall back to the existing replan path (cancel + spawn a new planner). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalChangeKind { + /// Small change — interrupt the running planner with the diff. + Small, + /// Large change — proceed with full replan. + Large, +} + +/// Heuristic: classify a goal edit as small or large. +/// +/// Rules (POC heuristic, kept deliberately simple): +/// 1. Empty old goal or empty new goal → Large (treat as a fresh start). +/// 2. If one goal is a prefix of the other → Small (pure addition / truncation). +/// 3. If the absolute length difference relative to the longer goal is < 0.3, +/// classify as Small. Otherwise Large. +pub fn classify_goal_change(old: &str, new: &str) -> GoalChangeKind { + let old = old.trim(); + let new = new.trim(); + + if old.is_empty() || new.is_empty() { + return GoalChangeKind::Large; + } + + if old == new { + // No content change — treat as small (no-op for the planner). + return GoalChangeKind::Small; + } + + // Pure prefix changes (added a sentence at the end, or removed a trailing + // clause) are almost always small. + if old.starts_with(new) || new.starts_with(old) { + return GoalChangeKind::Small; + } + + let old_len = old.chars().count(); + let new_len = new.chars().count(); + let longer = old_len.max(new_len) as f64; + let diff = (old_len as i64 - new_len as i64).unsigned_abs() as f64; + if longer == 0.0 { + return GoalChangeKind::Large; + } + let length_ratio = diff / longer; + + if length_ratio < 0.3 { + GoalChangeKind::Small + } else { + GoalChangeKind::Large + } +} + +/// Format the goal-edit interrupt message sent to a running planner task +/// when the user edits the directive goal mid-flight. +pub fn build_goal_edit_interrupt_message(old_goal: &str, new_goal: &str) -> String { + format!( + "GOAL_UPDATED: The user has edited the directive goal. Summary of changes follows. \ + Adjust your current plan in-flight rather than starting over.\n\ + --- OLD GOAL ---\n\ + {old}\n\ + --- NEW GOAL ---\n\ + {new}\n", + old = old_goal, + new = new_goal, + ) +} + +/// Result of attempting to send a goal-edit interrupt to a running planner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalEditInterruptResult { + /// A `SendMessage` daemon command was dispatched to the running planner. + Sent, + /// No suitable planner task was running, or the change was classified as + /// large — caller should fall through to the regular replanning path. + Skipped, +} + +/// Attempt to interrupt a directive's currently-running planner with a goal +/// edit summary instead of replanning from scratch. +/// +/// Returns `Ok(GoalEditInterruptResult::Sent)` when a `SendMessage` was +/// dispatched. Returns `Ok(GoalEditInterruptResult::Skipped)` when the change +/// was large, no orchestrator task exists, the task has already finished, or +/// no daemon is currently assigned. +/// +/// This function is best-effort: errors talking to the daemon are logged and +/// translated into `Skipped` so the caller can fall through to the normal +/// replan path. +pub async fn try_interrupt_planner_with_goal_edit( + pool: &PgPool, + state: &SharedState, + directive_id: Uuid, + old_goal: &str, + new_goal: &str, +) -> Result<GoalEditInterruptResult, anyhow::Error> { + // Only fire if the change classifies as small. + if classify_goal_change(old_goal, new_goal) != GoalChangeKind::Small { + tracing::debug!( + directive_id = %directive_id, + "Goal change classified as large — skipping planner interrupt" + ); + return Ok(GoalEditInterruptResult::Skipped); + } + + // Look up the directive's current orchestrator task (planner). + let directive = match repository::get_directive(pool, directive_id).await? { + Some(d) => d, + None => return Ok(GoalEditInterruptResult::Skipped), + }; + let Some(orchestrator_task_id) = directive.orchestrator_task_id else { + return Ok(GoalEditInterruptResult::Skipped); + }; + + // Fetch the planner task to confirm it's still queued/running. + let task = match repository::get_task(pool, orchestrator_task_id).await? { + Some(t) => t, + None => return Ok(GoalEditInterruptResult::Skipped), + }; + + let interruptible = matches!( + task.status.as_str(), + "queued" | "pending" | "starting" | "running" + ); + if !interruptible { + tracing::debug!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + task_status = %task.status, + "Planner task is not in an interruptible state — skipping interrupt" + ); + return Ok(GoalEditInterruptResult::Skipped); + } + + let Some(daemon_id) = task.daemon_id else { + tracing::debug!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + "Planner task has no assigned daemon — skipping interrupt" + ); + return Ok(GoalEditInterruptResult::Skipped); + }; + + let message = build_goal_edit_interrupt_message(old_goal, new_goal); + let command = DaemonCommand::SendMessage { + task_id: orchestrator_task_id, + message, + }; + + match state.send_daemon_command(daemon_id, command).await { + Ok(()) => { + tracing::info!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + daemon_id = %daemon_id, + "Sent goal-edit interrupt to running planner" + ); + Ok(GoalEditInterruptResult::Sent) + } + Err(e) => { + tracing::warn!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + daemon_id = %daemon_id, + error = %e, + "Failed to send goal-edit interrupt — falling back to replan" + ); + Ok(GoalEditInterruptResult::Skipped) + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifier_identical_goal_is_small() { + assert_eq!( + classify_goal_change("Build a todo app", "Build a todo app"), + GoalChangeKind::Small + ); + } + + #[test] + fn classifier_pure_addition_is_small() { + let old = "Build a todo app"; + let new = "Build a todo app with authentication"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small); + } + + #[test] + fn classifier_pure_truncation_is_small() { + let old = "Build a todo app with authentication and tests"; + let new = "Build a todo app"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small); + } + + #[test] + fn classifier_typo_fix_is_small() { + // Same length, single character diff — well below 0.3 length ratio. + let old = "Build a todo aap with authentication and tests today"; + let new = "Build a todo app with authentication and tests today"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small); + } + + #[test] + fn classifier_completely_different_is_large() { + // Wildly different lengths and content. + let old = "Build a todo app"; + let new = "Migrate the entire backend to Rust, port the frontend to Svelte, \ + and add a new realtime collaboration feature with operational transforms"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Large); + } + + #[test] + fn classifier_empty_goals_are_large() { + assert_eq!(classify_goal_change("", "Anything"), GoalChangeKind::Large); + assert_eq!(classify_goal_change("Anything", ""), GoalChangeKind::Large); + } + + #[test] + fn interrupt_message_contains_old_and_new() { + let msg = build_goal_edit_interrupt_message("OLD", "NEW"); + assert!(msg.contains("GOAL_UPDATED")); + assert!(msg.contains("OLD")); + assert!(msg.contains("NEW")); + assert!(msg.contains("--- OLD GOAL ---")); + assert!(msg.contains("--- NEW GOAL ---")); + } +} |
