diff --git a/README.md b/README.md index 6156f81..b534550 100644 --- a/README.md +++ b/README.md @@ -1173,6 +1173,11 @@ The other top-level views are: minutes receives `CHECK SESSION`: a deliberately uncertain prompt that can also mean a long-running local tool. Subsequent activity from the root or any linked agent clears the uncertain group-level warning. + Activity detection uses recorded event timestamps and observed file-size + changes as well as modification times, since Windows can retain an old + modification time while a rollout writer remains open. Startup examines a + recent tail, extending through its overlapping JSONL record so large tool + results and reasoning items still establish activity without recounting tokens. Codexometer never guesses `APPROVAL NEEDED` from inactivity. Closing the CLI releases its per-thread writer lock and clears every attention badge. A session already included in the current Sessions recording can remain as an diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index b58a9c6..7b837e2 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -126,6 +126,7 @@ type LiveTurnTiming struct { // sessions. It also extracts bounded display-only replies and request context; // reasoning and arbitrary tool output are never retained. type LiveUsageReader struct { + now func() time.Time sessionNames map[string]string nameIndexInfo os.FileInfo daemonContexts map[string]SessionContext @@ -167,6 +168,9 @@ type rolloutCursor struct { totalTokens int64 observedTokens int64 lastModified time.Time + lastSize int64 + lastEventAt time.Time + lastGrowthAt time.Time threadID string parentThreadID string workingDirectory string @@ -365,7 +369,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover return LiveUsageSnapshot{}, err } - now := time.Now() + now := r.activityNow() if !r.initialized { if err := r.initialize(ctx, now); err != nil { return LiveUsageSnapshot{}, err @@ -400,7 +404,7 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover if err := contextErr(ctx); err != nil { return LiveUsageSnapshot{}, err } - if now.Sub(cursor.lastModified) > activeRolloutHorizon { + if now.Sub(cursor.activityTime()) > activeRolloutHorizon { continue } if err := r.consume(path, cursor); err != nil { @@ -462,7 +466,7 @@ func (r *LiveUsageReader) observedThreadIDs(now time.Time) []string { seen := make(map[string]struct{}, len(r.files)) threadIDs := make([]string, 0, len(r.files)) for _, cursor := range r.files { - if cursor.threadID == "" || now.Sub(cursor.lastModified) > activeRolloutHorizon { + if cursor.threadID == "" || now.Sub(cursor.activityTime()) > activeRolloutHorizon { continue } if _, ok := seen[cursor.threadID]; ok { @@ -522,7 +526,7 @@ func (r *LiveUsageReader) discover(ctx context.Context, now time.Time, full bool return nil } if cursor, exists := r.files[path]; exists { - cursor.lastModified = info.ModTime() + cursor.observeFile(info, r.activityNow()) return nil } if err := r.addFile(path, info); err != nil { @@ -553,6 +557,7 @@ func (r *LiveUsageReader) addFile(path string, info os.FileInfo) error { } cursor := &rolloutCursor{ lastModified: info.ModTime(), + lastSize: info.Size(), threadID: metadata.ID, parentThreadID: metadata.ParentThreadID, workingDirectory: metadata.WorkingDirectory, @@ -560,7 +565,10 @@ func (r *LiveUsageReader) addFile(path string, info os.FileInfo) error { nonRoot: metadata.NonRoot, subagentHistoryStartOrdinal: metadata.SubagentHistoryStartOrdinal, } - if time.Since(info.ModTime()) <= activeRolloutHorizon { + if err := cursor.bootstrapActivity(path, info.Size(), r.activityNow()); err != nil { + return err + } + if r.activityNow().Sub(cursor.activityTime()) <= activeRolloutHorizon { attention, err := latestSessionAttention(path, cursor) if err != nil { return err @@ -618,7 +626,7 @@ func (r *LiveUsageReader) consume(path string, cursor *rolloutCursor) error { if err != nil { return err } - cursor.lastModified = info.ModTime() + cursor.observeFile(info, r.activityNow()) if info.Size() < cursor.offset { total, latestErr := latestTokenTotal(path) if latestErr != nil { @@ -668,6 +676,7 @@ func (r *LiveUsageReader) consume(path string, cursor *rolloutCursor) error { line, readErr := reader.ReadBytes('\n') if len(line) > 0 && line[len(line)-1] == '\n' { cursor.offset += int64(len(line)) + cursor.observeEvent(line, r.activityNow()) if settings, ordinal, at, ok := rolloutSessionSettingsRecord(line, cursor.modelSettings); ok && tokenRecordIsOwned(ordinal, cursor.subagentHistoryStartOrdinal, at, cursor.nonRoot, cursor.startedAt) { cursor.modelSettings = settings @@ -1329,7 +1338,7 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string for _, cursor := range r.files { if cursor.threadID != "" { previous := byID[cursor.threadID] - if previous == nil || cursor.lastModified.After(previous.lastModified) { + if previous == nil || cursor.activityTime().After(previous.activityTime()) { byID[cursor.threadID] = cursor } } @@ -1339,7 +1348,7 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string groupHasFreshActivity := make(map[string]bool) groupWorking := make(map[string]bool) for _, cursor := range r.files { - quietFor := now.Sub(cursor.lastModified) + quietFor := now.Sub(cursor.activityTime()) _, writerActive := liveWriters[cursor.threadID] exactStatus, exact := exactStatuses[cursor.threadID] // A currently loaded, ready daemon thread is still active even without @@ -1404,8 +1413,8 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string // root is demonstrably active, even if a linked child was left quiet // overnight. Definite input and approval signals still propagate. groupHasFreshActivity[rootID] = groupHasFreshActivity[rootID] || quietFor < fallbackAttentionAfter || exactWorking - if cursor.lastModified.After(group.LastActivity) { - group.LastActivity = cursor.lastModified + if cursor.activityTime().After(group.LastActivity) { + group.LastActivity = cursor.activityTime() } if group.WorkingDirectory == "" { group.WorkingDirectory = cursor.workingDirectory diff --git a/internal/codex/live_usage_test.go b/internal/codex/live_usage_test.go index d254bfd..90c4adb 100644 --- a/internal/codex/live_usage_test.go +++ b/internal/codex/live_usage_test.go @@ -182,26 +182,26 @@ func TestFreshLiveUsageReadNoticesAResumedDormantSession(t *testing.T) { func TestLiveUsageReaderExpiresInactiveSessionCount(t *testing.T) { home := t.TempDir() - now := time.Now() + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) path := testRolloutPath(t, home, now.Add(-time.Hour), "inactive") writeRollout(t, path, tokenCountLine(now.Add(-time.Hour), 100)+"\n") reader, err := NewLiveUsageReader(home) if err != nil { t.Fatal(err) } + freezeRolloutTime(t, path, now) + reader.now = func() time.Time { return now } if _, err := reader.FetchTokenUsage(context.Background()); err != nil { t.Fatal(err) } appendRollout(t, path, tokenCountLine(now, 125)+"\n") + freezeRolloutTime(t, path, now) usage, err := reader.FetchTokenUsage(context.Background()) if err != nil || usage.SessionCount != 1 { t.Fatalf("active session count = %#v, %v; want one", usage, err) } - inactiveAt := now.Add(-6 * time.Minute) - if err := os.Chtimes(path, inactiveAt, inactiveAt); err != nil { - t.Fatal(err) - } + now = now.Add(6 * time.Minute) usage, err = reader.FetchTokenUsage(context.Background()) if err != nil || usage.SessionCount != 0 { t.Fatalf("inactive session count = %#v, %v; want zero", usage, err) diff --git a/internal/codex/rollout_activity.go b/internal/codex/rollout_activity.go new file mode 100644 index 0000000..521398b --- /dev/null +++ b/internal/codex/rollout_activity.go @@ -0,0 +1,103 @@ +package codex + +import ( + "bytes" + "encoding/json" + "io" + "os" + "time" +) + +// Match the context bootstrap target. Extend through one overlapping record +// when needed, so a large tool result or reasoning item is not discarded. +const rolloutActivityTailBytes = 256 * 1024 + +func (r *LiveUsageReader) activityNow() time.Time { + if r.now != nil { + return r.now() + } + return time.Now() +} + +// Windows can retain an old modification time while the writer handle is open. +// Keep that metadata separate from observed growth and persisted event time. +func (c *rolloutCursor) activityTime() time.Time { + latest := c.lastModified + if c.lastEventAt.After(latest) { + latest = c.lastEventAt + } + if c.lastGrowthAt.After(latest) { + latest = c.lastGrowthAt + } + return latest +} + +func (c *rolloutCursor) observeFile(info os.FileInfo, now time.Time) { + c.lastModified = info.ModTime() + if info.Size() != c.lastSize { + c.lastGrowthAt = now + c.lastSize = info.Size() + } +} + +func (c *rolloutCursor) observeEvent(line []byte, now time.Time) { + var event struct { + Timestamp time.Time `json:"timestamp"` + Ordinal *uint64 `json:"ordinal"` + Type string `json:"type"` + } + if json.Unmarshal(line, &event) != nil || event.Type == "" || event.Type == "session_meta" || + event.Timestamp.IsZero() || event.Timestamp.After(now) || + !tokenRecordIsOwned(event.Ordinal, c.subagentHistoryStartOrdinal, event.Timestamp, c.nonRoot, c.startedAt) { + return + } + if event.Timestamp.After(c.lastEventAt) { + c.lastEventAt = event.Timestamp + } +} + +// Existing records establish activity age, never new token usage. Ignore an +// incomplete final record and future timestamps rather than keeping old logs +// active indefinitely. Only headers are decoded; no content is retained. +func (c *rolloutCursor) bootstrapActivity(path string, size int64, now time.Time) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + start := max(size-rolloutActivityTailBytes, 0) + start, err = rolloutActivityRecordStart(f, start) + if err != nil { + return err + } + data, err := io.ReadAll(io.NewSectionReader(f, start, size-start)) + if err != nil { + return err + } + lines := bytes.Split(data, []byte{'\n'}) + for _, line := range lines[:max(len(lines)-1, 0)] { + c.observeEvent(line, now) + } + return nil +} + +// Find the beginning of the record overlapping the target tail. Fixed chunks +// avoid repeatedly copying a growing carry buffer. Read at most that record, +// not earlier history; memory for decoding follows the existing JSONL reader's +// largest-record behavior rather than imposing a truncating record limit. +func rolloutActivityRecordStart(f *os.File, start int64) (int64, error) { + const chunkBytes = 64 * 1024 + buffer := make([]byte, chunkBytes) + for start > 0 { + size := min(start, int64(len(buffer))) + start -= size + n, err := f.ReadAt(buffer[:size], start) + if err != nil && err != io.EOF { + return 0, err + } + if index := bytes.LastIndexByte(buffer[:n], '\n'); index >= 0 { + return start + int64(index) + 1, nil + } + } + return 0, nil +} diff --git a/internal/codex/rollout_activity_smoke_test.go b/internal/codex/rollout_activity_smoke_test.go new file mode 100644 index 0000000..a4d2f57 --- /dev/null +++ b/internal/codex/rollout_activity_smoke_test.go @@ -0,0 +1,49 @@ +package codex + +import ( + "context" + "os" + "testing" +) + +// Explicit opt-in, read-only check against an already running local session. +// It never starts an app-server, a model turn, or a tool in that session. +func TestLiveRolloutActivitySmoke(t *testing.T) { + home, thread := os.Getenv("CODEXOMETER_ACTIVITY_SMOKE_HOME"), os.Getenv("CODEXOMETER_ACTIVITY_SMOKE_THREAD") + if home == "" || thread == "" { + t.Skip("set both CODEXOMETER_ACTIVITY_SMOKE_HOME and CODEXOMETER_ACTIVITY_SMOKE_THREAD") + } + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + if _, err := r.FetchTokenUsage(context.Background()); err != nil { + t.Fatal(err) + } + for _, c := range r.files { + if c.threadID != thread { + continue + } + now := r.activityNow() + t.Logf("filesystem=%s event=%s detected=%s age=%s", c.lastModified, c.lastEventAt, c.activityTime(), now.Sub(c.activityTime())) + if now.Sub(c.lastModified) < fallbackAttentionAfter { + t.Skip("filesystem timestamp is fresh; stale-timestamp case not present") + } + if now.Sub(c.lastEventAt) >= fallbackAttentionAfter { + t.Skip("no fresh persisted event available in this session") + } + sessions, _, _ := r.sessionSnapshots(now, map[string]struct{}{thread: {}}, true, nil) + for _, s := range sessions { + if s.ID == thread { + t.Logf("session=%s active=%v working=%v attention=%v", thread, s.Active, s.Working, s.Attention) + if s.Attention == SessionAttentionCheck { + t.Fatal("fresh actual events still produced CHECK SESSION") + } + return + } + } + t.Fatal("session was hidden despite fresh events") + } + t.Fatal("requested session log not found") +} diff --git a/internal/codex/rollout_activity_test.go b/internal/codex/rollout_activity_test.go new file mode 100644 index 0000000..dcde489 --- /dev/null +++ b/internal/codex/rollout_activity_test.go @@ -0,0 +1,264 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +func TestRolloutActivityBootstrapUsesEventTimeWithStaleModTime(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + old := now.Add(-72 * time.Hour) + home := t.TempDir() + path := testRolloutPath(t, home, old, "growing") + writeRollout(t, path, sessionMetaLine("growing", `"cli"`, "/work", nil)+"\n"+ + attentionEventLine(now.Add(-time.Minute), "task_started", nil)+"\n"+ + responseItemLine(now.Add(-time.Second), "reasoning")+"\n") + freezeRolloutTime(t, path, old) + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.now = func() time.Time { return now } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + usage, err := r.FetchTokenUsage(context.Background()) + if err != nil || usage.TotalTokens != 0 { + t.Fatalf("bootstrap counted tokens or failed: %+v, %v", usage, err) + } + assertRolloutAttention(t, r, now, SessionAttentionNone, true) + if !r.files[path].activityTime().Equal(now.Add(-time.Second)) { + t.Fatalf("bootstrap activity = %v", r.files[path].activityTime()) + } +} + +func TestRolloutActivityBootstrapOversizedLatestRecord(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + for _, kind := range []string{"function_call_output", "reasoning"} { + t.Run(kind, func(t *testing.T) { + old := now.Add(-72 * time.Hour) + home := t.TempDir() + path := testRolloutPath(t, home, old, "growing") + payload := map[string]any{"type": kind, "output": strings.Repeat("x", rolloutActivityTailBytes+1)} + line, err := json.Marshal(map[string]any{"timestamp": now, "type": "response_item", "payload": payload}) + if err != nil { + t.Fatal(err) + } + writeRollout(t, path, sessionMetaLine("growing", `"cli"`, "/work", nil)+"\n"+ + attentionEventLine(old, "task_started", nil)+"\n"+string(line)+"\n") + freezeRolloutTime(t, path, old) + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.now = func() time.Time { return now } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + usage, err := r.FetchTokenUsage(context.Background()) + if err != nil || usage.TotalTokens != 0 { + t.Fatalf("bootstrap = %+v, %v", usage, err) + } + if !r.files[path].lastEventAt.Equal(now) { + t.Fatalf("latest owned event lost: %v", r.files[path].lastEventAt) + } + assertRolloutAttention(t, r, now, SessionAttentionNone, true) + }) + } +} + +func TestRolloutActivityBootstrapKeepsRecordAtExactTailBoundary(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + path := testRolloutPath(t, t.TempDir(), now, "boundary") + recent := responseItemLine(now, "reasoning") + "\n" + // The first recent record begins exactly at size-tailTarget. The rest of + // the tail is an unfinished record and must not hide the complete one. + data := "{}\n" + recent + strings.Repeat(" ", rolloutActivityTailBytes-len(recent)) + writeRollout(t, path, data) + c := &rolloutCursor{} + if err := c.bootstrapActivity(path, int64(len(data)), now); err != nil { + t.Fatal(err) + } + if !c.lastEventAt.Equal(now) { + t.Fatalf("boundary event dropped: %v", c.lastEventAt) + } +} + +func TestRolloutActivityBootstrapOversizedPartialRecord(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + path := testRolloutPath(t, t.TempDir(), now, "partial") + line, err := json.Marshal(map[string]any{"timestamp": now, "type": "response_item", "payload": map[string]any{ + "type": "reasoning", "text": strings.Repeat("x", rolloutActivityTailBytes+1), + }}) + if err != nil { + t.Fatal(err) + } + writeRollout(t, path, string(line)) + c := &rolloutCursor{} + if err := c.bootstrapActivity(path, int64(len(line)), now); err != nil { + t.Fatal(err) + } + if !c.lastEventAt.IsZero() { + t.Fatalf("unterminated oversized event accepted: %v", c.lastEventAt) + } +} + +func TestRolloutActivityBootstrapRespectsOrdinalBoundary(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + path := testRolloutPath(t, t.TempDir(), now, "ordinal") + line := `{"timestamp":"2026-10-23T09:00:00Z","ordinal":9,"type":"response_item","payload":{"type":"reasoning"}}` + "\n" + writeRollout(t, path, line) + boundary := uint64(10) + c := &rolloutCursor{nonRoot: true, subagentHistoryStartOrdinal: &boundary} + if err := c.bootstrapActivity(path, int64(len(line)), now); err != nil { + t.Fatal(err) + } + if !c.lastEventAt.IsZero() { + t.Fatalf("inherited ordinal accepted: %v", c.lastEventAt) + } +} + +func TestRolloutActivityGrowthClearsCheckAndUnchangedPollsExpire(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + old := now.Add(-72 * time.Hour) + home := t.TempDir() + path := testRolloutPath(t, home, old, "growing") + writeRollout(t, path, sessionMetaLine("growing", `"cli"`, "/work", nil)+"\n"+ + attentionEventLine(old, "task_started", nil)+"\n") + freezeRolloutTime(t, path, old) + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.now = func() time.Time { return now } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + if _, err := r.FetchTokenUsage(context.Background()); err != nil { + t.Fatal(err) + } + // A real open writer would produce CHECK while its old log is quiet. + r.files[path].attention = sessionAttentionWorking + assertRolloutAttention(t, r, now, SessionAttentionCheck, true) + // A complete event arrives with no token-count update or mtime change. + appendRollout(t, path, responseItemLine(now, "reasoning")+"\n") + freezeRolloutTime(t, path, old) + usage, err := r.FetchTokenUsageFresh(context.Background()) + if err != nil || usage.TotalTokens != 0 { + t.Fatalf("growth counted tokens or failed: %+v, %v", usage, err) + } + assertRolloutAttention(t, r, now, SessionAttentionNone, true) + // Re-reading/discovery of unchanged bytes must not keep the log fresh. + now = now.Add(fallbackAttentionAfter + time.Second) + if _, err := r.FetchTokenUsageFresh(context.Background()); err != nil { + t.Fatal(err) + } + assertRolloutAttention(t, r, now, SessionAttentionCheck, true) +} + +func TestRolloutActivityPartialRecordRefreshesOnlyWhenSizeChanges(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + old := now.Add(-time.Hour) + home := t.TempDir() + path := testRolloutPath(t, home, old, "growing") + writeRollout(t, path, sessionMetaLine("growing", `"cli"`, "/work", nil)+"\n") + freezeRolloutTime(t, path, old) + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.now = func() time.Time { return now } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + if _, err := r.FetchTokenUsage(context.Background()); err != nil { + t.Fatal(err) + } + r.files[path].attention = sessionAttentionWorking + appendRollout(t, path, `{"timestamp":`) + freezeRolloutTime(t, path, old) + if _, err := r.FetchTokenUsage(context.Background()); err != nil { + t.Fatal(err) + } + assertRolloutAttention(t, r, now, SessionAttentionNone, true) + now = now.Add(fallbackAttentionAfter + time.Second) + if _, err := r.FetchTokenUsageFresh(context.Background()); err != nil { + t.Fatal(err) + } + assertRolloutAttention(t, r, now, SessionAttentionCheck, true) +} + +func TestRolloutActivityPreservesDefiniteRequests(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + for _, tc := range []struct { + kind string + want SessionAttention + }{ + {"request_user_input", SessionAttentionInput}, + {"exec_approval_request", SessionAttentionApproval}, + } { + t.Run(tc.kind, func(t *testing.T) { + home := t.TempDir() + old := now.Add(-time.Hour) + path := testRolloutPath(t, home, old, "growing") + writeRollout(t, path, sessionMetaLine("growing", `"cli"`, "/work", nil)+"\n"+ + attentionEventLine(old, tc.kind, nil)+"\n") + freezeRolloutTime(t, path, old) + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.now = func() time.Time { return now } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + if _, err := r.FetchTokenUsage(context.Background()); err != nil { + t.Fatal(err) + } + appendRollout(t, path, responseItemLine(now, "reasoning")+"\n") + freezeRolloutTime(t, path, old) + if _, err := r.FetchTokenUsage(context.Background()); err != nil { + t.Fatal(err) + } + assertRolloutAttention(t, r, now, tc.want, false) + }) + } +} + +func TestRolloutActivityIgnoresFutureInheritedAndPartialEvents(t *testing.T) { + now := time.Date(2026, time.October, 23, 9, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name, data string + child bool + }{ + {"future", responseItemLine(now.Add(time.Hour), "reasoning") + "\n", false}, + {"inherited", responseItemLine(now.Add(-time.Hour), "reasoning") + "\n", true}, + {"partial", responseItemLine(now, "reasoning"), false}, + {"malformed", "not JSON\n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + path := testRolloutPath(t, t.TempDir(), now, tc.name) + writeRollout(t, path, tc.data) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + c := &rolloutCursor{nonRoot: tc.child, startedAt: now} + if err := c.bootstrapActivity(path, info.Size(), now); err != nil { + t.Fatal(err) + } + if !c.lastEventAt.IsZero() { + t.Fatalf("unreliable activity accepted: %v", c.lastEventAt) + } + }) + } +} + +func freezeRolloutTime(t *testing.T, path string, at time.Time) { + t.Helper() + if err := os.Chtimes(path, at, at); err != nil { + t.Fatal(err) + } +} + +func assertRolloutAttention(t *testing.T, r *LiveUsageReader, now time.Time, want SessionAttention, working bool) { + t.Helper() + sessions, _, _ := r.sessionSnapshots(now, map[string]struct{}{"growing": {}}, true, nil) + if len(sessions) != 1 || sessions[0].Attention != want || sessions[0].Working != working { + t.Fatalf("sessions = %+v; want attention %v, working %v", sessions, want, working) + } +}