diff --git a/README.md b/README.md index 8578f16..b8787cc 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,16 @@ SIGINT or SIGTERM drains in-flight work and exits cleanly. With `--watch-idle-timeout` set, the process exits only once **every** root's daemon has been idle that long. +Only one watch process syncs a session root at a time. It holds an operating +system lock on `/.agy-reader-watch.lock` until it exits. An overlapping +watcher logs once that it is waiting, continues syncing its other roots, and +takes over on a later tick after the owner exits. This prevents different +reader versions from repeatedly invalidating each other's verification +records. The lock works across different cache directories and root symlinks, +and is released even if the owner crashes. Leave the lock file in place. +Watchers from before this locking support must be restarted with the updated +binary to participate. + On Linux, watch mode remembers successful SQLite and legacy protobuf verification checks in memory and in `$XDG_CACHE_HOME/agy-reader/verification-.json` (normally `~/.cache/agy-reader/`). A later tick or watcher process can reuse a check while diff --git a/docs/compatibility/agy-1.5.5.md b/docs/compatibility/agy-1.5.5.md index 06b5561..457deee 100644 --- a/docs/compatibility/agy-1.5.5.md +++ b/docs/compatibility/agy-1.5.5.md @@ -27,6 +27,9 @@ After those blockers were removed, the coordinator exposed an empty task list and two timestamp-prefixed skill reads. The empty list adds only `action: list` metadata. Skill name and description are derived from saved numbered plain frontmatter after two valid timestamp lines; the original output stays intact. +Timestamp-prefixed image reads preserve their timestamps while validating +media type and PNG-to-JPEG downscaling. The separate `images155` profile suffix +invalidates verification caches from builds predating that image-read rule. No current skill file or incoming RPC value supplies that metadata. Historical presentation alternatives remain available. No descriptor or source-only exception changed; old attestations must be reverified under the new profile. diff --git a/docs/completeness.md b/docs/completeness.md index 716c74e..d808800 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -1316,7 +1316,10 @@ absent, and kill arguments must match every returned ID. The empty native task list adds only its action metadata. Plain skill frontmatter may follow the two valid RFC3339 timestamp lines captured in 1.5.5; extraction still uses saved numbered content and keeps the original output intact. Other timestamp-prefixed -frontmatter forms remain unqualified. +frontmatter forms remain unqualified. Image reads may also follow those two +valid RFC3339 timestamp lines, matching either the stored media MIME type or +the qualified downscaled PNG-to-JPEG pair while preserving the complete +timestamped result text in the native `VIEW_FILE` step. These mappings need no embedded descriptor changes and no source-only exemptions. Existing attestations require reverification. All four captured diff --git a/internal/completeness/projection_replay147.go b/internal/completeness/projection_replay147.go index 674eecf..a14c408 100644 --- a/internal/completeness/projection_replay147.go +++ b/internal/completeness/projection_replay147.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "strconv" + "strings" + "time" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -152,7 +154,24 @@ func projectImageRead147(step, generic, result, payload protoreflect.Message) (p // JPEG. The captured read of a 3200x2400 PNG kept the file's own type in the // text ("image/png"), reported the file's full size, and stored a 2000x1500 // JPEG. No other conversion was observed, so no other pair is qualified. +// agy 1.5.5 prepends two valid timestamp lines to tool results. func imageReadNamed1410(text, mime string) bool { + if strings.HasPrefix(text, "Created At: ") { + lines := strings.SplitN(text, "\n", 3) + if len(lines) != 3 { + return false + } + for i, prefix := range []string{"Created At: ", "Completed At: "} { + value, ok := strings.CutPrefix(lines[i], prefix) + if !ok { + return false + } + if _, err := time.Parse(time.RFC3339, value); err != nil { + return false + } + } + text = lines[2] + } const prefix = "Showing binary file of type " return text == prefix+mime || (mime == "image/jpeg" && text == prefix+"image/png") } diff --git a/internal/completeness/projection_tools155_test.go b/internal/completeness/projection_tools155_test.go index 3c05f72..d6f8df3 100644 --- a/internal/completeness/projection_tools155_test.go +++ b/internal/completeness/projection_tools155_test.go @@ -247,3 +247,46 @@ func TestTwoChildren155UnobservedStates(t *testing.T) { } } } + +func TestTimestampedImageRead155(t *testing.T) { + prefix := "Created At: 2026-11-10T14:56:09-08:00\\nCompleted At: 2026-11-10T14:56:11-08:00\\n" + for _, mime := range []string{"image/jpeg", "image/png", "image/webp"} { + raw, shown := imageReadPair147(mime) + raw = replace1216(t, raw, `"result":"Showing binary file of type `+mime+`"`, `"result":"`+prefix+`Showing binary file of type `+mime+`"`) + shown = replace1216(t, shown, `"content":"Showing binary file of type `+mime+`"`, `"content":"`+prefix+`Showing binary file of type `+mime+`"`) + s := snapshot1216(t, raw) + require1216(t, s, raw, shown, Verified) + offline, err := s.OfflineJSON() + if err != nil { + t.Fatal(err) + } + require1216(t, s, raw, string(offline), Verified) + require1216(t, s, strings.Replace(raw, "2026-11-10T14:56:09-08:00", "2026-11-10T14:56:10-08:00", 1), shown, Incomplete) + require1216(t, s, raw, strings.Replace(shown, "2026-11-10T14:56:09-08:00", "2026-11-10T14:56:10-08:00", 1), Incomplete) + } + + // Downscaled PNG stored as JPEG with timestamps. + convRaw, convShown := convertedImage1410(t) + convRaw = replace1216(t, convRaw, `"result":"Showing binary file of type image/png"`, `"result":"`+prefix+`Showing binary file of type image/png"`) + convShown = replace1216(t, convShown, `"content":"Showing binary file of type image/png"`, `"content":"`+prefix+`Showing binary file of type image/png"`) + cs := snapshot1216(t, convRaw) + require1216(t, cs, convRaw, convShown, Verified) + convOffline, err := cs.OfflineJSON() + if err != nil { + t.Fatal(err) + } + require1216(t, cs, convRaw, string(convOffline), Verified) + + // Malformed timestamp lines fail-closed as Unknown. + raw, shown := imageReadPair147("image/png") + for _, badPrefix := range []string{ + "Created At: not-a-date\\nCompleted At: 2026-11-10T14:56:11-08:00\\n", + "Created At: 2026-11-10T14:56:09-08:00\\nCompleted At: not-a-date\\n", + "Created At: 2026-11-10T14:56:09-08:00\\nOther Line: 2026-11-10T14:56:11-08:00\\n", + "Created At: 2026-11-10T14:56:09-08:00\\n", + } { + badRaw := replace1216(t, raw, `"result":"Showing binary file of type image/png"`, `"result":"`+badPrefix+`Showing binary file of type image/png"`) + badShown := replace1216(t, shown, `"content":"Showing binary file of type image/png"`, `"content":"`+badPrefix+`Showing binary file of type image/png"`) + require1216(t, snapshot1216(t, badRaw), badRaw, badShown, Unknown) + } +} diff --git a/internal/completeness/schema.go b/internal/completeness/schema.go index 2b248fd..a330879 100644 --- a/internal/completeness/schema.go +++ b/internal/completeness/schema.go @@ -116,7 +116,9 @@ import ( // completed canceled non-background commands with a retained bypass argument. // tools155 qualifies captured swarm calls, share/branch workspace invocations, // two-child management, empty task lists and timestamp-prefixed skill reads. -const Profile = "sqlite-v1-format0-agy-1.3.4-5b93565c5f04-planner50-permission2-prompt23-tools143-permission1-tools144-skill1-history2-ide225-options145-compaction146-skill146-finish147-replay147-failedread146-subagents148-media149-tools149-sendmessage149-files1411-media1410-parentmsgs1410-paths1410-denied1412-cwd1412-unrun1412-config150-skill150-frontmatter150-bgcancel150-readlines150-config152-tools153-tools154-tools155" +// images155 separately qualifies timestamp-prefixed image reads so builds +// predating that rule cannot reuse their verification checks. +const Profile = "sqlite-v1-format0-agy-1.3.4-5b93565c5f04-planner50-permission2-prompt23-tools143-permission1-tools144-skill1-history2-ide225-options145-compaction146-skill146-finish147-replay147-failedread146-subagents148-media149-tools149-sendmessage149-files1411-media1410-parentmsgs1410-paths1410-denied1412-cwd1412-unrun1412-config150-skill150-frontmatter150-bgcancel150-readlines150-config152-tools153-tools154-tools155-images155" const maxBytes = 64 << 20 const maxDepth = 100 diff --git a/internal/vcache/vcache_test.go b/internal/vcache/vcache_test.go index f26d97a..7121c7d 100644 --- a/internal/vcache/vcache_test.go +++ b/internal/vcache/vcache_test.go @@ -131,7 +131,7 @@ func TestCacheInvalidation(t *testing.T) { } func TestCacheDiskFailuresAndCompatibility(t *testing.T) { - for _, action := range []string{"corrupt", "version", "profile", "boot", "root", "unwritable"} { + for _, action := range []string{"corrupt", "version", "profile", "before-timestamped-images", "boot", "root", "unwritable"} { t.Run(action, func(t *testing.T) { dir, s, c := dummyFixture(t) c.IsStale(s, alwaysFresh) @@ -160,6 +160,11 @@ func TestCacheDiskFailuresAndCompatibility(t *testing.T) { disk.Version++ case "profile": disk.Profile = "other-profile" + case "before-timestamped-images": + // The image projection added after the first agy 1.5.5 + // qualification must not share persisted checks with it. + const previous = "sqlite-v1-format0-agy-1.3.4-5b93565c5f04-planner50-permission2-prompt23-tools143-permission1-tools144-skill1-history2-ide225-options145-compaction146-skill146-finish147-replay147-failedread146-subagents148-media149-tools149-sendmessage149-files1411-media1410-parentmsgs1410-paths1410-denied1412-cwd1412-unrun1412-config150-skill150-frontmatter150-bgcancel150-readlines150-config152-tools153-tools154-tools155" + disk.Profile = previous + "\nlegacy-aes256gcm-v1-agy-1.4.4-337dcde66844/" + previous case "boot": disk.Boot = "previous-boot" case "root": diff --git a/internal/watcher/lock.go b/internal/watcher/lock.go new file mode 100644 index 0000000..ca3c6ed --- /dev/null +++ b/internal/watcher/lock.go @@ -0,0 +1,29 @@ +package watcher + +import ( + "os" + "path/filepath" +) + +// Keep this file in the store, rather than a user cache or runtime directory: +// watchers with different HOME/XDG settings must contend on the same inode. +// Never unlink it, even on exit, since a replacement inode would let a third +// watcher acquire a second lock while another process still holds the first. +const watchLockName = ".agy-reader-watch.lock" + +// acquireWatchLock returns nil, nil when another process owns the store. +// Ownership lasts until the returned file is closed or the process exits. +// A per-tick lock would still let different verification profiles repeatedly +// replace each other's sidecars between ticks. +func acquireWatchLock(root string) (*os.File, error) { + f, err := os.OpenFile(filepath.Join(root, watchLockName), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + locked, err := tryWatchLock(f) + if err != nil || !locked { + _ = f.Close() + return nil, err + } + return f, nil +} diff --git a/internal/watcher/lock_other.go b/internal/watcher/lock_other.go new file mode 100644 index 0000000..99f6c24 --- /dev/null +++ b/internal/watcher/lock_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows + +package watcher + +import ( + "errors" + "os" +) + +func tryWatchLock(_ *os.File) (bool, error) { + return false, errors.New("watcher ownership locks are unsupported on this platform") +} diff --git a/internal/watcher/lock_test.go b/internal/watcher/lock_test.go new file mode 100644 index 0000000..747d13b --- /dev/null +++ b/internal/watcher/lock_test.go @@ -0,0 +1,220 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || windows + +package watcher + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mjacobs/agy-reader/internal/discovery" +) + +// The helper is a separate process so the test exercises kernel ownership, +// including automatic release after a crash, rather than an in-process mutex. +func TestWatchLockProcess(t *testing.T) { + root := os.Getenv("AGY_READER_TEST_LOCK_ROOT") + if root == "" { + return + } + f, err := acquireWatchLock(root) + if err != nil || f == nil { + t.Fatalf("acquire lock: %v", err) + } + defer f.Close() + fmt.Println("locked") + _, _ = io.Copy(io.Discard, os.Stdin) +} + +func TestRunLoopWaitsForOtherProcessAndTakesOverAfterCrash(t *testing.T) { + t.Setenv("ANTIGRAVITY_DAEMON_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + root, other := t.TempDir(), t.TempDir() + seedPB(t, root, "conversations", "aaa", time.Now()) + seedPB(t, other, "conversations", "bbb", time.Now()) + var sharedFetches atomic.Int32 + srv := fakeDaemon(t, func(id string) { + if id == "aaa" { + sharedFetches.Add(1) + } + }) + defer srv.Close() + advertiseCLIDaemon(t, root, srv.URL) + advertiseCLIDaemon(t, other, srv.URL) + + owner := exec.Command(os.Args[0], "-test.run=^TestWatchLockProcess$") + owner.Env = append(os.Environ(), "AGY_READER_TEST_LOCK_ROOT="+root, "XDG_CACHE_HOME="+t.TempDir()) + stdin, err := owner.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := owner.StdoutPipe() + if err != nil { + t.Fatal(err) + } + owner.Stderr = os.Stderr + if err := owner.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = stdin.Close() + if owner.ProcessState == nil { + _ = owner.Process.Kill() + _ = owner.Wait() + } + }) + ready := make(chan bool, 1) + go func() { + scanner := bufio.NewScanner(stdout) + ready <- scanner.Scan() && scanner.Text() == "locked" + }() + select { + case ok := <-ready: + if !ok { + t.Fatal("owner did not acquire the lock") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out starting the owner") + } + + // Simulate a sidecar written under an incompatible verification profile. + // A waiting watcher must leave it alone even though it would fail its check. + sidecar := filepath.Join(root, "conversations", "aaa.trajectory.json") + foreign := `{"cascadeId":"aaa","agyReader":{"completeness":{"profile":"other-version"}}}` + if err := os.WriteFile(sidecar, []byte(foreign), 0o600); err != nil { + t.Fatal(err) + } + watchRoot := root + alias := filepath.Join(t.TempDir(), "store-alias") + if err := os.Symlink(root, alias); err == nil { + watchRoot = alias + } else if os.PathSeparator != '\\' { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- RunLoop(ctx, []string{watchRoot, other}, 10*time.Millisecond, 30*time.Millisecond) }() + t.Cleanup(cancel) + waitForWatchFile(t, filepath.Join(other, "conversations", "bbb.trajectory.json"), "\"steps\"") + time.Sleep(100 * time.Millisecond) // several ticks and idle timeouts + data, err := os.ReadFile(sidecar) + if err != nil || string(data) != foreign || sharedFetches.Load() != 0 { + t.Fatalf("waiting watcher touched the shared store: data=%s fetches=%d err=%v", data, sharedFetches.Load(), err) + } + select { + case err := <-done: + t.Fatalf("waiting watcher exited: %v", err) + default: + } + + if err := owner.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = owner.Wait() + waitForWatchFile(t, sidecar, "\"sourceDigest\"") + time.Sleep(60 * time.Millisecond) + // One refresh fetches both the full and the presentation RPC response. + if got := sharedFetches.Load(); got != 2 { + t.Fatalf("takeover made %d trajectory RPCs, want 2 for one refresh", got) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("waiting watcher did not cancel") + } + f, err := acquireWatchLock(root) + if err != nil || f == nil { + t.Fatalf("lock was not released after cancellation: %v", err) + } + _ = f.Close() +} + +func waitForWatchFile(t *testing.T, path, contains string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), contains) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("%s never contained %s", path, contains) +} + +func TestRunLoopDeduplicatesRootAliases(t *testing.T) { + t.Setenv("ANTIGRAVITY_DAEMON_URL", "") + root := t.TempDir() + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + if err := RunLoop(ctx, []string{root, alias}, 10*time.Millisecond, 30*time.Millisecond); err != nil { + t.Fatal(err) + } + if ctx.Err() != nil { + t.Fatal("duplicate root waited on its own lock instead of idle-exiting") + } +} + +func TestRunLoopFailsWhenStoreCannotBeLocked(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, watchLockName), 0o700); err != nil { + t.Fatal(err) + } + if err := RunLoop(t.Context(), []string{root}, time.Second, 0); err == nil { + t.Fatal("watcher ran without acquiring its store lock") + } +} + +func TestRunLoopPreservesSymlinkedDefaultIDERoot(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("ANTIGRAVITY_DAEMON_URL", "") + t.Setenv("ANTIGRAVITY_CSRF_TOKEN", "") + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("APPDATA", filepath.Join(home, "config")) + store := t.TempDir() + if err := os.Symlink(store, filepath.Join(home, ".gemini")); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + root := filepath.Join(home, discovery.DefaultIDERootSubpath) + seedPB(t, root, "conversations", "ide-session", time.Now()) + srv := fakeDaemon(t, nil) + defer srv.Close() + logsDir, err := discovery.IDELogsDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(logsDir, 0o700); err != nil { + t.Fatal(err) + } + port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:") + if err := os.WriteFile(filepath.Join(logsDir, "language_server.log"), []byte("Language server listening on random port at "+port+" for HTTP\n"), 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- RunLoop(ctx, []string{root}, 10*time.Millisecond, 0) }() + waitForWatchFile(t, filepath.Join(root, "conversations", "ide-session.trajectory.json"), "\"sourceDigest\"") + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} diff --git a/internal/watcher/lock_unix.go b/internal/watcher/lock_unix.go new file mode 100644 index 0000000..a8a7a39 --- /dev/null +++ b/internal/watcher/lock_unix.go @@ -0,0 +1,18 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package watcher + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func tryWatchLock(f *os.File) (bool, error) { + err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return false, nil + } + return err == nil, err +} diff --git a/internal/watcher/lock_windows.go b/internal/watcher/lock_windows.go new file mode 100644 index 0000000..d673b47 --- /dev/null +++ b/internal/watcher/lock_windows.go @@ -0,0 +1,16 @@ +package watcher + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryWatchLock(f *os.File) (bool, error) { + err := windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 044f99e..bf063fa 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -39,6 +39,8 @@ type Watcher struct { Held RefusalMemo Verification *vcache.Cache VerificationSaveFailed bool + watchLock *os.File + waitingForOwner bool Interval time.Duration IdleTimeout time.Duration @@ -182,7 +184,26 @@ func RunLoop(ctx context.Context, roots []string, interval, idleTimeout time.Dur pinned := strings.TrimSpace(os.Getenv("ANTIGRAVITY_DAEMON_URL")) != "" labels := WatchLogLabels(roots) watchers := make([]*Watcher, 0, len(roots)) + seen := map[string]bool{} + defer func() { + for _, w := range watchers { + if w.watchLock != nil { + _ = w.watchLock.Close() + } + } + }() for i, root := range roots { + key, err := filepath.Abs(root) + if err != nil { + return err + } + if resolved, err := filepath.EvalSymlinks(key); err == nil { + key = resolved + } + if seen[key] { + continue + } + seen[key] = true logger := log.New(os.Stderr, labels[i], log.LstdFlags) conn := discovery.Connection{} if v := strings.TrimSpace(os.Getenv("ANTIGRAVITY_DAEMON_URL")); v != "" { @@ -195,33 +216,61 @@ func RunLoop(ctx context.Context, roots []string, interval, idleTimeout time.Dur logger.Printf("watch: root=%s daemon=%s interval=%s idle-timeout=%s", root, conn.BaseURL, interval, idleTimeout) watchers = append(watchers, &Watcher{ - Ctx: ctx, - Root: root, - BaseURL: conn.BaseURL, - Client: session.NewDaemonClient(root, conn), - UrlPinned: pinned, - Logger: logger, - Interval: interval, - IdleTimeout: idleTimeout, - Verification: vcache.New(root), + Ctx: ctx, + Root: root, + BaseURL: conn.BaseURL, + Client: session.NewDaemonClient(root, conn), + UrlPinned: pinned, + Logger: logger, + Interval: interval, + IdleTimeout: idleTimeout, }) } - tickAll := func() (allIdleExpired bool) { + tickAll := func() (allIdleExpired bool, err error) { allIdleExpired = true for _, w := range watchers { + if w.watchLock == nil { + lock, err := acquireWatchLock(w.Root) + if errors.Is(err, os.ErrNotExist) { + if !w.UpdateIdle(true) { + allIdleExpired = false + } + continue + } + if err != nil { + return false, fmt.Errorf("watch: lock store %s: %w", w.Root, err) + } + if lock == nil { + if !w.waitingForOwner { + w.Logger.Printf("watch: another watcher owns this store; waiting for it to exit") + w.waitingForOwner = true + } + allIdleExpired = false + continue + } + w.watchLock = lock + w.Logger.Printf("watch: acquired store lock; syncing enabled") + // The previous owner may have saved newer reuse entries while + // this process waited. Load them only after taking ownership. + w.Verification = vcache.New(w.Root) + } if !w.Tick() { allIdleExpired = false } } - return allIdleExpired + return allIdleExpired, nil } processLogger := log.New(os.Stderr, "", log.LstdFlags) exitLine := func() { processLogger.Printf("watch: all roots idle for ~%s; exiting (a path-triggered unit relaunches on the next agy/IDE activity)", idleTimeout) } - if tickAll() { + allIdle, err := tickAll() + if err != nil { + return err + } + if allIdle { exitLine() return nil } @@ -233,7 +282,11 @@ func RunLoop(ctx context.Context, roots []string, interval, idleTimeout time.Dur processLogger.Printf("watch: shutdown signal received") return nil case <-t.C: - if tickAll() { + allIdle, err := tickAll() + if err != nil { + return err + } + if allIdle { exitLine() return nil }