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:
grokkybara[bot] 2026-07-29 17:17:54 +00:00
commit 500129c714
89 changed files with 3841 additions and 771 deletions

View file

@ -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

View file

@ -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,

View file

@ -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)."#
}

View file

@ -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!(

View file

@ -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