fix: restore console run accounting from persisted logs

This commit is contained in:
2026-10-09 02:27:27 +09:00
parent e5ea7544d4
commit f1287f27e7
19 changed files with 1023 additions and 39 deletions
@@ -83,6 +83,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
input_tokens,
output_tokens,
cache_read_input_tokens,
..
} => {
println!(
"[usage] in={} (cache_read={}) out={}",
+6 -9
View File
@@ -178,7 +178,10 @@ impl WorkerHandle {
(entries, entry_rx, in_flight)
};
let mut session =
session_store::public_snapshot::project_current_session_snapshot(&entries);
session_store::public_snapshot::project_current_session_snapshot_with_accounting(
self.artifact_store.as_ref(),
&entries,
);
session.pending_submissions = self
.pending_activations
.lock()
@@ -1286,14 +1289,8 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
});
});
let tx = working_event_tx.clone();
worker.on_usage(move |event| {
let _ = tx.send(Event::Usage {
input_tokens: event.input_tokens,
output_tokens: event.output_tokens,
cache_read_input_tokens: event.cache_read_input_tokens,
});
});
// Usage is published from committed LlmUsage records through the session-log
// lane. Snapshot and live usage therefore share one exact, gap-free boundary.
let tx = working_event_tx.clone();
worker.on_error(move |event| {
+5 -1
View File
@@ -494,7 +494,11 @@ impl InternalWorkerSessionHandle {
(entries, snapshot_from_guard(&guard))
};
InternalWorkerSessionSnapshot {
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
session:
session_store::public_snapshot::project_current_session_snapshot_with_accounting(
&self.store,
&entries,
),
greeting: self.greeting.as_ref().map(|greeting| {
greeting
.read()
+16 -1
View File
@@ -96,7 +96,22 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
item: value,
})
}
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
LogEntry::Invoke { ts, trigger } => Some(Event::InvokeStart {
kind: trigger,
timestamp_ms: Some(ts),
}),
LogEntry::LlmUsage {
ts,
input_total_tokens,
cache_read_tokens,
output_tokens,
..
} => Some(Event::Usage {
timestamp_ms: Some(ts),
input_tokens: Some(input_total_tokens),
cache_read_input_tokens: Some(cache_read_tokens),
output_tokens: Some(output_tokens),
}),
other => {
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
// variants off the broadcast lane; reaching here means the two are
+49
View File
@@ -124,6 +124,7 @@ impl SegmentLogSink {
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
| LogEntry::LlmUsage { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunResumed { .. }
| LogEntry::RunCancelled { .. }
@@ -357,6 +358,54 @@ mod tests {
assert!(rx.try_recv().is_err());
}
#[test]
fn persisted_usage_is_split_once_between_snapshot_and_live_even_with_equal_timestamps() {
let sink = SegmentLogSink::new();
sink.publish(LogEntry::Invoke {
ts: 100,
trigger: protocol::InvokeKind::UserSend,
});
let usage = LogEntry::LlmUsage {
ts: 200,
history_len: 2,
input_total_tokens: 2000,
cache_read_tokens: 1000,
cache_write_tokens: 0,
output_tokens: 100,
};
sink.publish(usage.clone());
let (snapshot, mut rx) = sink.subscribe_with_snapshot();
assert_eq!(
snapshot
.iter()
.filter(|e| matches!(e, LogEntry::LlmUsage { .. }))
.count(),
1
);
assert!(rx.try_recv().is_err());
sink.publish(usage);
let event =
crate::ipc::protocol_session::live_log_entry_event(rx.try_recv().unwrap()).unwrap();
assert!(matches!(
event,
protocol::Event::Usage {
timestamp_ms: Some(200),
input_tokens: Some(2000),
..
}
));
assert!(rx.try_recv().is_err());
let (reconnected, mut next_rx) = sink.subscribe_with_snapshot();
assert_eq!(
reconnected
.iter()
.filter(|e| matches!(e, LogEntry::LlmUsage { .. }))
.count(),
2
);
assert!(next_rx.try_recv().is_err());
}
#[test]
fn reset_with_initial_clears_and_broadcasts() {
let sink = SegmentLogSink::new();
+3
View File
@@ -294,6 +294,9 @@ impl SessionCapture {
// Observation deliberately excludes system items and
// controller errors from model-visible session evidence.
SessionSnapshotEntryData::SystemItem { .. }
| SessionSnapshotEntryData::Invoke { .. }
| SessionSnapshotEntryData::Usage { .. }
| SessionSnapshotEntryData::RunStats { .. }
| SessionSnapshotEntryData::RunYielded { .. }
| SessionSnapshotEntryData::RunResumed { .. }
| SessionSnapshotEntryData::RunCancelled
+64
View File
@@ -1556,6 +1556,70 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
}
}
#[tokio::test]
async fn reconnect_snapshot_restores_live_run_accounting_from_engine_events() {
let client = MockClient::new(vec![
LlmEvent::tool_use_start(0, "hold-call", "Hold"),
LlmEvent::tool_input_delta(0, "{}"),
LlmEvent::tool_use_stop(0),
LlmEvent::Usage(UsageEvent {
input_tokens: Some(25_000),
output_tokens: Some(300),
cache_read_input_tokens: Some(20_000),
..Default::default()
}),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let mut worker = make_worker(client).await;
worker
.engine_mut()
.register_tool(hanging_tool_definition("Hold"));
let handle = spawn_controller(worker).await;
let mut events = handle.sink.subscribe_with_snapshot().1;
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"keep running",
))
.await
.unwrap();
let observed = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if let LogEntry::LlmUsage {
ts,
input_total_tokens,
cache_read_tokens,
output_tokens,
..
} = events.recv().await.unwrap()
{
break (ts, input_total_tokens, cache_read_tokens, output_tokens);
}
}
})
.await
.expect("live accounting");
assert_eq!((observed.1, observed.2, observed.3), (25_000, 20_000, 300));
for _ in 0..2 {
let Event::Snapshot { session, .. } = handle.snapshot_event() else {
panic!("expected snapshot")
};
let usage: Vec<_> = session
.entries
.iter()
.filter(|e| matches!(e.data, protocol::SessionSnapshotEntryData::Usage { .. }))
.collect();
assert_eq!(usage.len(), 1);
assert_eq!(usage[0].timestamp, observed.0);
assert!(session.entries.iter().any(|e| matches!(
e.data,
protocol::SessionSnapshotEntryData::Invoke { .. }
) && e.timestamp > 0));
}
}
#[tokio::test]
async fn attach_snapshot_includes_current_status() {
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);