Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -5,7 +5,10 @@
//! Cancellation is cooperative via `CancellationToken`.
use std::pin::pin;
use std::sync::{Arc, Mutex};
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use futures_util::StreamExt;
@ -26,7 +29,8 @@ use crate::metrics::InferenceLatencyStats;
use crate::retry::{
self as retry_mod, RetryDecision, classify_error, clone_error, resolve_max_retries,
};
use crate::stream::{stream_chat_completions, stream_messages, stream_responses};
use crate::stream::responses::stream_responses_tracked;
use crate::stream::{stream_chat_completions, stream_messages};
use crate::types::RequestId;
/// Default per-chunk idle timeout when neither config nor caller
@ -89,7 +93,12 @@ pub(crate) async fn run_request_task(
.idle_timeout_secs
.unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS),
);
let max_retries = resolve_max_retries(config.max_retries.or(Some(retry_policy.max_retries)));
let configured_max_retries = config.max_retries.or(Some(retry_policy.max_retries));
let max_retries = if configured_max_retries == Some(0) {
0
} else {
resolve_max_retries(configured_max_retries)
};
// Build the initial client. Configuration errors here are fatal
// (no point retrying with the same broken config).
@ -117,9 +126,12 @@ pub(crate) async fn run_request_task(
let mut retry_count: u32 = 0;
// Doom-loop recovery keeps its own resample budget, independent of the
// transport/empty budget above.
let doom_policy = config.doom_loop_recovery;
let doom_policy = (max_retries > 0)
.then_some(config.doom_loop_recovery)
.flatten();
let doom_max_retries = doom_policy.map_or(0, |p| p.max_retries);
let mut doom_retry_count: u32 = 0;
let output_observed = Arc::new(AtomicBool::new(false));
loop {
if cancel_token.is_cancelled() {
@ -138,10 +150,18 @@ pub(crate) async fn run_request_task(
&event_tx,
&cancel_token,
doom_check,
Arc::clone(&output_observed),
)
.instrument(sampling_span.clone())
.await;
let effective_max_retries =
if retry_policy.retry_only_before_output && output_observed.load(Ordering::Relaxed) {
0
} else {
max_retries
};
match outcome {
AttemptOutcome::Completed {
response,
@ -196,13 +216,14 @@ pub(crate) async fn run_request_task(
if !apply_retry_decision(
&err,
&mut retry_count,
max_retries,
effective_max_retries,
&retry_policy,
&event_tx,
&request_id,
&mut request,
&mut client,
&config,
&cancel_token,
&mut completion_tx,
)
.await
@ -215,6 +236,13 @@ pub(crate) async fn run_request_task(
// consult the transport classifier, so no classifier change
// can silently debit the transport budget for a doom failure.
if let SamplingError::DoomLoopDetected { .. } = &error {
if retry_policy.retry_only_before_output
&& output_observed.load(Ordering::Relaxed)
{
emit_failed(&event_tx, &request_id, &error);
send_completion(&mut completion_tx, Err(clone_error(&error)));
return request_id;
}
let backoff = retry_mod::doom_loop_backoff(doom_retry_count + 1);
doom_retry_count += 1;
tracing::warn!(
@ -232,19 +260,23 @@ pub(crate) async fn run_request_task(
doom_max_retries,
&error,
);
tokio::time::sleep(backoff).await;
continue;
if sleep_or_cancel(backoff, &cancel_token).await {
continue;
}
handle_cancellation(&event_tx, &request_id, &mut completion_tx);
return request_id;
}
if !apply_retry_decision(
&error,
&mut retry_count,
max_retries,
effective_max_retries,
&retry_policy,
&event_tx,
&request_id,
&mut request,
&mut client,
&config,
&cancel_token,
&mut completion_tx,
)
.await
@ -260,13 +292,14 @@ pub(crate) async fn run_request_task(
if !apply_retry_decision(
&error,
&mut retry_count,
max_retries,
effective_max_retries,
&retry_policy,
&event_tx,
&request_id,
&mut request,
&mut client,
&config,
&cancel_token,
&mut completion_tx,
)
.await
@ -294,6 +327,7 @@ async fn apply_retry_decision(
request: &mut ConversationRequest,
client: &mut SamplingClient,
config: &SamplerConfig,
cancel_token: &CancellationToken,
completion_tx: &mut Option<oneshot::Sender<CompletionResult>>,
) -> bool {
let rate_limit_threshold = if retry_policy.rate_limit_retry_threshold == 0 {
@ -321,14 +355,22 @@ async fn apply_retry_decision(
RetryDecision::Retry { backoff } => {
*retry_count += 1;
emit_retrying(event_tx, request_id, *retry_count, max_retries, err);
tokio::time::sleep(backoff).await;
true
if sleep_or_cancel(backoff, cancel_token).await {
true
} else {
handle_cancellation(event_tx, request_id, completion_tx);
false
}
}
RetryDecision::RetryWithBackoff { backoff, .. } => {
*retry_count += 1;
emit_retrying(event_tx, request_id, *retry_count, max_retries, err);
tokio::time::sleep(backoff).await;
true
if sleep_or_cancel(backoff, cancel_token).await {
true
} else {
handle_cancellation(event_tx, request_id, completion_tx);
false
}
}
RetryDecision::RetryWithImageStrip => {
let stripped = request.strip_images();
@ -345,7 +387,10 @@ async fn apply_retry_decision(
RetryDecision::RetryWithClientRebuild { backoff } => {
*retry_count += 1;
emit_retrying(event_tx, request_id, *retry_count, max_retries, err);
tokio::time::sleep(backoff).await;
if !sleep_or_cancel(backoff, cancel_token).await {
handle_cancellation(event_tx, request_id, completion_tx);
return false;
}
// Rebuild client with HTTP/1.1 fallback to escape poisoned
// HTTP/2 connection pools.
@ -408,6 +453,14 @@ async fn apply_retry_decision(
}
}
async fn sleep_or_cancel(duration: Duration, cancel_token: &CancellationToken) -> bool {
tokio::select! {
biased;
_ = cancel_token.cancelled() => false,
_ = tokio::time::sleep(duration) => true,
}
}
/// Run a single attempt: build the raw stream, drive it through the
/// matching L2 transform, and forward all non-terminal events to
/// `event_tx`. Captures the rich `SamplingError` from the underlying
@ -416,6 +469,7 @@ async fn apply_retry_decision(
/// `doom_check` is the doom-loop policy while the resample budget lasts;
/// `None` disarms the mid-stream abort and the terminal confidence check so
/// the attempt completes and its response can be accepted.
#[allow(clippy::too_many_arguments)]
async fn run_one_attempt(
client: &SamplingClient,
request: ConversationRequest,
@ -424,6 +478,7 @@ async fn run_one_attempt(
event_tx: &mpsc::UnboundedSender<SamplingEvent>,
cancel_token: &CancellationToken,
doom_check: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>,
output_observed: Arc<AtomicBool>,
) -> AttemptOutcome {
match client.api_backend() {
ApiBackend::ChatCompletions => {
@ -433,7 +488,16 @@ async fn run_one_attempt(
};
let (teed, captured) = tee_errors(raw);
let l2 = stream_chat_completions(teed, metadata, request_id.clone(), idle_timeout);
drive_l2(l2, request_id, event_tx, cancel_token, captured, None).await
drive_l2(
l2,
request_id,
event_tx,
cancel_token,
captured,
None,
output_observed,
)
.await
}
ApiBackend::Responses => {
let (raw, metadata, doom_loop) =
@ -447,8 +511,24 @@ async fn run_one_attempt(
collector.disarm_abort();
}
let (teed, captured) = tee_errors(raw);
let l2 = stream_responses(teed, metadata, request_id.clone(), idle_timeout, doom_loop);
drive_l2(l2, request_id, event_tx, cancel_token, captured, doom_check).await
let l2 = stream_responses_tracked(
teed,
metadata,
request_id.clone(),
idle_timeout,
doom_loop,
Arc::clone(&output_observed),
);
drive_l2(
l2,
request_id,
event_tx,
cancel_token,
captured,
doom_check,
output_observed,
)
.await
}
ApiBackend::Messages => {
let (raw, metadata) = match client.conversation_stream_messages(request).await {
@ -457,7 +537,16 @@ async fn run_one_attempt(
};
let (teed, captured) = tee_errors(raw);
let l2 = stream_messages(teed, metadata, request_id.clone(), idle_timeout);
drive_l2(l2, request_id, event_tx, cancel_token, captured, None).await
drive_l2(
l2,
request_id,
event_tx,
cancel_token,
captured,
None,
output_observed,
)
.await
}
}
}
@ -497,6 +586,7 @@ fn tee_errors<'a, T: Send + 'a>(
/// the terminal event (or cancellation). `doom_check`, when set, turns a
/// completed response carrying confident doom-loop signals into a
/// retryable failure (belt-and-braces behind the mid-stream abort).
#[allow(clippy::too_many_arguments)]
async fn drive_l2(
l2: impl futures_util::Stream<Item = SamplingEvent>,
request_id: RequestId,
@ -504,6 +594,7 @@ async fn drive_l2(
cancel_token: &CancellationToken,
captured: ErrorCell,
doom_check: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>,
output_observed: Arc<AtomicBool>,
) -> AttemptOutcome {
let mut l2 = pin!(l2);
loop {
@ -514,6 +605,7 @@ async fn drive_l2(
}
next = l2.next() => match next {
Some(SamplingEvent::Completed { response, metrics, .. }) => {
output_observed.store(true, Ordering::Relaxed);
// Doom outranks the truncation/empty classes: a confident
// loop poisons the attempt whatever else it looks like.
if let Some(policy) = doom_check {
@ -552,6 +644,16 @@ async fn drive_l2(
return AttemptOutcome::Failed { error };
}
Some(other) => {
if matches!(
other,
SamplingEvent::FirstToken { .. }
| SamplingEvent::ChannelToken { .. }
| SamplingEvent::ToolCallDelta { .. }
| SamplingEvent::BackendToolCallStarted { .. }
| SamplingEvent::BackendToolCallCompleted { .. }
) {
output_observed.store(true, Ordering::Relaxed);
}
let _ = event_tx.send(retag(other, &request_id));
}
None => {
@ -842,6 +944,60 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
async fn retry_sleep_returns_immediately_on_cancellation() {
let cancel_token = CancellationToken::new();
let sleeper = sleep_or_cancel(Duration::from_secs(120), &cancel_token);
tokio::pin!(sleeper);
cancel_token.cancel();
assert!(!sleeper.await);
}
#[tokio::test(start_paused = true)]
async fn retry_decision_cancellation_emits_terminal_cancel() {
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let (completion_tx, completion_rx) = oneshot::channel();
let mut completion_tx = Some(completion_tx);
let mut retry_count = 0;
let mut request = ConversationRequest::default();
let config = SamplerConfig {
base_url: "http://localhost".into(),
model: "test-model".into(),
..Default::default()
};
let mut client = SamplingClient::new(config.clone()).expect("test client");
let error = SamplingError::EventStreamError("retry me".into());
let should_continue = apply_retry_decision(
&error,
&mut retry_count,
2,
&RetryPolicy::default(),
&event_tx,
&RequestId::from("cancel-backoff"),
&mut request,
&mut client,
&config,
&cancel_token,
&mut completion_tx,
)
.await;
assert!(!should_continue);
assert!(matches!(
event_rx.recv().await,
Some(SamplingEvent::Retrying { .. })
));
assert!(matches!(
event_rx.recv().await,
Some(SamplingEvent::Failed { .. })
));
assert!(completion_rx.await.expect("completion sent").is_err());
}
#[tokio::test]
async fn tee_captures_first_error_only() {
let items: Vec<SamplingResult<u32>> = vec![

View file

@ -184,6 +184,8 @@ pub struct RetryPolicy {
/// After this many rate-limit (429) retries, escalate to the caller.
/// Lower than `max_retries` because rate-limit waits can be long.
pub rate_limit_retry_threshold: u32,
#[serde(default)]
pub retry_only_before_output: bool,
}
impl Default for RetryPolicy {
@ -191,6 +193,7 @@ impl Default for RetryPolicy {
Self {
max_retries: DEFAULT_MAX_RETRIES,
rate_limit_retry_threshold: RATE_LIMIT_RETRY_THRESHOLD,
retry_only_before_output: false,
}
}
}

View file

@ -156,6 +156,9 @@ pub fn classify_error(
if err.is_encrypted_content_error() {
return RetryDecision::EmitToSession(clone_error(err));
}
if max_retries == 0 {
return RetryDecision::Fatal(clone_error(err));
}
// 413 Payload Too Large: strip inline images and try once. The
// caller checks if there are images left after the strip; if not,
@ -209,6 +212,9 @@ pub fn classify_error(
if err.is_rate_limited() {
let next_attempt = retry_count + 1;
let effective_cap = max_retries.min(rate_limit_threshold);
if effective_cap == 0 {
return RetryDecision::Fatal(clone_error(err));
}
if next_attempt >= effective_cap {
return RetryDecision::Fatal(clone_error(err));
}
@ -227,7 +233,7 @@ pub fn classify_error(
// later retries just back off.
if err.is_retryable() {
let next_attempt = retry_count + 1;
if next_attempt >= max_retries {
if max_retries == 0 || next_attempt >= max_retries {
return RetryDecision::Fatal(clone_error(err));
}
let backoff = err
@ -619,6 +625,34 @@ mod tests {
}
}
#[test]
fn zero_retry_budget_never_reuses_a_model_output_cap() {
for err in [
api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom"),
api_err(StatusCode::PAYLOAD_TOO_LARGE, "too big"),
api_err(StatusCode::BAD_REQUEST, "Could not process image"),
SamplingError::EmptyResponse {
context: xai_grok_sampling_types::EmptyResponseContext {
reason: xai_grok_sampling_types::EmptyReason::NoVisibleContent,
had_reasoning: false,
content_len: 0,
tool_call_count: 0,
finish_reason: Some("stop".into()),
completion_tokens: Some(1),
reasoning_tokens: Some(0),
prompt_tokens: Some(10),
model: "m".into(),
first_choice_seen: true,
},
},
] {
assert!(matches!(
classify_error(&err, 0, 0, RATE_LIMIT_RETRY_THRESHOLD),
RetryDecision::Fatal(_)
));
}
}
#[test]
fn classify_5xx_first_retry_rebuilds_client() {
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");

View file

@ -4,6 +4,10 @@
//! [`SamplingEvent`]s. Pure: no I/O, no shell coupling.
use std::collections::BTreeMap;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
@ -45,8 +49,15 @@ pub(crate) fn responses_event_has_meaningful_content(event: &rs::ResponseStreamE
ResponseStreamEvent::ResponseCodeInterpreterCallCodeDone(event) => !event.code.is_empty(),
ResponseStreamEvent::ResponseCustomToolCallInputDelta(event) => !event.delta.is_empty(),
ResponseStreamEvent::ResponseCustomToolCallInputDone(event) => !event.input.is_empty(),
ResponseStreamEvent::ResponseFailed(event) => {
!event.response.output.is_empty()
|| event
.response
.usage
.as_ref()
.is_some_and(|usage| usage.output_tokens > 0)
}
ResponseStreamEvent::ResponseCompleted(_)
| ResponseStreamEvent::ResponseFailed(_)
| ResponseStreamEvent::ResponseIncomplete(_)
| ResponseStreamEvent::ResponseOutputItemAdded(_)
| ResponseStreamEvent::ResponseOutputItemDone(_)
@ -78,6 +89,11 @@ pub(crate) fn responses_event_has_meaningful_content(event: &rs::ResponseStreamE
}
}
pub(crate) fn responses_event_may_have_output(event: &rs::ResponseStreamEvent) -> bool {
!matches!(event, rs::ResponseStreamEvent::ResponseError(_))
&& responses_event_has_meaningful_content(event)
}
/// Transform a raw Responses API event stream into a stream of
/// [`SamplingEvent`]s.
///
@ -97,6 +113,24 @@ pub fn stream_responses<'a>(
request_id: RequestId,
idle_timeout: Duration,
doom_loop: Option<crate::doom_loop::DoomLoopSignalCollector>,
) -> impl Stream<Item = SamplingEvent> + Send + 'a {
stream_responses_tracked(
raw_stream,
model_metadata,
request_id,
idle_timeout,
doom_loop,
Arc::new(AtomicBool::new(false)),
)
}
pub(crate) fn stream_responses_tracked<'a>(
raw_stream: BoxStream<'a, Result<rs::ResponseStreamEvent, SamplingError>>,
model_metadata: Option<ResponseModelMetadata>,
request_id: RequestId,
idle_timeout: Duration,
doom_loop: Option<crate::doom_loop::DoomLoopSignalCollector>,
output_observed: Arc<AtomicBool>,
) -> impl Stream<Item = SamplingEvent> + Send + 'a {
async_stream::stream! {
use rs::{ResponseStreamEvent, Status};
@ -158,6 +192,10 @@ pub fn stream_responses<'a>(
}
};
if responses_event_may_have_output(&event) {
output_observed.store(true, Ordering::Relaxed);
}
// A confident server-detected loop aborts the attempt (dropping
// the SSE connection) so the retry loop can resample instead of
// streaming the burning tail. Checked before the event is
@ -658,6 +696,15 @@ mod tests {
}
}
#[test]
fn empty_failed_response_is_not_treated_as_output() {
let event = rs::ResponseStreamEvent::ResponseFailed(rs_types::ResponseFailedEvent {
response: failed_response_with_error("boom"),
sequence_number: 0,
});
assert!(!responses_event_may_have_output(&event));
}
#[tokio::test]
async fn response_failed_yields_failed_500() {
let failed = rs::ResponseStreamEvent::ResponseFailed(rs_types::ResponseFailedEvent {
@ -766,6 +813,67 @@ mod tests {
assert!(responses_event_has_meaningful_content(&completed_event()));
}
#[test]
fn output_classifier_covers_non_forwarded_backend_events() {
let queued = rs::ResponseStreamEvent::ResponseQueued(rs_types::ResponseQueuedEvent {
sequence_number: 0,
response: empty_completed_response(),
});
assert!(!responses_event_may_have_output(&queued));
let response_error = rs::ResponseStreamEvent::ResponseError(rs_types::ResponseErrorEvent {
sequence_number: 1,
code: Some("server_error".into()),
message: "failed before output".into(),
param: None,
});
assert!(!responses_event_may_have_output(&response_error));
let refusal =
rs::ResponseStreamEvent::ResponseRefusalDelta(rs_types::ResponseRefusalDeltaEvent {
sequence_number: 1,
item_id: "item-1".into(),
output_index: 0,
content_index: 0,
delta: "no".into(),
});
assert!(responses_event_may_have_output(&refusal));
let backend_progress = rs::ResponseStreamEvent::ResponseWebSearchCallSearching(
rs_types::ResponseWebSearchCallSearchingEvent {
sequence_number: 2,
output_index: 0,
item_id: "search-1".into(),
},
);
assert!(responses_event_may_have_output(&backend_progress));
}
#[tokio::test]
async fn tracked_stream_marks_non_forwarded_refusal_as_output() {
let output_observed = Arc::new(AtomicBool::new(false));
let refusal =
rs::ResponseStreamEvent::ResponseRefusalDelta(rs_types::ResponseRefusalDeltaEvent {
sequence_number: 0,
item_id: "item-1".into(),
output_index: 0,
content_index: 0,
delta: "no".into(),
});
let raw = stream::iter(vec![Ok(refusal), Ok(completed_event())]).boxed();
let _ = collect(stream_responses_tracked(
raw,
None,
rid(),
Duration::from_secs(60),
None,
Arc::clone(&output_observed),
))
.await;
assert!(output_observed.load(Ordering::Relaxed));
}
fn function_call_added_event(
output_index: u32,
call_id: &str,