mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(auto-drive): scaffold qa orchestrator and observer plumbing
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"] =
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` — turn cadence between `AutoQaUpdate` notes (default `3`).
|
||||
- `CODE_QA_REVIEW_COOLDOWN_TURNS` — 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`.
|
||||
Reference in New Issue
Block a user