diff options
| author | soryu <soryu@soryu.co> | 2026-02-17 16:48:39 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-17 16:48:39 +0000 |
| commit | aee6cda5fc8c44ebc45b274d07a1ed64052e3699 (patch) | |
| tree | b484ced697dab34004ceeec826e1b884162f0f49 | |
| parent | 049fd3e8a15952627954678838ca5382c11ecd04 (diff) | |
| download | soryu-aee6cda5fc8c44ebc45b274d07a1ed64052e3699.tar.gz soryu-aee6cda5fc8c44ebc45b274d07a1ed64052e3699.zip | |
feat: smart cleanup, order linking, and improved PR titles (#69)
* feat: soryu-co/soryu: Reorder navigation: move Orders before Contracts
* feat: soryu-co/soryu: Generate PR titles from step content instead of directive title
* feat: soryu-co/soryu: Add orderId field to step creation and link orders to steps
* feat: soryu-co/soryu: Handle completed orders during plan-orders flow
* WIP: heartbeat checkpoint
* Merge origin/makima/soryu-co-soryu--handle-completed-orders-during-pla-5aa9a15b (resolved conflicts)
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 25 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 8 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 6 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 4 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 10 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 3 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 73 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 156 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 6 |
10 files changed, 241 insertions, 52 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index c9dac37..98940d0 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -25,7 +25,7 @@ interface DirectiveDetailProps { onUpdate: (req: UpdateDirectiveRequest) => void; onDelete: () => void; onRefresh: () => void; - onCleanupTasks: () => void; + onCleanup: () => void; onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>; onCreatePR: () => Promise<void>; } @@ -42,7 +42,7 @@ export function DirectiveDetail({ onUpdate, onDelete, onRefresh, - onCleanupTasks, + onCleanup, onPickUpOrders, onCreatePR, }: DirectiveDetailProps) { @@ -66,9 +66,6 @@ export function DirectiveDetail({ const completedSteps = directive.steps.filter((s) => s.status === "completed").length; const totalSteps = directive.steps.length; const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; - const terminalStatuses = new Set(["completed", "failed", "skipped"]); - const hasTerminalTasks = directive.steps.some((s) => s.taskId && terminalStatuses.has(s.status)); - // Get pending questions for this directive's tasks const { pendingQuestions, submitAnswer } = useSupervisorQuestions(); const directiveTaskIds = useMemo(() => { @@ -325,17 +322,15 @@ export function DirectiveDetail({ > Update Goal </button> + <button + type="button" + onClick={onCleanup} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1" + > + Clean up + </button> </div> )} - {hasTerminalTasks && ( - <button - type="button" - onClick={onCleanupTasks} - className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1 ml-auto" - > - Clean up tasks - </button> - )} {completedSteps > 0 && !directive.completionTaskId && ( <button type="button" @@ -360,7 +355,7 @@ export function DirectiveDetail({ <button type="button" onClick={onDelete} - className={`text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ${hasTerminalTasks ? "" : "ml-auto"}`} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto" > Delete </button> diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 18544da..898f671 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -19,7 +19,7 @@ import { failDirectiveStep, skipDirectiveStep, updateDirectiveGoal, - cleanupDirectiveTasks, + cleanupDirective, pickUpOrders as pickUpOrdersApi, createDirectivePR, } from "../lib/api"; @@ -173,9 +173,9 @@ export function useDirective(id: string | undefined) { await refresh(); }, [id, refresh]); - const cleanupTasks = useCallback(async () => { + const cleanup = useCallback(async () => { if (!id) return; - await cleanupDirectiveTasks(id); + await cleanupDirective(id); await refresh(); }, [id, refresh]); @@ -197,7 +197,7 @@ export function useDirective(id: string | undefined) { update, addStep, removeStep, start, pause, advance, completeStep, failStep, skipStep, - updateGoal, cleanupTasks, + updateGoal, cleanup, pickUpOrders: pickUpOrdersFn, createPR, }; diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index ed628f7..43eaa05 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3248,11 +3248,11 @@ export async function updateDirectiveGoal(id: string, goal: string): Promise<Dir return res.json(); } -export async function cleanupDirectiveTasks(id: string): Promise<{ deleted: number }> { - const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/cleanup-tasks`, { +export async function cleanupDirective(id: string): Promise<{ message: string; taskId: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/cleanup`, { method: "POST", }); - if (!res.ok) throw new Error(`Failed to cleanup tasks: ${res.statusText}`); + if (!res.ok) throw new Error(`Failed to cleanup directive: ${res.statusText}`); return res.json(); } diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 2bb673c..cee4920 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -12,7 +12,7 @@ export default function DirectivesPage() { const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); const { directives, loading: listLoading, create, remove } = useDirectives(); - const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks, pickUpOrders, createPR } = useDirective(selectedId); + const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanup, pickUpOrders, createPR } = useDirective(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -210,7 +210,7 @@ export default function DirectivesPage() { onUpdate={update} onDelete={handleDelete} onRefresh={refreshDetail} - onCleanupTasks={cleanupTasks} + onCleanup={cleanup} onPickUpOrders={pickUpOrders} onCreatePR={createPR} /> diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index f9a34b8..6b77563 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2832,13 +2832,21 @@ pub struct UpdateGoalRequest { pub goal: String, } -/// Response for cleanup_directive_tasks. +/// Response for cleanup_directive_tasks (legacy). #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CleanupTasksResponse { pub deleted: i64, } +/// Response for cleanup_directive endpoint. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CleanupResponse { + pub message: String, + pub task_id: Option<Uuid>, +} + /// Response for pick_up_orders endpoint. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index c7b0a1f..8d7a70c 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5188,6 +5188,7 @@ pub async fn cleanup_directive_tasks( /// Row type for completed step tasks. #[derive(Debug, Clone, sqlx::FromRow)] pub struct CompletedStepTask { + pub step_id: Uuid, pub step_name: String, pub task_id: Uuid, pub task_name: String, @@ -5321,7 +5322,7 @@ pub async fn get_completed_step_tasks( ) -> Result<Vec<CompletedStepTask>, sqlx::Error> { sqlx::query_as::<_, CompletedStepTask>( r#" - SELECT ds.name as step_name, ds.task_id, t.name as task_name + SELECT ds.id as step_id, ds.name as step_name, ds.task_id, t.name as task_name FROM directive_steps ds JOIN tasks t ON t.id = ds.task_id WHERE ds.directive_id = $1 diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index eb157a3..21053f3 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -1551,6 +1551,79 @@ IMPORTANT: You MUST run `makima directive update` with either `--pr-url` or `--s ) } +/// Build a prompt for cleaning up completed steps that have been merged into the PR branch. +/// +/// The prompt instructs the Claude instance to verify which step branches are +/// merged and remove merged steps, leaving unmerged steps alone. +pub fn build_cleanup_prompt( + directive: &crate::db::models::Directive, + step_tasks: &[crate::db::repository::CompletedStepTask], + pr_branch: &str, + base_branch: &str, +) -> String { + let step_list: String = step_tasks + .iter() + .map(|st| { + let branch = format!( + "makima/{}-{}", + crate::daemon::worktree::sanitize_name(&st.task_name), + crate::daemon::worktree::short_uuid(st.task_id), + ); + format!("- Step ID: {}, Name: \"{}\", Branch: {}", st.step_id, st.step_name, branch) + }) + .collect::<Vec<_>>() + .join("\n"); + + let pr_url_line = match &directive.pr_url { + Some(url) => format!("PR URL: {}", url), + None => String::new(), + }; + + format!( + r#"You are cleaning up completed steps for directive "{title}". + +The directive has a PR branch: {pr_branch} +Base branch: {base_branch} +{pr_url_line} + +Completed steps to verify: +{step_list} + +## Instructions + +1. First, fetch the latest remote state: +```bash +git fetch origin +``` + +2. Check which branches have been merged into the PR branch: +```bash +git branch -r --merged origin/{pr_branch} +``` + +3. For each completed step listed above, check if its branch appears in the merged list. + +4. For steps whose branches ARE merged: + - Remove the step (this also cleans up associated data): +```bash +makima directive remove-step <step_id> +``` + +5. For steps whose branches are NOT merged into the PR branch: + - Do NOT remove them. Leave them for the next PR cycle. + - Report them as skipped. + +6. After processing all steps, report a summary of what was cleaned up and what was left. + +IMPORTANT: Only remove steps whose task branches have been verified as merged. Never remove unmerged steps."#, + title = directive.title, + pr_branch = pr_branch, + base_branch = base_branch, + pr_url_line = pr_url_line, + step_list = step_list, + ) +} + /// Build a specialized planning prompt for picking up open orders. /// /// This prompt instructs the planner to evaluate available orders, select an diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index b4b438a..56278a8 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -9,14 +9,14 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ - CleanupTasksResponse, CreateDirectiveRequest, CreateTaskRequest, + CleanupResponse, CleanupTasksResponse, CreateDirectiveRequest, CreateTaskRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, UpdateOrderRequest, }; use crate::db::repository; -use crate::orchestration::directive::build_order_pickup_prompt; +use crate::orchestration::directive::{build_cleanup_prompt, build_order_pickup_prompt}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; @@ -872,20 +872,21 @@ pub async fn update_goal( // ============================================================================= -/// Clean up terminal tasks associated with a directive. +/// Clean up merged steps for an idle directive by spawning a verification task. #[utoipa::path( post, - path = "/api/v1/directives/{id}/cleanup-tasks", + path = "/api/v1/directives/{id}/cleanup", params(("id" = Uuid, Path, description = "Directive ID")), responses( - (status = 200, description = "Tasks cleaned up", body = CleanupTasksResponse), + (status = 200, description = "Cleanup task spawned", body = CleanupResponse), (status = 404, description = "Not found", body = ApiError), + (status = 409, description = "Directive is not idle", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Directives" )] -pub async fn cleanup_tasks( +pub async fn cleanup_directive( State(state): State<SharedState>, Authenticated(auth): Authenticated, Path(id): Path<Uuid>, @@ -898,36 +899,147 @@ pub async fn cleanup_tasks( .into_response(); }; - // Verify directive ownership - match repository::get_directive_for_owner(pool, auth.owner_id, id).await { - Ok(Some(_)) => {} - Ok(None) => { + // Get the directive with steps and verify ownership + let (directive, _steps) = + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some(ds)) => ds, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Verify directive is idle + if directive.status != "idle" { + return ( + StatusCode::CONFLICT, + Json(ApiError::new( + "NOT_IDLE", + "Directive must be idle to run cleanup", + )), + ) + .into_response(); + } + + // Get completed step tasks for branch name computation + let step_tasks = match repository::get_completed_step_tasks(pool, id).await { + Ok(tasks) => tasks, + Err(e) => { + tracing::error!("Failed to get completed step tasks: {}", e); return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Directive not found")), + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_STEPS_FAILED", &e.to_string())), ) .into_response(); } + }; + + if step_tasks.is_empty() { + return Json(CleanupResponse { + message: "No completed steps to clean up".to_string(), + task_id: None, + }) + .into_response(); + } + + let pr_branch = match &directive.pr_branch { + Some(b) => b.clone(), + None => { + return Json(CleanupResponse { + message: "No PR branch set — nothing to verify against".to_string(), + task_id: None, + }) + .into_response(); + } + }; + + let base_branch = directive.base_branch.as_deref().unwrap_or("main"); + + // Build the cleanup prompt + let prompt = build_cleanup_prompt(&directive, &step_tasks, &pr_branch, base_branch); + + // Create the cleanup task (following pick_up_orders pattern) + let req = CreateTaskRequest { + contract_id: None, + name: format!("Cleanup: {}", directive.title), + description: Some("Directive cleanup — verify merged branches and remove merged steps".to_string()), + plan: prompt, + parent_task_id: None, + is_supervisor: false, + priority: 0, + repository_url: directive.repository_url.clone(), + base_branch: directive.base_branch.clone(), + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + branched_from_task_id: None, + conversation_history: None, + supervisor_worktree_task_id: None, + directive_id: Some(directive.id), + directive_step_id: None, + }; + + let task = match repository::create_task_for_owner(pool, auth.owner_id, req).await { + Ok(t) => t, Err(e) => { + tracing::error!("Failed to create cleanup task: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("GET_FAILED", &e.to_string())), + Json(ApiError::new("CREATE_TASK_FAILED", &e.to_string())), ) .into_response(); } + }; + + // Assign as orchestrator task + if let Err(e) = repository::assign_orchestrator_task(pool, id, task.id).await { + tracing::error!("Failed to assign orchestrator task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("ASSIGN_TASK_FAILED", &e.to_string())), + ) + .into_response(); } - match repository::cleanup_directive_tasks(pool, auth.owner_id, id).await { - Ok(deleted) => Json(CleanupTasksResponse { deleted }).into_response(), - Err(e) => { - tracing::error!("Failed to cleanup directive tasks: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("CLEANUP_FAILED", &e.to_string())), - ) - .into_response() + // Cancel old planning tasks + let cancelled = repository::cancel_old_planning_tasks(pool, id, task.id).await; + if let Ok(count) = cancelled { + if count > 0 { + tracing::info!( + directive_id = %id, + cancelled_count = count, + "Cancelled old planning tasks superseded by cleanup" + ); } } + + // Set directive to active + if let Err(e) = repository::set_directive_status(pool, auth.owner_id, id, "active").await { + tracing::warn!("Failed to set directive status to active: {}", e); + } + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + Json(CleanupResponse { + message: format!("Cleanup task spawned for {} completed steps", step_tasks.len()), + task_id: Some(task.id), + }) + .into_response() } // ============================================================================= diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 6bd5ae0..2310ba3 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -237,7 +237,7 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) .route("/directives/{id}/goal", put(directives::update_goal)) - .route("/directives/{id}/cleanup-tasks", post(directives::cleanup_tasks)) + .route("/directives/{id}/cleanup", post(directives::cleanup_directive)) .route("/directives/{id}/create-pr", post(directives::create_pr)) .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) // Order endpoints diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 87ca9c5..6065eeb 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,7 +8,7 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CleanupTasksResponse, + CleanupResponse, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, @@ -128,7 +128,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::fail_step, directives::skip_step, directives::update_goal, - directives::cleanup_tasks, + directives::cleanup_directive, directives::create_pr, // Order endpoints orders::list_orders, @@ -234,7 +234,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage UpdateGoalRequest, CreateDirectiveStepRequest, UpdateDirectiveStepRequest, - CleanupTasksResponse, + CleanupResponse, // Order schemas Order, OrderListResponse, |
