Synced from monorepo
Synced from monorepo Changes: - Cache growing transcripts on the messages backend - Tell the model when a wait was clamped instead of re-inviting it - Stop the stationarity nudge from claiming results are identical - Deliver the stationarity nudge after the tool result - Run auth provider commands through the platform shell (fixes Windows) - Keep monitor tool stdout short and prescriptive - Use UUIDs for analytics event insert IDs - Stop crashing at startup when the host runs out of threads - Delete the current session from within the session - Add project forking-settings toggle (backend and deploy-time control) - Reap a session’s bash and background commands when it closes - Reap a session’s hook child processes when it closes - Track coding-data consent decisions - Fail open the access gate to stop false CLI paywalls - Ship Agent Dashboard user guide - Enable doom-loop recovery by default - Kill agent children and the idle inhibitor when the parent process dies - Fix multi-process credential wipe and orphaned session log writers Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
This commit is contained in:
parent
5da6962e4a
commit
500129c714
89 changed files with 3841 additions and 771 deletions
|
|
@ -476,6 +476,12 @@ struct LocalTerminalActor {
|
|||
/// their own to avoid latching the global.
|
||||
scope: crate::util::ProcessScope,
|
||||
|
||||
/// Additional owner: the scope of the session that started this backend, so
|
||||
/// closing the session reaps its commands without waiting for process exit.
|
||||
/// Enrolling in both means whichever reaper fires first wins and the other
|
||||
/// finds a dead group.
|
||||
session_scope: Option<crate::util::ProcessScope>,
|
||||
|
||||
/// Active processes: task_id -> ProcessState
|
||||
processes: HashMap<String, ProcessState>,
|
||||
|
||||
|
|
@ -549,12 +555,14 @@ impl LocalTerminalActor {
|
|||
foreground_block_budget: Duration,
|
||||
output_file_cap: u64,
|
||||
scope: crate::util::ProcessScope,
|
||||
session_scope: Option<crate::util::ProcessScope>,
|
||||
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cmd_rx,
|
||||
cancel_token,
|
||||
scope,
|
||||
session_scope,
|
||||
shell_env_policy,
|
||||
processes: HashMap::new(),
|
||||
completion_waiters: HashMap::new(),
|
||||
|
|
@ -1010,6 +1018,11 @@ impl LocalTerminalActor {
|
|||
) -> std::sync::Arc<crate::util::ProcessGroup> {
|
||||
let group = std::sync::Arc::new(group);
|
||||
self.scope.register(&group);
|
||||
if let Some(session_scope) = &self.session_scope {
|
||||
// A closed session scope kills the group here, which is the point:
|
||||
// a command racing session teardown must not survive it.
|
||||
session_scope.register(&group);
|
||||
}
|
||||
group
|
||||
}
|
||||
|
||||
|
|
@ -1304,13 +1317,13 @@ impl LocalTerminalActor {
|
|||
|
||||
// Register as a completion waiter and return control to the actor loop.
|
||||
let timeout = timeout.unwrap_or(Duration::from_secs(30));
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.unwrap_or_else(Instant::now);
|
||||
self.completion_waiters
|
||||
.entry(task_id)
|
||||
.or_default()
|
||||
.push(CompletionWaiter {
|
||||
reply,
|
||||
deadline: Instant::now() + timeout,
|
||||
});
|
||||
.push(CompletionWaiter { reply, deadline });
|
||||
|
||||
// Return immediately — actor loop resumes processing other commands.
|
||||
}
|
||||
|
|
@ -2144,6 +2157,7 @@ struct LocalTerminalConfig {
|
|||
login_shell_capture: bool,
|
||||
search_shadows: SearchShadowConfig,
|
||||
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
|
||||
process_scope: Option<crate::util::ProcessScope>,
|
||||
}
|
||||
|
||||
impl Default for LocalTerminalConfig {
|
||||
|
|
@ -2155,6 +2169,7 @@ impl Default for LocalTerminalConfig {
|
|||
login_shell_capture: true,
|
||||
search_shadows: SearchShadowConfig::default(),
|
||||
shell_env_policy: None,
|
||||
process_scope: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2191,15 +2206,6 @@ impl LocalTerminalBackend {
|
|||
})
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with both memory limits and persistent shell.
|
||||
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(LocalTerminalConfig {
|
||||
memory_config: Some(config),
|
||||
persistent_shell: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes).
|
||||
///
|
||||
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
||||
|
|
@ -2216,12 +2222,14 @@ impl LocalTerminalBackend {
|
|||
search_shadows: SearchShadowConfig,
|
||||
login_shell_capture: bool,
|
||||
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
|
||||
process_scope: Option<crate::util::ProcessScope>,
|
||||
) -> Self {
|
||||
Self::new_inner(LocalTerminalConfig {
|
||||
use_spawn_local: true,
|
||||
login_shell_capture,
|
||||
search_shadows,
|
||||
shell_env_policy,
|
||||
process_scope,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
|
@ -2233,21 +2241,14 @@ impl LocalTerminalBackend {
|
|||
pub fn new_local_with_persistent_shell(
|
||||
search_shadows: SearchShadowConfig,
|
||||
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
|
||||
process_scope: Option<crate::util::ProcessScope>,
|
||||
) -> Self {
|
||||
Self::new_inner(LocalTerminalConfig {
|
||||
use_spawn_local: true,
|
||||
persistent_shell: true,
|
||||
search_shadows,
|
||||
shell_env_policy,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local with memory limits.
|
||||
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(LocalTerminalConfig {
|
||||
memory_config: Some(config),
|
||||
use_spawn_local: true,
|
||||
process_scope,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
|
@ -2259,6 +2260,7 @@ impl LocalTerminalBackend {
|
|||
pub(crate) fn new_local_with_scope(
|
||||
search_shadows: SearchShadowConfig,
|
||||
scope: crate::util::ProcessScope,
|
||||
session_scope: Option<crate::util::ProcessScope>,
|
||||
) -> Self {
|
||||
Self::new_with_ttl(
|
||||
None,
|
||||
|
|
@ -2270,6 +2272,7 @@ impl LocalTerminalBackend {
|
|||
FOREGROUND_BLOCK_BUDGET,
|
||||
MAX_OUTPUT_FILE_BYTES,
|
||||
scope,
|
||||
session_scope,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
|
@ -2288,6 +2291,7 @@ impl LocalTerminalBackend {
|
|||
MAX_OUTPUT_FILE_BYTES,
|
||||
crate::util::global_process_scope().clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2305,6 +2309,7 @@ impl LocalTerminalBackend {
|
|||
MAX_OUTPUT_FILE_BYTES,
|
||||
crate::util::global_process_scope().clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2322,6 +2327,7 @@ impl LocalTerminalBackend {
|
|||
output_file_cap,
|
||||
crate::util::global_process_scope().clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2333,6 +2339,7 @@ impl LocalTerminalBackend {
|
|||
login_shell_capture,
|
||||
search_shadows,
|
||||
shell_env_policy,
|
||||
process_scope,
|
||||
} = config;
|
||||
Self::new_with_ttl(
|
||||
memory_config,
|
||||
|
|
@ -2344,6 +2351,7 @@ impl LocalTerminalBackend {
|
|||
foreground_block_budget_from_env(),
|
||||
output_file_cap_from_env(),
|
||||
crate::util::global_process_scope().clone(),
|
||||
process_scope,
|
||||
shell_env_policy,
|
||||
)
|
||||
}
|
||||
|
|
@ -2358,6 +2366,7 @@ impl LocalTerminalBackend {
|
|||
foreground_block_budget: Duration,
|
||||
output_file_cap: u64,
|
||||
scope: crate::util::ProcessScope,
|
||||
session_scope: Option<crate::util::ProcessScope>,
|
||||
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
|
||||
) -> Self {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
|
||||
|
|
@ -2385,6 +2394,7 @@ impl LocalTerminalBackend {
|
|||
foreground_block_budget,
|
||||
output_file_cap,
|
||||
scope,
|
||||
session_scope,
|
||||
shell_env_policy,
|
||||
);
|
||||
actor.run().await;
|
||||
|
|
@ -4564,6 +4574,7 @@ mod tests {
|
|||
let backend = LocalTerminalBackend::new_local_with_scope(
|
||||
SearchShadowConfig::default(),
|
||||
scope.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut bg_req = make_request("sleep 120");
|
||||
|
|
@ -4595,6 +4606,41 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
/// A session-scoped command stays enrolled in the base scope too, so the TUI
|
||||
/// exit paths (which `kill_all()` only the process-global scope, and reach
|
||||
/// `process::exit` without running `Drop`) still reap it.
|
||||
#[test]
|
||||
fn session_scoped_child_is_still_reaped_via_base_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 base = crate::util::ProcessScope::new();
|
||||
let session = crate::util::ProcessScope::new();
|
||||
let backend = LocalTerminalBackend::new_local_with_scope(
|
||||
SearchShadowConfig::default(),
|
||||
base.clone(),
|
||||
Some(session),
|
||||
);
|
||||
|
||||
let mut bg_req = make_request("sleep 120");
|
||||
bg_req.tool_call_id = "bg-dual-scope".to_string();
|
||||
let bg = backend
|
||||
.run_background(bg_req)
|
||||
.await
|
||||
.expect("background spawn should succeed");
|
||||
|
||||
base.kill_all();
|
||||
|
||||
assert!(
|
||||
poll_until_task_completed(&backend, &bg.task_id, Duration::from_secs(10)).await,
|
||||
"base-scope kill_all did not reap a session-scoped 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`
|
||||
|
|
@ -4612,6 +4658,7 @@ mod tests {
|
|||
let backend = LocalTerminalBackend::new_local_with_scope(
|
||||
SearchShadowConfig::default(),
|
||||
scope.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
// A brief sleep (not `true`): it must still be running when we read
|
||||
|
|
|
|||
|
|
@ -333,6 +333,11 @@ pub trait TerminalBackend: Send + Sync {
|
|||
}
|
||||
|
||||
/// Wait for a background task to complete, with optional timeout.
|
||||
///
|
||||
/// # Panics / overflow
|
||||
/// Implementations may add `timeout` to `Instant::now()`. Callers must
|
||||
/// bound `timeout` (e.g. via `capped_wait_timeout`) so the sum stays
|
||||
/// representable; unbounded model `timeout_ms` can overflow.
|
||||
async fn wait_for_completion(
|
||||
&self,
|
||||
task_id: &str,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ impl crate::types::tool_metadata::ToolMetadata for MonitorTool {
|
|||
fn description_template(&self) -> &str {
|
||||
r#"Start a background monitor that streams events from a long-running script. Each stdout line is an event${%- if system_reminders_enabled %} - you can keep working and notifications arrive in the chat${%- endif %}. Exit ends the watch.
|
||||
|
||||
**Output volume**: Every stdout line becomes a message in the conversation, so write selective filters. In pipes use `grep --line-buffered` (plain `grep` buffers and delays events by minutes).
|
||||
**Output volume**: Every stdout line is a main-agent wake. Print only `DONE`/`FAILED`/`CANCELLED`. No progress or CHANGE lines. Use `grep --line-buffered` in pipes (plain `grep` buffers and delays events by minutes).
|
||||
|
||||
Set `persistent: true` for session-length watches (PR monitoring, log tails) -- the monitor runs${%- if tools.by_kind.kill_task_action %} until you call ${{ tools.by_kind.kill_task_action }} or${%- endif %} until the session ends. Otherwise it stops at `timeout_ms` (default 10h)."#
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,86 @@ pub(crate) fn capped_wait_timeout(timeout_ms: Option<u64>) -> Duration {
|
|||
base.min(max_wait_block())
|
||||
}
|
||||
|
||||
/// The caller's requested wait before capping, or the default when omitted.
|
||||
fn requested_wait_timeout(timeout_ms: Option<u64>) -> Duration {
|
||||
timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(DEFAULT_WAIT_TIMEOUT)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum WaitHint {
|
||||
NotRequested,
|
||||
Elapsed {
|
||||
requested: Duration,
|
||||
waited: Duration,
|
||||
},
|
||||
ReturnedEarly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum WaitSubject {
|
||||
Task,
|
||||
Subagent,
|
||||
}
|
||||
|
||||
impl WaitSubject {
|
||||
fn noun(self) -> &'static str {
|
||||
match self {
|
||||
WaitSubject::Task => "task",
|
||||
WaitSubject::Subagent => "subagent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn still_running_wait_hint(hint: WaitHint, subject: WaitSubject) -> String {
|
||||
let noun = subject.noun();
|
||||
let lead = match hint {
|
||||
WaitHint::Elapsed { requested, waited } => {
|
||||
let waited_label = format_waited_duration(waited);
|
||||
if requested > waited {
|
||||
let requested_label = format_waited_duration(requested);
|
||||
format!(
|
||||
"Waited {waited_label}, the per-call maximum, of the {requested_label} you requested; \
|
||||
the {noun} is still running. You do not need to call this again."
|
||||
)
|
||||
} else {
|
||||
format!("Waited the requested {waited_label}; the {noun} is still running.")
|
||||
}
|
||||
}
|
||||
WaitHint::ReturnedEarly => {
|
||||
format!("Wait returned early because another finished; this {noun} is still running.")
|
||||
}
|
||||
WaitHint::NotRequested => "Use timeout_ms to wait for completion.".to_string(),
|
||||
};
|
||||
format!("{lead} You will be notified automatically when the {noun} completes.")
|
||||
}
|
||||
|
||||
fn format_waited_duration(d: Duration) -> String {
|
||||
let ms = d.as_millis();
|
||||
if ms < 1000 {
|
||||
format!("{ms}ms")
|
||||
} else {
|
||||
format!("{}s", ms / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
fn with_still_running_wait_hint(body: String, hint: WaitHint, subject: WaitSubject) -> String {
|
||||
format!("{body}\n\n{}", still_running_wait_hint(hint, subject))
|
||||
}
|
||||
|
||||
fn apply_running_wait_hint(
|
||||
mut result: TaskOutputResult,
|
||||
hint: WaitHint,
|
||||
subject: WaitSubject,
|
||||
) -> TaskOutputResult {
|
||||
if result.status == "running" {
|
||||
result.output =
|
||||
with_still_running_wait_hint(std::mem::take(&mut result.output), hint, subject);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn background_bash_requires_exprs() -> Vec<Expr<ToolRequirement>> {
|
||||
use crate::types::tool_metadata::ToolMetadata;
|
||||
let grok_build_bash = Expr::Value(ToolRequirement::Tool {
|
||||
|
|
@ -121,6 +201,14 @@ impl TaskOutputTool {
|
|||
}
|
||||
|
||||
let waits = xai_tool_types::task_output_waits(timeout_ms);
|
||||
let wait_hint = if waits {
|
||||
WaitHint::Elapsed {
|
||||
requested: requested_wait_timeout(timeout_ms),
|
||||
waited: capped_wait_timeout(timeout_ms),
|
||||
}
|
||||
} else {
|
||||
WaitHint::NotRequested
|
||||
};
|
||||
let snapshot = if waits {
|
||||
// Cap the blocking wait so a large `timeout_ms` can't wedge the turn;
|
||||
// the model is pinged on completion regardless (see `capped_wait_timeout`).
|
||||
|
|
@ -150,10 +238,10 @@ impl TaskOutputTool {
|
|||
)
|
||||
})
|
||||
.unwrap_or(DEFAULT_TOOL_OUTPUT_BYTES);
|
||||
return Ok(TaskOutputOutput::Result(snapshot_to_result(
|
||||
snapshot,
|
||||
&read_file_name,
|
||||
max_output_bytes,
|
||||
return Ok(TaskOutputOutput::Result(apply_running_wait_hint(
|
||||
snapshot_to_result(snapshot, &read_file_name, max_output_bytes),
|
||||
wait_hint,
|
||||
WaitSubject::Task,
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +265,7 @@ impl TaskOutputTool {
|
|||
.query(task_id, waits, query_timeout_ms)
|
||||
.await
|
||||
{
|
||||
return Ok(format_subagent_snapshot(&snapshot));
|
||||
return Ok(format_subagent_snapshot(&snapshot, wait_hint));
|
||||
}
|
||||
|
||||
// Neither found
|
||||
|
|
@ -209,6 +297,7 @@ impl TaskOutputTool {
|
|||
tool_name_for_truncation: &str,
|
||||
) -> Result<TaskOutputOutput, xai_tool_runtime::ToolError> {
|
||||
let waits = xai_tool_types::task_output_waits(timeout_ms);
|
||||
let requested = requested_wait_timeout(timeout_ms);
|
||||
let timeout = capped_wait_timeout(timeout_ms);
|
||||
|
||||
let (terminal, backend, read_file_name, max_output_bytes) = {
|
||||
|
|
@ -235,6 +324,7 @@ impl TaskOutputTool {
|
|||
&backend,
|
||||
&read_file_name,
|
||||
max_output_bytes,
|
||||
WaitHint::NotRequested,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -242,20 +332,22 @@ impl TaskOutputTool {
|
|||
&& (!initial.pending_bash_ids.is_empty() || !initial.pending_subagent_ids.is_empty())
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
wait_all_event_driven(
|
||||
let wait_hint = wait_all_event_driven(
|
||||
&terminal,
|
||||
&backend,
|
||||
&initial.pending_bash_ids,
|
||||
&initial.pending_subagent_ids,
|
||||
deadline,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.hint(requested, timeout);
|
||||
resolve_tasks(
|
||||
task_ids,
|
||||
&terminal,
|
||||
&backend,
|
||||
&read_file_name,
|
||||
max_output_bytes,
|
||||
wait_hint,
|
||||
)
|
||||
.await
|
||||
.results
|
||||
|
|
@ -317,6 +409,7 @@ pub(crate) async fn resolve_tasks(
|
|||
backend: &Option<SubagentBackendResource>,
|
||||
read_file_name: &str,
|
||||
max_output_bytes: usize,
|
||||
wait_hint: WaitHint,
|
||||
) -> ResolveResult {
|
||||
let mut results = Vec::with_capacity(task_ids.len());
|
||||
let mut pending_bash_ids = Vec::new();
|
||||
|
|
@ -325,7 +418,11 @@ pub(crate) async fn resolve_tasks(
|
|||
for id in task_ids {
|
||||
if let Some(snap) = terminal.get_task(id).await {
|
||||
let is_pending = !snap.completed;
|
||||
results.push(snapshot_to_result(snap, read_file_name, max_output_bytes));
|
||||
results.push(apply_running_wait_hint(
|
||||
snapshot_to_result(snap, read_file_name, max_output_bytes),
|
||||
wait_hint,
|
||||
WaitSubject::Task,
|
||||
));
|
||||
if is_pending {
|
||||
pending_bash_ids.push(id.clone());
|
||||
}
|
||||
|
|
@ -336,7 +433,7 @@ pub(crate) async fn resolve_tasks(
|
|||
&& let Some(snap) = be.backend().query(id, false, None).await
|
||||
{
|
||||
let is_terminal = snap.status.is_terminal();
|
||||
if let TaskOutputOutput::Result(r) = format_subagent_snapshot(&snap) {
|
||||
if let TaskOutputOutput::Result(r) = format_subagent_snapshot(&snap, wait_hint) {
|
||||
if !is_terminal {
|
||||
pending_subagent_ids.push(id.clone());
|
||||
}
|
||||
|
|
@ -381,6 +478,32 @@ impl Drop for AbortWaitsOnDrop {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a multi-task wait returned because the deadline was hit or because
|
||||
/// the wait condition (any/all) completed first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum WaitOutcome {
|
||||
DeadlineElapsed,
|
||||
CompletedEarly,
|
||||
}
|
||||
|
||||
impl WaitOutcome {
|
||||
pub(crate) fn hint(self, requested: Duration, waited: Duration) -> WaitHint {
|
||||
match self {
|
||||
WaitOutcome::DeadlineElapsed => WaitHint::Elapsed { requested, waited },
|
||||
WaitOutcome::CompletedEarly => WaitHint::ReturnedEarly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_wait_outcome(outcome: WaitOutcome, deadline: tokio::time::Instant) -> WaitOutcome {
|
||||
// select! is non-deterministic when both arms are ready at the deadline.
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
WaitOutcome::DeadlineElapsed
|
||||
} else {
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until any one task (bash or subagent) completes, or deadline is reached.
|
||||
pub(crate) async fn wait_any_event_driven(
|
||||
terminal: &std::sync::Arc<dyn crate::computer::types::TerminalBackend>,
|
||||
|
|
@ -388,10 +511,10 @@ pub(crate) async fn wait_any_event_driven(
|
|||
bash_ids: &[String],
|
||||
subagent_ids: &[String],
|
||||
deadline: tokio::time::Instant,
|
||||
) {
|
||||
) -> WaitOutcome {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return;
|
||||
return WaitOutcome::DeadlineElapsed;
|
||||
}
|
||||
|
||||
// Register waiter BEFORE spawns to avoid race: a spawned task could complete
|
||||
|
|
@ -436,10 +559,11 @@ pub(crate) async fn wait_any_event_driven(
|
|||
// deadline, or cancellation of this future.
|
||||
let _guard = AbortWaitsOnDrop(waits);
|
||||
|
||||
tokio::select! {
|
||||
_ = notified => {}
|
||||
_ = tokio::time::sleep_until(deadline) => {}
|
||||
}
|
||||
let outcome = tokio::select! {
|
||||
_ = notified => WaitOutcome::CompletedEarly,
|
||||
_ = tokio::time::sleep_until(deadline) => WaitOutcome::DeadlineElapsed,
|
||||
};
|
||||
finalize_wait_outcome(outcome, deadline)
|
||||
}
|
||||
|
||||
/// Wait until all tasks (bash and subagent) complete, or deadline is reached.
|
||||
|
|
@ -449,10 +573,10 @@ pub(crate) async fn wait_all_event_driven(
|
|||
bash_ids: &[String],
|
||||
subagent_ids: &[String],
|
||||
deadline: tokio::time::Instant,
|
||||
) {
|
||||
) -> WaitOutcome {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return;
|
||||
return WaitOutcome::DeadlineElapsed;
|
||||
}
|
||||
|
||||
let mut handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
||||
|
|
@ -482,10 +606,11 @@ pub(crate) async fn wait_all_event_driven(
|
|||
let _guard = AbortWaitsOnDrop(handles.iter().map(|h| h.abort_handle()).collect());
|
||||
|
||||
let all_fut = futures_util::future::join_all(handles);
|
||||
tokio::select! {
|
||||
_ = all_fut => {}
|
||||
_ = tokio::time::sleep_until(deadline) => {}
|
||||
}
|
||||
let outcome = tokio::select! {
|
||||
_ = all_fut => WaitOutcome::CompletedEarly,
|
||||
_ = tokio::time::sleep_until(deadline) => WaitOutcome::DeadlineElapsed,
|
||||
};
|
||||
finalize_wait_outcome(outcome, deadline)
|
||||
}
|
||||
|
||||
//
|
||||
|
|
@ -502,20 +627,21 @@ fn render_legacy_task_output_not_found(task_id: &str) -> String {
|
|||
format!("Task {} not found", task_id)
|
||||
}
|
||||
|
||||
fn format_subagent_snapshot(snap: &SubagentSnapshot) -> TaskOutputOutput {
|
||||
fn format_subagent_snapshot(snap: &SubagentSnapshot, wait_hint: WaitHint) -> TaskOutputOutput {
|
||||
let started = format_epoch_ms_as_rfc3339(snap.started_at_epoch_ms);
|
||||
match &snap.status {
|
||||
SubagentSnapshotStatus::Initializing => {
|
||||
let duration_secs = snap.duration_ms as f64 / 1000.0;
|
||||
let output = format!(
|
||||
// Measure body only — wait-hint is harness advisory, not task output.
|
||||
let body = format!(
|
||||
"Subagent is initializing (creating worktree, resolving config).\n\
|
||||
Type: {}\n\
|
||||
Description: {}\n\
|
||||
Elapsed: {duration_secs:.1}s\n\n\
|
||||
Use timeout_ms to wait for completion.",
|
||||
Elapsed: {duration_secs:.1}s",
|
||||
snap.subagent_type, snap.description,
|
||||
);
|
||||
let raw_output_bytes = output.len();
|
||||
let raw_output_bytes = body.len();
|
||||
let output = with_still_running_wait_hint(body, wait_hint, WaitSubject::Subagent);
|
||||
TaskOutputOutput::Result(TaskOutputResult {
|
||||
task_id: snap.subagent_id.clone(),
|
||||
command: format!("[subagent:{}] {}", snap.subagent_type, snap.description),
|
||||
|
|
@ -547,7 +673,8 @@ fn format_subagent_snapshot(snap: &SubagentSnapshot) -> TaskOutputOutput {
|
|||
};
|
||||
let tokens_k = tokens_used / 1000;
|
||||
let capacity_k = context_window_tokens / 1000;
|
||||
let output = format!(
|
||||
// Measure body only — wait-hint is harness advisory, not task output.
|
||||
let body = format!(
|
||||
"Subagent is still running.\n\
|
||||
Type: {}\n\
|
||||
Description: {}\n\
|
||||
|
|
@ -555,13 +682,13 @@ fn format_subagent_snapshot(snap: &SubagentSnapshot) -> TaskOutputOutput {
|
|||
Progress: turn {turn_count}, {tool_call_count} tool calls, \
|
||||
{tokens_k}K/{capacity_k}K tokens ({context_usage_pct}% context)\n\
|
||||
Tools used: {tools_str}\n\
|
||||
Errors: {error_count}\n\n\
|
||||
Use timeout_ms to wait for completion.",
|
||||
Errors: {error_count}",
|
||||
snap.subagent_type,
|
||||
snap.description,
|
||||
snap.duration_ms as f64 / 1000.0,
|
||||
);
|
||||
let raw_output_bytes = output.len();
|
||||
let raw_output_bytes = body.len();
|
||||
let output = with_still_running_wait_hint(body, wait_hint, WaitSubject::Subagent);
|
||||
TaskOutputOutput::Result(TaskOutputResult {
|
||||
task_id: snap.subagent_id.clone(),
|
||||
command: format!("[subagent:{}] {}", snap.subagent_type, snap.description),
|
||||
|
|
@ -960,19 +1087,79 @@ mod tests {
|
|||
// unbounded blocking wait wedged the turn for hours).
|
||||
#[test]
|
||||
fn capped_wait_timeout_clamps_and_defaults() {
|
||||
// Omitted -> default 30s.
|
||||
assert_eq!(capped_wait_timeout(None), DEFAULT_WAIT_TIMEOUT);
|
||||
// Small value -> unchanged.
|
||||
assert_eq!(
|
||||
capped_wait_timeout(Some(5_000)),
|
||||
Duration::from_millis(5_000)
|
||||
);
|
||||
// Huge value (10h) -> clamped to the cap.
|
||||
assert_eq!(capped_wait_timeout(Some(36_000_000)), MAX_WAIT_BLOCK);
|
||||
// Exactly at the cap (10m) -> unchanged.
|
||||
assert_eq!(capped_wait_timeout(Some(600_000)), MAX_WAIT_BLOCK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_running_wait_hint_omitted_invites_timeout_ms() {
|
||||
assert_eq!(
|
||||
still_running_wait_hint(WaitHint::NotRequested, WaitSubject::Task),
|
||||
"Use timeout_ms to wait for completion. You will be notified automatically when the task completes."
|
||||
);
|
||||
assert_eq!(
|
||||
still_running_wait_hint(WaitHint::NotRequested, WaitSubject::Subagent),
|
||||
"Use timeout_ms to wait for completion. You will be notified automatically when the subagent completes."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_running_wait_hint_within_cap_reports_auto_wake() {
|
||||
let hint = WaitHint::Elapsed {
|
||||
requested: Duration::from_secs(30),
|
||||
waited: Duration::from_secs(30),
|
||||
};
|
||||
assert_eq!(
|
||||
still_running_wait_hint(hint, WaitSubject::Task),
|
||||
"Waited the requested 30s; the task is still running. \
|
||||
You will be notified automatically when the task completes."
|
||||
);
|
||||
assert_eq!(
|
||||
still_running_wait_hint(hint, WaitSubject::Subagent),
|
||||
"Waited the requested 30s; the subagent is still running. \
|
||||
You will be notified automatically when the subagent completes."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_running_wait_hint_clamped_reports_cap_and_auto_wake() {
|
||||
let hint = WaitHint::Elapsed {
|
||||
requested: Duration::from_secs(2_400),
|
||||
waited: Duration::from_secs(600),
|
||||
};
|
||||
assert_eq!(
|
||||
still_running_wait_hint(hint, WaitSubject::Task),
|
||||
"Waited 600s, the per-call maximum, of the 2400s you requested; \
|
||||
the task is still running. You do not need to call this again. \
|
||||
You will be notified automatically when the task completes."
|
||||
);
|
||||
assert_eq!(
|
||||
still_running_wait_hint(hint, WaitSubject::Subagent),
|
||||
"Waited 600s, the per-call maximum, of the 2400s you requested; \
|
||||
the subagent is still running. You do not need to call this again. \
|
||||
You will be notified automatically when the subagent completes."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_running_wait_hint_returned_early_is_honest() {
|
||||
assert_eq!(
|
||||
still_running_wait_hint(WaitHint::ReturnedEarly, WaitSubject::Task),
|
||||
"Wait returned early because another finished; this task is still running. \
|
||||
You will be notified automatically when the task completes."
|
||||
);
|
||||
assert_eq!(
|
||||
still_running_wait_hint(WaitHint::ReturnedEarly, WaitSubject::Subagent),
|
||||
"Wait returned early because another finished; this subagent is still running. \
|
||||
You will be notified automatically when the subagent completes."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_and_description() {
|
||||
let tool = TaskOutputTool;
|
||||
|
|
@ -1661,7 +1848,7 @@ mod tests {
|
|||
started_at_epoch_ms: 1_700_000_000_000,
|
||||
duration_ms: 8_500,
|
||||
};
|
||||
let result = format_subagent_snapshot(&snap);
|
||||
let result = format_subagent_snapshot(&snap, WaitHint::NotRequested);
|
||||
match result {
|
||||
TaskOutputOutput::Result(r) => {
|
||||
assert_eq!(r.task_id, "sub-init");
|
||||
|
|
@ -1711,7 +1898,7 @@ mod tests {
|
|||
started_at_epoch_ms: 1_700_000_000_000,
|
||||
duration_ms: 12_500,
|
||||
};
|
||||
let result = format_subagent_snapshot(&snap);
|
||||
let result = format_subagent_snapshot(&snap, WaitHint::NotRequested);
|
||||
match result {
|
||||
TaskOutputOutput::Result(r) => {
|
||||
assert_eq!(r.task_id, "sub-abc");
|
||||
|
|
@ -1756,6 +1943,65 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// raw_output_bytes is body-only so identical Running state is stable across WaitHints.
|
||||
#[test]
|
||||
fn format_running_subagent_raw_output_bytes_stable_across_wait_hints() {
|
||||
let snap = SubagentSnapshot {
|
||||
subagent_id: "sub-stable".to_string(),
|
||||
description: "stable body".to_string(),
|
||||
subagent_type: "explore".to_string(),
|
||||
persona: None,
|
||||
status: SubagentSnapshotStatus::Running {
|
||||
turn_count: 1,
|
||||
tool_call_count: 2,
|
||||
tokens_used: 3_000,
|
||||
context_window_tokens: 128_000,
|
||||
context_usage_pct: 2,
|
||||
tools_used: vec!["bash".to_string()],
|
||||
error_count: 0,
|
||||
},
|
||||
started_at_epoch_ms: 1_700_000_000_000,
|
||||
duration_ms: 1_000,
|
||||
};
|
||||
let expected_body = format!(
|
||||
"Subagent is still running.\n\
|
||||
Type: {}\n\
|
||||
Description: {}\n\
|
||||
Elapsed: {:.1}s\n\
|
||||
Progress: turn 1, 2 tool calls, \
|
||||
3K/128K tokens (2% context)\n\
|
||||
Tools used: bash\n\
|
||||
Errors: 0",
|
||||
snap.subagent_type,
|
||||
snap.description,
|
||||
snap.duration_ms as f64 / 1000.0,
|
||||
);
|
||||
let not_requested = match format_subagent_snapshot(&snap, WaitHint::NotRequested) {
|
||||
TaskOutputOutput::Result(r) => r,
|
||||
other => panic!("Expected Result, got {:?}", other),
|
||||
};
|
||||
let clamped = match format_subagent_snapshot(
|
||||
&snap,
|
||||
WaitHint::Elapsed {
|
||||
requested: Duration::from_secs(2_400),
|
||||
waited: Duration::from_secs(600),
|
||||
},
|
||||
) {
|
||||
TaskOutputOutput::Result(r) => r,
|
||||
other => panic!("Expected Result, got {:?}", other),
|
||||
};
|
||||
assert_eq!(not_requested.raw_output_bytes, expected_body.len());
|
||||
assert_eq!(clamped.raw_output_bytes, expected_body.len());
|
||||
assert_eq!(not_requested.raw_output_bytes, clamped.raw_output_bytes);
|
||||
assert!(not_requested.output.starts_with(&expected_body));
|
||||
assert!(clamped.output.starts_with(&expected_body));
|
||||
assert_ne!(
|
||||
not_requested.output.len(),
|
||||
clamped.output.len(),
|
||||
"hint variants must still produce different formatted output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_running_subagent_with_no_tools_shows_none_yet() {
|
||||
let snap = SubagentSnapshot {
|
||||
|
|
@ -1775,7 +2021,7 @@ mod tests {
|
|||
started_at_epoch_ms: 1_700_000_000_000,
|
||||
duration_ms: 500,
|
||||
};
|
||||
let result = format_subagent_snapshot(&snap);
|
||||
let result = format_subagent_snapshot(&snap, WaitHint::NotRequested);
|
||||
match result {
|
||||
TaskOutputOutput::Result(r) => {
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
use crate::DEFAULT_TOOL_OUTPUT_BYTES;
|
||||
use crate::implementations::grok_build::task::backend::SubagentBackendResource;
|
||||
use crate::implementations::grok_build::task_output::{
|
||||
MAX_MULTI_WAIT_IDS, TaskOutputTool, resolve_tasks, wait_any_event_driven,
|
||||
MAX_MULTI_WAIT_IDS, TaskOutputTool, WaitHint, resolve_tasks, wait_any_event_driven,
|
||||
};
|
||||
use crate::types::requirements::{Expr, ToolRequirement};
|
||||
use crate::types::resources::{Terminal, TruncationCfg};
|
||||
|
|
@ -169,6 +169,10 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
|
|||
}
|
||||
|
||||
// wait_any: keep legacy event-driven path (not exposed on get_task_output).
|
||||
let requested = input
|
||||
.timeout_ms
|
||||
.map(std::time::Duration::from_millis)
|
||||
.unwrap_or(super::DEFAULT_WAIT_TIMEOUT);
|
||||
let timeout =
|
||||
crate::implementations::grok_build::task_output::capped_wait_timeout(input.timeout_ms);
|
||||
|
||||
|
|
@ -198,6 +202,7 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
|
|||
&backend,
|
||||
&read_file_name,
|
||||
max_output_bytes,
|
||||
WaitHint::NotRequested,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -206,20 +211,22 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
|
|||
|
||||
let results = if has_pending {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
wait_any_event_driven(
|
||||
let wait_hint = wait_any_event_driven(
|
||||
&terminal,
|
||||
&backend,
|
||||
&initial.pending_bash_ids,
|
||||
&initial.pending_subagent_ids,
|
||||
deadline,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.hint(requested, timeout);
|
||||
resolve_tasks(
|
||||
&input.task_ids,
|
||||
&terminal,
|
||||
&backend,
|
||||
&read_file_name,
|
||||
max_output_bytes,
|
||||
wait_hint,
|
||||
)
|
||||
.await
|
||||
.results
|
||||
|
|
|
|||
Loading…
Reference in a new issue