diff options
| author | soryu <soryu@soryu.co> | 2026-02-14 21:27:50 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-14 21:27:50 +0000 |
| commit | afd820f99056caeada04c19d7c33fef615a809b4 (patch) | |
| tree | 48eb67af94209f647cedc4facaf1823fbc42c926 | |
| parent | 1867caa31a79f6cf4fc163a73a8f18b4d8e285be (diff) | |
| parent | 41a24585e8c771132b47b28ab1b5869040744af0 (diff) | |
| download | soryu-afd820f99056caeada04c19d7c33fef615a809b4.tar.gz soryu-afd820f99056caeada04c19d7c33fef615a809b4.zip | |
Merge remote-tracking branch 'origin/makima/soryu-co-soryu---makima--add-question-support-for--525448aa' into makima/directive-soryu-co-soryu-makima-c29f9112
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 131 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 10 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 3 | ||||
| -rw-r--r-- | makima/migrations/20260214100000_directive_reconcile_mode.sql | 4 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 8 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 9 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 10 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 90 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 30 |
9 files changed, 268 insertions, 27 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index 79cc97c..e278939 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -1,8 +1,9 @@ import { useState, useMemo, useEffect, useRef } from "react"; -import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api"; import { DirectiveDAG } from "./DirectiveDAG"; import { DirectiveLogStream } from "./DirectiveLogStream"; import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; +import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, @@ -21,6 +22,7 @@ interface DirectiveDetailProps { onFailStep: (stepId: string) => void; onSkipStep: (stepId: string) => void; onUpdateGoal: (goal: string) => void; + onUpdate: (req: UpdateDirectiveRequest) => void; onDelete: () => void; onRefresh: () => void; onCleanupTasks: () => void; @@ -35,6 +37,7 @@ export function DirectiveDetail({ onFailStep, onSkipStep, onUpdateGoal, + onUpdate, onDelete, onRefresh, onCleanupTasks, @@ -59,6 +62,24 @@ export function DirectiveDetail({ 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(() => { + const ids = new Set<string>(); + if (directive.orchestratorTaskId) ids.add(directive.orchestratorTaskId); + for (const step of directive.steps) { + if (step.taskId) ids.add(step.taskId); + } + return ids; + }, [directive.orchestratorTaskId, directive.steps]); + + const directiveQuestions = useMemo( + () => pendingQuestions.filter((q) => + q.directiveId === directive.id || directiveTaskIds.has(q.taskId) + ), + [pendingQuestions, directive.id, directiveTaskIds] + ); + // Build task map from directive steps and orchestrator // Derive a stable key from the actual task IDs to avoid recreating the map on every poll const taskMapKey = useMemo(() => { @@ -155,6 +176,26 @@ export function DirectiveDetail({ </div> )} + {/* Reconcile mode toggle */} + <div className="flex items-center gap-2 mb-2"> + <button + type="button" + onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + directive.reconcileMode + ? "text-amber-400 border-amber-800 bg-amber-900/20" + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"} + </button> + <span className="text-[9px] font-mono text-[#445566]"> + {directive.reconcileMode + ? "Questions pause execution" + : "Questions timeout after 30s"} + </span> + </div> + {/* Orchestrator planning indicator */} {directive.orchestratorTaskId && ( <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a30] border border-[rgba(117,170,252,0.2)] rounded"> @@ -205,6 +246,20 @@ export function DirectiveDetail({ </div> )} + {/* Pending Questions */} + {directiveQuestions.length > 0 && ( + <div className="mb-2 space-y-2"> + {directiveQuestions.map((q) => ( + <DirectiveQuestionCard + key={q.questionId} + question={q} + taskName={taskMap.get(q.taskId) || "Task"} + onAnswer={(response) => submitAnswer(q.questionId, response)} + /> + ))} + </div> + )} + {/* Controls */} <div className="flex flex-wrap gap-2"> {(directive.status === "draft" || directive.status === "paused") && ( @@ -348,3 +403,77 @@ export function DirectiveDetail({ </div> ); } + +/** Inline question card for directive pending questions */ +function DirectiveQuestionCard({ + question, + taskName, + onAnswer, +}: { + question: { questionId: string; question: string; choices: string[]; context: string | null }; + taskName: string; + onAnswer: (response: string) => void; +}) { + const [customResponse, setCustomResponse] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (response: string) => { + setSubmitting(true); + await onAnswer(response); + setSubmitting(false); + }; + + return ( + <div className="px-2 py-2 bg-[#1a1020] border border-purple-900/50 rounded"> + <div className="flex items-center gap-1.5 mb-1"> + <span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" /> + <span className="text-[9px] font-mono text-purple-400 uppercase"> + Question from {taskName} + </span> + </div> + <p className="text-[11px] font-mono text-white mb-1.5">{question.question}</p> + {question.context && ( + <p className="text-[9px] font-mono text-[#556677] mb-1.5">{question.context}</p> + )} + {question.choices.length > 0 ? ( + <div className="flex flex-wrap gap-1"> + {question.choices.map((choice) => ( + <button + key={choice} + type="button" + disabled={submitting} + onClick={() => handleSubmit(choice)} + className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 hover:border-purple-600 rounded px-2 py-0.5 disabled:opacity-50" + > + {choice} + </button> + ))} + </div> + ) : ( + <div className="flex gap-1"> + <input + type="text" + value={customResponse} + onChange={(e) => setCustomResponse(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && customResponse.trim()) { + handleSubmit(customResponse.trim()); + } + }} + placeholder="Type your answer..." + className="flex-1 bg-[#0a0618] border border-purple-900/50 rounded px-2 py-0.5 text-[10px] font-mono text-white placeholder:text-[#445566]" + disabled={submitting} + /> + <button + type="button" + disabled={submitting || !customResponse.trim()} + onClick={() => handleSubmit(customResponse.trim())} + className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 rounded px-2 py-0.5 disabled:opacity-50" + > + Send + </button> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 6adc4d4..f88176b 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2241,6 +2241,8 @@ export interface PendingQuestion { questionId: string; taskId: string; contractId: string; + /** Directive this question relates to (if from a directive task) */ + directiveId?: string | null; question: string; choices: string[]; context: string | null; @@ -3025,6 +3027,8 @@ export interface Directive { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode: boolean; goalUpdatedAt: string; startedAt: string | null; version: number; @@ -3064,6 +3068,8 @@ export interface DirectiveSummary { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode: boolean; version: number; createdAt: string; updatedAt: string; @@ -3086,6 +3092,8 @@ export interface CreateDirectiveRequest { baseBranch?: string; /** Enable the memory system for this directive (default: false) */ memoryEnabled?: boolean; + /** Whether questions pause execution indefinitely until answered (default: false) */ + reconcileMode?: boolean; } export interface UpdateDirectiveRequest { @@ -3098,6 +3106,8 @@ export interface UpdateDirectiveRequest { orchestratorTaskId?: string; /** Enable or disable the memory system for this directive */ memoryEnabled?: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode?: boolean; version?: number; } diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index ca4437c..643cfee 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, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); + const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -207,6 +207,7 @@ export default function DirectivesPage() { onFailStep={failStep} onSkipStep={skipStep} onUpdateGoal={updateGoal} + onUpdate={update} onDelete={handleDelete} onRefresh={refreshDetail} onCleanupTasks={cleanupTasks} diff --git a/makima/migrations/20260214100000_directive_reconcile_mode.sql b/makima/migrations/20260214100000_directive_reconcile_mode.sql new file mode 100644 index 0000000..a06e8f2 --- /dev/null +++ b/makima/migrations/20260214100000_directive_reconcile_mode.sql @@ -0,0 +1,4 @@ +-- Add reconcile_mode flag to directives table. +-- When true, directive task questions pause execution indefinitely until answered. +-- When false (default), questions timeout after 30 seconds. +ALTER TABLE directives ADD COLUMN IF NOT EXISTS reconcile_mode BOOLEAN NOT NULL DEFAULT false; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index e36da0d..6ec6cf4 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2714,6 +2714,8 @@ pub struct Directive { pub pr_url: Option<String>, pub pr_branch: Option<String>, pub completion_task_id: Option<Uuid>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: bool, pub goal_updated_at: DateTime<Utc>, pub started_at: Option<DateTime<Utc>>, pub version: i32, @@ -2763,6 +2765,8 @@ pub struct DirectiveSummary { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub completion_task_id: Option<Uuid>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2789,6 +2793,8 @@ pub struct CreateDirectiveRequest { pub repository_url: Option<String>, pub local_path: Option<String>, pub base_branch: Option<String>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: Option<bool>, } /// Request to update a directive. @@ -2804,6 +2810,8 @@ pub struct UpdateDirectiveRequest { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub pr_branch: Option<String>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: Option<bool>, pub version: Option<i32>, } diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index bfc485b..a2489aa 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -4930,8 +4930,8 @@ pub async fn create_directive_for_owner( ) -> Result<Directive, sqlx::Error> { sqlx::query_as::<_, Directive>( r#" - INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, reconcile_mode) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) @@ -4941,6 +4941,7 @@ pub async fn create_directive_for_owner( .bind(&req.repository_url) .bind(&req.local_path) .bind(&req.base_branch) + .bind(req.reconcile_mode.unwrap_or(false)) .fetch_one(pool) .await } @@ -4993,6 +4994,7 @@ pub async fn list_directives_for_owner( SELECT d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url, d.orchestrator_task_id, d.pr_url, d.completion_task_id, + d.reconcile_mode, d.version, d.created_at, d.updated_at, COALESCE(s.total_steps, 0) as total_steps, COALESCE(s.completed_steps, 0) as completed_steps, @@ -5056,12 +5058,14 @@ pub async fn update_directive_for_owner( let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id); let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref()); let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref()); + let reconcile_mode = req.reconcile_mode.unwrap_or(current.reconcile_mode); let result = sqlx::query_as::<_, Directive>( r#" UPDATE directives SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7, base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11, + reconcile_mode = $12, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * @@ -5078,6 +5082,7 @@ pub async fn update_directive_for_owner( .bind(orchestrator_task_id) .bind(pr_url) .bind(pr_branch) + .bind(reconcile_mode) .fetch_optional(pool) .await .map_err(RepositoryError::Database)?; diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index ea8009d..6d5d63d 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -248,6 +248,16 @@ impl DirectiveOrchestrator { .await?; repository::check_directive_idle(&self.pool, step.directive_id).await?; } + "paused" => { + // Task is paused (e.g., waiting for user answer in reconcile mode) + // Keep step in running status — task will auto-resume when answered + tracing::debug!( + step_id = %step.step_id, + directive_id = %step.directive_id, + task_id = %step.task_id, + "Step task paused (waiting for user response) — keeping step running" + ); + } _ => { // Still running — do nothing } diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index c9cb849..90c6dc7 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -129,6 +129,9 @@ pub struct PendingQuestionSummary { pub question_id: Uuid, pub task_id: Uuid, pub contract_id: Uuid, + /// Directive this question relates to (if from a directive task) + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, pub question: String, pub choices: Vec<String>, pub context: Option<String>, @@ -257,11 +260,11 @@ async fn verify_supervisor_auth( ) })?; - // Verify task is a supervisor - if !task.is_supervisor { + // Verify task is a supervisor or a directive task + if !task.is_supervisor && task.directive_id.is_none() { return Err(( StatusCode::FORBIDDEN, - Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor tasks can use these endpoints")), + Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor or directive tasks can use these endpoints")), )); } @@ -1694,17 +1697,43 @@ pub async fn ask_question( } }; - let Some(contract_id) = supervisor.contract_id else { + // Determine context: contract or directive + let contract_id = supervisor.contract_id; + let directive_id = supervisor.directive_id; + + if contract_id.is_none() && directive_id.is_none() { return ( StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_CONTRACT", "Supervisor has no associated contract")), + Json(ApiError::new("NO_CONTEXT", "Supervisor has no associated contract or directive")), ).into_response(); + } + + let is_directive_context = directive_id.is_some() && contract_id.is_none(); + + // For directive context, check reconcile_mode to determine behavior + let directive_reconcile_mode = if let Some(did) = directive_id { + if is_directive_context { + match repository::get_directive_for_owner(pool, owner_id, did).await { + Ok(Some(d)) => d.reconcile_mode, + Ok(None) => false, + Err(e) => { + tracing::warn!(error = %e, "Failed to get directive for reconcile_mode check"); + false + } + } + } else { + false + } + } else { + false }; - // Add the question - let question_id = state.add_supervisor_question( + // Add the question (use Uuid::nil() for contract_id in directive-only context) + let effective_contract_id = contract_id.unwrap_or(Uuid::nil()); + let question_id = state.add_supervisor_question_with_directive( supervisor_id, - contract_id, + effective_contract_id, + directive_id, owner_id, request.question.clone(), request.choices.clone(), @@ -1714,15 +1743,18 @@ pub async fn ask_question( ); // Save state: question asked is a key save point (Task 3.3) - let pending_question = PendingQuestion { - id: question_id, - question: request.question.clone(), - choices: request.choices.clone(), - context: request.context.clone(), - question_type: request.question_type.clone(), - asked_at: chrono::Utc::now(), - }; - save_state_on_question_asked(pool, contract_id, pending_question).await; + // Only for contract context — directive tasks don't use supervisor_states table + if let Some(cid) = contract_id { + let pending_question = PendingQuestion { + id: question_id, + question: request.question.clone(), + choices: request.choices.clone(), + context: request.context.clone(), + question_type: request.question_type.clone(), + asked_at: chrono::Utc::now(), + }; + save_state_on_question_asked(pool, cid, pending_question).await; + } // Broadcast question as task output entry for the task's chat let question_data = serde_json::json!({ @@ -1775,9 +1807,10 @@ pub async fn ask_question( ).into_response(); } - // If phaseguard is enabled, pause the supervisor task and return + // If phaseguard is enabled (or directive reconcile mode), pause the supervisor task and return // The task will be auto-resumed when a message is sent to it (e.g., when user answers) - if request.phaseguard { + let use_phaseguard = request.phaseguard || (is_directive_context && directive_reconcile_mode); + if use_phaseguard { // Pause the supervisor task if let Some(daemon_id) = supervisor.daemon_id { let cmd = DaemonCommand::PauseTask { task_id: supervisor_id }; @@ -1808,7 +1841,13 @@ pub async fn ask_question( } // Poll for response with timeout - let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64); + // For directive tasks without reconcile mode, use 30s default timeout + let timeout_secs = if is_directive_context && !directive_reconcile_mode { + 30 + } else { + request.timeout_seconds.max(1) as u64 + }; + let timeout_duration = std::time::Duration::from_secs(timeout_secs); let start = std::time::Instant::now(); let poll_interval = std::time::Duration::from_millis(500); @@ -1819,7 +1858,10 @@ pub async fn ask_question( state.cleanup_question_response(question_id); // Clear pending question from supervisor state (Task 3.3) - clear_pending_question(pool, contract_id, question_id).await; + // Skip for directive context — no supervisor_states for directives + if let Some(cid) = contract_id { + clear_pending_question(pool, cid, question_id).await; + } return ( StatusCode::OK, @@ -1837,7 +1879,10 @@ pub async fn ask_question( state.remove_pending_question(question_id); // Clear pending question from supervisor state on timeout (Task 3.3) - clear_pending_question(pool, contract_id, question_id).await; + // Skip for directive context — no supervisor_states for directives + if let Some(cid) = contract_id { + clear_pending_question(pool, cid, question_id).await; + } return ( StatusCode::REQUEST_TIMEOUT, @@ -1880,6 +1925,7 @@ pub async fn list_pending_questions( question_id: q.question_id, task_id: q.task_id, contract_id: q.contract_id, + directive_id: q.directive_id, question: q.question, choices: q.choices, context: q.context, diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 58e8545..41c336e 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -142,8 +142,11 @@ pub struct SupervisorQuestionNotification { pub question_id: Uuid, /// Supervisor task that asked the question pub task_id: Uuid, - /// Contract this question relates to + /// Contract this question relates to (Uuid::nil() for directive context) pub contract_id: Uuid, + /// Directive this question relates to (if from a directive task) + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, /// Owner ID for data isolation #[serde(skip)] pub owner_id: Option<Uuid>, @@ -170,6 +173,8 @@ pub struct PendingSupervisorQuestion { pub question_id: Uuid, pub task_id: Uuid, pub contract_id: Uuid, + /// Directive this question relates to (if from a directive task) + pub directive_id: Option<Uuid>, pub owner_id: Uuid, pub question: String, pub choices: Vec<String>, @@ -819,6 +824,25 @@ impl AppState { multi_select: bool, question_type: String, ) -> Uuid { + self.add_supervisor_question_with_directive( + task_id, contract_id, None, owner_id, + question, choices, context, multi_select, question_type, + ) + } + + /// Add a pending supervisor question with optional directive context and broadcast it. + pub fn add_supervisor_question_with_directive( + &self, + task_id: Uuid, + contract_id: Uuid, + directive_id: Option<Uuid>, + owner_id: Uuid, + question: String, + choices: Vec<String>, + context: Option<String>, + multi_select: bool, + question_type: String, + ) -> Uuid { let question_id = Uuid::new_v4(); let now = chrono::Utc::now(); @@ -829,6 +853,7 @@ impl AppState { question_id, task_id, contract_id, + directive_id, owner_id, question: question.clone(), choices: choices.clone(), @@ -844,6 +869,7 @@ impl AppState { question_id, task_id, contract_id, + directive_id, owner_id: Some(owner_id), question, choices, @@ -857,6 +883,7 @@ impl AppState { question_id = %question_id, task_id = %task_id, contract_id = %contract_id, + directive_id = ?directive_id, question_type = %question_type, "Supervisor question added" ); @@ -904,6 +931,7 @@ impl AppState { question_id, task_id: question.1.task_id, contract_id: question.1.contract_id, + directive_id: question.1.directive_id, owner_id: Some(question.1.owner_id), question: question.1.question, choices: question.1.choices, |
