Report throughput and cost truthfully and make goals durable (#5977 #5976)

Throughput (#5977): the infoline and the session accumulator now share one
definition — provider-reported output tokens divided by measured request
seconds, labeled avg tok/s across all 15 packs. Streaming and non-streaming
requests agree; connection/first-token/in-response waiting counts, tool and
inter-request idle time does not; unmeasured aggregate receipts enter
neither side; live text estimates no longer invent a rate. The redundant
whole-turn rate state and its consumers are removed, and the English and
Chinese guides match the implementation.

Cost (#5976): missing-cost reasons are preserved through the shared
UsageChip, session metrics, activity detail, /cost and the /cache footer —
now with localized explanations instead of a bare unknown. Exact Model
Studio and Volcengine plan endpoints are recognized, provider
classification is exhaustive and pinned by a 49-provider default/alternate-
route audit guard (docs: artifacts/0913-provider-billing-audit.md), and
Concentrate keeps its explicit routing-dependent reason unless a verified
scoped offering or operator override applies — a requested prefix is never
treated as proof of the serving provider.

Goals: durable stall accounting flows through protocol/SQL v5, the TUI
sidecar v2 and full snapshot restoration; a Runtime turn admitted with no
host goal persists a model-created goal via create-if-absent CAS and adopts
its revision (a concurrent explicit PUT/DELETE always wins); corrupt
Active-at-ceiling stall records restore paused at every load boundary;
explicit resume/replacement revisions move through the existing FIFO. The
contradictory hidden Runtime token-budget stop is removed.

Goal UX: the model reports progress through update_goal (percent + now +
next), rendered as a transcript receipt with a bar and as a live
Goal (Nm) NN% reading on the metrics line, localized in 15 packs.
[goal] max_steps (#5994) gives goal turns a finite 1,000-step default (the
interactive 200 is unchanged; exec/worker ceilings always win); the soft
landing names the governing key, and exhaustion grants one bounded final
report, classifies BudgetExhausted, then pauses the unfinished goal with
BudgetLimit instead of re-arming.

Also in this slice: exact-resume hint reconstructs canonical-UUID commands
on TTY only (#6001); [tools] user_input_timeout_seconds governs question
and approval waits, 0 disables (#6003); session_idle / session_error /
waiting_for_user hook events fire on the shared turn-state projection,
with Waiting covering approval, user-input and goal-continuation waits
(#6004); slash commands and absolute paths persist in composer history
(#6006); OpenRouter vendor pinning is documented (#6007).

Evidence (this tree): lib suite 11974 passed / 0 failed; workspace
all-features suite green; workspace Clippy (all targets, all features, -D
warnings) green; doctests green; locale parity 2147 keys x 15 packs PASS;
goal filters 137, pricing 84, route_billing 50, chip 12, localization 51,
concentrate 8, model_created/transition/corrupt-stall 5, resume-hint 3,
goal-budget 3, session-state 4, composer-history 11 — all green.
This commit is contained in:
CodeWhale Bot committed 2026-09-07 18:59:06 -07:00
1 parent 5227cc3313
commit f5755a8832
65 files changed
+4231 -826

No files matched your search

+1 -1
View File
@@ -109,7 +109,7 @@ with an accessibility-first pointer.
bar's plan/act/operate chip. The `status`, `agents`, `reasoning_replay`,
`prefix_stability`, `git_branch`, `last_tool_elapsed` and `rate_limit`
items drove nothing and are retired; an existing `config.toml` still loads
and those keys are ignored (#5950).
and those keys are ignored (#5950, #5962).
- The context reading is back on screen at every fullness. 0.9.12 painted
`ctx NN%` only from 50% up, which left most of a session with no context
signal at all; it now paints from 0% and keeps its warning colour from 80%
+9
View File
@@ -245,6 +245,10 @@ memory_path = "~/.codewhale/memory.md"
# supported ranges (1..=10 and 2..=10) with a warning.
# user_input_max_questions = 6
# user_input_max_options = 4
#
# Seconds a question or an approval decision waits before it cancels (#6003).
# 0 disables the timeout entirely; values above 86400 (24h) are clamped.
# user_input_timeout_seconds = 300
# ─────────────────────────────────────────────────────────────────────────────────
# Product telemetry — opt-in, off by default
@@ -1227,6 +1231,11 @@ exponential_base = 2.0
# coordinator goals that poll on a cadence instead of keeping one provider
# turn open. Default: 0 (continue immediately). Cap: 86400 (24h).
# continuation_delay_seconds = 300
# Per-turn step allowance while a goal is active (#5994): larger but still
# finite. Default: 1000 (0/absent resolves to 1000). Range: 1..=100,000.
# Bounds each turn, never the number of continuation passes; explicit
# per-invocation ceilings (exec --max-turns, worker caps) always win.
# max_steps = 1000
# ─────────────────────────────────────────────────────────────────────────────────
# Context Compaction
+137 -70
View File
@@ -7,9 +7,9 @@
//! - `~/.codewhale/sessions/` — saved session JSON files (tool call history)
//! - `~/.codewhale/tasks/runtime/events/` — runtime thread JSONL event streams
//!
//! An install that never migrated off the DeepSeek-era `~/.deepseek` root still
//! reads there, but only for a path that actually exists — see
//! `resolve_state_file`.
//! Default-root audit history includes retained rotations and legacy receipts,
//! excluding records copied across roots. An explicit `CODEWHALE_HOME` never
//! reads outside that root.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value;
use sha2::{Digest, Sha256};
// ──────────────────────────────────────────────────────────────────────────────
// Public entry-point
@@ -37,7 +38,7 @@ pub fn run(args: MetricsArgs) -> Result<()> {
// streams off `<tasks>/runtime`. Resolving the home is fallible, and a
// rollup of zeros is indistinguishable from real emptiness, so a home we
// cannot resolve is an error rather than a silent all-zero report.
let audit_log = resolve_state_file("audit.log")?;
let audit_roots = resolve_audit_roots()?;
let sessions = codewhale_config::resolve_state_dir("sessions")?;
let runtime_events = codewhale_config::resolve_state_dir("tasks")?
.join("runtime")
@@ -45,7 +46,7 @@ pub fn run(args: MetricsArgs) -> Result<()> {
// Collect data from every source; treat missing files as empty.
let mut rollup = Rollup::default();
read_audit_log(&audit_log, args.since, &mut rollup);
read_audit_history(&audit_roots, args.since, &mut rollup);
read_session_files(&sessions, args.since, &mut rollup);
read_runtime_events(&runtime_events, args.since, &mut rollup);
@@ -250,8 +251,37 @@ impl Rollup {
// Source readers
// ──────────────────────────────────────────────────────────────────────────────
/// Read one-JSON-line-per-event audit log.
fn read_audit_log(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
/// Read both retained generations from each root. A copied legacy record is
/// counted once across roots, while repeated records within one root retain
/// their multiplicity. No source log is rewritten or removed.
fn read_audit_history(roots: &[PathBuf], since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
let mut earlier_roots = HashMap::new();
for root in roots {
let mut root_counts = HashMap::new();
for name in ["audit.log.1", "audit.log"] {
read_audit_log(
&root.join(name),
since,
rollup,
&earlier_roots,
&mut root_counts,
);
}
for (record, count) in root_counts {
let prior = earlier_roots.entry(record).or_insert(0);
*prior = (*prior).max(count);
}
}
}
/// Read one JSON event per line, excluding copies already seen in other roots.
fn read_audit_log(
path: &Path,
since: Option<DateTime<Utc>>,
rollup: &mut Rollup,
earlier_roots: &HashMap<[u8; 32], u64>,
root_counts: &mut HashMap<[u8; 32], u64>,
) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
@@ -280,6 +310,16 @@ fn read_audit_log(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup
}
};
// Copy migration preserves the complete event, including its timestamp.
// Count occurrences so two identical legitimate records in one source
// are not collapsed into one merely because another root also exists.
let fingerprint: [u8; 32] = Sha256::digest(v.to_string().as_bytes()).into();
let count = root_counts.entry(fingerprint).or_insert(0);
*count += 1;
if *count <= earlier_roots.get(&fingerprint).copied().unwrap_or(0) {
continue;
}
// Parse timestamp — field is "ts" in audit log.
let ts = parse_ts_field(&v, "ts");
@@ -832,26 +872,18 @@ fn print_human(rollup: &Rollup) {
// Helpers
// ──────────────────────────────────────────────────────────────────────────────
/// Resolve a file that lives directly in the state root, preferring the
/// canonical Codewhale root.
///
/// This is the file-shaped twin of `codewhale_config::resolve_state_dir` (and
/// of `default_config_path`, which resolves `config.toml` the same way): the
/// primary path wins whenever it exists, the legacy DeepSeek path is used only
/// when it is the *only* one present, and with neither present the primary is
/// returned so an empty rollup names the canonical location. An explicit
/// `CODEWHALE_HOME` is an isolation boundary and never falls back.
///
/// The two roots are never unioned. `ensure_state_dir` may migrate legacy state
/// by *copying* it (`StateMigrationKind::Copied` leaves the legacy tree in
/// place), so summing both roots would double-count every migrated record.
fn resolve_state_file(name: &str) -> Result<PathBuf> {
let primary = codewhale_config::codewhale_home()?.join(name);
if codewhale_config::codewhale_home_is_explicit() || primary.exists() {
return Ok(primary);
/// An explicit home is an isolation boundary. Default installs can have
/// distinct audit histories in both roots, even after a copied migration.
fn resolve_audit_roots() -> Result<Vec<PathBuf>> {
let primary = codewhale_config::codewhale_home()?;
let mut roots = vec![primary];
if !codewhale_config::codewhale_home_is_explicit() {
let legacy = codewhale_config::legacy_deepseek_home()?;
if !roots.contains(&legacy) {
roots.push(legacy);
}
}
let legacy = codewhale_config::legacy_deepseek_home()?.join(name);
Ok(if legacy.exists() { legacy } else { primary })
Ok(roots)
}
/// Parse a timestamp from a JSON value field (tries RFC3339).
@@ -880,6 +912,10 @@ fn fmt_num(n: u64) -> String {
mod tests {
use super::*;
fn read_audit_test_log(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
super::read_audit_log(path, since, rollup, &HashMap::new(), &mut HashMap::new());
}
// ── Duration parser ──
#[test]
@@ -958,7 +994,7 @@ mod tests {
fn audit_log_empty_file() {
let mut rollup = Rollup::default();
// Non-existent path — should not panic, rollup stays empty.
read_audit_log(Path::new("/nonexistent/audit.log"), None, &mut rollup);
read_audit_test_log(Path::new("/nonexistent/audit.log"), None, &mut rollup);
assert_eq!(rollup.total_lines, 0);
}
@@ -980,7 +1016,7 @@ mod tests {
writeln!(tmp, "{line2}").unwrap();
let mut rollup = Rollup::default();
read_audit_log(tmp.path(), None, &mut rollup);
read_audit_test_log(tmp.path(), None, &mut rollup);
assert_eq!(rollup.parsed_lines, 2);
assert_eq!(rollup.tools["exec_shell"].calls, 1);
@@ -1000,7 +1036,7 @@ mod tests {
.unwrap();
let mut rollup = Rollup::default();
read_audit_log(tmp.path(), None, &mut rollup);
read_audit_test_log(tmp.path(), None, &mut rollup);
// 2 lines total, 1 malformed skipped, 1 parsed.
assert_eq!(rollup.total_lines, 2);
@@ -1027,7 +1063,7 @@ mod tests {
let cutoff: DateTime<Utc> = "2026-01-01T00:00:00Z".parse().unwrap();
let mut rollup = Rollup::default();
read_audit_log(tmp.path(), Some(cutoff), &mut rollup);
read_audit_test_log(tmp.path(), Some(cutoff), &mut rollup);
// Only the newer line should be counted.
assert_eq!(rollup.parsed_lines, 1);
@@ -1072,70 +1108,101 @@ mod tests {
}
#[test]
fn state_files_resolve_under_the_codewhale_root_on_a_clean_install() {
fn default_audit_history_includes_both_roots_without_requiring_existing_files() {
let (home, _lock, _env) = isolated_home();
assert_eq!(
resolve_state_file("audit.log").expect("resolves"),
home.path().join(".codewhale").join("audit.log"),
"the reader must land on the root the audit writer actually writes"
resolve_audit_roots().expect("resolves"),
vec![
home.path().join(".codewhale"),
home.path().join(".deepseek")
],
);
}
#[test]
fn a_legacy_file_is_used_only_when_it_is_the_one_that_exists() {
let (home, _lock, _env) = isolated_home();
let legacy = home.path().join(".deepseek");
std::fs::create_dir_all(&legacy).expect("legacy root");
std::fs::write(legacy.join("audit.log"), b"{}\n").expect("legacy log");
assert_eq!(
resolve_state_file("audit.log").expect("resolves"),
fn copied_audit_history_keeps_unique_legacy_and_rotated_records() {
let dir = tempfile::TempDir::new().expect("tempdir");
let primary = dir.path().join("primary");
let legacy = dir.path().join("legacy");
std::fs::create_dir_all(&primary).unwrap();
std::fs::create_dir_all(&legacy).unwrap();
let shared = r#"{"ts":"2026-09-01T00:00:00Z","event":"credential.save","details":{}}"#;
let old = r#"{"ts":"2026-08-01T00:00:00Z","event":"credential.clear","details":{}}"#;
let new = r#"{"ts":"2026-09-02T00:00:00Z","event":"credential.save","details":{}}"#;
std::fs::write(primary.join("audit.log.1"), format!("{shared}\n{shared}\n")).unwrap();
std::fs::write(primary.join("audit.log"), format!("{new}\nmalformed\n")).unwrap();
std::fs::write(
legacy.join("audit.log"),
"real DeepSeek-era receipts must not be dropped on the floor"
);
// Once the canonical file exists it wins outright; the two roots are
// never summed, because legacy state may have been migrated by copy.
let primary = home.path().join(".codewhale");
std::fs::create_dir_all(&primary).expect("primary root");
std::fs::write(primary.join("audit.log"), b"{}\n").expect("primary log");
format!("{shared}\n{shared}\n{shared}\n"),
)
.unwrap();
std::fs::write(legacy.join("audit.log.1"), format!("{old}\n")).unwrap();
let roots = [primary, legacy];
let before: Vec<_> = roots
.iter()
.flat_map(|root| {
["audit.log.1", "audit.log"].map(|name| {
let path = root.join(name);
(path.clone(), std::fs::read(path).unwrap())
})
})
.collect();
let mut rollup = Rollup::default();
read_audit_history(&roots, None, &mut rollup);
assert_eq!(
resolve_state_file("audit.log").expect("resolves"),
primary.join("audit.log")
rollup.credentials.saves, 4,
"maximum occurrence count across copied roots"
);
assert_eq!(
rollup.credentials.clears, 1,
"unique old rotation is retained"
);
assert_eq!(rollup.parsed_lines, 5);
for (path, bytes) in before {
assert_eq!(
std::fs::read(path).unwrap(),
bytes,
"source history is read-only"
);
}
let mut recent = Rollup::default();
read_audit_history(
&roots,
Some("2026-09-01T00:00:00Z".parse().unwrap()),
&mut recent,
);
assert_eq!(recent.credentials.saves, 4);
assert_eq!(recent.credentials.clears, 0);
}
#[test]
fn an_explicit_codewhale_home_is_an_isolation_boundary() {
fn an_explicit_codewhale_home_is_an_audit_isolation_boundary() {
let (home, _lock, _env) = isolated_home();
let legacy = home.path().join(".deepseek");
std::fs::create_dir_all(&legacy).expect("legacy root");
std::fs::write(legacy.join("audit.log"), b"{}\n").expect("legacy log");
let explicit = tempfile::TempDir::new().expect("tempdir");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(legacy.join("audit.log"), r#"{"event":"credential.save"}"#).unwrap();
let explicit = tempfile::TempDir::new().unwrap();
let _pin =
crate::tests::ScopedEnvVar::set("CODEWHALE_HOME", &explicit.path().to_string_lossy());
assert_eq!(
resolve_state_file("audit.log").expect("resolves"),
explicit.path().join("audit.log"),
"an explicit home must never reach outside its own root"
);
let roots = resolve_audit_roots().unwrap();
assert_eq!(roots, vec![explicit.path().to_path_buf()]);
let mut rollup = Rollup::default();
read_audit_history(&roots, None, &mut rollup);
assert_eq!(rollup.parsed_lines, 0);
}
#[test]
fn the_legacy_deepseek_home_variable_is_no_longer_honoured() {
// docs/CONFIGURATION.md tells upgraders to rename DEEPSEEK_HOME to
// CODEWHALE_HOME; every other subsystem already ignores it, and this
// reader was the last consumer of the legacy alias.
let (home, _lock, _env) = isolated_home();
let stale = tempfile::TempDir::new().expect("tempdir");
let stale = tempfile::TempDir::new().unwrap();
let _stale =
crate::tests::ScopedEnvVar::set("DEEPSEEK_HOME", &stale.path().to_string_lossy());
assert_eq!(
resolve_state_file("audit.log").expect("resolves"),
home.path().join(".codewhale").join("audit.log")
resolve_audit_roots().unwrap(),
vec![
home.path().join(".codewhale"),
home.path().join(".deepseek")
],
);
}
}
+8
View File
@@ -762,6 +762,10 @@ impl ThreadManager {
tokens_used: 0,
time_used_seconds: 0,
continuation_count: 0,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
pause_reason: None,
created_at: now,
updated_at: now,
};
@@ -1885,6 +1889,10 @@ fn to_protocol_goal(goal: ThreadGoalRecord) -> ThreadGoal {
tokens_used: goal.tokens_used,
time_used_seconds: goal.time_used_seconds,
continuation_count: goal.continuation_count,
last_gap_fingerprint: goal.last_gap_fingerprint,
repeated_gap_count: goal.repeated_gap_count,
last_gap_pass: goal.last_gap_pass,
pause_reason: goal.pause_reason,
created_at: goal.created_at,
updated_at: goal.updated_at,
}
+84
View File
@@ -133,6 +133,90 @@ pub struct ThreadGoal {
pub continuation_count: i64,
pub created_at: i64,
pub updated_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_gap_fingerprint: Option<String>,
#[serde(default)]
pub repeated_gap_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_gap_pass: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pause_reason: Option<GoalPauseReason>,
}
/// Why an unfinished goal is paused. Shared by every durable host projection.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GoalPauseReason {
User,
Backoff,
NoProgress,
UsageLimit,
BudgetLimit,
}
impl GoalPauseReason {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::User => "user",
Self::Backoff => "run limit",
Self::NoProgress => "no progress",
Self::UsageLimit => "usage limit",
Self::BudgetLimit => "budget limit",
}
}
}
/// Validate the compact stall history without retaining verifier prose.
/// Legacy records with the entire history absent start with an empty window.
pub const MAX_REPEATED_GAP_COUNT: u32 = 3;
pub fn validate_goal_stall_state(
fingerprint: Option<&str>,
count: u32,
pass: Option<u32>,
continuation_count: u32,
) -> Result<(), &'static str> {
match (fingerprint, count, pass) {
(None, 0, None) => Ok(()),
(Some(digest), 1..=MAX_REPEATED_GAP_COUNT, Some(pass))
if digest.len() == 64
&& digest.bytes().all(|byte| byte.is_ascii_hexdigit())
&& pass <= continuation_count
&& count <= pass.saturating_add(1) =>
{
Ok(())
}
_ => Err("invalid persisted goal stall history"),
}
}
impl ThreadGoal {
pub fn validate_stall_state(&self) -> Result<(), &'static str> {
validate_goal_stall_state(
self.last_gap_fingerprint.as_deref(),
self.repeated_gap_count,
self.last_gap_pass,
u32::try_from(self.continuation_count.max(0)).unwrap_or(u32::MAX),
)
}
/// Restore a durably impossible record as paused. The engine pauses
/// NoProgress in the same locked mutation that fills the stall window, so
/// a persisted record that is still Active at the ceiling is corrupt
/// (e.g. a crash between the counter write and the pause). Returns true
/// when the record was healed.
pub fn normalize_restored_stall_state(&mut self) -> bool {
if matches!(self.status, ThreadGoalStatus::Active)
&& self.repeated_gap_count >= MAX_REPEATED_GAP_COUNT
{
self.status = ThreadGoalStatus::Paused;
self.pause_reason = Some(GoalPauseReason::NoProgress);
true
} else {
false
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+6
View File
@@ -217,6 +217,8 @@ pub enum Op {
status: String,
#[serde(default)]
clear: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
goal_id: Option<String>,
},
/// Set (or replace) the active goal objective and start goal work.
@@ -224,6 +226,8 @@ pub enum Op {
objective: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
token_budget: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
goal_id: Option<String>,
},
Cancel,
@@ -560,10 +564,12 @@ mod tests {
approval_mode: "suggest".into(),
},
Op::SetGoalStatus {
goal_id: None,
status: "paused".into(),
clear: false,
},
Op::SetGoalObjective {
goal_id: None,
objective: "ship".into(),
token_budget: Some(10),
},
+4
View File
@@ -175,6 +175,10 @@ fn thread_goal_event_serializes_status_and_accounting() {
tokens_used: 42_001,
time_used_seconds: 3600,
continuation_count: 7,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
pause_reason: None,
created_at: 1,
updated_at: 2,
};
+101 -6
View File
@@ -229,6 +229,14 @@ pub struct ThreadGoalRecord {
pub created_at: i64,
/// Unix timestamp (seconds) when the goal was last updated.
pub updated_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_gap_fingerprint: Option<String>,
#[serde(default)]
pub repeated_gap_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_gap_pass: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pause_reason: Option<codewhale_protocol::GoalPauseReason>,
}
/// Filters for listing conversation threads.
@@ -635,6 +643,26 @@ impl StateStore {
"#
))
.context("failed to initialize thread goal continuation schema")?;
user_version = 4;
}
if user_version < 5 {
let mut additions = String::new();
for (column, definition) in [
("last_gap_fingerprint", "TEXT"),
("repeated_gap_count", "INTEGER NOT NULL DEFAULT 0"),
("last_gap_pass", "INTEGER"),
("pause_reason", "TEXT"),
] {
if !column_exists(conn, "thread_goals", column)? {
additions.push_str(&format!(
"ALTER TABLE thread_goals ADD COLUMN {column} {definition};\n"
));
}
}
conn.execute_batch(&format!(
"BEGIN; {additions} PRAGMA user_version = 5; COMMIT;"
))
.context("failed to initialize durable goal stall schema")?;
}
Ok(())
}
@@ -827,6 +855,17 @@ impl StateStore {
/// Insert or replace the persisted goal for a thread.
pub fn upsert_thread_goal(&self, goal: &ThreadGoalRecord) -> Result<()> {
codewhale_protocol::validate_goal_stall_state(
goal.last_gap_fingerprint.as_deref(),
goal.repeated_gap_count,
goal.last_gap_pass,
u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX),
)
.map_err(anyhow::Error::msg)?;
let pause_reason = goal
.pause_reason
.map(|reason| serde_json::to_string(&reason))
.transpose()?;
let conn = self.conn()?;
let exists: Option<i64> = conn
.query_row(
@@ -844,8 +883,9 @@ impl StateStore {
r#"
INSERT INTO thread_goals (
thread_id, goal_id, objective, status, token_budget, tokens_used,
time_used_seconds, continuation_count, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
time_used_seconds, continuation_count, created_at, updated_at,
last_gap_fingerprint, repeated_gap_count, last_gap_pass, pause_reason
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(thread_id) DO UPDATE SET
goal_id=excluded.goal_id,
objective=excluded.objective,
@@ -855,7 +895,11 @@ impl StateStore {
time_used_seconds=excluded.time_used_seconds,
continuation_count=excluded.continuation_count,
created_at=excluded.created_at,
updated_at=excluded.updated_at
updated_at=excluded.updated_at,
last_gap_fingerprint=excluded.last_gap_fingerprint,
repeated_gap_count=excluded.repeated_gap_count,
last_gap_pass=excluded.last_gap_pass,
pause_reason=excluded.pause_reason
"#,
params![
goal.thread_id,
@@ -868,6 +912,10 @@ impl StateStore {
goal.continuation_count,
goal.created_at,
goal.updated_at,
goal.last_gap_fingerprint,
goal.repeated_gap_count,
goal.last_gap_pass,
pause_reason,
],
)
.context("failed to upsert thread goal")?;
@@ -956,7 +1004,8 @@ impl StateStore {
conn.query_row(
r#"
SELECT thread_id, goal_id, objective, status, token_budget, tokens_used,
time_used_seconds, continuation_count, created_at, updated_at
time_used_seconds, continuation_count, created_at, updated_at,
last_gap_fingerprint, repeated_gap_count, last_gap_pass, pause_reason
FROM thread_goals
WHERE thread_id = ?1
"#,
@@ -2024,7 +2073,8 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadGoalRecord> {
let status_raw: String = row.get(3)?;
Ok(ThreadGoalRecord {
let pause_reason: Option<String> = row.get(13)?;
let mut goal = ThreadGoalRecord {
thread_id: row.get(0)?,
goal_id: row.get(1)?,
objective: row.get(2)?,
@@ -2035,7 +2085,48 @@ fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadGoalRec
continuation_count: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
})
last_gap_fingerprint: row.get(10)?,
repeated_gap_count: row.get(11)?,
last_gap_pass: row.get(12)?,
pause_reason: pause_reason
.map(|value| serde_json::from_str(&value))
.transpose()
.map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
13,
rusqlite::types::Type::Text,
Box::new(error),
)
})?,
};
codewhale_protocol::validate_goal_stall_state(
goal.last_gap_fingerprint.as_deref(),
goal.repeated_gap_count,
goal.last_gap_pass,
u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX),
)
.map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
10,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, error)),
)
})?;
goal.normalize_restored_stall_state();
Ok(goal)
}
impl ThreadGoalRecord {
/// Same restore rule as `codewhale_protocol::ThreadGoal`: an Active
/// record with an exhausted stall window is corrupt and reads as paused.
fn normalize_restored_stall_state(&mut self) {
if matches!(self.status, ThreadGoalStatus::Active)
&& self.repeated_gap_count >= codewhale_protocol::MAX_REPEATED_GAP_COUNT
{
self.status = ThreadGoalStatus::Paused;
self.pause_reason = Some(codewhale_protocol::GoalPauseReason::NoProgress);
}
}
}
#[cfg(test)]
@@ -2101,6 +2192,10 @@ mod tests {
tokens_used: 7,
time_used_seconds: 11,
continuation_count: 0,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
pause_reason: None,
created_at: 100,
updated_at: 101,
}
+5 -4
View File
@@ -16,10 +16,11 @@ fn assert_workflow_trace_schema(conn: &Connection) {
let user_version: u32 = conn
.query_row("PRAGMA user_version;", [], |row| row.get(0))
.expect("read user_version");
// v4 (goal-progress migration) adds `thread_goals.continuation_count` on top
// of the v3 workflow-trace + thread_goals tables. The table set asserted
// below is unchanged; only the schema version advanced.
assert_eq!(user_version, 4);
// v5 (goal stall-history migration) adds `thread_goals.last_gap_fingerprint`,
// `repeated_gap_count`, `last_gap_pass` and `pause_reason` on top of the v4
// continuation-count column and the v3 workflow-trace + thread_goals tables.
// The table set asserted below is unchanged; only the schema version advanced.
assert_eq!(user_version, 5);
for table in [
"workflow_runs",
+1 -1
View File
@@ -109,7 +109,7 @@ with an accessibility-first pointer.
bar's plan/act/operate chip. The `status`, `agents`, `reasoning_replay`,
`prefix_stability`, `git_branch`, `last_tool_elapsed` and `rate_limit`
items drove nothing and are retired; an existing `config.toml` still loads
and those keys are ignored (#5950).
and those keys are ignored (#5950, #5962).
- The context reading is back on screen at every fullness. 0.9.12 painted
`ctx NN%` only from 50% up, which left most of a session with no context
signal at all; it now paints from 0% and keeps its warning colour from 80%
+30 -20
View File
@@ -412,26 +412,17 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
let mut app = self.host.app.borrow_mut();
let explicit_save_path = explicit_path.map(PathBuf::from);
let messages = app.api_messages.clone();
let mut session = crate::session_manager::create_saved_session_with_mode(
&messages,
&app.model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
session
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
app.sync_cost_to_metadata(&mut session.metadata);
session.context_references = app.session_context_references.clone();
session.artifacts = app.session_artifacts.clone();
session.work_state = match app.work_state_snapshot() {
Ok(state) => state,
Err(err) => return Err(format!("Failed to snapshot Work state: {err}")),
};
session.last_auto_route = app.auto_route_for_persistence();
// Explicit save must report contended Work state instead of falling
// back to the last automatic snapshot. Reuse the canonical snapshot
// builder after that preflight so stable IDs also retain lifecycle,
// title, provider, and window metadata.
app.work_state_snapshot()
.map_err(|error| format!("Failed to snapshot Work state: {error}"))?;
let manager = crate::session_manager::SessionManager::default_location()
.map_err(|error| format!("could not open sessions directory: {error}"))?;
let session = crate::tui::ui::build_session_snapshot(&mut app, &manager)?;
let queue_transition =
crate::tui::ui::prepare_offline_queue_transition(&app, &session.metadata.id)?;
let save_path = explicit_save_path.unwrap_or_else(|| {
let dir = crate::session_manager::default_sessions_dir()
.unwrap_or_else(|_| app.workspace.clone());
@@ -451,6 +442,10 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
};
match crate::utils::write_atomic(&save_path, json.as_bytes()) {
Ok(()) => {
crate::tui::ui::install_offline_queue_transition(
&mut app,
queue_transition,
);
app.current_session_id = Some(session.metadata.id.clone());
app.current_session_metadata = Some(session.metadata.clone());
app.session_title = Some(session.metadata.title.clone());
@@ -553,6 +548,8 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
forked.artifacts = app.session_artifacts.clone();
forked.work_state = work_state;
forked.last_auto_route = app.auto_route_for_persistence();
let queue_transition =
crate::tui::ui::prepare_offline_queue_transition(&app, &forked.metadata.id)?;
if let Err(err) = manager.save_session(&forked) {
return Err(format!("Failed to save forked session: {err}"));
@@ -563,6 +560,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
));
}
crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.session_title = Some(forked.metadata.title.clone());
@@ -647,9 +645,12 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
forked.artifacts = source_session.artifacts.clone();
forked.work_state = source_session.work_state.clone();
forked.last_auto_route = source_session.last_auto_route.clone();
let queue_transition =
crate::tui::ui::prepare_offline_queue_transition(&app, &forked.metadata.id)?;
if let Err(err) = manager.save_session(&forked) {
return Err(format!("Failed to save forked session: {err}"));
}
crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.session_title = Some(forked.metadata.title.clone());
@@ -696,12 +697,14 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
}
let new_id = uuid::Uuid::new_v4().to_string();
let queue_transition = crate::tui::ui::prepare_offline_queue_transition(&app, &new_id)?;
if !crate::commands::groups::core::reset_conversation_state(&mut app) {
return Err(
"Could not start a new session because Work state is busy; retry in a moment."
.to_string(),
);
}
crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
app.clear_input();
app.session_artifacts.clear();
app.session_context_references.clear();
@@ -1442,9 +1445,11 @@ fn import_session_container(
Err(e) => return Err(format!("foreign import failed: {e}")),
};
let new_id = imported.metadata.id.clone();
let queue_transition = crate::tui::ui::prepare_offline_queue_transition(app, &new_id)?;
if let Err(e) = manager.save_session(&imported) {
return Err(format!("imported session could not be saved: {e}"));
}
crate::tui::ui::install_offline_queue_transition(app, queue_transition);
app.current_session_id = Some(new_id.clone());
app.current_session_metadata = Some(imported.metadata.clone());
app.api_messages = imported.messages.clone();
@@ -4733,6 +4738,7 @@ mod tests {
// Pending controls flip the effective source to the durable state.
app.pending_goal_controls
.push_back(crate::tui::app::PendingGoalControl {
goal_id: None,
intent: crate::tui::app::GoalControlIntent::SetStatus {
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
@@ -4741,6 +4747,10 @@ mod tests {
});
app.last_known_goal_state = Some(crate::session_manager::SessionGoalState {
schema_version: 1,
goal_id: None,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
objective: "Durable objective".to_string(),
status: crate::session_manager::SessionGoalStatus::Paused,
token_budget: None,
@@ -2009,7 +2009,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
return CommandResult::with_message_and_action(
format!(
"model = auto (auto-select model per turn; thinking = {})",
@@ -2057,7 +2056,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
return CommandResult::with_message_and_action(
format!("model = {model}"),
AppAction::UpdateCompaction(app.compaction_config()),
@@ -2772,7 +2770,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
}
+13 -4
View File
@@ -156,6 +156,11 @@ pub fn clear(app: &mut App) -> CommandResult {
tr(app.ui_locale, MessageId::ClearConversationBusy).to_string(),
);
}
let new_id = uuid::Uuid::new_v4().to_string();
let queue_transition = match crate::tui::ui::prepare_offline_queue_transition(app, &new_id) {
Ok(transition) => transition,
Err(error) => return CommandResult::error(error),
};
if !reset_conversation_state(app) {
return CommandResult::error(
tr(app.ui_locale, MessageId::ClearConversationBusy).to_string(),
@@ -165,7 +170,7 @@ pub fn clear(app: &mut App) -> CommandResult {
// and every autosave, so mint the next id here (as `/new` does) rather
// than letting the engine generate one the App only learns about from
// `SessionUpdated`. Two ids for one conversation orphan the checkpoint.
let new_id = uuid::Uuid::new_v4().to_string();
crate::tui::ui::install_offline_queue_transition(app, queue_transition);
app.current_session_id = Some(new_id.clone());
app.current_session_metadata = None;
app.session_title = None;
@@ -193,6 +198,13 @@ pub(crate) fn reset_conversation_state(app: &mut App) -> bool {
if !app.clear_todos() {
return false;
}
// Explicit reset discards the queue it was invoked on. Capture its owner
// before `/clear` or `/new` installs the next session id.
if let Some(lease) = app.offline_queue_lease.clone() {
crate::tui::persistence_actor::persist(
crate::tui::persistence_actor::PersistRequest::ClearOfflineQueue { lease },
);
}
// Atomically retire background accounting before zeroing the session.
// Late reports retain the old scope token and are discarded instead of
// appearing in the new conversation.
@@ -225,7 +237,6 @@ pub(crate) fn reset_conversation_state(app: &mut App) -> bool {
app.last_exec_wait_command = None;
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
@@ -272,7 +283,6 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
} else {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
}
let provider_identity = app.provider_identity_for_persistence().to_string();
app.provider_models
@@ -363,7 +373,6 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
} else {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
}
let provider_identity = app.provider_identity_for_persistence().to_string();
app.provider_models
+18 -10
View File
@@ -617,7 +617,8 @@ const TURN_CACHE_TABLE_WIDTH: usize = 106;
fn turn_cost_cell(
rec: &TurnCacheRecord,
currency: crate::pricing::CostCurrency,
unpriced_notes: &mut std::collections::BTreeSet<&'static str>,
unpriced_reasons: &mut std::collections::BTreeSet<crate::pricing::UnpricedReason>,
unpriced_classes: &mut std::collections::BTreeSet<&'static str>,
) -> String {
let Some(audit) = rec.cost_audit.as_ref() else {
return "—".to_string();
@@ -628,10 +629,10 @@ fn turn_cost_cell(
return crate::pricing::format_cost_amount_precise(estimate.amount(currency), currency);
}
if let Some(reason) = audit.unpriced_reason {
unpriced_notes.insert(reason.label());
unpriced_reasons.insert(reason);
}
for class in &audit.unpriced_classes {
unpriced_notes.insert(class.label());
unpriced_classes.insert(class.label());
}
"—".to_string()
}
@@ -649,7 +650,9 @@ fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
let currency = app.cost_display_currency(app.cost_currency);
// Non-secret audit trail for turns whose spend is missing from the session
// total, so a `—` in the cost column is always explainable.
let mut unpriced_notes: std::collections::BTreeSet<&'static str> =
let mut unpriced_reasons: std::collections::BTreeSet<crate::pricing::UnpricedReason> =
std::collections::BTreeSet::new();
let mut unpriced_classes: std::collections::BTreeSet<&'static str> =
std::collections::BTreeSet::new();
let mut header = tr(locale, MessageId::CmdCacheHeader)
.replace("{count}", &rows.len().to_string())
@@ -688,7 +691,7 @@ fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
.map_or_else(|| "—".to_string(), |_| write.to_string());
totals_write += classes.cache_write;
totals_reasoning += u64::from(rec.reasoning_tokens.unwrap_or(0));
let cost_cell = turn_cost_cell(rec, currency, &mut unpriced_notes);
let cost_cell = turn_cost_cell(rec, currency, &mut unpriced_reasons, &mut unpriced_classes);
let route_cell = format_turn_cache_route(rec);
let age = humanize_age(now.saturating_duration_since(rec.recorded_at));
@@ -781,11 +784,16 @@ fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
.replace("{avg}", &avg_ratio),
);
footer.push_str(&tr(locale, MessageId::CmdCacheFootnote));
if !unpriced_notes.is_empty() {
footer.push_str(&format!(
"cost — = no authoritative price for that turn; it is missing from the session estimate ({}).\n",
unpriced_notes.into_iter().collect::<Vec<_>>().join(", ")
));
if !unpriced_reasons.is_empty() || !unpriced_classes.is_empty() {
// Reasons are localized prose; token-class labels are key names and
// stay raw, the same split `/cost` uses.
let notes = unpriced_reasons
.iter()
.map(|reason| tr(locale, reason.message_id()).into_owned())
.chain(unpriced_classes.iter().map(|class| (*class).to_string()))
.collect::<Vec<_>>()
.join(", ");
footer.push_str(&tr(locale, MessageId::CmdCacheUnpricedNote).replace("{notes}", &notes));
}
footer.push_str(&tr(locale, MessageId::CmdCacheAdvice));
@@ -189,7 +189,7 @@ fn cost_report_states_its_coverage_and_names_what_it_excludes() {
assert!(msg.contains("estimate, not a bill"), "{msg}");
assert!(msg.contains("Excluded: 1"), "{msg}");
assert!(msg.contains("Priced subtotal:"), "{msg}");
assert!(msg.contains("missing_class_price"), "{msg}");
assert!(msg.contains("token class rate unavailable"), "{msg}");
assert!(msg.contains("cache_write"), "{msg}");
// A run with no unpriced turns says so without an exclusion note.
@@ -976,7 +976,7 @@ fn cache_history_shows_cache_write_tokens_and_explains_unpriced_turns() {
assert!(msg.contains("sum_reasoning: 50000"), "{msg}");
// The priced turn shows money; the unpriced one shows why it does not.
assert!(msg.contains("$1.3450"), "{msg}");
assert!(msg.contains("missing_class_price"), "{msg}");
assert!(msg.contains("token class rate unavailable"), "{msg}");
assert!(msg.contains("cache_write"), "{msg}");
}
+10 -1
View File
@@ -299,7 +299,16 @@ pub(crate) fn cost_coverage_report(app: &App, locale: Locale) -> String {
out.push_str(
&tr(locale, MessageId::CmdCostUnpricedTurns)
.replace("{unpriced}", &unpriced.to_string())
.replace("{reasons}", &joined(reasons)),
.replace(
"{reasons}",
&crate::route_billing::format_unpriced_reasons(
&reasons
.iter()
.map(|reason| crate::pricing::UnpricedReason::from_label(reason))
.collect::<Vec<_>>(),
locale,
),
),
);
}
if !app.session.cost_unpriced_classes.is_empty() {
+23 -6
View File
@@ -8,9 +8,10 @@
//! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned
//! at append time).
//!
//! Entries that begin with `/` (slash commands) are NOT stored — they
//! pollute the recall stream and the fuzzy slash-menu already covers
//! them. Empty / whitespace-only inputs are also skipped.
//! Slash commands are stored as well: recalling `/theme` or `/compact`
//! with Up-arrow is ordinary recall (#6006), and filtering on the `/`
//! prefix also dropped absolute paths like `cat /etc/fstab`. Empty /
//! whitespace-only inputs are still skipped.
//!
//! ## Off-thread writes (#1927)
//!
@@ -219,7 +220,10 @@ fn append_history_entries_to<'a>(
let mut changed = false;
for entry in entries_to_append {
let trimmed = entry.trim();
if trimmed.is_empty() || trimmed.starts_with('/') {
// Slash commands are stored too: recalling `/theme` with Up-arrow is
// ordinary history behavior, and the old `/`-prefix filter also
// dropped absolute paths like `cat /etc/fstab` (#6006).
if trimmed.is_empty() {
continue;
}
if entries.last().map(String::as_str) == Some(trimmed) {
@@ -358,12 +362,25 @@ mod tests {
}
#[test]
fn slash_commands_skipped() {
fn slash_commands_and_absolute_paths_stored() {
let (_tmp, path) = temp_history_path();
append_history_to(&path, "/help");
append_history_to(&path, "real prompt");
append_history_to(&path, "/cost");
assert_eq!(load_history_from(&path), vec!["real prompt"]);
append_history_to(&path, "cat /etc/fstab");
assert_eq!(
load_history_from(&path),
vec!["/help", "real prompt", "/cost", "cat /etc/fstab"]
);
}
#[test]
fn consecutive_duplicate_commands_deduped() {
let (_tmp, path) = temp_history_path();
append_history_to(&path, "/theme");
append_history_to(&path, "/theme");
append_history_to(&path, "/theme");
assert_eq!(load_history_from(&path), vec!["/theme"]);
}
#[test]
+79
View File
@@ -2390,6 +2390,14 @@ pub struct ToolsConfig {
/// (4). Values outside `2..=10` are clamped with a warning.
#[serde(default)]
pub user_input_max_options: Option<u32>,
/// Seconds Codewhale waits for a user-input answer or an approval
/// decision before cancelling it (#6003). `None` uses the built-in
/// default (300). An explicit `0` disables the timeout entirely, so
/// long human review or overnight automation can wait indefinitely.
/// Values above 86,400 (24h) are clamped with a warning.
#[serde(default)]
pub user_input_timeout_seconds: Option<u64>,
}
/// Persistent-goal loop controls (`[goal]` table in config.toml, #5052).
@@ -2404,6 +2412,16 @@ pub struct GoalConfig {
/// control ends the run.
#[serde(default)]
pub max_continuations: Option<u32>,
/// Per-engine-turn step allowance while a goal is active (#5994). Goal
/// work gets a larger but still finite budget than an ordinary
/// interactive turn: `None` or `0` resolves to
/// [`crate::goal_loop::DEFAULT_GOAL_MAX_STEPS`] (1,000); values clamp to
/// `1..=100,000`. This bounds each turn, never the number of
/// continuation passes; explicit per-invocation ceilings
/// (`exec --max-turns`, child-worker caps) still win.
#[serde(default)]
pub max_steps: Option<u32>,
/// Optional quiet period between successful cross-turn continuations.
/// `0` preserves immediate continuation. Positive values make long-lived
/// coordinator goals yield visibly between turns instead of sleeping
@@ -4912,6 +4930,24 @@ impl Config {
)
}
/// Effective wait for a user-input answer or an approval decision
/// (#6003). `None` means the built-in default (300s). An explicit `0`
/// disables the timeout; values above 24h clamp with a warning.
#[must_use]
pub fn user_input_timeout(&self) -> Option<std::time::Duration> {
const MAX_SECONDS: u64 = 86_400;
let seconds = self
.tools
.as_ref()
.and_then(|tools| tools.user_input_timeout_seconds)?;
if seconds > MAX_SECONDS {
tracing::warn!(
"[tools] user_input_timeout_seconds={seconds} exceeds 24h; clamping to {MAX_SECONDS}"
);
}
Some(std::time::Duration::from_secs(seconds.min(MAX_SECONDS)))
}
#[must_use]
pub fn auto_review_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
self.auto_review
@@ -7389,6 +7425,24 @@ impl Config {
.unwrap_or(crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS)
}
/// Per-engine-turn step allowance while a goal is active (#5994). Goal
/// turns get [`crate::goal_loop::DEFAULT_GOAL_MAX_STEPS`] by default —
/// five times the ordinary interactive allowance — while staying finite.
#[must_use]
pub fn goal_max_steps(&self) -> u32 {
let configured = self.goal.as_ref().and_then(|goal| goal.max_steps);
match configured {
None | Some(0) => crate::goal_loop::DEFAULT_GOAL_MAX_STEPS,
Some(steps) => {
let clamped = steps.clamp(1, 100_000);
if clamped != steps {
tracing::warn!("[goal] max_steps={steps} out of range; clamping to {clamped}");
}
clamped
}
}
}
/// Quiet period between successful interactive goal turns (#5508).
/// Absent/zero keeps the existing immediate-continuation behavior.
#[must_use]
@@ -8204,6 +8258,31 @@ pub(crate) fn save_workspace_trust(workspace: &Path) -> Result<PathBuf> {
Ok(config_path)
}
/// Project hook approval lives only in user-owned config, never in the repo.
pub(crate) fn hook_receipt_for_workspace(workspace: &Path) -> Option<String> {
let raw = fs::read_to_string(default_config_path().ok()?).ok()?;
let doc = toml::from_str::<toml::Value>(&raw).ok()?;
doc.get("projects")?
.get(workspace_config_key(workspace))?
.get("hooks_sha256")?
.as_str()
.map(str::to_owned)
}
pub(crate) fn save_workspace_hook_receipt(workspace: &Path, digest: &str) -> Result<PathBuf> {
let config_path = try_default_config_path()?;
ensure_parent_dir(&config_path)?;
let project_key = workspace_config_key(workspace);
crate::config_persistence::mutate_config_document(&config_path, |doc| {
crate::config_persistence::set_document_value(
doc,
&["projects", project_key.as_str(), "hooks_sha256"],
digest,
)
})?;
Ok(config_path)
}
fn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {
let workspace = canonicalize_or_keep(workspace);
// Trust records may sit at the top level or — from the historic
+84
View File
@@ -1477,6 +1477,90 @@ fn user_input_limits_read_from_tools_table_and_clamp() {
assert_eq!(clamped.max_options, 2);
}
#[test]
fn goal_max_steps_resolves_default_zero_and_clamps() {
let parsed: ConfigFile = toml::from_str("").expect("empty config");
assert_eq!(
parsed.base.goal_max_steps(),
crate::goal_loop::DEFAULT_GOAL_MAX_STEPS
);
// An explicit 0 is the goal default (1,000), never unlimited.
let parsed: ConfigFile = toml::from_str(
r#"
[goal]
max_steps = 0
"#,
)
.expect("goal config");
assert_eq!(
parsed.base.goal_max_steps(),
crate::goal_loop::DEFAULT_GOAL_MAX_STEPS
);
let parsed: ConfigFile = toml::from_str(
r#"
[goal]
max_steps = 50
"#,
)
.expect("goal config");
assert_eq!(parsed.base.goal_max_steps(), 50);
let parsed: ConfigFile = toml::from_str(
r#"
[goal]
max_steps = 500000
"#,
)
.expect("goal config");
assert_eq!(parsed.base.goal_max_steps(), 100_000);
}
#[test]
fn user_input_timeout_defaults_disabled_and_clamps() {
let parsed: ConfigFile = toml::from_str("").expect("empty config");
assert_eq!(parsed.base.user_input_timeout(), None);
let parsed: ConfigFile = toml::from_str(
r#"
[tools]
user_input_timeout_seconds = 900
"#,
)
.expect("tools config");
assert_eq!(
parsed.base.user_input_timeout(),
Some(std::time::Duration::from_secs(900))
);
// An explicit 0 is the documented "wait forever" value, preserved as
// zero rather than defaulted or clamped away.
let parsed: ConfigFile = toml::from_str(
r#"
[tools]
user_input_timeout_seconds = 0
"#,
)
.expect("tools config");
assert_eq!(
parsed.base.user_input_timeout(),
Some(std::time::Duration::ZERO)
);
let parsed: ConfigFile = toml::from_str(
r#"
[tools]
user_input_timeout_seconds = 999999
"#,
)
.expect("tools config");
assert_eq!(
parsed.base.user_input_timeout(),
Some(std::time::Duration::from_secs(86_400))
);
}
#[test]
fn explicit_duckduckgo_search_provider_is_preserved() {
let config: Config = toml::from_str(
+42
View File
@@ -956,6 +956,48 @@ mod tests {
);
}
#[test]
fn outbound_web_payloads_require_a_session_decision_even_for_allowed_hosts() {
use crate::tools::{
fetch_url::FetchUrlTool, spec::ToolSpec, web_run::WebRunTool,
web_search::WebSearchTool, web_tool::WebTool,
};
let request = serde_json::json!({"action": "fetch", "url": "https://example.com/collect?data=synthetic-secret"});
for requirement in [
FetchUrlTool.approval_requirement_for(&request),
WebTool::new("Web").approval_requirement_for(&request),
WebSearchTool.approval_requirement(),
WebRunTool.approval_requirement(),
] {
for approval in [ApprovalMode::Suggest, ApprovalMode::Auto] {
let ask = authority(AppMode::Agent, false, approval);
assert_eq!(
resolve_tool_permission(&ask, requirement, false),
ToolPermission::Prompt
);
}
let never = authority(AppMode::Agent, false, ApprovalMode::Never);
assert_eq!(
resolve_tool_permission(&never, requirement, false),
ToolPermission::Deny
);
let granted = authority(AppMode::Agent, true, ApprovalMode::Bypass);
assert_eq!(
resolve_tool_permission(&granted, requirement, false),
ToolPermission::Allow
);
}
let local_read = crate::tools::file::ReadFileTool.approval_requirement();
assert_eq!(
resolve_tool_permission(
&authority(AppMode::Agent, false, ApprovalMode::Suggest),
local_read,
false
),
ToolPermission::Allow
);
}
#[test]
fn auto_requirement_always_allows() {
for (mode, auto_approve, approval_mode) in [
+88 -14
View File
@@ -475,6 +475,15 @@ pub struct EngineConfig {
/// (#5949). One authority for the validator, the tool schema, and the
/// tool description.
pub user_input_limits: crate::tools::user_input::UserInputLimits,
/// Wait for a user-input answer before cancelling it (#6003). `None`
/// uses the built-in default (300s); `Some(Duration::ZERO)` waits
/// indefinitely.
pub user_input_timeout: Option<Duration>,
/// Per-turn step allowance while a goal is active (#5994). Hosts opt in
/// with their resolved `[goal] max_steps`; `None` keeps the ordinary
/// `max_steps` ceiling for goal turns too — which is what exec/worker
/// paths with explicit per-invocation ceilings must see.
pub goal_max_steps: Option<u32>,
/// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell
/// through bubblewrap (#2184).
pub prefer_bwrap: bool,
@@ -601,6 +610,8 @@ impl Default for EngineConfig {
),
tools_always_load: HashSet::new(),
user_input_limits: crate::tools::user_input::UserInputLimits::default(),
user_input_timeout: None,
goal_max_steps: None,
prefer_bwrap: false,
bwrap_extensions: crate::sandbox::BwrapMountExtensions::default(),
// Fail-closed (F7): `Engine::new` unconditionally installs this
@@ -657,6 +668,7 @@ impl CancelReason {
/// Handle to communicate with the engine
#[derive(Clone)]
pub struct EngineHandle {
goal_state: SharedGoalState,
/// Send operations to the engine
pub tx_op: mpsc::Sender<Op>,
/// Receive events from the engine
@@ -1737,6 +1749,7 @@ impl Engine {
advisor_emission_guard: None,
};
let handle = EngineHandle {
goal_state: engine.config.goal_state.clone(),
tx_op,
rx_event: Arc::new(RwLock::new(rx_event)),
cancel_token: shared_cancel_token,
@@ -2726,14 +2739,19 @@ impl Engine {
)
.await;
}
Op::SetGoalStatus { status, clear } => {
self.handle_set_goal_status(status, clear).await;
Op::SetGoalStatus {
status,
clear,
goal_id,
} => {
self.handle_set_goal_status(status, clear, goal_id).await;
}
Op::SetGoalObjective {
objective,
token_budget,
goal_id,
} => {
self.handle_set_goal_objective(objective, token_budget)
self.handle_set_goal_objective(objective, token_budget, goal_id)
.await;
}
Op::PreviewOutboundRequest {
@@ -4154,7 +4172,12 @@ impl Engine {
/// Handle `/goal pause|resume|clear|complete|blocked` by writing the new
/// status to `SharedGoalState` so the cross-turn continuation loop respects
/// it. This does NOT dispatch a model turn — it's a control-plane update.
async fn handle_set_goal_status(&mut self, status: GoalStatus, clear: bool) {
async fn handle_set_goal_status(
&mut self,
status: GoalStatus,
clear: bool,
goal_id: Option<String>,
) {
if clear || status != GoalStatus::Active {
self.cancel_scheduled_goal_continuation(true).await;
}
@@ -4180,7 +4203,11 @@ impl Engine {
// is preserved (pause/resume shouldn't reset the counter).
let objective = state.objective().map(str::to_string);
let budget = state.token_budget();
state.sync_from_host_status(objective.as_deref(), budget, status);
if status == GoalStatus::Active {
state.resume(goal_id);
} else {
state.sync_from_host_status(objective.as_deref(), budget, status);
}
}
state.snapshot()
}
@@ -4241,7 +4268,12 @@ impl Engine {
/// publishes the new snapshot, and the first goal turn is dispatched as
/// runtime steering (the continuation prompt built from the goal
/// snapshot). The objective is never echoed as a raw user message.
async fn handle_set_goal_objective(&mut self, objective: String, token_budget: Option<u32>) {
async fn handle_set_goal_objective(
&mut self,
objective: String,
token_budget: Option<u32>,
goal_id: Option<String>,
) {
let Some(objective) = normalized_goal_objective(Some(&objective)) else {
let _ = self
.tx_event
@@ -4251,12 +4283,13 @@ impl Engine {
.await;
return;
};
sync_goal_state_from_host(
&self.config.goal_state,
Some(&objective),
token_budget,
GoalStatus::Active,
);
match self.config.goal_state.lock() {
Ok(mut state) => state.replace(&objective, token_budget, goal_id),
Err(error) => {
tracing::warn!("goal state lock poisoned during replacement: {error}");
return;
}
}
self.config.goal_objective = Some(objective);
self.config.goal_token_budget = token_budget;
self.config.goal_status = GoalStatus::Active;
@@ -4815,7 +4848,19 @@ impl Engine {
while self.rx_steer.try_recv().is_ok() {}
// Create turn context first so start event includes a stable turn id.
let mut turn = TurnContext::new(self.config.max_steps);
// An active goal gets the host's goal allowance (#5994); turns with
// an explicit per-invocation ceiling (exec --max-turns, child
// workers) never see it because those hosts leave `goal_max_steps`
// unset.
let goal_turn = goal_objective.is_some() && goal_status == GoalStatus::Active;
let mut turn = if goal_turn && let Some(goal_max_steps) = self.config.goal_max_steps {
TurnContext::with_budget_source(
goal_max_steps,
crate::core::turn::StepBudgetSource::Goal,
)
} else {
TurnContext::new(self.config.max_steps)
};
self.turn_counter = self.turn_counter.saturating_add(1);
let turn_started_at = chrono::Utc::now();
// Mint the route receipt from the client that `install_resolved_runtime_route`
@@ -5305,8 +5350,34 @@ impl Engine {
// its own op channel. RuntimeThreadManager engines instead yield here:
// their host must create the next durable claim before dispatching any
// further turn. A Failed or Interrupted turn never continues.
//
// #5994: a turn that exhausted the goal step budget got its bounded
// final report already. An unfinished goal pauses with BudgetLimit
// instead of silently re-arming another full goal turn; a verified
// completion reported in that final turn still wins.
let goal_budget_exhausted = turn.budget_source == crate::core::turn::StepBudgetSource::Goal
&& turn.budget_exhausted_final_report;
if goal_budget_exhausted {
let goal_still_active = self
.config
.goal_state
.lock()
.map(|state| state.is_active())
.unwrap_or(false);
if goal_still_active {
self.pause_goal_continuation(
GoalPauseReason::BudgetLimit,
format!(
"Goal paused: the [goal] max_steps budget ({}) was exhausted. Review the final report, then resume the goal explicitly to continue.",
turn.max_steps
),
)
.await;
}
}
let outcome = SendMessageOutcome::Finished { status, error };
if !self.host_managed_turns()
if !goal_budget_exhausted
&& !self.host_managed_turns()
&& matches!(
&outcome,
SendMessageOutcome::Finished {
@@ -6038,6 +6109,7 @@ impl Engine {
.with_shell_policy(authority.shell_policy())
.with_trusted_external_paths(trusted_external_paths)
.with_follow_symlinks(self.config.workspace_follow_symlinks);
ctx.disallowed_tools = self.config.disallowed_tools.clone().unwrap_or_default();
ctx.persist_services_enabled = self.config.runtime_services.persist_services_enabled;
// Hand the user-memory path to tools so the model-callable
@@ -6212,6 +6284,7 @@ impl Engine {
McpPool::new(McpConfig::default())
})
});
pool = pool.with_disallowed_tools(self.config.disallowed_tools.clone().unwrap_or_default());
if let Some(decider) = self.config.network_policy.as_ref() {
pool = pool.with_network_policy(decider.clone());
}
@@ -7390,6 +7463,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle {
)));
let compaction_cancellation = Arc::new(StdMutex::new(CompactionCancellationState::default()));
let handle = EngineHandle {
goal_state: new_shared_goal_state(),
tx_op,
rx_event: Arc::new(RwLock::new(rx_event)),
cancel_token: shared_cancel_token,
+12 -3
View File
@@ -221,6 +221,9 @@ impl Engine {
})
.await;
// #6003: `[tools] user_input_timeout_seconds` — absent uses the
// built-in default; an explicit 0 waits indefinitely.
let wait = self.config.user_input_timeout.unwrap_or(USER_INPUT_TIMEOUT);
loop {
tokio::select! {
_ = self.cancel_token.cancelled() => {
@@ -229,7 +232,13 @@ impl Engine {
format!("Request cancelled while awaiting user input{suffix}"),
));
}
result = tokio::time::timeout(USER_INPUT_TIMEOUT, self.rx_user_input.recv()) => {
result = async {
if wait.is_zero() {
Ok(self.rx_user_input.recv().await)
} else {
tokio::time::timeout(wait, self.rx_user_input.recv()).await
}
} => {
match result {
Ok(Some(decision)) => {
match decision {
@@ -255,12 +264,12 @@ impl Engine {
.send(Event::Status {
message: format!(
"User input timed out after {}s",
USER_INPUT_TIMEOUT.as_secs()
wait.as_secs()
),
})
.await;
return Err(ToolError::Timeout {
seconds: USER_INPUT_TIMEOUT.as_secs(),
seconds: wait.as_secs(),
});
}
}
+21
View File
@@ -18,6 +18,27 @@ use super::{
};
impl EngineHandle {
/// Called only while Runtime holds the idle turn admission claim. The
/// following SendMessage refreshes the existing prompt/config projection.
pub(crate) fn restore_runtime_goal(
&self,
goal: Option<&codewhale_protocol::ThreadGoal>,
) -> Result<()> {
let mut state = self
.goal_state
.lock()
.map_err(|_| anyhow::anyhow!("goal state lock poisoned"))?;
let current = state.snapshot();
if current.goal_id.as_deref() != goal.map(|goal| goal.goal_id.as_str()) {
*state = goal.map_or_else(crate::tools::goal::GoalState::default, |goal| {
crate::tools::goal::GoalState::from_snapshot(
&crate::tools::goal::GoalSnapshot::from_thread_goal(goal),
)
});
}
Ok(())
}
/// True when the caller must preflight a concrete provider client before
/// committing UI/runtime turn state. Test and embedding handles with an
/// injected model client return false because that client owns model I/O.
+44 -1
View File
@@ -146,7 +146,7 @@ pub(crate) async fn consult_reviewer(
_ = cancel_token.cancelled() => {
return ReviewerResult::finish(ReviewerOutcome::Cancelled, None);
}
response = tokio::time::timeout(REVIEWER_TIMEOUT, client.create_message(request)) => response,
response = tokio::time::timeout(REVIEWER_TIMEOUT, client.create_message_uncached(request)) => response,
};
let response = match response {
Err(_) => return ReviewerResult::unavailable("the reviewer timed out", None),
@@ -224,6 +224,49 @@ mod tests {
}
}
#[tokio::test]
async fn guardian_rechecks_identical_calls_instead_of_reusing_a_cached_allow() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let client = crate::client::DeepSeekClient::new(&crate::config::Config {
api_key: Some("test-guardian-cache-key".to_string()),
base_url: Some(server.uri()),
..Default::default()
})
.unwrap();
for (decision, risk) in [("allow", "low"), ("deny", "high")] {
server.reset().await;
let verdict = serde_json::json!({
"decision": decision,
"risk_level": risk,
"reason": "current authorization evidence",
});
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "guardian-fresh",
"object": "chat.completion",
"model": "deepseek-v4-pro",
"choices": [{"index": 0, "message": {
"role": "assistant", "content": verdict.to_string(),
}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 9, "completion_tokens": 3, "total_tokens": 12},
})))
.mount(&server)
.await;
let result = consult_reviewer(
&client,
"the same proposed call under current policy",
&CancellationToken::new(),
)
.await;
assert_eq!(result.outcome.audit_decision(), decision);
assert_eq!(result.usage.unwrap().output_tokens, 3);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
#[tokio::test]
async fn reviewer_records_usage_and_keeps_context_untrusted() {
let usage = Usage {
+150 -3
View File
@@ -1178,6 +1178,7 @@ async fn goal_continuation_preserves_goal_and_resolves_updated_authoritative_rou
.expect("second goal model request was never entered");
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -1360,6 +1361,7 @@ async fn saturated_mailbox_does_not_deadlock_goal_continuation_self_dispatch() {
handle
.tx_op
.try_send(Op::SetGoalStatus {
goal_id: None,
status,
clear: false,
})
@@ -1492,6 +1494,7 @@ async fn queued_ordinary_turn_does_not_multiply_engine_goal_continuations() {
.expect("coalesced synthetic continuation was never entered");
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -1894,6 +1897,7 @@ async fn goal_pause_during_configured_delay_cancels_pending_continuation() {
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -2438,6 +2442,7 @@ async fn saturated_goal_controls_run_before_ready_idle_child_completion() {
handle
.tx_op
.try_send(Op::SetGoalStatus {
goal_id: None,
status,
clear: false,
})
@@ -2469,7 +2474,7 @@ async fn saturated_goal_controls_run_before_ready_idle_child_completion() {
let EngineRunInput::Operation(op) = input else {
panic!("idle child completion beat queued control {index}");
};
let Op::SetGoalStatus { status, clear } = *op else {
let Op::SetGoalStatus { status, clear, .. } = *op else {
panic!("unexpected operation before queued control {index}");
};
assert!(!clear);
@@ -2537,6 +2542,7 @@ async fn unsaturated_goal_control_runs_before_ready_idle_child_completion() {
handle
.tx_op
.try_send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -2566,6 +2572,7 @@ async fn unsaturated_goal_control_runs_before_ready_idle_child_completion() {
assert!(matches!(
*first,
Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false
}
@@ -2966,6 +2973,7 @@ async fn queued_goal_clear_refreshes_prompt_and_cancels_stale_continuation() {
// synthetic continuation that TurnComplete schedules.
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Active,
clear: true,
})
@@ -3155,6 +3163,7 @@ async fn explicit_natural_goal_activates_before_provider_request() {
// Stop autonomous continuation after the one provider-boundary receipt.
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -3253,6 +3262,7 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option<String>, boo
// Stop autonomous continuation after the one provider call.
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -3371,6 +3381,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
);
handle
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -6967,6 +6978,139 @@ async fn max_steps_exhaustion_fails_as_budget_never_completed() {
task.await.expect("engine task");
}
#[tokio::test]
async fn goal_turn_uses_goal_step_allowance_and_pauses_budget_limit_after_final_report() {
use crate::llm_client::mock::{MockLlmClient, canned};
let config = goal_custom_route_config();
// The model makes one tool step (consuming the 1-step goal allowance),
// then writes its bounded final report when granted it.
let turns = vec![
canned::tool_call_turn(
"call-step-1",
"File",
r#"{"action":"read","path":"state.txt"}"#,
),
canned::simple_text_turn("final report: one step of progress made"),
];
let mock = std::sync::Arc::new(MockLlmClient::new(turns));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let workspace = tempdir().expect("tempdir");
fs::write(workspace.path().join("state.txt"), "still-working\n").expect("write fixture");
let (engine, handle) = Engine::new_with_model_client(
EngineConfig {
model: "local-model".to_string(),
workspace: workspace.path().to_path_buf(),
max_steps: 200,
goal_max_steps: Some(1),
goal_objective: Some("finish the migration".to_string()),
snapshots_enabled: false,
terminal_chrome_enabled: false,
..EngineConfig::default()
},
&config,
client,
);
let goal_state = engine.config.goal_state.clone();
let task = tokio::spawn(engine.run());
handle
.send(active_goal_message_op(
&config,
"Work on the goal.",
"finish the migration",
None,
))
.await
.expect("send goal-budget trajectory");
let mut rx = handle.rx_event.write().await;
let (status, _) = loop {
let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("timed out waiting for goal-budget trajectory")
.expect("engine event");
if let Event::TurnComplete { status, error, .. } = event {
break (status, error);
}
};
drop(rx);
// The final report closes the turn cleanly; the unfinished goal then
// pauses BudgetLimit instead of re-arming another full goal turn (#5994).
assert_eq!(status, TurnOutcomeStatus::Completed);
let snapshot = goal_state.lock().expect("goal lock").snapshot();
assert_eq!(snapshot.status, "paused");
assert_eq!(
snapshot.pause_reason,
Some(crate::tools::goal::GoalPauseReason::BudgetLimit)
);
// The mock served exactly the tool step plus the final report; any
// re-armed continuation would have needed a third provider turn.
assert_eq!(mock.call_count(), 2);
handle.send(Op::Shutdown).await.expect("shutdown engine");
task.await.expect("engine task");
}
#[tokio::test]
async fn interactive_turn_keeps_ordinary_ceiling_when_goal_allowance_is_configured() {
use crate::llm_client::mock::{MockLlmClient, canned};
let workspace = tempdir().expect("tempdir");
fs::write(workspace.path().join("state.txt"), "still-working\n").expect("write fixture");
// No active goal: the [goal] allowance must never raise the ordinary
// interactive ceiling.
let turns = vec![
canned::tool_call_turn(
"call-step-1",
"File",
r#"{"action":"read","path":"state.txt"}"#,
),
canned::tool_call_turn(
"call-step-2",
"File",
r#"{"action":"read","path":"state.txt"}"#,
),
];
let mock = std::sync::Arc::new(MockLlmClient::new(turns));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let engine_config = EngineConfig {
max_steps: 1,
goal_max_steps: Some(1_000),
..deterministic_engine_config(workspace.path())
};
let (engine, handle) = Engine::new_with_model_client(engine_config, &Config::default(), client);
let task = tokio::spawn(engine.run());
handle
.send(external_user_message_op(
"Keep reading until done.",
AppMode::Agent,
&Config::default(),
))
.await
.expect("send interactive trajectory");
let mut rx = handle.rx_event.write().await;
let (status, error) = loop {
let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("timed out waiting for interactive trajectory")
.expect("engine event");
if let Event::TurnComplete { status, error, .. } = event {
break (status, error);
}
};
drop(rx);
assert_eq!(status, TurnOutcomeStatus::Failed);
let error = error.expect("budget exhaustion carries a terminal error");
assert!(error.contains("limit: 1"), "{error}");
assert!(error.contains("max_steps"), "{error}");
handle.send(Op::Shutdown).await.expect("shutdown engine");
task.await.expect("engine task");
}
#[test]
fn synthetic_resume_paths_have_no_hidden_default_ceiling() {
let turn_loop = include_str!("turn_loop.rs");
@@ -11592,6 +11736,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
// real Operate turn seals with.
handle_for_approval
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
@@ -20758,6 +20903,7 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() {
// Construct a minimal EngineHandle with the tiny channel.
let cancel_token = CancellationToken::new();
let handle = EngineHandle {
goal_state: new_shared_goal_state(),
tx_op,
rx_event: Arc::new(RwLock::new(mpsc::channel::<Event>(1).1)),
cancel_token: Arc::new(StdMutex::new(cancel_token)),
@@ -21005,7 +21151,8 @@ lines.on('line', async (line) => {
let pool = engine.ensure_mcp_pool().await.expect("engine pool");
let task = tokio::spawn(async move { engine.run().await });
let mut events = handle.rx_event.write().await;
let progress = tokio::time::timeout(Duration::from_secs(10), async {
// This proves ordering, not Node cold-start speed on a loaded runner.
let progress = tokio::time::timeout(Duration::from_secs(30), async {
while let Some(event) = events.recv().await {
if let Event::McpSessionBoot {
snapshot,
@@ -21036,7 +21183,7 @@ lines.on('line', async (line) => {
}
// Release and shut down even when testing the old batch-buffered behavior.
std::fs::write(&release, "continue").expect("release stalled fixture");
let finished = tokio::time::timeout(Duration::from_secs(10), async {
let finished = tokio::time::timeout(Duration::from_secs(30), async {
while let Some(event) = events.recv().await {
if let Event::McpSessionBoot {
snapshot,
@@ -627,6 +627,7 @@ fn policy_tool_aliases(name: &str) -> &'static [&'static str] {
"edit_file",
],
"bash" | "exec_shell" => &["bash", "exec_shell"],
"mcp_read_resource" | "read_mcp_resource" => &["mcp_read_resource", "read_mcp_resource"],
_ => &[],
}
}
+23 -3
View File
@@ -234,7 +234,10 @@ impl Engine {
tx_event: &mpsc::Sender<Event>,
name: &str,
input: serde_json::Value,
disallowed_tools: &[String],
) -> Result<RichToolResult, ToolError> {
McpPool::authorize_call(disallowed_tools, name, &input)
.map_err(|error| ToolError::not_available(error.to_string()))?;
// A synthetic `mcp_<server>_authenticate` call runs the shared OAuth
// login flow with the pool lock released during the browser wait, so
// parallel MCP tools and the `/mcp` manager keep working while the
@@ -245,7 +248,7 @@ impl Engine {
// call yet.
let auth_target = pool.lock().await.authenticate_tool_target(name);
if let Some(server) = auth_target {
let result = crate::mcp::authenticate_tool_via_pool(&pool, &server, |url| {
let mut result = crate::mcp::authenticate_tool_via_pool(&pool, &server, |url| {
// The model cannot relay the URL until the call returns, and
// the call returns only after the sign-in completes — so the
// user must see it now. This status is the only copy of the
@@ -265,6 +268,7 @@ impl Engine {
})
.await
.map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
McpPool::filter_authenticate_result(&mut result, disallowed_tools);
let mut rich = crate::tools::registry::mcp_result_to_bounded_rich_tool_result(result);
if rich.result.success {
rich.result.metadata = Some(serde_json::json!({ "mcp_catalog_changed": true }));
@@ -272,7 +276,11 @@ impl Engine {
return Ok(rich);
}
let needs_auth_generation_before = pool.lock().await.needs_auth_generation();
let result = pool.lock().await.call_tool(name, input).await;
let result = pool
.lock()
.await
.call_tool_with_disallowed(name, input, disallowed_tools)
.await;
match result {
Ok(result) => {
Ok(crate::tools::registry::mcp_result_to_bounded_rich_tool_result(result))
@@ -544,7 +552,19 @@ impl Engine {
let outcome: Result<RichToolResult, ToolError> = if McpPool::is_mcp_tool(&tool_name) {
if let Some(pool) = mcp_pool {
Engine::execute_mcp_tool_with_pool(pool, &tx_event, &tool_name, tool_input).await
let disallowed_tools = context_override
.as_ref()
.or_else(|| registry.map(|registry| registry.context()))
.map(|context| context.disallowed_tools.as_slice())
.unwrap_or_default();
Engine::execute_mcp_tool_with_pool(
pool,
&tx_event,
&tool_name,
tool_input,
disallowed_tools,
)
.await
} else {
Err(ToolError::not_available(format!(
"tool '{tool_name}' is not registered"
+23 -14
View File
@@ -540,14 +540,14 @@ impl Engine {
if let Some(usage) = &review.usage {
turn.add_usage(usage);
if usage_has_reported_data(usage) {
let request_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
let _ = self
.tx_event
.send(Event::TurnUsage {
usage: usage.clone(),
duration_ms: u64::try_from(started.elapsed().as_millis())
.unwrap_or(u64::MAX),
duration_ms: request_ms,
first_token_ms: None,
request_ms: None,
request_ms: Some(request_ms),
})
.await;
}
@@ -815,13 +815,15 @@ impl Engine {
// report. Savings proved out by the grok-style parity work (ops
// A1): a step-faithful harness ends mid-report far too often.
if !soft_landing_sent
&& self.config.max_steps > 0
&& turn.steps_used() >= ((self.config.max_steps as f32 * 0.8).floor() as u32).max(1)
&& turn.max_steps > 0
&& turn.steps_used() >= ((turn.max_steps as f32 * 0.8).floor() as u32).max(1)
{
soft_landing_sent = true;
let notice = format!(
"Step budget soft landing: you have used about {}% of your {} step budget. Stop exploring; write your final, complete report now, in final form, with evidence.",
80, self.config.max_steps,
"Step budget soft landing: you have used about {}% of your {} step budget ({}). Stop exploring; write your final, complete report now, in final form, with evidence.",
80,
turn.max_steps,
turn.budget_source.key_label(),
);
self.add_session_message(self.user_text_message_with_turn_metadata(notice))
.await;
@@ -858,9 +860,11 @@ impl Engine {
// one final provider turn to write a bounded report, then
// let the natural no-tool termination close the turn.
final_report_sent = true;
turn.budget_exhausted_final_report = true;
let notice = format!(
"Your model-step budget was exhausted (limit: {}). You cannot continue working. Write your final report now: what you did, what you proved or found, what remains, and exact evidence. This is your last turn.",
self.config.max_steps,
"Your model-step budget was exhausted (limit: {}, {}). You cannot continue working. Write your final report now: what you did, what you proved or found, what remains, and exact evidence. This is your last turn.",
turn.max_steps,
turn.budget_source.key_label(),
);
self.add_session_message(self.user_text_message_with_turn_metadata(notice))
.await;
@@ -874,8 +878,9 @@ impl Engine {
break;
} else {
let error = format!(
"Maximum model steps reached before completion (limit: {})",
self.config.max_steps
"Maximum model steps reached before completion (limit: {}, {})",
turn.max_steps,
turn.budget_source.key_label(),
);
let _ = self.tx_event.send(Event::status(error.clone())).await;
return (TurnOutcomeStatus::Failed, Some(error));
@@ -2711,9 +2716,13 @@ impl Engine {
// #3027: deny wins over allow — check the deny-list first so a
// tool present in both lists is still blocked.
if blocked_error.is_none() && tool_policy.denies_tool(&tool_name) {
blocked_error = Some(ToolError::permission_denied(format!(
"Tool '{tool_name}' is in the disallowed-tools list"
)));
blocked_error = Some(if McpPool::is_mcp_tool(&tool_name) {
ToolError::not_available(format!("Unknown MCP tool name: {tool_name}"))
} else {
ToolError::permission_denied(format!(
"Tool '{tool_name}' is in the disallowed-tools list"
))
});
}
if blocked_error.is_none() && !tool_policy.passes_allow_list(&tool_name) {
+5 -3
View File
@@ -262,12 +262,14 @@ pub enum Event {
/// Wall-clock time from the moment the request was dispatched to the
/// provider until the first content-bearing stream event arrived
/// (time to first token). `None` when the call produced no content
/// or the emitting path does not measure dispatch (reviewer / REPL
/// consults), so the session metrics never invent a latency.
/// or the emitting path does not measure the first content event
/// (non-streaming reviewer / REPL consults).
first_token_ms: Option<u64>,
/// Wall-clock time from request dispatch to the usage receipt for
/// this model call — the whole call including connection setup, not
/// only the stream. `None` where dispatch is not measured.
/// only the stream. `None` where an individual request is not
/// measured (for example an aggregate REPL child receipt). This is
/// the denominator for effective session-average throughput.
request_ms: Option<u64>,
},
+4
View File
@@ -181,6 +181,8 @@ pub enum Op {
status: GoalStatus,
/// When `true`, clear the objective entirely (`/goal clear`).
clear: bool,
/// Accepted control revision; None lets direct callers mint it.
goal_id: Option<String>,
},
/// Set (or replace) the active goal objective and immediately start goal
@@ -189,6 +191,8 @@ pub enum Op {
SetGoalObjective {
objective: String,
token_budget: Option<u32>,
/// Accepted control revision; None lets direct callers mint it.
goal_id: Option<String>,
},
/// Describe the exact request the next turn would send, without
+10 -1
View File
@@ -969,14 +969,21 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
auto_approve: *auto_approve,
approval_mode: approval_mode_str(*approval_mode).to_string(),
},
Op::SetGoalStatus { status, clear } => wire_op::Op::SetGoalStatus {
Op::SetGoalStatus {
status,
clear,
goal_id,
} => wire_op::Op::SetGoalStatus {
goal_id: goal_id.clone(),
status: status.as_str().to_string(),
clear: *clear,
},
Op::SetGoalObjective {
objective,
token_budget,
goal_id,
} => wire_op::Op::SetGoalObjective {
goal_id: goal_id.clone(),
objective: objective.clone(),
token_budget: *token_budget,
},
@@ -1319,10 +1326,12 @@ mod tests {
fn protocol_covers_engine_ops() {
let ops = vec![
Op::SetGoalStatus {
goal_id: None,
status: GoalStatus::Paused,
clear: false,
},
Op::SetGoalObjective {
goal_id: None,
objective: "ship".into(),
token_budget: Some(7),
},
@@ -51,6 +51,10 @@ pub trait ModelClient: Send + Sync {
)
}
async fn create_message(&self, request: MessageRequest) -> Result<MessageResponse>;
/// Fresh authorization evidence; cache-owning adapters must bypass it.
async fn create_message_uncached(&self, request: MessageRequest) -> Result<MessageResponse> {
self.create_message(request).await
}
async fn create_message_stream(&self, request: MessageRequest) -> Result<StreamEventBox>;
async fn health_check(&self) -> Result<bool>;
}
@@ -97,6 +101,10 @@ where
LlmClient::create_message(self, request).await
}
async fn create_message_uncached(&self, request: MessageRequest) -> Result<MessageResponse> {
LlmClient::create_message_uncached(self, request).await
}
async fn create_message_stream(&self, request: MessageRequest) -> Result<StreamEventBox> {
LlmClient::create_message_stream(self, request).await
}
+35
View File
@@ -19,6 +19,26 @@ use crate::snapshot::SnapshotRepo;
use std::path::Path;
use std::time::{Duration, Instant};
/// Which configured limit governs a turn's step budget (#5994).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepBudgetSource {
/// The ordinary interactive ceiling (`max_steps`).
Interactive,
/// The goal-turn allowance (`[goal] max_steps`).
Goal,
}
impl StepBudgetSource {
/// The configuration key named in soft-landing and exhaustion notices.
#[must_use]
pub const fn key_label(self) -> &'static str {
match self {
Self::Interactive => "max_steps",
Self::Goal => "[goal] max_steps",
}
}
}
/// Context for a single turn (user message + AI response).
#[derive(Debug)]
pub struct TurnContext {
@@ -35,6 +55,14 @@ pub struct TurnContext {
/// Maximum steps allowed
pub max_steps: u32,
/// Which configured limit `max_steps` came from.
pub budget_source: StepBudgetSource,
/// The turn's step budget was exhausted and the bounded final report was
/// granted (#5994). Set by the turn loop; the cross-turn goal fence reads
/// it so an exhausted goal pauses instead of re-arming.
pub budget_exhausted_final_report: bool,
/// Number of tool calls made in this turn.
/// Whether the turn has been cancelled
@@ -62,11 +90,18 @@ pub struct TurnContext {
impl TurnContext {
/// Create a new turn context
pub fn new(max_steps: u32) -> Self {
Self::with_budget_source(max_steps, StepBudgetSource::Interactive)
}
/// Create a turn context with an explicit budget provenance (#5994).
pub fn with_budget_source(max_steps: u32, budget_source: StepBudgetSource) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
started_at: Instant::now(),
step: 0,
max_steps,
budget_source,
budget_exhausted_final_report: false,
cancelled: false,
usage: Usage {
input_tokens: 0,
+2
View File
@@ -334,6 +334,8 @@ pub(crate) async fn run_exec_agent(
execution_config.tools_always_load()
},
user_input_limits: execution_config.user_input_limits(),
user_input_timeout: execution_config.user_input_timeout(),
goal_max_steps: None,
tools: if fleet_authority_active {
None
} else {
+7
View File
@@ -32,6 +32,13 @@ use std::time::Duration;
/// with `[goal] max_continuations`; `0` keeps the default unlimited behavior.
pub const DEFAULT_MAX_GOAL_CONTINUATIONS: u32 = 0;
/// Default per-engine-turn step allowance while a goal is active (#5994).
/// Deliberate maintainer policy, not a measured number: five times the
/// ordinary interactive allowance (200), so intentional goal work has room
/// while every provider turn stays finite. The count of continuation passes
/// remains governed separately (`[goal] max_continuations`).
pub const DEFAULT_GOAL_MAX_STEPS: u32 = 1_000;
/// How many consecutive critical `not_achieved` reviews naming the *same*
/// normalized gap set a goal may accumulate before it pauses itself with
/// `GoalPauseReason::NoProgress`.
+2
View File
@@ -161,6 +161,8 @@ fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpSe
oauth: None,
oauth_resource: None,
reviewed_plugin: None,
runtime_added: false,
allow_private_network: false,
}
}
+214 -1
View File
@@ -265,6 +265,9 @@ pub(crate) const FIRST_PARTY_PAYG_BILLING_SURFACE: &str = "first-party-payg";
/// An aggregator/reseller endpoint: metered, but priced by the aggregator's own
/// catalog rather than by the upstream model owner's published rates.
pub(crate) const AGGREGATOR_BILLING_SURFACE: &str = "aggregator-payg";
pub(crate) const MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE: &str = "modelstudio-token-plan";
pub(crate) const MODELSTUDIO_CODING_PLAN_BILLING_SURFACE: &str = "modelstudio-coding-plan";
pub(crate) const VOLCENGINE_CODING_PLAN_BILLING_SURFACE: &str = "volcengine-coding-plan";
/// A reachable endpoint CodeWhale could not match to any known billing surface.
/// Distinct from "not classified yet": this is a positive statement that the
/// surface is unknown, and it fails closed everywhere it is consumed.
@@ -308,6 +311,18 @@ pub fn endpoint_metering_for_billing_surface(billing_surface: Option<&str>) -> E
(XIAOMI_PAYG_BILLING_SURFACE, EndpointMetering::Money),
(FIRST_PARTY_PAYG_BILLING_SURFACE, EndpointMetering::Money),
(AGGREGATOR_BILLING_SURFACE, EndpointMetering::Money),
(
MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE,
EndpointMetering::ExactSubscription,
),
(
MODELSTUDIO_CODING_PLAN_BILLING_SURFACE,
EndpointMetering::ExactSubscription,
),
(
VOLCENGINE_CODING_PLAN_BILLING_SURFACE,
EndpointMetering::ExactSubscription,
),
(
STEPFUN_PLAN_BILLING_SURFACE,
EndpointMetering::ExactSubscription,
@@ -417,6 +432,11 @@ pub(crate) fn billing_surface_for_route(
ApiProvider::Moonshot => moonshot_surface(&shape),
ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => minimax_surface(&shape),
ApiProvider::XiaomiMimo => xiaomi_surface(&shape),
ApiProvider::ModelstudioTokenPlan
| ApiProvider::ModelstudioTokenPlanAnthropic
| ApiProvider::ModelstudioCodingPlan
| ApiProvider::ModelstudioCodingPlanAnthropic => modelstudio_surface(&shape),
ApiProvider::Volcengine => volcengine_surface(&shape),
ApiProvider::Openrouter
| ApiProvider::NvidiaNim
| ApiProvider::OpencodeZen
@@ -429,6 +449,38 @@ pub(crate) fn billing_surface_for_route(
Some(surface.unwrap_or(UNCLASSIFIED_BILLING_SURFACE))
}
// Token Plan and Coding Plan keys/endpoints are isolated from PAYG.
// https://www.alibabacloud.com/help/en/model-studio/token-plan-quick-start
// https://www.alibabacloud.com/help/en/model-studio/coding-plan-faq
fn modelstudio_surface(shape: &EndpointShape) -> Option<&'static str> {
match (shape.host.as_str(), shape.path.as_str()) {
(
"token-plan.ap-southeast-1.maas.aliyuncs.com",
"/compatible-mode/v1" | "/apps/anthropic" | "/apps/anthropic/v1",
) => Some(MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE),
(
"coding-intl.dashscope.aliyuncs.com" | "coding.dashscope.aliyuncs.com",
"/v1" | "/apps/anthropic" | "/apps/anthropic/v1",
) => Some(MODELSTUDIO_CODING_PLAN_BILLING_SURFACE),
("dashscope-intl.aliyuncs.com" | "dashscope.aliyuncs.com", "/compatible-mode/v1") => {
Some(FIRST_PARTY_PAYG_BILLING_SURFACE)
}
_ => None,
}
}
// The Coding Plan gateway consumes plan quota; /api/v3 is billed separately.
// https://www.volcengine.com/docs/82379/1925114
fn volcengine_surface(shape: &EndpointShape) -> Option<&'static str> {
match (shape.host.as_str(), shape.path.as_str()) {
("ark.cn-beijing.volces.com", "/api/coding" | "/api/coding/v3") => {
Some(VOLCENGINE_CODING_PLAN_BILLING_SURFACE)
}
("ark.cn-beijing.volces.com", "/api/v3") => Some(FIRST_PARTY_PAYG_BILLING_SURFACE),
_ => None,
}
}
fn stepfun_surface(shape: &EndpointShape) -> Option<&'static str> {
if host_of(DEFAULT_STEPFUN_BASE_URL).is_some_and(|official| shape.host == official)
&& matches!(shape.path.as_str(), "" | "/v1")
@@ -578,6 +630,7 @@ pub fn has_pricing_for_provider(provider: ApiProvider, model: &str) -> bool {
/// Return whether a provider/model route has authoritative pricing for an
/// already-classified billing surface.
#[cfg(test)]
#[must_use]
pub(crate) fn has_pricing_for_billing_surface(
provider: ApiProvider,
@@ -1143,7 +1196,7 @@ pub(crate) fn calculate_turn_cost_estimate_for_provider_at(
/// Every `None` from the estimator carries one of these so `/cost`, `/cache`,
/// and the scorecard can say *why* a turn is missing from a total instead of
/// letting the total read as complete.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum UnpricedReason {
/// The route is **exactly identified** as one where money is not the unit:
/// a named OAuth subscription, a named prepaid token plan, or a local
@@ -1183,6 +1236,10 @@ pub enum UnpricedReason {
UnrepresentedTier,
/// No pricing row exists for this provider/model route.
NoPricingRow,
/// Automatic gateway routing has not identified the upstream rate owner.
RoutingDependentPrice,
/// Saved usage predates cost coverage, or carries an unknown reason code.
UnrecordedCoverage,
/// A row exists, but a token class this turn actually used has no published
/// price, so the estimate fails closed rather than under-reporting.
MissingClassPrice,
@@ -1199,6 +1256,51 @@ pub enum UnpricedReason {
}
impl UnpricedReason {
/// Decode persisted receipts without guessing from the current provider.
/// Older or future reason codes remain explicitly unrecorded coverage.
#[must_use]
pub fn from_label(label: &str) -> Self {
match label {
"not_money_metered" => Self::NotMoneyMetered,
"unknown_billing_basis" => Self::UnknownBillingBasis,
"ambiguous_billing_surface" => Self::AmbiguousBillingSurface,
"unestablished_endpoint" => Self::UnestablishedEndpoint,
"unpriced_billing_surface" => Self::UnpricedBillingSurface,
"unverified_live_pricing" => Self::UnverifiedLivePricing,
"retired_alias" => Self::RetiredAlias,
"unrepresented_pricing_tier" => Self::UnrepresentedTier,
"no_pricing_row" => Self::NoPricingRow,
"routing_dependent_price" => Self::RoutingDependentPrice,
"missing_class_price" => Self::MissingClassPrice,
"invalid_pricing_row" => Self::InvalidPricingRow,
"unsupported_currency" | "currency_not_published" => Self::UnsupportedCurrency,
"inconsistent_usage" => Self::InconsistentUsage,
_ => Self::UnrecordedCoverage,
}
}
#[must_use]
pub const fn message_id(self) -> crate::localization::MessageId {
use crate::localization::MessageId;
match self {
Self::NotMoneyMetered => MessageId::CostReasonNotMoney,
Self::UnknownBillingBasis => MessageId::CostReasonBillingUnknown,
Self::AmbiguousBillingSurface | Self::UnestablishedEndpoint => {
MessageId::CostReasonEndpointUnknown
}
Self::UnpricedBillingSurface | Self::NoPricingRow => MessageId::CostReasonRateMissing,
Self::UnverifiedLivePricing => MessageId::CostReasonLiveUnverified,
Self::RetiredAlias => MessageId::CostReasonRetiredAlias,
Self::UnrepresentedTier => MessageId::CostReasonTierMissing,
Self::RoutingDependentPrice => MessageId::CostReasonRoutingDependent,
Self::UnrecordedCoverage => MessageId::CostReasonCoverageMissing,
Self::MissingClassPrice => MessageId::CostReasonTokenRateMissing,
Self::InvalidPricingRow => MessageId::CostReasonInvalidRate,
Self::UnsupportedCurrency => MessageId::CostReasonCurrencyMissing,
Self::InconsistentUsage => MessageId::CostReasonUsageConflict,
}
}
/// Stable, non-localized identifier for logs, JSON, and scorecards.
#[must_use]
pub fn label(self) -> &'static str {
@@ -1212,6 +1314,8 @@ impl UnpricedReason {
Self::RetiredAlias => "retired_alias",
Self::UnrepresentedTier => "unrepresented_pricing_tier",
Self::NoPricingRow => "no_pricing_row",
Self::RoutingDependentPrice => "routing_dependent_price",
Self::UnrecordedCoverage => "unrecorded_coverage",
Self::MissingClassPrice => "missing_class_price",
Self::InvalidPricingRow => "invalid_pricing_row",
Self::UnsupportedCurrency => "unsupported_currency",
@@ -1525,6 +1629,14 @@ pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at(
// the unverified rate or a bare "no pricing row".
match (live_defect, hand_row) {
(Some(defect), None) => TurnCostAudit::unverified_live(defect),
// Concentrate publishes different upstream rates, and even a requested
// provider/model can fail over. A slash is not a billing receipt. Keep
// verified scoped offerings and operator overrides above authoritative;
// absent those, do not inherit a model owner's or aggregate rate.
// https://concentrate.ai/docs/api-reference/endpoint/auto-routing
(None, None) if provider == ApiProvider::Concentrate => {
TurnCostAudit::unpriced(UnpricedReason::RoutingDependentPrice)
}
(defect, hand_row) => hand_priced_audit(hand_row, usage).with_live_defect(defect),
}
}
@@ -4741,6 +4853,107 @@ mod tests {
);
}
/// Concentrate publishes different upstream rates and can fail over even
/// when a provider/model prefix is requested, so a requested model's own
/// published rate is never inherited: without a verified scoped offering
/// the route reports the explicit routing-dependent reason instead of
/// dollars (#5976).
#[test]
fn concentrate_without_verified_scoped_pricing_reports_routing_dependent() {
let _live = crate::provider_lake::lock_live_snapshot();
crate::provider_lake::clear_live_snapshot();
let usage = million_input_usage();
// deepseek-v4-pro is the model owner's own hand-priced row; a
// Concentrate request for it must not inherit that rate.
let audit = official_route_audit(ApiProvider::Concentrate, "deepseek-v4-pro", &usage);
assert!(!audit.is_priced(), "{audit:?}");
assert_eq!(
audit.unpriced_reason,
Some(UnpricedReason::RoutingDependentPrice),
"{audit:?}"
);
assert!(!has_pricing_for_provider(
ApiProvider::Concentrate,
"deepseek-v4-pro"
));
}
/// A fresh per-provider `/models` row fetched from the exact Concentrate
/// endpoint is the scoped offering that *is* authoritative: with the
/// endpoint fingerprint it prices at the scoped rate; without it the same
/// row degrades to an unverified-live receipt rather than billing.
#[test]
fn concentrate_scoped_offering_prices_only_with_endpoint_provenance() {
let _live = crate::provider_lake::lock_live_snapshot();
crate::provider_lake::clear_live_snapshot();
let now = Utc::now();
let fetched_at = u64::try_from(now.timestamp()).expect("timestamp");
let fingerprint = codewhale_config::catalog::base_url_fingerprint(
crate::config::DEFAULT_CONCENTRATE_BASE_URL,
);
crate::provider_lake::set_live_snapshot(
codewhale_config::catalog::CatalogSnapshot {
offerings: vec![codewhale_config::catalog::CatalogOffering {
provider: "concentrate".to_string(),
wire_model_id: "deepseek-v4-pro".to_string(),
endpoint_key: "chat".to_string(),
cost: Some(codewhale_config::models_dev::ModelsDevCost {
input: Some(0.5),
output: Some(1.5),
cache_read: None,
cache_write: None,
}),
source: codewhale_config::catalog::CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
}],
},
crate::provider_lake::LiveSource::PerProvider,
);
let usage = million_input_usage();
let surface = billing_surface_for_route(
ApiProvider::Concentrate,
Some(crate::config::DEFAULT_CONCENTRATE_BASE_URL),
);
let scoped = audit_turn_cost_for_route_on_endpoint_at(
ApiProvider::Concentrate,
"deepseek-v4-pro",
surface,
Some(&fingerprint),
&usage,
now,
);
let unproven = audit_turn_cost_for_route_on_endpoint_at(
ApiProvider::Concentrate,
"deepseek-v4-pro",
surface,
None,
&usage,
now,
);
crate::provider_lake::clear_live_snapshot();
assert!(scoped.is_priced(), "{scoped:?}");
assert_eq!(scoped.provenance, Some(PricingProvenance::ProviderLive));
let estimate = scoped.estimate.expect("priced");
assert!(
(estimate.usd - 0.5).abs() < 1e-12,
"scoped Concentrate rate must govern: {}",
estimate.usd
);
assert!(!unproven.is_priced(), "{unproven:?}");
assert_eq!(
unproven.unpriced_reason,
Some(UnpricedReason::UnverifiedLivePricing),
"{unproven:?}"
);
}
// ── BalanceResponse / BalanceInfo ──────────────────────────────
#[test]
+406 -60
View File
@@ -13,7 +13,8 @@
//! - unknown stays unknown — never `$0.00` and never an estimate-as-spend.
use crate::config::{ApiProvider, Config, ProviderConfig};
use crate::pricing::{CostCurrency, format_cost_amount};
use crate::localization::{Locale, MessageId, tr};
use crate::pricing::{CostCurrency, UnpricedReason, format_cost_amount};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BillingPresentation {
@@ -39,6 +40,7 @@ pub enum UsageChip {
PricedSubtotal {
amount: String,
legacy: bool,
reasons: Vec<UnpricedReason>,
},
/// Subscription / OAuth allowance. `used_pct` is only set when the
/// provider supplied a real percentage.
@@ -47,7 +49,7 @@ pub enum UsageChip {
used_pct: Option<f32>,
},
Local,
Unknown,
Unknown(Vec<UnpricedReason>),
/// Metered route with pricing, but nothing spent yet — omit the chip
/// rather than rendering `$0.00` / `<$0.0001`.
Hidden,
@@ -141,6 +143,9 @@ fn static_subscription_label(label: &str) -> Option<&'static str> {
"Grok OAuth quota" => "Grok OAuth quota",
"Claude OAuth quota" => "Claude OAuth quota",
"StepFun Step Plan quota" => "StepFun Step Plan quota",
"Alibaba Token Plan" => "Alibaba Token Plan",
"Alibaba Coding Plan" => "Alibaba Coding Plan",
"Volcengine Coding Plan" => "Volcengine Coding Plan",
_ => return None,
})
}
@@ -343,20 +348,10 @@ fn classify(
base_url: &str,
product: RouteProduct,
) -> BillingPresentation {
if matches!(
provider,
ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm
) {
return BillingPresentation::Local;
}
if provider == ApiProvider::OpenaiCodex {
return BillingPresentation::Subscription("Codex OAuth quota");
}
if provider == ApiProvider::OpencodeGo {
return BillingPresentation::Subscription("OpenCode Go quota");
}
match provider {
ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => BillingPresentation::Local,
ApiProvider::OpenaiCodex => BillingPresentation::Subscription("Codex OAuth quota"),
ApiProvider::OpencodeGo => BillingPresentation::Subscription("OpenCode Go quota"),
// StepFun already reduces an endpoint to a non-secret billing surface
// and fails closed on anything it does not recognize.
ApiProvider::Stepfun => stepfun_billing_for_endpoint(Some(base_url)),
@@ -369,7 +364,7 @@ fn classify(
ApiProvider::Zai if is_zai_coding_plan_endpoint(base_url) => {
BillingPresentation::Subscription("Z.ai Coding Plan quota")
}
ApiProvider::Zai => BillingPresentation::Metered,
ApiProvider::Zai => endpoint_shaped_payg_billing(provider, base_url),
ApiProvider::XiaomiMimo => product_billing(product),
// Moonshot's direct platform is pay-as-you-go metered. Only the exact
@@ -421,13 +416,52 @@ fn classify(
BillingPresentation::Unknown
}
ApiProvider::Custom => product_billing(product),
// Everything else is an endpoint-shaped, pay-as-you-go provider — but
// These providers are endpoint-shaped — but
// only on an endpoint we actually recognize. A first-party or
// aggregator provider pointed at an unrecognized host is not evidence
// that the host sells that provider's price list, so it must not fall
// through to metered per-token dollars on the strength of a provider
// name (#4318).
_ => endpoint_shaped_payg_billing(provider, base_url),
// Keep this match exhaustive: onboarding a provider requires an
// explicit billing decision and the default-route audit below.
ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::DeepseekAnthropic
| ApiProvider::NvidiaNim
| ApiProvider::Openai
| ApiProvider::Atlascloud
| ApiProvider::WanjieArk
| ApiProvider::Volcengine
| ApiProvider::Openrouter
| ApiProvider::Orcarouter
| ApiProvider::Novita
| ApiProvider::Fireworks
| ApiProvider::Siliconflow
| ApiProvider::SiliconflowCn
| ApiProvider::Arcee
| ApiProvider::OllamaCloud
| ApiProvider::Huggingface
| ApiProvider::Together
| ApiProvider::Qianfan
| ApiProvider::Openmodel
| ApiProvider::Deepinfra
| ApiProvider::Sakana
| ApiProvider::LongCat
| ApiProvider::OpencodeZen
| ApiProvider::Meta
| ApiProvider::Mistral
| ApiProvider::Google
| ApiProvider::Antigravity
| ApiProvider::Telecomjs
| ApiProvider::Edenai
| ApiProvider::Concentrate
| ApiProvider::Codewhale
| ApiProvider::ModelstudioTokenPlan
| ApiProvider::ModelstudioTokenPlanAnthropic
| ApiProvider::ModelstudioCodingPlan
| ApiProvider::ModelstudioCodingPlanAnthropic => {
endpoint_shaped_payg_billing(provider, base_url)
}
}
}
@@ -440,7 +474,14 @@ fn endpoint_shaped_payg_billing(provider: ApiProvider, base_url: &str) -> Billin
match crate::pricing::endpoint_metering_for_billing_surface(surface) {
EndpointMetering::Money => BillingPresentation::Metered,
EndpointMetering::LocalNoBill => BillingPresentation::Local,
EndpointMetering::ExactSubscription => BillingPresentation::Subscription("provider plan"),
EndpointMetering::ExactSubscription => BillingPresentation::Subscription(match surface {
Some(crate::pricing::MODELSTUDIO_TOKEN_PLAN_BILLING_SURFACE) => "Alibaba Token Plan",
Some(crate::pricing::MODELSTUDIO_CODING_PLAN_BILLING_SURFACE) => "Alibaba Coding Plan",
Some(crate::pricing::VOLCENGINE_CODING_PLAN_BILLING_SURFACE) => {
"Volcengine Coding Plan"
}
_ => "provider plan",
}),
EndpointMetering::Unknown => BillingPresentation::Unknown,
}
}
@@ -721,6 +762,7 @@ pub struct ChildRouteClaim<'a> {
/// Requires both a metered billing presentation and an authoritative priced
/// basis for the model. OAuth/token-plan routes always return false even when
/// the same model id is priced on a public API route.
#[cfg(test)]
#[must_use]
pub fn has_priced_metered_basis(
billing: BillingPresentation,
@@ -754,14 +796,38 @@ pub fn usage_chip(
) -> UsageChip {
match billing {
BillingPresentation::Local => UsageChip::Local,
BillingPresentation::Unknown => UsageChip::Unknown,
BillingPresentation::Unknown => {
UsageChip::Unknown(vec![UnpricedReason::UnknownBillingBasis])
}
BillingPresentation::Subscription(label) => UsageChip::Allowance {
label,
used_pct: used_pct.filter(|pct| pct.is_finite() && *pct >= 0.0),
},
BillingPresentation::Metered => {
if !has_priced_metered_basis(billing, provider, model) {
UsageChip::Unknown
let surface = (provider == ApiProvider::Stepfun)
.then_some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE);
let audit = if surface.is_some() {
crate::pricing::audit_turn_cost_for_route_at(
provider,
model,
surface,
&crate::models::Usage::default(),
chrono::Utc::now(),
)
} else {
crate::pricing::audit_turn_cost_for_provider_at(
provider,
model,
&crate::models::Usage::default(),
chrono::Utc::now(),
)
};
if !audit.is_priced_in(currency) {
UsageChip::Unknown(vec![
audit
.unpriced_reason
.unwrap_or(UnpricedReason::UnsupportedCurrency),
])
} else if displayed_cost.is_finite() && displayed_cost > 0.0 {
UsageChip::Money(format_cost_amount(displayed_cost, currency))
} else {
@@ -774,24 +840,56 @@ pub fn usage_chip(
/// Compact footer/header chip text. `None` means omit the chip.
#[must_use]
#[allow(dead_code)] // shared chip formatter for footer/sidebar siblings (TUI-DOG-010)
pub fn format_usage_chip(chip: &UsageChip) -> Option<String> {
pub fn format_usage_chip(chip: &UsageChip, locale: Locale) -> Option<String> {
match chip {
UsageChip::Money(amount) => Some(amount.clone()),
UsageChip::PricedSubtotal { amount, legacy } => Some(if *legacy {
format!("saved subtotal {amount} + unknown")
} else {
format!("subtotal {amount} + unknown")
}),
UsageChip::PricedSubtotal {
amount,
legacy,
reasons,
} => Some(
tr(
locale,
if *legacy {
MessageId::CostChipSavedSubtotal
} else {
MessageId::CostChipSubtotal
},
)
.replace("{amount}", amount)
.replace("{reasons}", &format_unpriced_reasons(reasons, locale)),
),
UsageChip::Allowance { label, used_pct } => Some(match used_pct {
Some(pct) => format!("usage: {label} · {pct:.0}%"),
None => format!("usage: {label}"),
Some(pct) => tr(locale, MessageId::CostChipAllowancePercent)
.replace("{plan}", label)
.replace("{percent}", &format!("{pct:.0}")),
None => tr(locale, MessageId::CostChipAllowance).replace("{plan}", label),
}),
UsageChip::Local => Some("cost: local".to_string()),
UsageChip::Unknown => Some("cost: unknown".to_string()),
UsageChip::Local => Some(tr(locale, MessageId::CostChipLocal).into_owned()),
UsageChip::Unknown(reasons) => Some(
tr(locale, MessageId::CostChipUnknown)
.replace("{reasons}", &format_unpriced_reasons(reasons, locale)),
),
UsageChip::Hidden => None,
}
}
/// The same saved receipt explains missing coverage in every cost surface.
#[must_use]
pub fn format_unpriced_reasons(reasons: &[UnpricedReason], locale: Locale) -> String {
if reasons.is_empty() {
return tr(locale, UnpricedReason::UnrecordedCoverage.message_id()).into_owned();
}
let mut descriptions = Vec::new();
for reason in reasons {
let text = tr(locale, reason.message_id());
if !descriptions.contains(&text) {
descriptions.push(text);
}
}
descriptions.join(", ")
}
fn custom_billing_unknown(config: &ProviderConfig) -> bool {
// A custom OpenAI-compatible endpoint with no explicit pay mode and no
// priced catalog is treated as unknown rather than inventing metered
@@ -1061,22 +1159,26 @@ mod tests {
);
assert!(!matches!(chip, UsageChip::Money(_)));
assert_eq!(
format_usage_chip(&chip).as_deref(),
format_usage_chip(&chip, crate::localization::Locale::En).as_deref(),
Some("usage: Kimi Code quota")
);
// The label names the membership product, never the credential import
// mechanism, and never a dollar figure.
assert!(
!format_usage_chip(&chip)
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains("OAuth")
);
assert!(
!format_usage_chip(&chip)
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains("imported token")
);
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1212,7 +1314,11 @@ mod tests {
None,
);
assert!(!matches!(chip, UsageChip::Money(_)));
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
}
@@ -1237,7 +1343,11 @@ mod tests {
None,
);
assert!(matches!(chip, UsageChip::Money(_)));
assert!(format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1273,7 +1383,11 @@ mod tests {
used_pct: None,
}
);
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1423,10 +1537,14 @@ mod tests {
None,
);
assert_eq!(
format_usage_chip(&chip).as_deref(),
format_usage_chip(&chip, crate::localization::Locale::En).as_deref(),
Some("usage: Codex OAuth quota")
);
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1476,7 +1594,11 @@ mod tests {
CostCurrency::Usd,
None,
);
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
assert_eq!(
for_child_route(
ApiProvider::Deepseek,
@@ -1521,7 +1643,11 @@ mod tests {
CostCurrency::Usd,
None,
);
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1556,7 +1682,10 @@ mod tests {
CostCurrency::Usd,
None,
);
assert_eq!(format_usage_chip(&payg_chip).as_deref(), Some("$0.42"));
assert_eq!(
format_usage_chip(&payg_chip, crate::localization::Locale::En).as_deref(),
Some("$0.42")
);
let plan_config = config_with(
ApiProvider::Stepfun,
@@ -1579,7 +1708,7 @@ mod tests {
None,
);
assert!(
!format_usage_chip(&plan_chip)
!format_usage_chip(&plan_chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
@@ -1751,7 +1880,7 @@ mod tests {
Some(37.0),
);
assert_eq!(
format_usage_chip(&chip).as_deref(),
format_usage_chip(&chip, crate::localization::Locale::En).as_deref(),
Some("usage: Grok OAuth quota · 37%")
);
}
@@ -1772,7 +1901,10 @@ mod tests {
CostCurrency::Usd,
None,
);
assert_eq!(format_usage_chip(&spent).as_deref(), Some("$0.42"));
assert_eq!(
format_usage_chip(&spent, crate::localization::Locale::En).as_deref(),
Some("$0.42")
);
let zero = usage_chip(
billing,
@@ -1783,8 +1915,12 @@ mod tests {
None,
);
assert_eq!(zero, UsageChip::Hidden);
assert!(format_usage_chip(&zero).is_none());
assert!(!format_usage_chip(&zero).unwrap_or_default().contains('$'));
assert!(format_usage_chip(&zero, crate::localization::Locale::En).is_none());
assert!(
!format_usage_chip(&zero, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1801,8 +1937,15 @@ mod tests {
CostCurrency::Usd,
None,
);
assert_eq!(format_usage_chip(&chip).as_deref(), Some("cost: local"));
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert_eq!(
format_usage_chip(&chip, crate::localization::Locale::En).as_deref(),
Some("cost: local")
);
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -1851,9 +1994,16 @@ mod tests {
CostCurrency::Usd,
None,
);
assert_eq!(chip, UsageChip::Unknown);
assert_eq!(format_usage_chip(&chip).as_deref(), Some("cost: unknown"));
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert_eq!(chip, UsageChip::Unknown(vec![UnpricedReason::NoPricingRow]));
assert_eq!(
format_usage_chip(&chip, crate::localization::Locale::En).as_deref(),
Some("cost: unknown (rate unavailable)")
);
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
let unknown_billing = usage_chip(
BillingPresentation::Unknown,
@@ -1863,9 +2013,12 @@ mod tests {
CostCurrency::Usd,
None,
);
assert_eq!(unknown_billing, UsageChip::Unknown);
assert_eq!(
unknown_billing,
UsageChip::Unknown(vec![UnpricedReason::UnknownBillingBasis])
);
assert!(
!format_usage_chip(&unknown_billing)
!format_usage_chip(&unknown_billing, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
@@ -2213,7 +2366,11 @@ mod tests {
None,
);
assert!(matches!(chip, UsageChip::Money(_)));
assert!(format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -2245,7 +2402,11 @@ mod tests {
None,
);
assert!(!matches!(chip, UsageChip::Money(_)));
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
#[test]
@@ -2399,8 +2560,15 @@ mod tests {
CostCurrency::Usd,
None,
);
assert_eq!(chip, UsageChip::Unknown);
assert!(!format_usage_chip(&chip).unwrap_or_default().contains('$'));
assert_eq!(
chip,
UsageChip::Unknown(vec![UnpricedReason::UnknownBillingBasis])
);
assert!(
!format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default()
.contains('$')
);
}
}
@@ -2781,4 +2949,182 @@ mod tests {
);
assert!(!billing.shows_money());
}
/// Every provider env contract that can move a default route's endpoint.
/// The audit below pins shipped defaults, so these must not leak in.
const BASE_URL_ENV_VARS: &[&str] = &[
"CODEWHALE_BASE_URL",
"DEEPSEEK_BASE_URL",
"NIM_BASE_URL",
"NVIDIA_BASE_URL",
"NVIDIA_NIM_BASE_URL",
"OPENAI_BASE_URL",
"ATLASCLOUD_BASE_URL",
"OPENROUTER_BASE_URL",
"ORCAROUTER_BASE_URL",
"MIMO_BASE_URL",
"XIAOMI_MIMO_BASE_URL",
"WANJIE_ARK_BASE_URL",
"WANJIE_BASE_URL",
"WANJIE_MAAS_BASE_URL",
"VOLCENGINE_BASE_URL",
"VOLCENGINE_ARK_BASE_URL",
"ARK_BASE_URL",
"NOVITA_BASE_URL",
"FIREWORKS_BASE_URL",
"SILICONFLOW_BASE_URL",
"ARCEE_BASE_URL",
"MOONSHOT_BASE_URL",
"KIMI_BASE_URL",
"SGLANG_BASE_URL",
"VLLM_BASE_URL",
"OLLAMA_BASE_URL",
"OLLAMA_CLOUD_BASE_URL",
"HF_BASE_URL",
"HUGGINGFACE_BASE_URL",
"META_MODEL_API_BASE_URL",
"MODEL_API_BASE_URL",
"MISTRAL_BASE_URL",
"XAI_BASE_URL",
"GEMINI_BASE_URL",
"GOOGLE_BASE_URL",
"TELECOMJS_BASE_URL",
"EDENAI_BASE_URL",
"CONCENTRATE_BASE_URL",
"MODELSTUDIO_TOKEN_PLAN_BASE_URL",
"MODELSTUDIO_CODING_PLAN_BASE_URL",
"OPENCODE_GO_BASE_URL",
"OPENCODE_ZEN_BASE_URL",
];
/// The shipped default-route billing decision for every runnable provider.
/// Onboarding or re-defaulting a provider must update this table and the
/// audit artifact (`docs/PROVIDERS.md` billing column) deliberately.
const DEFAULT_ROUTE_BILLING_AUDIT: &[(ApiProvider, BillingPresentation)] = &[
(ApiProvider::Deepseek, BillingPresentation::Metered),
(ApiProvider::DeepseekAnthropic, BillingPresentation::Metered),
(ApiProvider::NvidiaNim, BillingPresentation::Metered),
(ApiProvider::Openai, BillingPresentation::Metered),
(ApiProvider::Atlascloud, BillingPresentation::Metered),
(ApiProvider::WanjieArk, BillingPresentation::Metered),
(
ApiProvider::Volcengine,
BillingPresentation::Subscription("Volcengine Coding Plan"),
),
(ApiProvider::Openrouter, BillingPresentation::Metered),
(ApiProvider::Orcarouter, BillingPresentation::Metered),
(
ApiProvider::XiaomiMimo,
BillingPresentation::Subscription("MiMo token plan"),
),
(ApiProvider::Novita, BillingPresentation::Metered),
(ApiProvider::Fireworks, BillingPresentation::Metered),
(ApiProvider::Siliconflow, BillingPresentation::Metered),
(ApiProvider::Arcee, BillingPresentation::Metered),
(ApiProvider::SiliconflowCn, BillingPresentation::Metered),
(ApiProvider::Moonshot, BillingPresentation::Metered),
(ApiProvider::Sglang, BillingPresentation::Local),
(ApiProvider::Vllm, BillingPresentation::Local),
(ApiProvider::Ollama, BillingPresentation::Local),
(ApiProvider::OllamaCloud, BillingPresentation::Unknown),
(ApiProvider::Huggingface, BillingPresentation::Metered),
(ApiProvider::Together, BillingPresentation::Metered),
(ApiProvider::Qianfan, BillingPresentation::Metered),
(
ApiProvider::OpenaiCodex,
BillingPresentation::Subscription("Codex OAuth quota"),
),
(ApiProvider::Anthropic, BillingPresentation::Metered),
(ApiProvider::Openmodel, BillingPresentation::Metered),
(
ApiProvider::Zai,
BillingPresentation::Subscription("Z.ai Coding Plan quota"),
),
(ApiProvider::Stepfun, BillingPresentation::Metered),
(ApiProvider::Minimax, BillingPresentation::Unknown),
(ApiProvider::MinimaxAnthropic, BillingPresentation::Unknown),
(ApiProvider::Deepinfra, BillingPresentation::Metered),
(ApiProvider::Sakana, BillingPresentation::Metered),
(ApiProvider::LongCat, BillingPresentation::Metered),
(
ApiProvider::OpencodeGo,
BillingPresentation::Subscription("OpenCode Go quota"),
),
(ApiProvider::OpencodeZen, BillingPresentation::Metered),
(ApiProvider::Meta, BillingPresentation::Metered),
(ApiProvider::Xai, BillingPresentation::Metered),
(ApiProvider::Mistral, BillingPresentation::Metered),
(ApiProvider::Telecomjs, BillingPresentation::Metered),
(
ApiProvider::ModelstudioTokenPlan,
BillingPresentation::Subscription("Alibaba Token Plan"),
),
(
ApiProvider::ModelstudioTokenPlanAnthropic,
BillingPresentation::Subscription("Alibaba Token Plan"),
),
(
ApiProvider::ModelstudioCodingPlan,
BillingPresentation::Subscription("Alibaba Coding Plan"),
),
(
ApiProvider::ModelstudioCodingPlanAnthropic,
BillingPresentation::Subscription("Alibaba Coding Plan"),
),
// Retired identity: never selectable or runnable. Its classification
// is pinned only so the endpoint-shaped arm stays exhaustive.
(ApiProvider::Antigravity, BillingPresentation::Metered),
(ApiProvider::Google, BillingPresentation::Metered),
(ApiProvider::Edenai, BillingPresentation::Metered),
(ApiProvider::Concentrate, BillingPresentation::Metered),
(ApiProvider::Codewhale, BillingPresentation::Metered),
(ApiProvider::Custom, BillingPresentation::Unknown),
];
/// Default-route billing is a deliberate, audited decision for every
/// provider `ApiProvider::all()` exposes — 49 rows covering the primary
/// route and every dialect/plan-variant alternate identity.
#[test]
fn default_route_billing_audit_covers_every_provider() {
let _lock = crate::test_support::lock_test_env();
let _env: Vec<_> = BASE_URL_ENV_VARS
.iter()
.copied()
.map(crate::test_support::EnvVarGuard::remove)
.collect();
// Credential shape also steers MiniMax's default product; the audit
// pins the no-credential answer.
let _minimax = crate::test_support::EnvVarGuard::remove("MINIMAX_API_KEY");
let audited: Vec<_> = DEFAULT_ROUTE_BILLING_AUDIT
.iter()
.map(|(provider, _)| provider)
.collect();
for (index, provider) in audited.iter().enumerate() {
assert!(
!audited[..index].contains(provider),
"duplicate audit row for {provider:?}"
);
}
for provider in ApiProvider::all() {
assert!(
audited.contains(&provider),
"{provider:?} is missing from DEFAULT_ROUTE_BILLING_AUDIT"
);
}
assert_eq!(
DEFAULT_ROUTE_BILLING_AUDIT.len(),
49,
"the audit covers every provider identity, primary and alternate"
);
let config = Config::default();
for (provider, expected) in DEFAULT_ROUTE_BILLING_AUDIT {
let actual = for_route(&config, *provider);
assert_eq!(
&actual, expected,
"{provider:?} default route billing changed; update the audit deliberately"
);
}
}
}
+246 -97
View File
@@ -555,15 +555,25 @@ static TEST_APPROVAL_DECISION_TIMEOUT_MS: std::sync::atomic::AtomicU64 =
static TEST_DYNAMIC_TOOL_RESULT_TIMEOUT_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
fn approval_decision_timeout() -> Duration {
#[cfg(test)]
{
let ms = TEST_APPROVAL_DECISION_TIMEOUT_MS.load(std::sync::atomic::Ordering::SeqCst);
if ms > 0 {
return Duration::from_millis(ms);
impl RuntimeThreadManager {
/// Wait for one external approval decision. `[tools]
/// user_input_timeout_seconds` governs (#6003): absent uses the built-in
/// default, an explicit 0 returns `None` and the decision waits
/// indefinitely.
fn approval_decision_timeout(&self) -> Option<Duration> {
#[cfg(test)]
{
let ms = TEST_APPROVAL_DECISION_TIMEOUT_MS.load(std::sync::atomic::Ordering::SeqCst);
if ms > 0 {
return Some(Duration::from_millis(ms));
}
}
match self.read_config().user_input_timeout() {
Some(wait) if wait.is_zero() => None,
Some(wait) => Some(wait),
None => Some(APPROVAL_DECISION_TIMEOUT),
}
}
APPROVAL_DECISION_TIMEOUT
}
fn dynamic_tool_result_timeout() -> Duration {
@@ -1182,6 +1192,9 @@ pub struct RuntimeThreadStore {
items_dir: PathBuf,
events_dir: PathBuf,
goals_dir: PathBuf,
/// Serializes goal controls and revision-fenced progress transactions.
/// Acquired after `active` at admission; never held across an await.
goal_mutation: Arc<parking_lot::Mutex<()>>,
mail_dir: PathBuf,
turn_operations_dir: PathBuf,
owner_id: String,
@@ -1264,6 +1277,7 @@ impl RuntimeThreadStore {
_session_dir_claim: session_dir_claim,
thread_mutation: Arc::new(parking_lot::Mutex::new(())),
turn_mutation: Arc::new(parking_lot::Mutex::new(())),
goal_mutation: Arc::new(parking_lot::Mutex::new(())),
mail_mutation: Arc::new(parking_lot::Mutex::new(())),
#[cfg(test)]
turn_dir_files_read: Arc::new(std::sync::atomic::AtomicU64::new(0)),
@@ -1552,10 +1566,16 @@ impl RuntimeThreadStore {
/// in the `goals/` subdirectory; it is independent of the TUI state store
/// and requires only that the runtime thread exists.
pub fn save_goal(&self, goal: &codewhale_protocol::ThreadGoal) -> Result<()> {
let _guard = self.goal_mutation.lock();
goal.validate_stall_state().map_err(anyhow::Error::msg)?;
write_json_atomic(&self.goal_path(&goal.thread_id)?, goal)
}
/// Load the goal for a thread, returning `None` if no goal has been set.
/// A corrupt record that is still Active with an exhausted stall window
/// is restored paused: the engine pauses NoProgress in the same locked
/// mutation that fills the window, so Active-at-ceiling can only come
/// from an interrupted write.
pub fn load_goal(&self, thread_id: &str) -> Result<Option<codewhale_protocol::ThreadGoal>> {
let path = self.goal_path(thread_id)?;
if !path.exists() {
@@ -1563,13 +1583,84 @@ impl RuntimeThreadStore {
}
let raw = read_store_file(&path)
.with_context(|| format!("Failed to read goal {}", path.display()))?;
let goal: codewhale_protocol::ThreadGoal = serde_json::from_str(&raw)
let mut goal: codewhale_protocol::ThreadGoal = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse goal {}", path.display()))?;
goal.validate_stall_state().map_err(anyhow::Error::msg)?;
goal.normalize_restored_stall_state();
Ok(Some(goal))
}
/// A late turn can only update the revision admitted with that turn.
/// Load/compare/write share the same guard as explicit save/delete.
fn update_goal_if_revision(
&self,
thread_id: &str,
goal_id: &str,
update: impl FnOnce(&mut codewhale_protocol::ThreadGoal),
) -> Result<Option<codewhale_protocol::ThreadGoal>> {
let _guard = self.goal_mutation.lock();
let Some(mut goal) = self.load_goal(thread_id)? else {
return Ok(None);
};
if goal.goal_id != goal_id {
return Ok(None);
}
update(&mut goal);
goal.validate_stall_state().map_err(anyhow::Error::msg)?;
write_json_atomic(&self.goal_path(thread_id)?, &goal)?;
Ok(Some(goal))
}
/// Persist a goal the model created mid-turn through `create_goal`, but
/// only when the thread still has no durable goal: a concurrent explicit
/// PUT/DELETE is the newer revision and always wins. Returns the adopted
/// goal_id when this call created the record, `None` otherwise.
fn create_goal_from_snapshot_if_absent(
&self,
thread_id: &str,
snapshot: &crate::tools::goal::GoalSnapshot,
) -> Result<Option<String>> {
let _guard = self.goal_mutation.lock();
if !snapshot.is_active() || self.load_goal(thread_id)?.is_some() {
return Ok(None);
}
let Some(objective) = snapshot
.objective
.as_deref()
.map(str::trim)
.filter(|objective| !objective.is_empty())
else {
return Ok(None);
};
let now = chrono::Utc::now().timestamp();
let goal_id = snapshot
.goal_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let goal = codewhale_protocol::ThreadGoal {
thread_id: thread_id.to_string(),
goal_id: goal_id.clone(),
objective: objective.to_string(),
status: codewhale_protocol::ThreadGoalStatus::Active,
token_budget: snapshot.token_budget.map(i64::from),
tokens_used: 0,
time_used_seconds: 0,
continuation_count: 0,
created_at: now,
updated_at: now,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
pause_reason: None,
};
goal.validate_stall_state().map_err(anyhow::Error::msg)?;
write_json_atomic(&self.goal_path(thread_id)?, &goal)?;
Ok(Some(goal_id))
}
/// Remove the goal for a thread, returning `true` if one existed.
pub fn delete_goal(&self, thread_id: &str) -> Result<bool> {
let _guard = self.goal_mutation.lock();
let path = self.goal_path(thread_id)?;
if !path.exists() {
return Ok(false);
@@ -3199,6 +3290,7 @@ fn runtime_compaction_config(
#[derive(Debug, Clone)]
struct ActiveTurnState {
turn_id: String,
goal_id: Option<String>,
interrupt_requested: bool,
compaction_id: Option<String>,
}
@@ -3218,6 +3310,42 @@ impl ClaimedTurnKind {
}
}
/// Shared streamed/terminal projection. Usage is accrued once at terminal
/// settlement; compact reviewer history is checkpointed at every GoalUpdated.
fn merge_engine_goal_progress(
goal: &mut codewhale_protocol::ThreadGoal,
snapshot: &crate::tools::goal::GoalSnapshot,
) {
use codewhale_protocol::ThreadGoalStatus as Status;
if goal.status != Status::Active
|| snapshot.objective.as_deref() != Some(goal.objective.as_str())
|| snapshot
.goal_id
.as_deref()
.is_some_and(|id| id != goal.goal_id)
|| i64::from(snapshot.continuation_count) < goal.continuation_count
{
return;
}
goal.continuation_count = i64::from(snapshot.continuation_count);
goal.last_gap_fingerprint
.clone_from(&snapshot.last_gap_fingerprint);
goal.repeated_gap_count = snapshot.repeated_gap_count;
goal.last_gap_pass = snapshot.last_gap_pass;
goal.pause_reason = snapshot.pause_reason;
goal.status = match snapshot.status.as_str() {
"complete" => Status::Complete,
"blocked" => Status::Blocked,
"paused" => match snapshot.pause_reason {
Some(codewhale_protocol::GoalPauseReason::UsageLimit) => Status::UsageLimited,
Some(codewhale_protocol::GoalPauseReason::BudgetLimit) => Status::BudgetLimited,
_ => Status::Paused,
},
_ => Status::Active,
};
goal.updated_at = chrono::Utc::now().timestamp();
}
#[derive(Clone)]
struct ActiveThreadState {
engine: EngineHandle,
@@ -3247,7 +3375,7 @@ struct RecoveredTurnReceipt {
///
/// # Lock ordering invariant
///
/// Runtime state uses eight lock classes:
/// Runtime state uses nine lock classes:
/// - `RuntimeThreadManager::engine_load` — serializes cache-miss engine builds.
/// It may cross awaits and is always acquired before `active`.
/// - `RuntimeThreadManager::event_emit` — preserves append-to-broadcast event
@@ -3262,6 +3390,8 @@ struct RecoveredTurnReceipt {
/// - `RuntimeThreadStore::thread_mutation` — synchronizes short, synchronous
/// thread-record load-modify-save transactions and never crosses `.await`.
/// - `RuntimeThreadStore::turn_mutation` — does the same for turn records.
/// - `RuntimeThreadStore::goal_mutation` — serializes goal controls and progress;
/// acquired after `active` and before `thread_mutation` at turn admission.
/// - `RuntimeThreadManager::active` — protects the set of loaded engine handles.
///
/// `state` is never held with `active`, either record-mutation guard, or
@@ -4539,6 +4669,30 @@ impl RuntimeThreadManager {
.context("goal delete task panicked")?
}
/// Transition the goal status through a revision-checked mutation. A
/// stale load-then-save could otherwise overwrite a concurrent PUT
/// replacement or DELETE; here the write commits only when the record is
/// still the revision the caller read. Returns the updated goal, or
/// `Ok(None)` when the goal changed or vanished since the read.
pub async fn transition_goal_status(
&self,
thread_id: &str,
expected_goal_id: &str,
status: codewhale_protocol::ThreadGoalStatus,
) -> Result<Option<codewhale_protocol::ThreadGoal>> {
let thread_id = thread_id.to_string();
let expected_goal_id = expected_goal_id.to_string();
let store = self.store.clone();
tokio::task::spawn_blocking(move || {
store.update_goal_if_revision(&thread_id, &expected_goal_id, |goal| {
goal.status = status;
goal.updated_at = chrono::Utc::now().timestamp();
})
})
.await
.context("goal status transition task panicked")?
}
/// Activate a persisted `Active` goal: make sure the engine carries the
/// goal state, then dispatch the kickoff turn while the thread is idle.
/// A busy thread is left alone — the running turn already carries the
@@ -4583,7 +4737,11 @@ impl RuntimeThreadManager {
.map(|state| state.engine.clone())
};
if let Some(engine) = engine {
let _ = engine.try_send(Op::SetGoalStatus { status, clear });
let _ = engine.try_send(Op::SetGoalStatus {
status,
clear,
goal_id: None,
});
}
}
@@ -4637,17 +4795,14 @@ impl RuntimeThreadManager {
thread_id: &str,
turn: &TurnRecord,
engine_goal: Option<crate::tools::goal::GoalSnapshot>,
admitted_goal_id: Option<&str>,
turn_tool_catalog: Option<&[codewhale_core::request::Tool]>,
) {
let mut goal = match self.store.load_goal(thread_id) {
Ok(Some(goal)) => goal,
Ok(None) => return,
Err(err) => {
tracing::warn!("failed to load goal for {thread_id} after turn: {err}");
return;
}
let Some(admitted_goal_id) = admitted_goal_id else {
return;
};
let mut continue_after: Option<u64> = None;
let updated = self.store.update_goal_if_revision(thread_id, admitted_goal_id, |goal| {
// Accrue this turn's provider spend onto the durable counters. The
// engine tracks the same totals in memory; the record is the
// cross-restart authority.
@@ -4663,50 +4818,13 @@ impl RuntimeThreadManager {
goal.updated_at = chrono::Utc::now().timestamp();
}
// The engine snapshot is the authority for the continuation counter:
// `record_continuation` counts every intra-turn pass the turn actually
// ran, while a flat per-turn increment here would diverge (one turn
// with N intra-turn passes would count as 1) and could keep arming
// passes the engine's own `ContinuationLimit` gate then refuses.
// A rehydrated engine starts from the durable count, so this only
// ever moves the record forward.
if let Some(snapshot) = engine_goal.as_ref() {
let engine_count = i64::from(snapshot.continuation_count);
if engine_count > goal.continuation_count {
goal.continuation_count = engine_count;
goal.updated_at = chrono::Utc::now().timestamp();
}
}
// Mirror the model's terminal decision into the durable record so a
// restarted host does not resume a goal the verifier already closed.
if matches!(goal.status, codewhale_protocol::ThreadGoalStatus::Active)
&& let Some(snapshot) = engine_goal.as_ref()
&& let Some(projected) = match snapshot.status.as_str() {
"complete" => Some(codewhale_protocol::ThreadGoalStatus::Complete),
"blocked" => Some(codewhale_protocol::ThreadGoalStatus::Blocked),
"paused" => match snapshot.pause_reason {
// Pause reasons that map to the protocol's limit states
// keep their distinct reason; a user pause stays Paused.
Some(crate::tools::goal::GoalPauseReason::UsageLimit) => {
Some(codewhale_protocol::ThreadGoalStatus::UsageLimited)
}
Some(crate::tools::goal::GoalPauseReason::BudgetLimit) => {
Some(codewhale_protocol::ThreadGoalStatus::BudgetLimited)
}
_ => Some(codewhale_protocol::ThreadGoalStatus::Paused),
},
_ => None,
}
{
goal.status = projected;
goal.updated_at = chrono::Utc::now().timestamp();
merge_engine_goal_progress(goal, snapshot);
}
// Only a cleanly completed pass continues the loop. Failed or
// interrupted passes leave the goal Active for an explicit resume
// (PUT, or the next user turn).
let mut continue_after: Option<u64> = None;
if turn.status == RuntimeTurnStatus::Completed
&& matches!(goal.status, codewhale_protocol::ThreadGoalStatus::Active)
{
@@ -4754,10 +4872,20 @@ impl RuntimeThreadManager {
}
}
if let Err(err) = self.store.save_goal(&goal) {
tracing::warn!("failed to record goal progress for {thread_id}: {err}");
return;
}
});
let goal = match updated {
Ok(Some(goal)) => goal,
Ok(None) => {
// A new explicit goal was accepted while the old turn ran.
// Let its own durable gates claim the next idle pass.
self.spawn_goal_continuation(thread_id.to_string(), 0);
return;
}
Err(err) => {
tracing::warn!("failed to record goal progress for {thread_id}: {err}");
return;
}
};
if let Err(err) = self.emit_goal_updated_event(thread_id, goal.clone()).await {
tracing::warn!("failed to emit goal update for {thread_id}: {err}");
}
@@ -4807,12 +4935,6 @@ impl RuntimeThreadManager {
if max_continuations != 0 && goal.continuation_count >= max_continuations {
return Ok(());
}
if goal
.token_budget
.is_some_and(|budget| goal.tokens_used >= budget)
{
return Ok(());
}
let continuation_index = u32::try_from(goal.continuation_count.max(1)).unwrap_or(u32::MAX);
let snapshot = crate::tools::goal::GoalSnapshot::from_thread_goal(&goal);
let prompt = crate::tools::goal::render_continuation_prompt(&snapshot, continuation_index);
@@ -7725,7 +7847,7 @@ impl RuntimeThreadManager {
// would clear an injected goal on any ordinary message. Passing the
// durable record keeps the engine aligned with the store; a replaced
// objective (PUT) still resets counters through the same sync path.
let turn_goal = self.store.load_goal(thread_id).ok().flatten();
let turn_goal = self.store.load_goal(thread_id)?;
let turn_goal_objective = turn_goal
.as_ref()
.map(|goal| goal.objective.trim().to_string())
@@ -7809,6 +7931,11 @@ impl RuntimeThreadManager {
if state.active_turn.is_some() {
bail!("Thread already has an active turn");
}
let _goal_mutation = self.store.goal_mutation.lock();
if self.store.load_goal(thread_id)? != turn_goal {
bail!("Goal changed while preparing the turn; retry");
}
engine.restore_runtime_goal(turn_goal.as_ref())?;
let _thread_mutation = self.store.thread_mutation.lock();
let mut current_thread = self.store.load_thread(thread_id)?;
if !thread_execution_state_matches(&thread, &current_thread) {
@@ -7816,6 +7943,7 @@ impl RuntimeThreadManager {
}
let previous_active_route = (state.route_identity.clone(), state.route_model.clone());
state.active_turn = Some(ActiveTurnState {
goal_id: turn_goal.as_ref().map(|goal| goal.goal_id.clone()),
turn_id: turn_id.clone(),
interrupt_requested: false,
compaction_id: None,
@@ -8149,6 +8277,7 @@ impl RuntimeThreadManager {
}
let previous_active_route = (state.route_identity.clone(), state.route_model.clone());
state.active_turn = Some(ActiveTurnState {
goal_id: None,
turn_id: turn_id.clone(),
interrupt_requested: false,
compaction_id: Some(compaction_id),
@@ -8357,13 +8486,7 @@ impl RuntimeThreadManager {
// the durable record from the first turn. Usage and continuation
// counters are preserved; `sync_from_host_status` would reset
// them because the fresh state's objective "changed".
let persisted_goal = self.store.load_goal(&thread.id).unwrap_or_else(|err| {
tracing::warn!(
"failed to load persisted goal for thread {}: {err}",
thread.id
);
None
});
let persisted_goal = self.store.load_goal(&thread.id)?;
let (goal_objective, goal_token_budget, goal_status, goal_state) = match &persisted_goal
{
Some(goal) => {
@@ -8376,29 +8499,15 @@ impl RuntimeThreadManager {
crate::tools::goal::new_shared_goal_state(),
)
} else {
let (status, pause_reason) =
crate::tools::goal::thread_goal_status_projection(goal.status.clone());
let tokens_used =
u64::try_from(goal.tokens_used.max(0)).unwrap_or(u64::MAX);
let time_used_seconds =
u64::try_from(goal.time_used_seconds.max(0)).unwrap_or(u64::MAX);
let continuation_count =
u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX);
let snapshot = crate::tools::goal::GoalSnapshot::from_thread_goal(goal);
let status =
crate::tools::goal::thread_goal_status_projection(goal.status.clone())
.0;
(
Some(objective.to_string()),
goal.token_budget
.and_then(|value| u32::try_from(value.max(0)).ok()),
snapshot.token_budget,
status,
crate::tools::goal::new_shared_goal_state_from_persisted(
objective,
goal.token_budget
.and_then(|value| u32::try_from(value.max(0)).ok()),
status,
pause_reason,
tokens_used,
time_used_seconds,
continuation_count,
),
crate::tools::goal::new_shared_goal_state_from_snapshot(&snapshot),
)
}
}
@@ -8542,6 +8651,8 @@ impl RuntimeThreadManager {
cfg.tools_always_load()
},
user_input_limits: cfg.user_input_limits(),
user_input_timeout: cfg.user_input_timeout(),
goal_max_steps: Some(cfg.goal_max_steps()),
tools: (!isolated_chat).then(|| cfg.tools.clone()).flatten(),
verbosity: cfg.verbosity.clone(),
workspace_follow_symlinks: settings.workspace_follow_symlinks,
@@ -8874,6 +8985,15 @@ impl RuntimeThreadManager {
// model's `update_goal` decision (complete/blocked/paused) lands here
// before TurnComplete, so terminal settlement can mirror it into the
// durable goal record instead of continuing to spend.
let mut admitted_goal_id = {
let active = self.active.lock().await;
active
.engines
.get(&thread_id)
.and_then(|state| state.active_turn.as_ref())
.filter(|turn| turn.turn_id == turn_id)
.and_then(|turn| turn.goal_id.clone())
};
let mut latest_goal_snapshot: Option<crate::tools::goal::GoalSnapshot> = None;
// Tool definitions of the finished turn's request surface, from the
// final TurnComplete receipt. Goal settlement uses it to mirror the
@@ -9688,8 +9808,12 @@ impl RuntimeThreadManager {
return Err(err);
}
drop(projection);
let approval_timeout = approval_decision_timeout();
match tokio::time::timeout(approval_timeout, rx).await {
let approval_timeout = self.approval_decision_timeout();
let decision = match approval_timeout {
Some(wait) => tokio::time::timeout(wait, rx).await,
None => Ok(rx.await),
};
match decision {
Ok(Ok(ExternalApprovalDecision::Allow { remember })) => {
if remember {
self.remember_thread_auto_approve(&thread_id).await;
@@ -9738,7 +9862,7 @@ impl RuntimeThreadManager {
"approval.timeout",
json!({
"approval_id": id,
"timeout_secs": approval_timeout.as_secs(),
"timeout_secs": approval_timeout.map(|wait| wait.as_secs()),
}),
)
.await
@@ -9962,6 +10086,30 @@ impl RuntimeThreadManager {
break;
}
EngineEvent::GoalUpdated { snapshot } => {
snapshot
.validate_stall_state()
.map_err(anyhow::Error::msg)?;
if let Some(goal_id) = admitted_goal_id.as_deref() {
// Persist an acknowledged review before awaiting another
// event, so restart midway through a turn retains it.
self.store
.update_goal_if_revision(&thread_id, goal_id, |goal| {
merge_engine_goal_progress(goal, &snapshot);
})?;
} else if snapshot.is_active() {
// The turn was admitted with no host goal, but the
// model created one through create_goal. Persist it
// only when the store still has none — a concurrent
// PUT/DELETE is the newer revision and wins — and
// adopt the revision only when we actually created it,
// so settlement and later checkpoints stay fenced.
if let Some(goal_id) = self
.store
.create_goal_from_snapshot_if_absent(&thread_id, &snapshot)?
{
admitted_goal_id = Some(goal_id);
}
}
latest_goal_snapshot = Some(snapshot);
}
_ => {}
@@ -10135,6 +10283,7 @@ impl RuntimeThreadManager {
&thread_id,
&turn,
latest_goal_snapshot,
admitted_goal_id.as_deref(),
turn_tool_catalog.as_deref(),
)
.await;
+263 -7
View File
@@ -4953,6 +4953,7 @@ fn enforce_lru_capacity_does_not_loop_when_all_threads_are_active() {
ActiveThreadState {
engine: harness_a.handle,
active_turn: Some(ActiveTurnState {
goal_id: None,
turn_id: "turn_a".to_string(),
interrupt_requested: false,
compaction_id: None,
@@ -4972,6 +4973,7 @@ fn enforce_lru_capacity_does_not_loop_when_all_threads_are_active() {
ActiveThreadState {
engine: harness_b.handle,
active_turn: Some(ActiveTurnState {
goal_id: None,
turn_id: "turn_b".to_string(),
interrupt_requested: false,
compaction_id: None,
@@ -5860,6 +5862,7 @@ async fn update_thread_workspace_rejects_active_turn() -> Result<()> {
let mut active = manager.active.lock().await;
let state = active.engines.get_mut(&thread.id).expect("mock engine");
state.active_turn = Some(ActiveTurnState {
goal_id: None,
turn_id: "turn_live".to_string(),
interrupt_requested: false,
compaction_id: None,
@@ -6566,6 +6569,10 @@ fn active_test_goal(thread_id: &str, goal_id: &str) -> codewhale_protocol::Threa
tokens_used: 0,
time_used_seconds: 0,
continuation_count: 0,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
pause_reason: None,
created_at: 0,
updated_at: 0,
}
@@ -6631,6 +6638,7 @@ async fn host_goal_loop_kickoff_arms_one_continuation_and_parks_at_engine_cap()
goal: Some(crate::config::GoalConfig {
max_continuations: Some(2),
continuation_delay_seconds: None,
max_steps: None,
}),
..Config::default()
};
@@ -6978,13 +6986,26 @@ async fn host_goal_loop_mirrors_terminal_snapshot_and_does_not_rearm() -> Result
}
});
manager.activate_thread_goal(&thread.id).await?;
let goal = wait_for_goal_status(
&manager,
&thread.id,
expected,
TURN_SETTLEMENT_DEADLOCK_TIMEOUT,
)
.await?;
// The mid-turn checkpoint publishes the terminal status before
// settlement accrues turn usage, so wait for both the terminal turn
// and the settled counters rather than asserting on the first
// status sighting.
wait_for_terminal_turn_count(&manager, &thread.id, 1, TURN_SETTLEMENT_DEADLOCK_TIMEOUT)
.await?;
let deadline = Instant::now() + TURN_SETTLEMENT_DEADLOCK_TIMEOUT;
let goal = loop {
let goal = manager
.store
.load_goal(&thread.id)?
.ok_or_else(|| anyhow::anyhow!("goal record missing for {}", thread.id))?;
if goal.status == expected && goal.tokens_used == 10 {
break goal;
}
if Instant::now() > deadline {
bail!("goal settlement did not land in time: {goal:?}");
}
sleep(Duration::from_millis(20)).await;
};
// Usage and the engine's continuation counter landed on the record.
assert_eq!(goal.tokens_used, 10);
assert_eq!(goal.continuation_count, 1);
@@ -7004,6 +7025,241 @@ async fn host_goal_loop_mirrors_terminal_snapshot_and_does_not_rearm() -> Result
Ok(())
}
#[tokio::test]
async fn model_created_goal_persists_through_adopted_revision() -> Result<()> {
// A turn admitted with no host goal can still create one through the
// model's create_goal tool. The monitor must persist that creation and
// adopt its revision, so settlement accrues usage and the next admission
// restores the goal instead of clearing it.
let manager = RuntimeThreadManager::open(
Config {
goal: Some(crate::config::GoalConfig {
max_continuations: None,
continuation_delay_seconds: Some(3600),
max_steps: None,
}),
..Config::default()
},
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest::default())
.await?;
let harness = install_mock_engine(&manager, &thread.id).await;
let mut rx_op = harness.rx_op;
let tx_event = harness.tx_event;
tokio::spawn(async move {
while let Some(op) = rx_op.recv().await {
if !matches!(op, Op::SendMessage { .. }) {
continue;
}
let snapshot = crate::tools::goal::GoalSnapshot {
goal_id: Some("model_created_goal".to_string()),
objective: Some("model-built objective".to_string()),
status: "active".to_string(),
..Default::default()
};
let _ = tx_event.send(EngineEvent::GoalUpdated { snapshot }).await;
let _ = tx_event
.send(EngineEvent::TurnStarted {
turn_id: "engine_model_created".to_string(),
created_at: chrono::Utc::now(),
route: None,
})
.await;
let _ = tx_event
.send(EngineEvent::TurnComplete {
usage: Usage {
input_tokens: 10,
output_tokens: 10,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: Some(vec![catalog_tool("update_goal")]),
base_url: None,
})
.await;
}
});
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "build me a goal".to_string(),
..StartTurnRequest::default()
},
)
.await?;
wait_for_terminal_turn(&manager, &turn.id, TURN_SETTLEMENT_DEADLOCK_TIMEOUT).await?;
let deadline = Instant::now() + TURN_SETTLEMENT_DEADLOCK_TIMEOUT;
let goal = loop {
match manager.store.load_goal(&thread.id)? {
Some(goal) if goal.tokens_used == 20 => break goal,
_ if Instant::now() > deadline => {
bail!("model-created goal was not persisted and settled")
}
_ => sleep(Duration::from_millis(20)).await,
}
};
assert_eq!(goal.goal_id, "model_created_goal");
assert_eq!(goal.objective, "model-built objective");
assert_eq!(goal.status, codewhale_protocol::ThreadGoalStatus::Active);
Ok(())
}
#[tokio::test]
async fn model_created_goal_never_overwrites_concurrent_explicit_goal() -> Result<()> {
// The turn was admitted with no goal; an explicit PUT lands while it runs.
// The model's create_goal snapshot must not clobber that newer revision,
// and terminal settlement must stay fenced off it.
let manager = test_manager(test_runtime_dir())?;
let thread = manager
.create_thread(CreateThreadRequest::default())
.await?;
let harness = install_mock_engine(&manager, &thread.id).await;
let mut rx_op = harness.rx_op;
let tx_event = harness.tx_event;
let store = manager.store.clone();
let thread_id = thread.id.clone();
tokio::spawn(async move {
while let Some(op) = rx_op.recv().await {
if !matches!(op, Op::SendMessage { .. }) {
continue;
}
// The explicit revision arrives after admission but before the
// model's create_goal receipt.
let _ = store.save_goal(&active_test_goal(&thread_id, "goal_explicit"));
let snapshot = crate::tools::goal::GoalSnapshot {
goal_id: Some("model_created_goal".to_string()),
objective: Some("model-built objective".to_string()),
status: "active".to_string(),
..Default::default()
};
let _ = tx_event.send(EngineEvent::GoalUpdated { snapshot }).await;
let _ = tx_event
.send(EngineEvent::TurnStarted {
turn_id: "engine_concurrent".to_string(),
created_at: chrono::Utc::now(),
route: None,
})
.await;
let _ = tx_event
.send(EngineEvent::TurnComplete {
usage: Usage {
input_tokens: 10,
output_tokens: 10,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: Some(vec![catalog_tool("update_goal")]),
base_url: None,
})
.await;
}
});
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "build me a goal".to_string(),
..StartTurnRequest::default()
},
)
.await?;
wait_for_terminal_turn(&manager, &turn.id, TURN_SETTLEMENT_DEADLOCK_TIMEOUT).await?;
sleep(Duration::from_millis(200)).await;
let goal = manager
.store
.load_goal(&thread.id)?
.ok_or_else(|| anyhow::anyhow!("explicit goal vanished"))?;
assert_eq!(goal.goal_id, "goal_explicit");
assert_eq!(goal.objective, "ship the goal loop");
assert_eq!(
goal.tokens_used, 0,
"settlement must not accrue the no-goal turn onto the explicit revision"
);
Ok(())
}
#[tokio::test]
async fn transition_goal_status_commits_only_the_read_revision() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
let thread = manager
.create_thread(CreateThreadRequest::default())
.await?;
manager
.store
.save_goal(&active_test_goal(&thread.id, "goal_a"))?;
let completed = manager
.transition_goal_status(
&thread.id,
"goal_a",
codewhale_protocol::ThreadGoalStatus::Complete,
)
.await?
.ok_or_else(|| anyhow::anyhow!("current revision must commit"))?;
assert_eq!(
completed.status,
codewhale_protocol::ThreadGoalStatus::Complete
);
// A concurrent replacement makes the previously read revision stale.
manager
.store
.save_goal(&active_test_goal(&thread.id, "goal_b"))?;
let stale = manager
.transition_goal_status(
&thread.id,
"goal_a",
codewhale_protocol::ThreadGoalStatus::Blocked,
)
.await?;
assert!(stale.is_none(), "stale revision must not commit");
let goal = manager
.store
.load_goal(&thread.id)?
.ok_or_else(|| anyhow::anyhow!("goal record missing"))?;
assert_eq!(goal.goal_id, "goal_b");
assert_eq!(goal.status, codewhale_protocol::ThreadGoalStatus::Active);
Ok(())
}
#[test]
fn corrupt_active_exhausted_stall_window_loads_paused() -> Result<()> {
// The engine pauses NoProgress in the same locked mutation that fills the
// stall window, so a persisted Active record at the ceiling is corrupt;
// loads must restore it paused instead of re-arming spent passes.
let manager = test_manager(test_runtime_dir())?;
let thread_id = "thread_corrupt_stall";
let mut goal = active_test_goal(thread_id, "goal_corrupt");
goal.repeated_gap_count = codewhale_protocol::MAX_REPEATED_GAP_COUNT;
goal.last_gap_fingerprint = Some("a".repeat(64));
goal.last_gap_pass = Some(3);
goal.continuation_count = 3;
manager.store.save_goal(&goal)?;
let loaded = manager
.store
.load_goal(thread_id)?
.ok_or_else(|| anyhow::anyhow!("goal record missing"))?;
assert_eq!(loaded.status, codewhale_protocol::ThreadGoalStatus::Paused);
assert_eq!(
loaded.pause_reason,
Some(codewhale_protocol::GoalPauseReason::NoProgress)
);
// The stall history itself is preserved for inspection; only the
// impossible Active projection is healed.
assert_eq!(
loaded.repeated_gap_count,
codewhale_protocol::MAX_REPEATED_GAP_COUNT
);
Ok(())
}
#[tokio::test]
async fn get_thread_detail_batches_items_by_turn_without_losing_order() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
+155 -94
View File
@@ -34,7 +34,7 @@ const MAX_SESSIONS: usize = 50;
pub const MAX_SESSION_TITLE_CHARS: usize = 100;
const WORK_GRAPH_IMPORT_ARCHIVE_DIR: &str = ".work-graph-import-archive";
const SESSION_GOALS_DIR: &str = ".goals";
const CURRENT_SESSION_GOAL_SCHEMA_VERSION: u32 = 1;
const CURRENT_SESSION_GOAL_SCHEMA_VERSION: u32 = 2;
const MAX_SESSION_GOAL_OBJECTIVE_CHARS: usize = 8_192;
const MAX_SESSION_GOAL_FILE_BYTES: u64 = 64 * 1_024;
const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
@@ -605,6 +605,14 @@ pub struct SessionGoalState {
pub elapsed_seconds: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pause_reason: Option<GoalPauseReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_gap_fingerprint: Option<String>,
#[serde(default)]
pub repeated_gap_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_gap_pass: Option<u32>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -657,6 +665,10 @@ impl SessionGoalState {
continuation_count: snapshot.continuation_count,
elapsed_seconds: snapshot.elapsed_seconds.unwrap_or_default(),
pause_reason: snapshot.pause_reason,
goal_id: snapshot.goal_id.clone(),
last_gap_fingerprint: snapshot.last_gap_fingerprint.clone(),
repeated_gap_count: snapshot.repeated_gap_count,
last_gap_pass: snapshot.last_gap_pass,
};
state.validate()?;
Ok(Some(state))
@@ -681,12 +693,29 @@ impl SessionGoalState {
),
));
}
Ok(())
if self
.goal_id
.as_ref()
.is_some_and(|id| id.is_empty() || id.len() > 128)
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid session goal revision",
));
}
codewhale_protocol::validate_goal_stall_state(
self.last_gap_fingerprint.as_deref(),
self.repeated_gap_count,
self.last_gap_pass,
self.continuation_count,
)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
#[must_use]
pub fn to_runtime_snapshot(&self) -> GoalSnapshot {
GoalSnapshot {
goal_id: self.goal_id.clone(),
objective: Some(self.objective.clone()),
status: match self.status {
SessionGoalStatus::Active => "active",
@@ -705,8 +734,10 @@ impl SessionGoalState {
pause_reason: self.pause_reason,
completion_verification: None,
advisories: Vec::new(),
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_fingerprint: self.last_gap_fingerprint.clone(),
repeated_gap_count: self.repeated_gap_count,
last_gap_pass: self.last_gap_pass,
progress: None,
}
}
}
@@ -935,11 +966,21 @@ fn serialize_saved_session(session: &SavedSession) -> io::Result<String> {
pub struct SessionManager {
/// Directory where sessions are stored
sessions_dir: PathBuf,
/// Which session this manager last parked or restored an offline queue
/// for. `clear_offline_queue_state` has no session argument (the
/// persistence actor's clear request carries none), so this is what a
/// bare clear resolves to — never another instance's session.
queue_owner: std::sync::Mutex<Option<String>>,
}
/// One interactive editor owns a session's unsent text until its last queued
/// write finishes. The stable lock file is never unlinked: replacing it would
/// let two processes lock different files for the same session.
#[derive(Debug)]
pub struct OfflineQueueLease {
session_id: String,
_file: fs::File,
}
impl OfflineQueueLease {
pub fn session_id(&self) -> &str {
&self.session_id
}
}
/// Origin of a crash-recovery checkpoint file.
@@ -967,6 +1008,10 @@ const OFFLINE_QUEUE_FILE: &str = "offline_queue.json";
/// Per-session offline queue file: `checkpoints/<session_id>.offline_queue.json`.
const OFFLINE_QUEUE_SUFFIX: &str = ".offline_queue.json";
pub(crate) fn is_offline_queue_file(name: &str) -> bool {
name == OFFLINE_QUEUE_FILE || name.ends_with(OFFLINE_QUEUE_SUFFIX)
}
impl SessionManager {
fn approval_receipt_store(&self) -> ApprovalReceiptStore {
ApprovalReceiptStore::new(self.sessions_dir.clone())
@@ -1104,10 +1149,7 @@ impl SessionManager {
let sessions_dir = normalize_managed_dir(sessions_dir)?;
// Ensure the sessions directory exists
fs::create_dir_all(&sessions_dir)?;
Ok(Self {
sessions_dir,
queue_owner: std::sync::Mutex::new(None),
})
Ok(Self { sessions_dir })
}
/// Create a `SessionManager` using the default location.
@@ -1413,7 +1455,7 @@ impl SessionManager {
};
let source = if name == LEGACY_CHECKPOINT_FILE {
CheckpointSource::Legacy
} else if name == OFFLINE_QUEUE_FILE || name.ends_with(OFFLINE_QUEUE_SUFFIX) {
} else if is_offline_queue_file(name) {
// Parked offline queues live in this directory but are not
// crash-recovery checkpoints.
continue;
@@ -1454,6 +1496,41 @@ impl SessionManager {
Ok(true)
}
/// Acquire before loading or editing a queue, including on in-process
/// resume. A per-write lock is insufficient: the second editor's stale
/// snapshot would overwrite the first as soon as its write completed.
pub fn acquire_offline_queue_lease(
&self,
session_id: &str,
) -> io::Result<std::sync::Arc<OfflineQueueLease>> {
let session_id = self.validated_session_id(session_id)?.to_string();
let directory = self.checkpoints_dir();
fs::create_dir_all(&directory)?;
let path = directory.join(format!("{session_id}.offline_queue.lock"));
let file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)?;
let mut lock = fd_lock::RwLock::new(file);
let guard = lock.try_write().map_err(|error| {
io::Error::new(
error.kind(),
format!("Cannot open session {session_id}: its queued input is already open in another window, or its previous writes are still finishing ({error})"),
)
})?;
// fd-lock's guard borrows its owner. Retain the underlying descriptor
// instead so this lease can travel with asynchronous writes. Forgetting
// this non-owning guard keeps the OS lock held; closing the final Arc's
// file releases it on both Unix and Windows, including process crashes.
std::mem::forget(guard);
Ok(std::sync::Arc::new(OfflineQueueLease {
session_id,
_file: lock.into_inner(),
}))
}
/// Park this session's offline queue (queued + draft messages).
///
/// Queues are keyed per session (`checkpoints/<session_id>.offline_queue.json`)
@@ -1483,7 +1560,6 @@ impl SessionManager {
let content = serde_json::to_string_pretty(&owned)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
write_atomic(&path, content.as_bytes())?;
self.remember_queue_owner(session_id);
Ok(path)
}
@@ -1493,27 +1569,10 @@ impl SessionManager {
session_id: &str,
) -> std::io::Result<Option<OfflineQueueState>> {
let path = self.validated_offline_queue_path(session_id)?;
let state = match Self::read_offline_queue_file(&path)? {
Ok(match Self::read_offline_queue_file(&path)? {
Some(state) => Some(state),
None => self.adopt_legacy_offline_queue(session_id, &path)?,
};
if state.is_some() {
self.remember_queue_owner(session_id);
}
Ok(state)
}
/// Remove the parked offline queue for the session this manager last
/// parked or restored one for.
///
/// The persistence actor's clear request carries no session id, so the
/// owner is whichever session this manager instance last wrote a queue
/// for. It can therefore never reach another session's parked text.
pub fn clear_offline_queue_state(&self) -> std::io::Result<()> {
let Some(session_id) = self.queue_owner() else {
return Ok(());
};
self.clear_offline_queue_state_for(&session_id)
})
}
/// Remove one named session's parked offline queue.
@@ -1524,10 +1583,6 @@ impl SessionManager {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let mut owner = self.lock_queue_owner();
if owner.as_deref() == Some(session_id.trim()) {
*owner = None;
}
Ok(())
}
@@ -1595,20 +1650,6 @@ impl SessionManager {
Ok(Some(state))
}
fn lock_queue_owner(&self) -> std::sync::MutexGuard<'_, Option<String>> {
self.queue_owner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn remember_queue_owner(&self, session_id: &str) {
*self.lock_queue_owner() = Some(session_id.trim().to_string());
}
fn queue_owner(&self) -> Option<String> {
self.lock_queue_owner().clone()
}
/// Read a session snapshot without repairing tool call/result pairs.
///
/// This is the correct API for embedding hosts that inspect or update a
@@ -2694,6 +2735,7 @@ mod tests {
let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
let session_id = "11111111-2222-4333-8444-555555555555";
let runtime = GoalSnapshot {
goal_id: None,
objective: Some("finish the provider migration".to_string()),
status: "paused".to_string(),
token_budget: Some(50_000),
@@ -2708,6 +2750,8 @@ mod tests {
advisories: Vec::new(),
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
progress: None,
};
let durable = SessionGoalState::from_runtime(&runtime)
.expect("valid runtime goal")
@@ -4453,7 +4497,7 @@ mod tests {
assert!(loaded.draft.is_some());
manager
.clear_offline_queue_state()
.clear_offline_queue_state_for("test-session")
.expect("clear queue state");
assert!(
manager
@@ -4535,47 +4579,6 @@ mod tests {
);
}
#[test]
fn bare_clear_only_reaches_this_managers_own_queue() {
// The persistence actor's clear request carries no session id, so a
// bare clear resolves to whichever session this manager last parked
// or restored a queue for.
let tmp = tempdir().expect("tempdir");
let sessions_dir = tmp.path().join("sessions");
let mine = SessionManager::new(sessions_dir.clone()).expect("new");
let theirs = SessionManager::new(sessions_dir).expect("new");
theirs
.save_offline_queue_state(&parked("their text"), Some("session-B"))
.expect("park B");
mine.save_offline_queue_state(&parked("my text"), Some("session-A"))
.expect("park A");
mine.clear_offline_queue_state().expect("clear mine");
assert!(
mine.load_offline_queue_state("session-A")
.expect("load A")
.is_none()
);
assert!(
theirs
.load_offline_queue_state("session-B")
.expect("load B")
.is_some(),
"a bare clear must not reach another instance's parked text"
);
// Nothing parked through this manager: a bare clear is a no-op.
let bystander = SessionManager::new(tmp.path().join("sessions")).expect("new");
bystander.clear_offline_queue_state().expect("no-op clear");
assert!(
theirs
.load_offline_queue_state("session-B")
.expect("load B")
.is_some()
);
}
#[test]
fn legacy_global_queue_is_adopted_only_by_its_own_session() {
let tmp = tempdir().expect("tempdir");
@@ -5033,4 +5036,62 @@ mod tests {
);
assert!(legacy.exists(), "unreadable legacy queue is left in place");
}
#[test]
fn offline_queue_lease_excludes_another_process_and_releases() {
const PROBE: &str = "CODEWHALE_QUEUE_LEASE_PROBE_DIR";
const HELD: &str = "CODEWHALE_QUEUE_LEASE_PROBE_HELD";
if let Some(directory) = std::env::var_os(PROBE) {
let manager = SessionManager::new(PathBuf::from(directory)).expect("child store");
let result = manager.acquire_offline_queue_lease("shared-session");
if std::env::var(HELD).as_deref() == Ok("1") {
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::WouldBlock);
} else {
assert!(result.is_ok(), "closed owner must release its kernel lock");
}
return;
}
let directory = tempfile::tempdir().expect("queue fixture");
let sessions = directory.path().join("sessions");
let manager = SessionManager::new(sessions.clone()).expect("parent store");
let lease = manager
.acquire_offline_queue_lease("shared-session")
.expect("first editor");
let probe = |held: bool| {
let output = std::process::Command::new(
std::env::current_exe().expect("test executable"),
)
.args([
"--exact",
"session_manager::tests::offline_queue_lease_excludes_another_process_and_releases",
"--nocapture",
"--test-threads=1",
])
.env(PROBE, &sessions)
.env(HELD, if held { "1" } else { "0" })
.output()
.expect("second editor process");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
};
probe(true);
let _different_session = manager
.acquire_offline_queue_lease("different-session")
.expect("unrelated queue is available");
drop(lease);
probe(false);
for invalid in ["", "../session", "nested/session"] {
assert_eq!(
manager
.acquire_offline_queue_lease(invalid)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
}
}
+362 -51
View File
@@ -38,28 +38,10 @@ pub fn new_shared_goal_state_from_host_status(
Arc::new(Mutex::new(state))
}
/// Create shared state restored from a persisted goal record, keeping the
/// accumulated usage and continuation counters. See
/// [`GoalState::from_persisted`].
/// Restore the complete durable history; loading is not an explicit resume.
#[must_use]
pub fn new_shared_goal_state_from_persisted(
objective: &str,
token_budget: Option<u32>,
status: GoalStatus,
pause_reason: Option<GoalPauseReason>,
tokens_used: u64,
time_used_seconds: u64,
continuation_count: u32,
) -> SharedGoalState {
Arc::new(Mutex::new(GoalState::from_persisted(
objective,
token_budget,
status,
pause_reason,
tokens_used,
time_used_seconds,
continuation_count,
)))
pub fn new_shared_goal_state_from_snapshot(snapshot: &GoalSnapshot) -> SharedGoalState {
Arc::new(Mutex::new(GoalState::from_snapshot(snapshot)))
}
/// A goal declaration stated in ordinary user prose rather than as a leading
@@ -291,16 +273,7 @@ impl GoalStatus {
}
}
/// Why an otherwise unfinished goal is paused.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GoalPauseReason {
User,
Backoff,
NoProgress,
UsageLimit,
BudgetLimit,
}
pub use codewhale_protocol::GoalPauseReason;
/// Whether a goal review is allowed to decide the judged contract.
///
@@ -322,23 +295,25 @@ pub struct GoalAdvisoryNote {
pub summary: String,
}
impl GoalPauseReason {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::User => "user",
Self::Backoff => "run limit",
Self::NoProgress => "no progress",
Self::UsageLimit => "usage limit",
Self::BudgetLimit => "budget limit",
}
}
/// The model's own reported progress for the active goal: a coarse percent
/// plus what is happening now and what comes next. Runtime-only — the durable
/// record deliberately keeps no volatile progress projection. The percent is
/// the model's estimate, rendered as reported progress, never as a verified
/// fraction of the work.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GoalProgressReport {
pub percent: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub now: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next: Option<String>,
}
/// Session-local goal state. `Instant` stays runtime-only; snapshots expose
/// elapsed seconds so tool output remains serializable and stable.
#[derive(Debug, Clone, Default)]
pub struct GoalState {
goal_id: Option<String>,
objective: Option<String>,
token_budget: Option<u32>,
status: Option<GoalStatus>,
@@ -359,6 +334,8 @@ pub struct GoalState {
/// reporting the same gap several times inside one turn must not trip it
/// before any continuation has happened.
last_gap_pass: Option<u32>,
/// Latest reported progress, kept out of the stall accounting entirely.
progress: Option<GoalProgressReport>,
}
impl GoalState {
@@ -394,6 +371,7 @@ impl GoalState {
.status
.is_some_and(|previous| previous != GoalStatus::Active);
if changed {
self.goal_id = Some(uuid::Uuid::new_v4().to_string());
self.objective = Some(objective.to_string());
self.token_budget = token_budget;
self.tokens_used = 0;
@@ -408,11 +386,13 @@ impl GoalState {
self.last_gap_fingerprint = None;
self.repeated_gap_count = 0;
self.last_gap_pass = None;
self.progress = None;
} else if self.token_budget != token_budget {
self.token_budget = token_budget;
}
if resumed {
self.goal_id = Some(uuid::Uuid::new_v4().to_string());
self.evidence = None;
self.blocker = None;
self.pause_reason = None;
@@ -420,6 +400,7 @@ impl GoalState {
self.last_gap_fingerprint = None;
self.repeated_gap_count = 0;
self.last_gap_pass = None;
self.progress = None;
}
if changed || status_changed || self.status.is_none() {
@@ -450,6 +431,7 @@ impl GoalState {
"An unfinished goal already exists. Complete or clear it before creating another.",
);
}
self.goal_id = Some(uuid::Uuid::new_v4().to_string());
self.objective = Some(objective);
self.token_budget = token_budget;
self.status = Some(GoalStatus::Active);
@@ -466,6 +448,7 @@ impl GoalState {
self.last_gap_fingerprint = None;
self.repeated_gap_count = 0;
self.last_gap_pass = None;
self.progress = None;
Ok(())
}
@@ -479,12 +462,6 @@ impl GoalState {
/// blockers, and review notes are runtime-only and start empty; the
/// durable loop re-derives them on the next pass.
///
/// The stall fingerprint is deliberately runtime-only too: a stall does not
/// need to be replayed, because
/// [`crate::goal_loop::MAX_REPEATED_GAP_PASSES`] already converted it into a
/// pause, and a paused goal is what gets persisted and restored here. A
/// resume therefore starts a fresh stall window — which is correct, since a
/// resume is someone deciding the goal is worth continuing.
#[must_use]
pub fn from_persisted(
objective: &str,
@@ -496,6 +473,7 @@ impl GoalState {
continuation_count: u32,
) -> Self {
Self {
goal_id: None,
objective: Some(objective.to_string()),
token_budget,
status: Some(status),
@@ -512,6 +490,87 @@ impl GoalState {
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
progress: None,
}
}
/// Keep the pre-pause review window on ordinary load. Invalid in-memory
/// input is held paused; durable stores reject it before this constructor.
#[must_use]
pub fn from_snapshot(snapshot: &GoalSnapshot) -> Self {
let Some(objective) = snapshot.objective.as_deref() else {
return Self::default();
};
let status = match snapshot.status.as_str() {
"active" => GoalStatus::Active,
"complete" => GoalStatus::Complete,
"blocked" => GoalStatus::Blocked,
_ => GoalStatus::Paused,
};
let mut state = Self::from_persisted(
objective,
snapshot.token_budget,
status,
snapshot.pause_reason,
snapshot.tokens_used,
snapshot.time_used_seconds,
snapshot.continuation_count,
);
state.goal_id.clone_from(&snapshot.goal_id);
state
.last_gap_fingerprint
.clone_from(&snapshot.last_gap_fingerprint);
state.repeated_gap_count = snapshot.repeated_gap_count;
state.last_gap_pass = snapshot.last_gap_pass;
state.progress = snapshot.progress.clone();
let now = Instant::now();
state.started_at = now
.checked_sub(std::time::Duration::from_secs(
snapshot
.elapsed_seconds
.unwrap_or(snapshot.time_used_seconds),
))
.or(Some(now));
let stall_window_exhausted = state.status == Some(GoalStatus::Active)
&& state.repeated_gap_count >= crate::goal_loop::MAX_REPEATED_GAP_PASSES;
if let Err(error) = snapshot.validate_stall_state() {
tracing::warn!("holding invalid restored goal paused: {error}");
state.status = Some(GoalStatus::Paused);
state.pause_reason = Some(GoalPauseReason::NoProgress);
state.finished_at = Some(now);
} else if stall_window_exhausted {
// The engine pauses NoProgress in the same mutation that fills
// the stall window, so a restored Active goal at the ceiling is
// corrupt; hold it paused rather than re-arming spent passes.
tracing::warn!("holding exhausted-stall-window restored goal paused");
state.status = Some(GoalStatus::Paused);
state.pause_reason = Some(GoalPauseReason::NoProgress);
state.finished_at = Some(now);
}
state
}
/// An accepted user resume is a new control revision, even when already
/// active. Cached loads never call this path.
pub fn resume(&mut self, goal_id: Option<String>) {
let objective = self.objective.clone();
self.sync_from_host_status(objective.as_deref(), self.token_budget, GoalStatus::Active);
if self.objective.is_some() {
self.goal_id = Some(goal_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()));
self.last_gap_fingerprint = None;
self.repeated_gap_count = 0;
self.last_gap_pass = None;
self.progress = None;
}
}
/// A new explicit declaration replaces the old revision, including when
/// the user repeats the same objective text.
pub fn replace(&mut self, objective: &str, token_budget: Option<u32>, goal_id: Option<String>) {
self.clear();
self.sync_from_host_status(Some(objective), token_budget, GoalStatus::Active);
if let Some(goal_id) = goal_id {
self.goal_id = Some(goal_id);
}
}
@@ -555,6 +614,14 @@ impl GoalState {
Ok(())
}
/// Replace the reported progress projection. This never touches the
/// stall window or lifecycle state; it is display context only.
pub fn record_progress(&mut self, progress: GoalProgressReport) {
if self.is_active() {
self.progress = Some(progress);
}
}
pub fn record_advisory(&mut self, summary: String) -> Result<(), &'static str> {
if !self.is_active() {
return Err("Advisory notes require an active goal.");
@@ -659,6 +726,7 @@ impl GoalState {
(None, _) => None,
};
GoalSnapshot {
goal_id: self.goal_id.clone(),
objective: self.objective.clone(),
status: self
.status
@@ -677,6 +745,8 @@ impl GoalState {
advisories: self.advisories.clone(),
last_gap_fingerprint: self.last_gap_fingerprint.clone(),
repeated_gap_count: self.repeated_gap_count,
last_gap_pass: self.last_gap_pass,
progress: self.progress.clone(),
}
}
}
@@ -684,6 +754,7 @@ impl GoalState {
/// Serializable tool output and prompt input for the current goal.
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct GoalSnapshot {
pub goal_id: Option<String>,
pub objective: Option<String>,
pub status: String,
pub token_budget: Option<u32>,
@@ -698,6 +769,11 @@ pub struct GoalSnapshot {
pub advisories: Vec<GoalAdvisoryNote>,
pub last_gap_fingerprint: Option<String>,
pub repeated_gap_count: u32,
pub last_gap_pass: Option<u32>,
/// Latest reported progress. Skipped when absent so tool output and the
/// continuation prompt stay stable for goals that never report one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub progress: Option<GoalProgressReport>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -781,10 +857,20 @@ impl GoalSnapshot {
self.objective.is_some() && self.status == GoalStatus::Active.as_str()
}
pub fn validate_stall_state(&self) -> Result<(), &'static str> {
codewhale_protocol::validate_goal_stall_state(
self.last_gap_fingerprint.as_deref(),
self.repeated_gap_count,
self.last_gap_pass,
self.continuation_count,
)
}
#[must_use]
pub fn from_thread_goal(goal: &codewhale_protocol::ThreadGoal) -> Self {
let (status, pause_reason) = thread_goal_status_projection(goal.status.clone());
Self {
goal_id: Some(goal.goal_id.clone()),
objective: Some(goal.objective.clone()),
status: status.as_str().to_string(),
token_budget: goal
@@ -796,11 +882,13 @@ impl GoalSnapshot {
elapsed_seconds: None,
evidence: None,
blocker: None,
pause_reason,
pause_reason: goal.pause_reason.or(pause_reason),
completion_verification: None,
advisories: Vec::new(),
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_fingerprint: goal.last_gap_fingerprint.clone(),
repeated_gap_count: goal.repeated_gap_count,
last_gap_pass: goal.last_gap_pass,
progress: None,
}
}
}
@@ -840,6 +928,19 @@ pub fn render_continuation_prompt(snapshot: &GoalSnapshot, continuation_index: u
)
}
/// Render the reported-progress bar used by the transcript receipt and the
/// metrics line: eight cells, filled in proportion to the percent. The bar
/// visualizes a model-reported estimate; it is not a verified fraction.
#[must_use]
pub fn goal_progress_bar(percent: u8) -> String {
const CELLS: usize = 8;
let filled = (usize::from(percent.min(100)) * CELLS + 50) / 100;
let mut bar = String::with_capacity(CELLS * 3);
bar.push_str(&"▓".repeat(filled));
bar.push_str(&"░".repeat(CELLS - filled));
bar
}
fn lock_goal_state(
state: &SharedGoalState,
) -> Result<std::sync::MutexGuard<'_, GoalState>, ToolError> {
@@ -921,6 +1022,36 @@ fn parse_progress_verification(input: &Value) -> Result<GoalProgressVerification
Ok(verification)
}
fn parse_progress_report(input: &Value) -> Result<Option<GoalProgressReport>, ToolError> {
let Some(raw) = input.get("progress") else {
return Ok(None);
};
if raw.is_null() {
return Ok(None);
}
let percent = raw.get("percent").and_then(Value::as_u64).ok_or_else(|| {
ToolError::invalid_input("progress.percent must be an integer from 0 to 100")
})?;
let percent = u8::try_from(percent)
.ok()
.filter(|percent| *percent <= 100)
.ok_or_else(|| {
ToolError::invalid_input("progress.percent must be an integer from 0 to 100")
})?;
let note = |key: &str| -> Option<String> {
raw.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(160).collect())
};
Ok(Some(GoalProgressReport {
percent,
now: note("now"),
next: note("next"),
}))
}
fn json_result(snapshot: &GoalSnapshot) -> Result<ToolResult, ToolError> {
ToolResult::json(snapshot).map_err(|err| ToolError::execution_failed(err.to_string()))
}
@@ -1126,6 +1257,28 @@ impl ToolSpec for UpdateGoalTool {
"advisory": {
"type": "string",
"description": "Required when status is advisory. Appended separately from the judged completion contract."
},
"progress": {
"type": "object",
"description": "Optional with not_achieved or advisory: your current best estimate of overall completion, shown to the user as reported progress. Keep percent honest — it is an estimate, never a verified fraction.",
"properties": {
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "Estimated percent complete, 0-100."
},
"now": {
"type": "string",
"description": "One short line: what is being worked on right now."
},
"next": {
"type": "string",
"description": "One short line: what comes next."
}
},
"required": ["percent"],
"additionalProperties": false
}
},
"required": ["status"],
@@ -1157,6 +1310,12 @@ impl ToolSpec for UpdateGoalTool {
));
}
let status = required_str(&input, "status")?.trim().to_ascii_lowercase();
let progress = parse_progress_report(&input)?;
if progress.is_some() && !matches!(status.as_str(), "not_achieved" | "advisory") {
return Err(ToolError::invalid_input(
"progress is only accepted with status not_achieved or advisory",
));
}
let snapshot = {
let mut state = lock_goal_state(&self.goal_state)?;
match status.as_str() {
@@ -1198,6 +1357,9 @@ impl ToolSpec for UpdateGoalTool {
state
.record_not_achieved(verification)
.map_err(ToolError::invalid_input)?;
if let Some(progress) = progress {
state.record_progress(progress);
}
}
"advisory" => {
let advisory = input
@@ -1214,6 +1376,9 @@ impl ToolSpec for UpdateGoalTool {
state
.record_advisory(advisory)
.map_err(ToolError::invalid_input)?;
if let Some(progress) = progress {
state.record_progress(progress);
}
}
other => {
return Err(ToolError::invalid_input(format!(
@@ -1997,6 +2162,10 @@ mod tests {
tokens_used: 750,
time_used_seconds: 44,
continuation_count: 3,
last_gap_fingerprint: None,
repeated_gap_count: 0,
last_gap_pass: None,
pause_reason: None,
created_at: 1,
updated_at: 2,
});
@@ -2069,6 +2238,148 @@ mod tests {
assert!(update.description().contains("requires user input"));
}
#[test]
fn goal_progress_bar_fills_in_proportion() {
assert_eq!(goal_progress_bar(0), "░░░░░░░░");
assert_eq!(goal_progress_bar(50), "▓▓▓▓░░░░");
assert_eq!(goal_progress_bar(100), "▓▓▓▓▓▓▓▓");
assert_eq!(goal_progress_bar(200), "▓▓▓▓▓▓▓▓");
}
#[tokio::test]
async fn update_goal_records_progress_with_not_achieved_and_advisory() {
let state = new_shared_goal_state();
{
let mut guard = state.lock().expect("goal lock");
guard
.create("ship the release".to_string(), None)
.expect("create");
}
let tool = UpdateGoalTool::new(state.clone());
let context = ToolContext::new(".");
let result = tool
.execute(
json!({
"status": "not_achieved",
"verification": {
"status": "not_achieved",
"check": "cargo test",
"summary": "two failures remain",
"gaps": ["picker test", "pricing test"]
},
"progress": {"percent": 40, "now": "fixing the picker", "next": "rerun gates"}
}),
&context,
)
.await
.expect("not_achieved accepted");
let snapshot: Value = serde_json::from_str(&result.content).expect("snapshot json");
let progress = snapshot.get("progress").expect("progress recorded");
assert_eq!(progress.get("percent").and_then(Value::as_u64), Some(40));
assert_eq!(
progress.get("now").and_then(Value::as_str),
Some("fixing the picker")
);
assert_eq!(
progress.get("next").and_then(Value::as_str),
Some("rerun gates")
);
let result = tool
.execute(
json!({
"status": "advisory",
"advisory": "cache eviction is likely",
"progress": {"percent": 55}
}),
&context,
)
.await
.expect("advisory accepted");
let snapshot: Value = serde_json::from_str(&result.content).expect("snapshot json");
assert_eq!(
snapshot
.get("progress")
.and_then(|progress| progress.get("percent"))
.and_then(Value::as_u64),
Some(55)
);
}
#[tokio::test]
async fn update_goal_rejects_progress_on_terminal_status_and_bad_percent() {
let state = new_shared_goal_state();
{
let mut guard = state.lock().expect("goal lock");
guard
.create("ship the release".to_string(), None)
.expect("create");
}
let tool = UpdateGoalTool::new(state.clone());
let context = ToolContext::new(".");
let err = tool
.execute(
json!({
"status": "complete",
"evidence": "all gates pass",
"verification": {"status": "passed", "check": "cargo test", "summary": "ok"},
"progress": {"percent": 100}
}),
&context,
)
.await
.expect_err("progress is not terminal evidence");
assert!(
err.to_string().contains("not_achieved or advisory"),
"{err}"
);
let err = tool
.execute(
json!({
"status": "advisory",
"advisory": "note",
"progress": {"percent": 140}
}),
&context,
)
.await
.expect_err("percent above 100 must fail");
assert!(err.to_string().contains("0 to 100"), "{err}");
}
#[test]
fn from_snapshot_holds_exhausted_stall_window_paused() {
// A restored snapshot that is still Active with a full stall window
// is corrupt: the engine pauses NoProgress in the same mutation that
// reaches the ceiling. The rehydrated state must stay paused instead
// of arming another pass.
let snapshot = GoalSnapshot {
goal_id: Some("goal-stall".to_string()),
objective: Some("finish issue 2199".to_string()),
status: "active".to_string(),
continuation_count: 3,
last_gap_fingerprint: Some("b".repeat(64)),
repeated_gap_count: crate::goal_loop::MAX_REPEATED_GAP_PASSES,
last_gap_pass: Some(3),
..Default::default()
};
snapshot.validate_stall_state().expect("structurally valid");
let state = GoalState::from_snapshot(&snapshot);
assert_eq!(state.status, Some(GoalStatus::Paused));
assert_eq!(state.pause_reason, Some(GoalPauseReason::NoProgress));
assert!(!state.is_active());
// One below the ceiling restores as ordinary Active state.
let below = GoalSnapshot {
repeated_gap_count: crate::goal_loop::MAX_REPEATED_GAP_PASSES - 1,
..snapshot
};
let state = GoalState::from_snapshot(&below);
assert_eq!(state.status, Some(GoalStatus::Active));
assert!(state.is_active());
}
#[test]
fn from_persisted_keeps_counters_that_sync_from_host_status_resets() {
// Rehydration treats the durable record as history: the counters
-2
View File
@@ -77,7 +77,6 @@ impl ActiveCell {
/// Number of entries (each rendered as its own [`HistoryCell`]).
#[must_use]
#[allow(dead_code)] // Public surface used by tests and future renderers.
pub fn entry_count(&self) -> usize {
self.entries.len()
}
@@ -189,7 +188,6 @@ impl ActiveCell {
/// Look up the entry index that holds the given tool id.
#[must_use]
#[allow(dead_code)] // Reserved for the Codex-style "exec end target" lookup.
pub fn entry_index_for_tool(&self, tool_id: &str) -> Option<usize> {
self.tool_to_entry.get(tool_id).copied()
}
+47 -20
View File
@@ -26,7 +26,6 @@ use crate::localization::{Locale, MessageId, resolve_locale, tr};
use crate::models::{Message, SystemPrompt, Tool, Usage};
use crate::palette::{self, UiTheme};
use crate::pricing::{CostCurrency, CostEstimate};
use crate::resource_telemetry::TokenThroughput;
use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState};
use crate::settings::{InlineDiffMode, Settings};
use crate::tools::plan::{PlanState, SharedPlanState, new_shared_plan_state};
@@ -75,6 +74,25 @@ pub(crate) use types::{
// === Types ===
/// One login owns one mailbox. A cancelled task can only write its abandoned
/// mailbox, so a late result cannot complete or clear a later login.
pub(crate) struct PendingMcpLogin {
pub server: String,
pub cancel: tokio_util::sync::CancellationToken,
pub progress: std::sync::Arc<std::sync::Mutex<Option<McpLoginProgress>>>,
}
pub(crate) enum McpLoginProgress {
AuthorizationUrl(String),
Finished(Result<(), String>),
}
impl Drop for PendingMcpLogin {
fn drop(&mut self) {
self.cancel.cancel();
}
}
/// Lifecycle identity retained until the matching `TurnComplete` arrives.
///
/// This survives local cancellation clearing the visible runtime status, so
@@ -1001,6 +1019,9 @@ pub struct HostGoalState {
/// While `None`, elapsed time keeps growing; once set, the sidebar freezes
/// the timer at `finished_at - started_at` so completed goals stop ticking.
pub finished_at: Option<Instant>,
/// Latest progress the model reported for the active goal. Runtime-only
/// display state; never persisted and never treated as verified.
pub progress: Option<crate::tools::goal::GoalProgressReport>,
pub status: crate::tools::goal::GoalStatus,
}
@@ -1033,7 +1054,6 @@ pub struct SessionState {
pub displayed_cost_high_water_cny: f64,
pub last_prompt_tokens: Option<u32>,
pub last_completion_tokens: Option<u32>,
pub last_output_throughput: Option<TokenThroughput>,
pub last_prompt_cache_hit_tokens: Option<u32>,
pub last_prompt_cache_miss_tokens: Option<u32>,
pub last_reasoning_replay_tokens: Option<u32>,
@@ -1218,7 +1238,6 @@ impl Default for SessionState {
displayed_cost_high_water_cny: 0.0,
last_prompt_tokens: None,
last_completion_tokens: None,
last_output_throughput: None,
last_prompt_cache_hit_tokens: None,
last_prompt_cache_miss_tokens: None,
last_reasoning_replay_tokens: None,
@@ -1260,7 +1279,6 @@ impl SessionState {
self.total_cache_write_tokens = 0;
self.total_output_tokens = 0;
self.clear_pending_turn_usage();
self.last_output_throughput = None;
}
/// Add one provider-reported model-call receipt to the display-only
@@ -2009,6 +2027,9 @@ pub struct App {
pub backtrack: crate::tui::backtrack::BacktrackState,
/// Current session ID for auto-save updates
pub current_session_id: Option<String>,
/// Exclusive editor ownership, shared with outstanding queue writes.
pub(crate) offline_queue_lease:
Option<std::sync::Arc<crate::session_manager::OfflineQueueLease>>,
/// Last non-contended Work snapshot captured in this App. The outer
/// option distinguishes "never captured" from a captured empty state.
pub(crate) last_known_work_state: Option<Option<SessionWorkState>>,
@@ -2279,17 +2300,9 @@ pub struct App {
)>,
>,
>,
/// Shared cell for async MCP OAuth login delivery.
///
/// The browser callback wait is up to five minutes. Awaiting it inside the
/// action handler parked the whole event loop: no input, no redraw, and no
/// way to back out of a login started by a misclick. The login runs on the
/// background pattern instead, and `mcp_login_cancel` is what Esc trips.
#[allow(clippy::type_complexity)]
pub mcp_login_cell: std::sync::Arc<std::sync::Mutex<Option<(String, Result<(), String>)>>>,
/// Cancels the in-flight MCP OAuth login, if any, and names the server it
/// belongs to so the footer/notice can say what Esc would abandon.
pub mcp_login_cancel: Option<(String, tokio_util::sync::CancellationToken)>,
/// Discovery, registration and the browser callback all run in the
/// background. Esc or dropping the app cancels the entire operation.
pub(crate) mcp_login: Option<PendingMcpLogin>,
/// Shared cell for async prompt suggestion delivery from background task.
pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<(u64, String)>>>,
/// Tracks whether the initial balance fetch has been attempted for this session.
@@ -2727,6 +2740,7 @@ impl App {
/// complete total.
#[must_use]
pub fn cumulative_usage_chip(&self) -> crate::route_billing::UsageChip {
use crate::pricing::UnpricedReason;
let displayed = self.displayed_session_cost_for_currency(self.cost_currency);
let (priced, unpriced) = match self.cost_display_currency(self.cost_currency) {
CostCurrency::Usd => (
@@ -2738,14 +2752,28 @@ impl App {
self.session.cost_cny_unpriced_turns,
),
};
let saved_reasons = match self.cost_display_currency(self.cost_currency) {
CostCurrency::Usd => &self.session.cost_unpriced_reasons,
CostCurrency::Cny => &self.session.cost_cny_unpriced_reasons,
};
let mut reasons: Vec<_> = saved_reasons
.iter()
.map(|reason| UnpricedReason::from_label(reason))
.collect();
if (self.session.cost_coverage_unknown_legacy || reasons.is_empty())
&& !reasons.contains(&UnpricedReason::UnrecordedCoverage)
{
reasons.push(UnpricedReason::UnrecordedCoverage);
}
if self.session.cost_coverage_unknown_legacy {
return if displayed.is_finite() && displayed > 0.0 {
crate::route_billing::UsageChip::PricedSubtotal {
amount: self.format_cost_amount(displayed),
legacy: true,
reasons,
}
} else {
crate::route_billing::UsageChip::Unknown
crate::route_billing::UsageChip::Unknown(reasons)
};
}
if unpriced > 0 {
@@ -2753,9 +2781,10 @@ impl App {
crate::route_billing::UsageChip::PricedSubtotal {
amount: self.format_cost_amount(displayed),
legacy: false,
reasons,
}
} else {
crate::route_billing::UsageChip::Unknown
crate::route_billing::UsageChip::Unknown(reasons)
};
}
if priced > 0 {
@@ -2821,7 +2850,6 @@ impl App {
pub(crate) fn clear_model_scoped_telemetry(&mut self) {
self.session.last_prompt_tokens = None;
self.session.last_completion_tokens = None;
self.session.last_output_throughput = None;
self.session.last_prompt_cache_hit_tokens = None;
self.session.last_prompt_cache_miss_tokens = None;
self.session.last_reasoning_replay_tokens = None;
@@ -4152,7 +4180,7 @@ impl App {
#[must_use]
pub fn session_cost_label(&self) -> String {
let chip = self.cumulative_usage_chip();
crate::route_billing::format_usage_chip(&chip).unwrap_or_else(|| {
crate::route_billing::format_usage_chip(&chip, self.ui_locale).unwrap_or_else(|| {
self.format_cost_amount(self.displayed_session_cost_for_currency(self.cost_currency))
})
}
@@ -4780,7 +4808,6 @@ impl App {
/// Total number of cells in the *virtual* transcript: `history.len()`
/// plus active cell entries (if any).
#[must_use]
#[allow(dead_code)] // Reserved for renderers that need a unified cell count.
pub fn virtual_cell_count(&self) -> usize {
self.history.len() + self.active_cell.as_ref().map_or(0, ActiveCell::entry_count)
}
+2 -2
View File
@@ -969,6 +969,7 @@ impl App {
pending_user_input_prompt: None,
backtrack: crate::tui::backtrack::BacktrackState::new(),
current_session_id: None,
offline_queue_lease: None,
last_known_work_state: None,
last_known_goal_state: None,
pending_goal_controls: VecDeque::new(),
@@ -1068,8 +1069,7 @@ impl App {
draft_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
fleet_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
constitution_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
mcp_login_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
mcp_login_cancel: None,
mcp_login: None,
prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
balance_initiated: false,
last_balance_fetch: None,
+3 -2
View File
@@ -2143,7 +2143,8 @@ fn subscription_route_hides_stale_session_dollars_in_footer() {
!matches!(chip, crate::route_billing::UsageChip::Money(_)),
"{chip:?}"
);
let rendered = crate::route_billing::format_usage_chip(&chip).unwrap_or_default();
let rendered = crate::route_billing::format_usage_chip(&chip, crate::localization::Locale::En)
.unwrap_or_default();
assert!(!rendered.contains('$'), "{rendered}");
assert!(rendered.contains("Codex OAuth quota"), "{rendered}");
}
@@ -2174,7 +2175,7 @@ fn provider_switch_keeps_audited_cumulative_spend_visible() {
crate::route_billing::UsageChip::Money(_)
));
assert!(
crate::route_billing::format_usage_chip(&app.cumulative_usage_chip())
crate::route_billing::format_usage_chip(&app.cumulative_usage_chip(), app.ui_locale)
.is_some_and(|label| !label.is_empty())
);
+5
View File
@@ -891,6 +891,7 @@ pub(crate) enum GoalControlIntent {
/// already ordered in the engine channel; both remain pending until receipt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PendingGoalControl {
pub goal_id: Option<String>,
pub intent: GoalControlIntent,
pub dispatched: bool,
}
@@ -1298,5 +1299,9 @@ pub enum McpUiAction {
name: String,
},
Validate,
/// Report this server's last observed state without starting a new pool.
Diagnose {
name: String,
},
Reload,
}
+19 -8
View File
@@ -436,15 +436,26 @@ impl SessionControl {
}
}
/// The session's coarse turn state, shared by the control-socket `status`
/// answer and the session-state hook transitions (#6004). `Waiting` covers
/// every wait on the person — an open approval prompt, a presented
/// `request_user_input` question, or a parked goal continuation — not only
/// the continuation wait it used to map.
pub(crate) fn turn_state_from_app(app: &App) -> TurnState {
if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
return TurnState::InProgress;
}
if app.goal_continuation_waiting
|| app.pending_user_input_prompt.is_some()
|| app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::Approval)
{
return TurnState::Waiting;
}
TurnState::Idle
}
fn snapshot_from_app(app: &App) -> StatusSnapshot {
let turn_state =
if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
TurnState::InProgress
} else if app.goal_continuation_waiting {
TurnState::Waiting
} else {
TurnState::Idle
};
let turn_state = turn_state_from_app(app);
// A paused goal parks its objective in `paused_goal_objective`, so the
// snapshot surfaces the objective that is actually in flight.
let objective = app
+7
View File
@@ -88,6 +88,9 @@ pub enum InfoSegmentId {
/// only painted when `/statusline` has the balance item on, which is
/// also what authorises the fetch behind it.
Balance,
/// Active goal with elapsed time and the model's reported progress
/// (`Goal (9m) 12% ▓▓░░░░░░`). Painted only while a goal is active.
Goal,
}
impl InfoSegmentId {
@@ -106,6 +109,10 @@ impl InfoSegmentId {
// The balance outlives the cost: it is off by default, so a row
// that shows one is a row whose owner asked for it by name.
Self::Balance => 5,
// An active goal is the session's deliberate long-running mode:
// its reading outlives every telemetry segment and sheds only
// ahead of the route and context readings.
Self::Goal => 4,
Self::Model | Self::Context => 0,
}
}
+91 -25
View File
@@ -1,5 +1,5 @@
//! Session metrics strip: the compact `turns · steps │ LLM · tools │ TTFT ·
//! tok/s │ cache │ in` ledger painted on the phase strip.
//! Session metrics: the shared accumulators behind the metrics line and
//! the detailed `turns · steps │ LLM · tools │ TTFT · avg tok/s │ cache │ in` ledger.
//!
//! Every number here is sourced from runtime evidence the engine already
//! emits — never from transcript timestamps or estimates:
@@ -14,9 +14,19 @@
//! by tool id (the runtime's own clock, taken when the events drain).
//! - **TTFT avg**: mean of `TurnUsage::first_token_ms` over the model calls
//! that reported one.
//! - **tok/s**: provider-reported output tokens over the streamed seconds of
//! the same calls (`duration_ms`); calls without a stream duration are
//! excluded from both sides.
//! - **avg tok/s**: sum of provider-reported output tokens divided by the sum
//! of measured request seconds (`request_ms`), across this loaded session's
//! completed usage receipts. This is effective request throughput, including
//! connection setup, time to first token, and pauses within the response;
//! it is not a decoder-speed measurement or a live text-token estimate.
//! Streaming and non-streaming calls use the same dispatch-to-receipt clock.
//! Tool execution and idle time between calls are excluded. A transparent
//! retry before any content uses the replacement request's clock; a billed
//! response with usage is counted even if a later retry is needed. Calls
//! without a positive measured request duration (including aggregate REPL
//! child receipts) are excluded from both numerator and denominator. The
//! normalized `Usage::output_tokens` receipt is canonical; separate reasoning
//! counts are not added again and streamed estimates never enter this average.
//! - **cache**: provider-reported prompt-cache hit tokens over hit + miss
//! (`SessionState::total_cache_hit_tokens` / `total_cache_miss_tokens`).
//! - **in**: provider-reported input tokens (`SessionState::total_input_tokens`).
@@ -24,10 +34,9 @@
//! When a provider never reports a metric, or its evidence has not arrived
//! yet, the cell is omitted. Nothing here is estimated or captioned.
//!
//! The strip is one row wide and never grows the layout: it lives in the
//! phase-strip ledger tail (`crate::tui::phase_strip`), between the phase
//! marker and the right-hand key hints, and drops its lowest-value groups
//! until it fits the columns that are genuinely available.
//! The infoline and detailed ledger consume the same rate. The infoline keeps
//! the last measured session average while a request is in flight; it does not
//! divide a text estimate by a turn timer that also includes tools and waits.
use std::collections::HashMap;
use std::time::{Duration, Instant};
@@ -51,10 +60,10 @@ pub struct SessionMetrics {
ttft_total: Duration,
/// How many model calls reported a time-to-first-token.
ttft_samples: u64,
/// Output tokens from calls that also reported a stream duration.
/// Output tokens from calls that also reported a positive request duration.
rate_output_tokens: u64,
/// Stream time from the same calls.
rate_stream_time: Duration,
/// Dispatch-to-receipt time from exactly the same calls.
rate_request_time: Duration,
/// Tools currently running, keyed by tool id, with the instant their
/// start event drained.
tool_started: HashMap<String, Instant>,
@@ -65,24 +74,24 @@ impl SessionMetrics {
pub fn record_model_call(
&mut self,
output_tokens: u32,
stream_ms: u64,
duration_ms: u64,
first_token_ms: Option<u64>,
request_ms: Option<u64>,
) {
self.model_calls = self.model_calls.saturating_add(1);
let call_ms = request_ms.unwrap_or(stream_ms);
let call_ms = request_ms.unwrap_or(duration_ms);
self.llm_time = self.llm_time.saturating_add(Duration::from_millis(call_ms));
if let Some(ttft) = first_token_ms {
self.ttft_total = self.ttft_total.saturating_add(Duration::from_millis(ttft));
self.ttft_samples = self.ttft_samples.saturating_add(1);
}
if stream_ms > 0 {
if let Some(request_ms) = request_ms.filter(|millis| *millis > 0) {
self.rate_output_tokens = self
.rate_output_tokens
.saturating_add(u64::from(output_tokens));
self.rate_stream_time = self
.rate_stream_time
.saturating_add(Duration::from_millis(stream_ms));
self.rate_request_time = self
.rate_request_time
.saturating_add(Duration::from_millis(request_ms));
}
}
@@ -132,10 +141,11 @@ impl SessionMetrics {
Some(self.ttft_total / u32::try_from(self.ttft_samples).unwrap_or(u32::MAX))
}
/// Output tokens per streamed second, when the evidence exists.
/// Session-average output tokens per measured request second. See the
/// module documentation for included time and receipt coverage.
#[must_use]
pub fn tokens_per_second(&self) -> Option<f64> {
let secs = self.rate_stream_time.as_secs_f64();
let secs = self.rate_request_time.as_secs_f64();
if self.rate_output_tokens == 0 || !secs.is_finite() || secs <= 0.0 {
return None;
}
@@ -505,7 +515,7 @@ mod tests {
let text = full_text(sample(), Locale::En, false);
assert_eq!(
text,
"4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s │ TTFT avg 1.5s · 120 tok/s │ Cache hit 99% │ Input 9.3M"
"4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s │ TTFT avg 1.5s · 120 avg tok/s │ Cache hit 99% │ Input 9.3M"
);
let ascii = full_text(sample(), Locale::En, true);
assert!(ascii.is_ascii(), "{ascii}");
@@ -536,7 +546,7 @@ mod tests {
snapshot.ttft_avg = None;
snapshot.tokens_per_second = Some(88.0);
let text = full_text(snapshot, Locale::En, false);
assert!(text.ends_with("│ 88 tok/s"), "{text}");
assert!(text.ends_with("│ 88 avg tok/s"), "{text}");
}
#[test]
@@ -582,13 +592,69 @@ mod tests {
assert_eq!(metrics.llm_time, Duration::from_millis(3_500));
assert_eq!(metrics.ttft_average(), Some(Duration::from_millis(500)));
let rate = metrics.tokens_per_second().expect("rate");
assert!((rate - 40.0).abs() < 1e-9, "{rate}");
assert!((rate - 120.0 / 3.5).abs() < 1e-9, "{rate}");
// Missing request_ms falls back to the stream duration.
metrics.record_model_call(0, 700, None, None);
assert_eq!(metrics.llm_time, Duration::from_millis(4_200));
// Zero output tokens must not poison the rate.
assert!((metrics.tokens_per_second().unwrap() - 120.0 / 3.7).abs() < 1e-9);
// A duration without individual request timing cannot enter the rate.
assert!((metrics.tokens_per_second().unwrap() - 120.0 / 3.5).abs() < 1e-9);
}
#[test]
fn request_average_covers_ttft_stream_pauses_tools_and_non_streaming_calls() {
let mut metrics = SessionMetrics::default();
let t0 = Instant::now();
let connected = t0 + Duration::from_millis(200);
let first_token = t0 + Duration::from_secs(1);
let pause_started = t0 + Duration::from_secs(2);
let pause_finished = pause_started + Duration::from_secs(1);
let first_receipt = pause_finished + Duration::from_secs(2);
let millis = |duration: Duration| u64::try_from(duration.as_millis()).unwrap();
metrics.record_model_call(
120,
millis(first_receipt.duration_since(connected)),
Some(millis(first_token.duration_since(t0))),
Some(millis(first_receipt.duration_since(t0))),
);
assert_eq!(metrics.tokens_per_second(), Some(24.0));
// Thirty seconds of tool work and ten seconds idle are not model time.
metrics.record_tool_started_at("build", first_receipt);
let tool_finished = first_receipt + Duration::from_secs(30);
metrics.record_tool_completed_at("build", tool_finished);
let second_dispatch = tool_finished + Duration::from_secs(10);
assert_eq!(metrics.tokens_per_second(), Some(24.0));
let second_receipt = second_dispatch + Duration::from_secs(3);
metrics.record_model_call(
60,
2_800,
Some(500),
Some(millis(second_receipt.duration_since(second_dispatch))),
);
// A buffered/non-streaming call has a real request clock even though
// its adapter reports no stream duration or first-content timestamp.
let third_dispatch = second_receipt + Duration::from_secs(20);
let third_receipt = third_dispatch + Duration::from_secs(2);
metrics.record_model_call(
80,
0,
None,
Some(millis(third_receipt.duration_since(third_dispatch))),
);
assert_eq!(metrics.tokens_per_second(), Some(26.0)); // 260 / (5 + 3 + 2)
assert_eq!(metrics.ttft_average(), Some(Duration::from_millis(750)));
assert_eq!(metrics.tool_time, Duration::from_secs(30));
// Aggregate child or legacy receipts and zero-duration cache receipts
// cannot contribute tokens without their matching request denominator.
metrics.record_model_call(1_000, 9_000, None, None);
metrics.record_model_call(300, 0, None, Some(0));
assert_eq!(metrics.tokens_per_second(), Some(26.0));
// A measured, empty response consumes time and produces zero output.
metrics.record_model_call(0, 1_000, None, Some(1_000));
assert!((metrics.tokens_per_second().unwrap() - 260.0 / 11.0).abs() < 1e-9);
}
#[test]
@@ -65,7 +65,6 @@ impl SettingsPickerController {
}
#[must_use]
#[allow(dead_code)] // catalog accessors for model/provider migration (TUI-DOG-009)
pub fn options(&self) -> &[SettingOption] {
&self.options
}
+29 -7
View File
@@ -13,7 +13,7 @@ use std::sync::{
use std::time::{Duration, Instant};
use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity};
use crate::resource_telemetry::{TokenThroughput, estimate_output_tokens_from_text};
use crate::resource_telemetry::estimate_output_tokens_from_text;
use anyhow::{Context, Result};
use codewhale_release::InstallMethod;
// On Windows the push/pop helpers write the escapes directly; crossterm's
@@ -359,12 +359,34 @@ fn tui_launch_preflight_rejects_background_process_group() {
assert!(message.contains("codewhale exec"), "{message}");
}
fn should_show_resume_hint(session_id: Option<&str>) -> bool {
session_id.is_some_and(|id| !id.trim().is_empty())
}
fn resume_hint_text() -> &'static str {
"To continue this session, execute codewhale run --continue"
fn resume_hint_text(
locale: crate::localization::Locale,
session_id: Option<&str>,
terminal_output: bool,
) -> Option<String> {
use crate::localization::{MessageId, tr};
if !terminal_output {
return None;
}
let session_id = session_id.filter(|id| !id.trim().is_empty())?;
// Reconstruct a canonical UUID rather than interpolating a stored string
// into a shell command or terminal output. Legacy/noncanonical identities
// get the existing picker, never an ambiguous "most recent" shortcut.
let canonical = uuid::Uuid::parse_str(session_id)
.ok()
.map(|id| id.hyphenated().to_string())
.filter(|id| id == session_id);
let (message, command) = match canonical {
Some(id) => (
MessageId::ResumeExactSessionHint,
format!("codewhale resume {id}"),
),
None => (
MessageId::ResumeSavedSessionHint,
"codewhale resume".to_string(),
),
};
Some(tr(locale, message).replace("{command}", &command))
}
struct TerminalCleanupGuard {
+5 -3
View File
@@ -1644,7 +1644,7 @@ fn turn_route_lines(app: &App) -> Vec<String> {
crate::route_billing::UsageChip::PricedSubtotal { .. } => {
lines.push(format!(
"Cost (session): {}",
crate::route_billing::format_usage_chip(&chip).unwrap_or_default()
crate::route_billing::format_usage_chip(&chip, app.ui_locale).unwrap_or_default()
));
}
crate::route_billing::UsageChip::Allowance { label, used_pct } => {
@@ -1656,8 +1656,10 @@ fn turn_route_lines(app: &App) -> Vec<String> {
crate::route_billing::UsageChip::Local => {
lines.push("Cost: local".to_string());
}
crate::route_billing::UsageChip::Unknown => {
lines.push("Cost: unknown".to_string());
crate::route_billing::UsageChip::Unknown(_) => {
lines.push(
crate::route_billing::format_usage_chip(&chip, app.ui_locale).unwrap_or_default(),
);
}
crate::route_billing::UsageChip::Hidden => {}
}
+81 -14
View File
@@ -334,6 +334,12 @@ fn desired_goal_state(
GoalStatus::Complete => crate::session_manager::SessionGoalStatus::Complete,
GoalStatus::Blocked => crate::session_manager::SessionGoalStatus::Blocked,
};
if *status == GoalStatus::Active {
goal.goal_id = Some(uuid::Uuid::new_v4().to_string());
goal.last_gap_fingerprint = None;
goal.repeated_gap_count = 0;
goal.last_gap_pass = None;
}
goal.pause_reason = (*status == GoalStatus::Paused)
.then_some(crate::tools::goal::GoalPauseReason::User);
Ok(base)
@@ -342,6 +348,7 @@ fn desired_goal_state(
objective,
token_budget,
} => crate::session_manager::SessionGoalState::from_runtime(&GoalSnapshot {
goal_id: Some(uuid::Uuid::new_v4().to_string()),
objective: Some(objective.clone()),
status: GoalStatus::Active.as_str().to_string(),
token_budget: *token_budget,
@@ -375,9 +382,10 @@ fn persist_accepted_goal_state(
.map_err(|error| error.to_string())
}
fn goal_control_op(intent: &GoalControlIntent) -> Op {
fn goal_control_op(intent: &GoalControlIntent, goal_id: Option<String>) -> Op {
match intent {
GoalControlIntent::SetStatus { status, clear } => Op::SetGoalStatus {
goal_id,
status: *status,
clear: *clear,
},
@@ -385,6 +393,7 @@ fn goal_control_op(intent: &GoalControlIntent) -> Op {
objective,
token_budget,
} => Op::SetGoalObjective {
goal_id,
objective: objective.clone(),
token_budget: *token_budget,
},
@@ -399,7 +408,7 @@ pub(crate) fn flush_pending_goal_controls(app: &mut App, engine_handle: &EngineH
continue;
}
if engine_handle
.try_send(goal_control_op(&pending.intent))
.try_send(goal_control_op(&pending.intent, pending.goal_id.clone()))
.is_err()
{
return engine_handle.tx_op.is_closed();
@@ -465,6 +474,10 @@ fn accept_goal_control(app: &mut App, engine_handle: &EngineHandle, intent: Goal
}
app.last_known_goal_state = desired;
app.pending_goal_controls.push_back(PendingGoalControl {
goal_id: app
.last_known_goal_state
.as_ref()
.and_then(|goal| goal.goal_id.clone()),
intent,
dispatched: false,
});
@@ -490,10 +503,22 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
}
};
let pending_desired = app.last_known_goal_state.clone();
let matched_pending = app
.pending_goal_controls
.front()
.is_some_and(|pending| goal_control_matches(&pending.intent, durable_goal.as_ref()));
let matched_pending = app.pending_goal_controls.front().is_some_and(|pending| {
pending.dispatched
&& pending.goal_id.as_deref().is_none_or(|id| {
Some(id)
== durable_goal
.as_ref()
.and_then(|goal| goal.goal_id.as_deref())
})
&& goal_control_matches(&pending.intent, durable_goal.as_ref())
});
// Accepted controls own the durable target until their exact revision's
// receipt arrives. An earlier pass cannot restore pre-resume stall state.
if !app.pending_goal_controls.is_empty() && !matched_pending {
return false;
}
let durable_changed = app.last_known_goal_state != durable_goal;
if matched_pending {
app.pending_goal_controls.pop_front();
}
@@ -515,7 +540,7 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
} else {
pending_desired
};
return changed || matched_pending;
return changed || matched_pending || durable_changed;
}
let Some(objective) = snapshot
@@ -536,12 +561,14 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
};
let verdict = status;
let objective_changed = app.goal.objective.as_deref() != Some(objective);
let progress_changed = app.goal.progress != snapshot.progress;
let changed = objective_changed
|| app.goal.token_budget != snapshot.token_budget
|| app.goal.tokens_used != snapshot.tokens_used
|| app.goal.time_used_seconds != snapshot.time_used_seconds
|| app.goal.continuation_count != snapshot.continuation_count
|| app.goal.pause_reason != snapshot.pause_reason
|| progress_changed
|| app.goal.status != verdict;
if !changed {
app.last_known_goal_state = if app.pending_goal_controls.is_empty() {
@@ -549,7 +576,7 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
} else {
pending_desired
};
return matched_pending;
return matched_pending || durable_changed;
}
// The runtime introduced a new active objective (the model called
@@ -570,6 +597,38 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
};
app.add_message(crate::tui::history::HistoryCell::System { content });
}
// A fresh reported-progress receipt reads like the model's own status
// line: percent with a bar, then the optional now/next lines it wrote.
// Paused/complete goals keep their last report silent — the lifecycle
// receipt already spoke.
if progress_changed
&& verdict == GoalStatus::Active
&& let Some(progress) = snapshot.progress.as_ref()
{
let mut content = app
.tr(crate::localization::MessageId::GoalProgressReceipt)
.replace("{percent}", &progress.percent.to_string())
.replace(
"{bar}",
&crate::tools::goal::goal_progress_bar(progress.percent),
);
if let Some(now) = progress.now.as_deref() {
content.push('\n');
content.push_str(
&app.tr(crate::localization::MessageId::GoalProgressNow)
.replace("{note}", now),
);
}
if let Some(next) = progress.next.as_deref() {
content.push('\n');
content.push_str(
&app.tr(crate::localization::MessageId::GoalProgressNext)
.replace("{note}", next),
);
}
app.add_message(crate::tui::history::HistoryCell::System { content });
}
app.goal.progress = snapshot.progress.clone();
app.goal.objective = Some(objective.to_string());
app.goal.token_budget = snapshot.token_budget;
app.goal.tokens_used = snapshot.tokens_used;
@@ -1080,7 +1139,6 @@ pub(crate) async fn apply_provider_fallback_switch(
} else {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
}
let _ = engine_handle.send(Op::Shutdown).await;
@@ -1268,9 +1326,18 @@ pub(crate) async fn apply_command_result(
let is_full_reset = messages.is_empty() && system_prompt.is_none();
if is_full_reset && session_id.is_none() {
let new_session_id = uuid::Uuid::new_v4().to_string();
app.current_session_id = Some(new_session_id.clone());
session_id = Some(new_session_id);
}
if let Some(session_id) = session_id.as_deref() {
let transition = match prepare_offline_queue_transition(app, session_id) {
Ok(transition) => transition,
Err(error) => {
app.push_status_toast(error, StatusToastLevel::Error, Some(6_000));
return Ok(false);
}
};
install_offline_queue_transition(app, transition);
}
let workspace_changed = task_manager.default_workspace().await != workspace;
if workspace_changed {
apply_workspace_runtime_state(app, config, workspace.clone());
@@ -2272,7 +2339,6 @@ pub(crate) async fn apply_command_result(
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
// Rebuild the engine with the new config so API key/model/base URL take effect.
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
@@ -2386,8 +2452,8 @@ fn edit_project_hooks_from_tui(terminal: &mut AppTerminal, app: &mut App, config
// a silent no-op unless it is said out loud.
if !crate::hooks::workspace_allows_project_hooks(&app.workspace) {
content.push_str(
" This workspace is not trusted, so project hooks are read but not run \
(`/trust` to allow them).",
" Project hooks are not approved for these exact contents. Use /hooks review, \
then /hooks approve <digest> after reviewing the commands.",
);
}
if !reloaded.problems.is_empty() {
@@ -3318,11 +3384,13 @@ pub(crate) fn apply_loaded_session_with_goal(
// Restore/validate the contended state before mutating conversation or
// workspace fields. A failed session switch must leave the current session
// wholly intact.
let queue_transition = prepare_offline_queue_transition(app, &session.metadata.id)?;
app.restore_work_state(
&session.metadata.id,
&session.metadata.workspace,
session.work_state.as_ref(),
)?;
install_offline_queue_transition(app, queue_transition);
// All fallible preflight is complete. Retire the old session's background
// accounting atomically before mutating live state; any late old-scope
// provider response is rejected by `cost_status::report`.
@@ -3486,7 +3554,6 @@ pub(crate) fn apply_loaded_session_with_goal(
app.session.displayed_cost_high_water_cny = restored_high_water.cny.max(total_restored_cny);
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
-1
View File
@@ -885,7 +885,6 @@ pub(crate) fn build_dispatch_success_closure(
}
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
+120 -160
View File
@@ -6,6 +6,7 @@
use super::clamp_event_poll_timeout;
use super::observer_hooks::{
execute_session_error_hook, execute_session_state_transition_hooks,
execute_turn_end_observer_hook, subagent_failure_notice,
subagent_status_from_completion_result, surface_observer_hook_submission_failure,
};
@@ -26,6 +27,81 @@ pub(super) fn event_owner_is_active(
!owner_session_id.is_empty() && current_session_id == Some(owner_session_id)
}
/// Apply only the projection owned by this host session. A delayed SetModel
/// receipt from the previous session cannot replace the current transcript.
pub(super) fn apply_engine_session_projection(
app: &mut App,
config: &Config,
event: EngineEvent,
) -> bool {
let EngineEvent::SessionUpdated {
session_id,
messages,
system_prompt,
model,
workspace,
} = event
else {
return false;
};
// SetModel can emit the old session while a host-owned
// SyncSession is still queued. Reject that entire stale
// projection before changing transcript or persistence.
if !event_owner_is_active(app.current_session_id.as_deref(), &session_id) {
tracing::debug!(
expected = ?app.current_session_id,
received = %session_id,
"ignoring stale engine session projection"
);
return false;
}
if app.last_known_goal_state.is_some()
&& let Err(error) = persist_current_session_goal(app)
{
surface_goal_persistence_failure(app, &error);
}
app.context_token_cache.borrow_mut().clear();
app.api_messages = messages;
app.system_prompt = system_prompt;
if app.auto_model {
app.last_effective_model = Some(model);
} else {
app.set_model_selection(model);
}
app.update_model_compaction_budget();
if app.workspace != workspace {
apply_workspace_runtime_state(app, config, workspace);
}
if (app.is_loading || app.is_compacting || app.is_purging)
&& let Ok(manager) = SessionManager::default_location()
{
if let Ok(session) = build_session_snapshot(app, &manager) {
app.session_title = Some(session.metadata.title.clone());
// The engine's session id was pinned above, so
// every checkpoint of this session lands in the
// same per-session file.
if let Err(err) =
persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
{
app.status_message = Some(format!(
"To-do list update pending: checkpoint could not be queued ({err})"
));
}
}
} else if app.session_title.is_none() {
// Never synchronously reload the growing session
// JSON on the event-loop task just to recover a
// title. The in-memory metadata cache is authoritative.
let cached = app
.current_session_metadata
.as_ref()
.filter(|metadata| metadata.id == session_id)
.map(|metadata| metadata.title.clone());
app.session_title = cached.or_else(|| derive_session_title(&app.api_messages));
}
true
}
fn current_session_fleet_workers_status(
locale: crate::localization::Locale,
count: usize,
@@ -622,37 +698,19 @@ pub async fn run_tui(
app.status_message = Some(notice);
}
// The parked offline queue is keyed per session, so this reads back only
// *this* session's own unsent text: a fresh session has none, and a
// concurrent instance's queue is a different file that nothing here can
// reach. A session that is not being resumed has nothing parked yet.
let mut restored_offline_queue = false;
if let Some(session_id) = app.current_session_id.clone()
&& let Ok(manager) = SessionManager::default_location()
{
match manager.load_offline_queue_state(&session_id) {
Ok(Some(state)) => {
restored_offline_queue = restore_matching_offline_queue_state(&mut app, state);
if restored_offline_queue
&& app.status_message.is_none()
&& app.queued_message_count() > 0
{
app.status_message = Some(format!(
"Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard",
app.queued_message_count()
));
}
}
Ok(None) => {}
Err(err) => {
if app.status_message.is_none() {
app.status_message = Some(format!("Failed to restore offline queue: {err}"));
}
}
}
let session_id = ensure_runtime_session_id(&mut app);
let transition =
prepare_offline_queue_transition(&app, &session_id).map_err(anyhow::Error::msg)?;
let restored_offline_queue = install_offline_queue_transition(&mut app, transition)
|| !app.queued_messages.is_empty()
|| app.queued_draft.is_some();
if restored_offline_queue && app.status_message.is_none() && app.queued_message_count() > 0 {
app.status_message = Some(format!(
"Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard",
app.queued_message_count()
));
}
let session_id = ensure_runtime_session_id(&mut app);
let task_manager = TaskManager::start(
TaskManagerConfig::from_runtime(
config,
@@ -918,6 +976,9 @@ pub async fn run_tui(
// clearing it here unconditionally could erase in-flight progress that
// never reached a snapshot, so it survives for startup recovery review.
if let Some((handle, task)) = persistence_runtime {
// A quit key can leave the frame before its usual queue comparison.
// Capture the final edited draft before the shutdown durability barrier.
persist_offline_queue_state(&app);
let turn_in_flight = app.is_loading || app.dispatch_in_flight;
if !turn_in_flight && let Some(session_id) = app.current_session_id.clone() {
handle.try_send(PersistRequest::ClearCheckpoint { session_id });
@@ -984,7 +1045,13 @@ pub async fn run_tui(
}
}
if result.is_ok() && should_show_resume_hint(app.current_session_id.as_deref()) {
if result.is_ok()
&& let Some(hint) = resume_hint_text(
app.ui_locale,
app.current_session_id.as_deref(),
io::stdout().is_terminal(),
)
{
// Printed AFTER `LeaveAlternateScreen` / `drop(terminal)` above,
// so we're back on the primary screen — this is the one
// legitimate stdout write in the TUI module tree. The
@@ -992,7 +1059,7 @@ pub async fn run_tui(
// refuse it.
#[allow(clippy::print_stdout)]
{
println!("{}", resume_hint_text());
println!("{hint}");
}
}
@@ -1197,7 +1264,7 @@ pub(crate) async fn run_event_loop(
// channel, which nothing else in this loop reads.
let mut runtime_event_rx = task_manager.subscribe_runtime_events();
let mut pending_thinking_translations = 0usize;
let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
let mut last_queue_state = offline_queue_projection(app);
let mut last_queue_was_empty = app.queued_messages.is_empty() && app.queued_draft.is_none();
let mut last_task_refresh = Instant::now()
.checked_sub(Duration::from_secs(2))
@@ -1232,6 +1299,9 @@ pub(crate) async fn run_event_loop(
);
let mut prev_input_snapshot = String::new();
let mut terminal_paused_at: Option<Instant> = None;
// Last observed coarse turn state for the session-state hook transitions
// (#6004); `None` until the first publish records it without firing.
let mut previous_turn_state = None;
let mut force_terminal_repaint = false;
// FocusGained debounce: some terminal emulators (e.g. Tabby) re-trigger
// FocusGained when we re-arm focus-change reporting inside
@@ -1323,6 +1393,7 @@ pub(crate) async fn run_event_loop(
// seam) exits the loop through the ordinary `/exit` teardown.
session_control.reconcile(app.current_session_id.as_deref());
session_control.update_status(app);
execute_session_state_transition_hooks(app, &mut previous_turn_state);
if session_control
.drain(
app,
@@ -1546,31 +1617,8 @@ pub(crate) async fn run_event_loop(
deliver_constitution_draft_result(app, model_label, draft_locale, outcome);
}
// Poll the MCP OAuth login cell (same background pattern).
let mcp_login_delivery = app
.mcp_login_cell
.try_lock()
.ok()
.and_then(|mut guard| guard.take());
if let Some((server, outcome)) = mcp_login_delivery {
if app
.mcp_login_cancel
.as_ref()
.is_some_and(|(pending, _)| *pending == server)
{
app.mcp_login_cancel = None;
}
app.status_message = Some(match outcome {
Ok(()) => format!(
"Stored OAuth credentials for MCP server '{server}'. Run /mcp reload to reconnect it."
),
Err(error) if error == "cancelled" => {
format!("Cancelled the OAuth login for MCP server '{server}'.")
}
Err(error) => format!("OAuth login for MCP server '{server}' failed: {error}"),
});
app.needs_redraw = true;
}
// Discovery and callback delivery never park terminal input.
poll_mcp_login(app);
// #1830/#2317: service any already-arrived terminal keys before a
// potentially long engine batch so composer/modal input stays live.
@@ -2040,7 +2088,6 @@ pub(crate) async fn run_event_loop(
let now = Instant::now();
app.turn_started_at = Some(now);
app.turn_last_activity_at = Some(now);
app.session.last_output_throughput = None;
app.session.clear_pending_turn_usage();
app.streaming_output_token_estimate = 0;
app.provider_wait_incident_logged = false;
@@ -2195,6 +2242,12 @@ pub(crate) async fn run_event_loop(
) {
subagent_list_refresh_requested = true;
}
// #6004: only a turn that *ended* failed is a session
// error; transient tool failures the agent absorbed
// never fire it.
if matches!(status, crate::core::events::TurnOutcomeStatus::Failed) {
execute_session_error_hook(app, error.as_deref());
}
crate::tui::notifications::clear_taskbar_progress();
if status != crate::core::events::TurnOutcomeStatus::Completed {
crate::retry_status::clear();
@@ -2243,8 +2296,6 @@ pub(crate) async fn run_event_loop(
}
app.session.last_prompt_tokens = Some(usage.input_tokens);
app.session.last_completion_tokens = Some(usage.output_tokens);
app.session.last_output_throughput =
TokenThroughput::new(u64::from(usage.output_tokens), turn_elapsed);
app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens;
app.session.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens;
app.session.last_reasoning_replay_tokens = usage.reasoning_replay_tokens;
@@ -2738,84 +2789,8 @@ pub(crate) async fn run_event_loop(
};
app.status_message = Some(app.tr(message_id).to_string());
}
EngineEvent::SessionUpdated {
session_id,
messages,
system_prompt,
model,
workspace,
} => {
// The engine adopts the host session id at spawn and
// every SyncSession carries it, so a different id here
// means a checkpoint written under the previous id
// would be orphaned. Surface it instead of silently
// re-keying persistence mid-session.
if let Some(previous) = app.current_session_id.as_deref()
&& previous != session_id
{
tracing::warn!(
previous,
next = %session_id,
"engine session id diverged from the host session id"
);
// The operator, not the log, owns this: earlier
// checkpoints now live under the old id (#5931).
let message = session_id_divergence_notice(app, previous, &session_id);
app.push_status_toast(
message.clone(),
StatusToastLevel::Warning,
Some(12_000),
);
app.add_message(HistoryCell::System { content: message });
transcript_batch_updated = true;
}
app.current_session_id = Some(session_id.clone());
if app.last_known_goal_state.is_some()
&& let Err(error) = persist_current_session_goal(app)
{
surface_goal_persistence_failure(app, &error);
}
app.context_token_cache.borrow_mut().clear();
app.api_messages = messages;
app.system_prompt = system_prompt;
if app.auto_model {
app.last_effective_model = Some(model);
} else {
app.set_model_selection(model);
}
app.update_model_compaction_budget();
if app.workspace != workspace {
apply_workspace_runtime_state(app, config, workspace);
}
if (app.is_loading || app.is_compacting || app.is_purging)
&& let Ok(manager) = SessionManager::default_location()
{
if let Ok(session) = build_session_snapshot(app, &manager) {
app.session_title = Some(session.metadata.title.clone());
// The engine's session id was pinned above, so
// every checkpoint of this session lands in the
// same per-session file.
if let Err(err) = persist_with_pending_work_boundary(
app,
PersistRequest::SaveCheckpoint { session },
) {
app.status_message = Some(format!(
"To-do list update pending: checkpoint could not be queued ({err})"
));
}
}
} else if app.session_title.is_none() {
// Never synchronously reload the growing session
// JSON on the event-loop task just to recover a
// title. The in-memory metadata cache is authoritative.
let cached = app
.current_session_metadata
.as_ref()
.filter(|metadata| metadata.id == session_id)
.map(|metadata| metadata.title.clone());
app.session_title =
cached.or_else(|| derive_session_title(&app.api_messages));
}
event @ EngineEvent::SessionUpdated { .. } => {
apply_engine_session_projection(app, config, event);
}
EngineEvent::CompactionStarted { id, auto, .. } => {
apply_compaction_started(app, id, auto);
@@ -3834,7 +3809,7 @@ pub(crate) async fn run_event_loop(
// draft is only cloned while one is actually pending.
let queue_now_empty = app.queued_messages.is_empty() && app.queued_draft.is_none();
if !(queue_now_empty && last_queue_was_empty) {
let queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
let queue_state = offline_queue_projection(app);
if queue_state != last_queue_state {
persist_offline_queue_state(app);
last_queue_state = queue_state;
@@ -4509,6 +4484,12 @@ pub(crate) async fn run_event_loop(
// to canonical Ctrl+C so the quit-arm flow always runs (#4090).
normalize_raw_ctrl_c(&mut key);
// Login cancellation precedes modal/focus dispatch: Extensions
// must not consume the Esc promised by the authorization notice.
if handle_mcp_login_key(app, &key) {
continue;
}
// A route change made in-session is temporary and stays that way
// until the user EXPLICITLY persists it with a command
// (/fleet save updates the selected Fleet, /fleet save-as saves a
@@ -5622,19 +5603,6 @@ pub(crate) async fn run_event_loop(
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
// A pending MCP OAuth login owns Esc first: the notice that
// starts it promises "Esc cancels", and abandoning a login
// opened by a misclick must not depend on what else is focused.
KeyCode::Esc if app.mcp_login_cancel.is_some() => {
if let Some((server, token)) = app.mcp_login_cancel.take() {
token.cancel();
app.status_message = Some(format!(
"Cancelled the OAuth login for MCP server '{server}'."
));
app.needs_redraw = true;
}
continue;
}
// Agent focus: Esc on an empty composer returns to the main
// conversation before any other Esc meaning applies.
KeyCode::Esc
@@ -6713,14 +6681,6 @@ mod fleet_workers_status_tests {
}
}
/// Text for a mid-session engine/host session id divergence: names both ids
/// and the command that reopens the checkpoints written under the old one.
pub(crate) fn session_id_divergence_notice(app: &App, previous: &str, next: &str) -> String {
app.tr(MessageId::SessionIdDivergedNotice)
.replace("{previous}", previous)
.replace("{next}", next)
}
/// Per-tick budget for the runtime store-failure tap. These events are rare;
/// the bound only keeps a burst from starving the frame.
const RUNTIME_STORE_FAILURE_DRAIN_BUDGET: usize = 64;
+125 -59
View File
@@ -19,51 +19,32 @@ pub(crate) fn info_context_percent(app: &App) -> u8 {
/// (SHELL-DESIGN-20260901 §2.11 item 5). Empty until the session has a
/// priced or counted turn.
///
/// `cost: unknown` (#5578) stays wherever a price *could* exist and this
/// session simply lacks one — a metered route whose model has no price
/// table, a legacy session with unrecorded coverage, turns the pricer could
/// not cover. It is omitted only when the route itself cannot be priced at
/// all ([`BillingPresentation::Unknown`]: a custom OpenAI-compatible
/// endpoint with no pay mode, an unclassified gateway), where the words
/// would be permanent noise rather than a reading (#5950).
///
/// [`BillingPresentation::Unknown`]: crate::route_billing::BillingPresentation::Unknown
/// Incomplete cost includes its receipt's reason, including an unclassified
/// billing route. A provider switch cannot erase earlier missing coverage.
pub(crate) fn session_cost_label(app: &App) -> String {
use crate::route_billing::{BillingPresentation, UsageChip};
use crate::route_billing::UsageChip;
let usage_chip = app.cumulative_usage_chip();
match &usage_chip {
UsageChip::Money(amount) => Some(amount.clone()),
UsageChip::Unknown if app.billing_presentation == BillingPresentation::Unknown => None,
UsageChip::PricedSubtotal { .. } | UsageChip::Unknown => {
crate::route_billing::format_usage_chip(&usage_chip)
UsageChip::PricedSubtotal { .. } | UsageChip::Unknown(_) => {
crate::route_billing::format_usage_chip(&usage_chip, app.ui_locale)
}
_ => None,
}
.unwrap_or_default()
}
/// Output tokens and output rate for the metrics line: the live stream's
/// running estimate while a turn is producing text, else the last turn's
/// provider-reported figures. `None` before any turn has produced output.
fn output_figures(app: &App) -> Option<(u64, Option<f64>)> {
/// Output tokens for the metrics line: the live stream's running estimate,
/// else the last turn's provider receipt. Request throughput is independently
/// sourced from SessionMetrics, so a long tool call cannot lower that rate.
fn output_tokens(app: &App) -> Option<u64> {
if app.is_loading && app.streaming_output_token_estimate > 0 {
let rate = app
.turn_started_at
.map(|started| started.elapsed().as_secs_f64())
.filter(|secs| *secs > 0.0)
.map(|secs| app.streaming_output_token_estimate as f64 / secs);
return Some((app.streaming_output_token_estimate, rate));
}
if let Some(throughput) = app.session.last_output_throughput {
return Some((
throughput.output_tokens,
Some(throughput.tokens_per_second()),
));
return Some(app.streaming_output_token_estimate);
}
app.session
.last_completion_tokens
.filter(|tokens| *tokens > 0)
.map(|tokens| (u64::from(tokens), None))
.map(u64::from)
}
/// Build the metrics line's segments from live `App` state. Shedding is the
@@ -144,6 +125,36 @@ pub(crate) fn info_segments(app: &App, width: u16) -> Vec<InfoSegment> {
));
}
// The active goal's live reading: elapsed time plus the model's latest
// reported progress with its bar. Painted only while a goal is actually
// active — the percent is the model's own estimate, and the row never
// invents one for a goal that has not reported.
if app.goal.status == crate::tools::goal::GoalStatus::Active
&& app.goal.objective.is_some()
&& let Some(started) = app.goal.started_at
{
let secs = started.elapsed().as_secs();
let elapsed = if secs < 60 {
format!("{secs}s")
} else {
format!("{}m", secs / 60)
};
let value = match app.goal.progress.as_ref() {
Some(progress) => format!(
"({elapsed}) {}% {}",
progress.percent,
crate::tools::goal::goal_progress_bar(progress.percent)
),
None => format!("({elapsed})"),
};
segments.push(InfoSegment::new(
InfoSegmentId::Goal,
app.tr(MessageId::GoalProgressLabel).as_ref(),
value,
ChromeInk::Info,
));
}
let cost = session_cost_label(app);
if shows(StatusItem::Cost) && !cost.is_empty() {
segments.push(InfoSegment::new(
@@ -185,19 +196,21 @@ pub(crate) fn info_segments(app: &App, width: u16) -> Vec<InfoSegment> {
ChromeInk::MetadataValue,
));
}
if let Some((tokens, rate)) = output_figures(app) {
if let Some(rate) = rate.filter(|_| shows(StatusItem::SessionMetrics)) {
segments.push(InfoSegment::new(
InfoSegmentId::Rate,
"",
format!(
"{} {}",
crate::tui::session_metrics::format_rate(rate),
app.tr(MessageId::SessionMetricsTokensPerSecond)
),
ChromeInk::MetadataValue,
));
}
if shows(StatusItem::SessionMetrics)
&& let Some(rate) = app.session_metrics.tokens_per_second()
{
segments.push(InfoSegment::new(
InfoSegmentId::Rate,
"",
format!(
"{} {}",
crate::tui::session_metrics::format_rate(rate),
app.tr(MessageId::SessionMetricsTokensPerSecond)
),
ChromeInk::MetadataValue,
));
}
if let Some(tokens) = output_tokens(app) {
let hit = u64::from(app.session.displayed_total_cache_hit_tokens());
let miss = u64::from(app.session.displayed_total_cache_miss_tokens());
let cache_total = hit + miss;
@@ -798,10 +811,17 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
compaction: app.compaction_config(),
todos: app.todos.clone(),
plan_state: app.plan_state.clone(),
goal_state: crate::tools::goal::new_shared_goal_state_from_host_status(
app.goal.objective.clone(),
app.goal.token_budget,
app.goal.status,
goal_state: app.last_known_goal_state.as_ref().map_or_else(
|| {
crate::tools::goal::new_shared_goal_state_from_host_status(
app.goal.objective.clone(),
app.goal.token_budget,
app.goal.status,
)
},
|goal| {
crate::tools::goal::new_shared_goal_state_from_snapshot(&goal.to_runtime_snapshot())
},
),
max_spawn_depth: config.subagent_max_spawn_depth_for_provider(provider),
subagent_token_budget: config.subagent_token_budget_for_provider(provider),
@@ -868,6 +888,8 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()),
tools_always_load: config.tools_always_load(),
user_input_limits: config.user_input_limits(),
user_input_timeout: config.user_input_timeout(),
goal_max_steps: Some(config.goal_max_steps()),
tools: config.tools.clone(),
workspace_follow_symlinks: app.workspace_follow_symlinks,
exec_policy_engine: config.exec_policy_engine.clone(),
@@ -2436,7 +2458,10 @@ mod tests {
);
let row = metrics_row(&app, 200);
assert!(row.contains("vendor-model-x"), "{row:?}");
assert!(!row.contains("unavailable"), "{row:?}");
// The unresolvable effort placeholder stays out; the localized
// missing-cost explanation ("rate unavailable") is a separate,
// legitimate reading.
assert!(!row.contains("high→effective unavailable"), "{row:?}");
assert!(!row.contains("high"), "{row:?}");
// First-party routes are unchanged: the tier label stays.
@@ -2454,11 +2479,9 @@ mod tests {
);
}
/// `cost: unknown` (#5578) stays wherever a price could exist — a
/// metered route whose coverage this session lacks — and is omitted
/// only where nothing about the route can be priced (#5950).
/// A provider switch must not hide missing historical coverage.
#[test]
fn cost_is_omitted_only_where_the_route_cannot_be_priced() {
fn cost_unknown_preserves_saved_coverage_across_route_changes() {
use crate::route_billing::BillingPresentation;
let mut app = app_with_context_percent(10);
app.session.cost_coverage_unknown_legacy = true;
@@ -2466,9 +2489,12 @@ mod tests {
app.billing_presentation = BillingPresentation::Metered;
assert!(matches!(
app.cumulative_usage_chip(),
crate::route_billing::UsageChip::Unknown
crate::route_billing::UsageChip::Unknown(_)
));
assert_eq!(super::session_cost_label(&app), "cost: unknown");
assert_eq!(
super::session_cost_label(&app),
"cost: unknown (saved coverage unavailable)"
);
let row = metrics_row(&app, 200);
assert!(
row.contains("cost: unknown"),
@@ -2478,18 +2504,21 @@ mod tests {
app.billing_presentation = BillingPresentation::Unknown;
assert!(matches!(
app.cumulative_usage_chip(),
crate::route_billing::UsageChip::Unknown
crate::route_billing::UsageChip::Unknown(_)
));
assert_eq!(super::session_cost_label(&app), "");
assert_eq!(
super::session_cost_label(&app),
"cost: unknown (saved coverage unavailable)"
);
let ids: Vec<InfoSegmentId> = super::info_segments(&app, 200)
.iter()
.map(|segment| segment.id)
.collect();
assert!(!ids.contains(&InfoSegmentId::Cost), "{ids:?}");
assert!(ids.contains(&InfoSegmentId::Cost), "{ids:?}");
let row = metrics_row(&app, 200);
assert!(
!row.contains("cost"),
"an unpriceable route states no price: {row:?}"
row.contains("saved coverage unavailable"),
"an unclassified route preserves the reason: {row:?}"
);
assert!(row.contains("ctx 10%"), "and nothing else moves: {row:?}");
@@ -2508,6 +2537,43 @@ mod tests {
assert!(!super::session_cost_label(&app).is_empty());
}
#[test]
fn metrics_line_uses_measured_request_average_during_tool_waits_and_live_text() {
use crate::tui::session_metrics::{full_text, snapshot_from_app};
let mut app = app_with_context_percent(60);
app.ui_locale = crate::localization::Locale::En;
app.status_items = vec![StatusItem::SessionMetrics, StatusItem::Tokens];
app.is_loading = true;
app.turn_started_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(120));
app.streaming_output_token_estimate = 60_000;
assert!(
super::info_segments(&app, 200)
.iter()
.all(|segment| segment.id != InfoSegmentId::Rate),
"live text estimates do not invent measured request throughput"
);
app.session_metrics
.record_model_call(120, 4_800, Some(1_000), Some(5_000));
let rate = |app: &App| {
super::info_segments(app, 200)
.into_iter()
.find(|segment| segment.id == InfoSegmentId::Rate)
.map(|segment| segment.value)
};
assert_eq!(rate(&app).as_deref(), Some("24 avg tok/s"));
let detailed = full_text(snapshot_from_app(&app), app.ui_locale, false);
assert!(detailed.contains("24 avg tok/s"), "{detailed}");
// Finishing a long turn or replacing the displayed token receipt must
// not switch the rate to the turn timer (which includes tool waits).
app.is_loading = false;
app.session.last_completion_tokens = Some(9_000);
assert_eq!(rate(&app).as_deref(), Some("24 avg tok/s"));
app.status_items = vec![StatusItem::Tokens];
assert_eq!(rate(&app), None, "the existing status toggle still owns it");
}
/// Every remaining status item owns a segment, and an empty list leaves
/// the row with nothing but the help hint — no toggle in `/statusline`
/// paints something no toggle can remove.
@@ -87,9 +87,8 @@ fn working_app() -> App {
subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running),
];
app.session_metrics
.record_model_call(1_200, 30_000, Some(400), None);
app.session.last_output_throughput =
crate::resource_telemetry::TokenThroughput::new(1_200, Duration::from_secs(30));
.record_model_call(1_200, 29_600, Some(400), Some(30_000));
app.session.last_completion_tokens = Some(1_200);
app
}
@@ -140,7 +139,7 @@ fn composed_frame_paints_each_fact_in_exactly_one_row() {
"help hint",
crate::tui::shell_key_routing::info_help_hint(app.ui_locale),
),
("output rate", "40 tok/s".to_string()),
("output rate", "40 avg tok/s".to_string()),
("ttft", "ttft 400ms".to_string()),
];
facts.push(("context reading", format!("ctx {pct}%")));
@@ -232,7 +231,7 @@ fn idle_frame_keeps_two_chrome_rows_and_last_turn_metrics() {
rows[composer + 1]
);
assert!(
rows[composer + 1].contains("40 tok/s"),
rows[composer + 1].contains("40 avg tok/s"),
"{}",
rows[composer + 1]
);
+220
View File
@@ -139,6 +139,128 @@ pub(super) fn surface_observer_hook_submission_failure(app: &mut App, error: Str
app.surface_observer_hook_submission_failure(error);
}
/// Why the agent is waiting on the person, in the payload of
/// [`HookEvent::WaitingForUser`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SessionWaitReason {
Approval,
UserInput,
GoalContinuation,
}
impl SessionWaitReason {
fn as_str(self) -> &'static str {
match self {
Self::Approval => "approval",
Self::UserInput => "user_input",
Self::GoalContinuation => "goal_continuation",
}
}
}
/// The session's wait reason right now, when one exists.
pub(super) fn session_wait_reason(app: &App) -> Option<SessionWaitReason> {
if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::Approval) {
return Some(SessionWaitReason::Approval);
}
if app.pending_user_input_prompt.is_some() {
return Some(SessionWaitReason::UserInput);
}
if app.goal_continuation_waiting {
return Some(SessionWaitReason::GoalContinuation);
}
None
}
/// The hook a turn-state edge fires, if any. Pure so the transition table is
/// directly testable: into `Waiting` is `waiting_for_user`; into `Idle` from
/// work or a wait is `session_idle`; everything else is silent.
pub(super) fn session_state_transition_event(
previous: crate::tui::control_socket::TurnState,
current: crate::tui::control_socket::TurnState,
) -> Option<HookEvent> {
use crate::tui::control_socket::TurnState;
match (previous, current) {
(TurnState::InProgress | TurnState::Waiting, TurnState::Idle) => {
Some(HookEvent::SessionIdle)
}
(TurnState::Idle | TurnState::InProgress, TurnState::Waiting) => {
Some(HookEvent::WaitingForUser)
}
_ => None,
}
}
/// Fire the session-state hooks on transitions of the shared
/// [`crate::tui::control_socket::turn_state_from_app`] projection (#6004):
/// `waiting_for_user` when a wait begins, `session_idle` when the session
/// settles back to idle after work or a wait. The first observed state is
/// recorded without firing so startup never emits a spurious transition.
pub(super) fn execute_session_state_transition_hooks(
app: &App,
previous: &mut Option<crate::tui::control_socket::TurnState>,
) {
use crate::tui::control_socket::turn_state_from_app;
let current = turn_state_from_app(app);
let previous = previous.replace(current);
let Some(previous) = previous else {
return;
};
let Some(event) = session_state_transition_event(previous, current) else {
return;
};
if !app.hooks.has_hooks_for_event(event) {
return;
}
let mut payload = serde_json::json!({
"from": turn_state_name(previous),
"to": turn_state_name(current),
});
if event == HookEvent::WaitingForUser
&& let Some(reason) = session_wait_reason(app)
{
payload["reason"] = serde_json::Value::String(reason.as_str().to_string());
}
if event == HookEvent::SessionIdle
&& let Some(status) = app.runtime_turn_status.as_deref()
{
payload["last_turn_status"] = serde_json::Value::String(status.to_string());
}
if let Err(error) = app
.hooks
.submit_json_observer(event, app.base_hook_context(), payload)
{
tracing::warn!("session-state hook submission failed: {error}");
}
}
fn turn_state_name(state: crate::tui::control_socket::TurnState) -> &'static str {
match state {
crate::tui::control_socket::TurnState::Idle => "idle",
crate::tui::control_socket::TurnState::InProgress => "in_progress",
crate::tui::control_socket::TurnState::Waiting => "waiting",
}
}
/// Fire `session_error` for a turn whose terminal status is failed (#6004).
/// Transient tool failures the agent absorbs never reach this: only the
/// turn-ending failure fires it, so an alert here means the agent stopped.
pub(super) fn execute_session_error_hook(app: &App, error: Option<&str>) {
if !app.hooks.has_hooks_for_event(HookEvent::SessionError) {
return;
}
let payload = serde_json::json!({
"status": "failed",
"error": error.unwrap_or_default(),
});
if let Err(error) =
app.hooks
.submit_json_observer(HookEvent::SessionError, app.base_hook_context(), payload)
{
tracing::warn!("session-error hook submission failed: {error}");
}
}
pub(super) struct TurnEndObserverMetadata<'a> {
pub(super) turn_id: std::borrow::Cow<'a, str>,
pub(super) created_at: chrono::DateTime<chrono::Utc>,
@@ -266,3 +388,101 @@ pub(super) fn subagent_status_from_completion_result(result: &str) -> SubAgentSt
_ => SubAgentStatus::Completed,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::control_socket::TurnState;
use crate::tui::control_socket::turn_state_from_app;
#[test]
fn session_state_transition_table_fires_only_on_real_edges() {
use HookEvent::*;
assert_eq!(
session_state_transition_event(TurnState::InProgress, TurnState::Idle),
Some(SessionIdle)
);
assert_eq!(
session_state_transition_event(TurnState::Waiting, TurnState::Idle),
Some(SessionIdle)
);
assert_eq!(
session_state_transition_event(TurnState::Idle, TurnState::Waiting),
Some(WaitingForUser)
);
assert_eq!(
session_state_transition_event(TurnState::InProgress, TurnState::Waiting),
Some(WaitingForUser)
);
// No edge, or edges that are not attention transitions: silent.
assert_eq!(
session_state_transition_event(TurnState::Idle, TurnState::InProgress),
None
);
assert_eq!(
session_state_transition_event(TurnState::Idle, TurnState::Idle),
None
);
assert_eq!(
session_state_transition_event(TurnState::Waiting, TurnState::InProgress),
None
);
}
fn test_app() -> App {
let config = crate::config::Config::default();
App::new(
crate::test_support::test_tui_options(std::env::current_dir().unwrap()),
&config,
)
}
#[test]
fn turn_state_projection_covers_every_wait_on_the_person() {
let mut app = test_app();
assert_eq!(turn_state_from_app(&app), TurnState::Idle);
app.is_loading = true;
assert_eq!(turn_state_from_app(&app), TurnState::InProgress);
app.is_loading = false;
app.pending_user_input_prompt = Some((
"q1".to_string(),
crate::tools::user_input::UserInputRequest {
questions: Vec::new(),
},
));
assert_eq!(turn_state_from_app(&app), TurnState::Waiting);
assert_eq!(
session_wait_reason(&app),
Some(SessionWaitReason::UserInput)
);
app.pending_user_input_prompt = None;
app.goal_continuation_waiting = true;
assert_eq!(turn_state_from_app(&app), TurnState::Waiting);
assert_eq!(
session_wait_reason(&app),
Some(SessionWaitReason::GoalContinuation)
);
app.goal_continuation_waiting = false;
app.view_stack.push(
crate::tui::approval::ApprovalView::new_with_default_selection(
crate::tui::approval::ApprovalRequest::new_with_intent(
"a1",
"exec_shell",
"run the tests",
&serde_json::json!({"cmd": "cargo test"}),
"key",
None,
&app.workspace,
),
crate::localization::Locale::En,
crate::config::ApprovalDefaultSelection::default(),
),
);
assert_eq!(turn_state_from_app(&app), TurnState::Waiting);
assert_eq!(session_wait_reason(&app), Some(SessionWaitReason::Approval));
}
}
+667 -28
View File
@@ -588,23 +588,6 @@ fn permission_cycle_shortcut_accepts_both_shift_tab_encodings() {
)));
}
/// A live session in a deterministic focus state: no onboarding, no launch
/// screen, no modal, composer owns the keys.
#[test]
fn session_id_divergence_notice_names_both_ids_and_the_resume_command() {
// #5931: a diverged engine session id was only logged; the operator now
// sees which id the checkpoints moved to and how to reopen the old one.
let app = focus_test_app();
let text =
super::event_loop::session_id_divergence_notice(&app, "sess-old-1234", "sess-new-5678");
// Plain asserts on purpose: formatting the notice into a panic message
// trips CodeQL's cleartext-logging rule on the session ids it contains.
assert!(text.contains("sess-new-5678"));
assert!(text.contains("sess-old-1234"));
assert!(text.contains("codewhale resume sess-old-1234"));
assert!(text.contains("codewhale sessions"));
}
#[test]
fn runtime_store_failure_event_becomes_a_toast_naming_the_file_and_remedy() {
// #5931: an unreadable runtime store record was only a log line; the
@@ -1963,6 +1946,7 @@ fn workflow_panel_uses_non_text_keys_for_controls() {
}
struct ConfigPathEnvGuard {
_codewhale_home: crate::test_support::EnvVarGuard,
_codewhale_config_path: crate::test_support::EnvVarGuard,
_deepseek_config_path: crate::test_support::EnvVarGuard,
_tmp: TempDir,
@@ -1973,6 +1957,10 @@ impl ConfigPathEnvGuard {
fn new() -> Self {
let lock = crate::test_support::lock_test_env();
let tmp = TempDir::new().expect("config tempdir");
// Setup completion writes settings and its receipt under CODEWHALE_HOME
// independently of the config override (#5932).
let codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
let config_path = tmp.path().join(".deepseek").join("config.toml");
std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir");
let codewhale_config_path =
@@ -1980,6 +1968,7 @@ impl ConfigPathEnvGuard {
let deepseek_config_path =
crate::test_support::EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
Self {
_codewhale_home: codewhale_home,
_codewhale_config_path: codewhale_config_path,
_deepseek_config_path: deepseek_config_path,
_tmp: tmp,
@@ -2046,20 +2035,54 @@ impl SettingsHomeGuard {
}
#[test]
fn resume_hint_uses_canonical_resume_command() {
fn resume_hint_reconstructs_exact_command_for_canonical_uuid() {
let id = "019dd9d6-4f44-7c83-9863-59674a12b827";
let hint = resume_hint_text(crate::localization::Locale::En, Some(id), true);
assert_eq!(
resume_hint_text(),
"To continue this session, execute codewhale run --continue"
hint.as_deref(),
Some("To resume this session, run codewhale resume 019dd9d6-4f44-7c83-9863-59674a12b827")
);
assert!(should_show_resume_hint(Some(
"019dd9d6-4f44-7c83-9863-59674a12b827"
)));
}
#[test]
fn resume_hint_omits_missing_session_id() {
assert!(!should_show_resume_hint(None));
assert!(!should_show_resume_hint(Some(" ")));
fn resume_hint_falls_back_to_picker_for_noncanonical_ids() {
for id in [
"legacy-session-42",
// Uppercase and unhyphenated UUIDs parse but are not the canonical
// spelling, so they get the picker rather than an exact command.
"019DD9D6-4F44-7C83-9863-59674A12B827",
"019dd9d64f447c83986359674a12b827",
// Malicious stored identifiers must never reach terminal output.
"019dd9d6-4f44-7c83-9863-59674a12b827; rm -rf ~",
"$(reboot)",
"x\n\x1b[2J",
] {
assert_eq!(
resume_hint_text(crate::localization::Locale::En, Some(id), true).as_deref(),
Some("To choose a saved session, run codewhale resume"),
"id {id:?} must select the picker hint, never interpolation"
);
}
}
#[test]
fn resume_hint_omits_missing_id_and_non_tty_output() {
assert_eq!(
resume_hint_text(crate::localization::Locale::En, None, true),
None
);
assert_eq!(
resume_hint_text(crate::localization::Locale::En, Some(" "), true),
None
);
assert_eq!(
resume_hint_text(
crate::localization::Locale::En,
Some("019dd9d6-4f44-7c83-9863-59674a12b827"),
false,
),
None
);
}
#[test]
@@ -2071,6 +2094,10 @@ fn plain_mcp_show_refreshes_discovery_counts() {
"plain /mcp snapshots the engine-owned live pool, not a UI discovery pool"
);
assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));
assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Login {
name: "fixture".to_string(),
scopes: Vec::new(),
}));
assert!(
!mcp_ui_action_refreshes_discovery(&McpUiAction::Reload),
"reload is handled by the engine-owned live pool, not a UI discovery pool"
@@ -2080,6 +2107,283 @@ fn plain_mcp_show_refreshes_discovery_counts() {
}));
}
#[tokio::test]
async fn mcp_login_stalled_discovery_is_cancellable_and_repeat_clicks_keep_one_owner() {
use crate::tui::app::{McpLoginProgress, McpUiAction};
use tokio::io::AsyncReadExt;
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut app = create_test_app();
app.workspace = temp.path().to_path_buf();
app.mcp_config_path = temp.path().join("mcp.json");
crate::mcp::add_server_config(
&app.mcp_config_path,
"stalled".to_string(),
None,
Some(format!("http://{}/mcp", listener.local_addr().unwrap())),
Vec::new(),
None,
)
.unwrap();
let mock = mock_engine_handle();
let config = Config::default();
let login = || McpUiAction::Login {
name: "stalled".to_string(),
scopes: Vec::new(),
};
tokio::time::timeout(
Duration::from_millis(200),
handle_mcp_ui_action(&mut app, &mock.handle, &config, login()),
)
.await
.expect("the action must return before stalled discovery, leaving input responsive");
let pending = app.mcp_login.as_ref().unwrap();
let old_cell = Arc::clone(&pending.progress);
let token = pending.cancel.clone();
let (mut socket, _) = tokio::time::timeout(Duration::from_secs(2), listener.accept())
.await
.expect("local control reaches metadata discovery")
.unwrap();
let mut request = [0; 4096];
let count = tokio::time::timeout(Duration::from_secs(2), socket.read(&mut request))
.await
.unwrap()
.unwrap();
assert!(count > 0);
handle_mcp_ui_action(&mut app, &mock.handle, &config, login()).await;
assert!(Arc::ptr_eq(
&old_cell,
&app.mcp_login.as_ref().unwrap().progress
));
assert!(
app.status_toasts
.back()
.unwrap()
.text
.as_str()
.contains("already in progress")
);
open_mcp_extensions(&mut app);
let modal = app.view_stack.top_kind();
assert!(!handle_mcp_login_key(
&mut app,
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
));
assert!(handle_mcp_login_key(
&mut app,
&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
));
assert_eq!(
app.view_stack.top_kind(),
modal,
"Esc cancels before closing Extensions"
);
assert!(token.is_cancelled());
assert!(app.mcp_login.is_none());
assert_eq!(
tokio::time::timeout(Duration::from_secs(2), socket.read(&mut request))
.await
.expect("Esc drops the outstanding discovery request")
.unwrap(),
0,
);
handle_mcp_ui_action(&mut app, &mock.handle, &config, login()).await;
let new_pending = app.mcp_login.as_ref().unwrap();
assert!(!Arc::ptr_eq(&old_cell, &new_pending.progress));
let new_token = new_pending.cancel.clone();
*old_cell.lock().unwrap() = Some(McpLoginProgress::Finished(Ok(())));
poll_mcp_login(&mut app);
assert!(
app.mcp_login.is_some(),
"a stale completion cannot clear the new owner"
);
assert!(
!app.status_toasts
.back()
.unwrap()
.text
.as_str()
.contains("Stored OAuth")
);
drop(app);
assert!(
new_token.is_cancelled(),
"leaving the app cancels the operation too"
);
}
#[test]
fn mcp_login_progress_preserves_cancel_until_finish_and_ignores_cancelled_mailbox() {
use crate::tui::app::{McpLoginProgress, PendingMcpLogin};
let mut app = create_test_app();
let progress = Arc::new(Mutex::new(Some(McpLoginProgress::AuthorizationUrl(
"https://issuer.example/authorize?state=fixture".to_string(),
))));
let token = tokio_util::sync::CancellationToken::new();
app.mcp_login = Some(PendingMcpLogin {
server: "fixture".to_string(),
cancel: token.clone(),
progress: Arc::clone(&progress),
});
poll_mcp_login(&mut app);
assert!(app.mcp_login.is_some());
assert!(!token.is_cancelled());
assert!(
app.status_toasts
.back()
.unwrap()
.text
.as_str()
.contains("Esc cancels")
);
assert!(
app.status_toasts
.back()
.unwrap()
.text
.as_str()
.contains("state=fixture")
);
*progress.lock().unwrap() = Some(McpLoginProgress::Finished(Ok(())));
poll_mcp_login(&mut app);
assert!(app.mcp_login.is_none());
assert!(
app.status_toasts
.back()
.unwrap()
.text
.as_str()
.contains("Stored OAuth credentials")
);
assert!(token.is_cancelled());
let completed = app.status_toasts.back().unwrap().text.clone();
*progress.lock().unwrap() = Some(McpLoginProgress::Finished(Err("late failure".into())));
poll_mcp_login(&mut app);
assert_eq!(app.status_toasts.back().unwrap().text, completed);
}
#[tokio::test]
async fn mcp_login_background_handshake_preserves_network_default_deny() {
use crate::tui::app::McpUiAction;
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut app = create_test_app();
app.workspace = temp.path().to_path_buf();
app.mcp_config_path = temp.path().join("mcp.json");
crate::mcp::add_server_config(
&app.mcp_config_path,
"denied".to_string(),
None,
Some(format!("http://{}/mcp", listener.local_addr().unwrap())),
Vec::new(),
None,
)
.unwrap();
let config = Config {
network: Some(toml::from_str("default = \"deny\"").unwrap()),
..Config::default()
};
let mock = mock_engine_handle();
handle_mcp_ui_action(
&mut app,
&mock.handle,
&config,
McpUiAction::Login {
name: "denied".into(),
scopes: Vec::new(),
},
)
.await;
tokio::time::timeout(Duration::from_secs(2), async {
while app.mcp_login.is_some() {
poll_mcp_login(&mut app);
tokio::task::yield_now().await;
}
})
.await
.expect("policy denial completes without waiting for the server");
assert!(
app.status_toasts
.back()
.unwrap()
.text
.as_str()
.contains("network policy")
);
assert!(
tokio::time::timeout(Duration::from_millis(30), listener.accept())
.await
.is_err()
);
}
#[tokio::test]
async fn mcp_diagnose_reports_selected_last_observed_error_without_discovery() {
use crate::mcp::{McpManagerSnapshot, McpServerCapabilityMetadata, McpServerSnapshot};
use crate::tui::app::McpUiAction;
let mut app = create_test_app();
app.mcp_snapshot = Some(McpManagerSnapshot {
config_path: PathBuf::from("mcp.json"),
config_exists: true,
reload_required: false,
servers: vec![McpServerSnapshot {
name: "selected".into(),
enabled: true,
required: false,
transport: "http".into(),
command_or_url: "https://private-user:[email protected]/mcp".into(),
connect_timeout: 5,
execute_timeout: 5,
read_timeout: 5,
connected: false,
error: Some("metadata response could not be parsed".into()),
auth_required: false,
capability_metadata: McpServerCapabilityMetadata::NotObserved,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
}],
});
let mut mock = mock_engine_handle();
tokio::time::timeout(
Duration::from_millis(200),
handle_mcp_ui_action(
&mut app,
&mock.handle,
&Config::default(),
McpUiAction::Diagnose {
name: "selected".into(),
},
),
)
.await
.expect("Diagnose reports the observation without waiting for a new probe");
assert!(mock.rx_op.try_recv().is_err());
let receipt = app.status_toasts.back().unwrap().text.as_str();
assert!(receipt.contains("'selected'"));
assert!(receipt.contains("last observed: connection failed"));
assert!(receipt.contains("metadata response could not be parsed"));
assert!(receipt.contains("/mcp retry selected"));
assert!(!receipt.contains("private-password"));
assert!(!receipt.contains("refreshed"));
app.mcp_snapshot.as_mut().unwrap().servers[0].auth_required = true;
assert!(mcp_server_diagnosis(&app, "selected").contains("/mcp login selected"));
assert!(mcp_server_diagnosis(&app, "absent").contains("no observed connection state"));
}
#[tokio::test]
async fn mcp_enable_persists_and_applies_the_live_tool_pool_in_one_action() {
use crate::mcp::{McpManagerSnapshot, McpServerSnapshot};
@@ -6742,7 +7046,7 @@ fn saved_session_with_messages(messages: Vec<Message>) -> SavedSession {
SavedSession {
schema_version: 1,
metadata: crate::session_manager::SessionMetadata {
id: "resume-recovery-session".to_string(),
id: uuid::Uuid::new_v4().to_string(),
title: "resume recovery".to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
@@ -8285,6 +8589,15 @@ async fn successful_custom_provider_activation_completes_onboarding() {
complete_provider_picker_onboarding_if_switched(&mut app, ApiProvider::Custom, switched);
assert_eq!(app.api_provider, ApiProvider::Custom);
let fixture_home = config_env._tmp.path().join(".codewhale");
let fixture_settings = config_env
.config_path()
.parent()
.unwrap()
.join("settings.toml");
assert_eq!(crate::settings::Settings::path().unwrap(), fixture_settings);
assert!(fixture_settings.is_file());
assert!(fixture_home.join("setup_state.json").is_file());
assert_ne!(
app.onboarding,
OnboardingState::Provider,
@@ -10905,6 +11218,7 @@ fn pending_goal_control_waits_for_authoritative_receipt() {
.expect("valid paused target");
app.pending_goal_controls
.push_back(crate::tui::app::PendingGoalControl {
goal_id: None,
intent: crate::tui::app::GoalControlIntent::SetStatus {
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
@@ -11071,6 +11385,60 @@ fn apply_goal_snapshot_prints_a_receipt_when_the_runtime_sets_a_new_goal() {
);
}
#[test]
fn apply_goal_snapshot_prints_one_receipt_per_progress_report() {
let mut app = create_test_app();
let snapshot = crate::tools::goal::GoalSnapshot {
objective: Some("make the tests pass".to_string()),
status: "active".to_string(),
progress: Some(crate::tools::goal::GoalProgressReport {
percent: 12,
now: Some("implementing the composer slice".to_string()),
next: Some("screenshot-verify desktop/mobile".to_string()),
}),
..Default::default()
};
assert!(apply_goal_snapshot_to_app(&mut app, &snapshot));
let receipt = app
.history
.iter()
.find_map(|cell| match cell {
HistoryCell::System { content } if content.contains("Reported progress") => {
Some(content.clone())
}
_ => None,
})
.expect("progress receipt printed");
assert!(receipt.contains("12%"), "{receipt}");
assert!(receipt.contains('▓'), "{receipt}");
assert!(
receipt.contains("now implementing the composer slice"),
"{receipt}"
);
assert!(
receipt.contains("next screenshot-verify desktop/mobile"),
"{receipt}"
);
// The identical report does not reprint; a new percent does.
assert!(!apply_goal_snapshot_to_app(&mut app, &snapshot));
let mut advanced = snapshot.clone();
advanced.progress = Some(crate::tools::goal::GoalProgressReport {
percent: 34,
now: None,
next: None,
});
assert!(apply_goal_snapshot_to_app(&mut app, &advanced));
let receipts = app
.history
.iter()
.filter(
|cell| matches!(cell, HistoryCell::System { content } if content.contains("Reported progress")),
)
.count();
assert_eq!(receipts, 2);
}
#[test]
fn canonical_goal_clear_wins_after_stale_active_snapshot() {
let mut app = create_test_app();
@@ -17963,7 +18331,7 @@ fn custom_session_resume_requires_structural_route_not_client_construction() {
assert_eq!(
app.current_session_id.as_deref(),
Some("resume-recovery-session")
Some(session.metadata.id.as_str())
);
assert_eq!(app.api_messages, session.messages);
assert!(app.input.is_empty());
@@ -25203,3 +25571,274 @@ fn deleting_the_leading_slash_releases_the_command_claim() {
"the edited line submits normally"
);
}
async fn flush_offline_queue_test_actor(handle: &persistence_actor::PersistActorHandle) {
let (reply, receive) = tokio::sync::oneshot::channel();
assert!(handle.try_send(PersistRequest::FlushAndReport { reply }));
let report = receive.await.expect("queue durability report");
assert!(report.failures.is_empty(), "{:?}", report.failures);
}
#[tokio::test(flavor = "current_thread")]
async fn offline_queue_lifecycle_preserves_edits_and_explicit_reset() {
const PROBE: &str = "CODEWHALE_OFFLINE_QUEUE_LIFECYCLE_PROBE";
if std::env::var_os(PROBE).is_none() {
// The production actor is a process singleton. Run this complete UI
// lifecycle in its own process instead of replacing another test's
// actor or leaving a closed global sender behind.
let output = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"tui::ui::tests::offline_queue_lifecycle_preserves_edits_and_explicit_reset",
"--nocapture",
"--test-threads=1",
])
.env(PROBE, "1")
.output()
.expect("queue lifecycle subprocess");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
return;
}
let _environment = crate::test_support::lock_test_env();
let directory = tempfile::tempdir().expect("queue fixture");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", directory.path());
let manager = SessionManager::default_location().expect("session store");
for (id, text) in [("queue-A", "original A"), ("queue-B", "original B")] {
manager
.save_offline_queue_state(
&OfflineQueueState {
messages: vec![QueuedSessionMessage {
display: text.into(),
skill_instruction: None,
skill_provenance: None,
}],
..OfflineQueueState::default()
},
Some(id),
)
.expect("park initial queue");
}
let (handle, task) = persistence_actor::spawn_persistence_actor(
SessionManager::default_location().expect("actor store"),
);
persistence_actor::init_actor(handle.clone());
let mut app = create_test_app();
let transition = prepare_offline_queue_transition(&app, "queue-A").expect("claim A");
assert!(install_offline_queue_transition(&mut app, transition));
assert!(app.pop_last_queued_into_draft());
let original_projection = offline_queue_projection(&app);
app.input = "edited A\nsecond line".into();
assert_ne!(
offline_queue_projection(&app),
original_projection,
"composer edits must trigger the frame's queue change detector"
);
persist_offline_queue_state(&app);
flush_offline_queue_test_actor(&handle).await;
assert_eq!(
manager
.load_offline_queue_state("queue-A")
.unwrap()
.unwrap()
.draft
.unwrap()
.display,
"edited A\nsecond line"
);
assert_eq!(app.queued_draft.as_ref().unwrap().display, "original A");
assert!(app.cancel_queued_draft_edit());
assert_eq!(app.queued_messages.back().unwrap().display, "original A");
assert!(app.pop_last_queued_into_draft());
app.input = "survives reopening".into();
persist_offline_queue_state(&app);
flush_offline_queue_test_actor(&handle).await;
drop(app);
let mut app = create_test_app();
let transition = prepare_offline_queue_transition(&app, "queue-A").expect("reopen A");
assert!(install_offline_queue_transition(&mut app, transition));
assert_eq!(app.input, "survives reopening");
let transition = prepare_offline_queue_transition(&app, "queue-B").expect("switch to B");
assert!(install_offline_queue_transition(&mut app, transition));
assert_eq!(app.queued_messages.front().unwrap().display, "original B");
flush_offline_queue_test_actor(&handle).await;
drop(
manager
.acquire_offline_queue_lease("queue-A")
.expect("A released after its write"),
);
let mut contender = create_test_app();
let transition = prepare_offline_queue_transition(&contender, "queue-C").expect("claim C");
install_offline_queue_transition(&mut contender, transition);
contender.input = "keep this composer".into();
contender.queue_message(queued_session_to_ui(QueuedSessionMessage {
display: "keep C queue".into(),
skill_instruction: None,
skill_provenance: None,
}));
let mut session_b = saved_session_with_messages(vec![text_message("user", "B transcript")]);
session_b.metadata.id = "queue-B".into();
let error = apply_loaded_session(&mut contender, &mut Config::default(), &session_b)
.expect_err("B has another editor");
assert!(error.contains("already open"));
assert_eq!(contender.current_session_id.as_deref(), Some("queue-C"));
assert_eq!(contender.input, "keep this composer");
assert_eq!(
contender.queued_messages.front().unwrap().display,
"keep C queue"
);
assert!(contender.api_messages.is_empty());
// Saving a copy changes the output path, not the live engine/queue owner.
let mut before_save = create_saved_session_with_id_and_mode(
"queue-B".into(),
&[],
&app.model,
&app.workspace,
0,
None,
None,
);
before_save.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(10);
before_save.metadata.title = "User chosen title".into();
before_save.metadata.parent_session_id = Some("parent-session".into());
before_save.metadata.forked_from_message_count = Some(5);
before_save.metadata.archived = true;
manager
.save_session(&before_save)
.expect("saved lifecycle fixture");
app.current_session_metadata = Some(before_save.metadata.clone());
app.window_title = Some("User chosen tab".into());
let copy = directory.path().join("saved-copy.json");
let owner = app.offline_queue_lease.clone().unwrap();
let result = crate::commands::execute(&format!("/save {}", copy.display()), &mut app);
assert!(!result.is_error, "{:?}", result.message);
let saved: SavedSession = serde_json::from_slice(&std::fs::read(copy).unwrap()).unwrap();
assert_eq!(saved.metadata.id, "queue-B");
assert_eq!(saved.metadata.created_at, before_save.metadata.created_at);
assert_eq!(saved.metadata.title, before_save.metadata.title);
assert_eq!(
saved.metadata.parent_session_id,
before_save.metadata.parent_session_id
);
assert_eq!(saved.metadata.forked_from_message_count, Some(5));
assert!(saved.metadata.archived);
assert_eq!(saved.window_title.as_deref(), Some("User chosen tab"));
assert_eq!(app.current_session_id.as_deref(), Some("queue-B"));
assert!(Arc::ptr_eq(
&owner,
app.offline_queue_lease.as_ref().unwrap()
));
drop(owner);
let result = crate::commands::execute("/new --force", &mut app);
assert!(!result.is_error, "{:?}", result.message);
assert_ne!(app.current_session_id.as_deref(), Some("queue-B"));
flush_offline_queue_test_actor(&handle).await;
assert!(
manager
.load_offline_queue_state("queue-B")
.unwrap()
.is_none(),
"explicit reset must clear the captured old owner"
);
assert_eq!(
manager
.load_offline_queue_state("queue-A")
.unwrap()
.unwrap()
.draft
.unwrap()
.display,
"survives reopening"
);
// An unreadable queue cannot be turned into an empty queue by a failed
// resume, including a document produced by a newer version.
app.input = "preserve on failed resume".into();
let active = app.current_session_id.clone();
for (id, bytes) in [
("queue-corrupt", "{"),
("queue-future", "{\"schema_version\":999}"),
] {
let path = manager
.sessions_dir()
.join("checkpoints")
.join(format!("{id}.offline_queue.json"));
std::fs::write(&path, bytes).unwrap();
assert!(prepare_offline_queue_transition(&app, id).is_err());
assert_eq!(std::fs::read_to_string(path).unwrap(), bytes);
assert_eq!(app.current_session_id, active);
assert_eq!(app.input, "preserve on failed resume");
}
assert!(handle.try_send(PersistRequest::Shutdown));
task.await.expect("queue actor shutdown");
}
#[test]
fn stale_session_projection_cannot_rewind_a_host_launch() {
let _environment = crate::test_support::lock_test_env();
let mut app = create_test_app();
let previous = super::event_loop::ensure_runtime_session_id(&mut app);
let result = begin_launch_session(&mut app, None);
assert!(!result.is_error, "{:?}", result.message);
let current = app.current_session_id.clone().unwrap();
assert_ne!(current, previous);
app.api_messages = vec![text_message("user", "current host transcript")];
app.system_prompt = Some(SystemPrompt::Text("current prompt".into()));
app.input = "typing remains responsive".into();
let workspace = app.workspace.clone();
let model = app.model.clone();
let history_length = app.history.len();
let notice_count = app.status_toasts.len();
assert!(!super::event_loop::apply_engine_session_projection(
&mut app,
&Config::default(),
EngineEvent::SessionUpdated {
session_id: previous,
messages: vec![text_message("user", "stale transcript")],
system_prompt: Some(SystemPrompt::Text("stale prompt".into())),
model: "stale-model".into(),
workspace: PathBuf::from("/must-not-be-adopted"),
}
));
assert_eq!(app.current_session_id.as_deref(), Some(current.as_str()));
assert_eq!(
app.api_messages,
vec![text_message("user", "current host transcript")]
);
assert_eq!(
app.system_prompt,
Some(SystemPrompt::Text("current prompt".into()))
);
assert_eq!(app.workspace, workspace);
assert_eq!(app.model, model);
assert_eq!(app.input, "typing remains responsive");
assert_eq!(app.history.len(), history_length);
assert_eq!(app.status_toasts.len(), notice_count);
assert!(super::event_loop::apply_engine_session_projection(
&mut app,
&Config::default(),
EngineEvent::SessionUpdated {
session_id: current.clone(),
messages: vec![text_message("user", "expected engine transcript")],
system_prompt: None,
model,
workspace,
}
));
assert_eq!(app.current_session_id.as_deref(), Some(current.as_str()));
assert_eq!(
app.api_messages,
vec![text_message("user", "expected engine transcript")]
);
assert_eq!(app.input, "typing remains responsive");
}
+55
View File
@@ -474,6 +474,29 @@ the direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct
Arcee large-model API calls are tracked as 256K-context BF16 serving; Thinking
is reasoning-capable, while Preview is not marked as a thinking model.
### OpenRouter vendor pinning
OpenRouter serves each model through several upstream vendors, and Codewhale
passes the `model` string to OpenRouter verbatim — so OpenRouter's own
vendor-selection syntax works today in `[providers.openrouter] model` (or
`/model`), with no extra configuration (#6007):
```toml
provider = "openrouter"
model = "deepseek/deepseek-v4-pro:deepinfra" # pin the DeepInfra upstream
# model = "deepseek/deepseek-v4-pro:floor" # cheapest upstream
# model = "@preset/my-team-preset" # an account preset from the OpenRouter dashboard
```
The `:vendor` suffix pins one upstream vendor, `:floor` / `:ceil` bound its
price tier, and `@preset/...` resolves an account preset. Codewhale does not
fetch OpenRouter's per-vendor endpoint list and emits no `provider.order`
request field, so pricing and availability for a pinned vendor come from
OpenRouter's response, not from Codewhale's catalog: a pinned vendor may
bill at a different rate than the model's catalog row, in which case cost
surfaces report the routing-dependent missing-price reason rather than an
invented number.
### Custom OpenAI-Compatible Gateways
For a single third-party service that implements the OpenAI Chat Completions
@@ -2334,6 +2357,13 @@ max_continuations = 100
# for coordinator goals that should poll on a cadence instead of keeping one
# provider turn open. Default: 0 (continue immediately).
continuation_delay_seconds = 300
# Per-turn step allowance while a goal is active (#5994). Goal turns get a
# larger but still finite budget than an ordinary interactive turn.
# Default: 1000 (0 or absent resolves to 1000, never unlimited). Range:
# 1..=100,000. This bounds each provider turn, never the number of
# continuation passes.
max_steps = 1000
```
The effective delay is capped at 86,400 seconds (24 hours); use an automation
@@ -2343,6 +2373,15 @@ When an explicit backstop fires, the goal pauses with a status message naming
`[goal] max_continuations` and a warning is logged; resume the goal after
inspecting progress, or raise/disable the backstop.
`[goal] max_steps` governs one engine turn at a time: the ordinary interactive
ceiling (`max_steps`, default 200) is unchanged, and explicit per-invocation
ceilings — `exec --max-turns N`, child-worker caps — always win over it. At
about 80% of the selected budget the model is told to land; at exhaustion it
gets one bounded final report and the turn classifies as budget-exhausted. An
unfinished goal then pauses with the BudgetLimit reason instead of re-arming
another goal turn — resume it explicitly after reviewing the report. Wall-clock
and stream protections are separate and still apply.
The delay starts only after a successful turn while an explicitly created goal
is still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,
Esc, or Ctrl+C cancels a pending continuation before another provider request
@@ -2669,6 +2708,22 @@ schema (`minItems` / `maxItems`), its model-visible description, and the
payload validator. A rejected payload names the key to raise, so the model can
either resize the batch or tell the user which setting to change.
### User-input / approval wait timeout
Questions from `request_user_input` and approval decisions wait a bounded
time and then cancel with a timeout (#6003). The default is 300 seconds.
Raise it when you step away or read carefully, or set `0` to wait forever
(overnight automation, long human review).
```toml
[tools]
user_input_timeout_seconds = 300 # default 300; 0 disables the timeout; clamped to 86400 (24h)
```
The one key governs both the interactive question wait and the Runtime
approval-decision wait, and the wait is this table's only user-facing clock —
wall-clock and stream protections elsewhere are unaffected.
## Feature Flags
Feature flags live under the `[features]` table and are merged across profiles.
+10 -6
View File
@@ -250,12 +250,16 @@ row back to the transcript. A small tmux pane can hide both rows without
touching what `/statusline` composes.
`session_metrics` (on by default) paints the latency pair on the metrics
line: `ttft 1.5s` — the mean time to first streamed token — and `120 tok/s`,
provider-reported output tokens over streamed seconds. Both come from the
same accumulators `/status` prints in full (turns, steps, LLM and tool wall
time, cache hit, input), and a figure whose provider or runtime evidence has
not arrived is omitted rather than estimated. On narrow rows the pair sheds
before the cost and the context reading rather than truncating a number.
line: `ttft 1.5s` — the mean time to first streamed token — and `120 avg tok/s`,
the session's provider-reported output tokens divided by the measured request
seconds for those same calls. The rate includes connection setup, time to first
token and pauses within a response, and excludes tools and idle time between
calls. It measures effective request throughput, not decoder speed. Streaming
and non-streaming calls follow the same rule; receipts without individual
request timing are excluded from both tokens and time. While a request runs,
the last measured average stays visible. Both readings use the same
accumulators `/status` prints in full. Missing evidence is omitted rather than
estimated. On narrow rows the pair sheds before cost and context.
The transcript is the audit trail. When Codewhale reads files, runs commands,
or edits code, the action appears there. If a command fails, use the visible
+1 -1
View File
@@ -98,7 +98,7 @@ read-only parent has. Read-only is transitive through any delegation chain:
the clamp (`ChildAuthority::clamp` in `fleet/exact.rs`) intersects every
field with the narrower side, the deny-list union means a descendant can
never drop an ancestor's restriction, and `inherit_disallowed_tools: false`
cannot drop a posture denial (`is_posture_denial`). This is pinned by
cannot drop any operator or ancestor denial. This is pinned by
`a_read_only_parents_delegation_never_widens_authority` in
`crates/tui/src/fleet/exact.rs` tests.
+1 -1
View File
@@ -178,7 +178,7 @@ JSON 把凭据的 `source`(来源)与字面的 `availability`(可用性)
`status_items` 负责组合这两行;另有两个尺寸预设决定每行绘制多少。`[tui].posture_bar` 和 `[tui].metrics_line` 各接受 `full`(默认)、`compact` 或 `hidden`,也可以在运行时用 `/config posture_bar compact` 设置。`compact` 是该行走完最初几级舍弃阶梯后的样子:姿态栏保留权限与模式片区——以及属于建议而非装饰的容量警示——并舍弃时钟、计数和提示;指标行保留路由、上下文读数、成本和余额,并舍弃遥测与帮助提示。`hidden` 把该行交还给转录区。狭小的 tmux 面板可以隐藏两行而不动 `/statusline` 的组合。
`session_metrics`(默认开启)在指标行上绘制这一对延迟读数:`ttft 1.5s`(到首个流式 token 的平均时间)和 `120 tok/s`(提供商报告的输出 token 除以流式秒数)。两者来自 `/status` 完整打印的同一批累加器(turns、steps、LLM 与工具墙钟时间、缓存命中、输入);提供商或运行时证据尚未到达的数字会被省略而不是估算。在窄行上,这一对会先于成本和上下文读数被舍弃,而不是截断某个数字。
`session_metrics`(默认开启)在指标行上显示 `ttft 1.5s`(到首个流式 token 的平均时间)和 `120 平均 tok/s`(本次会话中提供商报告的输出 token 总数,除以同一批调用的实测请求总秒数)。速率包含连接建立、首 token 等待以及响应过程中的停顿,不包含工具执行和调用之间的空闲时间;它衡量请求的实际吞吐量,而非模型解码速度。流式和非流式调用使用相同规则;没有独立请求计时的回执,其 token 和时间都不计入。新请求进行时保留上次实测平均值。两项读数与 `/status` 共用累加器,缺少证据时省略而不估算。在窄行上,这一对会先于成本和上下文读数被舍弃。
转录区(对话记录)就是审计轨迹。当 Codewhale 读文件、跑命令或改代码时,动作会出现在那里。如果某条命令失败,把可见的失败输出作为你下一条指令的一部分,而不是从头再来。
+1 -1
View File
@@ -37,7 +37,7 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启
角色的默认值就是该角色*想要*的姿态,而父代理的有效姿态永远是天花板(子代理绝不会扩得比父代理更宽)。只读角色按意图扣留**工作区写入**;默认不拿走任何其他东西——每个角色都保留网络读取,`custom` 继承父代理的写入/网络/shell 姿态,并且只被它的显式工具列表或发起调用收窄。被聚焦的 worker 的头部会依据运行时自身的权限快照声明有效姿态(`scout · read-only · network · read-only shell`)。
**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。
**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉操作者或任何祖先代理的拒绝规则。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。
会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。