Skip to content

Commit 0b27303

Browse files
jongioCopilot
andcommitted
fix: use MAX of turn/updated/created timestamps for last-active calculation
lastActiveExpr previously used COALESCE(max_turn, updated_at, created_at) which always preferred turn timestamps even when stale. Sessions with unindexed turns appeared older than their actual last activity. Changed to MAX() of all three timestamps so the most recent signal wins, whether it comes from indexed turns or session metadata. Adds 4 regression tests covering stale-turn, fresh-turn, no-turn, and time-filter visibility scenarios. Co-authored-by: Copilot <[email protected]>
1 parent d06cae7 commit 0b27303

3 files changed

Lines changed: 119 additions & 13 deletions

File tree

internal/data/models.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ type Session struct {
2020
CreatedAt string `json:"created_at"`
2121
UpdatedAt string `json:"updated_at"`
2222

23-
// LastActiveAt is computed at query time as the latest turn timestamp,
24-
// falling back to updated_at then created_at. This avoids the problem
25-
// where the Copilot CLI reindex resets updated_at on all sessions.
23+
// LastActiveAt is computed at query time as the MAX of the latest
24+
// turn timestamp, updated_at, and created_at — whichever is most
25+
// recent. Indexed turns may lag (stale reindex) while updated_at
26+
// may be noisy, so taking the maximum gives the best estimate.
2627
LastActiveAt string `json:"last_active_at"`
2728

2829
// Computed fields – populated by JOIN aggregates, not stored in the table.

internal/data/store.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,18 @@ func escapeLIKE(s string) string {
173173
}
174174

175175
// lastActiveExpr is the SQL expression that computes a session's true
176-
// "last active" time: the latest turn timestamp, falling back to
177-
// updated_at then created_at. Use this in WHERE/GROUP BY clauses
178-
// where column aliases are not available.
179-
const lastActiveExpr = `COALESCE(
180-
(SELECT MAX(t.timestamp) FROM turns t WHERE t.session_id = s.id),
181-
s.updated_at,
182-
s.created_at
176+
// "last active" time as the most recent signal across indexed turns,
177+
// updated_at, and created_at. Indexed turns may lag behind actual
178+
// activity (reindex hasn't run), while updated_at may occasionally be
179+
// noisy (metadata-only updates). Taking the MAX of all three gives
180+
// the best available estimate.
181+
//
182+
// Each value is COALESCE'd to '' so that SQLite's multi-arg MAX()
183+
// never sees NULL (which would poison the result to NULL).
184+
const lastActiveExpr = `MAX(
185+
COALESCE((SELECT MAX(t.timestamp) FROM turns t WHERE t.session_id = s.id), ''),
186+
COALESCE(s.updated_at, ''),
187+
COALESCE(s.created_at, '')
183188
)`
184189

185190
// filterBuilder accumulates JOIN and WHERE clauses with parameterised args.
@@ -296,9 +301,10 @@ func pivotExpr(p PivotField) string {
296301
}
297302

298303
// sessionColumns is the shared SELECT list used by session queries.
299-
// last_active_at is computed as the most recent turn timestamp, falling
300-
// back to updated_at then created_at so that reindex-clobbered dates
301-
// don't make old sessions appear recent.
304+
// last_active_at is the MAX of the most recent turn timestamp,
305+
// updated_at, and created_at — whichever is newest wins. This
306+
// handles both stale turn indexes (updated_at is more recent) and
307+
// noisy updated_at values (turns are more recent).
302308
var sessionColumns = `s.id, ` + coalesceCwd + `, COALESCE(s.repository,''), COALESCE(s.branch,''),
303309
COALESCE(s.summary,''), COALESCE(s.created_at,''), COALESCE(s.updated_at,''),
304310
` + lastActiveExpr + ` AS last_active_at,

internal/data/store_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,3 +2370,102 @@ func TestDeepSearchWithGroupSessions(t *testing.T) {
23702370
t.Fatalf("expected 1 session matching file path in groups, got %d", totalSessions)
23712371
}
23722372
}
2373+
2374+
// ---------------------------------------------------------------------------
2375+
// lastActiveExpr regression tests — ensures the MAX-of-three logic picks
2376+
// the most recent timestamp regardless of which source it comes from.
2377+
// ---------------------------------------------------------------------------
2378+
2379+
func TestLastActive_StaleTurnsUsesUpdatedAt(t *testing.T) {
2380+
// When turns are stale (not yet reindexed) but updated_at is recent,
2381+
// lastActiveAt should reflect updated_at.
2382+
s := newTestStore(t)
2383+
defer func() { _ = s.Close() }()
2384+
2385+
seedSession(t, s.db, "s1", "/work", "r", "main", "Stale turns",
2386+
"2026-04-20T00:00:00Z", "2026-04-27T12:00:00Z")
2387+
seedTurn(t, s.db, "s1", 0, "hello", "hi", "2026-04-20T10:00:00Z")
2388+
2389+
sessions, err := s.ListSessions(FilterOptions{}, SortOptions{Field: SortByUpdated, Order: Descending}, 0)
2390+
if err != nil {
2391+
t.Fatalf("ListSessions: %v", err)
2392+
}
2393+
if len(sessions) != 1 {
2394+
t.Fatalf("expected 1 session, got %d", len(sessions))
2395+
}
2396+
if sessions[0].LastActiveAt != "2026-04-27T12:00:00Z" {
2397+
t.Errorf("LastActiveAt = %q, want updated_at %q", sessions[0].LastActiveAt, "2026-04-27T12:00:00Z")
2398+
}
2399+
}
2400+
2401+
func TestLastActive_FreshTurnsUsedOverUpdatedAt(t *testing.T) {
2402+
// When turns are more recent than updated_at, lastActiveAt should
2403+
// use the turn timestamp.
2404+
s := newTestStore(t)
2405+
defer func() { _ = s.Close() }()
2406+
2407+
seedSession(t, s.db, "s1", "/work", "r", "main", "Fresh turns",
2408+
"2026-04-20T00:00:00Z", "2026-04-22T00:00:00Z")
2409+
seedTurn(t, s.db, "s1", 0, "hello", "hi", "2026-04-25T10:00:00Z")
2410+
2411+
sessions, err := s.ListSessions(FilterOptions{}, SortOptions{Field: SortByUpdated, Order: Descending}, 0)
2412+
if err != nil {
2413+
t.Fatalf("ListSessions: %v", err)
2414+
}
2415+
if len(sessions) != 1 {
2416+
t.Fatalf("expected 1 session, got %d", len(sessions))
2417+
}
2418+
if sessions[0].LastActiveAt != "2026-04-25T10:00:00Z" {
2419+
t.Errorf("LastActiveAt = %q, want turn timestamp %q", sessions[0].LastActiveAt, "2026-04-25T10:00:00Z")
2420+
}
2421+
}
2422+
2423+
func TestLastActive_NoTurnsFallsBackToUpdatedAt(t *testing.T) {
2424+
// Sessions with no turns should fall back to updated_at (or created_at).
2425+
s := newTestStore(t)
2426+
defer func() { _ = s.Close() }()
2427+
2428+
// Insert a session with a turn so it passes the "EXISTS turns" filter,
2429+
// then test a second session that does have turns but where updated_at
2430+
// is the winner. For the no-turns case the session is excluded by the
2431+
// zero-turn filter, so we test indirectly via GetSession.
2432+
seedSession(t, s.db, "s1", "/work", "r", "main", "No turns",
2433+
"2026-04-10T00:00:00Z", "2026-04-15T00:00:00Z")
2434+
2435+
detail, err := s.GetSession("s1")
2436+
if err != nil {
2437+
t.Fatalf("GetSession: %v", err)
2438+
}
2439+
if detail.Session.LastActiveAt != "2026-04-15T00:00:00Z" {
2440+
t.Errorf("LastActiveAt = %q, want updated_at %q", detail.Session.LastActiveAt, "2026-04-15T00:00:00Z")
2441+
}
2442+
}
2443+
2444+
func TestLastActive_StaleTurnsVisibleInDayFilter(t *testing.T) {
2445+
// Regression: a session with stale turn data but recent updated_at
2446+
// must appear under a "since 1 day ago" filter.
2447+
s := newTestStore(t)
2448+
defer func() { _ = s.Close() }()
2449+
2450+
now := time.Now()
2451+
recent := now.Add(-6 * time.Hour).UTC().Format(time.RFC3339)
2452+
old := now.Add(-72 * time.Hour).UTC().Format(time.RFC3339)
2453+
2454+
seedSession(t, s.db, "s1", "/msbench", "r", "main", "MSBench",
2455+
old, recent) // created 3d ago, updated 6h ago
2456+
seedTurn(t, s.db, "s1", 0, "build", "ok", old) // turn from 3d ago
2457+
2458+
since := now.Add(-24 * time.Hour)
2459+
sessions, err := s.ListSessions(
2460+
FilterOptions{Since: &since},
2461+
SortOptions{Field: SortByUpdated, Order: Descending}, 0)
2462+
if err != nil {
2463+
t.Fatalf("ListSessions: %v", err)
2464+
}
2465+
if len(sessions) != 1 {
2466+
t.Fatalf("expected 1 session within day filter, got %d", len(sessions))
2467+
}
2468+
if sessions[0].ID != "s1" {
2469+
t.Errorf("expected s1, got %s", sessions[0].ID)
2470+
}
2471+
}

0 commit comments

Comments
 (0)