fix(cost): finished requests never read "outcome unknown"; running ones read "in progress"

The founder's TUI showed "subtotal $0.10 + unknown (request outcome
unknown)". Every provider dispatch first records a write-ahead reservation
(coverage Missing, reason RequestOutcomeUnknown, reservation=true), and
SessionCostSnapshot added that reason's label to unpriced_reasons but
removed it only when unpriced_turns returned to 0. So a running request
read "request outcome unknown", and once it settled the label stayed
whenever any other turn was unpriced for another reason.

Reproduced on the installed build 7b8a634fbb (TUI in tmux, isolated HOME,
mock DeepSeek endpoint, one turn with three explore children): after the
turn settled the chip still read "cost: unknown (request outcome unknown,
live rate unverified)", while the saved session showed all six sources
settled with coverage reported and no missing sources.

- The "request outcome unknown" and "provider omitted usage" labels follow
  missing_usage_sources: each is dropped once no unresolved, non-running
  source carries that reason (an overflowed source map keeps it, since its
  sources are no longer tracked one by one). Late background usage already
  projects its missing sources into the same map.
- A reservation is tracked in a process-local in_flight_usage_sources set
  (serde skip) and labelled with the new UnpricedReason::RequestInProgress
  ("request in progress", CostReasonInProgress in all 15 locale packs).
  The label leaves when the source settles. The set keeps only sources
  that are still unresolved. Persisted reasons never store it:
  serialize_settled_reasons writes it as request_outcome_unknown, so a
  reservation left by an exit or crash reloads as outcome unknown.
- The TUI rebuilds a SessionCostSnapshot from its own SessionState on every
  projection, so SessionState carries in_flight_usage_sources too: copied
  both ways, and cleared with the rest of the coverage on /new and session
  load.
- Not covered: the parent turn settles a cancelled or failed request with a
  final outcome-unknown record that replaces its reservation; a dispatch
  path that dropped its source without settling would keep reading
  "request in progress" until the session reloads.
- UsageSourceRevision gains is_reservation(); the one exhaustive test
  literal of SessionCostSnapshot names the new field.

Verification: MISSING

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-11-07 19:58:15 -08:00
1 parent 4bb1bd9ba2
commit 5d8e4427a7
23 files changed
+112 -14

No files matched your search

+6
View File
@@ -336,6 +336,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Fixed
- **Cost line: a finished request is never "outcome unknown":** the cost
line drops "request outcome unknown" (and "provider omitted usage") once
the requests behind it have settled, even when another turn is still
unpriced for a different reason, and a request that is still running now
reads "request in progress" instead. A request that an exit or crash left
unsettled still reads "request outcome unknown" after a restart.
- **Sub-agents in `codewhale exec`:** a sub-agent started from
`codewhale exec` now runs. It used to stop before its first provider
request with "child provider call has no captured accounting origin",
+4
View File
@@ -1354,6 +1354,10 @@ impl UsageSourceRevision {
pub fn revision(&self) -> u64 {
self.revision
}
pub fn is_reservation(&self) -> bool {
self.reservation
}
}
#[derive(Debug, Clone)]
+4
View File
@@ -876,6 +876,7 @@ pub enum UnpricedReason {
InconsistentUsage,
MissingUsageBreakdown,
RequestOutcomeUnknown,
RequestInProgress,
ProviderSuccessMissingUsage,
LegacyUsageUnverified,
UsageCorrectionUnverified,
@@ -905,6 +906,7 @@ impl UnpricedReason {
"late_usage_ledger_unavailable" => Self::LateUsageUnavailable,
"late_usage_ledger_overflow" => Self::LateUsageOverflow,
"request_outcome_unknown" => Self::RequestOutcomeUnknown,
"request_in_progress" => Self::RequestInProgress,
"provider_success_missing_usage" | "success_without_usage" => {
Self::ProviderSuccessMissingUsage
}
@@ -937,6 +939,7 @@ impl UnpricedReason {
Self::InconsistentUsage => MessageId::CostReasonUsageConflict,
Self::MissingUsageBreakdown => MessageId::CostReasonUsageBreakdown,
Self::RequestOutcomeUnknown => MessageId::CostReasonOutcomeUnknown,
Self::RequestInProgress => MessageId::CostReasonInProgress,
Self::ProviderSuccessMissingUsage => MessageId::CostReasonProviderUsageMissing,
Self::LegacyUsageUnverified => MessageId::CostReasonLegacyUsageUnverified,
Self::UsageCorrectionUnverified => MessageId::CostReasonUsageCorrectionUnverified,
@@ -966,6 +969,7 @@ impl UnpricedReason {
Self::InconsistentUsage => "inconsistent_usage",
Self::MissingUsageBreakdown => "missing_usage_breakdown",
Self::RequestOutcomeUnknown => "request_outcome_unknown",
Self::RequestInProgress => "request_in_progress",
Self::ProviderSuccessMissingUsage => "provider_success_missing_usage",
Self::LegacyUsageUnverified => "legacy_usage_unverified",
Self::UsageCorrectionUnverified => "usage_correction_unverified",
+69 -14
View File
@@ -681,6 +681,29 @@ const CURRENT_LATE_USAGE_SCHEMA_VERSION: u32 = 2;
const MAX_LATE_USAGE_UNRESOLVED_RECORDS_PER_SESSION: usize = 64;
const MAX_LATE_USAGE_LEDGER_BYTES: u64 = 1024 * 1024;
const LATE_USAGE_DELETED: &[u8] = b"codewhale-session-deleted-v1\n";
fn serialize_settled_reasons<S>(
reasons: &BTreeSet<String>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::Serialize as _;
let in_progress = crate::pricing::UnpricedReason::RequestInProgress.label();
let unknown = crate::pricing::UnpricedReason::RequestOutcomeUnknown.label();
reasons
.iter()
.map(|reason| {
if reason == in_progress {
unknown
} else {
reason.as_str()
}
})
.collect::<BTreeSet<_>>()
.serialize(serializer)
}
fn is_false(value: &bool) -> bool {
!*value
}
@@ -1330,9 +1353,17 @@ pub struct SessionCostSnapshot {
#[serde(default)]
pub cny_unpriced_turns: u32,
/// Stable reason labels for the unpriced turns.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
#[serde(
default,
skip_serializing_if = "BTreeSet::is_empty",
serialize_with = "serialize_settled_reasons"
)]
pub unpriced_reasons: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
#[serde(
default,
skip_serializing_if = "BTreeSet::is_empty",
serialize_with = "serialize_settled_reasons"
)]
pub cny_unpriced_reasons: BTreeSet<String>,
/// Token classes used on some route that carry no published price.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
@@ -1370,6 +1401,8 @@ pub struct SessionCostSnapshot {
pub missing_usage_sources: BTreeMap<String, crate::cost_status::MissingUsageCoverage>,
#[serde(default, skip_serializing_if = "is_false")]
pub missing_usage_overflowed: bool,
#[serde(skip)]
pub in_flight_usage_sources: BTreeSet<String>,
/// Written by builds that track coverage, so a reader can tell "this session
/// genuinely had zero money-metered turns" apart from "this session predates
/// coverage tracking". Absent on legacy rows.
@@ -1517,10 +1550,15 @@ impl SessionCostSnapshot {
crate::llm_client::ProviderUsageCoverage::Missing
| crate::llm_client::ProviderUsageCoverage::Incomplete
) {
self.unpriced_reasons
.insert(revision.reason.label().to_string());
self.cny_unpriced_reasons
.insert(revision.reason.label().to_string());
let label = if revision.is_reservation() {
self.in_flight_usage_sources.insert(key.clone());
crate::pricing::UnpricedReason::RequestInProgress.label()
} else {
self.in_flight_usage_sources.remove(&key);
revision.reason.label()
};
self.unpriced_reasons.insert(label.to_string());
self.cny_unpriced_reasons.insert(label.to_string());
if self.missing_usage_sources.len() < crate::cost_status::MAX_MISSING_USAGE_SOURCES
|| self.missing_usage_sources.contains_key(&key)
{
@@ -1536,17 +1574,34 @@ impl SessionCostSnapshot {
}
} else {
self.missing_usage_sources.remove(&key);
self.in_flight_usage_sources.remove(&key);
}
}
if self.unpriced_turns == 0 {
self.unpriced_reasons.remove("request_outcome_unknown");
self.unpriced_reasons
.remove("provider_success_missing_usage");
let missing_sources = &self.missing_usage_sources;
self.in_flight_usage_sources
.retain(|source| missing_sources.contains_key(source));
let in_flight = &self.in_flight_usage_sources;
for reason in [
crate::cost_status::RuntimeUsageMissingReason::RequestOutcomeUnknown,
crate::cost_status::RuntimeUsageMissingReason::SuccessWithoutUsage,
] {
let unresolved = self.missing_usage_overflowed
|| missing_sources.iter().any(|(source, missing)| {
missing.reason == reason && !in_flight.contains(source)
});
if self.unpriced_turns == 0 || !unresolved {
self.unpriced_reasons.remove(reason.label());
}
if self.cny_unpriced_turns == 0 || !unresolved {
self.cny_unpriced_reasons.remove(reason.label());
}
}
if self.cny_unpriced_turns == 0 {
self.cny_unpriced_reasons.remove("request_outcome_unknown");
self.cny_unpriced_reasons
.remove("provider_success_missing_usage");
let in_progress = crate::pricing::UnpricedReason::RequestInProgress.label();
if self.unpriced_turns == 0 || in_flight.is_empty() {
self.unpriced_reasons.remove(in_progress);
}
if self.cny_unpriced_turns == 0 || in_flight.is_empty() {
self.cny_unpriced_reasons.remove(in_progress);
}
self.usage_source_fingerprints.insert(key.clone());
self.accounted_usage_sources.insert(key, next);
@@ -1466,6 +1466,7 @@
usage_source_fingerprints: ["response-fingerprint".to_string()].into(),
missing_usage_sources: Default::default(),
missing_usage_overflowed: false,
in_flight_usage_sources: Default::default(),
coverage_recorded: true,
};
let json = serde_json::to_string(&full).expect("serialize");
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "recomptes de tokens contradictoris",
"CostReasonUsageBreakdown": "desglossament de tokens no disponible",
"CostReasonOutcomeUnknown": "resultat de la sol·licitud desconegut",
"CostReasonInProgress": "sol·licitud en curs",
"CostReasonProviderUsageMissing": "el proveïdor ha omès l’ús",
"CostReasonLegacyUsageUnverified": "ús antic no verificat",
"CostReasonUsageCorrectionUnverified": "correcció d’ús no verificada",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "widersprüchliche Tokenzahlen",
"CostReasonUsageBreakdown": "Aufschlüsselung der Token-Nutzung nicht verfügbar",
"CostReasonOutcomeUnknown": "Anfrageergebnis unbekannt",
"CostReasonInProgress": "Anfrage läuft",
"CostReasonProviderUsageMissing": "Anbieter hat keine Nutzungsdaten gemeldet",
"CostReasonLegacyUsageUnverified": "ältere Nutzungsdaten ungeprüft",
"CostReasonUsageCorrectionUnverified": "Nutzungskorrektur ungeprüft",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "conflicting token counts",
"CostReasonUsageBreakdown": "token usage breakdown unavailable",
"CostReasonOutcomeUnknown": "request outcome unknown",
"CostReasonInProgress": "request in progress",
"CostReasonProviderUsageMissing": "provider omitted usage",
"CostReasonLegacyUsageUnverified": "legacy usage unverified",
"CostReasonUsageCorrectionUnverified": "usage correction unverified",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "recuentos de tokens contradictorios",
"CostReasonUsageBreakdown": "desglose de tokens no disponible",
"CostReasonOutcomeUnknown": "resultado de la solicitud desconocido",
"CostReasonInProgress": "solicitud en curso",
"CostReasonProviderUsageMissing": "el proveedor omitió el uso",
"CostReasonLegacyUsageUnverified": "uso anterior sin verificar",
"CostReasonUsageCorrectionUnverified": "corrección de uso sin verificar",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "comptages de tokens contradictoires",
"CostReasonUsageBreakdown": "répartition des tokens indisponible",
"CostReasonOutcomeUnknown": "résultat de la requête inconnu",
"CostReasonInProgress": "requête en cours",
"CostReasonProviderUsageMissing": "utilisation non transmise par le fournisseur",
"CostReasonLegacyUsageUnverified": "ancienne utilisation non vérifiée",
"CostReasonUsageCorrectionUnverified": "correction d’utilisation non vérifiée",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "टोकन गणनाओं में विरोध",
"CostReasonUsageBreakdown": "टोकन उपयोग का विवरण उपलब्ध नहीं",
"CostReasonOutcomeUnknown": "अनुरोध का परिणाम अज्ञात",
"CostReasonInProgress": "अनुरोध जारी है",
"CostReasonProviderUsageMissing": "प्रदाता ने उपयोग नहीं बताया",
"CostReasonLegacyUsageUnverified": "पुराने उपयोग की पुष्टि नहीं हुई",
"CostReasonUsageCorrectionUnverified": "उपयोग सुधार की पुष्टि नहीं हुई",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "jumlah token bertentangan",
"CostReasonUsageBreakdown": "rincian penggunaan token tidak tersedia",
"CostReasonOutcomeUnknown": "hasil permintaan tidak diketahui",
"CostReasonInProgress": "permintaan sedang berjalan",
"CostReasonProviderUsageMissing": "penyedia tidak melaporkan penggunaan",
"CostReasonLegacyUsageUnverified": "penggunaan lama belum diverifikasi",
"CostReasonUsageCorrectionUnverified": "koreksi penggunaan belum diverifikasi",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "トークン数が不整合",
"CostReasonUsageBreakdown": "トークン使用量の内訳が不明",
"CostReasonOutcomeUnknown": "リクエスト結果が不明",
"CostReasonInProgress": "リクエスト処理中",
"CostReasonProviderUsageMissing": "プロバイダーが使用量を未報告",
"CostReasonLegacyUsageUnverified": "旧形式の使用量が未検証",
"CostReasonUsageCorrectionUnverified": "使用量の修正が未検証",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "토큰 수 불일치",
"CostReasonUsageBreakdown": "토큰 사용량 내역을 알 수 없음",
"CostReasonOutcomeUnknown": "요청 결과를 알 수 없음",
"CostReasonInProgress": "요청 진행 중",
"CostReasonProviderUsageMissing": "공급자가 사용량을 보고하지 않음",
"CostReasonLegacyUsageUnverified": "이전 사용량이 검증되지 않음",
"CostReasonUsageCorrectionUnverified": "사용량 수정이 검증되지 않음",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "contagens de tokens conflitantes",
"CostReasonUsageBreakdown": "detalhamento de tokens indisponível",
"CostReasonOutcomeUnknown": "resultado da solicitação desconhecido",
"CostReasonInProgress": "solicitação em andamento",
"CostReasonProviderUsageMissing": "o provedor omitiu o uso",
"CostReasonLegacyUsageUnverified": "uso antigo não verificado",
"CostReasonUsageCorrectionUnverified": "correção de uso não verificada",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "противоречивые счётчики токенов",
"CostReasonUsageBreakdown": "разбивка токенов недоступна",
"CostReasonOutcomeUnknown": "результат запроса неизвестен",
"CostReasonInProgress": "запрос выполняется",
"CostReasonProviderUsageMissing": "провайдер не сообщил расход",
"CostReasonLegacyUsageUnverified": "старые данные расхода не проверены",
"CostReasonUsageCorrectionUnverified": "корректировка расхода не проверена",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "суперечливі лічильники токенів",
"CostReasonUsageBreakdown": "деталізація токенів недоступна",
"CostReasonOutcomeUnknown": "результат запиту невідомий",
"CostReasonInProgress": "запит виконується",
"CostReasonProviderUsageMissing": "провайдер не повідомив використання",
"CostReasonLegacyUsageUnverified": "старі дані використання не перевірено",
"CostReasonUsageCorrectionUnverified": "коригування використання не перевірено",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "số token không nhất quán",
"CostReasonUsageBreakdown": "không có chi tiết sử dụng token",
"CostReasonOutcomeUnknown": "chưa rõ kết quả yêu cầu",
"CostReasonInProgress": "yêu cầu đang chạy",
"CostReasonProviderUsageMissing": "nhà cung cấp không báo cáo mức sử dụng",
"CostReasonLegacyUsageUnverified": "mức sử dụng cũ chưa được xác minh",
"CostReasonUsageCorrectionUnverified": "điều chỉnh mức sử dụng chưa được xác minh",
+1
View File
@@ -2540,6 +2540,7 @@
"CostReasonUsageConflict": "token 计数冲突",
"CostReasonUsageBreakdown": "token 用量明细不可用",
"CostReasonOutcomeUnknown": "请求结果未知",
"CostReasonInProgress": "请求进行中",
"CostReasonProviderUsageMissing": "提供商未返回用量",
"CostReasonLegacyUsageUnverified": "旧版用量未经核实",
"CostReasonUsageCorrectionUnverified": "用量修正未经核实",
+1
View File
@@ -2538,6 +2538,7 @@
"CostReasonUsageConflict": "token 計數衝突",
"CostReasonUsageBreakdown": "token 用量明細不可用",
"CostReasonOutcomeUnknown": "請求結果未知",
"CostReasonInProgress": "請求進行中",
"CostReasonProviderUsageMissing": "供應商未回傳用量",
"CostReasonLegacyUsageUnverified": "舊版用量未經核實",
"CostReasonUsageCorrectionUnverified": "用量修正未經核實",
+2
View File
@@ -190,6 +190,7 @@ pub enum MessageId {
CostReasonUsageConflict,
CostReasonUsageBreakdown,
CostReasonOutcomeUnknown,
CostReasonInProgress,
CostReasonProviderUsageMissing,
CostReasonLegacyUsageUnverified,
CostReasonUsageCorrectionUnverified,
@@ -3135,6 +3136,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CostReasonUsageConflict,
MessageId::CostReasonUsageBreakdown,
MessageId::CostReasonOutcomeUnknown,
MessageId::CostReasonInProgress,
MessageId::CostReasonProviderUsageMissing,
MessageId::CostReasonLegacyUsageUnverified,
MessageId::CostReasonUsageCorrectionUnverified,
+6
View File
@@ -331,6 +331,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Fixed
- **Cost line: a finished request is never "outcome unknown":** the cost
line drops "request outcome unknown" (and "provider omitted usage") once
the requests behind it have settled, even when another turn is still
unpriced for a different reason, and a request that is still running now
reads "request in progress" instead. A request that an exit or crash left
unsettled still reads "request outcome unknown" after a restart.
- **Sub-agents in `codewhale exec`:** a sub-agent started from
`codewhale exec` now runs. It used to stop before its first provider
request with "child provider call has no captured accounting origin",
+5
View File
@@ -1187,6 +1187,7 @@ pub struct SessionState {
pub missing_usage_sources:
std::collections::BTreeMap<String, codewhale_engine::cost_status::MissingUsageCoverage>,
pub missing_usage_overflowed: bool,
pub in_flight_usage_sources: BTreeSet<String>,
pub displayed_cost_high_water: f64,
pub displayed_cost_high_water_cny: f64,
pub last_prompt_tokens: Option<u32>,
@@ -1367,6 +1368,7 @@ impl Default for SessionState {
accounted_usage_sources: Default::default(),
missing_usage_sources: std::collections::BTreeMap::new(),
missing_usage_overflowed: false,
in_flight_usage_sources: BTreeSet::new(),
displayed_cost_high_water: 0.0,
displayed_cost_high_water_cny: 0.0,
last_prompt_tokens: None,
@@ -4708,6 +4710,7 @@ impl App {
self.session.accounted_usage_sources.clear();
self.session.missing_usage_sources.clear();
self.session.missing_usage_overflowed = false;
self.session.in_flight_usage_sources.clear();
self.session.cost_priced_turns = 0;
self.session.cost_unpriced_turns = 0;
self.session.cost_cny_priced_turns = 0;
@@ -4817,6 +4820,7 @@ impl App {
// apparently complete zero.
cost.missing_usage_sources = self.session.missing_usage_sources.clone();
cost.missing_usage_overflowed = self.session.missing_usage_overflowed;
cost.in_flight_usage_sources = self.session.in_flight_usage_sources.clone();
cost.coverage_recorded = !self.session.cost_coverage_unknown_legacy;
cost.accounted_usage_sources = self.session.accounted_usage_sources.clone();
}
@@ -4844,6 +4848,7 @@ impl App {
self.session.accounted_usage_sources = cost.accounted_usage_sources.clone();
self.session.missing_usage_sources = cost.missing_usage_sources.clone();
self.session.missing_usage_overflowed = cost.missing_usage_overflowed;
self.session.in_flight_usage_sources = cost.in_flight_usage_sources.clone();
self.session.cost_priced_turns = cost.priced_turns;
self.session.cost_unpriced_turns = cost.unpriced_turns;
self.session.cost_cny_priced_turns = cost.cny_priced_turns;