Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
849c27e
Add v4 quota cycle contribution sync
cursoragent Aug 22, 2026
95979c3
Fix v4 sync ack schema pairing tests
cursoragent Aug 22, 2026
fe4f507
Silence the too-many-arguments lint on a quota test helper
starkdmi Aug 24, 2026
cd52953
Sync whether a device reconstructed an overlapping quota cycle
starkdmi Aug 24, 2026
25d38e1
Name the field when the endpoint refuses an unrecognized record key
starkdmi Aug 24, 2026
12a0066
Ignore quota evidence recorded after its window already reset
starkdmi Aug 24, 2026
656b350
Document hosted drop of post-reset quota envelopes
cursoragent Aug 24, 2026
23d0ba5
Stop treating an unused window as its own quota cycle
starkdmi Aug 24, 2026
d7360cc
Keep the daily quota closing from walking backwards
starkdmi Aug 24, 2026
fbc41a5
Merge branch 'ai/quota-cycle-sync-f197' of https://github.com/starkdm…
starkdmi Aug 24, 2026
4fd4046
Drop quota schedules the provider never switched to
starkdmi Aug 24, 2026
1d444d7
Cluster quota resets by the gap between them, and trust bracketing alone
starkdmi Aug 24, 2026
b9ecc96
Report what quota reconstruction discarded
starkdmi Aug 24, 2026
fd414b2
Cite the provider lag the stale bound actually rests on
starkdmi Aug 25, 2026
2260982
Count each cached write once in a quota usage slice
starkdmi Aug 25, 2026
25d0c44
Refuse quota contributions the loopback daemon cannot store
starkdmi Aug 25, 2026
40c6438
Count pending quota cycles as pending upload work
starkdmi Aug 25, 2026
5a82f8f
Give artifact downloads their own timeout
starkdmi Aug 25, 2026
133bcaa
Retry an artifact download that was cut short
starkdmi Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ PUBLISH.md
tmp-statsai-*.sqlite*
/statsai.sqlite*
.wrangler/
/.claude
74 changes: 71 additions & 3 deletions crates/statsai-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ pub const DAILY_ROLLUP_SCHEMA_VERSION: &str = "daily_rollup.v1";
pub const SYNC_BATCH_V1_SCHEMA_VERSION: &str = "sync_batch.v1";
pub const SYNC_BATCH_V2_SCHEMA_VERSION: &str = "sync_batch.v2";
pub const SYNC_BATCH_V3_SCHEMA_VERSION: &str = "sync_batch.v3";
pub const SYNC_BATCH_V4_SCHEMA_VERSION: &str = "sync_batch.v4";
pub const SYNC_ACK_V1_SCHEMA_VERSION: &str = "sync_ack.v1";
pub const SYNC_ACK_V2_SCHEMA_VERSION: &str = "sync_ack.v2";
pub const SYNC_ACK_V3_SCHEMA_VERSION: &str = "sync_ack.v3";
pub const SYNC_BATCH_SCHEMA_VERSION: &str = SYNC_BATCH_V3_SCHEMA_VERSION;
pub const SYNC_ACK_SCHEMA_VERSION: &str = SYNC_ACK_V3_SCHEMA_VERSION;
pub const SYNC_ACK_V4_SCHEMA_VERSION: &str = "sync_ack.v4";
pub const SYNC_BATCH_SCHEMA_VERSION: &str = SYNC_BATCH_V4_SCHEMA_VERSION;
pub const SYNC_ACK_SCHEMA_VERSION: &str = SYNC_ACK_V4_SCHEMA_VERSION;

#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
Expand Down Expand Up @@ -727,6 +729,10 @@ pub struct SyncBatch {
/// arguments, and commit messages are deliberately absent from this type.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_change_metrics: Vec<CodeChangeMetric>,
/// Attributed quota-cycle contributions. Local quota records, payloads,
/// plans, credits, and sample counts are deliberately absent from this type.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quota_cycle_contributions: Vec<QuotaCycleContributionV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authoritative_snapshot: Option<SyncAuthoritativeSnapshot>,
pub created_at: DateTime<Utc>,
Expand All @@ -749,6 +755,8 @@ pub struct SyncAuthoritativeSnapshot {
pub summary_ids: Vec<SummaryId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_change_metric_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quota_cycle_contribution_ids: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -796,6 +804,8 @@ pub struct SyncEntityCounts {
pub task_verifications: u64,
#[serde(default, skip_serializing_if = "sync_count_is_zero")]
pub code_change_metrics: u64,
#[serde(default, skip_serializing_if = "sync_count_is_zero")]
pub quota_cycle_contributions: u64,
}

fn sync_count_is_zero(value: &u64) -> bool {
Expand Down Expand Up @@ -2138,6 +2148,7 @@ mod tests {
task_buckets: 0,
task_verifications: 0,
code_change_metrics: 0,
quota_cycle_contributions: 0,
},
duplicates: SyncEntityCounts {
sources: 0,
Expand All @@ -2149,6 +2160,7 @@ mod tests {
task_buckets: 0,
task_verifications: 0,
code_change_metrics: 0,
quota_cycle_contributions: 0,
},
rejected: Vec::new(),
};
Expand All @@ -2166,7 +2178,7 @@ mod tests {
#[test]
fn sync_ack_v3_keeps_nonzero_task_and_code_change_counters() {
let ack = SyncAck {
schema_version: SYNC_ACK_SCHEMA_VERSION.to_string(),
schema_version: SYNC_ACK_V3_SCHEMA_VERSION.to_string(),
batch_id: "batch-2".to_string(),
accepted: SyncEntityCounts {
sources: 0,
Expand All @@ -2178,6 +2190,7 @@ mod tests {
task_buckets: 3,
task_verifications: 1,
code_change_metrics: 2,
quota_cycle_contributions: 0,
},
duplicates: SyncEntityCounts {
sources: 0,
Expand All @@ -2189,6 +2202,7 @@ mod tests {
task_buckets: 0,
task_verifications: 0,
code_change_metrics: 0,
quota_cycle_contributions: 0,
},
rejected: Vec::new(),
};
Expand All @@ -2198,6 +2212,60 @@ mod tests {
assert_eq!(json["accepted"]["task_buckets"], 3);
assert_eq!(json["accepted"]["task_verifications"], 1);
assert_eq!(json["accepted"]["code_change_metrics"], 2);
assert!(json["accepted"].get("quota_cycle_contributions").is_none());
}

#[test]
fn sync_ack_v4_keeps_nonzero_quota_cycle_counters() {
let ack = SyncAck {
schema_version: SYNC_ACK_V4_SCHEMA_VERSION.to_string(),
batch_id: "batch-quota".to_string(),
accepted: SyncEntityCounts {
sources: 0,
accounts: 0,
source_account_assignments: 0,
subscriptions: 0,
events: 0,
summaries: 0,
task_buckets: 0,
task_verifications: 0,
code_change_metrics: 1,
quota_cycle_contributions: 4,
},
duplicates: SyncEntityCounts {
sources: 0,
accounts: 0,
source_account_assignments: 0,
subscriptions: 0,
events: 0,
summaries: 0,
task_buckets: 0,
task_verifications: 0,
code_change_metrics: 0,
quota_cycle_contributions: 0,
},
rejected: Vec::new(),
};

let json = serde_json::to_value(&ack).expect("ack should serialize");
assert_eq!(json["schema_version"], SYNC_ACK_V4_SCHEMA_VERSION);
assert_eq!(json["accepted"]["code_change_metrics"], 1);
assert_eq!(json["accepted"]["quota_cycle_contributions"], 4);
}

#[test]
fn sync_batch_v3_without_quota_contributions_remains_backward_compatible() {
let batch: SyncBatch = serde_json::from_value(serde_json::json!({
"schema_version": SYNC_BATCH_V3_SCHEMA_VERSION,
"batch_id": "batch-legacy-v3",
"device_id": "device-1",
"created_at": "2026-05-31T10:00:00Z"
}))
.expect("legacy v3 batch should deserialize");

assert!(batch.quota_cycle_contributions.is_empty());
let serialized = serde_json::to_value(batch).expect("batch should serialize");
assert!(serialized.get("quota_cycle_contributions").is_none());
}

fn test_source(provider: &str, path: &str) -> SourceLocation {
Expand Down
133 changes: 133 additions & 0 deletions crates/statsai-core/src/quota.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub const QUOTA_OBSERVATION_SCHEMA_VERSION: &str = "quota_observation.v1";
pub const QUOTA_WINDOW_OBSERVATION_SCHEMA_VERSION: &str = "quota_window_observation.v1";
pub const QUOTA_WINDOW_SCHEMA_VERSION: &str = "quota_window.v1";
pub const QUOTA_WINDOW_SYNC_PROJECTION_SCHEMA_VERSION: &str = "quota_window_sync_projection.v1";
pub const QUOTA_CYCLE_CONTRIBUTION_SCHEMA_VERSION: &str = "quota_cycle_contribution.v1";
pub const QUOTA_WEEKLY_WINDOW_MINUTES: u64 = 10_080;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -194,3 +196,134 @@ pub struct QuotaWindowSyncProjectionV1 {
pub change_points: Vec<QuotaChangePointV1>,
pub latest_status: QuotaProjectionStatusV1,
}

/// Timestamped first/last/min/max percentages observed on one UTC day.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct QuotaDailyEnvelopeV1 {
pub day: String,
pub first_observed_at: DateTime<Utc>,
pub first_used_percent: f64,
pub last_observed_at: DateTime<Utc>,
pub last_used_percent: f64,
pub minimum_used_percent: f64,
pub maximum_used_percent: f64,
}

/// Exact usage for a partial UTC day at a cycle or schedule-transition boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
pub struct QuotaUsageSliceV1 {
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
pub input_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub output_tokens: u64,
pub reasoning_tokens: u64,
pub total_tokens: u64,
pub estimated_cost_micro_usd: i64,
}

impl QuotaUsageSliceV1 {
pub fn add_usage(&mut self, usage: &UsageCounts, estimated_cost_micro_usd: Option<i64>) {
self.input_tokens = self
.input_tokens
.saturating_add(usage.input_tokens.unwrap_or(0));
// The 5m and 1h counters are the lifetime breakdown of the aggregate,
// not additional writes: Claude reports 248 alongside 148 + 100. Adding
// all three counted every cached write twice and pushed the slice
// components past its own `total_tokens`. Every other accumulator in
// the workspace takes the aggregate alone; the lifetimes are only a
// fallback for records that arrive without it.
self.cache_creation_tokens =
self.cache_creation_tokens
.saturating_add(usage.cache_creation_tokens.unwrap_or_else(|| {
usage
.cache_creation_5m_tokens
.unwrap_or(0)
.saturating_add(usage.cache_creation_1h_tokens.unwrap_or(0))
}));
self.cache_read_tokens = self
.cache_read_tokens
.saturating_add(usage.cache_read_tokens.unwrap_or(0));
self.output_tokens = self
.output_tokens
.saturating_add(usage.output_tokens.unwrap_or(0));
self.reasoning_tokens = self
.reasoning_tokens
.saturating_add(usage.reasoning_tokens.unwrap_or(0));
self.total_tokens = self.total_tokens.saturating_add(usage.computed_total());
if let Some(value) = estimated_cost_micro_usd {
self.estimated_cost_micro_usd = self.estimated_cost_micro_usd.saturating_add(value);
}
}
}

/// Hosted device contribution for one attributed quota cycle. Omits raw events,
/// paths, source IDs, payloads, plans, credits, slots, fingerprints, and sample
/// counts.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct QuotaCycleContributionV1 {
pub schema_version: String,
/// Deterministic device contribution ID, not a logical account-cycle ID.
pub contribution_id: String,
pub provider: String,
pub provider_account_id: ProviderAccountId,
pub limit_id: Option<String>,
pub window_minutes: u64,
pub representative_reset: DateTime<Utc>,
pub representative_reset_epoch_seconds: i64,
/// True when this device locally reconstructed another cycle for the same
/// scope whose schedule overlaps this one. Codex weekly cycles start lazily
/// at first use, so a corroborated overlap means the neighbouring cycle was
/// reset early (banked or server-granted), not that the data conflicts.
#[serde(default)]
pub has_schedule_overlap: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub daily_envelopes: Vec<QuotaDailyEnvelopeV1>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boundary_slices: Vec<QuotaUsageSliceV1>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_slice_counts_each_cached_write_once() {
// Claude reports the aggregate beside its lifetime breakdown, and the
// two describe the same 248 tokens.
let usage = UsageCounts {
input_tokens: Some(10),
output_tokens: Some(20),
cache_creation_tokens: Some(248),
cache_creation_5m_tokens: Some(148),
cache_creation_1h_tokens: Some(100),
total_tokens: Some(278),
..UsageCounts::default()
};

let mut slice = QuotaUsageSliceV1::default();
slice.add_usage(&usage, None);

assert_eq!(slice.cache_creation_tokens, 248);
assert!(
slice.input_tokens + slice.output_tokens + slice.cache_creation_tokens
<= slice.total_tokens
);
}

#[test]
fn a_slice_falls_back_to_the_lifetime_breakdown() {
let usage = UsageCounts {
cache_creation_tokens: None,
cache_creation_5m_tokens: Some(8),
cache_creation_1h_tokens: Some(5),
..UsageCounts::default()
};

let mut slice = QuotaUsageSliceV1::default();
slice.add_usage(&usage, None);

assert_eq!(slice.cache_creation_tokens, 13);
}
}
Loading
Loading