Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
4617 lines
176 KiB
Rust
4617 lines
176 KiB
Rust
//! Actor-based terminal implementation with support for foreground and background execution.
|
|
//!
|
|
//! This module implements a terminal backend using the actor pattern:
|
|
//! - `LocalTerminalBackend` is a handle that sends commands to the actor via channels
|
|
//! - `LocalTerminalActor` runs in a spawned task and owns all mutable state
|
|
//! - No mutex locks are needed - all state access is through message passing
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tokio::fs::File;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
|
use tokio::sync::{mpsc, oneshot};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::computer::local::cgroup::{
|
|
CgroupGuard, CgroupMemoryConfig, MemoryMonitor, PROCESS_OOM_EXIT_CODE,
|
|
};
|
|
use crate::computer::types::{
|
|
BackgroundHandle, ComputerError, KillOutcome, TaskSnapshot, TerminalBackend,
|
|
TerminalRunRequest, TerminalRunResult,
|
|
};
|
|
use crate::notification::types::{BashNotificationBase, BashOutputChunk, ToolNotificationHandle};
|
|
|
|
use super::SearchShadowConfig;
|
|
#[cfg(unix)]
|
|
use super::shell_state;
|
|
|
|
struct SpawnResult {
|
|
child: tokio::process::Child,
|
|
process_group: crate::util::ProcessGroup,
|
|
/// Handle for reading the state dump from fd 4 (persistent shell only).
|
|
state_dump_handle: Option<tokio::task::JoinHandle<std::io::Result<String>>>,
|
|
}
|
|
|
|
const READ_BUFFER_SIZE: usize = 8192;
|
|
const DEFAULT_NOTIFICATION_INTERVAL_MS: u64 = 100;
|
|
const COMMAND_CHANNEL_SIZE: usize = 32;
|
|
/// How long to keep completed background tasks in memory before eviction.
|
|
/// The output file on disk persists for the session lifetime.
|
|
const COMPLETED_TASK_TTL: Duration = Duration::from_secs(300);
|
|
/// SIGTERM → SIGKILL grace period.
|
|
const SIGTERM_GRACE: Duration = Duration::from_secs(1);
|
|
/// Maximum lifetime for a background task; the actor kills it once exceeded.
|
|
/// 10 hours, so long background monitor and bash runs survive.
|
|
const BACKGROUND_MAX_RUNTIME: Duration = Duration::from_secs(36_000);
|
|
/// Max time an *auto-backgroundable* foreground command blocks the turn before
|
|
/// it's moved to the background (kept running, never killed), independent of its
|
|
/// requested `timeout`. Env override: `KIGI_FOREGROUND_BLOCK_BUDGET_MS`.
|
|
const FOREGROUND_BLOCK_BUDGET: Duration = Duration::from_secs(15);
|
|
|
|
fn foreground_block_budget_from_env() -> Duration {
|
|
std::env::var("KIGI_FOREGROUND_BLOCK_BUDGET_MS")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.map(Duration::from_millis)
|
|
.unwrap_or(FOREGROUND_BLOCK_BUDGET)
|
|
}
|
|
|
|
/// Max bytes a command's output file may reach before the actor kills it — the
|
|
/// size analogue of [`BACKGROUND_MAX_RUNTIME`], stopping an unbounded writer
|
|
/// (`yes`, a runaway log) from filling the disk. Env override:
|
|
/// `KIGI_MAX_OUTPUT_FILE_BYTES`.
|
|
const MAX_OUTPUT_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
|
|
|
fn output_file_cap_from_env() -> u64 {
|
|
std::env::var("KIGI_MAX_OUTPUT_FILE_BYTES")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(MAX_OUTPUT_FILE_BYTES)
|
|
}
|
|
/// Max time to drain stdout/stderr after process exit. Prevents `cmd &`
|
|
/// (inherited pipe, no redirect) from blocking the actor loop forever.
|
|
const DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
|
/// Max bytes retained in the output file after process exit. Truncated
|
|
/// so `to_task_snapshot` / `read_file` don't materialize huge strings.
|
|
const MAX_RETAINED_OUTPUT_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
|
/// Maximum number of completed-task tombstones to keep; the oldest entries are
|
|
/// evicted past this. Each tombstone is metadata only (no output), ~10 KB total.
|
|
const MAX_COMPLETED_TASK_SNAPSHOTS: usize = 100;
|
|
|
|
fn notification_interval() -> Duration {
|
|
Duration::from_millis(DEFAULT_NOTIFICATION_INTERVAL_MS)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ExitStatus {
|
|
pub exit_code: Option<i32>,
|
|
pub signal: Option<String>,
|
|
}
|
|
|
|
enum TerminalCommand {
|
|
/// Foreground: spawn process, block until exit or timeout, reply with result.
|
|
Run {
|
|
request: TerminalRunRequest,
|
|
reply: oneshot::Sender<Result<TerminalRunResult, ComputerError>>,
|
|
},
|
|
|
|
/// Background: spawn process, register under a generated task_id,
|
|
/// reply immediately with BackgroundHandle. Process keeps running.
|
|
RunBackground {
|
|
request: TerminalRunRequest,
|
|
reply: oneshot::Sender<Result<BackgroundHandle, ComputerError>>,
|
|
},
|
|
|
|
/// Get snapshot of a background task (by task_id from RunBackground).
|
|
GetTask {
|
|
task_id: String,
|
|
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
|
},
|
|
|
|
Kill {
|
|
task_id: String,
|
|
reply: oneshot::Sender<KillOutcome>,
|
|
},
|
|
|
|
/// Kill foregrounded processes. Called on turn cancellation.
|
|
KillForegroundCommands,
|
|
|
|
/// Unblocks the completion waiter with signal="backgrounded".
|
|
BackgroundForeground {
|
|
tool_call_id: String,
|
|
reply: oneshot::Sender<bool>,
|
|
},
|
|
|
|
WaitForCompletion {
|
|
task_id: String,
|
|
timeout: Option<Duration>,
|
|
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
|
},
|
|
|
|
ListTasks {
|
|
reply: oneshot::Sender<Vec<TaskSnapshot>>,
|
|
},
|
|
|
|
GetShellCwd {
|
|
reply: oneshot::Sender<Option<PathBuf>>,
|
|
},
|
|
|
|
WarmShell {
|
|
cwd: PathBuf,
|
|
},
|
|
|
|
KillForegroundCommandsByOwner {
|
|
owner_session_id: String,
|
|
},
|
|
|
|
/// Kills only the session's *background* tasks.
|
|
KillTasksByOwner {
|
|
owner_session_id: String,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
|
|
/// Reparent notification handles for all tasks owned by a session.
|
|
/// Swaps the old notification handle with a new one so events from
|
|
/// surviving processes route to the parent session. Also re-spawns
|
|
/// monitor pipelines so monitor events continue streaming.
|
|
ReparentNotifications {
|
|
old_owner_session_id: String,
|
|
new_owner_session_id: String,
|
|
new_handle: crate::notification::types::ToolNotificationHandle,
|
|
/// Weak (not strong) backend handle for re-spawning monitor pipelines,
|
|
/// so a reparented monitor doesn't pin the backend. See `run_monitor_pipeline`.
|
|
backend_weak: std::sync::Weak<dyn crate::computer::types::TerminalBackend>,
|
|
reply: oneshot::Sender<()>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum BackgroundStatus {
|
|
Foreground { auto_bg_on_timeout: bool },
|
|
Backgrounded { reason: BackgroundReason },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum BackgroundReason {
|
|
/// Model requested `is_background=true`.
|
|
Explicit,
|
|
/// User pressed Ctrl+G.
|
|
UserSignal,
|
|
/// Foreground command exceeded default timeout.
|
|
ForegroundTimeout,
|
|
}
|
|
|
|
impl BackgroundStatus {
|
|
fn is_backgrounded(self) -> bool {
|
|
matches!(self, Self::Backgrounded { .. })
|
|
}
|
|
}
|
|
|
|
impl BackgroundReason {
|
|
fn as_signal(&self) -> &'static str {
|
|
match self {
|
|
Self::Explicit | Self::UserSignal => "backgrounded",
|
|
Self::ForegroundTimeout => "auto_backgrounded",
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProcessState {
|
|
child: tokio::process::Child,
|
|
/// Process-tree teardown handle, shared (`Arc`) with the process-global
|
|
/// `ProcessScope` so the TUI exit paths can reap it if this actor never runs
|
|
/// its own teardown. On Unix this stores the leader pid for `killpg`; on
|
|
/// Windows it owns a Job Object that terminates every descendant when killed
|
|
/// or dropped.
|
|
///
|
|
/// On Unix this auto-drops to `None` the tick the child is reaped (`child.id()`
|
|
/// is `None`) — the poll sweep (and the explicit-kill path) release the `Arc`
|
|
/// so the scope's `Weak` dies at reap. A completed task lingers here for
|
|
/// `completed_task_ttl`; holding the `Arc` that long would let `kill_all`
|
|
/// `killpg` a pid the OS may have recycled. On Windows it stays `Some` until
|
|
/// the `ProcessState` is removed (the JobObject HANDLE has no recyclable pid,
|
|
/// so dropping early at reap is unnecessary).
|
|
process_group: Option<std::sync::Arc<crate::util::ProcessGroup>>,
|
|
/// Accumulated output buffer — tail portion (may be truncated)
|
|
output_buffer: Vec<u8>,
|
|
/// Front portion of output, captured before truncation kicks in.
|
|
/// Once the total char count exceeds the limit, the first half of the
|
|
/// budget is frozen here and only the tail is kept in `output_buffer`.
|
|
front_buffer: Option<Vec<u8>>,
|
|
truncated: bool,
|
|
/// Total bytes written to file, before any truncation.
|
|
total_bytes: usize,
|
|
exit_status: Option<ExitStatus>,
|
|
bg_status: BackgroundStatus,
|
|
/// Foreground only.
|
|
completion_waiters: Vec<oneshot::Sender<Result<TerminalRunResult, ComputerError>>>,
|
|
output_byte_limit: usize,
|
|
timeout: Duration,
|
|
/// When auto_bg_on_timeout: max FG block before auto-bg (per-request or backend default).
|
|
foreground_block_budget: Duration,
|
|
start_time: Instant,
|
|
/// Every byte of output lands here, even for foreground commands.
|
|
output_file: PathBuf,
|
|
file_handle: Option<File>,
|
|
/// The command as executed (may be isolation-wrapped).
|
|
command: String,
|
|
/// Original user command before isolation wrapping, for display.
|
|
display_command: Option<String>,
|
|
cwd: String,
|
|
/// Wall-clock counterpart to `start_time`, for `TaskSnapshot`.
|
|
start_wall_time: std::time::SystemTime,
|
|
/// Drives TTL-based eviction of background tasks.
|
|
completed_at: Option<Instant>,
|
|
/// Wall-clock counterpart to `completed_at`, for `TaskSnapshot` durations.
|
|
end_wall_time: Option<std::time::SystemTime>,
|
|
|
|
notification_handle: ToolNotificationHandle,
|
|
tool_call_id: String,
|
|
kind: crate::computer::types::TaskKind,
|
|
/// Monotonic `total_bytes` at the time of the last chunk notification.
|
|
/// Used to detect "new output since last tick" — only send a chunk
|
|
/// when total_bytes > last_notified_total. Keyed off the monotonic
|
|
/// byte counter rather than `output_buffer.len()` because the buffer
|
|
/// is a truncated tail that *shrinks* once `maybe_truncate` fires; a
|
|
/// length-based gate would go (and stay) false after truncation.
|
|
last_notified_total: usize,
|
|
/// Whether stdout/stderr have already been drained after exit.
|
|
/// Prevents repeated 2s drain timeouts on every poll tick when
|
|
/// orphaned children hold pipes open.
|
|
drained: bool,
|
|
/// Set when a `block=true` waiter consumed this task's result.
|
|
block_waited: bool,
|
|
/// Set when the model explicitly killed this task via the kill tool,
|
|
/// or during `kill_all_background_tasks` teardown.
|
|
explicitly_killed: bool,
|
|
|
|
/// Join handle for reading the state dump from fd 4 (persistent shell only).
|
|
/// When present, the actor collects the dump on process exit and updates
|
|
/// the canonical `ShellState`.
|
|
state_dump_handle: Option<tokio::task::JoinHandle<std::io::Result<String>>>,
|
|
|
|
/// Session that owns this process. Used to scope kill operations so
|
|
/// subagent teardown only kills the subagent's own tasks.
|
|
owner_session_id: Option<String>,
|
|
}
|
|
|
|
impl ProcessState {
|
|
fn to_result(&self) -> TerminalRunResult {
|
|
let combined_output = if let Some(ref front) = self.front_buffer {
|
|
let front_str = String::from_utf8_lossy(front);
|
|
let back_str = String::from_utf8_lossy(&self.output_buffer);
|
|
format!(
|
|
"{}\n\n... (output truncated) ...\n\n{}",
|
|
front_str.trim_end(),
|
|
back_str.trim_start()
|
|
)
|
|
} else {
|
|
String::from_utf8_lossy(&self.output_buffer).into_owned()
|
|
};
|
|
TerminalRunResult {
|
|
combined_output,
|
|
exit_code: self.exit_status.as_ref().and_then(|s| s.exit_code),
|
|
truncated: self.truncated,
|
|
signal: match self.bg_status {
|
|
BackgroundStatus::Backgrounded { reason } => Some(reason.as_signal().to_string()),
|
|
_ => self.exit_status.as_ref().and_then(|s| s.signal.clone()),
|
|
},
|
|
timed_out: self
|
|
.exit_status
|
|
.as_ref()
|
|
.map(|s| s.signal.as_deref() == Some("timeout"))
|
|
.unwrap_or(false),
|
|
output_file: self.output_file.clone(),
|
|
total_bytes: self.total_bytes,
|
|
pid: self.child.id(),
|
|
}
|
|
}
|
|
|
|
fn notify_waiters(&mut self, result: Result<TerminalRunResult, ComputerError>) {
|
|
for waiter in self.completion_waiters.drain(..) {
|
|
let _ = waiter.send(result.clone());
|
|
}
|
|
}
|
|
|
|
/// Front-and-back truncation using character counts.
|
|
///
|
|
/// When the total char count of `output_buffer` exceeds `output_char_limit`:
|
|
/// 1. Freeze the first `half` chars into `front_buffer` (done once).
|
|
/// 2. Keep only the last `half` chars in `output_buffer`.
|
|
///
|
|
/// The two halves are re-joined by `to_result()` with a separator.
|
|
fn maybe_truncate(&mut self) {
|
|
let s = String::from_utf8_lossy(&self.output_buffer);
|
|
let char_count = s.chars().count();
|
|
if char_count <= self.output_byte_limit {
|
|
return;
|
|
}
|
|
let half = self.output_byte_limit / 2;
|
|
|
|
if self.front_buffer.is_none() {
|
|
let front_end = s
|
|
.char_indices()
|
|
.nth(half)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(s.len());
|
|
self.front_buffer = Some(s[..front_end].as_bytes().to_vec());
|
|
}
|
|
|
|
let tail_start_char = char_count.saturating_sub(half);
|
|
let tail_start_byte = s
|
|
.char_indices()
|
|
.nth(tail_start_char)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(s.len());
|
|
self.output_buffer = s[tail_start_byte..].as_bytes().to_vec();
|
|
self.truncated = true;
|
|
}
|
|
|
|
/// Flush and truncate the output file to [`MAX_RETAINED_OUTPUT_FILE_BYTES`].
|
|
async fn flush_and_truncate_output_file(&mut self) {
|
|
if let Some(ref mut file) = self.file_handle {
|
|
let _ = file.flush().await;
|
|
if self.total_bytes as u64 > MAX_RETAINED_OUTPUT_FILE_BYTES {
|
|
let _ = file.set_len(MAX_RETAINED_OUTPUT_FILE_BYTES).await;
|
|
// Seek to new end so post-exit drain appends correctly.
|
|
let _ = file.seek(std::io::SeekFrom::End(0)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_timed_out(&self) -> bool {
|
|
self.start_time.elapsed() > self.timeout
|
|
}
|
|
|
|
async fn to_task_snapshot(&self, task_id: &str) -> TaskSnapshot {
|
|
// For completed background tasks, the in-memory buffer is cleared to free
|
|
// memory. Fall back to reading from the output file (non-blocking).
|
|
let output = if self.output_buffer.is_empty() && self.exit_status.is_some() {
|
|
tokio::fs::read_to_string(&self.output_file)
|
|
.await
|
|
.unwrap_or_default()
|
|
} else if let Some(ref front) = self.front_buffer {
|
|
let front_str = String::from_utf8_lossy(front);
|
|
let back_str = String::from_utf8_lossy(&self.output_buffer);
|
|
format!(
|
|
"{}\n\n... (output truncated) ...\n\n{}",
|
|
front_str.trim_end(),
|
|
back_str.trim_start()
|
|
)
|
|
} else {
|
|
String::from_utf8_lossy(&self.output_buffer).into_owned()
|
|
};
|
|
|
|
TaskSnapshot {
|
|
task_id: task_id.to_string(),
|
|
command: self.command.clone(),
|
|
display_command: self.display_command.clone(),
|
|
cwd: self.cwd.clone(),
|
|
start_time: self.start_wall_time,
|
|
end_time: if self.exit_status.is_some() {
|
|
Some(
|
|
self.end_wall_time
|
|
.unwrap_or_else(std::time::SystemTime::now),
|
|
)
|
|
} else {
|
|
None
|
|
},
|
|
output,
|
|
output_file: self.output_file.clone(),
|
|
truncated: self.truncated,
|
|
exit_code: self.exit_status.as_ref().and_then(|s| s.exit_code),
|
|
signal: self.exit_status.as_ref().and_then(|s| s.signal.clone()),
|
|
completed: self.exit_status.is_some(),
|
|
block_waited: self.block_waited,
|
|
explicitly_killed: self.explicitly_killed,
|
|
kind: self.kind,
|
|
owner_session_id: self.owner_session_id.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Waiter registered by WaitForCompletion commands.
|
|
/// Instead of blocking the actor loop, we store the reply sender and deadline,
|
|
/// then check on each poll tick whether to fire it.
|
|
struct CompletionWaiter {
|
|
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
|
deadline: Instant,
|
|
}
|
|
|
|
struct LocalTerminalActor {
|
|
cmd_rx: mpsc::Receiver<TerminalCommand>,
|
|
|
|
cancel_token: CancellationToken,
|
|
|
|
/// Reaper for spawned child trees. Each spawned process enrolls its
|
|
/// `ProcessGroup` here (this actor keeps the owning `Arc`) so the TUI exit
|
|
/// paths can `kill_all()` `setsid`-detached children that would otherwise
|
|
/// outlive the process. Defaults to the process-global scope; tests inject
|
|
/// their own to avoid latching the global.
|
|
scope: crate::util::ProcessScope,
|
|
|
|
/// task_id -> live process state.
|
|
processes: HashMap<String, ProcessState>,
|
|
|
|
/// task_id -> waiters registered by WaitForCompletion commands.
|
|
completion_waiters: HashMap<String, Vec<CompletionWaiter>>,
|
|
|
|
/// Lightweight snapshots of completed background tasks that were evicted
|
|
/// from `processes`. Prevents "Task not found" when the model queries
|
|
/// a completed task after the 5-minute process eviction TTL.
|
|
/// These are small (no child process, no file handles) and retained
|
|
/// for the session lifetime.
|
|
completed_task_snapshots: HashMap<String, TaskSnapshot>,
|
|
|
|
/// How long to keep completed background tasks in `processes` before
|
|
/// evicting them to `completed_task_snapshots`.
|
|
completed_task_ttl: Duration,
|
|
|
|
/// Foreground turn-blocking budget (on the actor so tests can shorten it).
|
|
/// See [`FOREGROUND_BLOCK_BUDGET`].
|
|
foreground_block_budget: Duration,
|
|
|
|
/// Per-command output-file size cap (on the actor so tests can shrink it).
|
|
/// See [`MAX_OUTPUT_FILE_BYTES`].
|
|
output_file_cap: u64,
|
|
|
|
/// Cgroup guard — owns the child cgroup's lifecycle. Spawned processes
|
|
/// are moved into this cgroup so their memory is bounded.
|
|
_cgroup_guard: CgroupGuard,
|
|
|
|
/// Polls for memory pressure events from the cgroup.
|
|
memory_monitor: MemoryMonitor,
|
|
|
|
persistent_shell: bool,
|
|
|
|
/// Per-backend `find`→`bfs` / `grep`→`ugrep` shadow enable state, resolved
|
|
/// once by the host and baked in at construction. Passed to
|
|
/// `search_injection` per command rather than read from a process-global, so
|
|
/// a subagent reusing this backend can't clobber the parent's shadows.
|
|
search_shadows: SearchShadowConfig,
|
|
|
|
/// Persistent shell state (env vars, cwd, functions, aliases).
|
|
/// Lazily initialized on first command when `persistent_shell` is true.
|
|
#[cfg(unix)]
|
|
shell_state: Option<shell_state::ShellState>,
|
|
|
|
/// Captured login-shell PATH for the non-persistent path.
|
|
/// Lazily initialized on first command when `persistent_shell` is false.
|
|
/// Ensures CLI tools from rc files are discoverable even without a full
|
|
/// shell snapshot.
|
|
#[cfg(unix)]
|
|
login_path_env: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
impl LocalTerminalActor {
|
|
fn new(
|
|
cmd_rx: mpsc::Receiver<TerminalCommand>,
|
|
cancel_token: CancellationToken,
|
|
cgroup_guard: CgroupGuard,
|
|
memory_monitor: MemoryMonitor,
|
|
persistent_shell: bool,
|
|
search_shadows: SearchShadowConfig,
|
|
completed_task_ttl: Duration,
|
|
foreground_block_budget: Duration,
|
|
output_file_cap: u64,
|
|
scope: crate::util::ProcessScope,
|
|
) -> Self {
|
|
Self {
|
|
cmd_rx,
|
|
cancel_token,
|
|
scope,
|
|
processes: HashMap::new(),
|
|
completion_waiters: HashMap::new(),
|
|
completed_task_snapshots: HashMap::new(),
|
|
completed_task_ttl,
|
|
foreground_block_budget,
|
|
output_file_cap,
|
|
_cgroup_guard: cgroup_guard,
|
|
memory_monitor,
|
|
persistent_shell,
|
|
search_shadows,
|
|
#[cfg(unix)]
|
|
shell_state: None,
|
|
#[cfg(unix)]
|
|
login_path_env: None,
|
|
}
|
|
}
|
|
|
|
/// Spawn a child process, using persistent shell wrapping when enabled.
|
|
///
|
|
/// Returns the spawned child and an optional handle for reading the state dump
|
|
/// from fd 4 (only present when persistent shell is active).
|
|
async fn spawn_command(
|
|
&mut self,
|
|
command: &str,
|
|
cwd: &std::path::Path,
|
|
env: &HashMap<String, String>,
|
|
) -> Result<SpawnResult, ComputerError> {
|
|
#[cfg(unix)]
|
|
if self.persistent_shell {
|
|
return self.spawn_persistent_command(command, cwd, env).await;
|
|
}
|
|
|
|
// Lazy-init: capture the user's login-shell PATH on first command so
|
|
// CLI tools from rc files (.bashrc, .zshrc, virtualenvs) are visible.
|
|
#[cfg(unix)]
|
|
if self.login_path_env.is_none() {
|
|
self.login_path_env = Some(capture_login_path().await);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
let login_env = self.login_path_env.as_ref();
|
|
#[cfg(not(unix))]
|
|
let login_env: Option<&HashMap<String, String>> = None;
|
|
|
|
let (child, process_group) =
|
|
spawn_shell_command(command, cwd, env, login_env, self.search_shadows)?;
|
|
Ok(SpawnResult {
|
|
child,
|
|
process_group,
|
|
state_dump_handle: None,
|
|
})
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn ensure_persistent_shell_initialized(&mut self, cwd: &std::path::Path) {
|
|
if self.shell_state.is_some() {
|
|
return;
|
|
}
|
|
let shell = shell_state::ShellKind::detect();
|
|
match shell_state::ShellState::init(shell, cwd).await {
|
|
Ok(state) => self.shell_state = Some(state),
|
|
Err(e) => {
|
|
tracing::warn!("persistent shell init failed, using empty state: {e}");
|
|
self.shell_state = Some(shell_state::ShellState {
|
|
cwd: cwd.to_path_buf(),
|
|
snapshot: String::new(),
|
|
shell,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn a command with persistent shell state: restore the prior snapshot
|
|
/// via fd 3, run the user command, dump the new state to fd 4.
|
|
#[cfg(unix)]
|
|
async fn spawn_persistent_command(
|
|
&mut self,
|
|
command: &str,
|
|
cwd: &std::path::Path,
|
|
env: &HashMap<String, String>,
|
|
) -> Result<SpawnResult, ComputerError> {
|
|
use command_fds::CommandFdExt;
|
|
|
|
self.ensure_persistent_shell_initialized(cwd).await;
|
|
|
|
let shell_state = self.shell_state.as_ref().unwrap();
|
|
// The shell's own tracked cwd always wins, so there is never an override.
|
|
// The bash tool populates `request.working_directory` with the workspace's
|
|
// resolved Cwd even when no per-call override is intended; honoring that as
|
|
// an explicit override resets the model's `cd` on every Shell call.
|
|
//
|
|
// Genuine per-call overrides arrive through the shell adapter, which
|
|
// prefixes a subshell `(cd <wd> && …)` to the command string — local to one
|
|
// call, and it does not mutate the parent shell's `$PWD`.
|
|
let cwd_override: Option<&std::path::Path> = None;
|
|
// `cwd` is unused on this path; the non-persistent path in `spawn_command`
|
|
// still needs it, so it stays in the signature.
|
|
let _ = cwd;
|
|
let prep = shell_state
|
|
.prepare_command(command, cwd_override, self.search_shadows)
|
|
.map_err(|e| ComputerError::io(format!("prepare persistent command: {e}")))?;
|
|
|
|
let mut cmd = tokio::process::Command::new(&prep.binary);
|
|
cmd.args(&prep.args)
|
|
.current_dir(&prep.cwd)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
|
|
// Overrides (TERM=dumb, NO_COLOR, KIGI_AGENT=1, …), then request env, then
|
|
// pager env. The agent marker is applied last so request env cannot clear it.
|
|
cmd.envs(shell_state::shell_env_overrides());
|
|
|
|
for (key, value) in env {
|
|
cmd.env(key, value);
|
|
}
|
|
|
|
cmd.envs(crate::util::pager_env());
|
|
crate::util::apply_kigi_agent_marker(&mut cmd);
|
|
|
|
cmd.fd_mappings(prep.fd_mappings)
|
|
.map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?;
|
|
|
|
unsafe {
|
|
cmd.pre_exec(crate::util::detach_from_tty);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
if kigi_sandbox::should_restrict_child_network() {
|
|
unsafe {
|
|
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
|
}
|
|
}
|
|
|
|
let child = cmd.spawn().map_err(ComputerError::from)?;
|
|
// Drop cmd to release the FdMapping OwnedFds held in its pre_exec closure.
|
|
// Without this, the parent keeps the write-end of the state-out pipe open,
|
|
// preventing the dump reader from seeing EOF.
|
|
drop(cmd);
|
|
|
|
let mut process_group = crate::util::ProcessGroup::new()
|
|
.map_err(|e| ComputerError::io(format!("ProcessGroup::new: {e}")))?;
|
|
if let Err(e) = process_group.attach(&child) {
|
|
tracing::debug!("Failed to attach persistent-shell child to ProcessGroup: {e}");
|
|
}
|
|
|
|
// fd 3 is the state input pipe.
|
|
let snapshot = shell_state.snapshot.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) =
|
|
shell_state::write_snapshot_to_pipe(&snapshot, prep.state_in_write).await
|
|
{
|
|
tracing::debug!("failed to write shell snapshot to pipe: {e}");
|
|
}
|
|
});
|
|
|
|
// fd 4 is the state output pipe.
|
|
let dump_handle =
|
|
tokio::spawn(
|
|
async move { shell_state::read_dump_from_pipe(prep.state_out_read).await },
|
|
);
|
|
|
|
Ok(SpawnResult {
|
|
child,
|
|
process_group,
|
|
state_dump_handle: Some(dump_handle),
|
|
})
|
|
}
|
|
|
|
async fn run(mut self) {
|
|
let mut ticker = tokio::time::interval(notification_interval());
|
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
// Bias towards commands (cancel, kill) over periodic ticking
|
|
// so that kill_foreground_commands is handled promptly even
|
|
// when poll_all_processes was slow (e.g. drain timeouts).
|
|
biased;
|
|
|
|
_ = self.cancel_token.cancelled() => {
|
|
self.shutdown_all().await;
|
|
break;
|
|
}
|
|
|
|
cmd = self.cmd_rx.recv() => {
|
|
match cmd {
|
|
Some(cmd) => self.handle_command(cmd).await,
|
|
None => {
|
|
self.shutdown_all().await;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gated on live processes: the actor exists for the whole
|
|
// session lifetime, and an idle session must not wake 10x/sec
|
|
// to poll an empty map (one actor per open session/tab adds
|
|
// up). With the arm disabled the interval isn't polled, so no
|
|
// timer is registered at all; the first command that spawns a
|
|
// process re-enables it on the next loop iteration.
|
|
_ = ticker.tick(), if !self.processes.is_empty() => {
|
|
self.poll_all_processes().await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_command(&mut self, cmd: TerminalCommand) {
|
|
match cmd {
|
|
TerminalCommand::Run { request, reply } => {
|
|
self.handle_run(request, reply).await;
|
|
}
|
|
TerminalCommand::RunBackground { request, reply } => {
|
|
self.handle_run_background(request, reply).await;
|
|
}
|
|
TerminalCommand::GetTask { task_id, reply } => {
|
|
let snapshot = match self.processes.get(&task_id) {
|
|
Some(p) => Some(p.to_task_snapshot(&task_id).await),
|
|
None => self.completed_task_snapshots.get(&task_id).cloned(),
|
|
};
|
|
let _ = reply.send(snapshot);
|
|
}
|
|
TerminalCommand::Kill { task_id, reply } => {
|
|
let outcome = self.handle_kill(&task_id).await;
|
|
let _ = reply.send(outcome);
|
|
}
|
|
TerminalCommand::WaitForCompletion {
|
|
task_id,
|
|
timeout,
|
|
reply,
|
|
} => {
|
|
self.handle_wait_for_completion(task_id, timeout, reply)
|
|
.await;
|
|
}
|
|
TerminalCommand::ListTasks { reply } => {
|
|
let mut snapshots =
|
|
Vec::with_capacity(self.processes.len() + self.completed_task_snapshots.len());
|
|
for (id, p) in &self.processes {
|
|
snapshots.push(p.to_task_snapshot(id).await);
|
|
}
|
|
for snap in self.completed_task_snapshots.values() {
|
|
snapshots.push(snap.clone());
|
|
}
|
|
let _ = reply.send(snapshots);
|
|
}
|
|
TerminalCommand::GetShellCwd { reply } => {
|
|
#[cfg(unix)]
|
|
let cwd = self.shell_state.as_ref().map(|s| s.cwd.clone());
|
|
#[cfg(not(unix))]
|
|
let cwd = None;
|
|
let _ = reply.send(cwd);
|
|
}
|
|
TerminalCommand::WarmShell { cwd } => {
|
|
#[cfg(unix)]
|
|
if self.persistent_shell {
|
|
self.ensure_persistent_shell_initialized(&cwd).await;
|
|
}
|
|
#[cfg(not(unix))]
|
|
let _ = cwd;
|
|
}
|
|
TerminalCommand::KillForegroundCommands => {
|
|
self.kill_foreground_commands().await;
|
|
}
|
|
TerminalCommand::BackgroundForeground {
|
|
tool_call_id,
|
|
reply,
|
|
} => {
|
|
let found = self.handle_background_foreground(&tool_call_id);
|
|
let _ = reply.send(found);
|
|
}
|
|
TerminalCommand::KillForegroundCommandsByOwner { owner_session_id } => {
|
|
self.kill_foreground_commands_by_owner(&owner_session_id)
|
|
.await;
|
|
}
|
|
TerminalCommand::KillTasksByOwner {
|
|
owner_session_id,
|
|
reply,
|
|
} => {
|
|
self.kill_tasks_by_owner(&owner_session_id).await;
|
|
let _ = reply.send(());
|
|
}
|
|
TerminalCommand::ReparentNotifications {
|
|
old_owner_session_id,
|
|
new_owner_session_id,
|
|
new_handle,
|
|
backend_weak,
|
|
reply,
|
|
} => {
|
|
self.reparent_notifications(
|
|
&old_owner_session_id,
|
|
&new_owner_session_id,
|
|
new_handle,
|
|
backend_weak,
|
|
);
|
|
let _ = reply.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wrap a freshly-spawned child's process group in an `Arc`, enroll a `Weak`
|
|
/// into the `ProcessScope` so the TUI exit paths can reap this
|
|
/// setsid-detached tree if the actor's own teardown never runs, and return
|
|
/// the owning `Arc` for the `ProcessState` to hold. The actor keeps the only
|
|
/// strong ref, so a clean reap leaves a dead `Weak` (PID-reuse-safe).
|
|
fn enroll_spawned(
|
|
&self,
|
|
group: crate::util::ProcessGroup,
|
|
) -> std::sync::Arc<crate::util::ProcessGroup> {
|
|
let group = std::sync::Arc::new(group);
|
|
self.scope.register(&group);
|
|
group
|
|
}
|
|
|
|
async fn handle_run(
|
|
&mut self,
|
|
request: TerminalRunRequest,
|
|
reply: oneshot::Sender<Result<TerminalRunResult, ComputerError>>,
|
|
) {
|
|
// Foreground callers never see this id; the reply goes back on the oneshot.
|
|
let internal_id = uuid::Uuid::now_v7().to_string();
|
|
|
|
let SpawnResult {
|
|
child,
|
|
process_group,
|
|
state_dump_handle,
|
|
} = match self
|
|
.spawn_command(&request.command, &request.working_directory, &request.env)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
let _ = reply.send(Err(e));
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Move the child process into the memory-limited cgroup (best-effort).
|
|
if let Some(pid) = child.id()
|
|
&& let Err(e) = self._cgroup_guard.add_process(pid).await
|
|
{
|
|
tracing::debug!("Failed to add pid {pid} to cgroup (non-fatal): {e}");
|
|
}
|
|
|
|
let file_handle = match open_output_file(&request.output_file).await {
|
|
Ok(file) => Some(file),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to open output file {}: {}",
|
|
request.output_file.display(),
|
|
e
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
let process_state = ProcessState {
|
|
child,
|
|
process_group: Some(self.enroll_spawned(process_group)),
|
|
output_buffer: Vec::new(),
|
|
front_buffer: None,
|
|
truncated: false,
|
|
total_bytes: 0,
|
|
exit_status: None,
|
|
bg_status: BackgroundStatus::Foreground {
|
|
auto_bg_on_timeout: request.auto_background_on_timeout,
|
|
},
|
|
completion_waiters: vec![reply],
|
|
output_byte_limit: request.output_byte_limit,
|
|
timeout: request.timeout,
|
|
foreground_block_budget: request
|
|
.foreground_block_budget
|
|
.unwrap_or(self.foreground_block_budget),
|
|
start_time: Instant::now(),
|
|
output_file: request.output_file,
|
|
file_handle,
|
|
command: request.command.clone(),
|
|
display_command: request.display_command.clone(),
|
|
cwd: request.working_directory.display().to_string(),
|
|
start_wall_time: std::time::SystemTime::now(),
|
|
completed_at: None,
|
|
end_wall_time: None,
|
|
notification_handle: request.notification_handle.clone(),
|
|
tool_call_id: request.tool_call_id.clone(),
|
|
kind: request.kind,
|
|
last_notified_total: 0,
|
|
drained: false,
|
|
block_waited: false,
|
|
explicitly_killed: false,
|
|
state_dump_handle,
|
|
owner_session_id: request.owner_session_id.clone(),
|
|
};
|
|
|
|
// Send an initial empty notification so the TUI shows the execution
|
|
// timer immediately, before any stdout/stderr output arrives.
|
|
request
|
|
.notification_handle
|
|
.send_output_chunk(BashOutputChunk {
|
|
base: BashNotificationBase {
|
|
tool_call_id: request.tool_call_id.clone(),
|
|
command: request.command.clone(),
|
|
output: Vec::new(),
|
|
total_bytes: 0,
|
|
truncated: false,
|
|
cwd: request.working_directory.clone(),
|
|
},
|
|
});
|
|
|
|
self.processes.insert(internal_id, process_state);
|
|
}
|
|
|
|
async fn handle_kill(&mut self, terminal_id: &str) -> KillOutcome {
|
|
let Some(process) = self.processes.get_mut(terminal_id) else {
|
|
return KillOutcome::NotFound;
|
|
};
|
|
|
|
if process.exit_status.is_some() {
|
|
return KillOutcome::AlreadyExited;
|
|
}
|
|
|
|
// Mark as explicitly killed BEFORE sending the kill signal so the
|
|
// exit watcher's snapshot carries the flag.
|
|
process.explicitly_killed = true;
|
|
|
|
let outcome = kill_and_finalize(process).await;
|
|
|
|
// Resolve waiters here so callers blocked on wait_for_completion() unblock
|
|
// without waiting for the next poll tick.
|
|
if let Some(waiters) = self.completion_waiters.remove(terminal_id) {
|
|
let snapshot = match self.processes.get(terminal_id) {
|
|
Some(p) => Some(p.to_task_snapshot(terminal_id).await),
|
|
None => None,
|
|
};
|
|
for waiter in waiters {
|
|
let _ = waiter.reply.send(snapshot.clone());
|
|
}
|
|
}
|
|
|
|
outcome
|
|
}
|
|
|
|
async fn handle_run_background(
|
|
&mut self,
|
|
request: TerminalRunRequest,
|
|
reply: oneshot::Sender<Result<BackgroundHandle, ComputerError>>,
|
|
) {
|
|
// Background commands fork the current shell state but don't update it on exit.
|
|
let SpawnResult {
|
|
child,
|
|
process_group,
|
|
state_dump_handle,
|
|
} = match self
|
|
.spawn_command(&request.command, &request.working_directory, &request.env)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
let _ = reply.send(Err(e));
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Move the child process into the memory-limited cgroup (best-effort).
|
|
if let Some(pid) = child.id()
|
|
&& let Err(e) = self._cgroup_guard.add_process(pid).await
|
|
{
|
|
tracing::debug!("Failed to add pid {pid} to cgroup (non-fatal): {e}");
|
|
}
|
|
|
|
let file_handle = match open_output_file(&request.output_file).await {
|
|
Ok(file) => Some(file),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to open output file {}: {}",
|
|
request.output_file.display(),
|
|
e
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
// The actor owns task identity; callers only ever receive it.
|
|
let task_id = uuid::Uuid::now_v7().to_string();
|
|
|
|
let process_state = ProcessState {
|
|
child,
|
|
process_group: Some(self.enroll_spawned(process_group)),
|
|
output_buffer: Vec::new(),
|
|
front_buffer: None,
|
|
truncated: false,
|
|
total_bytes: 0,
|
|
exit_status: None,
|
|
bg_status: BackgroundStatus::Backgrounded {
|
|
reason: BackgroundReason::Explicit,
|
|
},
|
|
completion_waiters: vec![],
|
|
output_byte_limit: request.output_byte_limit,
|
|
timeout: request.timeout,
|
|
// Unused for already-backgrounded tasks; keep a defined value.
|
|
foreground_block_budget: request
|
|
.foreground_block_budget
|
|
.unwrap_or(self.foreground_block_budget),
|
|
start_time: Instant::now(),
|
|
output_file: request.output_file.clone(),
|
|
file_handle,
|
|
command: request.command.clone(),
|
|
display_command: request.display_command.clone(),
|
|
cwd: request.working_directory.display().to_string(),
|
|
start_wall_time: std::time::SystemTime::now(),
|
|
completed_at: None,
|
|
end_wall_time: None,
|
|
notification_handle: request.notification_handle.clone(),
|
|
tool_call_id: request.tool_call_id.clone(),
|
|
kind: request.kind,
|
|
last_notified_total: 0,
|
|
drained: false,
|
|
block_waited: false,
|
|
explicitly_killed: false,
|
|
// Background commands don't update the canonical shell state —
|
|
// they may run for hours and their env mutations shouldn't leak.
|
|
// Detach the dump reader so its result is discarded (the task
|
|
// continues independently until EOF or DUMP_READ_TIMEOUT).
|
|
state_dump_handle: if self.persistent_shell {
|
|
// The spawn still carries the state wrapping so bg commands
|
|
// inherit the session env; only the dump reader is discarded.
|
|
drop(state_dump_handle);
|
|
None
|
|
} else {
|
|
None
|
|
},
|
|
owner_session_id: request.owner_session_id.clone(),
|
|
};
|
|
|
|
let pid = process_state.child.id();
|
|
self.processes.insert(task_id.clone(), process_state);
|
|
|
|
let _ = reply.send(Ok(BackgroundHandle {
|
|
task_id,
|
|
output_file: request.output_file,
|
|
pid,
|
|
}));
|
|
}
|
|
|
|
/// Register a completion waiter and return immediately.
|
|
/// The polling loop will notify this waiter when the process exits or the deadline passes.
|
|
async fn handle_wait_for_completion(
|
|
&mut self,
|
|
task_id: String,
|
|
timeout: Option<Duration>,
|
|
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
|
) {
|
|
let Some(process) = self.processes.get_mut(&task_id) else {
|
|
// The task is already evicted from `processes`, so serve the tombstone
|
|
// and imprint `block_waited=true` on it in place, so other readers
|
|
// (list_tasks, get_task) also see that the model awaited the result.
|
|
// Imprint only when the waiter actually receives the reply — a dropped
|
|
// receiver (cancelled turn) means the model never saw it.
|
|
let snapshot = self.completed_task_snapshots.get(&task_id).map(|s| {
|
|
let mut s = s.clone();
|
|
s.block_waited = true;
|
|
s
|
|
});
|
|
let found = snapshot.is_some();
|
|
let delivered = reply.send(snapshot).is_ok();
|
|
if found
|
|
&& delivered
|
|
&& let Some(s) = self.completed_task_snapshots.get_mut(&task_id)
|
|
{
|
|
s.block_waited = true;
|
|
}
|
|
return;
|
|
};
|
|
|
|
// Mark so the notification bridge skips auto-wake for this task.
|
|
// If the waiter is cancelled before receiving the result, this flag
|
|
// is cleared again (timeout expiry in `poll_all_processes` step 2,
|
|
// or undelivered completion in step 1) so auto-wake still fires.
|
|
let prev_block_waited = process.block_waited;
|
|
process.block_waited = true;
|
|
|
|
if process.exit_status.is_some() {
|
|
let snapshot = process.to_task_snapshot(&task_id).await;
|
|
if reply.send(Some(snapshot)).is_err() {
|
|
// Receiver dropped (e.g. the awaiting turn was cancelled):
|
|
// the model never received the result, so don't let this
|
|
// registration suppress auto-wake bookkeeping downstream.
|
|
process.block_waited = prev_block_waited;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let timeout = timeout.unwrap_or(Duration::from_secs(30));
|
|
self.completion_waiters
|
|
.entry(task_id)
|
|
.or_default()
|
|
.push(CompletionWaiter {
|
|
reply,
|
|
deadline: Instant::now() + timeout,
|
|
});
|
|
}
|
|
|
|
async fn poll_all_processes(&mut self) {
|
|
// 0a. On a memory.high breach, kill the newest running process — the
|
|
// most recent command is the likeliest culprit.
|
|
if let Some(event) = self.memory_monitor.try_recv() {
|
|
tracing::warn!(
|
|
memory_current = event.memory_current,
|
|
memory_high = event.memory_high_threshold,
|
|
"Memory high threshold breached — killing newest running process"
|
|
);
|
|
|
|
let newest_id = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| p.exit_status.is_none())
|
|
.max_by_key(|(_, p)| p.start_time)
|
|
.map(|(id, _)| id.clone());
|
|
|
|
if let Some(id) = newest_id
|
|
&& let Some(process) = self.processes.get_mut(&id)
|
|
{
|
|
send_sigkill_to_group(process);
|
|
drain_remaining_output(process).await;
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: Some(PROCESS_OOM_EXIT_CODE),
|
|
signal: Some("oom".to_owned()),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
}
|
|
|
|
// 0b. Kill backgrounded tasks that exceeded BACKGROUND_MAX_RUNTIME
|
|
let bg_expired: Vec<String> = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| {
|
|
p.bg_status.is_backgrounded()
|
|
&& p.exit_status.is_none()
|
|
&& p.start_time.elapsed() > BACKGROUND_MAX_RUNTIME
|
|
})
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
|
|
for task_id in &bg_expired {
|
|
if let Some(process) = self.processes.get_mut(task_id) {
|
|
tracing::warn!(task_id, "Background task exceeded max runtime, killing");
|
|
// Fire-and-forget SIGTERM — poll loop escalates to SIGKILL
|
|
// on the next tick if the process doesn't exit.
|
|
send_sigterm_to_group(process);
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: None,
|
|
signal: Some("max_runtime".to_owned()),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
}
|
|
}
|
|
|
|
// 0b (size). Kill any running task whose output file passed the cap
|
|
// (a detached writer has no timeout watching its disk use).
|
|
let output_cap = self.output_file_cap;
|
|
let size_exceeded: Vec<String> = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| p.exit_status.is_none() && p.total_bytes as u64 > output_cap)
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
|
|
for task_id in &size_exceeded {
|
|
if let Some(process) = self.processes.get_mut(task_id) {
|
|
tracing::warn!(
|
|
task_id,
|
|
total_bytes = process.total_bytes,
|
|
cap = output_cap,
|
|
"Task exceeded output size cap, killing"
|
|
);
|
|
// Fire-and-forget SIGTERM — poll loop escalates to SIGKILL
|
|
// on the next tick if the process doesn't exit.
|
|
send_sigterm_to_group(process);
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: None,
|
|
signal: Some("output_limit".to_owned()),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
// Notify any waiter immediately (may be a still-foreground
|
|
// command, unlike the bg-only max-runtime sweep; mirrors OOM).
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
}
|
|
|
|
let task_ids: Vec<String> = self.processes.keys().cloned().collect();
|
|
|
|
for task_id in &task_ids {
|
|
self.poll_process(task_id).await;
|
|
}
|
|
|
|
// Once a child is reaped (`child.id()` is `None`), drop the actor's
|
|
// strong `Arc<ProcessGroup>` so the `ProcessScope`'s `Weak` dies at reap.
|
|
// A completed task lingers in `self.processes` for the TTL; keeping the
|
|
// Arc that long would let `kill_all` upgrade the `Weak` and `killpg` a
|
|
// pid the OS may have recycled. Runs after the poll loop so it catches a
|
|
// reap from any path (normal/kill/oom/timeout) within one tick.
|
|
//
|
|
// Unix-only: only `killpg` can hit a recycled pid. On Windows the group
|
|
// is a JobObject HANDLE (no recyclable pid), so an early drop buys
|
|
// nothing — the Arc is released when the `ProcessState` is removed.
|
|
#[cfg(unix)]
|
|
for process in self.processes.values_mut() {
|
|
if process.process_group.is_some() && process.child.id().is_none() {
|
|
process.process_group = None;
|
|
}
|
|
}
|
|
|
|
// 0c. Collect state dumps from completed foreground processes and update
|
|
// the canonical shell state (persistent shell only).
|
|
// Uses a scoped borrow on self.processes so self.shell_state can be
|
|
// updated afterwards.
|
|
#[cfg(unix)]
|
|
if self.persistent_shell {
|
|
for task_id in &task_ids {
|
|
let handle = {
|
|
let Some(process) = self.processes.get_mut(task_id) else {
|
|
continue;
|
|
};
|
|
// Only foreground processes update the canonical state.
|
|
if process.exit_status.is_none() || process.bg_status.is_backgrounded() {
|
|
continue;
|
|
}
|
|
process.state_dump_handle.take()
|
|
};
|
|
// Borrow on self.processes is released — safe to access self.shell_state.
|
|
if let Some(handle) = handle {
|
|
match handle.await {
|
|
Ok(Ok(dump)) => {
|
|
if let Some(ref mut state) = self.shell_state {
|
|
state.update_from_dump(&dump);
|
|
}
|
|
}
|
|
Ok(Err(e)) => {
|
|
tracing::debug!("failed to read shell state dump: {e}");
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("shell state dump task panicked: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Notify completion waiters for processes that have exited
|
|
let waiter_task_ids: Vec<String> = self.completion_waiters.keys().cloned().collect();
|
|
for task_id in waiter_task_ids {
|
|
let completed = self
|
|
.processes
|
|
.get(&task_id)
|
|
.map(|p| p.exit_status.is_some())
|
|
// A process that is gone counts as completed.
|
|
.unwrap_or(true);
|
|
|
|
if completed && let Some(waiters) = self.completion_waiters.remove(&task_id) {
|
|
let snapshot = match self.processes.get(&task_id) {
|
|
Some(p) => Some(p.to_task_snapshot(&task_id).await),
|
|
None => None,
|
|
};
|
|
let mut any_delivered = false;
|
|
for waiter in waiters {
|
|
if waiter.reply.send(snapshot.clone()).is_ok() {
|
|
any_delivered = true;
|
|
}
|
|
}
|
|
// If no waiter actually received the completion — every
|
|
// oneshot receiver was dropped because the awaiting turn(s)
|
|
// were cancelled (e.g. Ctrl+C mid `get_task_output`) — the
|
|
// model never saw the result. Clear `block_waited` so the
|
|
// TaskCompleted auto-wake in the notification bridge is NOT
|
|
// suppressed (this runs before step 3 emits the completion
|
|
// notification, so the cleared flag is what gets snapshotted).
|
|
if !any_delivered && let Some(process) = self.processes.get_mut(&task_id) {
|
|
process.block_waited = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Expire timed-out waiters (process still running but deadline passed)
|
|
let now = Instant::now();
|
|
let waiter_keys: Vec<String> = self.completion_waiters.keys().cloned().collect();
|
|
let mut timed_out_tasks: Vec<String> = Vec::new();
|
|
for task_id in waiter_keys {
|
|
let snapshot = match self.processes.get(&task_id) {
|
|
Some(p) => Some(p.to_task_snapshot(&task_id).await),
|
|
None => None,
|
|
};
|
|
if let Some(waiters) = self.completion_waiters.get_mut(&task_id) {
|
|
let mut i = 0;
|
|
while i < waiters.len() {
|
|
if now >= waiters[i].deadline {
|
|
let waiter = waiters.swap_remove(i);
|
|
let _ = waiter.reply.send(snapshot.clone());
|
|
timed_out_tasks.push(task_id.clone());
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.completion_waiters.retain(|_, v| !v.is_empty());
|
|
|
|
// Clear block_waited for tasks where all waiters timed out without
|
|
// receiving the completion. Without this, auto-wake is permanently
|
|
// suppressed even though the agent never saw the result.
|
|
for task_id in timed_out_tasks {
|
|
if !self.completion_waiters.contains_key(&task_id)
|
|
&& let Some(process) = self.processes.get_mut(&task_id)
|
|
{
|
|
process.block_waited = false;
|
|
}
|
|
}
|
|
|
|
// 3. Set completed_at and clear output buffer for completed background tasks.
|
|
let mut newly_completed: Vec<String> = Vec::new();
|
|
for (task_id, process) in self.processes.iter_mut() {
|
|
if process.exit_status.is_some()
|
|
&& process.bg_status.is_backgrounded()
|
|
&& process.completed_at.is_none()
|
|
{
|
|
process.completed_at = Some(Instant::now());
|
|
if process.end_wall_time.is_none() {
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
}
|
|
// Drop in-memory buffer — output file on disk has the full content
|
|
process.output_buffer.clear();
|
|
newly_completed.push(task_id.clone());
|
|
}
|
|
}
|
|
// The `block_waited` gate that suppresses the redundant auto-wake
|
|
// synthetic prompt for awaited tasks lives in
|
|
// `tools/notification_bridge.rs` (the `TaskCompleted` arm checks
|
|
// `task_snapshot.block_waited` before the auto-wake injection
|
|
// branch — see the comment there). This loop must still fire
|
|
// `send_task_complete` unconditionally for newly-completed
|
|
// background tasks so the pager UI, persistence, and
|
|
// `AutoWakeDeliveredIds` bookkeeping all still get the snapshot.
|
|
for task_id in newly_completed {
|
|
if let Some(process) = self.processes.get(&task_id) {
|
|
let snapshot = process.to_task_snapshot(&task_id).await;
|
|
process.notification_handle.send_task_complete(snapshot);
|
|
}
|
|
}
|
|
|
|
// 4. Evict processes based on completion state and TTL.
|
|
// Before evicting completed background tasks, save a lightweight
|
|
// TaskSnapshot so get_task can still return status after eviction.
|
|
let evict_ids: Vec<String> = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| {
|
|
if p.exit_status.is_none() {
|
|
return false;
|
|
}
|
|
// Foreground processes have already replied to their caller.
|
|
if !p.bg_status.is_backgrounded() {
|
|
return true;
|
|
}
|
|
// Backgrounded + completed: evict after TTL
|
|
matches!(p.completed_at, Some(t) if t.elapsed() >= self.completed_task_ttl)
|
|
})
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
for id in &evict_ids {
|
|
if let Some(p) = self.processes.get(id)
|
|
&& p.bg_status.is_backgrounded()
|
|
{
|
|
// Save metadata-only snapshot (no output) before eviction so
|
|
// get_task still returns status/exit_code. Output is on disk
|
|
// at `output_file` — reading it into memory here would leak
|
|
// unbounded data for long-running tasks.
|
|
let snapshot = TaskSnapshot {
|
|
task_id: id.clone(),
|
|
command: p.command.clone(),
|
|
display_command: p.display_command.clone(),
|
|
cwd: p.cwd.clone(),
|
|
start_time: p.start_wall_time,
|
|
end_time: p.end_wall_time,
|
|
output: String::new(),
|
|
output_file: p.output_file.clone(),
|
|
truncated: p.truncated,
|
|
exit_code: p.exit_status.as_ref().and_then(|s| s.exit_code),
|
|
signal: p.exit_status.as_ref().and_then(|s| s.signal.clone()),
|
|
completed: true,
|
|
kind: p.kind,
|
|
block_waited: p.block_waited,
|
|
explicitly_killed: p.explicitly_killed,
|
|
owner_session_id: p.owner_session_id.clone(),
|
|
};
|
|
self.completed_task_snapshots.insert(id.clone(), snapshot);
|
|
}
|
|
self.processes.remove(id);
|
|
}
|
|
// Cap the tombstone map by evicting the oldest entries.
|
|
while self.completed_task_snapshots.len() > MAX_COMPLETED_TASK_SNAPSHOTS {
|
|
let oldest = self
|
|
.completed_task_snapshots
|
|
.iter()
|
|
.min_by_key(|(_, s)| s.start_time)
|
|
.map(|(id, _)| id.clone());
|
|
if let Some(id) = oldest {
|
|
self.completed_task_snapshots.remove(&id);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn poll_process(&mut self, terminal_id: &str) {
|
|
let Some(process) = self.processes.get_mut(terminal_id) else {
|
|
return;
|
|
};
|
|
|
|
// If exit_status is already set (e.g., by timeout handler or external signal),
|
|
// the process may still be running. Escalate to SIGKILL if needed, and drain
|
|
// output once it exits.
|
|
if process.exit_status.is_some() {
|
|
if process.drained {
|
|
return;
|
|
}
|
|
match process.child.try_wait() {
|
|
Ok(None) => {
|
|
send_sigkill_to_group(process);
|
|
}
|
|
Ok(Some(_)) => {
|
|
drain_remaining_output(process).await;
|
|
process.flush_and_truncate_output_file().await;
|
|
process.drained = true;
|
|
}
|
|
Err(_) => {
|
|
drain_remaining_output(process).await;
|
|
process.flush_and_truncate_output_file().await;
|
|
process.drained = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Both streams are read with non-blocking `poll_read`, taking only what is
|
|
// available right now. A timeout-per-stream read costs that timeout per
|
|
// process on every tick even when idle, which for N processes overruns the
|
|
// 100 ms tick interval and delays the output-file writes.
|
|
|
|
let mut new_bytes: Vec<u8> = Vec::new();
|
|
|
|
let mut stdout_eof = false;
|
|
if let Some(stdout) = process.child.stdout.as_mut() {
|
|
loop {
|
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
|
match try_read_nonblocking(stdout, &mut buf) {
|
|
Some(Ok(0)) => {
|
|
stdout_eof = true;
|
|
break;
|
|
}
|
|
Some(Ok(n)) => {
|
|
new_bytes.extend_from_slice(&buf[..n]);
|
|
}
|
|
Some(Err(_)) => {
|
|
stdout_eof = true;
|
|
break;
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut stderr_eof = false;
|
|
if let Some(stderr) = process.child.stderr.as_mut() {
|
|
loop {
|
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
|
match try_read_nonblocking(stderr, &mut buf) {
|
|
Some(Ok(0)) => {
|
|
stderr_eof = true;
|
|
break;
|
|
}
|
|
Some(Ok(n)) => {
|
|
new_bytes.extend_from_slice(&buf[..n]);
|
|
}
|
|
Some(Err(_)) => {
|
|
stderr_eof = true;
|
|
break;
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Batch write to file + flush so the output is visible to readers
|
|
// (e.g. `read_file` on the output_file from get_task_output).
|
|
if !new_bytes.is_empty() {
|
|
process.output_buffer.extend_from_slice(&new_bytes);
|
|
process.total_bytes += new_bytes.len();
|
|
if let Some(ref mut file) = process.file_handle {
|
|
let _ = file.write_all(&new_bytes).await;
|
|
let _ = file.flush().await;
|
|
}
|
|
}
|
|
|
|
// Truncate in-memory buffer if needed (file has full output)
|
|
process.maybe_truncate();
|
|
|
|
// Keyed off the monotonic `total_bytes` (not `output_buffer.len()`):
|
|
// after `maybe_truncate` freezes the front half and keeps only the
|
|
// shrinking tail, a length-based gate would go false and stay false,
|
|
// starving the stream of chunks for the rest of a long-output command.
|
|
if process.total_bytes > process.last_notified_total {
|
|
process
|
|
.notification_handle
|
|
.send_output_chunk(BashOutputChunk {
|
|
base: BashNotificationBase {
|
|
tool_call_id: process.tool_call_id.clone(),
|
|
command: process.command.clone(),
|
|
output: process.output_buffer.clone(),
|
|
total_bytes: process.total_bytes,
|
|
truncated: process.truncated,
|
|
cwd: process.cwd.clone().into(),
|
|
},
|
|
});
|
|
process.last_notified_total = process.total_bytes;
|
|
}
|
|
|
|
// Foreground budget: auto-backgroundable commands stop blocking the
|
|
// turn after the per-process budget (independent of `timeout`) — this
|
|
// second timer only backgrounds, never kills. Default is 15s; sessions
|
|
// can override via BashParams.foreground_block_budget_ms (0 = disable
|
|
// short budget so only `timeout` auto-bgs). The `timeout` check below
|
|
// also auto-bgs when auto_bg is on, or kills when it is off.
|
|
if process.exit_status.is_none()
|
|
&& matches!(
|
|
process.bg_status,
|
|
BackgroundStatus::Foreground {
|
|
auto_bg_on_timeout: true
|
|
}
|
|
)
|
|
&& process.start_time.elapsed() > process.foreground_block_budget
|
|
{
|
|
self.transition_to_background(terminal_id, BackgroundReason::ForegroundTimeout);
|
|
return;
|
|
}
|
|
|
|
if process.is_timed_out() && process.exit_status.is_none() {
|
|
if matches!(
|
|
process.bg_status,
|
|
BackgroundStatus::Foreground {
|
|
auto_bg_on_timeout: true
|
|
}
|
|
) {
|
|
self.transition_to_background(terminal_id, BackgroundReason::ForegroundTimeout);
|
|
return;
|
|
}
|
|
|
|
send_sigterm_to_group(process);
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: None,
|
|
signal: Some("timeout".to_owned()),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
return;
|
|
}
|
|
|
|
let process_done = stdout_eof && stderr_eof;
|
|
match process.child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
// Drain what arrived after the reads above: fast commands (e.g.
|
|
// `python3 -c "print('x')"`) exit before their pipe buffers are
|
|
// read, and without this drain their output comes back empty.
|
|
drain_remaining_output(process).await;
|
|
|
|
process.exit_status = Some(extract_exit_status(status));
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
Ok(None) if process_done => {
|
|
// Streams closed but process hasn't exited yet - wait a bit
|
|
}
|
|
Ok(None) => {
|
|
// Still running
|
|
}
|
|
Err(e) => {
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: None,
|
|
signal: Some(format!("error: {}", e)),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn shutdown_all(&mut self) {
|
|
for process in self.processes.values_mut() {
|
|
send_sigkill_to_group(process);
|
|
// Abort the state dump reader so its spawn_blocking thread
|
|
// doesn't outlive the actor.
|
|
if let Some(handle) = process.state_dump_handle.take() {
|
|
handle.abort();
|
|
}
|
|
}
|
|
self.processes.clear();
|
|
}
|
|
|
|
/// Transition a foreground process to background (shared by auto-timeout
|
|
/// and user Ctrl+G). Re-keys from `old_key` to `tool_call_id`.
|
|
fn transition_to_background(&mut self, old_key: &str, reason: BackgroundReason) -> bool {
|
|
let Some(mut process) = self.processes.remove(old_key) else {
|
|
return false;
|
|
};
|
|
process.bg_status = BackgroundStatus::Backgrounded { reason };
|
|
process.timeout = BACKGROUND_MAX_RUNTIME;
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
let tool_call_id = process.tool_call_id.clone();
|
|
|
|
tracing::info!(
|
|
tool_call_id = %tool_call_id,
|
|
?reason,
|
|
"Foreground command transitioned to background"
|
|
);
|
|
self.processes.insert(tool_call_id, process);
|
|
true
|
|
}
|
|
|
|
/// Background a foreground command by `tool_call_id` (user Ctrl+G).
|
|
fn handle_background_foreground(&mut self, tool_call_id: &str) -> bool {
|
|
let internal_id = self
|
|
.processes
|
|
.iter()
|
|
.find(|(_, p)| p.tool_call_id == tool_call_id && !p.bg_status.is_backgrounded())
|
|
.map(|(id, _)| id.clone());
|
|
|
|
let Some(internal_id) = internal_id else {
|
|
return false;
|
|
};
|
|
|
|
self.transition_to_background(&internal_id, BackgroundReason::UserSignal)
|
|
}
|
|
|
|
/// Kill all non-backgrounded (foreground) processes and notify their waiters.
|
|
/// Backgrounded processes are left untouched. The actor stays alive for reuse.
|
|
///
|
|
/// After sending SIGKILL we **wait for each child to actually exit** (with a
|
|
/// 5 s timeout) so the kernel reclaims its memory pages before the next tool
|
|
/// call can allocate. Without this wait, a rapid OOM → recover → OOM cycle
|
|
/// can hit memory.max because the previous child's RSS hasn't been freed yet.
|
|
async fn kill_foreground_commands(&mut self) {
|
|
let fg_ids: Vec<String> = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| !p.bg_status.is_backgrounded() && p.exit_status.is_none())
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
|
|
for id in &fg_ids {
|
|
if let Some(process) = self.processes.get_mut(id) {
|
|
send_sigkill_to_group(process);
|
|
|
|
// Bounded at 5 s; SIGKILL is unconditional so this resolves
|
|
// almost instantly in practice.
|
|
let _ =
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), process.child.wait())
|
|
.await;
|
|
|
|
// Abort the state dump reader task so its `spawn_blocking`
|
|
// thread doesn't leak. Without this, a grandchild that
|
|
// inherited fd 4 and escaped the process group keeps the
|
|
// pipe's write end open, and the blocking `read_to_string`
|
|
// hangs indefinitely (the timeout future was abandoned when
|
|
// the JoinHandle was dropped).
|
|
if let Some(handle) = process.state_dump_handle.take() {
|
|
handle.abort();
|
|
}
|
|
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: None,
|
|
signal: Some("cancelled".to_owned()),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
}
|
|
|
|
for id in &fg_ids {
|
|
self.processes.remove(id);
|
|
}
|
|
}
|
|
|
|
/// Kill all running foreground processes owned by a specific session.
|
|
/// Processes owned by other sessions are left untouched.
|
|
async fn kill_foreground_commands_by_owner(&mut self, owner_session_id: &str) {
|
|
let fg_ids: Vec<String> = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| {
|
|
p.owner_session_id.as_deref() == Some(owner_session_id)
|
|
&& !p.bg_status.is_backgrounded()
|
|
&& p.exit_status.is_none()
|
|
})
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
|
|
for id in &fg_ids {
|
|
if let Some(process) = self.processes.get_mut(id) {
|
|
send_sigkill_to_group(process);
|
|
let _ =
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), process.child.wait())
|
|
.await;
|
|
if let Some(handle) = process.state_dump_handle.take() {
|
|
handle.abort();
|
|
}
|
|
process.exit_status = Some(ExitStatus {
|
|
exit_code: None,
|
|
signal: Some("cancelled".to_owned()),
|
|
});
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
process.flush_and_truncate_output_file().await;
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
}
|
|
|
|
for id in &fg_ids {
|
|
self.processes.remove(id);
|
|
}
|
|
}
|
|
|
|
/// Kill all running background tasks owned by a specific session.
|
|
/// Foreground tasks and tasks owned by other sessions are left untouched.
|
|
async fn kill_tasks_by_owner(&mut self, owner_session_id: &str) {
|
|
let owned_ids: Vec<String> = self
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, p)| {
|
|
p.owner_session_id.as_deref() == Some(owner_session_id)
|
|
&& p.exit_status.is_none()
|
|
&& p.bg_status.is_backgrounded()
|
|
})
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
|
|
for id in owned_ids {
|
|
if let Some(process) = self.processes.get_mut(&id) {
|
|
process.explicitly_killed = true;
|
|
}
|
|
self.handle_kill(&id).await;
|
|
}
|
|
}
|
|
|
|
/// Reparent notification handles for all tasks owned by `old_owner_session_id`.
|
|
/// Swaps the notification handle to `new_handle` so events from surviving
|
|
/// processes (monitors, background tasks) route to the parent session.
|
|
///
|
|
/// Also sends synthetic `BashExecutionBackgrounded` notifications through
|
|
/// the new handle for each reparented background task, so the parent's TUI
|
|
/// creates a `bg_tasks` entry and subsequent `MonitorEvent`/`TaskCompleted`
|
|
/// notifications have a target to attach to.
|
|
///
|
|
/// For monitors, re-spawns the output pipeline so events continue streaming.
|
|
fn reparent_notifications(
|
|
&mut self,
|
|
old_owner_session_id: &str,
|
|
new_owner_session_id: &str,
|
|
new_handle: crate::notification::types::ToolNotificationHandle,
|
|
backend_weak: std::sync::Weak<dyn crate::computer::types::TerminalBackend>,
|
|
) {
|
|
for (task_id, process) in self.processes.iter_mut() {
|
|
if process.owner_session_id.as_deref() == Some(old_owner_session_id)
|
|
&& process.bg_status.is_backgrounded()
|
|
&& process.exit_status.is_none()
|
|
{
|
|
// Only reparent backgrounded, still-running tasks. Foreground
|
|
// processes keep the child's owner_session_id so the subsequent
|
|
// kill_foreground_commands_by_owner call can reap them.
|
|
process.owner_session_id = Some(new_owner_session_id.to_string());
|
|
process.notification_handle = new_handle.clone();
|
|
|
|
// Send a synthetic backgrounded notification so the parent's
|
|
// TUI registers this task in its bg_tasks map. For a reparented
|
|
// monitor, recover the human description from the baked
|
|
// "[monitor] <desc>" display command and forward the real
|
|
// command + `monitor_description`, so the pager renders a proper
|
|
// "Monitor" row (matching the original-spawn path) rather than a
|
|
// bash-highlighted "[monitor] …".
|
|
let is_monitor = process.kind == crate::computer::types::TaskKind::Monitor;
|
|
let monitor_description = if is_monitor {
|
|
process
|
|
.display_command
|
|
.as_deref()
|
|
.and_then(|d| d.strip_prefix("[monitor] "))
|
|
.map(str::to_string)
|
|
} else {
|
|
None
|
|
};
|
|
let reparent_command = if is_monitor {
|
|
process.command.clone()
|
|
} else {
|
|
process
|
|
.display_command
|
|
.clone()
|
|
.unwrap_or_else(|| process.command.clone())
|
|
};
|
|
new_handle.send_backgrounded(crate::notification::BashExecutionBackgrounded {
|
|
base: crate::notification::BashNotificationBase {
|
|
tool_call_id: process.tool_call_id.clone(),
|
|
command: reparent_command,
|
|
output: Vec::new(),
|
|
total_bytes: 0,
|
|
truncated: false,
|
|
cwd: std::path::PathBuf::from(&process.cwd),
|
|
},
|
|
output_file: process.output_file.clone(),
|
|
task_id: task_id.clone(),
|
|
monitor_description,
|
|
// Reparent path has no model tool description; monitors use
|
|
// `monitor_description` above.
|
|
description: None,
|
|
});
|
|
|
|
// Re-spawn the monitor pipeline so events continue streaming.
|
|
// The old pipeline died with the child's runtime.
|
|
if process.kind == crate::computer::types::TaskKind::Monitor {
|
|
let pipeline_task_id = task_id.clone();
|
|
let pipeline_description = process
|
|
.display_command
|
|
.clone()
|
|
.unwrap_or_else(|| process.command.clone());
|
|
// Weak so the reparented monitor doesn't pin the backend.
|
|
let pipeline_terminal = backend_weak.clone();
|
|
let pipeline_notif = new_handle.clone();
|
|
let pipeline_output_file = process.output_file.clone();
|
|
// Start from current file size so we don't re-emit
|
|
// events already delivered by the old pipeline.
|
|
let start_offset = std::fs::metadata(&pipeline_output_file)
|
|
.map(|m| m.len())
|
|
.unwrap_or(0);
|
|
tokio::spawn(async move {
|
|
crate::implementations::kigi::monitor::tool::run_monitor_pipeline(
|
|
&pipeline_task_id,
|
|
&pipeline_description,
|
|
pipeline_terminal,
|
|
&pipeline_notif,
|
|
&pipeline_output_file,
|
|
Some("kill_command_or_subagent".to_string()),
|
|
start_offset,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle to the terminal actor: the public `TerminalBackend` implementation,
|
|
/// which reaches the actor's state only by sending commands over a channel.
|
|
#[derive(Clone)]
|
|
pub struct LocalTerminalBackend {
|
|
cmd_tx: mpsc::Sender<TerminalCommand>,
|
|
cancel_token: CancellationToken,
|
|
}
|
|
|
|
impl LocalTerminalBackend {
|
|
/// Spawns the actor task that owns all terminal state.
|
|
pub fn new() -> Self {
|
|
Self::new_inner(None, false, false, SearchShadowConfig::default())
|
|
}
|
|
|
|
/// Environment variables, working directory, functions, aliases, and shell
|
|
/// options persist across command invocations. The user's login shell (bash or
|
|
/// zsh) is detected and its rc files are loaded once on the first command.
|
|
pub fn with_persistent_shell() -> Self {
|
|
Self::new_inner(None, false, true, SearchShadowConfig::default())
|
|
}
|
|
|
|
/// See [`CgroupMemoryConfig`] for details on the soft/hard limit model.
|
|
pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
|
Self::new_inner(Some(config), false, false, SearchShadowConfig::default())
|
|
}
|
|
|
|
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
|
|
Self::new_inner(Some(config), false, true, SearchShadowConfig::default())
|
|
}
|
|
|
|
/// Runs the actor on `spawn_local`, for single-threaded runtimes.
|
|
///
|
|
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
|
/// state, baked into this backend (see [`SearchShadowConfig`]).
|
|
pub fn new_local(search_shadows: SearchShadowConfig) -> Self {
|
|
Self::new_inner(None, true, false, search_shadows)
|
|
}
|
|
|
|
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
|
/// state, baked into this backend (see [`SearchShadowConfig`]).
|
|
pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self {
|
|
Self::new_inner(None, true, true, search_shadows)
|
|
}
|
|
|
|
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
|
Self::new_inner(Some(config), true, false, SearchShadowConfig::default())
|
|
}
|
|
|
|
/// Test-only: a spawn_local backend that enrolls spawned children into
|
|
/// `scope` instead of the process-global one, so a test can `kill_all()` in
|
|
/// isolation without latching the global scope shared by other tests.
|
|
#[cfg(test)]
|
|
pub(crate) fn new_local_with_scope(
|
|
search_shadows: SearchShadowConfig,
|
|
scope: crate::util::ProcessScope,
|
|
) -> Self {
|
|
Self::new_with_ttl(
|
|
None,
|
|
true,
|
|
false,
|
|
search_shadows,
|
|
COMPLETED_TASK_TTL,
|
|
FOREGROUND_BLOCK_BUDGET,
|
|
MAX_OUTPUT_FILE_BYTES,
|
|
scope,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn new_with_completed_task_ttl(ttl: Duration) -> Self {
|
|
Self::new_with_ttl(
|
|
None,
|
|
false,
|
|
false,
|
|
SearchShadowConfig::default(),
|
|
ttl,
|
|
FOREGROUND_BLOCK_BUDGET,
|
|
MAX_OUTPUT_FILE_BYTES,
|
|
crate::util::global_process_scope().clone(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn new_with_foreground_budget(budget: Duration) -> Self {
|
|
Self::new_with_ttl(
|
|
None,
|
|
false,
|
|
false,
|
|
SearchShadowConfig::default(),
|
|
COMPLETED_TASK_TTL,
|
|
budget,
|
|
MAX_OUTPUT_FILE_BYTES,
|
|
crate::util::global_process_scope().clone(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn new_with_output_cap(output_file_cap: u64) -> Self {
|
|
Self::new_with_ttl(
|
|
None,
|
|
false,
|
|
false,
|
|
SearchShadowConfig::default(),
|
|
COMPLETED_TASK_TTL,
|
|
FOREGROUND_BLOCK_BUDGET,
|
|
output_file_cap,
|
|
crate::util::global_process_scope().clone(),
|
|
)
|
|
}
|
|
|
|
fn new_inner(
|
|
memory_config: Option<CgroupMemoryConfig>,
|
|
use_spawn_local: bool,
|
|
persistent_shell: bool,
|
|
search_shadows: SearchShadowConfig,
|
|
) -> Self {
|
|
Self::new_with_ttl(
|
|
memory_config,
|
|
use_spawn_local,
|
|
persistent_shell,
|
|
search_shadows,
|
|
COMPLETED_TASK_TTL,
|
|
foreground_block_budget_from_env(),
|
|
output_file_cap_from_env(),
|
|
crate::util::global_process_scope().clone(),
|
|
)
|
|
}
|
|
|
|
fn new_with_ttl(
|
|
memory_config: Option<CgroupMemoryConfig>,
|
|
use_spawn_local: bool,
|
|
persistent_shell: bool,
|
|
search_shadows: SearchShadowConfig,
|
|
completed_task_ttl: Duration,
|
|
foreground_block_budget: Duration,
|
|
output_file_cap: u64,
|
|
scope: crate::util::ProcessScope,
|
|
) -> Self {
|
|
let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
|
|
let cancel_token = CancellationToken::new();
|
|
|
|
let cancel_token_clone = cancel_token.clone();
|
|
let actor_fut = async move {
|
|
let (cgroup_guard, memory_monitor) = match memory_config {
|
|
Some(config) => {
|
|
let guard = CgroupGuard::try_create(&config).await;
|
|
let monitor = MemoryMonitor::start(&guard, &config, use_spawn_local).await;
|
|
(guard, monitor)
|
|
}
|
|
None => (CgroupGuard::noop(), MemoryMonitor::noop()),
|
|
};
|
|
let actor = LocalTerminalActor::new(
|
|
cmd_rx,
|
|
cancel_token_clone,
|
|
cgroup_guard,
|
|
memory_monitor,
|
|
persistent_shell,
|
|
search_shadows,
|
|
completed_task_ttl,
|
|
foreground_block_budget,
|
|
output_file_cap,
|
|
scope,
|
|
);
|
|
actor.run().await;
|
|
};
|
|
|
|
if use_spawn_local {
|
|
tokio::task::spawn_local(actor_fut);
|
|
} else {
|
|
tokio::spawn(actor_fut);
|
|
}
|
|
|
|
Self {
|
|
cmd_tx,
|
|
cancel_token,
|
|
}
|
|
}
|
|
|
|
pub fn cancel(&self) {
|
|
self.cancel_token.cancel();
|
|
}
|
|
}
|
|
|
|
impl Default for LocalTerminalBackend {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl TerminalBackend for LocalTerminalBackend {
|
|
async fn run(&self, request: TerminalRunRequest) -> Result<TerminalRunResult, ComputerError> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
|
|
self.cmd_tx
|
|
.send(TerminalCommand::Run {
|
|
request,
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.map_err(|_| ComputerError::io("terminal actor shut down"))?;
|
|
|
|
reply_rx
|
|
.await
|
|
.map_err(|_| ComputerError::io("terminal actor dropped reply channel"))?
|
|
}
|
|
|
|
async fn run_background(
|
|
&self,
|
|
request: TerminalRunRequest,
|
|
) -> Result<BackgroundHandle, ComputerError> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
|
|
self.cmd_tx
|
|
.send(TerminalCommand::RunBackground {
|
|
request,
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.map_err(|_| ComputerError::io("terminal actor shut down"))?;
|
|
|
|
reply_rx
|
|
.await
|
|
.map_err(|_| ComputerError::io("terminal actor dropped reply channel"))?
|
|
}
|
|
|
|
async fn get_task(&self, task_id: &str) -> Option<TaskSnapshot> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
self.cmd_tx
|
|
.send(TerminalCommand::GetTask {
|
|
task_id: task_id.to_string(),
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.ok()?;
|
|
reply_rx.await.ok().flatten()
|
|
}
|
|
|
|
async fn kill_task(&self, task_id: &str) -> KillOutcome {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
if self
|
|
.cmd_tx
|
|
.send(TerminalCommand::Kill {
|
|
task_id: task_id.to_string(),
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.is_err()
|
|
{
|
|
return KillOutcome::NotFound;
|
|
}
|
|
reply_rx.await.unwrap_or(KillOutcome::NotFound)
|
|
}
|
|
|
|
async fn wait_for_completion(
|
|
&self,
|
|
task_id: &str,
|
|
timeout: Option<Duration>,
|
|
) -> Option<TaskSnapshot> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
self.cmd_tx
|
|
.send(TerminalCommand::WaitForCompletion {
|
|
task_id: task_id.to_string(),
|
|
timeout,
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.ok()?;
|
|
reply_rx.await.ok().flatten()
|
|
}
|
|
|
|
async fn list_tasks(&self) -> Vec<TaskSnapshot> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
if self
|
|
.cmd_tx
|
|
.send(TerminalCommand::ListTasks { reply: reply_tx })
|
|
.await
|
|
.is_err()
|
|
{
|
|
return vec![];
|
|
}
|
|
reply_rx.await.unwrap_or_default()
|
|
}
|
|
|
|
async fn get_shell_cwd(&self) -> Option<PathBuf> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
self.cmd_tx
|
|
.send(TerminalCommand::GetShellCwd { reply: reply_tx })
|
|
.await
|
|
.ok()?;
|
|
reply_rx.await.ok().flatten()
|
|
}
|
|
|
|
async fn warm_persistent_shell(&self, cwd: &std::path::Path) {
|
|
let _ = self
|
|
.cmd_tx
|
|
.send(TerminalCommand::WarmShell {
|
|
cwd: cwd.to_path_buf(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn kill_foreground_commands(&self) {
|
|
let _ = self
|
|
.cmd_tx
|
|
.send(TerminalCommand::KillForegroundCommands)
|
|
.await;
|
|
}
|
|
|
|
async fn kill_all_background_tasks(&self) {
|
|
let tasks = self.list_tasks().await;
|
|
for task in tasks {
|
|
if task.exit_code.is_none() && task.signal.is_none() {
|
|
self.kill_task(&task.task_id).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn kill_foreground_commands_by_owner(&self, owner_session_id: &str) {
|
|
let _ = self
|
|
.cmd_tx
|
|
.send(TerminalCommand::KillForegroundCommandsByOwner {
|
|
owner_session_id: owner_session_id.to_string(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn kill_all_background_tasks_by_owner(&self, owner_session_id: &str) {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
if self
|
|
.cmd_tx
|
|
.send(TerminalCommand::KillTasksByOwner {
|
|
owner_session_id: owner_session_id.to_string(),
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
let _ = reply_rx.await;
|
|
}
|
|
|
|
async fn reparent_notifications(
|
|
&self,
|
|
old_owner_session_id: &str,
|
|
new_owner_session_id: &str,
|
|
new_handle: crate::notification::types::ToolNotificationHandle,
|
|
backend_weak: std::sync::Weak<dyn crate::computer::types::TerminalBackend>,
|
|
) {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
// `backend_weak` (anchored by the parent's `Arc`) drives re-spawned
|
|
// pipelines without keeping the backend alive.
|
|
if self
|
|
.cmd_tx
|
|
.send(TerminalCommand::ReparentNotifications {
|
|
old_owner_session_id: old_owner_session_id.to_string(),
|
|
new_owner_session_id: new_owner_session_id.to_string(),
|
|
new_handle,
|
|
backend_weak,
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
// Wait for the actor to process the reparent before returning,
|
|
// so the caller can safely shut down the old session without
|
|
// risking notification loss.
|
|
let _ = reply_rx.await;
|
|
}
|
|
|
|
async fn background_foreground_command(&self, tool_call_id: &str) -> bool {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
if self
|
|
.cmd_tx
|
|
.send(TerminalCommand::BackgroundForeground {
|
|
tool_call_id: tool_call_id.to_string(),
|
|
reply: reply_tx,
|
|
})
|
|
.await
|
|
.is_err()
|
|
{
|
|
return false;
|
|
}
|
|
reply_rx.await.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
/// Non-blocking read: returns `Some(Ok(n))` if data is available,
|
|
/// `Some(Err(e))` on I/O error, `Some(Ok(0))` on EOF, or `None` if
|
|
/// no data is ready right now.
|
|
///
|
|
/// Uses `Waker::noop()` — safe because the actor runs a periodic
|
|
/// polling loop and doesn't need wake-up notifications from the pipe.
|
|
fn try_read_nonblocking(
|
|
reader: &mut (impl tokio::io::AsyncRead + Unpin),
|
|
buf: &mut [u8],
|
|
) -> Option<std::io::Result<usize>> {
|
|
use std::pin::Pin;
|
|
use std::task::{Context, Poll, Waker};
|
|
use tokio::io::ReadBuf;
|
|
|
|
let waker = Waker::noop();
|
|
let mut cx = Context::from_waker(waker);
|
|
let mut read_buf = ReadBuf::new(buf);
|
|
match Pin::new(reader).poll_read(&mut cx, &mut read_buf) {
|
|
Poll::Ready(Ok(())) => Some(Ok(read_buf.filled().len())),
|
|
Poll::Ready(Err(e)) => Some(Err(e)),
|
|
Poll::Pending => None,
|
|
}
|
|
}
|
|
/// Soft-kill the process tree. Non-blocking, fire-and-forget.
|
|
/// The actor's poll loop will reap the process on the next tick.
|
|
fn send_sigterm_to_group(process: &ProcessState) {
|
|
// Unix: skip if child already reaped (avoid pid-reuse race on the
|
|
// stored leader_pid). Windows JobObjects don't have this issue.
|
|
#[cfg(unix)]
|
|
if process.child.id().is_none() {
|
|
return;
|
|
}
|
|
if let Some(pg) = process.process_group.as_ref() {
|
|
let _ = pg.terminate();
|
|
}
|
|
}
|
|
|
|
/// Hard-kill the process tree, plus `start_kill` on the immediate child
|
|
/// as a fallback when group teardown is degraded.
|
|
fn send_sigkill_to_group(process: &mut ProcessState) {
|
|
// Unix: skip if child already reaped (see send_sigterm_to_group).
|
|
#[cfg(unix)]
|
|
if process.child.id().is_none() {
|
|
let _ = process.child.start_kill();
|
|
return;
|
|
}
|
|
if let Some(pg) = process.process_group.as_ref() {
|
|
let _ = pg.kill();
|
|
}
|
|
let _ = process.child.start_kill();
|
|
}
|
|
|
|
/// Drain remaining stdout/stderr into the output buffer and file.
|
|
/// Bounded by `DRAIN_TIMEOUT` so a backgrounded child holding the
|
|
/// pipe open cannot block the actor loop indefinitely.
|
|
async fn drain_remaining_output(process: &mut ProcessState) {
|
|
let timed_out = tokio::time::timeout(DRAIN_TIMEOUT, async {
|
|
if let Some(stdout) = process.child.stdout.as_mut() {
|
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
|
loop {
|
|
match stdout.read(&mut buf).await {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
process.output_buffer.extend_from_slice(&buf[..n]);
|
|
process.total_bytes += n;
|
|
if let Some(ref mut file) = process.file_handle {
|
|
let _ = file.write_all(&buf[..n]).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(stderr) = process.child.stderr.as_mut() {
|
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
|
loop {
|
|
match stderr.read(&mut buf).await {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
process.output_buffer.extend_from_slice(&buf[..n]);
|
|
process.total_bytes += n;
|
|
if let Some(ref mut file) = process.file_handle {
|
|
let _ = file.write_all(&buf[..n]).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
if timed_out.is_err() {
|
|
tracing::debug!(
|
|
command = %process.command,
|
|
"drain timed out after {:?}, a backgrounded child may be holding the pipe open",
|
|
DRAIN_TIMEOUT,
|
|
);
|
|
}
|
|
|
|
// Drop the pipe handles so orphaned children holding them open cannot
|
|
// cause repeated drain timeouts on subsequent poll ticks.
|
|
process.child.stdout.take();
|
|
process.child.stderr.take();
|
|
|
|
process.maybe_truncate();
|
|
}
|
|
|
|
/// Two-phase kill that synchronously waits for the process to exit.
|
|
/// Used ONLY by `kill_and_finalize` (the explicit kill_task API) where
|
|
/// the caller expects the process to be dead when the call returns.
|
|
///
|
|
/// Every `.await` is bounded by a timeout so the actor loop is never
|
|
/// blocked indefinitely.
|
|
async fn graceful_kill_and_wait(process: &mut ProcessState) {
|
|
send_sigterm_to_group(process);
|
|
|
|
if tokio::time::timeout(SIGTERM_GRACE, process.child.wait())
|
|
.await
|
|
.is_ok()
|
|
{
|
|
drain_remaining_output(process).await;
|
|
return;
|
|
}
|
|
|
|
send_sigkill_to_group(process);
|
|
|
|
// Wait for reap — bounded at 5s. SIGKILL is unconditional so this
|
|
// almost always returns instantly. The cap protects against D-state
|
|
// (uninterruptible kernel I/O, e.g. NFS hang). If it times out,
|
|
// abandon — poll_process will pick it up later.
|
|
const SIGKILL_REAP_TIMEOUT: Duration = Duration::from_secs(5);
|
|
if tokio::time::timeout(SIGKILL_REAP_TIMEOUT, process.child.wait())
|
|
.await
|
|
.is_err()
|
|
{
|
|
tracing::warn!(
|
|
pid = ?process.child.id(),
|
|
"Process did not exit after SIGKILL within {:?}, \
|
|
abandoning reap — poll loop will pick it up",
|
|
SIGKILL_REAP_TIMEOUT
|
|
);
|
|
}
|
|
|
|
drain_remaining_output(process).await;
|
|
}
|
|
|
|
#[tracing::instrument(
|
|
name = "terminal.kill_and_finalize",
|
|
skip_all,
|
|
fields(pid = process.child.id())
|
|
)]
|
|
async fn kill_and_finalize(process: &mut ProcessState) -> KillOutcome {
|
|
// Already reaped between the caller's check and here (race with poll_process)
|
|
if process.exit_status.is_some() {
|
|
return KillOutcome::AlreadyExited;
|
|
}
|
|
|
|
// Fast path: process already exited on its own.
|
|
match process.child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
drain_remaining_output(process).await;
|
|
finalize_process(process, Some(status)).await;
|
|
return KillOutcome::AlreadyExited;
|
|
}
|
|
Err(_) => {
|
|
drain_remaining_output(process).await;
|
|
finalize_process(process, None).await;
|
|
return KillOutcome::AlreadyExited;
|
|
}
|
|
// Still running, proceed to kill.
|
|
Ok(None) => {}
|
|
}
|
|
|
|
graceful_kill_and_wait(process).await;
|
|
|
|
// The child is reaped now, so drop the scope's reaping handle immediately
|
|
// rather than waiting for the next poll sweep — closes the window where a
|
|
// racing kill_all() could killpg the (now reused) pid. Guarded like the
|
|
// sweep: if a D-state reap was abandoned (child.id() still Some), keep the
|
|
// Arc so the poll loop can still reap it later.
|
|
#[cfg(unix)]
|
|
if process.child.id().is_none() {
|
|
process.process_group = None;
|
|
}
|
|
|
|
// Abort the state dump reader so its spawn_blocking thread doesn't
|
|
// leak (same rationale as kill_foreground_commands).
|
|
if let Some(handle) = process.state_dump_handle.take() {
|
|
handle.abort();
|
|
}
|
|
|
|
finalize_process(process, None).await;
|
|
KillOutcome::Killed
|
|
}
|
|
|
|
async fn finalize_process(process: &mut ProcessState, status: Option<std::process::ExitStatus>) {
|
|
if process.exit_status.is_some() {
|
|
return;
|
|
}
|
|
|
|
process.exit_status = Some(match status {
|
|
Some(s) => extract_exit_status(s),
|
|
None => ExitStatus {
|
|
exit_code: None,
|
|
signal: Some("killed".to_owned()),
|
|
},
|
|
});
|
|
if process.end_wall_time.is_none() {
|
|
process.end_wall_time = Some(std::time::SystemTime::now());
|
|
}
|
|
|
|
process.flush_and_truncate_output_file().await;
|
|
|
|
let result = Ok(process.to_result());
|
|
process.notify_waiters(result);
|
|
}
|
|
|
|
/// Open an output file for writing, creating parent directories if needed.
|
|
#[tracing::instrument(name = "fs.open_output_file", skip_all)]
|
|
async fn open_output_file(path: &std::path::Path) -> std::io::Result<File> {
|
|
if let Some(parent) = path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
|
|
tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(path)
|
|
.await
|
|
}
|
|
|
|
/// Capture the user's login-shell PATH so CLI tools from rc files are discoverable.
|
|
///
|
|
/// Non-interactive shells (`/bin/bash -c`) don't source rc files, so tools
|
|
/// installed via `.bashrc`/`.zshrc`/virtualenvs are invisible. This runs the
|
|
/// detected shell with `-lc` plus an explicit `source` of the rc file, extracts
|
|
/// PATH using SOH byte markers, and merges it with the current process PATH.
|
|
///
|
|
/// Returns a `HashMap` with a single `PATH` key, or an empty map on failure.
|
|
/// A 5-second timeout kills the child if rc files hang (conda init, nvm, etc.).
|
|
#[cfg(unix)]
|
|
async fn capture_login_path() -> HashMap<String, String> {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let shell = shell_state::ShellKind::detect();
|
|
let rc_file = shell.rc_file_name();
|
|
|
|
// Use $HOME inside the script (not interpolated from Rust) to avoid
|
|
// shell injection if HOME contains special characters.
|
|
let script = format!("source \"$HOME/{rc_file}\" 2>/dev/null; printf '\\x01%s\\x01' \"$PATH\"");
|
|
|
|
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
|
let mut cmd = tokio::process::Command::new(shell.binary_path());
|
|
cmd.args(["-lc", &script])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.kill_on_drop(true);
|
|
crate::util::detach_command(&mut cmd);
|
|
cmd.envs(crate::util::pager_env());
|
|
let mut child = cmd.spawn().ok()?;
|
|
|
|
let mut stdout_buf = Vec::new();
|
|
if let Some(ref mut stdout) = child.stdout {
|
|
stdout.read_to_end(&mut stdout_buf).await.ok();
|
|
}
|
|
|
|
let status = child.wait().await.ok()?;
|
|
if !status.success() {
|
|
return None;
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&stdout_buf);
|
|
let parts: Vec<&str> = stdout.split('\x01').collect();
|
|
let login_path = (parts.len() >= 3).then(|| parts[1].trim())?;
|
|
|
|
if login_path.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Merge: login PATH first, then current-process entries not already present.
|
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
|
let mut seen = std::collections::HashSet::new();
|
|
let merged: Vec<&str> = login_path
|
|
.split(':')
|
|
.chain(current_path.split(':'))
|
|
.filter(|e| !e.is_empty() && seen.insert(*e))
|
|
.collect();
|
|
|
|
Some(merged.join(":"))
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Some(path)) => HashMap::from([("PATH".to_string(), path)]),
|
|
Ok(None) => HashMap::new(),
|
|
Err(_) => {
|
|
tracing::warn!("login-shell PATH capture timed out after 5s");
|
|
HashMap::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn the shell command and attach the child to a [`ProcessGroup`].
|
|
///
|
|
/// The returned `ProcessGroup` is what the teardown helpers
|
|
/// ([`send_sigterm_to_group`], [`send_sigkill_to_group`]) dispatch to:
|
|
/// `killpg` on Unix; `TerminateJobObject` on Windows. This gives
|
|
/// grandchild teardown for fan-out workloads (npm install, git clone,
|
|
/// cargo build) on both platforms.
|
|
fn spawn_shell_command(
|
|
command: &str,
|
|
cwd: &std::path::Path,
|
|
env: &HashMap<String, String>,
|
|
login_env: Option<&HashMap<String, String>>,
|
|
search_shadows: SearchShadowConfig,
|
|
) -> std::io::Result<(tokio::process::Child, crate::util::ProcessGroup)> {
|
|
// `login_env` and `search_shadows` are only consumed by the `#[cfg(unix)]`
|
|
// shell wrapper below; keep them live on Windows to avoid unused-arg warnings.
|
|
#[cfg(not(unix))]
|
|
let _ = (&login_env, &search_shadows);
|
|
#[cfg(unix)]
|
|
let mut cmd = {
|
|
let shell = shell_state::ShellKind::detect();
|
|
let wrapped_command = {
|
|
let inject = super::embedded_search_tools::search_injection(search_shadows);
|
|
if inject.is_empty() {
|
|
command.to_string()
|
|
} else {
|
|
format!("{inject}{command}")
|
|
}
|
|
};
|
|
let mut cmd = tokio::process::Command::new(shell.binary_path());
|
|
// Non-interactive zsh still defaults to NOMATCH; pass via argv like init's -o extendedglob.
|
|
if matches!(shell, shell_state::ShellKind::Zsh) {
|
|
cmd.arg("-o").arg("nonomatch");
|
|
}
|
|
cmd.arg("-c")
|
|
.arg(&wrapped_command)
|
|
.current_dir(cwd)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
// NOTE: do NOT set .process_group(0) here — std runs setpgid()
|
|
// BEFORE pre_exec hooks, which would make the child a process
|
|
// group leader and cause setsid() to fail with EPERM.
|
|
// detach_from_tty() handles both session and process group creation.
|
|
.kill_on_drop(true);
|
|
|
|
// Request env carries .envrc, color vars, ACP-provided vars.
|
|
cmd.envs(shell_state::shell_env_overrides());
|
|
for (key, value) in env {
|
|
cmd.env(key, value);
|
|
}
|
|
cmd.envs(crate::util::pager_env());
|
|
|
|
// Inject the user's login-shell PATH LAST so tools installed via rc
|
|
// files (.bashrc, .zshrc, virtualenvs) are always discoverable. The
|
|
// request env often carries a copy of the parent process's PATH which
|
|
// doesn't include rc-file additions — applying login PATH after the
|
|
// request env ensures those additions aren't clobbered.
|
|
if let Some(login) = login_env {
|
|
cmd.envs(login);
|
|
}
|
|
// Agent marker must win over request/login env.
|
|
crate::util::apply_kigi_agent_marker(&mut cmd);
|
|
|
|
// Detach from the controlling terminal so subprocesses cannot open
|
|
// /dev/tty and compete with the TUI for terminal input.
|
|
crate::util::detach_command(&mut cmd);
|
|
|
|
// If the sandbox profile restricts network, install a seccomp BPF
|
|
// filter on the child that blocks connect/bind/sendto/listen/accept.
|
|
// The parent (kigi) process retains network for the LLM API.
|
|
// Filesystem restrictions are already inherited from the process-level
|
|
// Landlock/Seatbelt sandbox — no action needed here for FS.
|
|
#[cfg(target_os = "linux")]
|
|
if kigi_sandbox::should_restrict_child_network() {
|
|
unsafe {
|
|
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
|
|
}
|
|
}
|
|
cmd
|
|
};
|
|
|
|
#[cfg(not(unix))]
|
|
let mut build_cmd = |with_breakaway: bool| {
|
|
use windows::Win32::System::Threading::{
|
|
CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW,
|
|
};
|
|
|
|
let inv = kigi_config::shell::shell_command_argv(command);
|
|
let mut cmd = tokio::process::Command::new(&inv.program);
|
|
cmd.args(&inv.args)
|
|
.envs(inv.env)
|
|
.current_dir(cwd)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
|
|
for (key, value) in env {
|
|
cmd.env(key, value);
|
|
}
|
|
cmd.envs(crate::util::pager_env());
|
|
// Agent marker must win over request env.
|
|
crate::util::apply_kigi_agent_marker(&mut cmd);
|
|
|
|
// Set creation flags inline rather than via crate::util::detach_command
|
|
// + new_process_group: tokio's creation_flags is a SET, not OR, so
|
|
// the helpers don't compose.
|
|
// - CREATE_NO_WINDOW: no console window pops up.
|
|
// - CREATE_NEW_PROCESS_GROUP: child can receive its own Ctrl+Break.
|
|
// - CREATE_BREAKAWAY_FROM_JOB: lets the child escape any inherited
|
|
// job so we can assign it to our own ProcessGroup. Per Microsoft
|
|
// docs this *fails CreateProcess with ERROR_ACCESS_DENIED* (os
|
|
// error 5) when the parent process is in a job that does not
|
|
// have JOB_OBJECT_LIMIT_BREAKAWAY_OK set — common when kigi-agent
|
|
// is launched under a Windows service, scheduled task, or some
|
|
// ACP host wrappers. The caller below retries without this flag
|
|
// on os error 5.
|
|
let mut flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP;
|
|
if with_breakaway {
|
|
flags |= CREATE_BREAKAWAY_FROM_JOB;
|
|
}
|
|
cmd.creation_flags(flags.0);
|
|
cmd
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
let mut group = crate::util::ProcessGroup::new()?;
|
|
#[cfg(unix)]
|
|
let child = cmd.spawn()?;
|
|
|
|
#[cfg(not(unix))]
|
|
let (child, mut group) = {
|
|
let group = crate::util::ProcessGroup::new()?;
|
|
let mut cmd = build_cmd(true);
|
|
match cmd.spawn() {
|
|
Ok(child) => (child, group),
|
|
Err(e) if e.raw_os_error() == Some(5) => {
|
|
// Parent's containing Job Object does not allow breakaway.
|
|
// Retry without CREATE_BREAKAWAY_FROM_JOB. We lose the
|
|
// ability to assign the child to our own job (so the
|
|
// attach() below will also fail), but the command runs.
|
|
// kill_on_drop + child.kill() still terminate the immediate
|
|
// child via TerminateProcess.
|
|
tracing::debug!(
|
|
"spawn with CREATE_BREAKAWAY_FROM_JOB returned ERROR_ACCESS_DENIED; \
|
|
retrying without breakaway (process-tree teardown disabled for this child)"
|
|
);
|
|
drop(cmd);
|
|
let mut cmd = build_cmd(false);
|
|
let child = cmd.spawn()?;
|
|
(child, group)
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
};
|
|
|
|
if let Err(e) = group.attach(&child) {
|
|
tracing::debug!("Failed to attach child to ProcessGroup: {e}");
|
|
}
|
|
Ok((child, group))
|
|
}
|
|
|
|
fn extract_exit_status(status: std::process::ExitStatus) -> ExitStatus {
|
|
let exit_code = status.code();
|
|
|
|
#[cfg(unix)]
|
|
let signal = {
|
|
use std::os::unix::process::ExitStatusExt;
|
|
status.signal().map(|s| format!("signal {}", s))
|
|
};
|
|
|
|
#[cfg(not(unix))]
|
|
let signal: Option<String> = None;
|
|
|
|
ExitStatus { exit_code, signal }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::computer::types::TaskKind;
|
|
use std::path::PathBuf;
|
|
|
|
fn make_request(command: &str) -> TerminalRunRequest {
|
|
let output_file = std::env::temp_dir().join(format!(
|
|
"terminal-test-{}-{}.out",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
|
|
TerminalRunRequest {
|
|
command: command.to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 10000,
|
|
output_file,
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
}
|
|
}
|
|
|
|
/// Poll `get_task` every 25ms until the task reports `completed`, returning
|
|
/// `false` if `timeout` elapses first. Lets callers keep a bespoke assert
|
|
/// message while sharing the poll-until-reaped boilerplate.
|
|
async fn poll_until_task_completed(
|
|
backend: &LocalTerminalBackend,
|
|
task_id: &str,
|
|
timeout: Duration,
|
|
) -> bool {
|
|
let deadline = std::time::Instant::now() + timeout;
|
|
loop {
|
|
if backend
|
|
.get_task(task_id)
|
|
.await
|
|
.map(|s| s.completed)
|
|
.unwrap_or(false)
|
|
{
|
|
return true;
|
|
}
|
|
if std::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "flaky: combined_output is sometimes empty in CI"]
|
|
async fn test_simple_command() {
|
|
let backend = LocalTerminalBackend::new();
|
|
let request = make_request("echo hello");
|
|
let output_file = request.output_file.clone();
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
|
|
assert_eq!(result.combined_output.trim(), "hello");
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(!result.timed_out);
|
|
|
|
// Verify output was written to file
|
|
let file_content = tokio::fs::read_to_string(&output_file).await.unwrap();
|
|
assert_eq!(file_content.trim(), "hello");
|
|
|
|
// Cleanup
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_command_with_exit_code() {
|
|
let backend = LocalTerminalBackend::new();
|
|
let result = backend.run(make_request("exit 42")).await.unwrap();
|
|
|
|
assert_eq!(result.exit_code, Some(42));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_timeout() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let output_file =
|
|
std::env::temp_dir().join(format!("terminal-test-timeout-{}.out", std::process::id()));
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_millis(200),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-timeout".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert!(result.timed_out);
|
|
|
|
// Cleanup
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_auto_background_on_timeout() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let output_file =
|
|
std::env::temp_dir().join(format!("terminal-test-auto-bg-{}.out", std::process::id()));
|
|
let tool_call_id = "test-auto-bg";
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_millis(500),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: tool_call_id.to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: true,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
|
|
// Should NOT be marked timed_out — it was auto-backgrounded instead.
|
|
assert!(
|
|
!result.timed_out,
|
|
"auto-backgrounded result must not be timed_out"
|
|
);
|
|
assert_eq!(
|
|
result.signal.as_deref(),
|
|
Some("auto_backgrounded"),
|
|
"signal should be auto_backgrounded, got {:?}",
|
|
result.signal
|
|
);
|
|
|
|
// The process should be accessible via get_task under the tool_call_id.
|
|
let snapshot = backend
|
|
.get_task(tool_call_id)
|
|
.await
|
|
.expect("auto-backgrounded task should be queryable by tool_call_id");
|
|
assert!(
|
|
!snapshot.completed,
|
|
"auto-backgrounded process should still be running"
|
|
);
|
|
|
|
// Cleanup: kill the background task so the test doesn't leak.
|
|
let outcome = backend.kill_task(tool_call_id).await;
|
|
assert!(
|
|
matches!(outcome, KillOutcome::Killed | KillOutcome::AlreadyExited),
|
|
"kill after auto-bg should succeed: {outcome:?}"
|
|
);
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
// Huge timeout (1h) + tiny budget: the budget, not the timeout, backgrounds
|
|
// an auto-backgroundable command.
|
|
#[tokio::test]
|
|
async fn test_foreground_block_budget_backgrounds_before_timeout() {
|
|
// Serialize flag-asserting tests; opt into the guards for this one.
|
|
let backend = LocalTerminalBackend::new_with_foreground_budget(Duration::from_millis(300));
|
|
|
|
let output_file = std::env::temp_dir().join(format!(
|
|
"terminal-test-fg-budget-{}.out",
|
|
std::process::id()
|
|
));
|
|
let tool_call_id = "test-fg-budget";
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
// 1h timeout, far longer than the 300ms budget.
|
|
timeout: Duration::from_secs(3600),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: tool_call_id.to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: true,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let start = Instant::now();
|
|
let result = backend.run(request).await.unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
// Backgrounded by the budget, not killed by the timeout.
|
|
assert!(
|
|
!result.timed_out,
|
|
"budget-backgrounded result must not be timed_out"
|
|
);
|
|
assert_eq!(
|
|
result.signal.as_deref(),
|
|
Some("auto_backgrounded"),
|
|
"signal should be auto_backgrounded, got {:?}",
|
|
result.signal
|
|
);
|
|
// It returned on the budget (~300ms), nowhere near the 1h timeout.
|
|
assert!(
|
|
elapsed < Duration::from_secs(10),
|
|
"should background on the budget, not block on the timeout (took {elapsed:?})"
|
|
);
|
|
|
|
// Still running in the background under the tool_call_id.
|
|
let snapshot = backend
|
|
.get_task(tool_call_id)
|
|
.await
|
|
.expect("budget-backgrounded task should be queryable by tool_call_id");
|
|
assert!(
|
|
!snapshot.completed,
|
|
"budget-backgrounded process should still be running"
|
|
);
|
|
|
|
// Cleanup: kill the background task so the test doesn't leak.
|
|
let outcome = backend.kill_task(tool_call_id).await;
|
|
assert!(
|
|
matches!(outcome, KillOutcome::Killed | KillOutcome::AlreadyExited),
|
|
"kill after budget-bg should succeed: {outcome:?}"
|
|
);
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
// A non-backgroundable command must NOT be affected by the budget — it
|
|
// keeps its requested `timeout` as the sole (kill) deadline.
|
|
#[tokio::test]
|
|
async fn test_foreground_block_budget_skips_non_backgroundable() {
|
|
// Serialize flag-asserting tests; opt in so the "skip" is attributable
|
|
// to the command being non-backgroundable, not to the master switch.
|
|
let backend = LocalTerminalBackend::new_with_foreground_budget(Duration::from_millis(300));
|
|
|
|
let output_file = std::env::temp_dir().join(format!(
|
|
"terminal-test-fg-budget-skip-{}.out",
|
|
std::process::id()
|
|
));
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
// Short timeout so the test is fast; auto_bg is OFF so the budget
|
|
// must be ignored and the command killed on the timeout instead.
|
|
timeout: Duration::from_millis(500),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-fg-budget-skip".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
|
|
// Killed by the timeout — NOT backgrounded by the budget.
|
|
assert!(
|
|
result.timed_out,
|
|
"non-backgroundable command should time out, not background"
|
|
);
|
|
assert_ne!(
|
|
result.signal.as_deref(),
|
|
Some("auto_backgrounded"),
|
|
"non-backgroundable command must not be auto-backgrounded by the budget"
|
|
);
|
|
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
// Per-request FG budget overrides the backend default (and can disable the
|
|
// short budget via Duration::MAX so only `timeout` auto-bgs).
|
|
#[tokio::test]
|
|
async fn test_per_request_foreground_block_budget_overrides_backend() {
|
|
// Backend default is 10s — request overrides to 300ms.
|
|
let backend = LocalTerminalBackend::new_with_foreground_budget(Duration::from_secs(10));
|
|
|
|
let output_file = std::env::temp_dir().join(format!(
|
|
"terminal-test-per-req-budget-{}.out",
|
|
std::process::id()
|
|
));
|
|
let tool_call_id = "test-per-req-budget";
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(3600),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: tool_call_id.to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: true,
|
|
// Per-request 300ms budget wins over backend's 10s.
|
|
foreground_block_budget: Some(Duration::from_millis(300)),
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let start = Instant::now();
|
|
let result = backend.run(request).await.unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(
|
|
!result.timed_out,
|
|
"per-request budget should auto-bg, not kill"
|
|
);
|
|
assert_eq!(result.signal.as_deref(), Some("auto_backgrounded"));
|
|
assert!(
|
|
elapsed < Duration::from_secs(5),
|
|
"should fire on per-request 300ms budget, not backend 10s (took {elapsed:?})"
|
|
);
|
|
|
|
let outcome = backend.kill_task(tool_call_id).await;
|
|
assert!(matches!(
|
|
outcome,
|
|
KillOutcome::Killed | KillOutcome::AlreadyExited
|
|
));
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
// `foreground_block_budget: Some(Duration::MAX)` disables the short budget:
|
|
// auto-bg only when the request timeout elapses.
|
|
#[tokio::test]
|
|
async fn test_duration_max_budget_waits_for_timeout_only() {
|
|
let backend = LocalTerminalBackend::new_with_foreground_budget(Duration::from_millis(100));
|
|
|
|
let output_file = std::env::temp_dir().join(format!(
|
|
"terminal-test-max-budget-{}.out",
|
|
std::process::id()
|
|
));
|
|
let tool_call_id = "test-max-budget";
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
// ~800ms kill/auto-bg timeout — short budget is disabled.
|
|
timeout: Duration::from_millis(800),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: tool_call_id.to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: true,
|
|
foreground_block_budget: Some(Duration::MAX),
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let start = Instant::now();
|
|
let result = backend.run(request).await.unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert_eq!(result.signal.as_deref(), Some("auto_backgrounded"));
|
|
// Must wait past the short backend budget (100ms) and near the timeout.
|
|
assert!(
|
|
elapsed >= Duration::from_millis(500),
|
|
"Duration::MAX budget must not short-circuit at 100ms backend default (took {elapsed:?})"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_secs(10),
|
|
"should auto-bg around the 800ms timeout (took {elapsed:?})"
|
|
);
|
|
|
|
let outcome = backend.kill_task(tool_call_id).await;
|
|
assert!(matches!(
|
|
outcome,
|
|
KillOutcome::Killed | KillOutcome::AlreadyExited
|
|
));
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
// Runaway-output guard: a stdout flood is killed once the output file
|
|
// passes the size cap, independent of its timeout.
|
|
#[tokio::test]
|
|
async fn test_output_size_guard_kills_runaway() {
|
|
// Serialize flag-asserting tests; opt into the guards for this one.
|
|
// Tiny cap so `yes` trips it within a tick or two.
|
|
let backend = LocalTerminalBackend::new_with_output_cap(2_000);
|
|
|
|
let output_file =
|
|
std::env::temp_dir().join(format!("terminal-test-size-{}.out", std::process::id()));
|
|
|
|
let request = TerminalRunRequest {
|
|
// `yes` floods stdout forever.
|
|
command: "yes".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
// Long timeout: the SIZE guard, not the timeout, must fire.
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 10_000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-size".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
|
|
assert_eq!(
|
|
result.signal.as_deref(),
|
|
Some("output_limit"),
|
|
"runaway output should be killed by the size guard, got {:?}",
|
|
result.signal
|
|
);
|
|
assert!(!result.timed_out, "size kill is not a timeout");
|
|
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_stderr_captured() {
|
|
let backend = LocalTerminalBackend::new();
|
|
let result = backend.run(make_request("echo error >&2")).await.unwrap();
|
|
|
|
assert!(result.combined_output.contains("error"));
|
|
assert_eq!(result.exit_code, Some(0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "flaky: combined_output is sometimes empty in CI"]
|
|
async fn test_multiple_commands() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let result1 = backend.run(make_request("echo first")).await.unwrap();
|
|
let result2 = backend.run(make_request("echo second")).await.unwrap();
|
|
|
|
assert_eq!(result1.combined_output.trim(), "first");
|
|
assert_eq!(result2.combined_output.trim(), "second");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "flaky: output file content sometimes not flushed in CI"]
|
|
async fn test_output_file_written() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let output_file =
|
|
std::env::temp_dir().join(format!("terminal-test-file-{}.out", std::process::id()));
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "echo 'line1'; echo 'line2'; echo 'line3'".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-output-file".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
// Verify file contains all output
|
|
let file_content = tokio::fs::read_to_string(&output_file).await.unwrap();
|
|
assert!(file_content.contains("line1"));
|
|
assert!(file_content.contains("line2"));
|
|
assert!(file_content.contains("line3"));
|
|
|
|
// Cleanup
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_background_and_get_task() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let output_file =
|
|
std::env::temp_dir().join(format!("terminal-test-bg-{}.out", std::process::id()));
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "echo background_test && sleep 0.1".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-bg".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
// Start background task
|
|
let handle = backend.run_background(request).await.unwrap();
|
|
assert!(!handle.task_id.is_empty());
|
|
|
|
// Wait for completion
|
|
let snapshot = backend
|
|
.wait_for_completion(&handle.task_id, Some(Duration::from_secs(5)))
|
|
.await;
|
|
assert!(snapshot.is_some());
|
|
let snapshot = snapshot.unwrap();
|
|
assert!(snapshot.completed);
|
|
assert_eq!(snapshot.exit_code, Some(0));
|
|
|
|
// Cleanup
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kill_background_task() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let output_file =
|
|
std::env::temp_dir().join(format!("terminal-test-kill-{}.out", std::process::id()));
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(300),
|
|
output_byte_limit: 10000,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-kill".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let handle = backend.run_background(request).await.unwrap();
|
|
|
|
// Kill it
|
|
let outcome = backend.kill_task(&handle.task_id).await;
|
|
assert_eq!(outcome, KillOutcome::Killed);
|
|
|
|
// Cleanup
|
|
let _ = tokio::fs::remove_file(&output_file).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn chunk_notifications_sent_during_execution() {
|
|
// Create a real notification channel (not noop)
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let handle = ToolNotificationHandle::from_sender(tx);
|
|
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
// Command that produces output over time (not all at once)
|
|
command: "for i in 1 2 3; do echo chunk_$i; sleep 0.15; done".to_string(),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(5),
|
|
output_byte_limit: 1024 * 1024,
|
|
output_file: tmp.path().join("output.log"),
|
|
notification_handle: handle,
|
|
tool_call_id: "test-call-123".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
// Drain the notification channel
|
|
let mut chunks = vec![];
|
|
while let Ok(notification) = rx.try_recv() {
|
|
if let crate::notification::types::ToolNotification::BashOutputChunk(chunk) =
|
|
notification
|
|
{
|
|
chunks.push(chunk);
|
|
}
|
|
}
|
|
|
|
// Should have received at least 2 chunks: the initial empty notification
|
|
// plus at least one with output (command runs ~450ms with 100ms ticks)
|
|
assert!(
|
|
chunks.len() >= 2,
|
|
"Expected at least 2 BashOutputChunk notifications (initial + output), got {}",
|
|
chunks.len()
|
|
);
|
|
|
|
// The first chunk is the initial empty notification (sent before any output)
|
|
let initial = &chunks[0];
|
|
assert_eq!(initial.base.tool_call_id, "test-call-123");
|
|
assert!(!initial.base.command.is_empty());
|
|
assert!(
|
|
initial.base.output.is_empty(),
|
|
"Initial chunk should have empty output"
|
|
);
|
|
|
|
// Subsequent chunks should have output
|
|
let first_with_output = &chunks[1];
|
|
assert!(!first_with_output.base.output.is_empty());
|
|
|
|
// Later chunks should have more output than earlier ones
|
|
assert!(
|
|
chunks.last().unwrap().base.output.len() >= first_with_output.base.output.len(),
|
|
"Output should accumulate across chunks"
|
|
);
|
|
|
|
// The final output should contain all chunks
|
|
assert!(result.combined_output.contains("chunk_1"));
|
|
assert!(result.combined_output.contains("chunk_3"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn chunk_notifications_keep_flowing_after_truncation() {
|
|
// Regression guard for the emission gate fix: the gate is keyed off the
|
|
// monotonic `total_bytes`, not `output_buffer.len()`. Before the fix the
|
|
// length-based gate went false once `maybe_truncate` started shrinking
|
|
// the tail, starving the stream of chunks for the rest of a long command.
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let handle = ToolNotificationHandle::from_sender(tx);
|
|
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
// ~1.8 KB of ASCII over ~1.8s; far exceeds the 200-char limit so
|
|
// truncation fires early and keeps firing on the shrinking tail.
|
|
command: "for i in $(seq 1 60); do printf 'LINE%03d-XXXXXXXXXXXXXXXXXXXX\\n' \"$i\"; sleep 0.03; done".to_string(),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(10),
|
|
output_byte_limit: 200,
|
|
output_file: tmp.path().join("output.log"),
|
|
notification_handle: handle,
|
|
tool_call_id: "trunc-call".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(result.truncated, "expected output to be truncated");
|
|
|
|
let mut chunks = vec![];
|
|
while let Ok(notification) = rx.try_recv() {
|
|
if let crate::notification::types::ToolNotification::BashOutputChunk(chunk) =
|
|
notification
|
|
{
|
|
chunks.push(chunk);
|
|
}
|
|
}
|
|
|
|
// total_bytes is monotonically non-decreasing across all chunks.
|
|
for w in chunks.windows(2) {
|
|
assert!(
|
|
w[1].base.total_bytes >= w[0].base.total_bytes,
|
|
"total_bytes regressed: {} < {}",
|
|
w[1].base.total_bytes,
|
|
w[0].base.total_bytes
|
|
);
|
|
}
|
|
|
|
// Truncation must surface, and chunks must keep arriving afterwards.
|
|
let first_truncated = chunks
|
|
.iter()
|
|
.position(|c| c.base.truncated)
|
|
.expect("expected at least one truncated chunk past the byte limit");
|
|
assert!(
|
|
first_truncated < chunks.len() - 1,
|
|
"chunks stopped after truncation (first_truncated={first_truncated}, total={})",
|
|
chunks.len()
|
|
);
|
|
// And the buffer length oscillates around the limit while total_bytes
|
|
// keeps climbing — proving the gate is total-based, not length-based.
|
|
assert!(
|
|
chunks.last().unwrap().base.total_bytes > 200,
|
|
"expected total_bytes to exceed the byte limit"
|
|
);
|
|
}
|
|
|
|
/// Size guard must kill runaway writers and bound the output file.
|
|
#[tokio::test]
|
|
async fn output_file_capped_by_size_guard() {
|
|
let cap: u64 = 5000;
|
|
let output_amount = cap * 1000;
|
|
let backend = LocalTerminalBackend::new_with_output_cap(cap);
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let output_file = tmp.path().join("output.log");
|
|
|
|
let request = TerminalRunRequest {
|
|
command: format!("head -c {output_amount} /dev/zero | tr '\\0' 'x'"),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 1024,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "cap-test".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert_eq!(result.signal.as_deref(), Some("output_limit"));
|
|
|
|
let file_size = tokio::fs::metadata(&output_file).await.unwrap().len();
|
|
// Guard fires on 100ms tick — some overshoot expected (up to ~512 KB
|
|
// on arm64 CI). Without the guard the file would be `output_amount`.
|
|
assert!(
|
|
file_size < output_amount / 2,
|
|
"output file should be bounded by size guard, got {file_size} bytes (cap={cap})"
|
|
);
|
|
}
|
|
|
|
/// Verify retention cap constant and that small output is not truncated.
|
|
#[tokio::test]
|
|
async fn output_file_truncated_after_exit() {
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let output_file = tmp.path().join("output.log");
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "head -c 200000 /dev/zero | tr '\\0' 'x'".to_string(),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(10),
|
|
output_byte_limit: 1024,
|
|
output_file: output_file.clone(),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "trunc-exit-test".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
// Output is under 64 MB, so file should have full content (no truncation).
|
|
let file_size = tokio::fs::metadata(&output_file).await.unwrap().len();
|
|
assert!(
|
|
file_size >= 190_000,
|
|
"output file should have full ~200 KB, got {file_size}"
|
|
);
|
|
// Verify the retention cap constant is reasonable.
|
|
assert_eq!(MAX_RETAINED_OUTPUT_FILE_BYTES, 64 * 1024 * 1024);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_chunks_when_noop_handle() {
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "echo hello".to_string(),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(5),
|
|
output_byte_limit: 1024 * 1024,
|
|
output_file: tmp.path().join("output.log"),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-call".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
// noop() drops the receiver — send() is a silent no-op.
|
|
// This confirms no panic/crash when nobody is listening.
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn chunk_tool_call_id_matches_request() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let handle = ToolNotificationHandle::from_sender(tx);
|
|
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "echo test; sleep 0.15; echo done".to_string(),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(5),
|
|
output_byte_limit: 1024 * 1024,
|
|
output_file: tmp.path().join("output.log"),
|
|
notification_handle: handle,
|
|
tool_call_id: "unique-id-abc".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
backend.run(request).await.unwrap();
|
|
|
|
while let Ok(notification) = rx.try_recv() {
|
|
if let crate::notification::types::ToolNotification::BashOutputChunk(chunk) =
|
|
notification
|
|
{
|
|
assert_eq!(
|
|
chunk.base.tool_call_id, "unique-id-abc",
|
|
"Every chunk must carry the request's tool_call_id"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_chunk_sent_when_no_new_output() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let handle = ToolNotificationHandle::from_sender(tx);
|
|
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
// Command that outputs once then sleeps (3 idle ticks with no new output)
|
|
let request = TerminalRunRequest {
|
|
command: "echo once; sleep 0.5".to_string(),
|
|
working_directory: tmp.path().to_path_buf(),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(5),
|
|
output_byte_limit: 1024 * 1024,
|
|
output_file: tmp.path().join("output.log"),
|
|
notification_handle: handle,
|
|
tool_call_id: "test-idle".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
backend.run(request).await.unwrap();
|
|
|
|
let mut chunks = vec![];
|
|
while let Ok(notification) = rx.try_recv() {
|
|
if let crate::notification::types::ToolNotification::BashOutputChunk(chunk) =
|
|
notification
|
|
{
|
|
chunks.push(chunk);
|
|
}
|
|
}
|
|
|
|
// Should have chunks, but NOT one per tick — idle ticks should be skipped.
|
|
// The command outputs "once\n" early then sleeps 500ms (5 ticks).
|
|
// We should see 1-2 chunks, not 5.
|
|
assert!(
|
|
chunks.len() <= 3,
|
|
"Expected at most 3 chunks (not one per idle tick), got {}",
|
|
chunks.len()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_timeout_uses_sigterm_then_sigkill() {
|
|
// Run a command with a short timeout. Verify the result has timed_out == true
|
|
// and the process is dead.
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_millis(200),
|
|
output_byte_limit: 10000,
|
|
output_file: tmp.path().join("timeout-test.out"),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-timeout-graceful".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert!(result.timed_out);
|
|
assert_eq!(result.signal.as_deref(), Some("timeout"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "flaky: output buffer sometimes not flushed before kill completes"]
|
|
async fn test_output_preserved_on_kill() {
|
|
// Spawn a command that produces output then sleeps.
|
|
// Kill it and verify the output before the kill is preserved.
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "echo before_kill; sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 10000,
|
|
output_file: tmp.path().join("kill-output.out"),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-kill-output".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let handle = backend.run_background(request).await.unwrap();
|
|
|
|
// Give it time to produce output
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
// Kill it
|
|
let outcome = backend.kill_task(&handle.task_id).await;
|
|
assert_eq!(outcome, KillOutcome::Killed);
|
|
|
|
// Check the snapshot has the output
|
|
let snapshot = backend.get_task(&handle.task_id).await;
|
|
if let Some(snapshot) = snapshot {
|
|
assert!(
|
|
snapshot.output.contains("before_kill"),
|
|
"Output should contain 'before_kill', got: {:?}",
|
|
snapshot.output
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_output_preserved_on_timeout() {
|
|
// Verify output captured before timeout is preserved in the result.
|
|
// Uses 2s timeout (not 500ms) so the poll loop has enough ticks to
|
|
// read the echo before the timeout handler snapshots the buffer.
|
|
let backend = LocalTerminalBackend::new();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
let request = TerminalRunRequest {
|
|
command: "echo before_timeout; sleep 60".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(2),
|
|
output_byte_limit: 10000,
|
|
output_file: tmp.path().join("timeout-output.out"),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-timeout-output".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let result = backend.run(request).await.unwrap();
|
|
assert!(result.timed_out);
|
|
assert!(
|
|
result.combined_output.contains("before_timeout"),
|
|
"Timed-out output should contain 'before_timeout', got: {:?}",
|
|
result.combined_output
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_background_child_with_inherited_pipe_does_not_block() {
|
|
// `sleep 300 &` inherits the pipe — without drain timeout this blocks forever.
|
|
let backend = LocalTerminalBackend::new();
|
|
let request = TerminalRunRequest {
|
|
command: "sleep 300 &\nsleep 1\necho done".to_string(),
|
|
working_directory: PathBuf::from("/tmp"),
|
|
env: HashMap::new(),
|
|
timeout: Duration::from_secs(30),
|
|
output_byte_limit: 10000,
|
|
output_file: std::env::temp_dir().join(format!(
|
|
"terminal-test-drain-timeout-{}.out",
|
|
std::process::id()
|
|
)),
|
|
notification_handle: ToolNotificationHandle::noop(),
|
|
tool_call_id: "test-drain-timeout".to_string(),
|
|
display_command: None,
|
|
auto_background_on_timeout: false,
|
|
foreground_block_budget: None,
|
|
kind: TaskKind::Bash,
|
|
owner_session_id: None,
|
|
};
|
|
|
|
let start = Instant::now();
|
|
let result = backend.run(request).await.unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(
|
|
elapsed < Duration::from_secs(10),
|
|
"Command should complete in ~3s (1s sleep + 2s drain timeout), got {:?}",
|
|
elapsed
|
|
);
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(
|
|
result.combined_output.contains("done"),
|
|
"Output should contain 'done', got: {:?}",
|
|
result.combined_output
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_drain_timeout_constant() {
|
|
assert_eq!(DRAIN_TIMEOUT, Duration::from_secs(2));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sigterm_grace_is_one_second() {
|
|
// Static assertion: guard against someone changing the constant
|
|
assert_eq!(SIGTERM_GRACE, Duration::from_secs(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_background_max_runtime_constant() {
|
|
// Static assertion: guard against accidental changes
|
|
assert_eq!(BACKGROUND_MAX_RUNTIME, Duration::from_secs(36_000));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "flaky: pgrep sees sleep processes from other sandbox co-tenants"]
|
|
async fn test_kill_kills_child_processes() {
|
|
// Spawn a background command that creates multiple child processes.
|
|
// Kill it, then verify no sleep processes remain.
|
|
let backend = LocalTerminalBackend::new();
|
|
let request = make_request("bash -c 'sleep 60 & sleep 60 & wait'");
|
|
|
|
// Run in background
|
|
let handle = backend.run_background(request).await.unwrap();
|
|
|
|
// Give it a moment to spawn children
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
// Kill the background task
|
|
let _ = backend.kill_task(&handle.task_id).await;
|
|
|
|
// Wait for processes to be reaped
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
// Verify no sleep processes remain owned by this test process
|
|
#[cfg(unix)]
|
|
{
|
|
let pgrep = std::process::Command::new("pgrep")
|
|
.arg("-P")
|
|
.arg(std::process::id().to_string())
|
|
.arg("sleep")
|
|
.output()
|
|
.expect("pgrep should run");
|
|
|
|
// No sleep processes should be found
|
|
assert!(
|
|
pgrep.stdout.is_empty(),
|
|
"Expected no sleep processes to remain, but pgrep found: {:?}",
|
|
String::from_utf8_lossy(&pgrep.stdout)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_local_runs_on_current_thread_runtime() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let local = tokio::task::LocalSet::new();
|
|
local.block_on(&rt, async {
|
|
let backend = LocalTerminalBackend::new_local(SearchShadowConfig::default());
|
|
let result = backend
|
|
.run(make_request("echo hello"))
|
|
.await
|
|
.expect("command should succeed");
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(result.combined_output.contains("hello"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn new_local_sequential_commands_dont_stall() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let local = tokio::task::LocalSet::new();
|
|
local.block_on(&rt, async {
|
|
let backend = LocalTerminalBackend::new_local(SearchShadowConfig::default());
|
|
for i in 0..5 {
|
|
let result = backend
|
|
.run(make_request(&format!("echo run_{i}")))
|
|
.await
|
|
.expect("command should succeed");
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(result.combined_output.contains(&format!("run_{i}")));
|
|
}
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn new_local_background_task_lifecycle() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let local = tokio::task::LocalSet::new();
|
|
local.block_on(&rt, async {
|
|
let backend = LocalTerminalBackend::new_local(SearchShadowConfig::default());
|
|
|
|
let mut bg_req = make_request("sleep 60");
|
|
bg_req.tool_call_id = "bg-1".to_string();
|
|
let bg = backend
|
|
.run_background(bg_req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
let snap = backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("task should exist");
|
|
assert!(!snap.completed);
|
|
|
|
let outcome = backend.kill_task(&bg.task_id).await;
|
|
assert!(
|
|
matches!(outcome, KillOutcome::Killed | KillOutcome::AlreadyExited),
|
|
"kill should succeed: {outcome:?}"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// A background command must be reaped by the `ProcessScope` the backend
|
|
/// enrolled it into -- the same handle the TUI exit paths `kill_all()` on
|
|
/// process exit. The kill is driven through the scope (not the actor's
|
|
/// `kill_task`), so this fails if the spawn site stops enrolling children.
|
|
/// An injected scope keeps the process-global one un-latched for other tests.
|
|
#[test]
|
|
fn background_child_is_reaped_via_process_scope() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let local = tokio::task::LocalSet::new();
|
|
local.block_on(&rt, async {
|
|
let scope = crate::util::ProcessScope::new();
|
|
let backend = LocalTerminalBackend::new_local_with_scope(
|
|
SearchShadowConfig::default(),
|
|
scope.clone(),
|
|
);
|
|
|
|
let mut bg_req = make_request("sleep 120");
|
|
bg_req.tool_call_id = "bg-scope-1".to_string();
|
|
let bg = backend
|
|
.run_background(bg_req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
assert!(
|
|
!backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("task should exist")
|
|
.completed,
|
|
"task should be running before kill_all"
|
|
);
|
|
|
|
// Reap via the scope, exactly like the TUI exit handlers do.
|
|
scope.kill_all();
|
|
|
|
// The actor observes the externally-killed child and marks the task
|
|
// complete. If enrollment regressed, the `sleep 120` would outlive
|
|
// the scope kill and this would time out.
|
|
assert!(
|
|
poll_until_task_completed(&backend, &bg.task_id, Duration::from_secs(10)).await,
|
|
"kill_all did not reap the enrolled background child"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Once a background child is reaped, the actor must drop its
|
|
/// `Arc<ProcessGroup>` so the scope's `Weak` dies. The completed task lingers
|
|
/// in `self.processes` for `COMPLETED_TASK_TTL`; if the actor kept the `Arc`
|
|
/// that long, a `kill_all()` on exit could `killpg` a pid the OS recycled.
|
|
/// Asserts the injected scope's live-group count goes 1 -> 0 across the reap.
|
|
#[test]
|
|
fn reaped_background_child_leaves_scope_empty() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let local = tokio::task::LocalSet::new();
|
|
local.block_on(&rt, async {
|
|
let scope = crate::util::ProcessScope::new();
|
|
let backend = LocalTerminalBackend::new_local_with_scope(
|
|
SearchShadowConfig::default(),
|
|
scope.clone(),
|
|
);
|
|
|
|
// A brief sleep (not `true`): it must still be running when we read
|
|
// live_count below so we can observe the `1` end of the transition.
|
|
// The actor's first poll tick fires right after spawn, and `true`
|
|
// could already be reaped by then, making the `== 1` check racy.
|
|
let mut bg_req = make_request("sleep 1");
|
|
bg_req.tool_call_id = "bg-reap-1".to_string();
|
|
let bg = backend
|
|
.run_background(bg_req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
// Enrollment is synchronous with the reply and the still-running
|
|
// child keeps the Arc alive, so exactly one group is live here.
|
|
assert_eq!(
|
|
scope.live_count(),
|
|
1,
|
|
"spawn must enroll exactly one live group"
|
|
);
|
|
|
|
// Drive the actor until it observes the exit and reaps the child.
|
|
assert!(
|
|
poll_until_task_completed(&backend, &bg.task_id, Duration::from_secs(10)).await,
|
|
"background `sleep 1` was never reaped"
|
|
);
|
|
|
|
// The reap sweep runs in the same poll tick that sets exit_status, so
|
|
// by the time get_task reports completed the Arc is already dropped —
|
|
// kill_all() would be a no-op and can't killpg a reused pid.
|
|
assert_eq!(
|
|
scope.live_count(),
|
|
0,
|
|
"a reaped child must leave no live group enrolled in the scope"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_persistent_shell_cd_persists() {
|
|
let backend = LocalTerminalBackend::with_persistent_shell();
|
|
|
|
// cd to /tmp
|
|
let result = backend.run(make_request("cd /tmp")).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
// pwd should return /tmp (macOS resolves to /private/tmp)
|
|
let result = backend.run(make_request("pwd")).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
let pwd = result.combined_output.trim();
|
|
assert!(
|
|
pwd == "/tmp" || pwd == "/private/tmp",
|
|
"cwd should persist across commands, got: {pwd}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_persistent_shell_env_var_persists() {
|
|
let backend = LocalTerminalBackend::with_persistent_shell();
|
|
|
|
let result = backend
|
|
.run(make_request("export KIGI_PERSIST_TEST=hello123"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
let result = backend
|
|
.run(make_request("echo $KIGI_PERSIST_TEST"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert_eq!(
|
|
result.combined_output.trim(),
|
|
"hello123",
|
|
"env var should persist across commands"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_persistent_shell_clears_gpg_tty() {
|
|
// GPG_TTY must be forced empty on the live path even when supplied via the request env.
|
|
let backend = LocalTerminalBackend::with_persistent_shell();
|
|
|
|
let mut req = make_request("echo \"[$GPG_TTY]\"");
|
|
req.env
|
|
.insert("GPG_TTY".to_string(), "/kigi-sentinel-tty".to_string());
|
|
|
|
let result = backend.run(req).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert_eq!(
|
|
result.combined_output.trim(),
|
|
"[]",
|
|
"GPG_TTY must be empty on the live tool path, got: {:?}",
|
|
result.combined_output
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_persistent_shell_function_persists() {
|
|
let backend = LocalTerminalBackend::with_persistent_shell();
|
|
|
|
let result = backend
|
|
.run(make_request("myfunc() { echo \"called with $1\"; }"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
let result = backend.run(make_request("myfunc test_arg")).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert_eq!(
|
|
result.combined_output.trim(),
|
|
"called with test_arg",
|
|
"function should persist across commands"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_persistent_shell_variable_capture() {
|
|
let backend = LocalTerminalBackend::with_persistent_shell();
|
|
|
|
// Set a variable via command substitution
|
|
let result = backend
|
|
.run(make_request("export CAPTURED=$(echo captured_value)"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
// Read it back
|
|
let result = backend.run(make_request("echo $CAPTURED")).await.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert_eq!(
|
|
result.combined_output.trim(),
|
|
"captured_value",
|
|
"variable from command substitution should persist"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_non_persistent_shell_no_state() {
|
|
// Verify the default (non-persistent) mode doesn't carry state.
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let result = backend
|
|
.run(make_request("export SHOULD_NOT_PERSIST=yes"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
let result = backend
|
|
.run(make_request("echo ${SHOULD_NOT_PERSIST:-empty}"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert_eq!(
|
|
result.combined_output.trim(),
|
|
"empty",
|
|
"non-persistent mode should not carry state"
|
|
);
|
|
}
|
|
|
|
/// End-to-end test: completed background task remains queryable after
|
|
/// its ProcessState is evicted from the actor's process map.
|
|
///
|
|
/// Uses a 200ms TTL so we don't have to wait 5 minutes.
|
|
#[tokio::test]
|
|
async fn completed_bg_task_queryable_after_eviction() {
|
|
let ttl = Duration::from_millis(200);
|
|
let backend = LocalTerminalBackend::new_with_completed_task_ttl(ttl);
|
|
|
|
// 1. Start a fast background command.
|
|
let mut req = make_request("echo eviction_test_output");
|
|
req.tool_call_id = "evict-test".to_string();
|
|
let bg = backend
|
|
.run_background(req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
// 2. Wait for completion.
|
|
let snap = backend
|
|
.wait_for_completion(&bg.task_id, Some(Duration::from_secs(5)))
|
|
.await
|
|
.expect("task should complete");
|
|
assert!(snap.completed, "task should be completed");
|
|
assert_eq!(snap.exit_code, Some(0));
|
|
|
|
// 3. Sleep past the TTL so the actor evicts the ProcessState.
|
|
tokio::time::sleep(ttl + Duration::from_millis(200)).await;
|
|
|
|
// 4. get_task should still return the snapshot (from the tombstone map).
|
|
let snap_after = backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("task should still be queryable after eviction");
|
|
assert!(snap_after.completed);
|
|
assert_eq!(snap_after.exit_code, Some(0));
|
|
assert_eq!(snap_after.task_id, bg.task_id);
|
|
|
|
// 5. list_tasks should include the evicted task.
|
|
let all = backend.list_tasks().await;
|
|
assert!(
|
|
all.iter().any(|t| t.task_id == bg.task_id),
|
|
"evicted task should appear in list_tasks"
|
|
);
|
|
|
|
// 6. wait_for_completion should also return it (already done).
|
|
let snap_wait = backend
|
|
.wait_for_completion(&bg.task_id, Some(Duration::from_millis(100)))
|
|
.await
|
|
.expect("wait_for_completion should return evicted task");
|
|
assert!(snap_wait.completed);
|
|
}
|
|
|
|
/// Fix 3 — when wait_for_completion is called AFTER the task was
|
|
/// evicted from `processes` (snapshot-only branch), the returned
|
|
/// snapshot must reflect `block_waited=true` AND the in-place
|
|
/// tombstone in `completed_task_snapshots` must also be updated.
|
|
///
|
|
/// IMPORTANT: this test must NOT call `wait_for_completion` before
|
|
/// eviction, because that would set `process.block_waited=true` on
|
|
/// the live process and the eviction snapshot at
|
|
/// `poll_all_processes` step 4 would copy `block_waited: true` into
|
|
/// the tombstone (so the late-wait assertion would trivially pass
|
|
/// even with Fix 3 reverted). Sequence below keeps the tombstone
|
|
/// born with `block_waited=false` so the test actually exercises
|
|
/// the in-place mutation in `handle_wait_for_completion`'s
|
|
/// already-evicted branch.
|
|
#[tokio::test]
|
|
async fn wait_after_eviction_returns_snapshot_with_block_waited_true() {
|
|
let ttl = Duration::from_millis(100);
|
|
let backend = LocalTerminalBackend::new_with_completed_task_ttl(ttl);
|
|
|
|
// 1. Spawn a fast-exit background task. DO NOT call wait_for_completion
|
|
// so process.block_waited stays false.
|
|
let mut req = make_request("echo evict_and_wait");
|
|
req.tool_call_id = "evict-wait".to_string();
|
|
let bg = backend
|
|
.run_background(req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
// 2. Poll `get_task` until the task completes.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut completed = false;
|
|
while Instant::now() < deadline {
|
|
if let Some(snap) = backend.get_task(&bg.task_id).await
|
|
&& snap.completed
|
|
{
|
|
completed = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
assert!(completed, "task should complete within deadline");
|
|
|
|
// 3. Sleep past the TTL so eviction triggers.
|
|
tokio::time::sleep(ttl + Duration::from_millis(250)).await;
|
|
|
|
// 4. Assert the tombstone was born with `block_waited == false`.
|
|
// This pins the precondition: the tombstone has NOT inherited
|
|
// block_waited from the process, so a passing assertion below
|
|
// can only come from Fix 3's in-place mutation.
|
|
let snap_pre_wait = backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("evicted task should still be queryable via tombstone");
|
|
assert!(
|
|
snap_pre_wait.completed,
|
|
"tombstone should reflect completion"
|
|
);
|
|
assert!(
|
|
!snap_pre_wait.block_waited,
|
|
"tombstone must be born with block_waited=false (no wait_for_completion \
|
|
was called before eviction); got block_waited=true which would make \
|
|
the late-wait assertion below trivial"
|
|
);
|
|
|
|
// 5. Late wait_for_completion against the tombstone.
|
|
let snap_after = backend
|
|
.wait_for_completion(&bg.task_id, Some(Duration::from_millis(100)))
|
|
.await
|
|
.expect("late wait should still return evicted snapshot");
|
|
assert!(snap_after.completed);
|
|
assert!(
|
|
snap_after.block_waited,
|
|
"late wait must set block_waited=true on the returned snapshot"
|
|
);
|
|
|
|
// 6. A subsequent get_task must see the imprinted flag too,
|
|
// confirming the mutation is in-place and observable to
|
|
// other readers of the tombstone (Fix 3's reason for using
|
|
// get_mut over get + clone).
|
|
let snap_via_get = backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("get_task should still return after eviction");
|
|
assert!(
|
|
snap_via_get.block_waited,
|
|
"get_task after late wait must see the persisted block_waited flag \
|
|
— Fix 3's in-place mutation must be observable to other readers"
|
|
);
|
|
}
|
|
|
|
/// A blocking wait whose receiver is dropped before the task completes
|
|
/// (the awaiting turn was cancelled, e.g. Ctrl+C mid
|
|
/// `get_command_or_subagent_output`) must NOT leave `block_waited=true`
|
|
/// imprinted on the task. The model never received the result, so the
|
|
/// completion must still auto-wake it.
|
|
///
|
|
/// Regression test for the cancelled-wait race:
|
|
/// wait(timeout_ms=180000) → Ctrl+C → task exits before the wait
|
|
/// deadline → completion delivered to a dead oneshot → `block_waited`
|
|
/// stayed true → auto-wake suppressed → agent slept until the user
|
|
/// manually typed "continue".
|
|
#[tokio::test]
|
|
async fn cancelled_wait_does_not_suppress_auto_wake_on_completion() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
// 1. Background task that outlives the cancelled waiter below.
|
|
let mut req = make_request("sleep 1; echo done");
|
|
req.tool_call_id = "cancelled-wait".to_string();
|
|
let bg = backend
|
|
.run_background(req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
// 2. Register a blocking wait with a deadline far beyond the task's
|
|
// runtime, then cancel it (drop the future — and with it the
|
|
// oneshot receiver) long before the task exits. This mirrors a
|
|
// turn abort while `get_task_output` blocks.
|
|
let cancelled = tokio::time::timeout(
|
|
Duration::from_millis(100),
|
|
backend.wait_for_completion(&bg.task_id, Some(Duration::from_secs(120))),
|
|
)
|
|
.await;
|
|
assert!(
|
|
cancelled.is_err(),
|
|
"wait must still be pending when the caller is cancelled"
|
|
);
|
|
|
|
// 3. Let the task complete and its completion be processed.
|
|
assert!(
|
|
poll_until_task_completed(&backend, &bg.task_id, Duration::from_secs(10)).await,
|
|
"task should complete within deadline"
|
|
);
|
|
|
|
// 4. The completion was never delivered to any waiter, so
|
|
// block_waited must be false — otherwise the notification
|
|
// bridge suppresses the TaskCompleted auto-wake.
|
|
let snap = backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("completed task should be queryable");
|
|
assert!(
|
|
!snap.block_waited,
|
|
"a cancelled (never-delivered) blocking wait must not leave \
|
|
block_waited=true — that suppresses the completion auto-wake"
|
|
);
|
|
}
|
|
|
|
/// If one waiter is cancelled but another live waiter consumes the
|
|
/// completion, `block_waited` must stay true: the model received the
|
|
/// result through the surviving call, so the auto-wake would be
|
|
/// redundant noise.
|
|
#[tokio::test]
|
|
async fn cancelled_wait_alongside_live_waiter_keeps_block_waited() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let mut req = make_request("sleep 1; echo done");
|
|
req.tool_call_id = "mixed-waiters".to_string();
|
|
let bg = backend
|
|
.run_background(req)
|
|
.await
|
|
.expect("background spawn should succeed");
|
|
|
|
// Dead waiter: cancelled before completion.
|
|
let cancelled = tokio::time::timeout(
|
|
Duration::from_millis(100),
|
|
backend.wait_for_completion(&bg.task_id, Some(Duration::from_secs(120))),
|
|
)
|
|
.await;
|
|
assert!(cancelled.is_err(), "first wait should be cancelled");
|
|
|
|
// Live waiter: rides until completion.
|
|
let snap = backend
|
|
.wait_for_completion(&bg.task_id, Some(Duration::from_secs(30)))
|
|
.await
|
|
.expect("live wait should return the completed snapshot");
|
|
assert!(snap.completed, "live wait should observe completion");
|
|
assert!(
|
|
snap.block_waited,
|
|
"delivered wait must keep block_waited=true on the returned snapshot"
|
|
);
|
|
|
|
let snap_via_get = backend
|
|
.get_task(&bg.task_id)
|
|
.await
|
|
.expect("completed task should be queryable");
|
|
assert!(
|
|
snap_via_get.block_waited,
|
|
"block_waited must remain true when at least one waiter received \
|
|
the completion — auto-wake would be redundant"
|
|
);
|
|
}
|
|
|
|
/// Helper: create a request owned by a specific session.
|
|
fn make_owned_request(command: &str, owner: &str) -> TerminalRunRequest {
|
|
let mut req = make_request(command);
|
|
req.owner_session_id = Some(owner.to_string());
|
|
req
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn kill_foreground_by_owner_only_kills_matching_session() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
// Spawn two foreground long-running commands owned by different sessions.
|
|
// Use auto_background_on_timeout with a long timeout so they stay foreground.
|
|
let mut req_a = make_owned_request("sleep 60", "session-a");
|
|
req_a.tool_call_id = "fg-a".to_string();
|
|
req_a.timeout = Duration::from_secs(300);
|
|
|
|
let mut req_b = make_owned_request("sleep 60", "session-b");
|
|
req_b.tool_call_id = "fg-b".to_string();
|
|
req_b.timeout = Duration::from_secs(300);
|
|
|
|
// Run both as background tasks first (so they don't block this test),
|
|
// then we'll test the scoped kill via the backend trait method.
|
|
let handle_a = backend.run_background(req_a).await.unwrap();
|
|
let handle_b = backend.run_background(req_b).await.unwrap();
|
|
|
|
// Both should be running
|
|
let snap_a = backend.get_task(&handle_a.task_id).await;
|
|
let snap_b = backend.get_task(&handle_b.task_id).await;
|
|
assert!(snap_a.is_some(), "task A should exist");
|
|
assert!(snap_b.is_some(), "task B should exist");
|
|
|
|
// Kill only session-a's tasks
|
|
backend
|
|
.kill_all_background_tasks_by_owner("session-a")
|
|
.await;
|
|
|
|
// Give the actor a moment to process
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
// Task A should be killed (completed with explicitly_killed)
|
|
let snap_a = backend.get_task(&handle_a.task_id).await;
|
|
assert!(
|
|
snap_a.is_some_and(|s| s.completed && s.explicitly_killed),
|
|
"task A should be killed"
|
|
);
|
|
|
|
// Task B should still be running
|
|
let snap_b = backend.get_task(&handle_b.task_id).await;
|
|
assert!(
|
|
snap_b.is_some_and(|s| !s.completed),
|
|
"task B should still be running"
|
|
);
|
|
|
|
// Cleanup
|
|
backend.kill_task(&handle_b.task_id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn kill_by_owner_ignores_unowned_tasks() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
// Spawn a task with no owner (None)
|
|
let mut req = make_request("sleep 60");
|
|
req.tool_call_id = "fg-none".to_string();
|
|
let handle = backend.run_background(req).await.unwrap();
|
|
|
|
// Killing by a specific owner should NOT affect unowned tasks
|
|
backend
|
|
.kill_all_background_tasks_by_owner("some-session")
|
|
.await;
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
let snap = backend.get_task(&handle.task_id).await;
|
|
assert!(
|
|
snap.is_some_and(|s| !s.completed),
|
|
"unowned task should NOT be killed by owner-scoped kill"
|
|
);
|
|
|
|
// Cleanup
|
|
backend.kill_task(&handle.task_id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reparent_notifications_changes_owner_and_sends_synthetic() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let new_handle = ToolNotificationHandle::from_sender(tx);
|
|
|
|
let backend: std::sync::Arc<dyn crate::computer::types::TerminalBackend> =
|
|
std::sync::Arc::new(LocalTerminalBackend::new());
|
|
|
|
// Spawn a background task owned by "child-session"
|
|
let mut req = make_owned_request("sleep 60", "child-session");
|
|
req.tool_call_id = "reparent-test".to_string();
|
|
let bg = backend.run_background(req).await.unwrap();
|
|
|
|
// Verify it's running and owned by child-session
|
|
let snap = backend.get_task(&bg.task_id).await.unwrap();
|
|
assert!(!snap.completed);
|
|
assert_eq!(snap.owner_session_id.as_deref(), Some("child-session"));
|
|
|
|
// Reparent from child-session to parent-session
|
|
backend
|
|
.reparent_notifications(
|
|
"child-session",
|
|
"parent-session",
|
|
new_handle,
|
|
std::sync::Arc::downgrade(&backend),
|
|
)
|
|
.await;
|
|
|
|
// Verify owner changed
|
|
let snap = backend.get_task(&bg.task_id).await.unwrap();
|
|
assert_eq!(
|
|
snap.owner_session_id.as_deref(),
|
|
Some("parent-session"),
|
|
"owner should be reparented to parent-session"
|
|
);
|
|
|
|
// Verify synthetic BashExecutionBackgrounded notification was sent
|
|
let mut found_backgrounded = false;
|
|
while let Ok(notification) = rx.try_recv() {
|
|
if let crate::notification::types::ToolNotification::BashExecutionBackgrounded(
|
|
bg_notif,
|
|
) = notification
|
|
{
|
|
assert_eq!(bg_notif.task_id, bg.task_id);
|
|
found_backgrounded = true;
|
|
}
|
|
}
|
|
assert!(
|
|
found_backgrounded,
|
|
"reparent should send a synthetic BashExecutionBackgrounded notification"
|
|
);
|
|
|
|
// Now killing by parent-session should kill the reparented task
|
|
backend
|
|
.kill_all_background_tasks_by_owner("parent-session")
|
|
.await;
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
let snap = backend.get_task(&bg.task_id).await;
|
|
assert!(
|
|
snap.is_some_and(|s| s.completed),
|
|
"reparented task should be killed when parent-session tasks are killed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reparent_skips_tasks_owned_by_other_sessions() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let new_handle = ToolNotificationHandle::from_sender(tx);
|
|
|
|
let backend: std::sync::Arc<dyn crate::computer::types::TerminalBackend> =
|
|
std::sync::Arc::new(LocalTerminalBackend::new());
|
|
|
|
// Spawn tasks owned by different sessions
|
|
let mut req_child = make_owned_request("sleep 60", "child-session");
|
|
req_child.tool_call_id = "reparent-child".to_string();
|
|
let bg_child = backend.run_background(req_child).await.unwrap();
|
|
|
|
let mut req_sibling = make_owned_request("sleep 60", "sibling-session");
|
|
req_sibling.tool_call_id = "reparent-sibling".to_string();
|
|
let bg_sibling = backend.run_background(req_sibling).await.unwrap();
|
|
|
|
// Reparent only child-session
|
|
backend
|
|
.reparent_notifications(
|
|
"child-session",
|
|
"parent-session",
|
|
new_handle,
|
|
std::sync::Arc::downgrade(&backend),
|
|
)
|
|
.await;
|
|
|
|
// Child's owner should change
|
|
let snap_child = backend.get_task(&bg_child.task_id).await.unwrap();
|
|
assert_eq!(
|
|
snap_child.owner_session_id.as_deref(),
|
|
Some("parent-session")
|
|
);
|
|
|
|
// Sibling's owner should NOT change
|
|
let snap_sibling = backend.get_task(&bg_sibling.task_id).await.unwrap();
|
|
assert_eq!(
|
|
snap_sibling.owner_session_id.as_deref(),
|
|
Some("sibling-session"),
|
|
"sibling task should not be reparented"
|
|
);
|
|
|
|
// Only one synthetic notification (for the child, not the sibling)
|
|
let mut bg_count = 0;
|
|
while let Ok(notification) = rx.try_recv() {
|
|
if matches!(
|
|
notification,
|
|
crate::notification::types::ToolNotification::BashExecutionBackgrounded(_)
|
|
) {
|
|
bg_count += 1;
|
|
}
|
|
}
|
|
assert_eq!(
|
|
bg_count, 1,
|
|
"only the child task should produce a synthetic notification"
|
|
);
|
|
|
|
// Cleanup
|
|
backend.kill_task(&bg_child.task_id).await;
|
|
backend.kill_task(&bg_sibling.task_id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn owner_session_id_propagated_through_run_and_snapshot() {
|
|
let backend = LocalTerminalBackend::new();
|
|
|
|
let mut req = make_owned_request("echo owned", "test-owner");
|
|
req.tool_call_id = "owned-test".to_string();
|
|
let bg = backend.run_background(req).await.unwrap();
|
|
|
|
// Wait for completion
|
|
let snap = backend
|
|
.wait_for_completion(&bg.task_id, Some(Duration::from_secs(5)))
|
|
.await
|
|
.expect("task should complete");
|
|
|
|
assert!(snap.completed);
|
|
assert_eq!(
|
|
snap.owner_session_id.as_deref(),
|
|
Some("test-owner"),
|
|
"owner_session_id should propagate from request to snapshot"
|
|
);
|
|
}
|
|
}
|