diff options
Diffstat (limited to 'makima/src/orchestration/directive.rs')
| -rw-r--r-- | makima/src/orchestration/directive.rs | 365 |
1 files changed, 50 insertions, 315 deletions
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 7897c2c..384fa23 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -81,12 +81,14 @@ impl DirectiveOrchestrator { let prev_merged = repository::get_latest_merged_revision(&self.pool, directive.id) .await .unwrap_or(None); + let contract_body = + repository::get_active_contract_body(&self.pool, directive.id).await?; let plan = build_planning_prompt( &directive, &[], 1, - &[], + &contract_body, None, prev_merged.as_ref(), ); @@ -485,8 +487,8 @@ impl DirectiveOrchestrator { repository::list_directive_steps(&self.pool, directive.id).await?; let generation = repository::get_directive_max_generation(&self.pool, directive.id).await? + 1; - let goal_history = - repository::get_directive_goal_history(&self.pool, directive.id, 3).await?; + let contract_body = + repository::get_active_contract_body(&self.pool, directive.id).await?; // If steps are currently running (or recently completed), build a // WORK IN PROGRESS summary for the planner so it doesn't re-issue @@ -506,7 +508,7 @@ impl DirectiveOrchestrator { &directive, &existing_steps, generation, - &goal_history, + &contract_body, progress_summary.as_deref(), prev_merged.as_ref(), ); @@ -825,8 +827,12 @@ impl DirectiveOrchestrator { }) .collect(); + let contract_body = repository::get_active_contract_body(&self.pool, directive.id) + .await + .unwrap_or_default(); let prompt = build_completion_prompt( &directive, + &contract_body, &step_tasks, &step_branches, &directive_branch, @@ -1355,7 +1361,10 @@ pub async fn trigger_completion_task( }) .collect(); - let prompt = build_completion_prompt(&directive, &step_tasks, &step_branches, &directive_branch, base_branch); + let contract_body = repository::get_active_contract_body(pool, directive_id) + .await + .unwrap_or_default(); + let prompt = build_completion_prompt(&directive, &contract_body, &step_tasks, &step_branches, &directive_branch, base_branch); let task_name = if directive.pr_url.is_some() { format!("Update PR: {}", directive.title) @@ -1543,7 +1552,7 @@ fn build_planning_prompt( directive: &crate::db::models::Directive, existing_steps: &[crate::db::models::DirectiveStep], generation: i32, - goal_history: &[crate::db::models::DirectiveGoalHistory], + contract_body: &str, progress_summary: Option<&str>, previous_merged_revision: Option<&crate::db::models::DirectiveRevision>, ) -> String { @@ -1566,7 +1575,7 @@ fn build_planning_prompt( prompt.push_str("PREVIOUSLY-MERGED CONTRACT (frozen content):\n"); prompt.push_str(&prev.content); prompt.push_str("\n\nAMENDED CONTRACT (what the user wants now):\n"); - prompt.push_str(&directive.goal); + prompt.push_str(contract_body); prompt.push_str( "\n\nIMPORTANT:\n\ - Identify what CHANGED between the previously-merged contract and the amended one.\n\ @@ -1591,6 +1600,17 @@ fn build_planning_prompt( } } + // Always include the current contract body so the planner has the + // up-to-date spec, regardless of whether there are existing steps. + prompt.push_str("CURRENT GOAL (active contract body):\n"); + prompt.push_str(contract_body); + prompt.push_str("\n\n"); + + // Suppress unused warning for `directive` — kept in the signature so + // callers don't have to plumb the contract body separately when we + // expand the prompt later. + let _ = directive; + if !existing_steps.is_empty() { // ── RE-PLANNING header ────────────────────────────────────── prompt.push_str(&format!( @@ -1599,37 +1619,6 @@ fn build_planning_prompt( relevant. Review each step below and act according to the instructions per status category.\n\n", )); - // ── Goal changes section ────────────────────────────────── - if !goal_history.is_empty() { - prompt.push_str("-- GOAL CHANGES --\n"); - prompt.push_str("The goal has been updated. Compare the previous and current goals to understand what changed:\n\n"); - for (i, entry) in goal_history.iter().enumerate() { - if i == 0 { - prompt.push_str(&format!( - "PREVIOUS GOAL (replaced at {}):\n{}\n\n", - entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"), - entry.goal - )); - } else { - prompt.push_str(&format!( - "OLDER GOAL (version from {}):\n{}\n\n", - entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"), - entry.goal - )); - } - } - prompt.push_str(&format!( - "CURRENT GOAL (what you must plan for):\n{}\n\n", - directive.goal - )); - prompt.push_str( - "IMPORTANT: Analyze what CHANGED between the previous goal and the current goal.\n\ - - If the change is minor (e.g., clarification, small addition), try to KEEP existing pending steps and only add/modify what is needed for the delta.\n\ - - If the change is major (e.g., completely different objective), you may need to remove most pending steps and create a fresh plan.\n\ - - Always preserve completed and running steps - they represent work already done.\n\n", - ); - } - prompt.push_str(&format!( "EXISTING STEPS (generation {}):\n", generation - 1 @@ -1763,7 +1752,18 @@ Your job: 1. Explore the repository to understand the codebase 2. Decompose the goal into concrete, ordered steps 3. Each step = one task for a Claude Code instance to execute -4. Submit ALL steps using the batch command or individual add-step commands +4. Submit ALL steps using the batch command or individual add-step commands"#, + title = directive.title, + goal = contract_body, + repo_section = match &directive.repository_url { + Some(url) => format!("REPOSITORY: {}\n", url), + None => String::new(), + }, + )); + + // The original tail (orders, dependency rules, etc.) follows below; + // re-attached intact so the prompt structure is unchanged. + prompt.push_str(r#" For each step, define: - name: Short imperative title (e.g., "Add user authentication middleware") @@ -1854,14 +1854,7 @@ When to create orders: Do NOT create orders for: - Work that should be a step in the current plan - Tasks that are part of the current goal -"#, - title = directive.title, - goal = directive.goal, - repo_section = match &directive.repository_url { - Some(url) => format!("REPOSITORY: {}\n", url), - None => String::new(), - }, - )); +"#); prompt } @@ -1869,6 +1862,7 @@ Do NOT create orders for: /// Build the prompt for a completion task that creates or updates a PR. fn build_completion_prompt( directive: &crate::db::models::Directive, + contract_body: &str, step_tasks: &[crate::db::repository::CompletedStepTask], step_branches: &[String], directive_branch: &str, @@ -2050,7 +2044,7 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR makima directive ask "Your question" --phaseguard "#, title = directive.title, - goal = directive.goal, + goal = contract_body, pr_url = pr_url, directive_branch = directive_branch, base_branch = base_branch, @@ -2058,7 +2052,7 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR merge_commands = merge_commands, pr_body = format!( "## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}", - directive.goal.replace('\n', "\\n").replace('"', "\\\""), + contract_body.replace('\n', "\\n").replace('"', "\\\""), step_summary.replace('\n', "\\n").replace('"', "\\\""), ), ) @@ -2156,14 +2150,14 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR makima directive ask "Your question" --phaseguard "#, title = directive.title, - goal = directive.goal, + goal = contract_body, directive_branch = directive_branch, base_branch = base_branch, step_summary = step_summary, merge_commands = merge_commands, pr_body = format!( "## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}", - directive.goal.replace('\n', "\\n").replace('"', "\\\""), + contract_body.replace('\n', "\\n").replace('"', "\\\""), step_summary.replace('\n', "\\n").replace('"', "\\\""), ), ) @@ -2316,7 +2310,7 @@ pub fn build_order_pickup_prompt( existing_steps: &[crate::db::models::DirectiveStep], orders: &[crate::db::models::Order], generation: i32, - goal_history: &[crate::db::models::DirectiveGoalHistory], + contract_body: &str, ) -> String { let mut prompt = String::new(); @@ -2326,33 +2320,13 @@ pub fn build_order_pickup_prompt( GOAL: {goal}\n\ {repo_section}\n", title = directive.title, - goal = directive.goal, + goal = contract_body, repo_section = match &directive.repository_url { Some(url) => format!("REPOSITORY: {}\n", url), None => String::new(), }, )); - // ── Goal history (if any) ───────────────────────────────────── - if !goal_history.is_empty() { - prompt.push_str("-- GOAL CHANGES --\n"); - for (i, entry) in goal_history.iter().enumerate() { - if i == 0 { - prompt.push_str(&format!( - "PREVIOUS GOAL (replaced at {}):\n{}\n\n", - entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"), - entry.goal - )); - } else { - prompt.push_str(&format!( - "OLDER GOAL (version from {}):\n{}\n\n", - entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"), - entry.goal - )); - } - } - } - // ── Orders being picked up ─────────────────────────────────── prompt.push_str("== ORDERS AVAILABLE FOR PLANNING ==\n"); prompt.push_str("The following open orders have been linked to this directive. \ @@ -2558,93 +2532,9 @@ Do NOT ask questions for trivial decisions — use your best judgment. } // ============================================================================= -// Goal-edit classification (small vs large) and interrupt helpers +// Planner cancellation helper // ============================================================================= -/// 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, -} - /// Best-effort cancellation of a directive's currently-running orchestrator /// (planner) task. Used by the goal-update path: when we are about to clear /// `orchestrator_task_id` from the directive, the still-running task would @@ -2727,160 +2617,5 @@ pub async fn try_cancel_running_planner( Ok(true) } -/// 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 ---")); - } -} +// (Goal-edit classification + interrupt helpers were tied to directive.goal, +// which has been dropped. Their unit tests went with them.) |
