From ea4c345712e312c088efbcf9e8c5f8e0cc29b48c Mon Sep 17 00:00:00 2001 From: Peace7 Date: Fri, 23 Oct 2026 13:28:58 +0300 Subject: [PATCH] FIX: detect rollout activity despite stale Windows timestamps Use owned event timestamps and observed size changes alongside filesystem mtime. Bootstrap complete tail records, including oversized records, without recounting tokens. Preserve definite requests and real inactivity warnings. --- README.md | 5 + internal/codex/live_usage.go | 29 +- internal/codex/live_usage_test.go | 10 +- internal/codex/rollout_activity.go | 103 +++++++ internal/codex/rollout_activity_smoke_test.go | 49 ++++ internal/codex/rollout_activity_test.go | 264 ++++++++++++++++++ 6 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 internal/codex/rollout_activity.go create mode 100644 internal/codex/rollout_activity_smoke_test.go create mode 100644 internal/codex/rollout_activity_test.go 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) + } +}