Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions internal/codex/live_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -553,14 +557,18 @@ 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,
startedAt: metadata.StartedAt,
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions internal/codex/live_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions internal/codex/rollout_activity.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions internal/codex/rollout_activity_smoke_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading