Skip to content

Commit 3c33e11

Browse files
authored
fix: keep active Codex sessions current (#1308)
Long-running and resumed Codex sessions can stay stale while their rollout descriptor remains open, because recursive filesystem notifications may not expose each append. Activity intervals, session freshness, and daily usage then lag until the descriptor closes. Add a capability-gated live-activity backstop that consumes identity-only Codex history hints and polls only the recently active rollout paths already indexed in SQLite. Poll-wide byte, decoder, retry, path-memory, and hot-set bounds keep recurring work independent of archive cardinality. If a shared budget ends partway through a source, the attempted work still consumes that poll's allowance while its cursor is restored so a later rotation can process the records without loss. A hinted session whose indexed source is briefly stale stays in the bounded lookup-retry window until its canonical path becomes visible. Configured hint roots are processed through rotating windows of at most 256 sources per poll. Retained cursors are least-recently-used bounded to 256 entries and 2 MiB of source paths. Cursor state is metadata-only: rewrite validation stores a SHA-256 boundary digest and incomplete records store a file offset, with the line reread under existing limits after an append. Raw history or prompt bytes are never retained between polls. Cursor resets may replay older valid hints, so hot activity and retry timestamps only move forward; observed autonomous growth cannot be shortened by replay. The normal watcher remains the low-latency path and exact-path synchronization remains the sole write path. Cancellation exits at each reader, lookup, stat, and sync boundary so daemon shutdown does not drain the remaining bounded workload. History-disabled installations and frontends without the evidenced TUI history producer retain their existing watcher/degraded-coverage behavior, while restart bootstrap is intentionally bounded to the newest 4 MiB and preceding 24 hours. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent 5a12c42 commit 3c33e11

16 files changed

Lines changed: 3359 additions & 12 deletions

cmd/agentsview/live_activity.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log"
8+
"sync"
9+
10+
"go.kenn.io/agentsview/internal/config"
11+
"go.kenn.io/agentsview/internal/db"
12+
"go.kenn.io/agentsview/internal/parser"
13+
"go.kenn.io/agentsview/internal/server"
14+
agentsync "go.kenn.io/agentsview/internal/sync"
15+
)
16+
17+
func collectLiveActivityTargets(
18+
ctx context.Context,
19+
cfg config.Config,
20+
) ([]agentsync.LiveActivityTarget, error) {
21+
var targets []agentsync.LiveActivityTarget
22+
var targetErrors []error
23+
for _, factory := range parser.ProviderFactories() {
24+
roots := cfg.ResolveDirs(factory.Definition().Type)
25+
if len(roots) == 0 {
26+
continue
27+
}
28+
provider := factory.NewProvider(parser.ProviderConfig{
29+
Roots: roots,
30+
Machine: cfg.LocalMachineName,
31+
})
32+
hints, supported, err := parser.ResolveActivityHintProvider(provider)
33+
if err != nil {
34+
targetErrors = append(targetErrors, err)
35+
continue
36+
}
37+
if !supported || hints == nil {
38+
continue
39+
}
40+
sources, err := hints.ActivityHintSources(ctx)
41+
if err != nil {
42+
targetErrors = append(targetErrors, fmt.Errorf(
43+
"%s activity hint sources: %w",
44+
factory.Definition().Type, err,
45+
))
46+
continue
47+
}
48+
if len(sources) == 0 {
49+
continue
50+
}
51+
targets = append(targets, agentsync.LiveActivityTarget{
52+
Provider: provider,
53+
Hints: hints,
54+
Sources: sources,
55+
})
56+
}
57+
return targets, errors.Join(targetErrors...)
58+
}
59+
60+
func newLiveActivityLookup(database *db.DB) agentsync.LiveActivityLookup {
61+
return func(
62+
ctx context.Context,
63+
fullSessionID string,
64+
) (agentsync.LiveActivitySource, bool, error) {
65+
session, err := database.GetSessionFull(ctx, fullSessionID)
66+
if err != nil {
67+
return agentsync.LiveActivitySource{}, false, err
68+
}
69+
if session == nil || session.FilePath == nil || *session.FilePath == "" {
70+
return agentsync.LiveActivitySource{}, false, nil
71+
}
72+
source := agentsync.LiveActivitySource{Path: *session.FilePath}
73+
if session.FileSize != nil && session.FileMtime != nil {
74+
source.StoredSize = *session.FileSize
75+
source.StoredMTimeNS = *session.FileMtime
76+
source.HasStoredStat = true
77+
}
78+
if session.FileInode != nil && session.FileDevice != nil {
79+
source.StoredInode = *session.FileInode
80+
source.StoredDevice = *session.FileDevice
81+
source.HasStoredIdentity = true
82+
}
83+
return source, true, nil
84+
}
85+
}
86+
87+
func trackLiveActivitySync(
88+
idleTracker *server.IdleTracker,
89+
syncPaths agentsync.LiveActivitySync,
90+
) agentsync.LiveActivitySync {
91+
return func(ctx context.Context, paths []string) error {
92+
done, ok := idleTracker.BeginWork()
93+
if !ok {
94+
return context.Canceled
95+
}
96+
defer done()
97+
return syncPaths(ctx, paths)
98+
}
99+
}
100+
101+
func startLiveActivityRun(
102+
ctx context.Context,
103+
cancel context.CancelFunc,
104+
poller *agentsync.LiveActivityPoller,
105+
) func() {
106+
var workers sync.WaitGroup
107+
workers.Go(func() {
108+
poller.Run(ctx)
109+
})
110+
var once sync.Once
111+
return func() {
112+
once.Do(func() {
113+
cancel()
114+
workers.Wait()
115+
})
116+
}
117+
}
118+
119+
func startLiveActivityPoller(
120+
ctx context.Context,
121+
cfg config.Config,
122+
database *db.DB,
123+
engine *agentsync.Engine,
124+
idleTracker *server.IdleTracker,
125+
) func() {
126+
runCtx, cancel := context.WithCancel(ctx)
127+
targets, err := collectLiveActivityTargets(runCtx, cfg)
128+
if err != nil {
129+
log.Printf("live activity target discovery: %v", err)
130+
}
131+
poller := agentsync.NewLiveActivityPoller(
132+
targets,
133+
newLiveActivityLookup(database),
134+
trackLiveActivitySync(idleTracker, engine.SyncPathsContext),
135+
log.Printf,
136+
)
137+
return startLiveActivityRun(runCtx, cancel, poller)
138+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strconv"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"go.kenn.io/agentsview/internal/config"
14+
"go.kenn.io/agentsview/internal/db"
15+
"go.kenn.io/agentsview/internal/dbtest"
16+
"go.kenn.io/agentsview/internal/parser"
17+
"go.kenn.io/agentsview/internal/server"
18+
agentsync "go.kenn.io/agentsview/internal/sync"
19+
)
20+
21+
func TestCollectLiveActivityTargetsUsesOnlyConfiguredHintProviders(t *testing.T) {
22+
base := t.TempDir()
23+
custom := filepath.Join(t.TempDir(), "custom")
24+
cfg := config.Config{
25+
LocalMachineName: "local",
26+
AgentDirs: map[parser.AgentType][]string{
27+
parser.AgentCodex: {
28+
filepath.Join(base, "sessions"),
29+
filepath.Join(base, "archived_sessions"),
30+
filepath.Join(custom, "sessions"),
31+
"s3://bucket/archive/sessions",
32+
},
33+
parser.AgentClaude: {filepath.Join(t.TempDir(), "claude")},
34+
},
35+
}
36+
37+
targets, err := collectLiveActivityTargets(t.Context(), cfg)
38+
39+
require.NoError(t, err)
40+
require.Len(t, targets, 1)
41+
assert.Equal(t, parser.AgentCodex, targets[0].Provider.Definition().Type)
42+
assert.Equal(t, []parser.ActivityHintSource{
43+
{Path: filepath.Join(base, "history.jsonl")},
44+
{Path: filepath.Join(custom, "history.jsonl")},
45+
}, targets[0].Sources)
46+
}
47+
48+
func TestCollectLiveActivityTargetsDoesNotRequireExistingRoots(t *testing.T) {
49+
base := t.TempDir()
50+
missing := filepath.Join(base, "missing", "sessions")
51+
cfg := config.Config{
52+
AgentDirs: map[parser.AgentType][]string{
53+
parser.AgentCodex: {missing},
54+
},
55+
}
56+
57+
targets, err := collectLiveActivityTargets(t.Context(), cfg)
58+
59+
require.NoError(t, err)
60+
require.Len(t, targets, 1)
61+
assert.Equal(t, filepath.Join(base, "missing", "history.jsonl"),
62+
targets[0].Sources[0].Path)
63+
_, statErr := os.Stat(missing)
64+
assert.ErrorIs(t, statErr, os.ErrNotExist,
65+
"target collection must not create or discover rollout roots")
66+
}
67+
68+
func TestLiveActivityIndexedLookupReturnsExactStoredMetadata(t *testing.T) {
69+
database := dbtest.OpenTestDB(t)
70+
path := filepath.Join(t.TempDir(), "rollout.jsonl")
71+
size := int64(123)
72+
mtime := int64(456)
73+
inode := int64(789)
74+
device := int64(1011)
75+
require.NoError(t, database.UpsertSession(db.Session{
76+
ID: "codex:exact-id",
77+
Project: "project",
78+
Machine: "local",
79+
Agent: string(parser.AgentCodex),
80+
FilePath: &path,
81+
FileSize: &size,
82+
FileMtime: &mtime,
83+
FileInode: &inode,
84+
FileDevice: &device,
85+
}))
86+
lookup := newLiveActivityLookup(database)
87+
88+
got, found, err := lookup(t.Context(), "codex:exact-id")
89+
90+
require.NoError(t, err)
91+
assert.True(t, found)
92+
assert.Equal(t, agentsync.LiveActivitySource{
93+
Path: path,
94+
StoredSize: size,
95+
StoredMTimeNS: mtime,
96+
StoredInode: inode,
97+
StoredDevice: device,
98+
HasStoredStat: true,
99+
HasStoredIdentity: true,
100+
}, got)
101+
102+
_, found, err = lookup(t.Context(), "codex:missing-id")
103+
require.NoError(t, err)
104+
assert.False(t, found)
105+
}
106+
107+
func TestLiveActivityIndexedLookupSchedulesRowsWithoutCompleteStat(t *testing.T) {
108+
database := dbtest.OpenTestDB(t)
109+
path := filepath.Join(t.TempDir(), "rollout.jsonl")
110+
require.NoError(t, database.UpsertSession(db.Session{
111+
ID: "codex:no-stat",
112+
Project: "project",
113+
Machine: "local",
114+
Agent: string(parser.AgentCodex),
115+
FilePath: &path,
116+
}))
117+
118+
got, found, err := newLiveActivityLookup(database)(
119+
t.Context(), "codex:no-stat",
120+
)
121+
122+
require.NoError(t, err)
123+
assert.True(t, found)
124+
assert.Equal(t, path, got.Path)
125+
assert.False(t, got.HasStoredStat)
126+
}
127+
128+
func TestStartLiveActivityRunTracksSyncAndWaitsForStop(t *testing.T) {
129+
ctx, cancel := context.WithCancel(t.Context())
130+
defer cancel()
131+
base := t.TempDir()
132+
sessions := filepath.Join(base, "sessions")
133+
history := filepath.Join(base, "history.jsonl")
134+
rollout := filepath.Join(base, "rollout.jsonl")
135+
id := "019f0000-0000-7000-8000-000000000002"
136+
now := time.Now()
137+
require.NoError(t, os.WriteFile(history, []byte(
138+
`{"session_id":"`+id+`","ts":`+
139+
strconv.FormatInt(now.Unix(), 10)+
140+
`,"text":"private prompt sentinel"}`+"\n",
141+
), 0o644))
142+
require.NoError(t, os.WriteFile(rollout, []byte("changed"), 0o644))
143+
provider, ok := parser.NewProvider(parser.AgentCodex, parser.ProviderConfig{
144+
Roots: []string{sessions},
145+
})
146+
require.True(t, ok)
147+
hints, ok, err := parser.ResolveActivityHintProvider(provider)
148+
require.NoError(t, err)
149+
require.True(t, ok)
150+
sources, err := hints.ActivityHintSources(t.Context())
151+
require.NoError(t, err)
152+
153+
idled := make(chan struct{}, 1)
154+
idle := server.NewIdleTracker(20*time.Millisecond, func() {
155+
idled <- struct{}{}
156+
})
157+
go idle.Run(ctx)
158+
159+
entered := make(chan struct{})
160+
release := make(chan struct{})
161+
finished := make(chan struct{})
162+
trackedSync := trackLiveActivitySync(idle,
163+
func(context.Context, []string) error {
164+
close(entered)
165+
<-release
166+
close(finished)
167+
return nil
168+
})
169+
runCtx, runCancel := context.WithCancel(ctx)
170+
poller := agentsync.NewLiveActivityPoller(
171+
[]agentsync.LiveActivityTarget{{
172+
Provider: provider,
173+
Hints: hints,
174+
Sources: sources,
175+
}},
176+
func(context.Context, string) (agentsync.LiveActivitySource, bool, error) {
177+
return agentsync.LiveActivitySource{Path: rollout}, true, nil
178+
},
179+
trackedSync,
180+
nil,
181+
)
182+
stop := startLiveActivityRun(runCtx, runCancel, poller)
183+
184+
select {
185+
case <-entered:
186+
case <-time.After(time.Second):
187+
require.FailNow(t, "tracked sync did not start")
188+
}
189+
select {
190+
case <-idled:
191+
require.FailNow(t, "idle callback fired while sync work was active")
192+
case <-time.After(3 * 20 * time.Millisecond):
193+
}
194+
195+
stopped := make(chan struct{})
196+
go func() {
197+
stop()
198+
close(stopped)
199+
}()
200+
select {
201+
case <-stopped:
202+
require.FailNow(t, "stop returned before active sync work completed")
203+
case <-time.After(20 * time.Millisecond):
204+
}
205+
close(release)
206+
select {
207+
case <-finished:
208+
case <-time.After(time.Second):
209+
require.FailNow(t, "tracked sync did not finish")
210+
}
211+
select {
212+
case <-stopped:
213+
case <-time.After(time.Second):
214+
require.FailNow(t, "stop did not join the poller goroutine")
215+
}
216+
}

cmd/agentsview/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,10 @@ func runServe(cfg config.Config, opts serveOptions) {
418418
log.Printf("warning: remote_hosts config invalid, skipping periodic remote sync: %v", err)
419419
validRemotes = false
420420
}
421+
stopLiveActivity := startLiveActivityPoller(
422+
ctx, cfg, database, engine, idleTracker,
423+
)
424+
defer stopLiveActivity()
421425
go startPeriodicSync(
422426
ctx, cfg, engine, database, writeLock, idleTracker, validRemotes, emitter,
423427
)

0 commit comments

Comments
 (0)