feat(auto-drive): scaffold qa orchestrator and observer plumbing

This commit is contained in:
James Peter
2025-10-21 16:17:02 +10:00
parent 8647e403bd
commit 1194cf929e
11 changed files with 577 additions and 752 deletions
+8
View File
@@ -3,6 +3,14 @@
> [!TIP]
> We're constantly improving Code! This page documents the core changes. You can also check our [releases page](https://github.com/just-every/code/releases) for additional information.
## [Unreleased]
- Auto Drive: retire coordinator-driven `cross_check`/`code_review` actions; QA scheduling now lives in the standalone orchestrator thread.
- TUI: new QA orchestrator emits `AppEvent::AutoQaUpdate` cadence notes and `AppEvent::AutoReviewRequest` when a diff passes the review cooldown.
- Ops: tune QA cadence with `CODE_QA_CADENCE` (turns between updates) and `CODE_QA_REVIEW_COOLDOWN_TURNS` (turns between automated reviews).
- Auto Drive: force a cross-check observer pass before honoring coordinator success to prevent premature exits.
- QA: post-turn code review is now triggered exclusively via QA automation events instead of every write turn.
## [0.2.188] - 2025-10-06
- MCP: Validate stdio tool commands on PATH and surface clearer spawn errors during setup. (3a51d30)
+2
View File
@@ -996,6 +996,8 @@ pub fn set_tui_auto_drive_settings(
doc["tui"]["auto_drive"]["review_enabled"] = toml_edit::value(settings.review_enabled);
doc["tui"]["auto_drive"]["agents_enabled"] = toml_edit::value(settings.agents_enabled);
doc["tui"]["auto_drive"]["qa_automation_enabled"] =
toml_edit::value(settings.qa_automation_enabled);
doc["tui"]["auto_drive"]["cross_check_enabled"] =
toml_edit::value(settings.cross_check_enabled);
doc["tui"]["auto_drive"]["observer_enabled"] =
+4
View File
@@ -738,6 +738,9 @@ pub struct AutoDriveSettings {
#[serde(default)]
pub agents_enabled: bool,
#[serde(default = "default_true")]
pub qa_automation_enabled: bool,
#[serde(default = "default_true")]
pub cross_check_enabled: bool,
@@ -753,6 +756,7 @@ impl Default for AutoDriveSettings {
Self {
review_enabled: false,
agents_enabled: false,
qa_automation_enabled: true,
cross_check_enabled: true,
observer_enabled: true,
continue_mode: AutoDriveContinueMode::TenSeconds,
+12 -4
View File
@@ -1772,6 +1772,7 @@ impl App<'_> {
review_enabled,
agents_enabled,
cross_check_enabled,
qa_automation_enabled,
observer_enabled,
continue_mode,
} => {
@@ -1780,6 +1781,7 @@ impl App<'_> {
review_enabled,
agents_enabled,
cross_check_enabled,
qa_automation_enabled,
observer_enabled,
continue_mode,
);
@@ -1802,6 +1804,16 @@ impl App<'_> {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::AutoQaUpdate { note } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_auto_qa_update(note);
}
}
AppEvent::AutoReviewRequest { summary } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_auto_review_request(summary);
}
}
AppEvent::AutoCoordinatorDecision {
status,
progress_past,
@@ -1809,8 +1821,6 @@ impl App<'_> {
cli,
agents_timing,
agents,
code_review,
cross_check,
transcript,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
@@ -1821,8 +1831,6 @@ impl App<'_> {
cli,
agents_timing,
agents,
code_review,
cross_check,
transcript,
);
}
+15 -2
View File
@@ -126,6 +126,13 @@ pub(crate) struct AutoTurnCrossCheckAction {
pub forced: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ObserverMode {
Bootstrap,
Cadence,
CrossCheck,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoCoordinatorStatus {
Continue,
@@ -242,6 +249,12 @@ pub(crate) enum AppEvent {
elapsed: Duration,
},
/// Placeholder QA automation updates (no-op for now).
AutoQaUpdate { note: String },
/// Placeholder review requests routed from the QA orchestrator.
AutoReviewRequest { summary: Option<String> },
/// Internal: flush any pending out-of-order ExecEnd events that did not
/// receive a matching ExecBegin within a short pairing window. This lets
/// the TUI render a fallback "Ran call_<id>" cell so output is not lost.
@@ -268,8 +281,6 @@ pub(crate) enum AppEvent {
cli: Option<AutoTurnCliAction>,
agents_timing: Option<AutoTurnAgentsTiming>,
agents: Vec<AutoTurnAgentsAction>,
code_review: Option<AutoTurnCodeReviewAction>,
cross_check: Option<AutoTurnCrossCheckAction>,
transcript: Vec<ResponseItem>,
},
AutoCoordinatorThinking {
@@ -286,6 +297,7 @@ pub(crate) enum AppEvent {
attempt: u32,
},
AutoObserverReport {
mode: ObserverMode,
status: AutoObserverStatus,
telemetry: AutoObserverTelemetry,
replace_message: Option<String>,
@@ -301,6 +313,7 @@ pub(crate) enum AppEvent {
review_enabled: bool,
agents_enabled: bool,
cross_check_enabled: bool,
qa_automation_enabled: bool,
observer_enabled: bool,
continue_mode: AutoContinueMode,
},
@@ -19,6 +19,7 @@ pub(crate) struct AutoDriveSettingsView {
review_enabled: bool,
agents_enabled: bool,
cross_check_enabled: bool,
qa_automation_enabled: bool,
observer_enabled: bool,
continue_mode: AutoContinueMode,
closing: bool,
@@ -32,6 +33,7 @@ impl AutoDriveSettingsView {
review_enabled: bool,
agents_enabled: bool,
cross_check_enabled: bool,
qa_automation_enabled: bool,
observer_enabled: bool,
continue_mode: AutoContinueMode,
) -> Self {
@@ -41,6 +43,7 @@ impl AutoDriveSettingsView {
review_enabled,
agents_enabled,
cross_check_enabled,
qa_automation_enabled,
observer_enabled,
continue_mode,
closing: false,
@@ -48,7 +51,7 @@ impl AutoDriveSettingsView {
}
fn option_count() -> usize {
5
6
}
fn selected_mut(&mut self) -> &mut bool {
@@ -56,7 +59,8 @@ impl AutoDriveSettingsView {
0 => &mut self.review_enabled,
1 => &mut self.agents_enabled,
2 => &mut self.cross_check_enabled,
3 => &mut self.observer_enabled,
3 => &mut self.qa_automation_enabled,
4 => &mut self.observer_enabled,
_ => unreachable!(),
}
}
@@ -66,6 +70,7 @@ impl AutoDriveSettingsView {
review_enabled: self.review_enabled,
agents_enabled: self.agents_enabled,
cross_check_enabled: self.cross_check_enabled,
qa_automation_enabled: self.qa_automation_enabled,
observer_enabled: self.observer_enabled,
continue_mode: self.continue_mode,
});
@@ -81,7 +86,7 @@ impl AutoDriveSettingsView {
}
fn toggle_selected(&mut self) {
if self.selected_index <= 3 {
if self.selected_index <= 4 {
let slot = self.selected_mut();
*slot = !*slot;
self.send_update();
@@ -121,8 +126,12 @@ impl AutoDriveSettingsView {
0 => ("Automatic review", self.review_enabled),
1 => ("Agents enabled", self.agents_enabled),
2 => ("Cross-check QA", self.cross_check_enabled),
3 => ("Observer watchdog", self.observer_enabled),
4 => (
3 => (
"QA automation (observer + cross-check + review)",
self.qa_automation_enabled,
),
4 => ("Observer watchdog", self.observer_enabled),
5 => (
"Auto-continue delay",
matches!(self.continue_mode, AutoContinueMode::Manual),
),
@@ -139,14 +148,14 @@ impl AutoDriveSettingsView {
let mut spans = vec![Span::styled(prefix, label_style)];
match index {
0 | 1 | 2 | 3 => {
0 | 1 | 2 | 3 | 4 => {
let checkbox = if enabled { "[x]" } else { "[ ]" };
spans.push(Span::styled(
format!("{checkbox} {label}"),
label_style,
));
}
4 => {
5 => {
spans.push(Span::styled(label.to_string(), label_style));
spans.push(Span::raw(" "));
spans.push(Span::styled(
@@ -169,6 +178,7 @@ impl AutoDriveSettingsView {
lines.push(self.option_label(2));
lines.push(self.option_label(3));
lines.push(self.option_label(4));
lines.push(self.option_label(5));
lines.push(Line::default());
let footer_style = Style::default().fg(colors::text_dim());
@@ -220,13 +230,13 @@ impl AutoDriveSettingsView {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
KeyCode::Left => {
if self.selected_index == 4 {
if self.selected_index == 5 {
self.cycle_continue_mode(false);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
KeyCode::Right => {
if self.selected_index == 4 {
if self.selected_index == 5 {
self.cycle_continue_mode(true);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
@@ -276,13 +286,13 @@ impl<'a> BottomPaneView<'a> for AutoDriveSettingsView {
pane.request_redraw();
}
KeyCode::Left => {
if self.selected_index == 4 {
if self.selected_index == 5 {
self.cycle_continue_mode(false);
pane.request_redraw();
}
}
KeyCode::Right => {
if self.selected_index == 4 {
if self.selected_index == 5 {
self.cycle_continue_mode(true);
pane.request_redraw();
}
+152 -51
View File
@@ -57,6 +57,7 @@ use code_protocol::num_format::format_with_separators;
pub(crate) mod auto_coordinator;
mod auto_drive_history;
mod auto_observer;
mod qa_orchestrator;
#[cfg(feature = "dev-faults")]
mod faults;
mod retry;
@@ -101,6 +102,7 @@ use self::auto_coordinator::{
TurnDescriptor,
CROSS_CHECK_RESTART_BANNER,
};
use self::qa_orchestrator::start_qa_orchestrator;
use self::limits_overlay::{LimitsOverlayContent, LimitsTab};
use crate::chrome_launch::ChromeLaunchOption;
use self::rate_limit_refresh::start_rate_limit_refresh;
@@ -628,6 +630,7 @@ struct AutoCoordinatorUiState {
review_enabled: bool,
subagents_enabled: bool,
cross_check_enabled: bool,
qa_automation_enabled: bool,
observer_enabled: bool,
pending_agent_actions: Vec<AutoTurnAgentsAction>,
pending_agent_timing: Option<AutoTurnAgentsTiming>,
@@ -731,6 +734,7 @@ impl Default for AutoCoordinatorUiState {
review_enabled: false,
subagents_enabled: false,
cross_check_enabled: true,
qa_automation_enabled: true,
observer_enabled: true,
pending_agent_actions: Vec::new(),
pending_agent_timing: None,
@@ -1014,6 +1018,7 @@ pub(crate) struct ChatWidget<'a> {
auto_drive_variant: AutoDriveVariant,
auto_state: AutoCoordinatorUiState,
auto_handle: Option<AutoCoordinatorHandle>,
qa_handle: Option<qa_orchestrator::QaOrchestratorHandle>,
auto_history: AutoDriveHistory,
auto_turn_review_state: Option<AutoTurnReviewState>,
cloud_tasks_selected_env: Option<CloudEnvironment>,
@@ -4008,6 +4013,7 @@ impl ChatWidget<'_> {
auto_state: AutoCoordinatorUiState::default(),
auto_handle: None,
auto_history: AutoDriveHistory::new(),
qa_handle: None,
auto_turn_review_state: None,
cloud_tasks_selected_env: None,
cloud_tasks_environments: Vec::new(),
@@ -4063,6 +4069,7 @@ impl ChatWidget<'_> {
w.auto_state.review_enabled = auto_defaults.review_enabled;
w.auto_state.subagents_enabled = auto_defaults.agents_enabled;
w.auto_state.cross_check_enabled = auto_defaults.cross_check_enabled;
w.auto_state.qa_automation_enabled = auto_defaults.qa_automation_enabled;
w.auto_state.observer_enabled = auto_defaults.observer_enabled;
w.auto_state.continue_mode = auto_continue_from_config(auto_defaults.continue_mode);
w.auto_state.reset_countdown();
@@ -4318,6 +4325,7 @@ impl ChatWidget<'_> {
auto_state: AutoCoordinatorUiState::default(),
auto_handle: None,
auto_history: AutoDriveHistory::new(),
qa_handle: None,
auto_turn_review_state: None,
cloud_tasks_selected_env: None,
cloud_tasks_environments: Vec::new(),
@@ -9359,19 +9367,6 @@ impl ChatWidget<'_> {
if self.auto_resolve_enabled() {
self.auto_resolve_on_task_complete(last_agent_message.clone());
}
// Auto-review: if the coordinator marked this as a write turn, kick off a review pass
if let Some(cfg) = self.pending_auto_turn_config.clone() {
// `waiting_for_review` may already be true here to pause Auto Drive until
// the review pipeline kicks in, so always attempt to launch the post-turn
// review when the coordinator provides a write turn.
if self.auto_state.active
&& self.auto_state.review_enabled
&& !self.is_review_flow_active()
{
let descriptor = self.pending_turn_descriptor.clone();
self.auto_handle_post_turn_review(cfg, descriptor.as_ref());
}
}
// Defensive: mark any lingering agent state as complete so the spinner can quiesce
self.finalize_agent_activity();
// Convert any lingering running exec/tool cells to completed so the UI doesn't hang
@@ -11730,6 +11725,7 @@ impl ChatWidget<'_> {
let review = self.auto_state.review_enabled;
let agents = self.auto_state.subagents_enabled;
let cross = self.auto_state.cross_check_enabled;
let qa = self.auto_state.qa_automation_enabled;
let observer = self.auto_state.observer_enabled;
let mode = self.auto_state.continue_mode;
let view = AutoDriveSettingsView::new(
@@ -11737,6 +11733,7 @@ impl ChatWidget<'_> {
review,
agents,
cross,
qa,
observer,
mode,
);
@@ -12691,11 +12688,13 @@ fi\n\
review_enabled: bool,
subagents_enabled: bool,
cross_check_enabled: bool,
qa_automation_enabled: bool,
observer_enabled: bool,
continue_mode: AutoContinueMode,
) {
let conversation = self.rebuild_auto_history();
self.config.tui.auto_drive.cross_check_enabled = cross_check_enabled;
self.config.tui.auto_drive.qa_automation_enabled = qa_automation_enabled;
self.config.tui.auto_drive.observer_enabled = observer_enabled;
match start_auto_coordinator(
self.app_event_tx.clone(),
@@ -12706,11 +12705,20 @@ fi\n\
self.config.auto_drive_observer_cadence,
) {
Ok(handle) => {
if let Some(handle) = self.qa_handle.take() {
handle.stop();
}
let should_spawn_qa =
qa_automation_enabled && (review_enabled || cross_check_enabled || observer_enabled);
if should_spawn_qa {
self.qa_handle = Some(start_qa_orchestrator(self.app_event_tx.clone()));
}
self.auto_handle = Some(handle);
self.auto_state.reset();
self.auto_state.review_enabled = review_enabled;
self.auto_state.subagents_enabled = subagents_enabled;
self.auto_state.cross_check_enabled = cross_check_enabled;
self.auto_state.qa_automation_enabled = qa_automation_enabled;
self.auto_state.observer_enabled = observer_enabled;
self.auto_state.continue_mode = continue_mode;
self.auto_state.reset_countdown();
@@ -12754,6 +12762,7 @@ fi\n\
self.auto_state.review_enabled = review_enabled;
self.auto_state.subagents_enabled = subagents_enabled;
self.auto_state.cross_check_enabled = cross_check_enabled;
self.auto_state.qa_automation_enabled = qa_automation_enabled;
self.auto_state.observer_enabled = observer_enabled;
self.auto_state.continue_mode = continue_mode;
self.auto_state.reset_countdown();
@@ -12763,6 +12772,13 @@ fi\n\
}
}
fn auto_should_run_qa_orchestrator(&self) -> bool {
self.auto_state.qa_automation_enabled
&& (self.auto_state.review_enabled
|| self.auto_state.cross_check_enabled
|| self.auto_state.observer_enabled)
}
pub(crate) fn handle_auto_command(&mut self, goal: Option<String>) {
let provided = goal.unwrap_or_default();
let trimmed = provided.trim();
@@ -12797,6 +12813,7 @@ fi\n\
self.auto_state.review_enabled = defaults.review_enabled;
self.auto_state.subagents_enabled = defaults.agents_enabled;
self.auto_state.cross_check_enabled = defaults.cross_check_enabled;
self.auto_state.qa_automation_enabled = defaults.qa_automation_enabled;
self.auto_state.continue_mode = auto_continue_from_config(defaults.continue_mode);
self.auto_state.reset_countdown();
self.auto_state.mark_intro_pending();
@@ -12821,6 +12838,7 @@ fi\n\
defaults.review_enabled,
defaults.agents_enabled,
defaults.cross_check_enabled,
defaults.qa_automation_enabled,
defaults.observer_enabled,
default_mode,
);
@@ -12857,6 +12875,7 @@ fi\n\
review_enabled: bool,
agents_enabled: bool,
cross_check_enabled: bool,
qa_automation_enabled: bool,
observer_enabled: bool,
continue_mode: AutoContinueMode,
) {
@@ -12873,6 +12892,10 @@ fi\n\
self.auto_state.cross_check_enabled = cross_check_enabled;
changed = true;
}
if self.auto_state.qa_automation_enabled != qa_automation_enabled {
self.auto_state.qa_automation_enabled = qa_automation_enabled;
changed = true;
}
if self.auto_state.observer_enabled != observer_enabled {
self.auto_state.observer_enabled = observer_enabled;
changed = true;
@@ -12896,6 +12919,7 @@ fi\n\
self.config.tui.auto_drive.review_enabled = review_enabled;
self.config.tui.auto_drive.agents_enabled = agents_enabled;
self.config.tui.auto_drive.cross_check_enabled = cross_check_enabled;
self.config.tui.auto_drive.qa_automation_enabled = qa_automation_enabled;
self.config.tui.auto_drive.observer_enabled = observer_enabled;
self.config.tui.auto_drive.continue_mode = auto_continue_to_config(continue_mode);
@@ -12909,6 +12933,25 @@ fi\n\
tracing::warn!("Could not locate config home to persist Auto Drive settings");
}
let should_run_qa = self.auto_should_run_qa_orchestrator();
if self.auto_state.active {
match (self.qa_handle.is_some(), should_run_qa) {
(false, true) => {
self.qa_handle = Some(start_qa_orchestrator(self.app_event_tx.clone()));
}
(true, false) => {
if let Some(handle) = self.qa_handle.take() {
handle.stop();
}
}
_ => {}
}
} else if !should_run_qa {
if let Some(handle) = self.qa_handle.take() {
handle.stop();
}
}
self.refresh_settings_overview_rows();
self.refresh_auto_drive_visuals();
self.request_redraw();
@@ -13071,8 +13114,6 @@ fi\n\
cli: Option<AutoTurnCliAction>,
agents_timing: Option<AutoTurnAgentsTiming>,
agents: Vec<AutoTurnAgentsAction>,
code_review: Option<AutoTurnCodeReviewAction>,
cross_check: Option<AutoTurnCrossCheckAction>,
transcript: Vec<code_protocol::models::ResponseItem>,
) {
if !self.auto_state.active {
@@ -13116,28 +13157,34 @@ fi\n\
self.pending_auto_turn_config = None;
let mut promoted_agents: Vec<String> = Vec::new();
if matches!(status, AutoCoordinatorStatus::Continue) {
let continue_status = matches!(status, AutoCoordinatorStatus::Continue);
let resolved_agents: Vec<AutoTurnAgentsAction> = agents
.into_iter()
.map(|mut action| {
let original = action.write;
let requested = action.write_requested;
let resolved = self.resolve_agent_write_flag(requested);
if resolved && !original {
promoted_agents.push(action.prompt.clone());
}
action.write = resolved;
action
})
.collect();
let resolved_agents: Vec<AutoTurnAgentsAction> = agents
.into_iter()
.map(|mut action| {
let original = action.write;
let requested = action.write_requested;
let resolved = self.resolve_agent_write_flag(requested);
if resolved && !original {
promoted_agents.push(action.prompt.clone());
}
action.write = resolved;
action
})
.collect();
if continue_status {
self.auto_state.pending_agent_actions = resolved_agents;
self.auto_state.pending_agent_timing = agents_timing
.filter(|_| !self.auto_state.pending_agent_actions.is_empty());
} else {
self.auto_state.pending_agent_actions.clear();
self.auto_state.pending_agent_timing = None;
let has_diff = self.auto_turn_has_diff();
if let Some(handle) = self.qa_handle.as_ref() {
handle.notify_turn_finished(has_diff);
}
}
if !promoted_agents.is_empty() {
@@ -13158,14 +13205,6 @@ fi\n\
));
}
if let Some(review_action) = code_review {
self.auto_trigger_review_action(review_action);
}
if let Some(cross_check_action) = cross_check {
self.auto_handle_cross_check_request(cross_check_action);
}
if !matches!(status, AutoCoordinatorStatus::Failed) {
self.auto_state.transient_restart_attempts = 0;
self.auto_state.waiting_for_transient_recovery = false;
@@ -13520,6 +13559,7 @@ fi\n\
let restart_attempts = self.auto_state.transient_restart_attempts;
let review_enabled = self.auto_state.review_enabled;
let agents_enabled = self.auto_state.subagents_enabled;
let qa_automation_enabled = self.auto_state.qa_automation_enabled;
self.auto_state.pending_restart = None;
self.auto_state.waiting_for_transient_recovery = false;
@@ -13541,6 +13581,7 @@ fi\n\
review_enabled,
agents_enabled,
cross_check_enabled,
qa_automation_enabled,
observer_enabled,
continue_mode,
);
@@ -13927,6 +13968,74 @@ fi\n\
})
}
fn auto_turn_has_diff(&self) -> bool {
if self.worktree_has_uncommitted_changes().unwrap_or(false) {
return true;
}
if let Some(base_commit) = self
.auto_turn_review_state
.as_ref()
.and_then(|state| state.base_commit.as_ref())
{
if let Some(head) = self.current_head_commit_sha() {
if let Ok(paths) = self.git_diff_name_only_between(base_commit.id(), &head) {
if !paths.is_empty() {
return true;
}
}
}
}
false
}
pub(crate) fn handle_auto_qa_update(&mut self, note: String) {
let trimmed = note.trim();
if trimmed.is_empty() {
return;
}
let banner = format!("Auto QA update: {trimmed}");
self.auto_queue_observer_banner(banner);
self.auto_flush_observer_banners();
self.bottom_pane.update_status_text(trimmed.to_string());
self.request_redraw();
}
pub(crate) fn handle_auto_review_request(&mut self, summary: Option<String>) {
let summary_text = summary
.as_ref()
.map(|text| text.trim())
.filter(|text| !text.is_empty())
.map(|text| text.to_string());
match summary_text.as_ref() {
Some(text) => self.push_background_tail(format!("QA review requested: {text}")),
None => self.push_background_tail("QA review requested.".to_string()),
}
if !self.auto_state.review_enabled {
self.push_background_tail(
"QA review skipped: Auto Drive reviews are disabled in settings.".to_string(),
);
self.request_redraw();
return;
}
self.prepare_auto_turn_review_state();
let cfg = self
.pending_auto_turn_config
.clone()
.unwrap_or_else(|| TurnConfig {
read_only: false,
complexity: None,
});
let descriptor = self.pending_turn_descriptor.clone();
self.auto_handle_post_turn_review(cfg, descriptor.as_ref());
}
fn prepare_auto_turn_review_state(&mut self) {
if !self.auto_state.active || !self.auto_state.review_enabled {
self.auto_turn_review_state = None;
@@ -14185,10 +14294,15 @@ fi\n\
.unwrap_or_default();
let turns_completed = self.auto_state.turns_completed;
let goal = self.auto_state.goal.clone();
let has_diff = self.auto_turn_has_diff();
if let Some(handle) = self.auto_handle.take() {
handle.cancel();
let _ = handle.send(AutoCoordinatorCommand::Stop);
}
if let Some(handle) = self.qa_handle.take() {
handle.notify_finalize(has_diff);
handle.stop();
}
self.bottom_pane.clear_auto_coordinator_view(true);
if let Some(msg) = message.clone() {
self.push_background_tail(msg);
@@ -22272,14 +22386,6 @@ mod tests {
write_requested: Some(false),
models: None,
}],
Some(AutoTurnCodeReviewAction {
commit: AutoReviewCommit {
source: AutoReviewCommitSource::Staged,
sha: None,
summary: Some("Pre-merge checklist".to_string()),
},
}),
None,
Vec::new(),
);
@@ -22287,11 +22393,7 @@ mod tests {
chat.auto_state.current_cli_prompt.as_deref(),
Some("Run cargo test")
);
if chat.auto_state.waiting_for_review {
// Review remains pending; nothing else to assert.
} else {
assert!(chat.auto_state.current_cli_prompt.is_some());
}
assert!(!chat.auto_state.waiting_for_review);
assert_eq!(chat.auto_state.pending_agent_actions.len(), 1);
assert_eq!(
chat.auto_state.pending_agent_timing,
@@ -22375,7 +22477,6 @@ mod tests {
None,
None,
Vec::new(),
None,
Vec::new(),
);
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -10,17 +10,25 @@ use serde::Deserialize;
use serde_json::{self, json, Value};
use tracing::{debug, error, warn};
use crate::app_event::{AutoObserverStatus, AutoObserverTelemetry};
use crate::app_event::AutoObserverTelemetry;
use crate::chatwidget::AutoObserverStatus;
use crate::thread_spawner;
use super::auto_coordinator::{
extract_first_json_object,
make_message,
AutoCoordinatorCommand,
CrossCheckTurnSnapshot,
ObserverMode,
MODEL_SLUG,
};
#[derive(Debug, Clone)]
pub(super) struct CrossCheckTurnSnapshot {
pub cli_prompt: Option<String>,
pub cli_context: Option<String>,
pub progress_summary: Option<String>,
}
#[derive(Debug)]
pub(super) struct AutoObserverHandle {
pub tx: Sender<AutoObserverCommand>,
@@ -36,6 +44,11 @@ impl AutoObserverHandle {
#[derive(Debug)]
pub(super) enum AutoObserverCommand {
Trigger(ObserverTrigger),
BeginCrossCheck {
conversation: Vec<ResponseItem>,
from_index: usize,
forced: bool,
},
Stop,
}
@@ -70,6 +83,7 @@ pub(super) struct ObserverEvaluation {
#[derive(Debug, Clone)]
pub(super) struct ObserverOutcome {
pub mode: ObserverMode,
pub status: AutoObserverStatus,
pub replace_message: Option<String>,
pub additional_instructions: Option<String>,
@@ -153,6 +167,7 @@ fn run_observer_loop(
);
let outcome = ObserverOutcome {
mode: ObserverMode::Cadence,
status,
replace_message,
additional_instructions,
@@ -176,6 +191,7 @@ fn run_observer_loop(
telemetry.last_status = AutoObserverStatus::Ok;
telemetry.last_intervention = Some(format!("error: {err}"));
let outcome = ObserverOutcome {
mode: ObserverMode::Cadence,
status: AutoObserverStatus::Ok,
replace_message: None,
additional_instructions: None,
@@ -0,0 +1,176 @@
use std::num::NonZeroUsize;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
/// Internal commands understood by the QA orchestrator loop.
enum QaMsg {
Shutdown,
#[allow(dead_code)]
Tick,
TurnFinished { has_diff: bool },
Finalize { has_diff: bool },
}
/// Handle giving the caller control over the QA orchestrator thread.
pub struct QaOrchestratorHandle {
tx: Sender<QaMsg>,
join: Option<JoinHandle<()>>,
}
impl QaOrchestratorHandle {
/// Request shutdown and wait for the orchestrator thread to exit.
pub fn stop(mut self) {
let _ = self.tx.send(QaMsg::Shutdown);
if let Some(join) = self.join.take() {
let _ = join.join();
}
}
/// Notify the orchestrator that an Auto Drive turn finished.
pub fn notify_turn_finished(&self, has_diff: bool) {
let _ = self.tx.send(QaMsg::TurnFinished { has_diff });
}
/// Notify the orchestrator that Auto Drive is about to stop so it can run
/// a final QA cadence check before shutting down.
pub fn notify_finalize(&self, has_diff: bool) {
let _ = self.tx.send(QaMsg::Finalize { has_diff });
}
}
/// Spawn a placeholder QA orchestrator thread. For now it only idles until
/// told to shut down; future iterations will drive cross-check + review logic.
pub fn start_qa_orchestrator(app_tx: AppEventSender) -> QaOrchestratorHandle {
let (tx, rx) = mpsc::channel();
let join = thread::Builder::new()
.name("qa-orchestrator".into())
.spawn(move || orchestrator_loop(app_tx, rx))
.expect("failed to spawn qa orchestrator thread");
QaOrchestratorHandle {
tx,
join: Some(join),
}
}
const ENV_QA_CADENCE: &str = "CODE_QA_CADENCE";
const ENV_QA_REVIEW_COOLDOWN_TURNS: &str = "CODE_QA_REVIEW_COOLDOWN_TURNS";
#[derive(Debug)]
struct QaCadenceState {
cadence: NonZeroUsize,
completed: usize,
review_cooldown: NonZeroUsize,
since_last_review: usize,
}
impl QaCadenceState {
fn from_env() -> Self {
let cadence = std::env::var(ENV_QA_CADENCE)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.and_then(NonZeroUsize::new)
.unwrap_or_else(|| NonZeroUsize::new(3).expect("3 is non-zero"));
let review_cooldown = std::env::var(ENV_QA_REVIEW_COOLDOWN_TURNS)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.and_then(NonZeroUsize::new)
.unwrap_or_else(|| NonZeroUsize::new(1).expect("1 is non-zero"));
Self {
cadence,
completed: 0,
review_cooldown,
since_last_review: review_cooldown.get(),
}
}
fn record_turn(&mut self) -> bool {
self.completed += 1;
if self.completed >= self.cadence.get() {
self.completed = 0;
true
} else {
false
}
}
fn reset(&mut self) {
self.completed = 0;
self.since_last_review = self.review_cooldown.get();
}
fn record_review_turn(&mut self, has_diff: bool) -> Option<usize> {
self.since_last_review = (self.since_last_review + 1)
.min(self.review_cooldown.get());
if has_diff && self.since_last_review >= self.review_cooldown.get() {
let waited = self.since_last_review;
self.since_last_review = 0;
Some(waited)
} else {
None
}
}
}
fn orchestrator_loop(app_tx: AppEventSender, rx: Receiver<QaMsg>) {
let mut cadence = QaCadenceState::from_env();
// Wait for commands; once we get Shutdown we return and end the thread.
while let Ok(msg) = rx.recv_timeout(Duration::from_secs(60)) {
match msg {
QaMsg::Shutdown => {
cadence.reset();
break;
}
QaMsg::Tick => {
// Placeholder: emit AppEvent::AutoQaUpdate or AutoReviewRequest.
}
QaMsg::TurnFinished { has_diff } => {
if cadence.record_turn() {
let diff_note = if has_diff { "changes detected" } else { "no changes" };
let note = format!(
"Auto QA cadence checkpoint ({} turns, {diff_note}).",
cadence.cadence.get()
);
let _ = app_tx.send(AppEvent::AutoQaUpdate { note });
}
if let Some(turns_waited) = cadence.record_review_turn(has_diff) {
let summary = Some(format!(
"Automated QA review requested after {turns_waited} turn(s) with workspace changes."
));
let _ = app_tx.send(AppEvent::AutoReviewRequest { summary });
}
}
QaMsg::Finalize { has_diff } => {
let note = format!(
"Final QA check ({}-turn cooldown)",
cadence.review_cooldown.get()
);
let _ = app_tx.send(AppEvent::AutoQaUpdate { note });
if has_diff && cadence.since_last_review > 0 {
let summary = Some("Final safety review before stop.".to_string());
let _ = app_tx.send(AppEvent::AutoReviewRequest { summary });
}
cadence.reset();
}
}
}
}
impl Drop for QaOrchestratorHandle {
fn drop(&mut self) {
if let Some(join) = self.join.take() {
let _ = self.tx.send(QaMsg::Shutdown);
let _ = join.join();
}
}
}
+30
View File
@@ -0,0 +1,30 @@
# Auto QA Orchestration
## Overview
- Auto Drive no longer hands QA control to the coordinator.
- A dedicated QA orchestrator thread watches turn completions and emits QA events.
## Coordinator Schema Changes
- `code_review` and `cross_check` fields were removed from `CoordinatorTurnNew` and are rejected if present.
- Coordinator decisions now focus on CLI + agent planning only.
## QA Orchestrator Responsibilities
- Emit `AppEvent::AutoQaUpdate { note }` every cadence window (default 3 turns).
- Emit `AppEvent::AutoReviewRequest { summary }` when a diff-bearing turn satisfies the review cooldown. The chat widget no longer launches review on every write turn; these requests are the sole entry point for automated code review.
- Cadence and cooldown state resets when the orchestrator shuts down.
## Coordinator Finalization
- When the coordinator produces `finish_success`, Auto Drive triggers a forced cross-check run before completing.
- A successful cross-check forwards the stored success decision to the UI; any failing cross-check converts the result into a coordinator failure and surfaces the restart banner so automation can recover instead of exiting early.
## Start / Stop Conditions
- ChatWidget starts the orchestrator when Auto Drive launches and at least one QA feature (review, cross-check, observer) is enabled.
- The orchestrator shuts down whenever Auto Drive stops.
- Review requests still respect `review_enabled`; handlers reuse existing post-turn review helpers.
## Environment Knobs
- `CODE_QA_CADENCE` &mdash; turn cadence between `AutoQaUpdate` notes (default `3`).
- `CODE_QA_REVIEW_COOLDOWN_TURNS` &mdash; turns with diffs before an automated `AutoReviewRequest` is sent (default `1`).
## Future Work
- Consolidate QA flags behind a single `qa_automation_enabled` toggle in `AutoDriveSettings`.