Skip to content

Commit c180419

Browse files
authored
[codex] scope unwatched root polling (#738)
This PR stops the unwatched-root fallback from re-running a global sync every two minutes. It adds SyncRootsSince, which keeps the existing incremental mtime cutoff behavior while limiting discovery to the roots reported as unwatched by the file watcher. The existing full sync and full resync paths remain unscoped. The startup poller now uses the recorded last-sync start time with a small safety margin, so watcher-budget exhaustion polls only the affected roots. The watcher-unavailable all sentinel still falls back to the previous global behavior. Co-authored-by: Jesse Vincent <obra@users.noreply.github.com>
1 parent 57c9512 commit c180419

5 files changed

Lines changed: 523 additions & 153 deletions

File tree

cmd/agentsview/main.go

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/signal"
1111
"path/filepath"
12+
"slices"
1213
"syscall"
1314
"time"
1415
_ "time/tzdata"
@@ -282,7 +283,7 @@ func runServe(cfg config.Config) {
282283
)
283284
defer stopWatcher()
284285
if len(unwatchedDirs) > 0 {
285-
go startUnwatchedPoll(engine)
286+
go startUnwatchedPoll(engine, unwatchedDirs)
286287
}
287288
}
288289

@@ -513,62 +514,7 @@ func startFileWatcher(
513514
return func() {}, []string{"all"}
514515
}
515516

516-
type watchRoot struct {
517-
dir string
518-
root string // actual path passed to WatchRecursive
519-
shallow bool // use shallow watch (root only)
520-
}
521-
522-
var roots []watchRoot
523-
seenRoots := make(map[string]struct{})
524-
addRoot := func(r watchRoot) {
525-
if _, ok := seenRoots[r.root]; ok {
526-
return
527-
}
528-
seenRoots[r.root] = struct{}{}
529-
roots = append(roots, r)
530-
}
531-
for _, def := range parser.Registry {
532-
if !def.FileBased {
533-
continue
534-
}
535-
for _, d := range cfg.ResolveDirs(def.Type) {
536-
if def.ShallowWatchRootsFunc != nil {
537-
for _, watchDir := range def.ShallowWatchRootsFunc(d) {
538-
if _, err := os.Stat(watchDir); err == nil {
539-
addRoot(watchRoot{d, watchDir, true})
540-
}
541-
}
542-
}
543-
if def.WatchRootsFunc != nil {
544-
watchDirs := def.WatchRootsFunc(d)
545-
if len(watchDirs) == 0 {
546-
unwatchedDirs = append(unwatchedDirs, d)
547-
continue
548-
}
549-
for _, watchDir := range watchDirs {
550-
if _, err := os.Stat(watchDir); err == nil {
551-
addRoot(watchRoot{d, watchDir, def.ShallowWatch})
552-
continue
553-
}
554-
unwatchedDirs = append(unwatchedDirs, d)
555-
}
556-
continue
557-
}
558-
if len(def.WatchSubdirs) == 0 {
559-
if _, err := os.Stat(d); err == nil {
560-
addRoot(watchRoot{d, d, def.ShallowWatch})
561-
}
562-
continue
563-
}
564-
for _, sub := range def.WatchSubdirs {
565-
watchDir := filepath.Join(d, sub)
566-
if _, err := os.Stat(watchDir); err == nil {
567-
addRoot(watchRoot{d, watchDir, def.ShallowWatch})
568-
}
569-
}
570-
}
571-
}
517+
roots, unwatchedDirs := collectWatchRoots(cfg)
572518

573519
var totalWatched int
574520
var shallowWatched int
@@ -579,7 +525,7 @@ func startFileWatcher(
579525
shallowWatched++
580526
totalWatched++
581527
} else {
582-
unwatchedDirs = append(unwatchedDirs, r.dir)
528+
unwatchedDirs = append(unwatchedDirs, r.dirs...)
583529
}
584530
continue
585531
}
@@ -588,13 +534,13 @@ func startFileWatcher(
588534
remaining -= result.Watched
589535
if result.Unwatched > 0 || result.BudgetExhausted ||
590536
result.ResourceExhausted || result.Err != nil {
591-
unwatchedDirs = append(unwatchedDirs, r.dir)
537+
unwatchedDirs = append(unwatchedDirs, r.dirs...)
592538
log.Printf(
593539
"Couldn't watch %d directories under %s, will poll every %s",
594-
result.Unwatched, r.dir, unwatchedPollInterval,
540+
result.Unwatched, r.root, unwatchedPollInterval,
595541
)
596542
if result.Err != nil {
597-
log.Printf("watching %s: %v", r.dir, result.Err)
543+
log.Printf("watching %s: %v", r.root, result.Err)
598544
}
599545
}
600546
}
@@ -620,6 +566,72 @@ func startFileWatcher(
620566
return watcher.Stop, unwatchedDirs
621567
}
622568

569+
type watchRoot struct {
570+
dirs []string
571+
root string // actual path passed to WatchRecursive
572+
shallow bool // use shallow watch (root only)
573+
}
574+
575+
func collectWatchRoots(cfg config.Config) (roots []watchRoot, unwatchedDirs []string) {
576+
rootIndexes := make(map[string]int)
577+
addRoot := func(dir, root string, shallow bool) {
578+
if idx, ok := rootIndexes[root]; ok {
579+
if !slices.Contains(roots[idx].dirs, dir) {
580+
roots[idx].dirs = append(roots[idx].dirs, dir)
581+
}
582+
return
583+
}
584+
rootIndexes[root] = len(roots)
585+
roots = append(roots, watchRoot{
586+
dirs: []string{dir},
587+
root: root,
588+
shallow: shallow,
589+
})
590+
}
591+
for _, def := range parser.Registry {
592+
if !def.FileBased {
593+
continue
594+
}
595+
for _, d := range cfg.ResolveDirs(def.Type) {
596+
if def.ShallowWatchRootsFunc != nil {
597+
for _, watchDir := range def.ShallowWatchRootsFunc(d) {
598+
if _, err := os.Stat(watchDir); err == nil {
599+
addRoot(d, watchDir, true)
600+
}
601+
}
602+
}
603+
if def.WatchRootsFunc != nil {
604+
watchDirs := def.WatchRootsFunc(d)
605+
if len(watchDirs) == 0 {
606+
unwatchedDirs = append(unwatchedDirs, d)
607+
continue
608+
}
609+
for _, watchDir := range watchDirs {
610+
if _, err := os.Stat(watchDir); err == nil {
611+
addRoot(d, watchDir, def.ShallowWatch)
612+
continue
613+
}
614+
unwatchedDirs = append(unwatchedDirs, d)
615+
}
616+
continue
617+
}
618+
if len(def.WatchSubdirs) == 0 {
619+
if _, err := os.Stat(d); err == nil {
620+
addRoot(d, d, def.ShallowWatch)
621+
}
622+
continue
623+
}
624+
for _, sub := range def.WatchSubdirs {
625+
watchDir := filepath.Join(d, sub)
626+
if _, err := os.Stat(watchDir); err == nil {
627+
addRoot(d, watchDir, def.ShallowWatch)
628+
}
629+
}
630+
}
631+
}
632+
return roots, unwatchedDirs
633+
}
634+
623635
func startPeriodicSync(
624636
engine *sync.Engine, database *db.DB,
625637
) {
@@ -659,11 +671,21 @@ func recomputePendingSessions(
659671
}
660672
}
661673

662-
func startUnwatchedPoll(engine *sync.Engine) {
674+
type unwatchedPollSyncer interface {
675+
SyncRootsSince(
676+
context.Context, []string, time.Time, sync.ProgressFunc,
677+
) sync.SyncStats
678+
}
679+
680+
func startUnwatchedPoll(engine unwatchedPollSyncer, roots []string) {
663681
ticker := time.NewTicker(unwatchedPollInterval)
664682
defer ticker.Stop()
665683
for range ticker.C {
666684
log.Println("Polling unwatched directories...")
667-
engine.SyncAll(context.Background(), nil)
685+
pollUnwatchedRootsOnce(engine, roots)
668686
}
669687
}
688+
689+
func pollUnwatchedRootsOnce(engine unwatchedPollSyncer, roots []string) {
690+
engine.SyncRootsSince(context.Background(), roots, time.Time{}, nil)
691+
}

cmd/agentsview/main_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ package main
22

33
import (
44
"bytes"
5+
"context"
56
"errors"
67
"io"
78
"log"
89
"os"
910
"path/filepath"
1011
"syscall"
1112
"testing"
13+
"time"
1214

1315
"github.com/stretchr/testify/assert"
1416
"github.com/stretchr/testify/require"
1517
"go.kenn.io/agentsview/internal/config"
18+
"go.kenn.io/agentsview/internal/parser"
1619
"go.kenn.io/agentsview/internal/sync"
1720
)
1821

@@ -225,6 +228,60 @@ func TestTruncateLogFileSymlink(t *testing.T) {
225228
assert.Len(t, data, 1024, "symlink target was truncated")
226229
}
227230

231+
type fakeUnwatchedPollSyncer struct {
232+
roots []string
233+
since time.Time
234+
calls int
235+
callRoots [][]string
236+
callSince []time.Time
237+
}
238+
239+
func (f *fakeUnwatchedPollSyncer) SyncRootsSince(
240+
ctx context.Context, roots []string, since time.Time,
241+
onProgress sync.ProgressFunc,
242+
) sync.SyncStats {
243+
f.calls++
244+
f.roots = append([]string(nil), roots...)
245+
f.since = since
246+
f.callRoots = append(f.callRoots, append([]string(nil), roots...))
247+
f.callSince = append(f.callSince, since)
248+
return sync.SyncStats{}
249+
}
250+
251+
func TestPollUnwatchedRootsOnceUsesScopedFullSync(t *testing.T) {
252+
fake := &fakeUnwatchedPollSyncer{}
253+
roots := []string{"/tmp/claude", "/tmp/codex"}
254+
255+
pollUnwatchedRootsOnce(fake, roots)
256+
pollUnwatchedRootsOnce(fake, roots)
257+
258+
require.Equal(t, 2, fake.calls)
259+
assert.Equal(t, roots, fake.callRoots[0])
260+
assert.True(t, fake.callSince[0].IsZero(), "first poll cutoff = %v", fake.callSince[0])
261+
assert.Equal(t, roots, fake.callRoots[1])
262+
assert.True(t, fake.callSince[1].IsZero(), "second poll cutoff = %v", fake.callSince[1])
263+
}
264+
265+
func TestCollectWatchRootsPreservesDirsSharingWatchRoot(t *testing.T) {
266+
parent := filepath.Join(t.TempDir(), "codex-state")
267+
require.NoError(t, os.Mkdir(parent, 0o755), "mkdir parent")
268+
269+
sessionsDir := filepath.Join(parent, "sessions")
270+
archivedDir := filepath.Join(parent, "archived_sessions")
271+
cfg := config.Config{
272+
AgentDirs: map[parser.AgentType][]string{
273+
parser.AgentCodex: {sessionsDir, archivedDir},
274+
},
275+
}
276+
277+
roots, unwatchedDirs := collectWatchRoots(cfg)
278+
279+
require.Empty(t, unwatchedDirs, "unwatched dirs before watcher setup")
280+
require.Len(t, roots, 1, "shared watch root should be represented once")
281+
assert.Equal(t, parent, roots[0].root)
282+
assert.ElementsMatch(t, []string{sessionsDir, archivedDir}, roots[0].dirs)
283+
}
284+
228285
func TestResyncCoversSignals(t *testing.T) {
229286
tests := []struct {
230287
name string

0 commit comments

Comments
 (0)