summaryrefslogtreecommitdiff
path: root/makima/src/orchestration/directive.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/orchestration/directive.rs')
-rw-r--r--makima/src/orchestration/directive.rs346
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 ---"));
+ }
+}