From 1d872b3c04c430fd81057b0aa5fb9df0c7ad329d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 2 Apr 2026 13:44:19 +1100 Subject: [PATCH 1/3] fix: move queued session draining to backend --- apps/staged/src-tauri/src/session_commands.rs | 20 ++++++ apps/staged/src-tauri/src/session_runner.rs | 66 ++++++++++++------- apps/staged/src-tauri/src/store/sessions.rs | 23 +++++++ .../lib/listeners/sessionStatusListener.ts | 8 --- 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index d66dfe12..83b69ff0 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -859,7 +859,27 @@ pub async fn drain_queued_sessions( provider: Option, ) -> Result { let store = get_store(&store)?; + drain_queued_sessions_for_branch( + store, + Arc::clone(®istry), + app_handle, + branch_id, + provider, + ) + .await +} +/// Start the oldest queued branch session, if any. +/// +/// This is shared by the Tauri command and backend lifecycle hooks so queue +/// progression remains owned by the backend. +pub async fn drain_queued_sessions_for_branch( + store: Arc, + registry: Arc, + app_handle: tauri::AppHandle, + branch_id: String, + provider: Option, +) -> Result { // Bail out if the branch already has a running session to prevent // concurrent sessions on the same branch. if store diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index caa2d0ad..e19d9078 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -402,41 +402,59 @@ pub fn start_session( error_msg, Some(&completion_reason), ); - } - // Trigger auto review when a commit session completes successfully, - // but only if there are no queued sessions waiting for this branch. - // Queued sessions take priority — the next one will be drained instead. - if let Some(branch_id) = committed_branch_id { - let has_queued = store_for_status - .get_queued_sessions_for_branch(&branch_id) - .map(|q| !q.is_empty()) - .unwrap_or(false); + let branch_id = store_for_status + .get_branch_id_for_session(&session_id_for_status) + .ok() + .flatten(); + let auto_review_branch_id = committed_branch_id.clone(); - if !has_queued { - let store_for_auto = Arc::clone(&store_for_status); - let registry_for_auto = Arc::clone(®istry); - let app_handle_for_auto = app_handle.clone(); + if let Some(branch_id) = branch_id { + let store_for_follow_up = Arc::clone(&store_for_status); + let registry_for_follow_up = Arc::clone(®istry); + let app_handle_for_follow_up = app_handle.clone(); tauri::async_runtime::spawn(async move { - match crate::session_commands::trigger_auto_review( - store_for_auto, - registry_for_auto, - app_handle_for_auto, + match crate::session_commands::drain_queued_sessions_for_branch( + Arc::clone(&store_for_follow_up), + Arc::clone(®istry_for_follow_up), + app_handle_for_follow_up.clone(), branch_id.clone(), None, ) .await { - Ok(resp) => { - log::info!( - "Auto review triggered for branch {branch_id}: session={}, review={}", - resp.session_id, - resp.artifact_id, - ); + Ok(true) => { + log::info!("Drained next queued session for branch {branch_id}"); + } + Ok(false) => { + if let Some(auto_review_branch_id) = auto_review_branch_id { + match crate::session_commands::trigger_auto_review( + store_for_follow_up, + registry_for_follow_up, + app_handle_for_follow_up, + auto_review_branch_id.clone(), + None, + ) + .await + { + Ok(resp) => { + log::info!( + "Auto review triggered for branch {auto_review_branch_id}: session={}, review={}", + resp.session_id, + resp.artifact_id, + ); + } + Err(e) => { + log::error!( + "Failed to trigger auto review for branch {auto_review_branch_id}: {e}" + ); + } + } + } } Err(e) => { log::error!( - "Failed to trigger auto review for branch {branch_id}: {e}" + "Failed to drain queued sessions for branch {branch_id}: {e}" ); } } diff --git a/apps/staged/src-tauri/src/store/sessions.rs b/apps/staged/src-tauri/src/store/sessions.rs index 26dfd625..15526564 100644 --- a/apps/staged/src-tauri/src/store/sessions.rs +++ b/apps/staged/src-tauri/src/store/sessions.rs @@ -189,6 +189,29 @@ impl Store { Ok(count > 0) } + /// Resolve the branch that owns a session through its linked artifact. + /// + /// Project-note sessions do not belong to a branch and therefore return `None`. + pub fn get_branch_id_for_session( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT branch_id FROM ( + SELECT branch_id FROM commits WHERE session_id = ?1 + UNION ALL + SELECT branch_id FROM notes WHERE session_id = ?1 + UNION ALL + SELECT branch_id FROM reviews WHERE session_id = ?1 + ) LIMIT 1", + params![session_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) + } + pub fn delete_session(&self, id: &str) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?; diff --git a/apps/staged/src/lib/listeners/sessionStatusListener.ts b/apps/staged/src/lib/listeners/sessionStatusListener.ts index 9a13c0f8..cced4595 100644 --- a/apps/staged/src/lib/listeners/sessionStatusListener.ts +++ b/apps/staged/src/lib/listeners/sessionStatusListener.ts @@ -10,7 +10,6 @@ */ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import * as commands from '../api/commands'; import { extractPrUrl, extractPrNumber, @@ -90,13 +89,6 @@ async function handleSessionEnd(sessionId: string, status: string) { // Clean up the session from the unified registry (single point of cleanup). sessionRegistry.unregister(sessionId); - - // Drain queued sessions for this branch so the next one starts automatically. - if (branchId) { - commands.drainQueuedSessions(branchId).catch((e) => { - console.error('[sessionStatusListener] Failed to drain queued sessions:', e); - }); - } } async function handlePrCompletion(sessionId: string, branchId: string, status: string) { From 2205fe491c824c73e7f2196d401c621f3de2e453 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 2 Apr 2026 16:21:21 +1100 Subject: [PATCH 2/3] fix: recover queued sessions after orphan cleanup Drain the next queued branch session after orphaned running sessions are recovered at startup. Reuse the app session registry during recovery and clarify the queue-drain and branch-resolution assumptions in docs. --- apps/staged/src-tauri/src/lib.rs | 4 ++- apps/staged/src-tauri/src/session_commands.rs | 2 +- apps/staged/src-tauri/src/session_runner.rs | 36 ++++++++++++++++++- apps/staged/src-tauri/src/store/sessions.rs | 3 ++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 7ee71a5f..9d9b8c29 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1601,6 +1601,7 @@ pub fn run() { // Check compatibility *before* creating the store. let compat = store::check_db_compatibility(&db_path) .map_err(|e| format!("Cannot check database: {e}"))?; + let session_registry = Arc::new(session_runner::SessionRegistry::new()); let (store_slot, reset_info) = match compat { store::DbCompatibility::Ok => { @@ -1611,6 +1612,7 @@ pub fn run() { // owned by other live Staged instances untouched. session_runner::recover_dead_sessions( Arc::clone(&store_arc), + Arc::clone(&session_registry), app.handle().clone(), ); // Clean up images left in "pending" state from compose @@ -1651,7 +1653,7 @@ pub fn run() { }; app.manage(store_slot); - app.manage(Arc::new(session_runner::SessionRegistry::new())); + app.manage(session_registry); app.manage(Arc::new(actions::ActionExecutor::new())); app.manage(Arc::new(actions::ActionRegistry::new())); app.manage(ShutdownState::default()); diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 83b69ff0..bf1a576a 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -869,7 +869,7 @@ pub async fn drain_queued_sessions( .await } -/// Start the oldest queued branch session, if any. +/// Start the oldest queued branch session if one exists and the branch is idle. /// /// This is shared by the Tauri command and backend lifecycle hooks so queue /// progression remains owned by the backend. diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index e19d9078..34e940fe 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -480,7 +480,11 @@ pub fn start_session( /// - `owner_pid` is dead (or NULL for pre-migration rows) → transition to /// error with `AppQuit` reason and emit `session-status-changed` so the /// frontend learns the outcome. -pub fn recover_dead_sessions(store: Arc, app_handle: AppHandle) { +pub fn recover_dead_sessions( + store: Arc, + registry: Arc, + app_handle: AppHandle, +) { let sessions = match store.get_running_sessions() { Ok(s) => s, Err(e) => { @@ -514,6 +518,36 @@ pub fn recover_dead_sessions(store: Arc, app_handle: AppHandle) { None, Some(&CompletionReason::AppQuit), ); + + let branch_id = store.get_branch_id_for_session(&session.id).ok().flatten(); + if let Some(branch_id) = branch_id { + let store_for_follow_up = Arc::clone(&store); + let registry_for_follow_up = Arc::clone(®istry); + let app_handle_for_follow_up = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match crate::session_commands::drain_queued_sessions_for_branch( + store_for_follow_up, + registry_for_follow_up, + app_handle_for_follow_up, + branch_id.clone(), + None, + ) + .await + { + Ok(true) => { + log::info!( + "Drained next queued session after orphan recovery for branch {branch_id}" + ); + } + Ok(false) => {} + Err(e) => { + log::error!( + "Failed to drain queued sessions after orphan recovery for branch {branch_id}: {e}" + ); + } + } + }); + } } } } diff --git a/apps/staged/src-tauri/src/store/sessions.rs b/apps/staged/src-tauri/src/store/sessions.rs index 15526564..7eb045a6 100644 --- a/apps/staged/src-tauri/src/store/sessions.rs +++ b/apps/staged/src-tauri/src/store/sessions.rs @@ -192,6 +192,9 @@ impl Store { /// Resolve the branch that owns a session through its linked artifact. /// /// Project-note sessions do not belong to a branch and therefore return `None`. + /// This assumes all branch-linked artifacts for a session point at the same + /// branch; if a session somehow links artifacts across multiple branches, + /// the first row returned by SQLite wins. pub fn get_branch_id_for_session( &self, session_id: &str, From 85452178695dfd660fbbd1e6cec27fe03b2346f7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 2 Apr 2026 16:44:33 +1100 Subject: [PATCH 3/3] fix: restore session status command imports Re-add the commands API import in sessionStatusListener so PR and push completion handlers typecheck again after the queue-drain cleanup removed the import. --- apps/staged/src/lib/listeners/sessionStatusListener.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/staged/src/lib/listeners/sessionStatusListener.ts b/apps/staged/src/lib/listeners/sessionStatusListener.ts index cced4595..d9feb560 100644 --- a/apps/staged/src/lib/listeners/sessionStatusListener.ts +++ b/apps/staged/src/lib/listeners/sessionStatusListener.ts @@ -10,6 +10,7 @@ */ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import * as commands from '../api/commands'; import { extractPrUrl, extractPrNumber,