Skip to content

Commit 36a0494

Browse files
committed
timeline bug patch #154
1 parent c9127b1 commit 36a0494

18 files changed

Lines changed: 1058 additions & 60 deletions

File tree

crates/atlas-checkpoint/src/import.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ use serde::{Deserialize, Serialize};
5454
use crate::blobs;
5555
use crate::capture::{Capture, SessionKey, ToolCallContent, TurnContent};
5656
use crate::error::{Error, Result};
57-
use crate::model::{Mode, Role, Source, ToolStatus, WorkspaceMode};
57+
use crate::model::{Mode, Role, Source, TokenTotals, ToolStatus, WorkspaceMode};
5858
use crate::store::Store;
5959
use crate::tools::{canonical_name, ToolName};
6060

@@ -319,6 +319,14 @@ fn import_file(
319319
// matching `tool_result` carries just the id. Remembered per file so the
320320
// result's upsert does not downgrade the stored name to `Other`.
321321
let mut call_meta: HashMap<String, (ToolName, i64)> = HashMap::new();
322+
// Usage, deduplicated by request — see `read_usage`. Accumulated for the
323+
// whole file and written once at the end, because the total is only true
324+
// once the file has been read.
325+
let mut usage_by_request: HashMap<String, TokenTotals> = HashMap::new();
326+
// `read_turn` only sees `message.model` on lines that carry text, so a
327+
// transcript whose assistant lines are all tool calls would never name its
328+
// model. Taken from any line that has one.
329+
let mut model_seen: Option<String> = None;
322330
let no_locations = serde_json::json!([]);
323331

324332
{
@@ -355,6 +363,34 @@ fn import_file(
355363
continue;
356364
}
357365

366+
// Read before the "nothing usable here" filter below: a tool-only
367+
// assistant line produces no turn and no tool_result, and dropping
368+
// it would silently lose the usage of every tool-calling request.
369+
if let Some(line_usage) = read_usage(&value) {
370+
let slot = usage_by_request.entry(line_usage.key).or_default();
371+
// Per-field max rather than overwrite: two copies of one
372+
// request disagree when the first was written mid-stream, and
373+
// the finished one is the larger.
374+
slot.input_tokens = slot.input_tokens.max(line_usage.totals.input_tokens);
375+
slot.output_tokens = slot.output_tokens.max(line_usage.totals.output_tokens);
376+
slot.cache_creation_tokens =
377+
slot.cache_creation_tokens.max(line_usage.totals.cache_creation_tokens);
378+
slot.cache_read_tokens =
379+
slot.cache_read_tokens.max(line_usage.totals.cache_read_tokens);
380+
}
381+
// Assistant lines only: the model is a property of what answered,
382+
// and a user line that happens to carry one is echoing the client's
383+
// request rather than reporting what ran.
384+
if model_seen.is_none()
385+
&& value.get("type").and_then(serde_json::Value::as_str) == Some("assistant")
386+
{
387+
model_seen = value
388+
.get("message")
389+
.and_then(|m| m.get("model"))
390+
.and_then(serde_json::Value::as_str)
391+
.map(str::to_string);
392+
}
393+
358394
let created_at = line_timestamp(&value);
359395
let turn = read_turn(&value).filter(|t| !is_envelope(t));
360396
let tool_uses = read_tool_uses(&value);
@@ -504,6 +540,30 @@ fn import_file(
504540

505541
if let Some(id) = &session_id {
506542
outcome.sessions_imported += imported_here;
543+
544+
// The whole-file total, deduplicated by request and written as a
545+
// replace. After an I/O error this covers only the prefix that was
546+
// read, which is correct: the progress marker below is not advanced, so
547+
// the next pass recomputes the file from the top.
548+
let mut usage = TokenTotals::default();
549+
for request in usage_by_request.values() {
550+
usage.input_tokens = usage.input_tokens.saturating_add(request.input_tokens);
551+
usage.output_tokens = usage.output_tokens.saturating_add(request.output_tokens);
552+
usage.cache_creation_tokens =
553+
usage.cache_creation_tokens.saturating_add(request.cache_creation_tokens);
554+
usage.cache_read_tokens =
555+
usage.cache_read_tokens.saturating_add(request.cache_read_tokens);
556+
}
557+
if usage != TokenTotals::default() {
558+
store.replace_usage_totals(id, &usage)?;
559+
}
560+
// The model, when the Session did not already have one — `COALESCE` in
561+
// the upsert takes the new value and leaves an existing one alone.
562+
if let Some(model) = &model_seen {
563+
let mut capture = Capture::new(store, mode);
564+
capture.ensure_session(&session_key, None, Some(model.as_str()), None, None)?;
565+
}
566+
507567
// Live capture stamped "now" at first sighting; the transcript knows
508568
// when the conversation really began. Without this, a year of imported
509569
// history all dates from the day the import ran.
@@ -522,6 +582,47 @@ fn import_file(
522582
Ok(())
523583
}
524584

585+
/// The usage one assistant line reports, with the request it belongs to.
586+
struct UsageLine {
587+
key: String,
588+
totals: TokenTotals,
589+
}
590+
591+
/// Read `message.usage` off an assistant line.
592+
///
593+
/// The transcript writes the same logical assistant message more than once — a
594+
/// streaming record, then its rewrite — and every copy repeats the same usage
595+
/// block. On a real transcript here, 18 usage lines collapsed to 8 requests:
596+
/// summing lines instead of requests over-counts by 2.2x. `requestId` is the
597+
/// key that collapses them, falling back to the message id and then the line's
598+
/// own uuid so a line without one is at least counted once rather than merged
599+
/// into a neighbour.
600+
fn read_usage(value: &serde_json::Value) -> Option<UsageLine> {
601+
let message = value.get("message")?;
602+
let usage = message.get("usage")?;
603+
let key = value
604+
.get("requestId")
605+
.and_then(serde_json::Value::as_str)
606+
.or_else(|| message.get("id").and_then(serde_json::Value::as_str))
607+
.or_else(|| value.get("uuid").and_then(serde_json::Value::as_str))?
608+
.to_string();
609+
610+
let field = |name: &str| usage.get(name).and_then(serde_json::Value::as_u64).unwrap_or(0);
611+
let totals = TokenTotals {
612+
input_tokens: field("input_tokens"),
613+
output_tokens: field("output_tokens"),
614+
cache_creation_tokens: field("cache_creation_input_tokens"),
615+
cache_read_tokens: field("cache_read_input_tokens"),
616+
..Default::default()
617+
};
618+
// A usage block of all zeros carries no information and would otherwise
619+
// occupy a request slot, so an empty total stays empty.
620+
if totals == TokenTotals::default() {
621+
return None;
622+
}
623+
Some(UsageLine { key, totals })
624+
}
625+
525626
/// One usable line of a transcript.
526627
struct ImportedTurn {
527628
role: Role,

crates/atlas-checkpoint/src/model.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,13 @@ pub struct Session {
277277
pub summary: Option<String>,
278278
pub started_at: DateTime<Utc>,
279279
pub updated_at: DateTime<Utc>,
280+
/// When this Session last did work — its newest message stamp or turn end.
281+
///
282+
/// Deliberately not `updated_at`, which is a row-mutation clock: deriving a
283+
/// title, recording usage or re-importing a transcript all bump that and
284+
/// none of them is work happening now. Null only for a Session with no
285+
/// message and no closed turn.
286+
pub last_activity_at: Option<DateTime<Utc>>,
280287
/// Something went wrong recording this Session and a human should know.
281288
pub needs_attention: bool,
282289
pub attention_reason: Option<String>,

crates/atlas-checkpoint/src/schema.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use rusqlite::Connection;
1616
use crate::error::{Error, Result};
1717

1818
/// Bump when adding a migration, and add the matching arm in [`migrate`].
19-
pub const SCHEMA_VERSION: i64 = 7;
19+
pub const SCHEMA_VERSION: i64 = 8;
2020

2121
pub fn migrate(conn: &Connection) -> Result<()> {
2222
// Fast path, outside any transaction: the overwhelmingly common case is a
@@ -70,6 +70,11 @@ pub fn migrate(conn: &Connection) -> Result<()> {
7070
// a store that half-applied it must still be openable.
7171
apply_tolerant(conn, V7)?;
7272
}
73+
if found < 8 {
74+
// Same tolerance again: one ALTER TABLE, two indexes, and three
75+
// repair statements that are all safe to re-run.
76+
apply_tolerant(conn, V8)?;
77+
}
7378
conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
7479
Ok(())
7580
})();
@@ -132,6 +137,48 @@ const V7: &str = r#"
132137
ALTER TABLE agent_session ADD COLUMN branch TEXT;
133138
"#;
134139

140+
const V8: &str = r#"
141+
-- When the Session last did work, as opposed to when its row was last written.
142+
--
143+
-- `updated_at` is stamped with the wall clock by every write there is: an
144+
-- upsert, a title derivation, a token update, an import. The Timeline read it
145+
-- as the end of the Session and got `updated_at - started_at` as a duration,
146+
-- so a transcript that ran in June and was imported in July reported 1395
147+
-- hours, and the day grouping filed a year of history under Today. Activity
148+
-- and mutation are two different facts and now live in two different columns.
149+
ALTER TABLE agent_session ADD COLUMN last_activity_at TEXT;
150+
151+
-- Backfill from the only timestamps in the store that were never the wall clock
152+
-- at write time: a message carries the transcript's own stamp, and a turn ends
153+
-- when the turn ended.
154+
UPDATE agent_session SET last_activity_at = (
155+
SELECT MAX(stamp) FROM (
156+
SELECT MAX(created_at) AS stamp FROM agent_message WHERE session_id = agent_session.id
157+
UNION ALL
158+
SELECT MAX(ended_at) AS stamp FROM turn WHERE session_id = agent_session.id
159+
)
160+
);
161+
162+
-- A Session with no message and no closed turn has nothing better to offer.
163+
UPDATE agent_session SET last_activity_at = updated_at WHERE last_activity_at IS NULL;
164+
165+
-- Board ordering and day bucketing.
166+
CREATE INDEX IF NOT EXISTS idx_session_activity
167+
ON agent_session (workspace_id, last_activity_at);
168+
169+
-- Covers the gap-capped active-time scan, which reads (session_id, created_at)
170+
-- and nothing else.
171+
CREATE INDEX IF NOT EXISTS idx_message_activity
172+
ON agent_message (session_id, created_at);
173+
174+
-- Re-read every transcript once. Token usage was never parsed out of the JSONL,
175+
-- so every imported Session carries an empty total — and progress is keyed on
176+
-- file size, which means a naive re-run skips the entire corpus. Clearing this
177+
-- is the whole token backfill. Re-reading is idempotent: every line carries the
178+
-- agent's own message id, and the usage write is a replace rather than a sum.
179+
DELETE FROM import_progress;
180+
"#;
181+
135182
const V1: &str = r#"
136183
-- One row per Session. Named `agent_session`, never `session`: the server's
137184
-- `session` table belongs to Better Auth, and the local schema mirrors the
@@ -514,4 +561,6 @@ pub const REQUIRED_INDEXES: &[&str] = &[
514561
"idx_checkpoint_patch",
515562
"idx_checkpoint_outbox",
516563
"idx_file_touch_unconsumed",
564+
"idx_session_activity",
565+
"idx_message_activity",
517566
];

0 commit comments

Comments
 (0)