From 06741b68f2b09b1d4bb21c51efd2c2c6f1805c74 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Fri, 31 Jul 2026 17:43:48 -0500 Subject: [PATCH] Prevent crosscheck races with pending AI work --- app/src/ai/blocklist/action_model.rs | 18 +++++++++++---- app/src/ai/blocklist/controller.rs | 33 ++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index f8d98991..e3cf1f69 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -1329,8 +1329,20 @@ impl BlocklistAIActionModel { return; } - // The phase is fully drained — sort results back into original tool-call order before - // notifying the controller that it may send the follow-up request. + // A serial phase may have more queued actions. Start those before notifying the controller; + // otherwise the controller can drain this phase's result while the next action is still + // pending, and that later tool result will lose its follow-up trigger. + if self + .pending_actions + .get(&conversation_id) + .is_some_and(|actions| !actions.is_empty()) + { + self.try_to_execute_available_actions(conversation_id, ctx); + return; + } + + // The entire action batch is fully drained — sort results back into original tool-call + // order before notifying the controller that it may send one complete follow-up request. self.sort_finished_results(conversation_id); ctx.emit(BlocklistAIActionEvent::FinishedAction { action_id, @@ -1374,8 +1386,6 @@ impl BlocklistAIActionModel { ); }); } - } else { - self.try_to_execute_available_actions(conversation_id, ctx); } } diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index bdaba69d..f358e2b0 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -2522,13 +2522,42 @@ impl BlocklistAIController { return; } - // Don't trigger crosscheck for child conversations + // Crosscheck is a terminal-turn activity. Do not start it until every source of work for + // this conversation has drained, or its feedback can race a tool result or another stream. + if self + .in_flight_response_streams + .has_active_stream_for_conversation(conversation_id, ctx) + { + return; + } + + let action_model = self.action_model.as_ref(ctx); + if action_model.has_unfinished_actions_for_conversation(conversation_id) + || action_model + .get_finished_action_results(conversation_id) + .is_some_and(|results| !results.is_empty()) + { + return; + } + + if self + .crosscheck_reviewer + .as_ref(ctx) + .is_reviewing(conversation_id) + { + return; + } + + // Don't trigger crosscheck for child conversations or a conversation that is no longer + // running under this controller. let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) else { return; }; - if conversation.parent_conversation_id().is_some() { + if conversation.parent_conversation_id().is_some() + || !conversation.status().is_in_progress() + { return; }