From 520e122ca0fac617cd4de648893a81f17b3e372c Mon Sep 17 00:00:00 2001 From: Rui Andrada <27135+shingonoide@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:54:15 -0300 Subject: [PATCH 1/4] task: Choose review mode when creating a Task --- crates/slashit-cli/src/main.rs | 6 +++ crates/slashit-ipc/src/protocol.rs | 5 +++ src-tauri/src/commands/task.rs | 68 +++++++++++++++++++++++++++++- src-tauri/src/ipc/handlers.rs | 20 ++++++++- src-tauri/tests/ipc_integration.rs | 1 + src/components/task_edit_modal.rs | 2 + src/services/task_service.rs | 4 ++ 7 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/slashit-cli/src/main.rs b/crates/slashit-cli/src/main.rs index 3b6f8f9c..98b3a086 100644 --- a/crates/slashit-cli/src/main.rs +++ b/crates/slashit-cli/src/main.rs @@ -64,6 +64,10 @@ enum Commands { /// Priority: urgent, high, medium, low #[arg(long)] priority: Option, + /// Review mode: manual (no AI Review, run goes to Human Review) or + /// automatic (AI Review runs after each run; the default) + #[arg(long, value_parser = ["manual", "automatic"])] + review_mode: Option, }, /// Move a task to a different status Move { @@ -140,11 +144,13 @@ async fn main() -> Result<()> { title, description, priority, + review_mode, } => IpcRequest::CreateTask { project_id: project.clone(), title: title.clone(), description: description.clone(), priority: priority.clone(), + review_mode: review_mode.clone(), }, Commands::Move { task_id, status } => IpcRequest::MoveTask { task_id: task_id.clone(), diff --git a/crates/slashit-ipc/src/protocol.rs b/crates/slashit-ipc/src/protocol.rs index d2e40117..4718bbf4 100644 --- a/crates/slashit-ipc/src/protocol.rs +++ b/crates/slashit-ipc/src/protocol.rs @@ -44,6 +44,9 @@ pub enum IpcRequest { title: String, description: Option, priority: Option, + /// `manual` or `automatic`; absent keeps the default, automatic. + #[serde(default, skip_serializing_if = "Option::is_none")] + review_mode: Option, }, /// Move a task to a different status MoveTask { task_id: String, status: String }, @@ -326,6 +329,7 @@ mod tests { title: String::new(), description: None, priority: None, + review_mode: None, } .spawns_agent()); assert!(IpcRequest::MoveTask { @@ -368,6 +372,7 @@ mod tests { title: String::new(), description: None, priority: None, + review_mode: None, }, IpcRequest::MoveTask { task_id: String::new(), diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 747ee431..c4426a10 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -83,6 +83,10 @@ pub struct CreateTaskParams { pub github_issue_url: Option, pub gitlab_issue_url: Option, pub linear_ticket_id: Option, + /// How a finished run is reviewed. Absent keeps the default, Automatic. + pub review_mode: Option, + /// The coding Worker. Absent keeps the default, Claude Code. + pub provider: Option, } #[tauri::command] @@ -106,6 +110,8 @@ pub async fn create_task( &dependencies, )?; + let review_mode = params.review_mode.unwrap_or_default(); + let provider = params.provider.unwrap_or_default(); let new = NewTask { id, project_id, @@ -132,7 +138,10 @@ pub async fn create_task( project_id, move |existing| { crate::domain::task_dependencies::check_targets(existing, id, project_id, &dependencies)?; - Ok(Task::new_backlog(existing, new)) + let mut task = Task::new_backlog(existing, new); + task.review_mode = review_mode; + task.provider = provider; + Ok(task) }, ) .await @@ -3076,11 +3085,68 @@ mod task_dependency_contract { github_issue_url: None, gitlab_issue_url: None, linear_ticket_id: None, + review_mode: None, + provider: None, }, ) .await } + async fn create_in_mode( + app: &App, + project_id: Uuid, + review_mode: Option, + ) -> Result { + create_task( + GuiMutation::untracked(), + app.state(), + CreateTaskParams { + reviewer_model: None, + project_id: project_id.to_string(), + title: "Moded".to_string(), + description: None, + model: "default".to_string(), + planning_mode: false, + dependencies: vec![], + category: None, + priority: None, + complexity: None, + impact: None, + security_severity: None, + github_issue_url: None, + gitlab_issue_url: None, + linear_ticket_id: None, + review_mode, + provider: None, + }, + ) + .await + } + + /// Each mode is written in the creating write, survives a reload, and is + /// what the Worker handoff is built from. + #[tokio::test] + async fn a_new_task_keeps_the_review_mode_it_was_created_with() { + use crate::domain::task::ReviewMode::{Automatic, Manual}; + let project_id = Uuid::new_v4(); + let (_tmp, _paths, app) = app_with(vec![], &[project_id]).await; + + for (asked, expected) in [(Some(Manual), Manual), (Some(Automatic), Automatic), (None, Automatic)] { + let created = create_in_mode(&app, project_id, asked).await.expect("created"); + assert_eq!(created.review_mode, expected); + let (memory, disk) = both_copies(&app, project_id, created.id).await; + let mode = |v: &serde_json::Value| v.get("review_mode").and_then(|m| m.as_str()).map(str::to_owned); + let stored = (mode(&memory), mode(&disk)); + match expected { + Manual => assert_eq!(stored, (Some("manual".into()), Some("manual".into()))), + Automatic => assert_eq!(stored, (None, None), "the default is omitted"), + } + let reloaded = app.state::().storage.load_project_tasks(project_id).expect("reload"); + let reloaded = reloaded.into_iter().find(|t| t.id == created.id).expect("present"); + assert_eq!(reloaded.review_mode, expected); + } + } + async fn set(app: &App, task_id: Uuid, dependencies: Vec) -> Result, String> { set_task_dependencies( GuiMutation::untracked(), diff --git a/src-tauri/src/ipc/handlers.rs b/src-tauri/src/ipc/handlers.rs index 3c09726e..e8ae885b 100644 --- a/src-tauri/src/ipc/handlers.rs +++ b/src-tauri/src/ipc/handlers.rs @@ -60,7 +60,10 @@ pub async fn dispatch(req: IpcRequest, ctx: &IpcContext, peer: &PeerContext) -> title, description, priority, - } => Dispatch::reply(handle_create_task(ctx, project_id, title, description, priority).await), + review_mode, + } => Dispatch::reply( + handle_create_task(ctx, project_id, title, description, priority, review_mode).await, + ), IpcRequest::MoveTask { task_id, status } => { Dispatch::reply(handle_move_task(ctx, task_id, status).await) } @@ -150,11 +153,21 @@ async fn handle_create_task( title: String, description: Option, priority: Option, + review_mode: Option, ) -> IpcResponse { let project_uuid = match Uuid::parse_str(&project_id) { Ok(id) => id, Err(_) => return IpcResponse::error(format!("Invalid project_id: {project_id}")), }; + let review_mode = match review_mode.as_deref() { + None | Some("automatic") => crate::domain::task::ReviewMode::Automatic, + Some("manual") => crate::domain::task::ReviewMode::Manual, + Some(other) => { + return IpcResponse::error(format!( + "Invalid review mode: {other}. Use manual or automatic." + )) + } + }; // Verify project exists and get its name let project_name = { @@ -192,7 +205,9 @@ async fn handle_create_task( &ctx.storage, project_uuid, move |existing| { - crate::domain::Task::new_backlog(existing, new) + let mut task = crate::domain::Task::new_backlog(existing, new); + task.review_mode = review_mode; + task }, ) .await; @@ -1069,6 +1084,7 @@ pub(super) mod tests { title: format!("task {i}"), description: None, priority: None, + review_mode: None, }, &ctx, &peer(), diff --git a/src-tauri/tests/ipc_integration.rs b/src-tauri/tests/ipc_integration.rs index 6ef0b2ec..b5039242 100644 --- a/src-tauri/tests/ipc_integration.rs +++ b/src-tauri/tests/ipc_integration.rs @@ -451,6 +451,7 @@ fn agent_spawning_requests() -> Vec { title: "from a remote peer".to_string(), description: Some("rm -rf".to_string()), priority: None, + review_mode: None, }, IpcRequest::MoveTask { task_id: uuid::Uuid::new_v4().to_string(), diff --git a/src/components/task_edit_modal.rs b/src/components/task_edit_modal.rs index d42e3c0f..7b568d1e 100644 --- a/src/components/task_edit_modal.rs +++ b/src/components/task_edit_modal.rs @@ -616,6 +616,8 @@ pub fn TaskEditModal( complexity: None, impact: None, security_severity: None, + review_mode: review_mode_val, + provider: provider_val, }).await } TaskEditMode::Edit(task) => { diff --git a/src/services/task_service.rs b/src/services/task_service.rs index 89a25ace..970a88ef 100644 --- a/src/services/task_service.rs +++ b/src/services/task_service.rs @@ -33,6 +33,8 @@ pub struct CreateTaskParams { pub complexity: Option, pub impact: Option, pub security_severity: Option, + pub review_mode: ReviewMode, + pub provider: CodingProvider, } pub async fn create_task(params: CreateTaskParams) -> Result { @@ -50,6 +52,8 @@ pub async fn create_task(params: CreateTaskParams) -> Result { "complexity": params.complexity, "impact": params.impact, "securitySeverity": params.security_severity, + "reviewMode": params.review_mode, + "provider": params.provider, } })).unwrap(); From c7c9aab7c9efd779055960f014a8fbba61469117 Mon Sep 17 00:00:00 2001 From: Rui Andrada <27135+shingonoide@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:49:26 -0300 Subject: [PATCH 2/4] test(task): prove the review mode chosen at creation reaches the Worker handoff Create Tasks through create_task in each mode, start them through the executor's real run-start write, and assert the stored WorkerHandoff in memory and on disk. Correct the neighbouring test comment to match what it asserts. --- src-tauri/src/commands/task.rs | 74 ++++++++++++++++++++++++++++++++- src-tauri/src/queue/executor.rs | 17 ++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index c4426a10..fec2c596 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -3123,8 +3123,8 @@ mod task_dependency_contract { .await } - /// Each mode is written in the creating write, survives a reload, and is - /// what the Worker handoff is built from. + /// Each mode is written in the creating write and survives a reload. That + /// the mode reaches the Worker handoff is proved by the next test. #[tokio::test] async fn a_new_task_keeps_the_review_mode_it_was_created_with() { use crate::domain::task::ReviewMode::{Automatic, Manual}; @@ -3147,6 +3147,76 @@ mod task_dependency_contract { } } + fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .current_dir(dir) + .args(["-c", "user.email=t@example.com", "-c", "user.name=T"]) + .args(args) + .output() + .expect("git runs"); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + } + + /// A Task created through `create_task` and then started through the + /// executor's real run-start write records the selected mode in the + /// stored `worker_handoff`, in memory and on disk. No test helper sets + /// the mode; an omitted mode keeps the Automatic default. + #[tokio::test] + async fn the_mode_chosen_at_creation_reaches_the_stored_worker_handoff() { + use crate::domain::task::ReviewMode::{Automatic, Manual}; + let project_id = Uuid::new_v4(); + let (tmp, _paths, app) = app_with(vec![], &[project_id]).await; + let state = app.state::(); + + let repo = tmp.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "base"]); + let repository_id = Uuid::new_v4(); + state.repository.repositories.write().await.insert( + repository_id, + crate::domain::Repository { + id: repository_id, + local_path: repo.to_string_lossy().to_string(), + remote_url: None, + remote_type: None, + created_at: chrono::Utc::now(), + }, + ); + { + let mut projects = state.project.projects.write().await; + let project = projects.get_mut(&project_id).unwrap(); + project.repository_id = Some(repository_id); + project.base = Some(crate::domain::project::ProjectBase::LocalBranch { + branch: "main".to_string(), + }); + } + let executor = crate::test_helpers::attach_test_executor(&state); + + for (asked, expected) in [(Some(Manual), Manual), (Some(Automatic), Automatic), (None, Automatic)] { + let created = create_in_mode(&app, project_id, asked).await.expect("created"); + executor + .record_run_start_for_test(created.id, repo.to_str().unwrap()) + .await + .expect("the run start is recorded"); + + let memory = state.task.tasks.read().await[&created.id].clone(); + let disk = state + .storage + .load_project_tasks(project_id) + .expect("reload") + .into_iter() + .find(|t| t.id == created.id) + .expect("present"); + for task in [memory, disk] { + let handoff = task.worker_handoff.expect("the start wrote a handoff"); + assert!(matches!(handoff.stage, crate::domain::WorkerHandoffStage::Started {})); + assert_eq!(handoff.review_mode, expected, "asked {asked:?}"); + assert!(!handoff.review_mode_unproven); + } + } + } + async fn set(app: &App, task_id: Uuid, dependencies: Vec) -> Result, String> { set_task_dependencies( GuiMutation::untracked(), diff --git a/src-tauri/src/queue/executor.rs b/src-tauri/src/queue/executor.rs index 896f04a9..d2a920bb 100644 --- a/src-tauri/src/queue/executor.rs +++ b/src-tauri/src/queue/executor.rs @@ -2534,6 +2534,23 @@ impl TaskExecutor { } } + /// Runs the production checkout acquisition and run-start write for a + /// Task, without launching a Worker. The stored `worker_handoff` is the + /// one a real start would leave. + #[cfg(test)] + pub(crate) async fn record_run_start_for_test( + &self, + task_id: Uuid, + repo_path: &str, + ) -> Result<(), String> { + let (_, acquired) = self.acquire_task_worktree(task_id, repo_path).await; + let acquired = acquired.map_err(|e| format!("{e:?}"))?; + self.record_run_start(task_id, repo_path, acquired) + .await + .map(|_| ()) + .map_err(|e| format!("{e:?}")) + } + /// Makes the current run's record the rebuilt kind that recovery writes /// when the original record is lost: Manual, and unproven. #[cfg(all(test, unix))] From 1bf2f5539ada4738cf0a00f0d1135b6e14075db7 Mon Sep 17 00:00:00 2001 From: Rui Andrada <27135+shingonoide@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:49:31 -0300 Subject: [PATCH 3/4] task: Choose review mode when creating a Task From 4a1323f2bbe8957bc1a044773832e2460cbdf2b1 Mon Sep 17 00:00:00 2001 From: Rui Andrada <27135+shingonoide@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:03:16 -0300 Subject: [PATCH 4/4] task: Choose review mode when creating a Task --- src-tauri/src/commands/task.rs | 13 +++++++++---- src-tauri/src/queue/executor.rs | 10 +++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index fec2c596..34922911 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -3157,10 +3157,15 @@ mod task_dependency_contract { assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); } - /// A Task created through `create_task` and then started through the - /// executor's real run-start write records the selected mode in the - /// stored `worker_handoff`, in memory and on disk. No test helper sets - /// the mode; an omitted mode keeps the Automatic default. + /// A Task created through `create_task` and then passed through the + /// shared run-start write (via the `cfg(test)` `record_run_start` + /// wrapper, which skips new-attempt authorization and the + /// additional-directory refusal) records the selected mode in the stored + /// `worker_handoff`, in memory and on disk. No test helper sets the mode; + /// an omitted mode keeps the Automatic default. Only the Manual iteration + /// discriminates the propagation: the Some(Automatic) and None iterations + /// would pass even if the mode were dropped, because Automatic is the + /// default. #[tokio::test] async fn the_mode_chosen_at_creation_reaches_the_stored_worker_handoff() { use crate::domain::task::ReviewMode::{Automatic, Manual}; diff --git a/src-tauri/src/queue/executor.rs b/src-tauri/src/queue/executor.rs index d2a920bb..29d163d0 100644 --- a/src-tauri/src/queue/executor.rs +++ b/src-tauri/src/queue/executor.rs @@ -2534,9 +2534,13 @@ impl TaskExecutor { } } - /// Runs the production checkout acquisition and run-start write for a - /// Task, without launching a Worker. The stored `worker_handoff` is the - /// one a real start would leave. + /// Runs the production checkout acquisition, then the `cfg(test)` + /// `record_run_start` wrapper, without launching a Worker. That wrapper + /// passes no new-attempt authorization and no additional directories, so + /// this exercises the shared write that stores `review_mode` from + /// `t.review_mode` into `worker_handoff`. It does not exercise new-attempt + /// authorization or the additional-directory refusal that the production + /// `record_run_start_for` applies with its computed decision. #[cfg(test)] pub(crate) async fn record_run_start_for_test( &self,