diff --git a/backend/.sqlx/query-1268afe9ca849daa6722e3df7ca8e9e61f0d37052e782bb5452ab8e1018d9b63.json b/backend/.sqlx/query-1268afe9ca849daa6722e3df7ca8e9e61f0d37052e782bb5452ab8e1018d9b63.json new file mode 100644 index 00000000..805a1a56 --- /dev/null +++ b/backend/.sqlx/query-1268afe9ca849daa6722e3df7ca8e9e61f0d37052e782bb5452ab8e1018d9b63.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM execution_processes WHERE task_attempt_id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1268afe9ca849daa6722e3df7ca8e9e61f0d37052e782bb5452ab8e1018d9b63" +} diff --git a/backend/.sqlx/query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json b/backend/.sqlx/query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json new file mode 100644 index 00000000..4d18d962 --- /dev/null +++ b/backend/.sqlx/query-14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE task_attempt_id = $1 \n ORDER BY created_at ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "process_type!: ExecutionProcessType", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status!: ExecutionProcessStatus", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "command", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "args", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "working_directory", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stdout", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "stderr", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "started_at!: DateTime", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "completed_at?: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "14ad9267623a3aa678a943db6d0b14581a6d353da673c9e4156e0bbeac0b3346" +} diff --git a/backend/.sqlx/query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json b/backend/.sqlx/query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json new file mode 100644 index 00000000..6ed459be --- /dev/null +++ b/backend/.sqlx/query-1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "process_type!: ExecutionProcessType", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status!: ExecutionProcessStatus", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "command", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "args", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "working_directory", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stdout", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "stderr", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "started_at!: DateTime", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "completed_at?: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "1ada5613889792cd6098da71ce2ba1ecdee7e5dc2ff8196872368fff0caa48d8" +} diff --git a/backend/.sqlx/query-2c1e9b7c0038a44d9b78791538529107aeaa6bd49501316b67dceb908df873a1.json b/backend/.sqlx/query-2c1e9b7c0038a44d9b78791538529107aeaa6bd49501316b67dceb908df873a1.json deleted file mode 100644 index bdf70e72..00000000 --- a/backend/.sqlx/query-2c1e9b7c0038a44d9b78791538529107aeaa6bd49501316b67dceb908df873a1.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE project_id = $1 \n ORDER BY created_at DESC", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "project_id!: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "description", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "status!: TaskStatus", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - true, - false, - false, - false - ] - }, - "hash": "2c1e9b7c0038a44d9b78791538529107aeaa6bd49501316b67dceb908df873a1" -} diff --git a/backend/.sqlx/query-36c9e3dd10648e94b949db5c91a774ecb1e10a899ef95da74066eccedca4d8b2.json b/backend/.sqlx/query-36c9e3dd10648e94b949db5c91a774ecb1e10a899ef95da74066eccedca4d8b2.json new file mode 100644 index 00000000..1c6fc8af --- /dev/null +++ b/backend/.sqlx/query-36c9e3dd10648e94b949db5c91a774ecb1e10a899ef95da74066eccedca4d8b2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE execution_processes SET stderr = COALESCE(stderr, '') || $1, updated_at = datetime('now') WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "36c9e3dd10648e94b949db5c91a774ecb1e10a899ef95da74066eccedca4d8b2" +} diff --git a/backend/.sqlx/query-52c58db6e8a3b690a8980e395733a6e44bc5b0836eab8801e4c43cb47560ca41.json b/backend/.sqlx/query-52c58db6e8a3b690a8980e395733a6e44bc5b0836eab8801e4c43cb47560ca41.json deleted file mode 100644 index 8af79fd7..00000000 --- a/backend/.sqlx/query-52c58db6e8a3b690a8980e395733a6e44bc5b0836eab8801e4c43cb47560ca41.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT DISTINCT ta.id as \"id!: Uuid\"\n FROM task_attempts ta\n INNER JOIN (\n SELECT task_attempt_id, MAX(created_at) as latest_created_at\n FROM task_attempt_activities\n GROUP BY task_attempt_id\n ) latest_activity ON ta.id = latest_activity.task_attempt_id\n INNER JOIN task_attempt_activities taa ON ta.id = taa.task_attempt_id \n AND taa.created_at = latest_activity.latest_created_at\n WHERE taa.status IN ($1, $2, $3)", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - true - ] - }, - "hash": "52c58db6e8a3b690a8980e395733a6e44bc5b0836eab8801e4c43cb47560ca41" -} diff --git a/backend/.sqlx/query-aa7c3034f96c9b2dfd7c1121b9eb7749987b035d603480a8bf15f776d83a0363.json b/backend/.sqlx/query-aa7c3034f96c9b2dfd7c1121b9eb7749987b035d603480a8bf15f776d83a0363.json deleted file mode 100644 index 2d23ee53..00000000 --- a/backend/.sqlx/query-aa7c3034f96c9b2dfd7c1121b9eb7749987b035d603480a8bf15f776d83a0363.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE task_attempts SET stdout = $1, stderr = $2, updated_at = datetime('now') WHERE id = $3", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "aa7c3034f96c9b2dfd7c1121b9eb7749987b035d603480a8bf15f776d83a0363" -} diff --git a/backend/.sqlx/query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json b/backend/.sqlx/query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json new file mode 100644 index 00000000..c518642f --- /dev/null +++ b/backend/.sqlx/query-b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO execution_processes (\n id, task_attempt_id, process_type, status, command, args, \n working_directory, stdout, stderr, exit_code, started_at, \n completed_at, created_at, updated_at\n ) \n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) \n RETURNING \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "process_type!: ExecutionProcessType", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status!: ExecutionProcessStatus", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "command", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "args", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "working_directory", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stdout", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "stderr", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "started_at!: DateTime", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "completed_at?: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 14 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "b50af42f635dec3167508f3c4f81d03911102a603ac94b22a431a513d36471b0" +} diff --git a/backend/.sqlx/query-c67259be8bf4ee0cfd32167b2aa3b7fe9192809181a8171bf1c2d6df731967ae.json b/backend/.sqlx/query-c67259be8bf4ee0cfd32167b2aa3b7fe9192809181a8171bf1c2d6df731967ae.json new file mode 100644 index 00000000..d4c4941e --- /dev/null +++ b/backend/.sqlx/query-c67259be8bf4ee0cfd32167b2aa3b7fe9192809181a8171bf1c2d6df731967ae.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE execution_processes \n SET status = $1, exit_code = $2, completed_at = $3, updated_at = datetime('now') \n WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "c67259be8bf4ee0cfd32167b2aa3b7fe9192809181a8171bf1c2d6df731967ae" +} diff --git a/backend/.sqlx/query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json b/backend/.sqlx/query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json new file mode 100644 index 00000000..6266c44d --- /dev/null +++ b/backend/.sqlx/query-d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n id as \"id!: Uuid\", \n task_attempt_id as \"task_attempt_id!: Uuid\", \n process_type as \"process_type!: ExecutionProcessType\",\n status as \"status!: ExecutionProcessStatus\",\n command, \n args, \n working_directory, \n stdout, \n stderr, \n exit_code,\n started_at as \"started_at!: DateTime\",\n completed_at as \"completed_at?: DateTime\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"\n FROM execution_processes \n WHERE status = 'running' \n ORDER BY created_at ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_attempt_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "process_type!: ExecutionProcessType", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "status!: ExecutionProcessStatus", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "command", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "args", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "working_directory", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "stdout", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "stderr", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "started_at!: DateTime", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "completed_at?: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "d25396768d88ecab6e13ad9fca8e8c46e92ff17474ebd24657384e130c49afa8" +} diff --git a/backend/.sqlx/query-ed8456646fa69ddd412441955f06ff22bfb790f29466450735e0b8bb1bc4ec94.json b/backend/.sqlx/query-ed8456646fa69ddd412441955f06ff22bfb790f29466450735e0b8bb1bc4ec94.json new file mode 100644 index 00000000..896d7278 --- /dev/null +++ b/backend/.sqlx/query-ed8456646fa69ddd412441955f06ff22bfb790f29466450735e0b8bb1bc4ec94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE execution_processes SET stdout = COALESCE(stdout, '') || $1, updated_at = datetime('now') WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ed8456646fa69ddd412441955f06ff22bfb790f29466450735e0b8bb1bc4ec94" +} diff --git a/backend/migrations/20250620212427_execution_processes.sql b/backend/migrations/20250620212427_execution_processes.sql new file mode 100644 index 00000000..f1a43088 --- /dev/null +++ b/backend/migrations/20250620212427_execution_processes.sql @@ -0,0 +1,25 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE execution_processes ( + id BLOB PRIMARY KEY, + task_attempt_id BLOB NOT NULL, + process_type TEXT NOT NULL DEFAULT 'setupscript' + CHECK (process_type IN ('setupscript','codingagent','devserver')), + status TEXT NOT NULL DEFAULT 'running' + CHECK (status IN ('running','completed','failed','killed')), + command TEXT NOT NULL, + args TEXT, -- JSON array of arguments + working_directory TEXT NOT NULL, + stdout TEXT, + stderr TEXT, + exit_code INTEGER, + started_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE +); + +CREATE INDEX idx_execution_processes_task_attempt_id ON execution_processes(task_attempt_id); +CREATE INDEX idx_execution_processes_status ON execution_processes(status); +CREATE INDEX idx_execution_processes_type ON execution_processes(process_type); diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 97a29f89..280e2ed7 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -44,7 +44,7 @@ impl AppState { .any(|exec| exec.task_attempt_id == attempt_id) } - pub async fn get_running_executions_for_monitor(&self) -> Vec<(Uuid, Uuid, bool, Option)> { + pub async fn get_running_executions_for_monitor(&self) -> Vec<(Uuid, Uuid, bool, Option)> { let mut executions = self.running_executions.lock().await; let mut completed_executions = Vec::new(); @@ -52,7 +52,7 @@ impl AppState { match running_exec.child.try_wait() { Ok(Some(status)) => { let success = status.success(); - let exit_code = status.code(); + let exit_code = status.code().map(|c| c as i64); completed_executions.push(( *execution_id, running_exec.task_attempt_id, diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs index da9e35e0..b1ad6083 100644 --- a/backend/src/execution_monitor.rs +++ b/backend/src/execution_monitor.rs @@ -3,6 +3,7 @@ use uuid::Uuid; use crate::app_state::AppState; use crate::models::{ + execution_process::{ExecutionProcess, ExecutionProcessStatus, ExecutionProcessType}, task::{Task, TaskStatus}, task_attempt::{TaskAttempt, TaskAttemptStatus}, task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity}, @@ -191,82 +192,282 @@ pub async fn execution_monitor(app_state: AppState) { tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text); - // Play sound notification if enabled - if app_state.get_sound_alerts_enabled().await { - play_sound_notification().await; + // Update the execution process record + let execution_status = if success { + ExecutionProcessStatus::Completed + } else { + ExecutionProcessStatus::Failed + }; + + if let Err(e) = ExecutionProcess::update_completion( + &app_state.db_pool, + execution_id, + execution_status, + exit_code, + ) + .await + { + tracing::error!( + "Failed to update execution process {} completion: {}", + execution_id, + e + ); } - // Get task attempt to access worktree path for committing changes - if let Ok(Some(task_attempt)) = - TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await + // Get the execution process to determine next steps + if let Ok(Some(execution_process)) = + ExecutionProcess::find_by_id(&app_state.db_pool, execution_id).await { - // Commit any unstaged changes after execution completion - if let Err(e) = - commit_execution_changes(&task_attempt.worktree_path, task_attempt_id).await - { - tracing::error!( - "Failed to commit execution changes for attempt {}: {}", - task_attempt_id, - e - ); - } else { - tracing::info!( - "Successfully committed execution changes for attempt {}", - task_attempt_id - ); - } - - // Create task attempt activity with appropriate completion status - let activity_id = Uuid::new_v4(); - let status = if success { - TaskAttemptStatus::ExecutorComplete - } else { - TaskAttemptStatus::ExecutorFailed - }; - let create_activity = CreateTaskAttemptActivity { - task_attempt_id, - status: Some(status.clone()), - note: Some(format!("Execution completed{}", exit_text)), - }; - - if let Err(e) = TaskAttemptActivity::create( - &app_state.db_pool, - &create_activity, - activity_id, - status, - ) - .await - { - tracing::error!("Failed to create paused activity: {}", e); - } else { - tracing::info!( - "Task attempt {} set to paused after execution completion", - task_attempt_id - ); - - // Get task to access task_id and project_id for status update - if let Ok(Some(task)) = - Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await - { - // Update task status to InReview - if let Err(e) = Task::update_status( - &app_state.db_pool, - task.id, - task.project_id, - TaskStatus::InReview, + match execution_process.process_type { + ExecutionProcessType::SetupScript => { + handle_setup_completion( + &app_state, + task_attempt_id, + execution_process, + success, + exit_code, ) - .await - { - tracing::error!("Failed to update task status to InReview for completed attempt: {}", e); - } + .await; + } + ExecutionProcessType::CodingAgent => { + handle_coding_agent_completion( + &app_state, + task_attempt_id, + execution_process, + success, + exit_code, + ) + .await; + } + ExecutionProcessType::DevServer => { + handle_dev_server_completion( + &app_state, + task_attempt_id, + execution_process, + success, + exit_code, + ) + .await; } } } else { tracing::error!( - "Failed to find task attempt {} for execution completion", - task_attempt_id + "Failed to find execution process {} for completion handling", + execution_id ); } } } } + +/// Handle setup script completion +async fn handle_setup_completion( + app_state: &AppState, + task_attempt_id: Uuid, + _execution_process: ExecutionProcess, + success: bool, + exit_code: Option, +) { + let exit_text = if let Some(code) = exit_code { + format!(" with exit code {}", code) + } else { + String::new() + }; + + if success { + // Setup completed successfully, create activity + let activity_id = Uuid::new_v4(); + let create_activity = CreateTaskAttemptActivity { + task_attempt_id, + status: Some(TaskAttemptStatus::SetupComplete), + note: Some(format!("Setup script completed successfully{}", exit_text)), + }; + + if let Err(e) = TaskAttemptActivity::create( + &app_state.db_pool, + &create_activity, + activity_id, + TaskAttemptStatus::SetupComplete, + ) + .await + { + tracing::error!("Failed to create setup complete activity: {}", e); + return; + } + + // Get task and project info to start coding agent + if let Ok(Some(task_attempt)) = + TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await + { + if let Ok(Some(task)) = Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await + { + // Start the coding agent + if let Err(e) = TaskAttempt::start_coding_agent( + &app_state.db_pool, + app_state, + task_attempt_id, + task.id, + task.project_id, + ) + .await + { + tracing::error!("Failed to start coding agent after setup completion: {}", e); + } + } + } + } else { + // Setup failed, create activity and update task status + let activity_id = Uuid::new_v4(); + let create_activity = CreateTaskAttemptActivity { + task_attempt_id, + status: Some(TaskAttemptStatus::SetupFailed), + note: Some(format!("Setup script failed{}", exit_text)), + }; + + if let Err(e) = TaskAttemptActivity::create( + &app_state.db_pool, + &create_activity, + activity_id, + TaskAttemptStatus::SetupFailed, + ) + .await + { + tracing::error!("Failed to create setup failed activity: {}", e); + } + + // Update task status to InReview since setup failed + if let Ok(Some(task_attempt)) = + TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await + { + if let Ok(Some(task)) = Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await + { + if let Err(e) = Task::update_status( + &app_state.db_pool, + task.id, + task.project_id, + TaskStatus::InReview, + ) + .await + { + tracing::error!( + "Failed to update task status to InReview after setup failure: {}", + e + ); + } + } + } + } +} + +/// Handle coding agent completion +async fn handle_coding_agent_completion( + app_state: &AppState, + task_attempt_id: Uuid, + _execution_process: ExecutionProcess, + success: bool, + exit_code: Option, +) { + let exit_text = if let Some(code) = exit_code { + format!(" with exit code {}", code) + } else { + String::new() + }; + + // Play sound notification if enabled + if app_state.get_sound_alerts_enabled().await { + play_sound_notification().await; + } + + // Get task attempt to access worktree path for committing changes + if let Ok(Some(task_attempt)) = + TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await + { + // Commit any unstaged changes after execution completion + if let Err(e) = commit_execution_changes(&task_attempt.worktree_path, task_attempt_id).await + { + tracing::error!( + "Failed to commit execution changes for attempt {}: {}", + task_attempt_id, + e + ); + } else { + tracing::info!( + "Successfully committed execution changes for attempt {}", + task_attempt_id + ); + } + + // Create task attempt activity with appropriate completion status + let activity_id = Uuid::new_v4(); + let status = if success { + TaskAttemptStatus::ExecutorComplete + } else { + TaskAttemptStatus::ExecutorFailed + }; + let create_activity = CreateTaskAttemptActivity { + task_attempt_id, + status: Some(status.clone()), + note: Some(format!("Coding agent execution completed{}", exit_text)), + }; + + if let Err(e) = + TaskAttemptActivity::create(&app_state.db_pool, &create_activity, activity_id, status) + .await + { + tracing::error!("Failed to create executor completion activity: {}", e); + } else { + tracing::info!( + "Task attempt {} set to paused after coding agent completion", + task_attempt_id + ); + + // Get task to access task_id and project_id for status update + if let Ok(Some(task)) = Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await + { + // Update task status to InReview + if let Err(e) = Task::update_status( + &app_state.db_pool, + task.id, + task.project_id, + TaskStatus::InReview, + ) + .await + { + tracing::error!( + "Failed to update task status to InReview for completed attempt: {}", + e + ); + } + } + } + } else { + tracing::error!( + "Failed to find task attempt {} for coding agent completion", + task_attempt_id + ); + } +} + +/// Handle dev server completion (future functionality) +async fn handle_dev_server_completion( + _app_state: &AppState, + task_attempt_id: Uuid, + _execution_process: ExecutionProcess, + _success: bool, + exit_code: Option, +) { + let exit_text = if let Some(code) = exit_code { + format!(" with exit code {}", code) + } else { + String::new() + }; + + tracing::info!( + "Dev server for task attempt {} completed{}", + task_attempt_id, + exit_text + ); + + // Dev servers might restart automatically or have different completion semantics + // For now, just log the completion +} diff --git a/backend/src/models/execution_process.rs b/backend/src/models/execution_process.rs new file mode 100644 index 00000000..2886e91d --- /dev/null +++ b/backend/src/models/execution_process.rs @@ -0,0 +1,317 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool, Type}; +use ts_rs::TS; +use uuid::Uuid; + +use crate::app_state::ExecutionType; + +#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)] +#[sqlx(type_name = "execution_process_status", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum ExecutionProcessStatus { + Running, + Completed, + Failed, + Killed, +} + +#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)] +#[sqlx(type_name = "execution_process_type", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum ExecutionProcessType { + SetupScript, + CodingAgent, + DevServer, +} + +impl From for ExecutionProcessType { + fn from(exec_type: ExecutionType) -> Self { + match exec_type { + ExecutionType::SetupScript => ExecutionProcessType::SetupScript, + ExecutionType::CodingAgent => ExecutionProcessType::CodingAgent, + ExecutionType::DevServer => ExecutionProcessType::DevServer, + } + } +} + +impl From for ExecutionType { + fn from(exec_type: ExecutionProcessType) -> Self { + match exec_type { + ExecutionProcessType::SetupScript => ExecutionType::SetupScript, + ExecutionProcessType::CodingAgent => ExecutionType::CodingAgent, + ExecutionProcessType::DevServer => ExecutionType::DevServer, + } + } +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ExecutionProcess { + pub id: Uuid, + pub task_attempt_id: Uuid, + pub process_type: ExecutionProcessType, + pub status: ExecutionProcessStatus, + pub command: String, + pub args: Option, // JSON array of arguments + pub working_directory: String, + pub stdout: Option, + pub stderr: Option, + pub exit_code: Option, + pub started_at: DateTime, + pub completed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateExecutionProcess { + pub task_attempt_id: Uuid, + pub process_type: ExecutionProcessType, + pub command: String, + pub args: Option, + pub working_directory: String, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct UpdateExecutionProcess { + pub status: Option, + pub exit_code: Option, + pub completed_at: Option>, +} + +impl ExecutionProcess { + /// Find execution process by ID + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + ExecutionProcess, + r#"SELECT + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + process_type as "process_type!: ExecutionProcessType", + status as "status!: ExecutionProcessStatus", + command, + args, + working_directory, + stdout, + stderr, + exit_code, + started_at as "started_at!: DateTime", + completed_at as "completed_at?: DateTime", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM execution_processes + WHERE id = $1"#, + id + ) + .fetch_optional(pool) + .await + } + + /// Find all execution processes for a task attempt + pub async fn find_by_task_attempt_id( + pool: &SqlitePool, + task_attempt_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + ExecutionProcess, + r#"SELECT + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + process_type as "process_type!: ExecutionProcessType", + status as "status!: ExecutionProcessStatus", + command, + args, + working_directory, + stdout, + stderr, + exit_code, + started_at as "started_at!: DateTime", + completed_at as "completed_at?: DateTime", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM execution_processes + WHERE task_attempt_id = $1 + ORDER BY created_at ASC"#, + task_attempt_id + ) + .fetch_all(pool) + .await + } + + /// Find running execution processes + pub async fn find_running(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + ExecutionProcess, + r#"SELECT + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + process_type as "process_type!: ExecutionProcessType", + status as "status!: ExecutionProcessStatus", + command, + args, + working_directory, + stdout, + stderr, + exit_code, + started_at as "started_at!: DateTime", + completed_at as "completed_at?: DateTime", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM execution_processes + WHERE status = 'running' + ORDER BY created_at ASC"# + ) + .fetch_all(pool) + .await + } + + /// Create a new execution process + pub async fn create( + pool: &SqlitePool, + data: &CreateExecutionProcess, + process_id: Uuid, + ) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + ExecutionProcess, + r#"INSERT INTO execution_processes ( + id, task_attempt_id, process_type, status, command, args, + working_directory, stdout, stderr, exit_code, started_at, + completed_at, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING + id as "id!: Uuid", + task_attempt_id as "task_attempt_id!: Uuid", + process_type as "process_type!: ExecutionProcessType", + status as "status!: ExecutionProcessStatus", + command, + args, + working_directory, + stdout, + stderr, + exit_code, + started_at as "started_at!: DateTime", + completed_at as "completed_at?: DateTime", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime""#, + process_id, + data.task_attempt_id, + data.process_type, + ExecutionProcessStatus::Running, + data.command, + data.args, + data.working_directory, + None::, // stdout + None::, // stderr + None::, // exit_code + now, // started_at + None::>, // completed_at + now, // created_at + now // updated_at + ) + .fetch_one(pool) + .await + } + + /// Update execution process status and completion info + pub async fn update_completion( + pool: &SqlitePool, + id: Uuid, + status: ExecutionProcessStatus, + exit_code: Option, + ) -> Result<(), sqlx::Error> { + let completed_at = if matches!(status, ExecutionProcessStatus::Running) { + None + } else { + Some(Utc::now()) + }; + + sqlx::query!( + r#"UPDATE execution_processes + SET status = $1, exit_code = $2, completed_at = $3, updated_at = datetime('now') + WHERE id = $4"#, + status, + exit_code, + completed_at, + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Append to stdout for this execution process (for streaming updates) + pub async fn append_stdout( + pool: &SqlitePool, + id: Uuid, + stdout_append: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE execution_processes SET stdout = COALESCE(stdout, '') || $1, updated_at = datetime('now') WHERE id = $2", + stdout_append, + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Append to stderr for this execution process (for streaming updates) + pub async fn append_stderr( + pool: &SqlitePool, + id: Uuid, + stderr_append: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE execution_processes SET stderr = COALESCE(stderr, '') || $1, updated_at = datetime('now') WHERE id = $2", + stderr_append, + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Append to both stdout and stderr for this execution process + pub async fn append_output( + pool: &SqlitePool, + id: Uuid, + stdout_append: Option<&str>, + stderr_append: Option<&str>, + ) -> Result<(), sqlx::Error> { + if let Some(stdout_data) = stdout_append { + Self::append_stdout(pool, id, stdout_data).await?; + } + + if let Some(stderr_data) = stderr_append { + Self::append_stderr(pool, id, stderr_data).await?; + } + + Ok(()) + } + + /// Delete execution processes for a task attempt (cleanup) + pub async fn delete_by_task_attempt_id( + pool: &SqlitePool, + task_attempt_id: Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM execution_processes WHERE task_attempt_id = $1", + task_attempt_id + ) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index edd9df20..03893fb2 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod api_response; pub mod config; +pub mod execution_process; pub mod project; pub mod task; pub mod task_attempt; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 6bbb74ab..a673b4cd 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -430,6 +430,9 @@ impl TaskAttempt { task_id: Uuid, project_id: Uuid, ) -> Result<(), TaskAttemptError> { + use crate::models::execution_process::{ + CreateExecutionProcess, ExecutionProcess, ExecutionProcessType, + }; use crate::models::project::Project; use crate::models::task::{Task, TaskStatus}; use crate::models::task_attempt_activity::{ @@ -471,14 +474,29 @@ impl TaskAttempt { ) .await?; + // Create execution process record for setup script + let setup_process_id = Uuid::new_v4(); + let create_setup_process = CreateExecutionProcess { + task_attempt_id: attempt_id, + process_type: ExecutionProcessType::SetupScript, + command: "bash".to_string(), + args: Some(serde_json::to_string(&["-c", setup_script]).unwrap()), + working_directory: task_attempt.worktree_path.clone(), + }; + + let _setup_process = + ExecutionProcess::create(pool, &create_setup_process, setup_process_id).await?; + tracing::info!("Running setup script for task attempt {}", attempt_id); - let output = tokio::process::Command::new("bash") + // Start setup script as streaming process + let child = tokio::process::Command::new("bash") .arg("-c") .arg(setup_script) .current_dir(&task_attempt.worktree_path) - .output() - .await + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() .map_err(|e| { TaskAttemptError::Git(git2::Error::from_str(&format!( "Failed to execute setup script: {}", @@ -486,60 +504,54 @@ impl TaskAttempt { ))) })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::error!("Setup script failed for attempt {}: {}", attempt_id, stderr); - - // Create activity for setup script failure - let activity_id = Uuid::new_v4(); - let create_activity = CreateTaskAttemptActivity { - task_attempt_id: attempt_id, - status: Some(TaskAttemptStatus::SetupFailed), - note: Some(format!("Setup script failed: {}", stderr)), - }; - - TaskAttemptActivity::create( - pool, - &create_activity, - activity_id, - TaskAttemptStatus::SetupFailed, + // Add setup script to running executions for monitoring + app_state + .add_running_execution( + setup_process_id, + crate::app_state::RunningExecution { + task_attempt_id: attempt_id, + execution_type: ExecutionType::SetupScript, + child, + }, ) - .await?; + .await; - // Update task status to InReview - Task::update_status(pool, task_id, project_id, TaskStatus::InReview).await?; - - return Err(TaskAttemptError::Git(git2::Error::from_str(&format!( - "Setup script failed: {}", - stderr - )))); - } - - let stdout = String::from_utf8_lossy(&output.stdout); tracing::info!( - "Setup script completed for attempt {}: {}", - attempt_id, - stdout + "Started setup script execution {} for task attempt {}", + setup_process_id, + attempt_id ); - // Create activity for setup script completion - let activity_id = Uuid::new_v4(); - let create_activity = CreateTaskAttemptActivity { - task_attempt_id: attempt_id, - status: Some(TaskAttemptStatus::SetupComplete), - note: Some("Setup script completed successfully".to_string()), - }; - - TaskAttemptActivity::create( - pool, - &create_activity, - activity_id, - TaskAttemptStatus::SetupComplete, - ) - .await?; + // Wait for setup script to complete before starting executor + // We'll let the execution monitor handle the completion and then start the executor + return Ok(()); } } + // If no setup script, start executor directly + Self::start_coding_agent(pool, app_state, attempt_id, task_id, project_id).await + } + + /// Start the coding agent after setup is complete or if no setup is needed + pub async fn start_coding_agent( + pool: &SqlitePool, + app_state: &crate::app_state::AppState, + attempt_id: Uuid, + task_id: Uuid, + _project_id: Uuid, + ) -> Result<(), TaskAttemptError> { + use crate::models::execution_process::{ + CreateExecutionProcess, ExecutionProcess, ExecutionProcessType, + }; + use crate::models::task_attempt_activity::{ + CreateTaskAttemptActivity, TaskAttemptActivity, + }; + + // Get the task attempt + let task_attempt = TaskAttempt::find_by_id(pool, attempt_id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + // Step 2: Start the executor let executor = task_attempt.get_executor(); @@ -559,16 +571,28 @@ impl TaskAttempt { ) .await?; + // Create execution process record for coding agent + let agent_process_id = Uuid::new_v4(); + let create_agent_process = CreateExecutionProcess { + task_attempt_id: attempt_id, + process_type: ExecutionProcessType::CodingAgent, + command: "executor".to_string(), // This will be the actual executor command + args: None, // Executor-specific args will be handled by the executor itself + working_directory: task_attempt.worktree_path.clone(), + }; + + let _agent_process = + ExecutionProcess::create(pool, &create_agent_process, agent_process_id).await?; + let child = executor .execute_streaming(pool, task_id, attempt_id, &task_attempt.worktree_path) .await .map_err(|e| TaskAttemptError::Git(git2::Error::from_str(&e.to_string())))?; // Add to running executions - let execution_id = Uuid::new_v4(); app_state .add_running_execution( - execution_id, + agent_process_id, crate::app_state::RunningExecution { task_attempt_id: attempt_id, execution_type: ExecutionType::CodingAgent, @@ -579,7 +603,7 @@ impl TaskAttempt { tracing::info!( "Started execution {} for task attempt {}", - execution_id, + agent_process_id, attempt_id ); diff --git a/shared/types.ts b/shared/types.ts index e3c418bd..b7c2e158 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -40,7 +40,7 @@ export type TaskAttempt = { id: string, task_id: string, worktree_path: string, export type CreateTaskAttempt = { task_id: string, worktree_path: string, merge_commit: string | null, executor: string | null, }; -export type UpdateTaskAttempt = { worktree_path: string | null, merge_commit: string | null, }; +export type UpdateTaskAttempt = Record; export type TaskAttemptActivity = { id: string, task_attempt_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, };