Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/ai/agent_management/agent_management_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ impl AgentNotificationsModel {
ctx,
);
}
CLIAgentSessionStatus::Blocked { message } => {
CLIAgentSessionStatus::Blocked { message, .. } => {
let title = session_context
.display_title()
.unwrap_or_else(|| format!("{} needs attention", agent.display_name()));
Expand Down
32 changes: 29 additions & 3 deletions app/src/ai/agent_sdk/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3237,10 +3237,13 @@ impl AgentDriver {
.context("Failed to enqueue periodic harness conversation save"));
}
_ = harness_exit_rx => {
let start_event = ExitEscalationEvent::ShutdownRequested {
awaiting_input: Self::session_blocked_on_needs_input(foreground).await,
};
break Self::escalate_harness_exit(
runner.as_ref(),
&harness_name,
ExitEscalationEvent::ShutdownRequested,
start_event,
&mut command_handle,
foreground,
)
Expand Down Expand Up @@ -3396,8 +3399,10 @@ impl AgentDriver {
let mut escalation = ExitEscalation::new();
match escalation.on_event(start_event) {
ExitEscalationAction::SendExit => {}
ExitEscalationAction::ForceKillAndFinish => {
return Self::force_kill_and_report_timeout(harness_name, 1, foreground).await;
Comment thread
sohumgaitonde marked this conversation as resolved.
}
ExitEscalationAction::SendFollowup
| ExitEscalationAction::ForceKillAndFinish
| ExitEscalationAction::Finish
| ExitEscalationAction::Ignore => {
log::error!(
Expand Down Expand Up @@ -3471,9 +3476,17 @@ impl AgentDriver {
}
}

Self::force_kill_and_report_timeout(harness_name, 3, foreground).await
}

async fn force_kill_and_report_timeout(
harness_name: &str,
attempt: u8,
foreground: &ModelSpawner<Self>,
) -> Result<warp_core::command::ExitCode, AgentDriverError> {
log::warn!(
"Ambient agent CLI lifecycle: event=harness_exit_attempt \
harness={harness_name} attempt=3 method=force_kill"
harness={harness_name} attempt={attempt} method=force_kill"
);
Self::send_harness_exit_telemetry(harness_name, "force_kill", foreground).await;
Self::force_kill_harness(foreground).await;
Expand All @@ -3482,6 +3495,19 @@ impl AgentDriver {
})
}

async fn session_blocked_on_needs_input(foreground: &ModelSpawner<Self>) -> bool {
foreground
.spawn(|me, ctx| {
let view_id = me.terminal_driver.as_ref(ctx).terminal_view().id();
CLIAgentSessionsModel::handle(ctx)
.as_ref(ctx)
.session(view_id)
.is_some_and(|session| session.is_blocked_on_needs_input())
})
.await
.unwrap_or(false)
}

/// Force-kills the harness and attempts a final conversation save after a sandbox-deadline
/// or signal interruption. Dropping `run_internal` for either cause bypasses
/// `run_harness`'s ordinary final-save path, so this recovers it best-effort.
Expand Down
27 changes: 22 additions & 5 deletions app/src/ai/agent_sdk/driver/harness/exit_escalation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Bounded shutdown ladder for a third-party harness: `/exit`, a follow-up
//! Enter, then a best-effort force-kill. Completes without waiting to prove
//! the process exited.
//! the process exited. A harness waiting on user input skips straight to the
//! force-kill, since typed text would answer whatever prompt is open.

use super::super::AgentDriverError;

Expand All @@ -18,7 +19,10 @@ pub(crate) enum ExitEscalationPhase {
pub(crate) enum ExitEscalationEvent {
CommandExited,
/// CLI session reached a terminal state and asked the driver to stop the harness.
ShutdownRequested,
ShutdownRequested {
/// The session is blocked on user input, so typed text would answer the open prompt.
awaiting_input: bool,
},
/// Runtime-failure scanner confirmed a hang/auth failure and asked the driver to stop.
ScannerDetected,
FollowupDeadlineElapsed,
Expand Down Expand Up @@ -66,11 +70,23 @@ impl ExitEscalation {
}
(
ExitEscalationPhase::Running,
ExitEscalationEvent::ShutdownRequested | ExitEscalationEvent::ScannerDetected,
ExitEscalationEvent::ShutdownRequested {
awaiting_input: false,
}
| ExitEscalationEvent::ScannerDetected,
) => {
self.phase = ExitEscalationPhase::AwaitingGracefulExit;
ExitEscalationAction::SendExit
}
(
ExitEscalationPhase::Running,
ExitEscalationEvent::ShutdownRequested {
awaiting_input: true,
},
) => {
self.phase = ExitEscalationPhase::Done;
ExitEscalationAction::ForceKillAndFinish
}
(
ExitEscalationPhase::AwaitingGracefulExit,
ExitEscalationEvent::FollowupDeadlineElapsed,
Expand All @@ -88,14 +104,15 @@ impl ExitEscalation {
(
ExitEscalationPhase::Done,
ExitEscalationEvent::CommandExited
| ExitEscalationEvent::ShutdownRequested
| ExitEscalationEvent::ShutdownRequested { .. }
| ExitEscalationEvent::ScannerDetected
| ExitEscalationEvent::FollowupDeadlineElapsed
| ExitEscalationEvent::ForceKillDeadlineElapsed,
)
| (
ExitEscalationPhase::AwaitingGracefulExit | ExitEscalationPhase::AwaitingFollowup,
ExitEscalationEvent::ShutdownRequested | ExitEscalationEvent::ScannerDetected,
ExitEscalationEvent::ShutdownRequested { .. }
| ExitEscalationEvent::ScannerDetected,
)
| (
ExitEscalationPhase::Running,
Expand Down
45 changes: 37 additions & 8 deletions app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ use super::{
};
use crate::ai::agent_sdk::driver::AgentDriverError;

const SHUTDOWN: ExitEscalationEvent = ExitEscalationEvent::ShutdownRequested {
awaiting_input: false,
};
const SHUTDOWN_AWAITING_INPUT: ExitEscalationEvent = ExitEscalationEvent::ShutdownRequested {
awaiting_input: true,
};

fn actions_for(events: &[ExitEscalationEvent]) -> (ExitEscalation, Vec<ExitEscalationAction>) {
let mut escalation = ExitEscalation::new();
let actions = events
Expand All @@ -16,10 +23,7 @@ fn actions_for(events: &[ExitEscalationEvent]) -> (ExitEscalation, Vec<ExitEscal

#[test]
fn clean_exit_after_exit_request_finishes_before_followup() {
let (escalation, actions) = actions_for(&[
ExitEscalationEvent::ShutdownRequested,
ExitEscalationEvent::CommandExited,
]);
let (escalation, actions) = actions_for(&[SHUTDOWN, ExitEscalationEvent::CommandExited]);

assert_eq!(
actions,
Expand All @@ -31,7 +35,7 @@ fn clean_exit_after_exit_request_finishes_before_followup() {
#[test]
fn followup_enter_is_sent_after_the_first_deadline() {
let (escalation, actions) = actions_for(&[
ExitEscalationEvent::ShutdownRequested,
SHUTDOWN,
ExitEscalationEvent::FollowupDeadlineElapsed,
ExitEscalationEvent::CommandExited,
]);
Expand All @@ -50,7 +54,7 @@ fn followup_enter_is_sent_after_the_first_deadline() {
#[test]
fn force_kill_completes_the_ladder_after_the_second_deadline() {
let (escalation, actions) = actions_for(&[
ExitEscalationEvent::ShutdownRequested,
SHUTDOWN,
ExitEscalationEvent::FollowupDeadlineElapsed,
ExitEscalationEvent::ForceKillDeadlineElapsed,
]);
Expand All @@ -74,7 +78,7 @@ fn scanner_detection_starts_the_same_ladder_as_an_exit_request() {
ExitEscalationEvent::ForceKillDeadlineElapsed,
]);
let (from_shutdown, shutdown_actions) = actions_for(&[
ExitEscalationEvent::ShutdownRequested,
SHUTDOWN,
ExitEscalationEvent::FollowupDeadlineElapsed,
ExitEscalationEvent::ForceKillDeadlineElapsed,
]);
Expand All @@ -87,7 +91,7 @@ fn scanner_detection_starts_the_same_ladder_as_an_exit_request() {
fn scanner_detection_during_an_in_flight_ladder_does_not_restart_it() {
let mut escalation = ExitEscalation::new();
assert_eq!(
escalation.on_event(ExitEscalationEvent::ShutdownRequested),
escalation.on_event(SHUTDOWN),
ExitEscalationAction::SendExit
);
assert_eq!(
Expand All @@ -100,6 +104,31 @@ fn scanner_detection_during_an_in_flight_ladder_does_not_restart_it() {
);
}

#[test]
fn shutdown_while_awaiting_input_force_kills_without_sending_any_input() {
let (escalation, actions) = actions_for(&[SHUTDOWN_AWAITING_INPUT]);

assert_eq!(actions, vec![ExitEscalationAction::ForceKillAndFinish]);
assert_eq!(escalation.phase(), ExitEscalationPhase::Done);
}

#[test]
fn shutdown_while_awaiting_input_does_not_restart_an_in_flight_ladder() {
let mut escalation = ExitEscalation::new();
assert_eq!(
escalation.on_event(SHUTDOWN),
ExitEscalationAction::SendExit
);
assert_eq!(
escalation.on_event(SHUTDOWN_AWAITING_INPUT),
ExitEscalationAction::Ignore
);
assert_eq!(
escalation.phase(),
ExitEscalationPhase::AwaitingGracefulExit
);
}

#[test]
fn bounded_timeout_is_not_a_driver_error_and_is_still_err_before_mapping() {
let timeout = Err(AgentDriverError::HarnessExitTimedOut {
Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/blocklist/local_agent_task_sync_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ fn map_cli_session_status(
};
(task_state, message.as_ref().map(TaskStatusUpdate::message))
}
CLIAgentSessionStatus::Blocked { message } => (
CLIAgentSessionStatus::Blocked { message, .. } => (
AgentTaskState::Blocked,
message.as_ref().map(TaskStatusUpdate::message),
),
Expand Down
11 changes: 8 additions & 3 deletions app/src/ai/blocklist/local_agent_task_sync_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ use crate::server::server_api::ai::{AIClient, MockAIClient, TaskStatusUpdate};
use crate::terminal::CLIAgent;
use crate::terminal::cli_agent_sessions::event::parse_event;
use crate::terminal::cli_agent_sessions::{
CLIAgentInputState, CLIAgentSession, CLIAgentSessionStatus, CLIAgentSessionsModel,
CLIAgentSessionsModelEvent,
BlockedSource, CLIAgentInputState, CLIAgentSession, CLIAgentSessionStatus,
CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
};

/// Helper to assert a (state, Option<TaskStatusUpdate>) tuple.
Expand Down Expand Up @@ -469,6 +469,7 @@ fn cli_success_maps_correctly() {
fn cli_blocked_maps_correctly() {
let (state, update) = map_cli_session_status(&CLIAgentSessionStatus::Blocked {
message: Some("needs approval".into()),
source: BlockedSource::PermissionRequest,
});
assert_eq!(state, AgentTaskState::Blocked);
let update = update.expect("should have status update");
Expand All @@ -477,7 +478,10 @@ fn cli_blocked_maps_correctly() {

#[test]
fn cli_blocked_without_message() {
let (state, update) = map_cli_session_status(&CLIAgentSessionStatus::Blocked { message: None });
let (state, update) = map_cli_session_status(&CLIAgentSessionStatus::Blocked {
message: None,
source: BlockedSource::PermissionRequest,
});
assert_eq!(state, AgentTaskState::Blocked);
assert!(update.is_none());
}
Expand Down Expand Up @@ -776,6 +780,7 @@ fn confirmed_terminal_state_remembers_blocked() {
terminal_view_id,
CLIAgentSessionStatus::Blocked {
message: Some("needs approval".into()),
source: BlockedSource::PermissionRequest,
},
);

Expand Down
1 change: 1 addition & 0 deletions app/src/terminal/cli_agent_sessions/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub enum CLIAgentEventType {
PermissionRequest,
PermissionReplied,
QuestionAsked,
NeedsInput,
IdlePrompt,
Unknown(String),
}
Expand Down
1 change: 1 addition & 0 deletions app/src/terminal/cli_agent_sessions/event/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
"permission_request" => CLIAgentEventType::PermissionRequest,
"permission_replied" => CLIAgentEventType::PermissionReplied,
"question_asked" => CLIAgentEventType::QuestionAsked,
"agent_needs_input" => CLIAgentEventType::NeedsInput,
"idle_prompt" => CLIAgentEventType::IdlePrompt,
other => CLIAgentEventType::Unknown(other.to_string()),
};
Expand Down
Loading
Loading