refactor(split): move workshop budget, route evidence and usage persistence below billing (S24a, N12, CW-6129) [skip ci]

N12: the ACTIVE_WORKSHOP slot, its install/read helpers and the
ActiveWorkshopTestGuard move verbatim from tools/large_output_router.rs to
`codewhale-runtime::active_workshop`; the test gate becomes the FD-04
predicate any(test, all(feature = "test-thread-scoped-state",
debug_assertions)) with the #6306 lock order unchanged; ~30 callers
repointed, no facade; route_budget no longer depends on tools.
S24a: `AutoRouteDecisionEvidence` moves to
`codewhale_models::auto_route_evidence`; the cost_status persistence
cluster (persistent usage sink registration and interactive-origin
reporting through SessionManager) moves to tui `usage_persistence.rs`, so
production cost_status has no SessionManager or model_routing references.
Unblocks S25 billing together with S24 proper (still open).

Implemented on Boat VM vm10 by the split-s22-provider session; integrated
by the coordinator on d9ccf8f3e4 (Linux x86_64):
- cargo check --workspace --all-targets --locked: clean
- codewhale-runtime 850, codewhale-models 106, feature_registry 2
  passed; tui filtered (cost_status, usage_persistence,
  large_output_router, route_budget, tools::file, model_routing,
  superfast, activity_detail, purge, compaction, subagent) 1449 passed,
  0 failed
Not run: full tui suite on this commit, macOS, Windows, clippy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 21:40:11 +00:00
1 parent d9ccf8f3e4
commit 98104b9737
30 files changed
+498 -468

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
+28
View File
@@ -0,0 +1,28 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// What a decision model answered and what it cost. Probabilities are basis
/// points (0..=10000) so the receipt stays `Eq` and never re-renders floats.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoRouteDecisionEvidence {
/// The tier the decision model chose (`fast` | `strong`).
pub choice: String,
pub probabilities_bp: BTreeMap<String, u16>,
pub confidence_bp: u16,
pub min_confidence_bp: u16,
/// `[auto] cost_saving` kept the fast tier because the strong tier's
/// probability was below the cost-saving floor.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cost_saving_kept_fast: bool,
/// The reasoning effort the decision applied, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<String>,
/// `usage.cost` exactly as the provider reported it (USD decimal).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_reported_cost_usd: Option<String>,
pub latency_ms: u64,
/// The dated model snapshot the provider echoed, when present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_model: Option<String>,
}
+1
View File
@@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
pub mod auto_route_evidence;
pub mod image_window;
pub mod reasoning_effort_tier;
pub mod schema_canonicalize;
+146
View File
@@ -0,0 +1,146 @@
use std::sync::{Mutex, OnceLock};
use codewhale_config::WorkshopConfig;
static ACTIVE_WORKSHOP: OnceLock<Mutex<WorkshopConfig>> = OnceLock::new();
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
static ACTIVE_WORKSHOP_TEST_SERIAL: OnceLock<Mutex<()>> = OnceLock::new();
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
std::thread_local! {
static ACTIVE_WORKSHOP_TEST_SERIAL_HELD: std::cell::Cell<bool> = const {
std::cell::Cell::new(false)
};
}
/// Holds every test-side workshop activation behind one process-wide gate.
/// The thread-local marker lets the owning current-thread test call
/// `install_active` without trying to acquire its own non-reentrant lock.
///
/// The guard also owns the process-wide test env barrier, because holding this
/// gate across a `reload_config` is holding it across `Settings::load` and
/// `Config`'s env reads, which take that barrier (#6306). Field order is the
/// release order: the serial gate first, then the barrier.
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
pub struct ActiveWorkshopTestGuard {
_serial: std::sync::MutexGuard<'static, ()>,
_env: Option<crate::test_support::TestEnvLock>,
}
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
impl Drop for ActiveWorkshopTestGuard {
fn drop(&mut self) {
ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(|held| held.set(false));
}
}
/// Take the workshop gate, and the env barrier under it, in that order (#6306).
///
/// A holder of this gate keeps it across whole product calls — `reload_config`
/// installs the budgets and then loads settings — and those calls read the
/// environment through `test_support::with_test_env_lock`. A test that sealed
/// the environment first and then reaches `install_active` through the very
/// same `reload_config` takes the two locks in the opposite order, and the
/// pair wedges the whole binary: `cargo test -p codewhale-tui --lib config`
/// never returned, with dozens of unrelated tests queued on the barrier.
/// Acquiring the barrier here, before the gate, makes that inversion
/// impossible for every caller at once — the same shape as
/// `provider_lake::lock_live_snapshot`.
///
/// Known limitation: this orders these two locks and nothing else. A future
/// process-wide test gate held across product code has to join the same order
/// rather than invent a third one.
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
pub fn active_workshop_test_guard() -> ActiveWorkshopTestGuard {
assert!(
!ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get),
"active workshop test guard is not reentrant"
);
let env = if crate::test_support::current_thread_holds_test_env_lock() {
None
} else {
Some(crate::test_support::lock_test_env())
};
let serial = ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(|held| held.set(true));
ActiveWorkshopTestGuard {
_serial: serial,
_env: env,
}
}
fn active_workshop_slot() -> &'static Mutex<WorkshopConfig> {
ACTIVE_WORKSHOP.get_or_init(|| Mutex::new(WorkshopConfig::default()))
}
/// Install the process-wide workshop budgets used by read/tool compactors.
///
/// The returned immutable receipt is the snapshot written while the
/// singleton lock was held. Callers that need evidence of their own
/// activation can inspect it without racing a later process-wide update.
pub fn install_active_workshop(config: Option<&WorkshopConfig>) -> WorkshopConfig {
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
let _test_serial = if ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get) {
None
} else {
Some(
ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
};
let snapshot = config.cloned().unwrap_or_default();
let mut slot = active_workshop_slot()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = snapshot;
slot.clone()
}
/// Optional model-visible read budget, when the user opted in (#5367).
#[must_use]
pub fn active_read_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.read_result_max_bytes.filter(|n| *n > 0))
}
/// Optional model-visible tool-result budget, when the user opted in (#5367).
#[must_use]
pub fn active_tool_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.tool_result_max_bytes.filter(|n| *n > 0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workshop_byte_budgets_raise_floor_only() {
let _guard = active_workshop_test_guard();
let installed = install_active_workshop(Some(&WorkshopConfig {
large_output_threshold_tokens: None,
per_tool_thresholds: None,
read_result_max_bytes: Some(102_400),
tool_result_max_bytes: Some(80_000),
}));
assert_eq!(installed.read_result_max_bytes, Some(102_400));
assert_eq!(installed.tool_result_max_bytes, Some(80_000));
assert_eq!(active_read_result_max_bytes(), Some(102_400));
assert_eq!(active_tool_result_max_bytes(), Some(80_000));
let cleared = install_active_workshop(None);
assert_eq!(cleared.read_result_max_bytes, None);
assert_eq!(cleared.tool_result_max_bytes, None);
assert_eq!(active_read_result_max_bytes(), None);
assert_eq!(active_tool_result_max_bytes(), None);
}
}
+1
View File
@@ -12,6 +12,7 @@
pub use codewhale_command_contract::elapsed;
pub mod active_workshop;
pub mod audit;
pub mod browser;
pub mod child_env;
+1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
+2 -2
View File
@@ -2131,7 +2131,7 @@ async fn create_summary(
&response.usage,
);
} else {
crate::cost_status::report_effective_route_for_interactive_origin(
crate::usage_persistence::report_effective_route_for_interactive_origin(
*scope,
session,
agent,
@@ -2148,7 +2148,7 @@ async fn create_summary(
&cost_route,
);
} else {
crate::cost_status::report_unreceipted_for_interactive_origin(
crate::usage_persistence::report_unreceipted_for_interactive_origin(
*scope,
session,
agent,
+1 -1
View File
@@ -6980,7 +6980,7 @@ impl Engine {
&& self.config.compaction.runtime_cost_owner.is_none()
{
let owner = format!("interactive:{}:{}", self.session.id, turn.id);
crate::cost_status::register_persistent_interactive_runtime_usage_sink(
crate::usage_persistence::register_persistent_interactive_runtime_usage_sink(
&owner,
crate::cost_status::scope_token(),
&self.session.id,
+2 -2
View File
@@ -792,7 +792,7 @@ impl CapturedRlmCaller {
usage,
);
} else {
crate::cost_status::report_effective_route_for_interactive_origin(
crate::usage_persistence::report_effective_route_for_interactive_origin(
self.origin_scope,
&self.origin_session,
&self.origin_turn,
@@ -810,7 +810,7 @@ impl CapturedRlmCaller {
reason,
);
} else {
crate::cost_status::report_missing_usage_for_interactive_origin(
crate::usage_persistence::report_missing_usage_for_interactive_origin(
self.origin_scope,
&self.origin_session,
&self.origin_turn,
+10 -261
View File
@@ -1196,7 +1196,7 @@ pub struct RuntimeDecisionReceipt {
pub usage_complete: bool,
pub shadow: bool,
pub valid_answers: bool,
pub evidence: crate::model_routing::AutoRouteDecisionEvidence,
pub evidence: codewhale_models::auto_route_evidence::AutoRouteDecisionEvidence,
}
impl RuntimeDecisionReceipt {
@@ -1303,7 +1303,7 @@ pub(crate) fn decision_receipt_fixture(source_id: &str) -> RuntimeDecisionReceip
usage_complete: true,
shadow: true,
valid_answers: false,
evidence: crate::model_routing::AutoRouteDecisionEvidence {
evidence: codewhale_models::auto_route_evidence::AutoRouteDecisionEvidence {
choice: "invalid".to_string(),
probabilities_bp: Default::default(),
confidence_bp: 0,
@@ -1629,7 +1629,7 @@ fn record_runtime_decision(owner: &str, record: &RuntimeDecisionReceipt) {
});
}
fn report_interactive_decision(scope: CostScopeToken, receipt: &RuntimeDecisionReceipt) {
pub(crate) fn report_interactive_decision(scope: CostScopeToken, receipt: &RuntimeDecisionReceipt) {
if !receipt.is_bounded() {
return;
}
@@ -1685,120 +1685,6 @@ pub(crate) fn register_interactive_runtime_usage_sink(owner: &str, scope: CostSc
);
}
/// Install an interactive sink whose stale-scope fallback is an origin-session
/// sidecar. `/new` may close the foreground pool while a detached provider call
/// is still running; the sidecar keeps that exact response with the old saved
/// session instead of either dropping it or contaminating the new one.
pub(crate) fn register_persistent_interactive_runtime_usage_sink(
owner: &str,
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
) {
let Ok(manager) = crate::session_manager::SessionManager::default_location() else {
// With no durable origin gate, leave the owner on the bounded journal
// fallback. An in-memory-only sink could keep accepting a deleted
// session's responses after its directory becomes available again.
return;
};
register_persistent_interactive_runtime_usage_sink_at(
owner,
scope,
session_id,
turn_id,
manager.sessions_dir().to_path_buf(),
);
}
fn register_persistent_interactive_runtime_usage_sink_at(
owner: &str,
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
sessions_dir: std::path::PathBuf,
) {
let usage_session_id = session_id.to_string();
let usage_turn_id = turn_id.to_string();
let drop_session_id = usage_session_id.clone();
let drop_turn_id = usage_turn_id.clone();
let usage_sessions_dir = sessions_dir.clone();
let decision_sessions_dir = sessions_dir.clone();
let decision_session_id = session_id.to_string();
let decision_turn_id = turn_id.to_string();
register_runtime_usage_sink_with_drop(
owner,
Arc::new(move |record| {
crate::session_manager::SessionManager::new(usage_sessions_dir.clone())
.map(|manager| {
report_effective_route_for_interactive_origin_with_manager(
scope,
&usage_session_id,
&usage_turn_id,
&record.source_id,
&record.usage.route,
&record.usage.usage,
&manager,
)
})
.unwrap_or(false)
}),
Some(Arc::new(move |record| {
crate::session_manager::SessionManager::new(sessions_dir.clone())
.map(|manager| {
report_missing_usage_for_interactive_origin_with_manager(
scope,
&drop_session_id,
&drop_turn_id,
&record.source_id,
&record.route,
record.reason,
&manager,
)
})
.unwrap_or(false)
})),
);
register_runtime_decision_sink(
owner,
Arc::new(move |receipt| {
let Ok(manager) =
crate::session_manager::SessionManager::new(decision_sessions_dir.clone())
else {
return false;
};
let accepted = manager
.persist_late_decision_receipt(&decision_session_id, &decision_turn_id, &receipt)
.unwrap_or(false);
if accepted {
// The append may have been handled by a deletion tombstone. Only
// a live origin may enter the foreground diagnostic projection.
let _ = manager.with_live_session_origin(&decision_session_id, || {
report_interactive_decision(scope, &receipt);
true
});
}
accepted
}),
);
}
#[cfg(test)]
pub(crate) fn register_persistent_interactive_runtime_usage_sink_for_test(
owner: &str,
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
manager: &crate::session_manager::SessionManager,
) {
register_persistent_interactive_runtime_usage_sink_at(
owner,
scope,
session_id,
turn_id,
manager.sessions_dir().to_path_buf(),
);
}
/// Acquire an owner lease for a root sub-agent runtime. Runtime clones inherit
/// the lease, so top-level detached children can outlive the parent mailbox
/// without losing their accounting path.
@@ -1974,7 +1860,7 @@ pub(crate) fn restore_usage_source_ledger(
/// Mark a deleted origin's response handled in its original live generation.
/// This suppresses legacy mailbox fallback without putting deleted usage or
/// even its fingerprint into the pending pool or a durable session snapshot.
fn acknowledge_retired_usage_source(scope: CostScopeToken, source_id: &str) {
pub(crate) fn acknowledge_retired_usage_source(scope: CostScopeToken, source_id: &str) {
with_pending_state_mut(|state| {
if state.generation == scope.0 {
state
@@ -2197,147 +2083,6 @@ pub fn report_effective_route_for_runtime(
}
}
/// Report an interactive auxiliary response against its immutable origin.
/// A stale foreground scope is not an error: it means `/new` or session load
/// already moved on, so the exact receipt is appended to the old session's
/// durable sidecar instead of being redirected to the active session.
pub(crate) fn report_effective_route_for_interactive_origin(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
usage: &Usage,
) {
let persisted =
crate::session_manager::SessionManager::default_location().is_ok_and(|manager| {
report_effective_route_for_interactive_origin_with_manager(
scope, session_id, turn_id, source_id, route, usage, &manager,
)
});
if !persisted {
tracing::warn!("late interactive usage could not be persisted for its origin session");
}
}
fn report_effective_route_for_interactive_origin_with_manager(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
usage: &Usage,
manager: &crate::session_manager::SessionManager,
) -> bool {
let record = RuntimeUsageRecord {
source_id: source_id.to_string(),
usage: EffectiveRouteUsage {
route: route.sanitized_for_persistence(),
usage: usage.clone(),
},
};
match manager.with_live_session_origin(session_id, || {
record_interactive_runtime_usage(scope, record.clone())
}) {
Ok(None) => {
acknowledge_retired_usage_source(scope, source_id);
return true;
}
Ok(Some(true)) => return true,
Ok(Some(false)) => {}
Err(_) => return false,
}
manager
.persist_late_runtime_usage(session_id, turn_id, &record)
.unwrap_or(false)
}
pub(crate) fn report_unreceipted_for_interactive_origin(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
) {
report_missing_usage_for_interactive_origin(
scope,
session_id,
turn_id,
source_id,
route,
RuntimeUsageMissingReason::SuccessWithoutUsage,
);
}
pub(crate) fn report_missing_usage_for_interactive_origin(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
reason: RuntimeUsageMissingReason,
) {
let persisted =
crate::session_manager::SessionManager::default_location().is_ok_and(|manager| {
report_missing_usage_for_interactive_origin_with_manager(
scope, session_id, turn_id, source_id, route, reason, &manager,
)
});
if !persisted {
tracing::warn!(
"late interactive missing-usage receipt could not be persisted for its origin session"
);
}
}
#[cfg(test)]
fn report_unreceipted_for_interactive_origin_with_manager(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
manager: &crate::session_manager::SessionManager,
) -> bool {
report_missing_usage_for_interactive_origin_with_manager(
scope,
session_id,
turn_id,
source_id,
route,
RuntimeUsageMissingReason::SuccessWithoutUsage,
manager,
)
}
fn report_missing_usage_for_interactive_origin_with_manager(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
reason: RuntimeUsageMissingReason,
manager: &crate::session_manager::SessionManager,
) -> bool {
let record = RuntimeUsageDropRecord {
reason,
source_id: source_id.to_string(),
route: route.sanitized_for_persistence(),
};
match manager.with_live_session_origin(session_id, || {
record_interactive_runtime_usage_drop(scope, record.clone())
}) {
Ok(None) => {
acknowledge_retired_usage_source(scope, source_id);
return true;
}
Ok(Some(true)) => return true,
Ok(Some(false)) => {}
Err(_) => return false,
}
manager
.persist_late_runtime_drop(session_id, turn_id, &record)
.unwrap_or(false)
}
/// Record one provider-success response whose usage payload was absent.
///
/// Callers must supply the same fixed-length, non-secret source identity they
@@ -2568,7 +2313,10 @@ fn record(scope: CostScopeToken, route_receipt: String, audit: &TurnCostAudit, u
});
}
fn record_interactive_runtime_usage(scope: CostScopeToken, record: RuntimeUsageRecord) -> bool {
pub(crate) fn record_interactive_runtime_usage(
scope: CostScopeToken,
record: RuntimeUsageRecord,
) -> bool {
if record.usage.usage == Usage::default() {
return record_interactive_runtime_usage_drop(
scope,
@@ -2623,7 +2371,7 @@ fn record_interactive_runtime_usage(scope: CostScopeToken, record: RuntimeUsageR
true
})
}
fn record_interactive_runtime_usage_drop(
pub(crate) fn record_interactive_runtime_usage_drop(
scope: CostScopeToken,
record: RuntimeUsageDropRecord,
) -> bool {
@@ -2821,6 +2569,7 @@ pub(crate) fn test_scope() -> TestCostScope {
#[cfg(test)]
mod tests {
use super::*;
use crate::usage_persistence::*;
fn configured_fixture_receipt() -> (
codewhale_runtime::config::Config,
+1 -1
View File
@@ -814,7 +814,7 @@ pub(crate) async fn run_exec_agent(
.tag()
.to_string(),
workshop: {
crate::tools::large_output_router::install_active_workshop(config.workshop.as_ref());
codewhale_runtime::active_workshop::install_active_workshop(config.workshop.as_ref());
config.workshop.clone()
},
search_provider: execution_config.search_provider(),
+1
View File
@@ -35,6 +35,7 @@ mod conformance;
mod context_report;
mod core;
mod cost_status;
mod usage_persistence;
/// Guarded installation I/O reusing Engine's confined file primitives.
pub mod delivery_files;
mod doctor;
+1 -25
View File
@@ -19,6 +19,7 @@ use crate::model_inventory::{ModelInventory, probability_bp};
use crate::reasoning_preference::ReasoningEffort;
use codewhale_config::ProviderKind;
use codewhale_models::Role;
use codewhale_models::auto_route_evidence::AutoRouteDecisionEvidence;
use codewhale_models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt};
use codewhale_runtime::config::{
AutoRouterKind, Config, ProviderIdentity, normalize_model_name_for_provider,
@@ -557,31 +558,6 @@ pub(crate) struct AutoRouteReceipt {
pub(crate) router_failure: Option<AutoRouterFailure>,
}
/// What a decision model answered and what it cost. Probabilities are basis
/// points (0..=10000) so the receipt stays `Eq` and never re-renders floats.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoRouteDecisionEvidence {
/// The tier the decision model chose (`fast` | `strong`).
pub(crate) choice: String,
pub(crate) probabilities_bp: BTreeMap<String, u16>,
pub(crate) confidence_bp: u16,
pub(crate) min_confidence_bp: u16,
/// `[auto] cost_saving` kept the fast tier because the strong tier's
/// probability was below the cost-saving floor.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(crate) cost_saving_kept_fast: bool,
/// The reasoning effort the decision applied, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) thinking: Option<String>,
/// `usage.cost` exactly as the provider reported it (USD decimal).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) provider_reported_cost_usd: Option<String>,
pub(crate) latency_ms: u64,
/// The dated model snapshot the provider echoed, when present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) response_model: Option<String>,
}
/// Non-secret failure class for a configured router. Provider error bodies
/// never enter this type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+1 -1
View File
@@ -737,7 +737,7 @@ pub async fn run_purge(
.unwrap_or_default(),
response.id
);
crate::cost_status::report_effective_route_for_interactive_origin(
crate::usage_persistence::report_effective_route_for_interactive_origin(
cost_scope,
session_id,
&source_id,
+1 -1
View File
@@ -98,7 +98,7 @@ const TOOL_OUTPUT_INLINE_OPT_IN_MAX_CHARS: usize = 2 * 1024 * 1024;
pub(crate) fn route_inline_char_budget(window_tokens: Option<u32>) -> usize {
route_inline_char_budget_with_raise(
window_tokens,
crate::tools::large_output_router::active_tool_result_max_bytes(),
codewhale_runtime::active_workshop::active_tool_result_max_bytes(),
)
}
+1 -1
View File
@@ -1648,7 +1648,7 @@ fn replay_flag_is_absent(replayed: &bool) -> bool {
}
fn install_runtime_server_workshop_budgets(config: &Config) -> codewhale_config::WorkshopConfig {
crate::tools::large_output_router::install_active_workshop(config.workshop.as_ref())
codewhale_runtime::active_workshop::install_active_workshop(config.workshop.as_ref())
}
#[cfg(test)]
+6 -6
View File
@@ -164,7 +164,7 @@ fn runtime_request_model_uses_the_active_provider_default() {
#[tokio::test(flavor = "current_thread")]
async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_budgets() -> Result<()>
{
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let temp = tempfile::tempdir()?;
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace)?;
@@ -194,11 +194,11 @@ async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_bu
assert_eq!(startup_activation.read_result_max_bytes, Some(73_728));
assert_eq!(startup_activation.tool_result_max_bytes, Some(65_536));
assert_eq!(
crate::tools::large_output_router::active_read_result_max_bytes(),
codewhale_runtime::active_workshop::active_read_result_max_bytes(),
Some(73_728)
);
assert_eq!(
crate::tools::large_output_router::active_tool_result_max_bytes(),
codewhale_runtime::active_workshop::active_tool_result_max_bytes(),
Some(65_536)
);
@@ -217,14 +217,14 @@ async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_bu
assert_eq!(reload_activation.read_result_max_bytes, Some(81_920));
assert_eq!(reload_activation.tool_result_max_bytes, Some(77_824));
assert_eq!(
crate::tools::large_output_router::active_read_result_max_bytes(),
codewhale_runtime::active_workshop::active_read_result_max_bytes(),
Some(81_920)
);
assert_eq!(
crate::tools::large_output_router::active_tool_result_max_bytes(),
codewhale_runtime::active_workshop::active_tool_result_max_bytes(),
Some(77_824)
);
crate::tools::large_output_router::install_active_workshop(None);
codewhale_runtime::active_workshop::install_active_workshop(None);
Ok(())
}
@@ -15,7 +15,7 @@ struct CatalogReset;
impl Drop for CatalogReset {
fn drop(&mut self) {
cold_process();
crate::tools::large_output_router::install_active_workshop(None);
codewhale_runtime::active_workshop::install_active_workshop(None);
}
}
@@ -114,7 +114,7 @@ fn headless_startup_publishes_cold_endpoint_catalog_capabilities() -> Result<()>
// gate and holds both (#6306), so it is the whole lock acquisition. Taking
// `lock_test_env()` again here parks this thread on a non-reentrant mutex
// it already owns; the ordering that #6049 hand-rolled now lives in the guard.
let _workshop = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop = codewhale_runtime::active_workshop::active_workshop_test_guard();
let _offline = EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1");
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir()?;
@@ -147,7 +147,7 @@ fn headless_startup_publishes_cold_endpoint_catalog_capabilities() -> Result<()>
async fn headless_reload_publishes_only_the_accepted_identity_and_endpoint() -> Result<()> {
// The guard owns the env barrier and the workshop gate — see the
// headless_startup twin.
let _workshop = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop = codewhale_runtime::active_workshop::active_workshop_test_guard();
let _offline = EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1");
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir()?;
+1 -1
View File
@@ -8746,7 +8746,7 @@ impl RuntimeThreadManager {
// byte limits are active. Rejected reloads leave the prior limits in
// place.
new_config.retain_compatible_session_keys();
let workshop_activation = crate::tools::large_output_router::install_active_workshop(
let workshop_activation = codewhale_runtime::active_workshop::install_active_workshop(
new_config.workshop.as_ref(),
);
codewhale_runtime::config::initialize_cloud_facts(&new_config);
+1 -1
View File
@@ -417,7 +417,7 @@ async fn evaluate(
.is_some_and(|u| u.complete && (u.input_tokens > 0 || u.output_tokens > 0)),
shadow: true,
valid_answers: response.answers_validated != Some(false),
evidence: crate::model_routing::AutoRouteDecisionEvidence {
evidence: codewhale_models::auto_route_evidence::AutoRouteDecisionEvidence {
choice: intent
.as_ref()
.map_or_else(|| "invalid".to_string(), |v| v.choice.clone()),
+2 -2
View File
@@ -626,7 +626,7 @@ async fn load_contract_source(
/// request never shrinks a budget the operator widened, and an operator who
/// widened it process-wide keeps that floor when the model asks for less.
fn effective_read_max_bytes(requested: Option<usize>) -> usize {
let baseline = crate::tools::large_output_router::active_read_result_max_bytes()
let baseline = codewhale_runtime::active_workshop::active_read_result_max_bytes()
.map_or(READ_DEFAULT_MAX_BYTES, |configured| {
configured.clamp(READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES)
});
@@ -1376,7 +1376,7 @@ fn render_line_window(
// short head (budget/5) plus the matching tail so the model sees both
// ends of a long range. The full file already lives at `path_str` — the
// recovery note names that absolute/workspace path for a re-read.
let visible_bytes = crate::tools::large_output_router::active_read_result_max_bytes()
let visible_bytes = codewhale_runtime::active_workshop::active_read_result_max_bytes()
.map(|n| n.clamp(MAX_VISIBLE_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES))
.unwrap_or(MAX_VISIBLE_BYTES);
let truncated_by_bytes = numbered.len() > visible_bytes;
+11 -11
View File
@@ -56,7 +56,7 @@ async fn read_file_missing_pdftotext_is_a_failed_typed_outcome() {
/// no reason. Bytes are now the only bound.
#[tokio::test]
async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whole() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let content = (0..5_000)
.map(|index| format!("line-{index}"))
.collect::<Vec<_>>()
@@ -110,9 +110,9 @@ fn contract_read_byte_limit_keeps_only_complete_utf8_lines() {
#[test]
fn read_budget_precedence_is_request_then_workshop_then_default() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
crate::tools::large_output_router::install_active_workshop(None);
codewhale_runtime::active_workshop::install_active_workshop(None);
assert_eq!(effective_read_max_bytes(None), READ_DEFAULT_MAX_BYTES);
assert_eq!(effective_read_max_bytes(Some(250_000)), 250_000);
// Above the model-requestable maximum clamps down instead of erroring.
@@ -123,7 +123,7 @@ fn read_budget_precedence_is_request_then_workshop_then_default() {
// A request below the active baseline leaves the baseline in place.
assert_eq!(effective_read_max_bytes(Some(10)), READ_DEFAULT_MAX_BYTES);
crate::tools::large_output_router::install_active_workshop(Some(
codewhale_runtime::active_workshop::install_active_workshop(Some(
&codewhale_config::WorkshopConfig {
read_result_max_bytes: Some(700_000),
..Default::default()
@@ -132,7 +132,7 @@ fn read_budget_precedence_is_request_then_workshop_then_default() {
assert_eq!(effective_read_max_bytes(None), 700_000);
assert_eq!(effective_read_max_bytes(Some(200_000)), 700_000);
// The workshop override keeps the 2 MiB absolute ceiling.
crate::tools::large_output_router::install_active_workshop(Some(
codewhale_runtime::active_workshop::install_active_workshop(Some(
&codewhale_config::WorkshopConfig {
read_result_max_bytes: Some(READ_RESULT_ABSOLUTE_MAX_BYTES * 4),
..Default::default()
@@ -142,12 +142,12 @@ fn read_budget_precedence_is_request_then_workshop_then_default() {
effective_read_max_bytes(None),
READ_RESULT_ABSOLUTE_MAX_BYTES
);
crate::tools::large_output_router::install_active_workshop(None);
codewhale_runtime::active_workshop::install_active_workshop(None);
}
#[tokio::test]
async fn contract_read_max_bytes_raises_the_budget_for_one_call() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
let line = "y".repeat(199);
let content = std::iter::repeat_n(line.as_str(), 1_500)
@@ -187,7 +187,7 @@ async fn contract_read_max_bytes_raises_the_budget_for_one_call() {
#[tokio::test]
async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
// 999-byte lines: 100 of them plus their 99 separators are 99 999 bytes,
// one under the 100 000-byte budget, so page one is exactly lines 1-100.
@@ -235,7 +235,7 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer(
/// file.
#[tokio::test]
async fn contract_read_reports_size_and_truncation_for_huge_files() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
let line = "x".repeat(99);
let content = std::iter::repeat_n(line.as_str(), 110_000)
@@ -280,7 +280,7 @@ async fn contract_read_reports_size_and_truncation_for_huge_files() {
/// terminates with an untruncated page whose union is the whole file.
#[tokio::test]
async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
let content = (0..3_000)
.map(|index| format!("paged-line-{index:05}"))
@@ -388,7 +388,7 @@ async fn grep_then_read_flow_targets_matched_lines() {
#[tokio::test]
async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
let _workshop_guard = codewhale_runtime::active_workshop::active_workshop_test_guard();
let temporary = tempfile::tempdir().expect("tempdir");
std::fs::write(
temporary.path().join("huge.txt"),
-141
View File
@@ -10,7 +10,6 @@
use std::io;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
@@ -33,126 +32,6 @@ pub const DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS: usize = 32_768;
/// on the side of routing rather than dumping raw data into the parent.
const CHARS_PER_TOKEN_ESTIMATE: usize = 3;
static ACTIVE_WORKSHOP: OnceLock<Mutex<WorkshopConfig>> = OnceLock::new();
#[cfg(test)]
static ACTIVE_WORKSHOP_TEST_SERIAL: OnceLock<Mutex<()>> = OnceLock::new();
#[cfg(test)]
std::thread_local! {
static ACTIVE_WORKSHOP_TEST_SERIAL_HELD: std::cell::Cell<bool> = const {
std::cell::Cell::new(false)
};
}
/// Holds every test-side workshop activation behind one process-wide gate.
/// The thread-local marker lets the owning current-thread test call
/// `install_active` without trying to acquire its own non-reentrant lock.
///
/// The guard also owns the process-wide test env barrier, because holding this
/// gate across a `reload_config` is holding it across `Settings::load` and
/// `Config`'s env reads, which take that barrier (#6306). Field order is the
/// release order: the serial gate first, then the barrier.
#[cfg(test)]
pub(crate) struct ActiveWorkshopTestGuard {
_serial: std::sync::MutexGuard<'static, ()>,
_env: Option<crate::test_support::TestEnvLock>,
}
#[cfg(test)]
impl Drop for ActiveWorkshopTestGuard {
fn drop(&mut self) {
ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(|held| held.set(false));
}
}
/// Take the workshop gate, and the env barrier under it, in that order (#6306).
///
/// A holder of this gate keeps it across whole product calls — `reload_config`
/// installs the budgets and then loads settings — and those calls read the
/// environment through `test_support::with_test_env_lock`. A test that sealed
/// the environment first and then reaches `install_active` through the very
/// same `reload_config` takes the two locks in the opposite order, and the
/// pair wedges the whole binary: `cargo test -p codewhale-tui --lib config`
/// never returned, with dozens of unrelated tests queued on the barrier.
/// Acquiring the barrier here, before the gate, makes that inversion
/// impossible for every caller at once — the same shape as
/// `provider_lake::lock_live_snapshot`.
///
/// Known limitation: this orders these two locks and nothing else. A future
/// process-wide test gate held across product code has to join the same order
/// rather than invent a third one.
#[cfg(test)]
pub(crate) fn active_workshop_test_guard() -> ActiveWorkshopTestGuard {
assert!(
!ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get),
"active workshop test guard is not reentrant"
);
let env = if crate::test_support::current_thread_holds_test_env_lock() {
None
} else {
Some(crate::test_support::lock_test_env())
};
let serial = ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(|held| held.set(true));
ActiveWorkshopTestGuard {
_serial: serial,
_env: env,
}
}
fn active_workshop_slot() -> &'static Mutex<WorkshopConfig> {
ACTIVE_WORKSHOP.get_or_init(|| Mutex::new(WorkshopConfig::default()))
}
// ── Configuration ─────────────────────────────────────────────────────────────
/// Install the process-wide workshop budgets used by read/tool compactors.
///
/// The returned immutable receipt is the snapshot written while the
/// singleton lock was held. Callers that need evidence of their own
/// activation can inspect it without racing a later process-wide update.
pub fn install_active_workshop(config: Option<&WorkshopConfig>) -> WorkshopConfig {
#[cfg(test)]
let _test_serial = if ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get) {
None
} else {
Some(
ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
};
let snapshot = config.cloned().unwrap_or_default();
let mut slot = active_workshop_slot()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = snapshot;
slot.clone()
}
/// Optional model-visible read budget, when the user opted in (#5367).
#[must_use]
pub fn active_read_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.read_result_max_bytes.filter(|n| *n > 0))
}
/// Optional model-visible tool-result budget, when the user opted in (#5367).
#[must_use]
pub fn active_tool_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.tool_result_max_bytes.filter(|n| *n > 0))
}
/// Resolve the effective threshold for the given tool name.
#[must_use]
pub fn workshop_threshold_for(config: &WorkshopConfig, tool_name: &str) -> usize {
@@ -395,26 +274,6 @@ mod tests {
);
}
#[test]
fn workshop_byte_budgets_raise_floor_only() {
let _guard = active_workshop_test_guard();
let installed = install_active_workshop(Some(&WorkshopConfig {
large_output_threshold_tokens: None,
per_tool_thresholds: None,
read_result_max_bytes: Some(102_400),
tool_result_max_bytes: Some(80_000),
}));
assert_eq!(installed.read_result_max_bytes, Some(102_400));
assert_eq!(installed.tool_result_max_bytes, Some(80_000));
assert_eq!(active_read_result_max_bytes(), Some(102_400));
assert_eq!(active_tool_result_max_bytes(), Some(80_000));
let cleared = install_active_workshop(None);
assert_eq!(cleared.read_result_max_bytes, None);
assert_eq!(cleared.tool_result_max_bytes, None);
assert_eq!(active_read_result_max_bytes(), None);
assert_eq!(active_tool_result_max_bytes(), None);
}
#[test]
fn estimate_tokens_conservative() {
// 9 chars → ceil(9/3) = 3 tokens
+1 -1
View File
@@ -64,7 +64,7 @@ impl ChildAccountingProjection {
route,
reason,
),
None => crate::cost_status::report_missing_usage_for_interactive_origin(
None => crate::usage_persistence::report_missing_usage_for_interactive_origin(
self.origin.cost_scope,
&self.origin.session_id,
&self.owner,
+2 -2
View File
@@ -1245,7 +1245,7 @@ impl SubAgentAccountingOrigin {
route: &crate::cost_status::EffectiveRouteEnvelope,
usage: &Usage,
) {
crate::cost_status::report_effective_route_for_interactive_origin(
crate::usage_persistence::report_effective_route_for_interactive_origin(
self.cost_scope,
&self.session_id,
agent_id,
@@ -19116,7 +19116,7 @@ fn annotate_child_model_error_with_origin(
const SUBAGENT_SUMMARY_CHAR_BUDGET: usize = 12_000;
fn subagent_summary_char_budget() -> usize {
crate::tools::large_output_router::active_tool_result_max_bytes()
codewhale_runtime::active_workshop::active_tool_result_max_bytes()
.map(|bytes| bytes.clamp(SUBAGENT_SUMMARY_CHAR_BUDGET, 2 * 1024 * 1024))
.unwrap_or(SUBAGENT_SUMMARY_CHAR_BUDGET)
}
+1 -1
View File
@@ -1814,7 +1814,7 @@ mod tests {
app.auto_model = true;
app.last_effective_provider = Some(codewhale_config::ProviderKind::Deepseek);
app.last_effective_model = Some("deepseek-v4-pro".to_string());
let decision = crate::model_routing::AutoRouteDecisionEvidence {
let decision = codewhale_models::auto_route_evidence::AutoRouteDecisionEvidence {
choice: "strong".to_string(),
probabilities_bp: [("fast".to_string(), 1800), ("strong".to_string(), 8200)]
.into_iter()
+2 -2
View File
@@ -347,7 +347,7 @@ impl TranslationAccountingContext {
self.origin_session_id.as_deref(),
self.origin_turn_id.as_deref(),
) {
crate::cost_status::report_effective_route_for_interactive_origin(
crate::usage_persistence::report_effective_route_for_interactive_origin(
self.cost_scope,
session_id,
turn_id,
@@ -369,7 +369,7 @@ impl TranslationAccountingContext {
self.origin_session_id.as_deref(),
self.origin_turn_id.as_deref(),
) {
crate::cost_status::report_unreceipted_for_interactive_origin(
crate::usage_persistence::report_unreceipted_for_interactive_origin(
self.cost_scope,
session_id,
turn_id,
+1 -1
View File
@@ -1076,7 +1076,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
reasoning_only_reprompt_message: Some(config.reasoning_only_reprompt_message().to_string()),
locale_tag: app.ui_locale.tag().to_string(),
workshop: {
crate::tools::large_output_router::install_active_workshop(config.workshop.as_ref());
codewhale_runtime::active_workshop::install_active_workshop(config.workshop.as_ref());
config.workshop.clone()
},
search_provider: config.search_provider(),
+1 -1
View File
@@ -17824,7 +17824,7 @@ fn active_deleted_origin_mailbox_usage_does_not_accrue_or_restore_the_session()
app.current_session_metadata = Some(session.metadata.clone());
let origin_scope = crate::cost_status::scope_token();
let owner = "interactive:active-deleted-origin:origin-turn";
crate::cost_status::register_persistent_interactive_runtime_usage_sink_for_test(
crate::usage_persistence::register_persistent_interactive_runtime_usage_sink_for_test(
owner,
origin_scope,
&session.metadata.id,
+267
View File
@@ -0,0 +1,267 @@
use std::sync::Arc;
use crate::cost_status::{
CostScopeToken, EffectiveRouteEnvelope, EffectiveRouteUsage, RuntimeUsageDropRecord,
RuntimeUsageMissingReason, RuntimeUsageRecord, acknowledge_retired_usage_source,
record_interactive_runtime_usage, record_interactive_runtime_usage_drop,
register_runtime_decision_sink, register_runtime_usage_sink_with_drop,
report_interactive_decision,
};
use codewhale_models::Usage;
/// Install an interactive sink whose stale-scope fallback is an origin-session
/// sidecar. `/new` may close the foreground pool while a detached provider call
/// is still running; the sidecar keeps that exact response with the old saved
/// session instead of either dropping it or contaminating the new one.
pub(crate) fn register_persistent_interactive_runtime_usage_sink(
owner: &str,
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
) {
let Ok(manager) = crate::session_manager::SessionManager::default_location() else {
// With no durable origin gate, leave the owner on the bounded journal
// fallback. An in-memory-only sink could keep accepting a deleted
// session's responses after its directory becomes available again.
return;
};
register_persistent_interactive_runtime_usage_sink_at(
owner,
scope,
session_id,
turn_id,
manager.sessions_dir().to_path_buf(),
);
}
pub(crate) fn register_persistent_interactive_runtime_usage_sink_at(
owner: &str,
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
sessions_dir: std::path::PathBuf,
) {
let usage_session_id = session_id.to_string();
let usage_turn_id = turn_id.to_string();
let drop_session_id = usage_session_id.clone();
let drop_turn_id = usage_turn_id.clone();
let usage_sessions_dir = sessions_dir.clone();
let decision_sessions_dir = sessions_dir.clone();
let decision_session_id = session_id.to_string();
let decision_turn_id = turn_id.to_string();
register_runtime_usage_sink_with_drop(
owner,
Arc::new(move |record| {
crate::session_manager::SessionManager::new(usage_sessions_dir.clone())
.map(|manager| {
report_effective_route_for_interactive_origin_with_manager(
scope,
&usage_session_id,
&usage_turn_id,
&record.source_id,
&record.usage.route,
&record.usage.usage,
&manager,
)
})
.unwrap_or(false)
}),
Some(Arc::new(move |record| {
crate::session_manager::SessionManager::new(sessions_dir.clone())
.map(|manager| {
report_missing_usage_for_interactive_origin_with_manager(
scope,
&drop_session_id,
&drop_turn_id,
&record.source_id,
&record.route,
record.reason,
&manager,
)
})
.unwrap_or(false)
})),
);
register_runtime_decision_sink(
owner,
Arc::new(move |receipt| {
let Ok(manager) =
crate::session_manager::SessionManager::new(decision_sessions_dir.clone())
else {
return false;
};
let accepted = manager
.persist_late_decision_receipt(&decision_session_id, &decision_turn_id, &receipt)
.unwrap_or(false);
if accepted {
// The append may have been handled by a deletion tombstone. Only
// a live origin may enter the foreground diagnostic projection.
let _ = manager.with_live_session_origin(&decision_session_id, || {
report_interactive_decision(scope, &receipt);
true
});
}
accepted
}),
);
}
#[cfg(test)]
pub(crate) fn register_persistent_interactive_runtime_usage_sink_for_test(
owner: &str,
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
manager: &crate::session_manager::SessionManager,
) {
register_persistent_interactive_runtime_usage_sink_at(
owner,
scope,
session_id,
turn_id,
manager.sessions_dir().to_path_buf(),
);
}
/// Report an interactive auxiliary response against its immutable origin.
/// A stale foreground scope is not an error: it means `/new` or session load
/// already moved on, so the exact receipt is appended to the old session's
/// durable sidecar instead of being redirected to the active session.
pub(crate) fn report_effective_route_for_interactive_origin(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
usage: &Usage,
) {
let persisted =
crate::session_manager::SessionManager::default_location().is_ok_and(|manager| {
report_effective_route_for_interactive_origin_with_manager(
scope, session_id, turn_id, source_id, route, usage, &manager,
)
});
if !persisted {
tracing::warn!("late interactive usage could not be persisted for its origin session");
}
}
pub(crate) fn report_effective_route_for_interactive_origin_with_manager(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
usage: &Usage,
manager: &crate::session_manager::SessionManager,
) -> bool {
let record = RuntimeUsageRecord {
source_id: source_id.to_string(),
usage: EffectiveRouteUsage {
route: route.sanitized_for_persistence(),
usage: usage.clone(),
},
};
match manager.with_live_session_origin(session_id, || {
record_interactive_runtime_usage(scope, record.clone())
}) {
Ok(None) => {
acknowledge_retired_usage_source(scope, source_id);
return true;
}
Ok(Some(true)) => return true,
Ok(Some(false)) => {}
Err(_) => return false,
}
manager
.persist_late_runtime_usage(session_id, turn_id, &record)
.unwrap_or(false)
}
pub(crate) fn report_unreceipted_for_interactive_origin(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
) {
report_missing_usage_for_interactive_origin(
scope,
session_id,
turn_id,
source_id,
route,
RuntimeUsageMissingReason::SuccessWithoutUsage,
);
}
pub(crate) fn report_missing_usage_for_interactive_origin(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
reason: RuntimeUsageMissingReason,
) {
let persisted =
crate::session_manager::SessionManager::default_location().is_ok_and(|manager| {
report_missing_usage_for_interactive_origin_with_manager(
scope, session_id, turn_id, source_id, route, reason, &manager,
)
});
if !persisted {
tracing::warn!(
"late interactive missing-usage receipt could not be persisted for its origin session"
);
}
}
#[cfg(test)]
pub(crate) fn report_unreceipted_for_interactive_origin_with_manager(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
manager: &crate::session_manager::SessionManager,
) -> bool {
report_missing_usage_for_interactive_origin_with_manager(
scope,
session_id,
turn_id,
source_id,
route,
RuntimeUsageMissingReason::SuccessWithoutUsage,
manager,
)
}
pub(crate) fn report_missing_usage_for_interactive_origin_with_manager(
scope: CostScopeToken,
session_id: &str,
turn_id: &str,
source_id: &str,
route: &EffectiveRouteEnvelope,
reason: RuntimeUsageMissingReason,
manager: &crate::session_manager::SessionManager,
) -> bool {
let record = RuntimeUsageDropRecord {
reason,
source_id: source_id.to_string(),
route: route.sanitized_for_persistence(),
};
match manager.with_live_session_origin(session_id, || {
record_interactive_runtime_usage_drop(scope, record.clone())
}) {
Ok(None) => {
acknowledge_retired_usage_source(scope, source_id);
return true;
}
Ok(Some(true)) => return true,
Ok(Some(false)) => {}
Err(_) => return false,
}
manager
.persist_late_runtime_drop(session_id, turn_id, &record)
.unwrap_or(false)
}