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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/stella-core/src/accounted_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ pub async fn run_accounted_call(
// window against the count at its end already gives each window its own
// delta regardless of how many attempts ran inside it.
let progress = StreamProgress::default();
let mut no_parking = crate::retry::NoParking;
let future = retry_with_backoff_observed(
&call.retry_policy,
sleeper,
Expand Down Expand Up @@ -230,6 +231,13 @@ pub async fn run_accounted_call(
error.partial_usage().copied(),
);
},
// Auxiliary calls never park on a rate limit (#2677): only the
// engine's worker path converts sustained 429s into a budgeted wait.
// A summarizer/triage/authoring call failing fast is the same
// capacity-cascade posture the comparator takes for its background
// request sources — the caller falls through or retries at its own
// layer instead of amplifying pressure from inside a helper.
&mut no_parking,
);
let outcome = match call.timeout {
Some(limit) => {
Expand Down
107 changes: 14 additions & 93 deletions crates/stella-core/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ use crate::hooks::{HookEvent, HookPayload, HookRunner, Hooks, any_matcher_matche
use crate::loop_detect::LoopDetectionConfig;
use crate::ports::ToolExecutor;
use crate::receipts::ReceiptLedger;
use crate::retry::{RetryOutcome, RetryPolicy, Sleeper, retry_with_backoff_observed};
use crate::retry::{RetryOutcome, RetryPolicy, Sleeper};
use crate::speculation::{SpeculationGate, SpeculationPool, SpeculativeResult};
use crate::step::{
AbortKind, BorrowedTurn, CancelUsageGuard, CompactionPass, SpeculationDropGuard, StepOutcome,
StreamProgress, SummarizerHealth, TurnState, bounded_generation,
AbortKind, BorrowedTurn, CompactionPass, SpeculationDropGuard, StepOutcome, StreamProgress,
SummarizerHealth, TurnState, bounded_generation,
};
pub(crate) use truncation::CONTINUATION_MARKER_PREFIX;
use truncation::{ContinuationBudget, ContinuationPlan, TIME_EXHAUSTED_PARTIAL, plan_continuation};
Expand Down Expand Up @@ -129,6 +129,7 @@ use tokio::sync::mpsc::UnboundedSender;

mod dispatch;
mod drive;
mod rate_limit;
mod settlement;
mod waiting;
use settlement::{BudgetWarnings, emit_budget_warning, record_settled_cost};
Expand Down Expand Up @@ -1707,98 +1708,18 @@ impl<'a> Engine<'a> {
})
});

let call_started = std::time::Instant::now();
// Armed for exactly the interval where a paid attempt may be in
// flight: a caller-side hard cancel that drops this future mid-await
// still leaves one content-free `Cancelled` envelope behind.
// Disarmed on BOTH normal exits — a success reports through its
// `StepUsage`, a terminal failure through the per-attempt observer
// below. The shared latch narrows "in flight" to the dispatch
// itself: a drop landing in a backoff sleep emits nothing.
let mut cancel_guard = CancelUsageGuard {
events: events.clone(),
role: self.call_role,
provider: self.provider.id().to_string(),
started: call_started,
armed: true,
attempt_in_flight,
};
let incomplete_events = events.clone();
// Every failed attempt's reason, accumulated through the observer:
// retry.rs returns retry history only for calls that COMMIT, so on
// exhaustion this is the sole record of the doomed attempts
// (receipts spec §6.3 — RetriesExhausted). A std Mutex, never held
// across an await and contention-free — the observer runs serially
// within this call.
let attempt_reasons: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
// The ladder itself — the cancellation usage guard, per-attempt
// incompleteness envelopes, parked rate-limit recovery (#2677),
// provider-outcome feedback (#2673), and the exhaustion event pair —
// lives in `driver/rate_limit.rs`.
let (call_started, outcome) = self
.drive_attempt_ladder(attempt, attempt_in_flight, budget, events)
.await?;
let RetryOutcome {
value: (result, speculation_future),
retries,
..
} = match retry_with_backoff_observed(
&self.config.retry_policy,
self.sleeper,
attempt,
// Per-attempt duration (retry.rs times each dispatch
// individually): the failed call's own latency, never
// cumulative across earlier attempts or backoff sleeps.
|attempt, error, attempt_duration| {
attempt_reasons
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(error.to_string());
let _ = incomplete_events.send(AgentEvent::UsageIncomplete {
role: self.call_role,
provider: self.provider.id().to_string(),
model: "unknown".into(),
reason: stella_protocol::UsageIncompleteReason::ProviderError,
duration_ms: attempt_duration.as_millis() as u64,
retries: Some(attempt.saturating_sub(1)),
// Whatever the adapter salvaged from the dying stream.
// This observer is the only place it can be read: retry.rs
// returns history only for calls that COMMIT, so a doomed
// attempt's accounting reaches the wire here or nowhere.
partial: error.partial_usage().copied(),
});
},
)
.await
{
Ok(outcome) => {
cancel_guard.disarm();
outcome
}
Err(error) => {
cancel_guard.disarm();
let reasons =
std::mem::take(&mut *attempt_reasons.lock().unwrap_or_else(|p| p.into_inner()));
// Shared with the paired `Error` event below: whether the
// FINAL attempt's error is of a retryable class. `false`
// means every attempt (typically just one — see
// `retry_with_backoff_observed`, which bails on the first
// non-retryable error) was doomed from the start, not
// exhausted by an actual retry loop (#926).
let retryable = error.is_retryable();
let _ = events.send(AgentEvent::RetriesExhausted {
turn_instance: self.config.turn_instance,
attempts: reasons.len() as u32,
reasons,
retryable,
});
let message = error.to_string();
let _ = events.send(AgentEvent::Error {
message: message.clone(),
retryable,
});
if let Some(outcomes) = self.outcomes {
outcomes.record_failure(self.provider.id());
}
return Err(format!("model call failed: {message}"));
}
};
if let Some(outcomes) = self.outcomes {
outcomes.record_success(self.provider.id());
}
} = outcome;
// One boundary read: the call's duration and the tick's clock axis.
let now = std::time::Instant::now();
let call_duration_ms = now.duration_since(call_started).as_millis() as u64;
Expand Down Expand Up @@ -2417,8 +2338,8 @@ impl<'a> Engine<'a> {
}
}

/// The boxed-future shape [`retry_with_backoff_observed`] needs from its
/// `attempt_fn` — named here purely to keep the call site in
/// The boxed-future shape [`crate::retry::retry_with_backoff_observed`]
/// needs from its `attempt_fn` — named here purely to keep the call site in
/// [`Engine::run_model_call`] readable. Each attempt yields the completion
/// AND its still-live speculation future as one value. The caller settles the billed completion synchronously before
/// awaiting that future, closing the cancellation window without moving the
Expand Down
Loading