mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
5227cc3313
commit
f5755a8832
65 files changed
+4231
-826
No files matched your search
+1
-1
@@ -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%
|
||||
|
||||
@@ -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
@@ -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")
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}", ¬es));
|
||||
}
|
||||
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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t_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;
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 读文件、跑命令或改代码时,动作会出现在那里。如果某条命令失败,把可见的失败输出作为你下一条指令的一部分,而不是从头再来。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
Reference in new issue
Block a user