@@ -11,11 +11,13 @@ import type { Harness, NormalizedSession, NormalizedToolUse } from "./types.js";
1111 *
1212 * Idempotency (FEA-1503 AC): re-import adds nothing new.
1313 * - session row: COALESCE-fill on conflict, never clobbers a live row.
14+ * `updated_at` stays an ingest-time mutation cursor for cloud sync; source
15+ * dates live on `started_at`, `ended_at`, events, and token usage analytics.
1416 * - events: per-(session, event_type) high-water-mark on `created_at` — only
15- * events with a transcript timestamp strictly greater than the stored max are
16- * inserted (the exact vendor mechanism). Hook-written events carry
17- * `created_at ≈ now`, so file events with past transcript timestamps fall under
18- * the high-water-mark and are never double-counted against the live hook path .
17+ * events with a source timestamp strictly greater than the stored max are
18+ * inserted. Backfill never stamps events with importer runtime `now`; when an
19+ * individual source event has no timestamp, it falls back to the session's
20+ * source timestamp so date windows remain tied to when work occurred .
1921 * - tokens: `tokenUsage.replace` nets zero when re-applying equal cumulatives.
2022 *
2123 * Each session is applied in one `BEGIN IMMEDIATE` transaction (mirrors
@@ -81,10 +83,12 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
8183 cwd = COALESCE(cwd, ?),
8284 harness = CASE WHEN COALESCE(harness, '') = '' THEN ? ELSE harness END,
8385 billing_mode = CASE WHEN COALESCE(billing_mode, '') IN ('', 'unknown') THEN ? ELSE billing_mode END,
84- metadata = ?,
85- updated_at = ?
86+ metadata = ?
8687 WHERE id = ?
8788 ` ) ;
89+ const touchSessionStmt = db . prepare (
90+ "UPDATE sessions SET updated_at = CASE WHEN updated_at IS NULL OR updated_at < ? THEN ? ELSE updated_at END WHERE id = ?" ,
91+ ) ;
8892 const reactivateSessionStmt = db . prepare (
8993 "UPDATE sessions SET status = 'active', ended_at = NULL, updated_at = ? WHERE id = ?" ,
9094 ) ;
@@ -114,11 +118,19 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
114118 return `${ sessionId } -main` ;
115119 }
116120
117- function isRecentlyActive ( session : NormalizedSession , nowMs : number ) : boolean {
121+ function isRecentlyActive (
122+ session : NormalizedSession ,
123+ nowMs : number ,
124+ sourceUpdatedAt : string ,
125+ ) : boolean {
126+ const sourceUpdatedAtMs = Date . parse ( sourceUpdatedAt ) ;
118127 return (
119128 session . fileModifiedAt != null &&
120129 Number . isFinite ( session . fileModifiedAt ) &&
121- nowMs - session . fileModifiedAt < RECENT_ACTIVITY_MS
130+ nowMs - session . fileModifiedAt < RECENT_ACTIVITY_MS &&
131+ Number . isFinite ( sourceUpdatedAtMs ) &&
132+ sourceUpdatedAtMs <= nowMs &&
133+ nowMs - sourceUpdatedAtMs < RECENT_ACTIVITY_MS
122134 ) ;
123135 }
124136
@@ -173,6 +185,29 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
173185 ) ;
174186 }
175187
188+ function sessionSourceUpdatedAt ( session : NormalizedSession , startedAt : string ) : string {
189+ let latest = startedAt ;
190+ let latestMs = Date . parse ( startedAt ) ;
191+ const consider = ( value : string | null | undefined ) : void => {
192+ if ( ! value ) return ;
193+ const ms = Date . parse ( value ) ;
194+ if ( ! Number . isFinite ( ms ) ) return ;
195+ if ( ! Number . isFinite ( latestMs ) || ms > latestMs ) {
196+ latest = value ;
197+ latestMs = ms ;
198+ }
199+ } ;
200+
201+ consider ( session . endedAt ) ;
202+ for ( const ts of session . messageTimestamps ?? [ ] ) consider ( ts ) ;
203+ for ( const toolUse of session . toolUses ?? [ ] ) consider ( toolUse . timestamp ) ;
204+ for ( const duration of session . turnDurations ?? [ ] ) consider ( duration . timestamp ) ;
205+ for ( const error of session . apiErrors ?? [ ] ) consider ( error . timestamp ) ;
206+ for ( const error of session . toolResultErrors ?? [ ] ) consider ( error . timestamp ) ;
207+
208+ return latest ;
209+ }
210+
176211 function importSession ( session : NormalizedSession , harness : Harness ) : ImportResult {
177212 if ( typeof session . sessionId !== "string" || session . sessionId . length === 0 ) {
178213 return { skipped : true , reactivated : false } ;
@@ -181,11 +216,14 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
181216 return { skipped : true , reactivated : false } ;
182217 }
183218
219+ const startedAt = session . startedAt ;
184220 const now = nowFn ( ) ;
221+ const sourceUpdatedAt = sessionSourceUpdatedAt ( session , startedAt ) ;
185222 const nowMs = Date . parse ( now ) ;
186223 const recentlyActive = isRecentlyActive (
187224 session ,
188225 Number . isNaN ( nowMs ) ? Date . now ( ) : nowMs ,
226+ sourceUpdatedAt ,
189227 ) ;
190228 const mainId = mainAgentId ( session . sessionId ) ;
191229
@@ -205,9 +243,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
205243 status ,
206244 session . cwd ?? null ,
207245 session . model ?? null ,
208- session . startedAt ,
209- session . endedAt ?? session . startedAt ,
210- status === "completed" ? session . endedAt ?? null : null ,
246+ startedAt ,
247+ now ,
248+ status === "completed" ? sourceUpdatedAt : null ,
211249 harness ,
212250 billingMode ,
213251 buildMetadata ( session , harness ) ,
@@ -223,9 +261,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
223261 status === "completed" ? "completed" : "waiting" ,
224262 null ,
225263 null ,
226- session . startedAt ,
264+ startedAt ,
227265 now ,
228- status === "completed" ? session . endedAt ?? now : null ,
266+ status === "completed" ? sourceUpdatedAt : null ,
229267 null ,
230268 null ,
231269 ) ;
@@ -238,7 +276,6 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
238276 harness ,
239277 billingMode ,
240278 buildMetadata ( session , harness ) ,
241- now ,
242279 session . sessionId ,
243280 ) ;
244281 const isLive = existing . status === "active" && existing . ended_at == null ;
@@ -261,6 +298,7 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
261298 }
262299
263300 let inserted = 0 ;
301+ let namelessEventCounter = 0 ;
264302 const addEvent = (
265303 eventType : string ,
266304 agentId : string ,
@@ -269,9 +307,14 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
269307 summary : string | null ,
270308 data : string | null ,
271309 ) : void => {
272- if ( ! ts ) return ;
310+ const eventTimestamp = ts ?? ( ( ) => {
311+ // Synthetic increment to distinguish no-timestamp events in the same
312+ // batch so they don't all collide on the high-water-mark dedup.
313+ const base = Date . parse ( sourceUpdatedAt ) ;
314+ return new Date ( base + namelessEventCounter ++ ) . toISOString ( ) ;
315+ } ) ( ) ;
273316 const prev = highWater . get ( eventType ) ;
274- if ( prev != null && ts <= prev ) return ;
317+ if ( prev != null && eventTimestamp <= prev ) return ;
275318 insertEventStmt . run (
276319 randomUUID ( ) ,
277320 session . sessionId ,
@@ -280,7 +323,7 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
280323 toolName ,
281324 summary ,
282325 data ,
283- ts ,
326+ eventTimestamp ,
284327 ) ;
285328 inserted ++ ;
286329 } ;
@@ -312,9 +355,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
312355 subagentName ( tu ) ,
313356 strOf ( input . subagent_type ) ?? null ,
314357 prompt ? prompt . slice ( 0 , 500 ) : null ,
315- tu . timestamp ?? session . startedAt ,
316- now ,
317- tu . timestamp ?? session . endedAt ?? now ,
358+ tu . timestamp ?? startedAt ,
359+ tu . timestamp ?? now ,
360+ tu . timestamp ?? sourceUpdatedAt ,
318361 mainId ,
319362 ) ;
320363 addEvent ( "PreToolUse" , subId , tu . timestamp , tu . name , "Spawned subagent" , eventData ( enrichedData ) ) ;
@@ -335,7 +378,11 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
335378
336379 // ── Tokens (store reconciles raw/effective; idempotent on equal counts) ───
337380 for ( const [ model , counts ] of Object . entries ( session . tokensByModel ?? { } ) ) {
338- deps . tokenUsage . replace ( session . sessionId , model , counts , now ) ;
381+ deps . tokenUsage . replace ( session . sessionId , model , counts , sourceUpdatedAt ) ;
382+ }
383+
384+ if ( existing != null && inserted > 0 && ! reactivated ) {
385+ touchSessionStmt . run ( now , now , session . sessionId ) ;
339386 }
340387
341388 db . exec ( "COMMIT" ) ;
0 commit comments