From 5fac113f8224d5483a168733a42399ebafa5c3e5 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 30 Jul 2026 07:12:38 -0400 Subject: [PATCH] fix(sync): bound OpenCode watcher fallback work --- cmd/agentsview/archive_write_backend.go | 17 +- cmd/agentsview/archive_write_backend_test.go | 13 + cmd/agentsview/main.go | 593 ++++++++++++-- cmd/agentsview/main_test.go | 589 +++++++++++++- cmd/agentsview/sync_worker.go | 2 +- cmd/agentsview/unwatched_poll.go | 299 +++++++- cmd/agentsview/unwatched_poll_test.go | 307 ++++++++ internal/parser/capabilities.go | 1 + internal/parser/opencode.go | 28 +- internal/parser/opencode_change_feed.go | 445 +++++++++++ internal/parser/opencode_change_feed_test.go | 424 ++++++++++ internal/parser/opencode_provider.go | 392 +++++++++- internal/parser/opencode_provider_test.go | 726 +++++++++++++++++- internal/parser/provider.go | 79 +- internal/parser/provider_capabilities_test.go | 16 + internal/parser/sqlite_container_state.go | 14 +- internal/parser/sqlite_file_identity_unix.go | 9 + .../parser/sqlite_file_identity_windows.go | 26 + internal/sync/engine.go | 280 +++++++ internal/sync/engine_integration_test.go | 378 +++++++++ internal/sync/engine_test.go | 62 ++ internal/sync/provider_process_test.go | 482 +++++++++++- internal/sync/watch_backend.go | 14 +- internal/sync/watch_backend_factory_darwin.go | 34 +- internal/sync/watch_backend_fsnotify.go | 79 +- internal/sync/watcher.go | 27 +- internal/sync/watcher_darwin_test.go | 49 +- internal/sync/watcher_test.go | 34 + 28 files changed, 5162 insertions(+), 257 deletions(-) create mode 100644 internal/parser/opencode_change_feed.go create mode 100644 internal/parser/opencode_change_feed_test.go create mode 100644 internal/parser/sqlite_file_identity_unix.go create mode 100644 internal/parser/sqlite_file_identity_windows.go diff --git a/cmd/agentsview/archive_write_backend.go b/cmd/agentsview/archive_write_backend.go index ef1aad796..340786161 100644 --- a/cmd/agentsview/archive_write_backend.go +++ b/cmd/agentsview/archive_write_backend.go @@ -106,8 +106,9 @@ func newArchivePushUnwatchedPoller( return hooks.newUnwatchedPoller(ctx, engine) } ticker := time.NewTicker(unwatchedPollInterval) - return newUnwatchedPollCoordinatorWithTicks( + return newUnwatchedPollCoordinatorWithSchedule( ctx, engine, ticker.C, ticker.Stop, func(work func()) { work() }, nil, + unwatchedPollInterval, ) } @@ -155,10 +156,16 @@ func archivePushWatchWatcherOptions( return loop.NotifyCoverageDegraded(roots) }, OnPollingRequired: func(obligation syncpkg.PollingObligation) error { + scopes := make([]pollingScope, 0, len(obligation.Scopes)) + for _, scope := range obligation.Scopes { + scopes = append(scopes, pollingScope{ + Agent: parser.AgentType(scope.Agent), Root: scope.SyncDir, + CoverageKey: scope.CoverageKey, + }) + } return poller.AddObligation(pollingObligation{ - Key: obligation.Key, - Roots: obligation.Roots, - Probe: obligation.Probe, + Key: obligation.Key, Roots: obligation.Roots, Probe: obligation.Probe, + NonBlockingProbe: obligation.NonBlockingProbe, Scopes: scopes, }) }, OnPollingReleased: poller.RemoveObligation, @@ -172,7 +179,7 @@ func archivePushWatchBatchCallback( ) syncpkg.WatchCallback { return func(callbackCtx context.Context, batch syncpkg.WatchBatch) error { scope := func() watchRecoveryScope { - return probeWatchRecoveryScope(appCfg) + return probeWatchRecoveryScopeWithOwner(appCfg, engine) } if err := syncWatchBatch(callbackCtx, engine, batch, scope); err != nil { return err diff --git a/cmd/agentsview/archive_write_backend_test.go b/cmd/agentsview/archive_write_backend_test.go index 030adce3c..63520372e 100644 --- a/cmd/agentsview/archive_write_backend_test.go +++ b/cmd/agentsview/archive_write_backend_test.go @@ -331,6 +331,19 @@ type pushWatchOwnerCase struct { run func(context.Context, *archivePushWatchHooks) error } +func TestStartArchivePushWatcherWithoutLocalEngine(t *testing.T) { + root := t.TempDir() + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root}, + }} + stop, open, _ := startArchivePushWatcher( + nil, cfg, nil, func(context.Context, syncpkg.WatchBatch) error { return nil }, + syncpkg.WatcherOptions{}, + ) + open() + stop() +} + func pushWatchOwnerCases(t *testing.T) []pushWatchOwnerCase { t.Helper() // Keep SQLite setup outside the timed owner goroutines so channel deadlines diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index e1c1b651e..ab218294a 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -319,7 +319,7 @@ func runServe(cfg config.Config, opts serveOptions) { // The serve ctx reaches watcher-driven syncs so SIGTERM can // interrupt database reconciliation before Stop waits for it. return syncWatchBatch(ctx, engine, batch, func() watchRecoveryScope { - return probeWatchRecoveryScope(cfg) + return probeWatchRecoveryScopeWithOwner(cfg, engine) }) }, sync.WatcherOptions{ @@ -329,10 +329,17 @@ func runServe(cfg config.Config, opts serveOptions) { }) }, OnPollingRequired: func(obligation sync.PollingObligation) error { + scopes := make([]pollingScope, 0, len(obligation.Scopes)) + for _, scope := range obligation.Scopes { + scopes = append(scopes, pollingScope{ + Agent: parser.AgentType(scope.Agent), Root: scope.SyncDir, + CoverageKey: scope.CoverageKey, + }) + } return unwatchedPoller.AddObligation(pollingObligation{ - Key: obligation.Key, - Roots: obligation.Roots, - Probe: obligation.Probe, + Key: obligation.Key, Roots: obligation.Roots, + Probe: obligation.Probe, + NonBlockingProbe: obligation.NonBlockingProbe, Scopes: scopes, }) }, OnPollingReleased: unwatchedPoller.RemoveObligation, @@ -365,7 +372,7 @@ func runServe(cfg config.Config, opts serveOptions) { // RecordStartupReconciled fires. completeWorkerStartup = func() { var gapErr error - if gapRoots := reconcileRootPaths(cfg); len(gapRoots) > 0 { + if gapRoots := reconcileRootPathsWithOwner(cfg, engine); len(gapRoots) > 0 { gapErr = engine.ReconcileWatchRoots(ctx, gapRoots, false) } if gapErr != nil && ctx.Err() == nil { @@ -770,6 +777,14 @@ func reconcileRootPaths(cfg config.Config) []string { return probeWatchRecoveryScope(cfg).available } +func reconcileRootPathsWithOwner(cfg config.Config, owner activeSourceProbe) []string { + return probeWatchRecoveryScopeWithOwner(cfg, owner).available +} + +type activeSourceProbe interface { + HasActiveSessionSourceBelow(agent, path string) (bool, error) +} + // watchRecoveryScope is one probed availability snapshot of the configured // watch scope: the currently available reconciliation paths plus the // configured dirs whose physical scope is missing and therefore deferred to @@ -799,22 +814,33 @@ func (s watchRecoveryScope) coversProviderRoot(root string) bool { // probeWatchRecoveryScope computes the probed reconciliation scope backing // reconcileRootPaths; see that function for the deferral semantics. func probeWatchRecoveryScope(cfg config.Config) watchRecoveryScope { + return probeWatchRecoveryScopeWithOwner(cfg, nil) +} + +func probeWatchRecoveryScopeWithOwner( + cfg config.Config, owner activeSourceProbe, +) watchRecoveryScope { roots, unwatchedDirs, symlinkGatedDirs := collectWatchRoots(cfg) deferred := make(map[string]struct{}) // A recursive symlink root never joins the watch roots, so its exact // availability probe is the symlink target itself: os.Stat follows the // link and fails while the target is gone, deferring the configured // scope before an overlapping present path could expand into it. - for symRoot, dirs := range symlinkGatedDirs { + for symRoot, gate := range symlinkGatedDirs { if _, err := os.Stat(symRoot); err == nil { continue } - for _, dir := range dirs { - deferred[filepath.Clean(dir)] = struct{}{} + root := watchRoot{path: symRoot, scopes: gate.scopes, + nonBlockingProbe: gate.nonBlockingProbe} + if gate.nonBlockingProbe && !watchRootHasArchivedSources(owner, root) { + continue + } + for _, scope := range gate.scopes { + deferred[filepath.Clean(scope.syncDir)] = struct{}{} } } for _, r := range roots { - if r.exists { + if r.exists || (r.nonBlockingProbe && !watchRootHasArchivedSources(owner, r)) { continue } for _, dir := range r.pendingPollingDirs { @@ -861,6 +887,19 @@ func probeWatchRecoveryScope(cfg config.Config) watchRecoveryScope { return watchRecoveryScope{available: paths, deferred: deferred} } +func watchRootHasArchivedSources(owner activeSourceProbe, root watchRoot) bool { + if owner == nil { + return false + } + for _, scope := range root.scopes { + has, err := owner.HasActiveSessionSourceBelow(string(scope.agent), root.path) + if err != nil || has { + return true + } + } + return false +} + // overlapsDeferredScope reports whether the engine-side expansion of a // present reconciliation path would pull a deferred configured scope back // into an authoritative pass. The engine expands a requested root to every @@ -1805,6 +1844,17 @@ func startFileWatcher( ) { t := time.Now() roots, unwatchedDirs, symlinkGatedDirs := collectWatchRoots(cfg) + primeWatchCoverage(engine, roots) + authorityRoots := append([]watchRoot(nil), roots...) + for path, gate := range symlinkGatedDirs { + authorityRoots = append(authorityRoots, watchRoot{ + path: path, scopes: gate.scopes, + nonBlockingProbe: gate.nonBlockingProbe, + }) + } + onChange = annotateWatchCoverageWithAuthority( + onChange, roots, authorityRoots, activeSourceProbeForEngine(engine), + ) watcher, err := sync.NewWatcherWithCallback( watcherBatchDelay, watcherSyncMinInterval, @@ -1897,6 +1947,197 @@ func startFileWatcher( watcher.QueueRetryBatch } +func activeSourceProbeForEngine(engine *sync.Engine) activeSourceProbe { + if engine == nil { + return nil + } + return engine +} + +func primeWatchCoverage(engine *sync.Engine, roots []watchRoot) { + if engine == nil { + return + } + seen := make(map[string]struct{}) + for _, root := range roots { + for _, scope := range root.scopes { + if scope.agent == "" || scope.syncDir == "" || scope.coverageKey == "" { + continue + } + key := string(scope.agent) + "\x00" + scope.syncDir + "\x00" + scope.coverageKey + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if err := engine.PrimeCoverage(context.Background(), parser.CoverageTask{ + Agent: scope.agent, Root: scope.syncDir, + CoverageKey: scope.coverageKey, + }); err != nil { + log.Printf("prime %s coverage: %v", scope.agent, err) + } + } + } +} + +func annotateWatchCoverage( + next sync.WatchCallback, roots []watchRoot, +) sync.WatchCallback { + return annotateWatchCoverageWithAuthority(next, roots, roots, nil) +} + +func annotateWatchCoverageWithAuthority( + next sync.WatchCallback, roots, authorityRoots []watchRoot, + owner activeSourceProbe, +) sync.WatchCallback { + return func(ctx context.Context, batch sync.WatchBatch) error { + for _, rename := range batch.Renames { + if rename.ItemType != sync.ItemIsDir { + batch.Paths = appendUniqueString(batch.Paths, rename.Path) + } + } + batch.Coverage = watchCoverageForPathsWithAuthority( + roots, authorityRoots, owner, batch.Paths, + ) + return next(ctx, batch) + } +} + +func watchCoverageForPaths( + roots []watchRoot, paths []string, +) []sync.WatchCoverage { + return watchCoverageForPathsWithAuthority(roots, roots, nil, paths) +} + +func watchCoverageForPathsWithAuthority( + roots, authorityRoots []watchRoot, owner activeSourceProbe, paths []string, +) []sync.WatchCoverage { + byKey := make(map[string]sync.WatchCoverage) + for _, path := range paths { + bestLen := -1 + var best *watchRoot + hasGenericScope := false + for i := range roots { + if !sameOrDescendantPath(path, roots[i].path) { + continue + } + for _, scope := range roots[i].scopes { + if scope.coverageKey == "" { + hasGenericScope = true + } + } + if len(roots[i].path) >= bestLen { + bestLen = len(roots[i].path) + best = &roots[i] + } + } + if best == nil { + continue + } + hasBoundedCoverage := false + for _, scope := range best.scopes { + if scope.coverageKey != "" && watchScopeMatchesPath(*best, scope, path) { + hasBoundedCoverage = true + } + } + if !hasBoundedCoverage { + continue + } + for _, scope := range best.scopes { + if scope.coverageKey == "" || !watchScopeMatchesPath(*best, scope, path) { + continue + } + key := string(scope.agent) + "\x00" + scope.syncDir + "\x00" + scope.coverageKey + coverage := byKey[key] + coverage.Agent = string(scope.agent) + coverage.Root = scope.syncDir + coverage.CoverageKey = scope.coverageKey + coverage.AuthoritativeFallback = watchScopeFallbackAvailable( + authorityRoots, scope, owner, + ) + coverage.ConsumePath = !hasGenericScope + coverage.Paths = appendUniqueString(coverage.Paths, path) + byKey[key] = coverage + } + } + out := make([]sync.WatchCoverage, 0, len(byKey)) + for _, coverage := range byKey { + slices.Sort(coverage.Paths) + out = append(out, coverage) + } + slices.SortFunc(out, func(a, b sync.WatchCoverage) int { + if c := strings.Compare(a.Agent, b.Agent); c != 0 { + return c + } + if c := strings.Compare(a.CoverageKey, b.CoverageKey); c != 0 { + return c + } + return strings.Compare(a.Root, b.Root) + }) + return out +} + +func watchScopeFallbackAvailable( + roots []watchRoot, scope watchScope, owner activeSourceProbe, +) bool { + for _, root := range roots { + ownsScope := false + for _, candidate := range root.scopes { + if candidate.agent == scope.agent && candidate.syncDir == scope.syncDir { + ownsScope = true + break + } + } + if !ownsScope { + continue + } + if _, err := os.Stat(root.path); err != nil { + if !root.nonBlockingProbe || watchRootHasArchivedSources(owner, root) { + return false + } + } + } + return true +} + +func watchScopeMatchesPath(root watchRoot, scope watchScope, path string) bool { + rel, err := filepath.Rel(root.path, path) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + includes := splitWatchGlobs(scope.includeGlobs) + if len(includes) > 0 && !watchGlobMatchesAny(includes, rel) { + return false + } + return !watchGlobMatchesAny(splitWatchGlobs(scope.excludeGlobs), rel) +} + +func watchGlobMatchesAny(patterns []string, rel string) bool { + for _, pattern := range patterns { + if matched, err := filepath.Match(pattern, rel); err == nil && matched { + return true + } + } + return false +} + +func splitWatchGlobs(encoded string) []string { + if encoded == "" { + return nil + } + return strings.Split(encoded, "\x00") +} + +func sameOrDescendantPath(path, root string) bool { + path = filepath.Clean(path) + root = filepath.Clean(root) + if path == root { + return true + } + rel, err := filepath.Rel(root, path) + return err == nil && rel != "." && rel != ".." && + !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + func watchPollingObligations( roots []watchRoot, results []sync.RecursiveWatchResult, @@ -1904,6 +2145,8 @@ func watchPollingObligations( ) []sync.PollingObligation { byKey := make(map[string][]string) probes := make(map[string]string) + scopesByKey := make(map[string][]sync.WatchScope) + nonBlockingByKey := make(map[string]bool) represented := make(map[string]struct{}) // The probe is the physical path whose availability gates the // obligation's reconciliation roots: the watch root's own path for @@ -1929,9 +2172,17 @@ func watchPollingObligations( } if !result.MissingRootLifecycleOwned { add(root.path, root.path, root.pendingPollingDirs...) + nonBlockingByKey[root.path] = root.nonBlockingProbe + scopesByKey[root.path] = appendUniqueWatchScopes( + scopesByKey[root.path], root.registeredRoot().Scopes, + ) } for _, dir := range root.persistentPollingDirs { - add("persistent:"+filepath.Clean(dir), dir, dir) + key := "persistent:" + filepath.Clean(dir) + add(key, dir, dir) + scopesByKey[key] = appendUniqueWatchScopes( + scopesByKey[key], root.registeredRoot().Scopes, + ) } if i >= len(results) { // No registration result exists for this root: the watcher was @@ -1941,11 +2192,19 @@ func watchPollingObligations( // pollable and the fallback poll reconciles it as an // authoritative empty discovery. add(root.path, root.path, root.syncDirs()...) + nonBlockingByKey[root.path] = root.nonBlockingProbe + scopesByKey[root.path] = appendUniqueWatchScopes( + scopesByKey[root.path], root.registeredRoot().Scopes, + ) continue } if result.Unwatched > 0 || result.BudgetExhausted || result.ResourceExhausted || result.Err != nil { add(root.path, root.path, root.syncDirs()...) + nonBlockingByKey[root.path] = root.nonBlockingProbe + scopesByKey[root.path] = appendUniqueWatchScopes( + scopesByKey[root.path], root.registeredRoot().Scopes, + ) } } for _, dir := range unwatchedDirs { @@ -1959,6 +2218,7 @@ func watchPollingObligations( slices.Sort(roots) obligations = append(obligations, sync.PollingObligation{ Key: key, Roots: roots, Probe: probes[key], + NonBlockingProbe: nonBlockingByKey[key], Scopes: scopesByKey[key], }) } slices.SortFunc(obligations, func(a, b sync.PollingObligation) int { @@ -1967,6 +2227,18 @@ func watchPollingObligations( return obligations } +func appendUniqueWatchScopes( + dst []sync.WatchScope, values []sync.WatchScope, +) []sync.WatchScope { + for _, value := range values { + if slices.Contains(dst, value) { + continue + } + dst = append(dst, value) + } + return dst +} + // registerWatcherUnavailableObligations installs the polling obligations for // a daemon whose file watcher could not be constructed: the coverage-degraded // fallback poll over every sync dir plus the same probe gates the success @@ -1989,14 +2261,20 @@ func registerWatcherUnavailableObligations( options sync.WatcherOptions, roots []watchRoot, unwatchedDirs []string, - symlinkGatedDirs map[string][]string, + symlinkGatedDirs map[string]symlinkGate, ) error { + typedRoots := make(map[string]struct{}) if options.OnPollingRequired != nil { obligations := watchPollingObligations(roots, nil, unwatchedDirs) obligations = append( obligations, symlinkPollingObligations(symlinkGatedDirs)..., ) for _, obligation := range obligations { + if len(obligation.Scopes) > 0 { + for _, root := range obligation.Roots { + typedRoots[filepath.Clean(root)] = struct{}{} + } + } if err := options.OnPollingRequired(obligation); err != nil { log.Printf( "register polling obligation %q: %v", obligation.Key, err, @@ -2007,7 +2285,17 @@ func registerWatcherUnavailableObligations( if options.OnCoverageDegraded == nil { return nil } - return options.OnCoverageDegraded(unwatchedDirs) + untyped := slices.DeleteFunc( + append([]string(nil), unwatchedDirs...), + func(root string) bool { + _, ok := typedRoots[filepath.Clean(root)] + return ok + }, + ) + if len(untyped) == 0 { + return nil + } + return options.OnCoverageDegraded(untyped) } // symlinkPollingObligations gates persistent polling of dirs whose recursive @@ -2019,19 +2307,21 @@ func registerWatcherUnavailableObligations( // referencing it has a missing probe, so this composes with the dir's own // persistent obligation. func symlinkPollingObligations( - symlinkGatedDirs map[string][]string, + symlinkGatedDirs map[string]symlinkGate, ) []sync.PollingObligation { obligations := make([]sync.PollingObligation, 0, len(symlinkGatedDirs)) - for symRoot, dirs := range symlinkGatedDirs { - roots := make([]string, 0, len(dirs)) - for _, dir := range dirs { - roots = appendUniqueString(roots, filepath.Clean(dir)) + for symRoot, gate := range symlinkGatedDirs { + roots := make([]string, 0, len(gate.scopes)) + for _, scope := range gate.scopes { + roots = appendUniqueString(roots, filepath.Clean(scope.syncDir)) } slices.Sort(roots) obligations = append(obligations, sync.PollingObligation{ - Key: "symlink:" + filepath.Clean(symRoot), - Roots: roots, - Probe: filepath.Clean(symRoot), + Key: "symlink:" + filepath.Clean(symRoot), + Roots: roots, + Probe: filepath.Clean(symRoot), + NonBlockingProbe: gate.nonBlockingProbe, + Scopes: watchRoot{scopes: gate.scopes}.registeredRoot().Scopes, }) } slices.SortFunc(obligations, func(a, b sync.PollingObligation) int { @@ -2086,6 +2376,12 @@ type watchSyncer interface { ReconcileWatchRootsAfterLostEvents(context.Context, []string, bool) error } +type providerExcludingWatchSyncer interface { + SyncPathsExcludingProvidersContext( + context.Context, []string, []parser.AgentType, + ) error +} + type watchReconciliationError struct { cause error retry sync.WatchBatch @@ -2137,9 +2433,21 @@ func (e *watchReconciliationError) WatchRetryBatch() sync.WatchBatch { retry := e.retry retry.Paths = append([]string(nil), retry.Paths...) retry.ReconcileRoots = append([]string(nil), retry.ReconcileRoots...) + retry.Renames = append([]sync.WatchRename(nil), retry.Renames...) + retry.Coverage = append([]sync.WatchCoverage(nil), retry.Coverage...) + for i := range retry.Coverage { + retry.Coverage[i].Paths = append([]string(nil), retry.Coverage[i].Paths...) + } return retry } +func coverageRetryBatch(batch sync.WatchBatch) sync.WatchBatch { + if batch.FullSync { + return sync.WatchBatch{FullSync: true, LostEvents: batch.LostEvents} + } + return batch +} + // syncWatchBatch applies one watcher batch to the engine. recoveryScope // supplies the probed availability snapshot used by a full recovery // (overflow or unscoped rename) and by directory-rename promotion, per @@ -2154,13 +2462,10 @@ func syncWatchBatch( recoveryScope func() watchRecoveryScope, ) error { paths := append([]string(nil), batch.Paths...) + coverageHandled := make(map[string]struct{}) full := batch.FullSync reconcileRoots := append([]string(nil), batch.ReconcileRoots...) lostEvents := batch.LostEvents - type renameOwner struct { - path string - agent string - } var scope watchRecoveryScope scopeProbed := false probeScope := func() watchRecoveryScope { @@ -2170,6 +2475,49 @@ func syncWatchBatch( } return scope } + if len(batch.Coverage) > 0 { + covered, excluded, err := syncWatchCoverage(ctx, engine, batch.Coverage) + if err != nil { + return &watchReconciliationError{ + cause: err, + retry: coverageRetryBatch(batch), + } + } + paths = slices.DeleteFunc(paths, func(path string) bool { + _, ok := covered[filepath.Clean(path)] + if ok { + coverageHandled[filepath.Clean(path)] = struct{}{} + } + return ok + }) + if len(paths) > 0 && len(excluded) > 0 { + if dispatcher, ok := engine.(providerExcludingWatchSyncer); ok { + for _, path := range paths { + agents := excluded[filepath.Clean(path)] + if len(agents) == 0 { + continue + } + coverageHandled[filepath.Clean(path)] = struct{}{} + if err := dispatcher.SyncPathsExcludingProvidersContext( + ctx, []string{path}, agents, + ); err != nil { + return &watchReconciliationError{ + cause: err, + retry: coverageRetryBatch(batch), + } + } + } + paths = slices.DeleteFunc(paths, func(path string) bool { + _, handled := coverageHandled[filepath.Clean(path)] + return handled + }) + } + } + } + type renameOwner struct { + path string + agent string + } authoritativePaths := make(map[string]struct{}) authoritativeRenames := make(map[renameOwner]struct{}) promoteDirectoryRename := func(rename sync.WatchRename) { @@ -2196,7 +2544,9 @@ func syncWatchBatch( } switch rename.ItemType { case sync.ItemIsFile: - paths = appendUniqueString(paths, rename.Path) + if _, handled := coverageHandled[filepath.Clean(rename.Path)]; !handled { + paths = appendUniqueString(paths, rename.Path) + } case sync.ItemIsDir: promoteDirectoryRename(rename) authoritativePaths[rename.Path] = struct{}{} @@ -2211,7 +2561,9 @@ func syncWatchBatch( authoritativeRenames[owner] = struct{}{} paths = removeString(paths, rename.Path) } else { - paths = appendUniqueString(paths, rename.Path) + if _, handled := coverageHandled[filepath.Clean(rename.Path)]; !handled { + paths = appendUniqueString(paths, rename.Path) + } } continue } @@ -2229,7 +2581,9 @@ func syncWatchBatch( paths = removeString(paths, rename.Path) } else { if _, authoritative := authoritativePaths[rename.Path]; !authoritative { - paths = appendUniqueString(paths, rename.Path) + if _, handled := coverageHandled[filepath.Clean(rename.Path)]; !handled { + paths = appendUniqueString(paths, rename.Path) + } } } } @@ -2285,6 +2639,73 @@ func syncWatchBatch( return nil } +func syncWatchCoverage( + ctx context.Context, engine watchSyncer, coverage []sync.WatchCoverage, +) (map[string]struct{}, map[string][]parser.AgentType, error) { + dispatcher, ok := engine.(boundedCoverageSyncer) + if !ok { + return nil, nil, nil + } + covered := make(map[string]struct{}) + excluded := make(map[string][]parser.AgentType) + for _, item := range coverage { + trigger := parser.CoverageTriggerNative + for page := 0; ; page++ { + if page >= 1024 { + return nil, nil, fmt.Errorf("%s coverage continuation limit exceeded", item.Agent) + } + result, err := dispatcher.ReconcileCoverage(ctx, parser.CoverageTask{ + Agent: parser.AgentType(item.Agent), Root: item.Root, + CoverageKey: item.CoverageKey, Trigger: trigger, + AuthoritativeFallback: item.AuthoritativeFallback, + ChangedPaths: append([]string(nil), item.Paths...), + }) + if err != nil { + return nil, nil, fmt.Errorf("%s watcher coverage: %w", item.Agent, err) + } + if result.AuditRequired { + log.Printf("%s watcher coverage requested archive audit", item.Agent) + } + if !result.More { + break + } + trigger = parser.CoverageTriggerContinuation + delay := result.NextDelay + if delay <= 0 { + delay = 100 * time.Millisecond + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return nil, nil, ctx.Err() + case <-timer.C: + } + } + for _, path := range item.Paths { + path = filepath.Clean(path) + excluded[path] = appendUniqueAgentType( + excluded[path], parser.AgentType(item.Agent), + ) + if item.ConsumePath { + covered[path] = struct{}{} + } + } + } + return covered, excluded, nil +} + +func appendUniqueAgentType( + values []parser.AgentType, value parser.AgentType, +) []parser.AgentType { + if slices.Contains(values, value) { + return values + } + return append(values, value) +} + func removeString(values []string, remove string) []string { return slices.DeleteFunc(values, func(value string) bool { return value == remove }) } @@ -2306,32 +2727,40 @@ func deduplicateStrings(values []string) []string { } type watchScope struct { - agent parser.AgentType - syncDir string + agent parser.AgentType + syncDir string + coverageKey string + includeGlobs string + excludeGlobs string } type watchRoot struct { path string recursive bool exists bool + nonBlockingProbe bool scopes []watchScope pendingPollingDirs []string persistentPollingDirs []string } +type symlinkGate struct { + scopes []watchScope + nonBlockingProbe bool +} + func (r watchRoot) registeredRoot() sync.WatchRoot { scopes := make([]sync.WatchScope, 0, len(r.scopes)) for _, scope := range r.scopes { scopes = append(scopes, sync.WatchScope{ - Agent: string(scope.agent), - SyncDir: scope.syncDir, + Agent: string(scope.agent), + SyncDir: scope.syncDir, + CoverageKey: scope.coverageKey, }) } return sync.WatchRoot{ - Path: r.path, - Recursive: r.recursive, - Exists: r.exists, - Scopes: scopes, + Path: r.path, Recursive: r.recursive, Exists: r.exists, + NonBlockingProbe: r.nonBlockingProbe, Scopes: scopes, } } @@ -2350,17 +2779,27 @@ func (r watchRoot) syncDirs() []string { func collectWatchRoots(cfg config.Config) ( roots []watchRoot, unwatchedDirs []string, - symlinkGatedDirs map[string][]string, + symlinkGatedDirs map[string]symlinkGate, ) { rootIndexes := make(map[string]int) persistentPollingDirs := make(map[string]struct{}) - symlinkGatedDirs = make(map[string][]string) - addRoot := func(agent parser.AgentType, dir, path string, recursive, exists bool) { + symlinkGatedDirs = make(map[string]symlinkGate) + addRoot := func( + agent parser.AgentType, dir, path string, recursive, exists bool, + coverageKey string, nonBlockingProbe bool, + includeGlobs, excludeGlobs []string, + ) { + dir = filepath.Clean(dir) path = filepath.Clean(path) - scope := watchScope{agent: agent, syncDir: dir} + scope := watchScope{ + agent: agent, syncDir: dir, coverageKey: coverageKey, + includeGlobs: strings.Join(includeGlobs, "\x00"), + excludeGlobs: strings.Join(excludeGlobs, "\x00"), + } if idx, ok := rootIndexes[path]; ok { roots[idx].recursive = roots[idx].recursive || recursive roots[idx].exists = roots[idx].exists || exists + roots[idx].nonBlockingProbe = roots[idx].nonBlockingProbe && nonBlockingProbe if !slices.Contains(roots[idx].scopes, scope) { roots[idx].scopes = append(roots[idx].scopes, scope) } @@ -2368,27 +2807,40 @@ func collectWatchRoots(cfg config.Config) ( } rootIndexes[path] = len(roots) roots = append(roots, watchRoot{ - path: path, - recursive: recursive, - exists: exists, - scopes: []watchScope{scope}, + path: path, recursive: recursive, exists: exists, + nonBlockingProbe: nonBlockingProbe, scopes: []watchScope{scope}, }) } for _, def := range parser.Registry { for _, d := range cfg.ResolveDirs(def.Type) { - addAgentRoot := func(dir, root string, recursive, exists bool) { - addRoot(def.Type, dir, root, recursive, exists) + addAgentRoot := func( + dir, root string, recursive, exists bool, coverageKey string, + nonBlockingProbe bool, includeGlobs, excludeGlobs []string, + ) { + addRoot( + def.Type, dir, root, recursive, exists, coverageKey, + nonBlockingProbe, includeGlobs, excludeGlobs, + ) } _, hasProvider := parser.ProviderFactoryByType(def.Type) if providerWatched, polling := collectProviderWatchRoots(def, d, addAgentRoot); providerWatched { - if polling.persistent { - persistentPollingDirs[d] = struct{}{} - unwatchedDirs = appendUniqueString(unwatchedDirs, d) - } for _, symRoot := range polling.symlinkRoots { - symlinkGatedDirs[symRoot] = appendUniqueString( - symlinkGatedDirs[symRoot], d, - ) + scope := watchScope{ + agent: def.Type, syncDir: d, + coverageKey: symRoot.coverageKey, + includeGlobs: symRoot.includeGlobs, + excludeGlobs: symRoot.excludeGlobs, + } + gate := symlinkGatedDirs[symRoot.path] + if len(gate.scopes) == 0 { + gate.nonBlockingProbe = symRoot.nonBlockingProbe + } else { + gate.nonBlockingProbe = gate.nonBlockingProbe && symRoot.nonBlockingProbe + } + if !slices.Contains(gate.scopes, scope) { + gate.scopes = append(gate.scopes, scope) + } + symlinkGatedDirs[symRoot.path] = gate } for _, missing := range polling.missingRoots { idx, ok := rootIndexes[filepath.Clean(missing)] @@ -2409,7 +2861,12 @@ func collectWatchRoots(cfg config.Config) ( } continue } - fallbackUnwatched := collectLegacyWatchRoots(def, d, addAgentRoot) + fallbackUnwatched := collectLegacyWatchRoots( + def, d, + func(dir, root string, recursive, exists bool) { + addAgentRoot(dir, root, recursive, exists, "", false, nil, nil) + }, + ) for _, pollingDir := range fallbackUnwatched { persistentPollingDirs[pollingDir] = struct{}{} unwatchedDirs = appendUniqueString(unwatchedDirs, pollingDir) @@ -2435,14 +2892,24 @@ type providerPollingReasons struct { // the root itself is a symlink. They are served by persistent polling, and // their target availability gates the configured dir's reconciliation // scope: a broken symlink streams an empty discovery without error. - symlinkRoots []string - persistent bool + symlinkRoots []providerSymlinkRoot +} + +type providerSymlinkRoot struct { + path string + coverageKey string + includeGlobs string + excludeGlobs string + nonBlockingProbe bool } func collectProviderWatchRoots( def parser.AgentDef, dir string, - addRoot func(dir, root string, recursive, exists bool), + addRoot func( + dir, root string, recursive, exists bool, coverageKey string, + nonBlockingProbe bool, includeGlobs, excludeGlobs []string, + ), ) (bool, providerPollingReasons) { factory, ok := parser.ProviderFactoryByType(def.Type) if !ok { @@ -2468,13 +2935,21 @@ func collectProviderWatchRoots( } planned = true if providerRoot.Recursive && isSymlinkPath(root) { - polling.persistent = true - polling.symlinkRoots = append(polling.symlinkRoots, root) + polling.symlinkRoots = append(polling.symlinkRoots, providerSymlinkRoot{ + path: root, coverageKey: providerRoot.CoverageKey, + includeGlobs: strings.Join(providerRoot.IncludeGlobs, "\x00"), + excludeGlobs: strings.Join(providerRoot.ExcludeGlobs, "\x00"), + nonBlockingProbe: providerRoot.NonBlockingProbe, + }) continue } _, err := os.Stat(root) exists := err == nil - addRoot(dir, root, providerRoot.Recursive, exists) + addRoot( + dir, root, providerRoot.Recursive, exists, + providerRoot.CoverageKey, providerRoot.NonBlockingProbe, + providerRoot.IncludeGlobs, providerRoot.ExcludeGlobs, + ) if exists { continue } diff --git a/cmd/agentsview/main_test.go b/cmd/agentsview/main_test.go index e1cb69568..daed35db8 100644 --- a/cmd/agentsview/main_test.go +++ b/cmd/agentsview/main_test.go @@ -613,7 +613,7 @@ func TestCollectWatchRootsPollsRecursiveSymlinkProviderRoot(t *testing.T) { }, } - roots, unwatchedDirs, _ := collectWatchRoots(cfg) + roots, unwatchedDirs, symlinkGatedDirs := collectWatchRoots(cfg) require.Len(t, roots, 2) assert.Equal(t, root, roots[0].path) @@ -628,7 +628,12 @@ func TestCollectWatchRootsPollsRecursiveSymlinkProviderRoot(t *testing.T) { assert.False(t, roots[1].recursive) assert.True(t, roots[1].exists) assert.Equal(t, []watchScope{{agent: parser.AgentVSCopilot, syncDir: root}}, roots[1].scopes) - assert.ElementsMatch(t, []string{root}, unwatchedDirs) + assert.Empty(t, unwatchedDirs) + assert.Equal(t, map[string]symlinkGate{ + filepath.Join(root, ".VS"): {scopes: []watchScope{{ + agent: parser.AgentVSCopilot, syncDir: root, + }}}, + }, symlinkGatedDirs) } // fakeEmitter records Emit calls; safe for concurrent use. @@ -1141,6 +1146,255 @@ func TestCollectWatchRootsUsesCoworkProviderRecursiveRoot(t *testing.T) { assert.Equal(t, []watchScope{{agent: parser.AgentCowork, syncDir: root}}, got.scopes) } +func TestCollectWatchRootsKeepsOpenCodeSQLiteShallowBeforeStorage(t *testing.T) { + root := t.TempDir() + raw, err := sql.Open("sqlite3", filepath.Join(root, "opencode.db")) + require.NoError(t, err) + _, err = raw.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + require.NoError(t, err) + require.NoError(t, raw.Close()) + storage := filepath.Join(root, "storage") + require.NoError(t, os.Mkdir(storage, 0o755)) + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root + string(filepath.Separator)}, + }} + + roots, _, _ := collectWatchRoots(cfg) + require.Len(t, roots, 2) + assert.Equal(t, root, roots[0].path) + assert.False(t, roots[0].recursive) + assert.Equal(t, []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + includeGlobs: "opencode.db\x00opencode.db-wal", + }}, roots[0].scopes) + assert.Equal(t, storage, roots[1].path) + assert.True(t, roots[1].recursive) + assert.Equal(t, []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + }}, roots[1].scopes) +} + +func TestCollectWatchRootsRetainsMissingOpenCodeStorageLifecycle(t *testing.T) { + root := t.TempDir() + raw, err := sql.Open("sqlite3", filepath.Join(root, "opencode.db")) + require.NoError(t, err) + _, err = raw.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + require.NoError(t, err) + require.NoError(t, raw.Close()) + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root}, + }} + + roots, unwatched, _ := collectWatchRoots(cfg) + require.Len(t, roots, 2) + storage := filepath.Join(root, "storage") + assert.Equal(t, storage, roots[1].path) + assert.True(t, roots[1].recursive) + assert.False(t, roots[1].exists) + assert.Equal(t, []string{root}, roots[1].pendingPollingDirs) + assert.Equal(t, []string{root}, unwatched) + obligations := watchPollingObligations(roots, nil, unwatched) + local := make([]pollingObligation, 0, len(obligations)) + for _, obligation := range obligations { + local = append(local, pollingObligation{ + Key: obligation.Key, Roots: obligation.Roots, Probe: obligation.Probe, + NonBlockingProbe: obligation.NonBlockingProbe, + }) + } + assert.Equal(t, []string{root}, availableUnwatchedPollRoots(local), + "missing optional storage must not block SQLite coverage") + assert.True(t, probeWatchRecoveryScope(cfg).coversProviderRoot(root), + "missing optional storage must not block provider fallback authority") + owner := &watchSyncRecorder{lookupResults: map[string]bool{ + string(parser.AgentOpenCode) + "\x00" + storage: true, + }} + assert.False(t, probeWatchRecoveryScopeWithOwner(cfg, owner).coversProviderRoot(root), + "archived storage ownership must keep the missing root blocking") +} + +func TestWatchCoverageForPathsRoutesSQLiteButLeavesStorageNative(t *testing.T) { + root := t.TempDir() + storage := filepath.Join(root, "storage") + roots := []watchRoot{ + {path: root, scopes: []watchScope{ + {agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root}, + {agent: parser.AgentClaude, syncDir: root}, + }}, + {path: storage, recursive: true, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + }}}, + } + dbPath := filepath.Join(root, "opencode.db-wal") + storagePath := filepath.Join(storage, "message", "ses_1", "msg_1.json") + + assert.Equal(t, []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: root, + CoverageKey: "opencode-sqlite:" + root, Paths: []string{dbPath}, + ConsumePath: false, + }}, watchCoverageForPaths(roots, []string{storagePath, dbPath})) +} + +func TestWatchCoverageForPathsAppliesCoverageRoutingGlobs(t *testing.T) { + root := t.TempDir() + roots := []watchRoot{{ + path: root, + scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + includeGlobs: "opencode.db\x00opencode.db-wal", + }}, + }} + + assert.Empty(t, watchCoverageForPaths( + roots, []string{filepath.Join(root, "unrelated.json")}, + )) + assert.Equal(t, []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: root, + CoverageKey: "opencode-sqlite:" + root, + AuthoritativeFallback: true, + Paths: []string{filepath.Join(root, "opencode.db-wal")}, + ConsumePath: true, + }}, watchCoverageForPaths(roots, []string{ + filepath.Join(root, "unrelated.json"), + filepath.Join(root, "opencode.db-wal"), + })) +} + +func TestWatchCoverageForPathsPreservesStartupProbeAuthority(t *testing.T) { + root := t.TempDir() + storage := filepath.Join(root, "storage") + scope := watchScope{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + includeGlobs: "opencode.db\x00opencode.db-wal", + } + dbPath := filepath.Join(root, "opencode.db") + + optional := watchCoverageForPaths([]watchRoot{ + {path: root, exists: true, scopes: []watchScope{scope}}, + {path: storage, nonBlockingProbe: true, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + }}}, + }, []string{dbPath}) + require.Len(t, optional, 1) + assert.True(t, optional[0].AuthoritativeFallback) + + require.NoError(t, os.Mkdir(storage, 0o755)) + plannedPresent := []watchRoot{ + {path: root, exists: true, scopes: []watchScope{scope}}, + {path: storage, exists: true, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + }}}, + } + require.NoError(t, os.RemoveAll(storage)) + disappeared := watchCoverageForPaths(plannedPresent, []string{dbPath}) + require.Len(t, disappeared, 1) + assert.False(t, disappeared[0].AuthoritativeFallback) +} + +func TestWatchCoverageAuthorityIncludesBrokenSymlinkGate(t *testing.T) { + root := t.TempDir() + symRoot := filepath.Join(root, "storage-link") + scope := watchScope{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + includeGlobs: "opencode.db", + } + coverage := watchCoverageForPathsWithAuthority( + []watchRoot{{path: root, scopes: []watchScope{scope}}}, + []watchRoot{ + {path: root, scopes: []watchScope{scope}}, + {path: symRoot, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + }}}, + }, nil, []string{filepath.Join(root, "opencode.db")}, + ) + require.Len(t, coverage, 1) + assert.False(t, coverage[0].AuthoritativeFallback) +} + +func TestWatchCoverageOptionalProbeBlocksWhenArchiveOwnsSources(t *testing.T) { + root := t.TempDir() + storage := filepath.Join(root, "storage") + scope := watchScope{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + includeGlobs: "opencode.db", + } + owner := &watchSyncRecorder{lookupResults: map[string]bool{ + string(parser.AgentOpenCode) + "\x00" + storage: true, + }} + coverage := watchCoverageForPathsWithAuthority( + []watchRoot{{path: root, scopes: []watchScope{scope}}}, + []watchRoot{ + {path: root, scopes: []watchScope{scope}}, + {path: storage, nonBlockingProbe: true, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + }}}, + }, owner, []string{filepath.Join(root, "opencode.db")}, + ) + require.Len(t, coverage, 1) + assert.False(t, coverage[0].AuthoritativeFallback) +} + +func TestWatchCoverageForPathsRetainsGenericAncestorOwnership(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "nested") + dbPath := filepath.Join(nested, "opencode.db-wal") + roots := []watchRoot{ + {path: root, recursive: true, scopes: []watchScope{{ + agent: parser.AgentClaude, syncDir: root, + }}}, + {path: nested, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: nested, + coverageKey: "opencode-sqlite:" + nested, + }}}, + } + + assert.Equal(t, []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: nested, + CoverageKey: "opencode-sqlite:" + nested, + Paths: []string{dbPath}, ConsumePath: false, + }}, watchCoverageForPaths(roots, []string{dbPath})) +} + +func TestWatcherUnavailableOpenCodeUsesOnlyTypedPolling(t *testing.T) { + root := t.TempDir() + planned := watchRoot{ + path: root, scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + }}, + } + var typed []agentsync.PollingObligation + var degraded [][]string + err := registerWatcherUnavailableObligations( + agentsync.WatcherOptions{ + OnPollingRequired: func(obligation agentsync.PollingObligation) error { + typed = append(typed, obligation) + return nil + }, + OnCoverageDegraded: func(roots []string) error { + degraded = append(degraded, append([]string(nil), roots...)) + return nil + }, + }, + []watchRoot{planned}, []string{root}, nil, + ) + require.NoError(t, err) + require.Len(t, typed, 1) + require.Len(t, typed[0].Scopes, 1) + assert.Empty(t, degraded) +} + func TestCollectWatchRootsUsesGeminiProviderMetadataRoot(t *testing.T) { root := t.TempDir() tmpRoot := filepath.Join(root, "tmp") @@ -1580,8 +1834,10 @@ func TestWatchPollingObligationsKeepPendingAndPersistentReasonsIndependent(t *te ) assert.Equal(t, []agentsync.PollingObligation{ - {Key: pendingPath, Roots: []string{shared}, Probe: pendingPath}, - {Key: "persistent:" + shared, Roots: []string{shared}, Probe: shared}, + {Key: pendingPath, Roots: []string{shared}, Probe: pendingPath, + Scopes: []agentsync.WatchScope{{Agent: string(parser.AgentDevin), SyncDir: shared}}}, + {Key: "persistent:" + shared, Roots: []string{shared}, Probe: shared, + Scopes: []agentsync.WatchScope{{Agent: string(parser.AgentDevin), SyncDir: shared}}}, }, got) } @@ -1601,6 +1857,7 @@ func TestWatchPollingObligationsCoverRegistrationFailureByLogicalRoot(t *testing assert.Equal(t, []agentsync.PollingObligation{{ Key: watchPath, Roots: []string{syncDir}, Probe: watchPath, + Scopes: []agentsync.WatchScope{{Agent: string(parser.AgentClaude), SyncDir: syncDir}}, }}, got) } @@ -1616,12 +1873,34 @@ func TestSymlinkPollingObligationsGateDirsOnTargetAvailability(t *testing.T) { symRoot := filepath.Join(parent, "sessions") requireSymlinkOrSkip(t, target, symRoot) - obligations := symlinkPollingObligations(map[string][]string{ - symRoot: {parent}, + obligations := symlinkPollingObligations(map[string]symlinkGate{ + symRoot: {scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: parent, + coverageKey: "opencode-storage:" + symRoot, + }}}, }) require.Equal(t, []agentsync.PollingObligation{{ Key: "symlink:" + symRoot, Roots: []string{parent}, Probe: symRoot, + Scopes: []agentsync.WatchScope{{ + Agent: string(parser.AgentOpenCode), SyncDir: parent, + CoverageKey: "opencode-storage:" + symRoot, + }}, }}, obligations) + recorder := &watchSyncRecorder{} + pollCoverageOnce(t.Context(), recorder, pollingObligation{ + Key: obligations[0].Key, Roots: obligations[0].Roots, + Probe: obligations[0].Probe, + Scopes: []pollingScope{{ + Agent: parser.AgentOpenCode, Root: parent, + CoverageKey: "opencode-storage:" + symRoot, + }}, + }) + assert.Equal(t, []parser.CoverageTask{{ + Agent: parser.AgentOpenCode, Root: parent, + CoverageKey: "opencode-storage:" + symRoot, + Trigger: parser.CoverageTriggerDegraded, + }}, recorder.coverageCalls) + assert.Empty(t, recorder.reconcileCalls) combined := []pollingObligation{ {Key: "persistent:" + parent, Roots: []string{parent}, Probe: parent}, @@ -1635,6 +1914,27 @@ func TestSymlinkPollingObligationsGateDirsOnTargetAvailability(t *testing.T) { "a broken symlink target must defer the dir even though the dir itself exists") } +func TestSymlinkPollingObligationsPreserveOptionalProbe(t *testing.T) { + parent := t.TempDir() + symRoot := filepath.Join(parent, "missing-storage-link") + obligations := symlinkPollingObligations(map[string]symlinkGate{ + symRoot: { + nonBlockingProbe: true, + scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: parent, + }}, + }, + }) + require.Len(t, obligations, 1) + assert.True(t, obligations[0].NonBlockingProbe) + assert.Equal(t, []string{parent}, availableUnwatchedPollRoots([]pollingObligation{ + {Key: "persistent", Roots: []string{parent}, Probe: parent}, + {Key: obligations[0].Key, Roots: obligations[0].Roots, + Probe: obligations[0].Probe, + NonBlockingProbe: obligations[0].NonBlockingProbe}, + })) +} + // TestWatcherUnavailableFallbackDefersBrokenSymlinkScope guards the // watcher-construction failure path: the coverage-degraded fallback poll // covers every configured dir, and a configured dir can still exist while @@ -1650,24 +1950,29 @@ func TestWatcherUnavailableFallbackDefersBrokenSymlinkScope(t *testing.T) { requireSymlinkOrSkip(t, target, symRoot) other := requireExistingPollRoot(t, t.TempDir(), "other") - syncer := &recordingUnwatchedPollSyncer{wake: make(chan struct{}, 3)} - coordinator := newUnwatchedPollCoordinatorWithTicks( - t.Context(), syncer, make(chan time.Time), func() {}, - func(run func()) { run() }, nil, - ) - t.Cleanup(coordinator.Stop) + var registered []pollingObligation options := agentsync.WatcherOptions{ OnCoverageDegraded: func(roots []string) error { - return coordinator.AddObligation(pollingObligation{ + registered = append(registered, pollingObligation{ Key: "watcher-fallback", Roots: roots, }) + return nil }, OnPollingRequired: func(obligation agentsync.PollingObligation) error { - return coordinator.AddObligation(pollingObligation{ - Key: obligation.Key, - Roots: obligation.Roots, - Probe: obligation.Probe, + scopes := make([]pollingScope, 0, len(obligation.Scopes)) + for _, scope := range obligation.Scopes { + scopes = append(scopes, pollingScope{ + Agent: parser.AgentType(scope.Agent), Root: scope.SyncDir, + CoverageKey: scope.CoverageKey, + }) + } + registered = append(registered, pollingObligation{ + Key: obligation.Key, + Roots: obligation.Roots, + Probe: obligation.Probe, + Scopes: scopes, }) + return nil }, } @@ -1675,26 +1980,21 @@ func TestWatcherUnavailableFallbackDefersBrokenSymlinkScope(t *testing.T) { options, nil, []string{parent, other}, - map[string][]string{symRoot: {parent}}, + map[string]symlinkGate{symRoot: {scopes: []watchScope{{ + agent: parser.AgentOpenCode, syncDir: parent, + coverageKey: "opencode-storage:" + symRoot, + }}}}, )) - bothDirs := []string{parent, other} - slices.Sort(bothDirs) - coordinator.requestPoll() - requirePollWithin(t, syncer.wake, time.Second) - assert.Equal(t, [][]string{bothDirs}, syncer.snapshot(), + assert.ElementsMatch(t, []string{parent, other}, availableUnwatchedPollRoots(registered), "a working symlink target keeps the configured dir pollable") require.NoError(t, os.RemoveAll(target)) - coordinator.requestPoll() - requirePollWithin(t, syncer.wake, time.Second) - assert.Equal(t, [][]string{bothDirs, {other}}, syncer.snapshot(), + assert.Equal(t, []string{other}, availableUnwatchedPollRoots(registered), "a broken symlink target must defer the configured dir from the fallback poll") require.NoError(t, os.MkdirAll(target, 0o755)) - coordinator.requestPoll() - requirePollWithin(t, syncer.wake, time.Second) - assert.Equal(t, [][]string{bothDirs, {other}, bothDirs}, syncer.snapshot(), + assert.ElementsMatch(t, []string{parent, other}, availableUnwatchedPollRoots(registered), "the deferred dir must resume once the symlink target returns") } @@ -2181,6 +2481,23 @@ type watchSyncRecorder struct { reconcileErr error callOrder []string ctxValue any + coverageCalls []parser.CoverageTask + coverageResult parser.CoverageResult + coverageErr error + excludedCalls []watchExcludedPathCall +} + +type watchExcludedPathCall struct { + paths []string + agents []parser.AgentType +} + +func (r *watchSyncRecorder) ReconcileCoverage( + _ context.Context, task parser.CoverageTask, +) (parser.CoverageResult, error) { + r.coverageCalls = append(r.coverageCalls, task) + r.callOrder = append(r.callOrder, "coverage") + return r.coverageResult, r.coverageErr } type watchReconcileCall struct { @@ -2226,6 +2543,17 @@ func (r *watchSyncRecorder) SyncPathsContext(ctx context.Context, paths []string return r.pathErr } +func (r *watchSyncRecorder) SyncPathsExcludingProvidersContext( + ctx context.Context, paths []string, agents []parser.AgentType, +) error { + r.excludedCalls = append(r.excludedCalls, watchExcludedPathCall{ + paths: append([]string(nil), paths...), + agents: append([]parser.AgentType(nil), agents...), + }) + r.ctxValue = ctx.Value(watchSyncContextKey{}) + return r.pathErr +} + func (r *watchSyncRecorder) HasActiveSessionSourceBelow(agent, path string) (bool, error) { r.lookupCalls = append(r.lookupCalls, [2]string{agent, path}) return r.lookupResults[agent+"\x00"+path], nil @@ -2260,6 +2588,37 @@ func (r *watchSyncRecorder) ReconcileWatchRootsAfterLostEvents( return r.reconcileErr } +func TestNilEngineCoverageAnnotationDispatchesOpenCodeEvent(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + require.NoError(t, os.WriteFile(dbPath, []byte("sqlite"), 0o600)) + scope := watchScope{ + agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root, + includeGlobs: "opencode.db\x00opencode.db-wal", + } + roots := []watchRoot{{path: root, scopes: []watchScope{scope}}} + authorityRoots := append([]watchRoot(nil), roots...) + authorityRoots = append(authorityRoots, watchRoot{ + path: filepath.Join(root, "storage"), + nonBlockingProbe: true, scopes: []watchScope{scope}, + }) + var received agentsync.WatchBatch + callback := annotateWatchCoverageWithAuthority( + func(_ context.Context, batch agentsync.WatchBatch) error { + received = batch + return nil + }, + roots, authorityRoots, activeSourceProbeForEngine(nil), + ) + + require.NoError(t, callback(t.Context(), agentsync.WatchBatch{ + Paths: []string{dbPath}, + })) + require.Len(t, received.Coverage, 1) + assert.True(t, received.Coverage[0].AuthoritativeFallback) +} + type watchSyncContextKey struct{} func TestSyncWatchBatch(t *testing.T) { @@ -2274,6 +2633,101 @@ func TestSyncWatchBatch(t *testing.T) { probedRoot := filepath.Join(tempDir, "probed-root") agent := string(parser.AgentCodex) + t.Run("provider coverage bypasses changed-path discovery", func(t *testing.T) { + dbPath := filepath.Join(tempDir, "opencode.db-wal") + recorder := &watchSyncRecorder{} + err := syncWatchBatch(ctx, recorder, agentsync.WatchBatch{ + Paths: []string{dbPath}, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: tempDir, + CoverageKey: "opencode-sqlite:" + tempDir, + AuthoritativeFallback: true, + Paths: []string{dbPath}, + }}, + }, staticFullRoots()) + + require.NoError(t, err) + assert.Empty(t, recorder.pathCalls) + assert.Empty(t, recorder.reconcileCalls) + assert.Equal(t, []parser.CoverageTask{{ + Agent: parser.AgentOpenCode, Root: tempDir, + CoverageKey: "opencode-sqlite:" + tempDir, + Trigger: parser.CoverageTriggerNative, + AuthoritativeFallback: true, + ChangedPaths: []string{dbPath}, + }}, recorder.coverageCalls) + }) + + t.Run("provider coverage preserves annotated fallback authority", func(t *testing.T) { + dbPath := filepath.Join(tempDir, "opencode.db-wal") + recorder := &watchSyncRecorder{} + err := syncWatchBatch(ctx, recorder, agentsync.WatchBatch{ + Paths: []string{dbPath}, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: tempDir, + CoverageKey: "opencode-sqlite:" + tempDir, + AuthoritativeFallback: true, + Paths: []string{dbPath}, + }}, + }, staticFullRoots(tempDir)) + + require.NoError(t, err) + require.Len(t, recorder.coverageCalls, 1) + assert.True(t, recorder.coverageCalls[0].AuthoritativeFallback) + }) + + t.Run("coverage failure preserves authoritative retry work", func(t *testing.T) { + coverageErr := errors.New("coverage failed") + recorder := &watchSyncRecorder{coverageErr: coverageErr} + batch := agentsync.WatchBatch{ + Paths: []string{filePath}, ReconcileRoots: []string{root}, + LostEvents: true, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: tempDir, + CoverageKey: "opencode-sqlite:" + tempDir, + Paths: []string{filePath}, + }}, + } + err := syncWatchBatch(ctx, recorder, batch, staticFullRoots()) + assert.ErrorIs(t, err, coverageErr) + assert.Equal(t, batch, requireWatchRetryBatch(t, err)) + }) + + t.Run("coverage failure promotes coalesced full retry", func(t *testing.T) { + coverageErr := errors.New("coverage failed") + recorder := &watchSyncRecorder{coverageErr: coverageErr} + err := syncWatchBatch(ctx, recorder, agentsync.WatchBatch{ + FullSync: true, LostEvents: true, + Paths: []string{filePath}, ReconcileRoots: []string{root}, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: tempDir, + CoverageKey: "opencode-sqlite:" + tempDir, + }}, + }, staticFullRoots()) + assert.ErrorIs(t, err, coverageErr) + assert.Equal(t, agentsync.WatchBatch{ + FullSync: true, LostEvents: true, + }, requireWatchRetryBatch(t, err)) + }) + + t.Run("generic exclusion failure preserves coalesced full retry", func(t *testing.T) { + pathErr := errors.New("generic path failed") + recorder := &watchSyncRecorder{pathErr: pathErr} + err := syncWatchBatch(ctx, recorder, agentsync.WatchBatch{ + FullSync: true, LostEvents: true, Paths: []string{filePath}, + ReconcileRoots: []string{root}, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: tempDir, + CoverageKey: "opencode-sqlite:" + tempDir, + Paths: []string{filePath}, ConsumePath: false, + }}, + }, staticFullRoots()) + assert.ErrorIs(t, err, pathErr) + assert.Equal(t, agentsync.WatchBatch{ + FullSync: true, LostEvents: true, + }, requireWatchRetryBatch(t, err)) + }) + t.Run("file rename uses changed path", func(t *testing.T) { recorder := &watchSyncRecorder{} err := syncWatchBatch(ctx, recorder, agentsync.WatchBatch{Renames: []agentsync.WatchRename{{ @@ -2599,6 +3053,83 @@ func TestSyncWatchBatch(t *testing.T) { }) } +func TestOpenCodeNativeCoverageBypassesChangedPathDiscovery(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db-wal") + recorder := &watchSyncRecorder{} + err := syncWatchBatch(t.Context(), recorder, agentsync.WatchBatch{ + Paths: []string{dbPath}, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: root, + CoverageKey: "opencode-sqlite:" + root, Paths: []string{dbPath}, + }}, + }, staticFullRoots()) + + require.NoError(t, err) + assert.Empty(t, recorder.pathCalls) + assert.Empty(t, recorder.reconcileCalls) + assert.Len(t, recorder.coverageCalls, 1) + assert.Equal(t, []watchExcludedPathCall{{ + paths: []string{dbPath}, agents: []parser.AgentType{parser.AgentOpenCode}, + }}, recorder.excludedCalls) +} + +func TestOpenCodeRenameUsesBoundedCoverageAndPreservesGenericScope(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + recorder := &watchSyncRecorder{} + roots := []watchRoot{{ + path: root, + scopes: []watchScope{ + {agent: parser.AgentOpenCode, syncDir: root, + coverageKey: "opencode-sqlite:" + root}, + {agent: parser.AgentClaude, syncDir: root}, + }, + }} + callback := annotateWatchCoverage( + func(ctx context.Context, batch agentsync.WatchBatch) error { + return syncWatchBatch(ctx, recorder, batch, staticFullRoots()) + }, + roots, + ) + + err := callback(t.Context(), agentsync.WatchBatch{ + Renames: []agentsync.WatchRename{{ + Path: dbPath, Agent: string(parser.AgentOpenCode), + ItemType: agentsync.ItemIsFile, + }}, + }) + require.NoError(t, err) + assert.Len(t, recorder.coverageCalls, 1) + assert.Equal(t, []string{dbPath}, recorder.coverageCalls[0].ChangedPaths) + assert.Equal(t, []watchExcludedPathCall{{ + paths: []string{dbPath}, agents: []parser.AgentType{parser.AgentOpenCode}, + }}, recorder.excludedCalls) + assert.Empty(t, recorder.pathCalls) +} + +func TestOpenCodeCoverageExcludesEverySharedGenericPath(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + walPath := dbPath + "-wal" + recorder := &watchSyncRecorder{} + err := syncWatchBatch(t.Context(), recorder, agentsync.WatchBatch{ + Paths: []string{dbPath, walPath}, + Coverage: []agentsync.WatchCoverage{{ + Agent: string(parser.AgentOpenCode), Root: root, + CoverageKey: "opencode-sqlite:" + root, + Paths: []string{dbPath, walPath}, ConsumePath: false, + }}, + }, staticFullRoots()) + + require.NoError(t, err) + assert.Empty(t, recorder.pathCalls) + assert.Equal(t, []watchExcludedPathCall{ + {paths: []string{dbPath}, agents: []parser.AgentType{parser.AgentOpenCode}}, + {paths: []string{walPath}, agents: []parser.AgentType{parser.AgentOpenCode}}, + }, recorder.excludedCalls) +} + type stubRetryRootsError struct{ roots []string } func (e *stubRetryRootsError) Error() string { return "reconciliation incomplete" } diff --git a/cmd/agentsview/sync_worker.go b/cmd/agentsview/sync_worker.go index 110851c18..299dd2d68 100644 --- a/cmd/agentsview/sync_worker.go +++ b/cmd/agentsview/sync_worker.go @@ -195,7 +195,7 @@ func runSyncWorkerStartup( var stats sync.SyncStats var tombstoned int var auditErr error - if auditRoots := reconcileRootPaths(cfg); len(auditRoots) > 0 { + if auditRoots := reconcileRootPathsWithOwner(cfg, engine); len(auditRoots) > 0 { stats, tombstoned, auditErr = engine.ReconcileWatchRootsWithStats( ctx, auditRoots, false, ) diff --git a/cmd/agentsview/unwatched_poll.go b/cmd/agentsview/unwatched_poll.go index 40b042e0b..f5c6aa534 100644 --- a/cmd/agentsview/unwatched_poll.go +++ b/cmd/agentsview/unwatched_poll.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/server" ) @@ -20,6 +21,10 @@ type unwatchedPollSyncer interface { ReconcileWatchRoots(context.Context, []string, bool) error } +type boundedCoverageSyncer interface { + ReconcileCoverage(context.Context, parser.CoverageTask) (parser.CoverageResult, error) +} + type unwatchedPollAdd struct { obligation pollingObligation remove bool @@ -36,7 +41,16 @@ type pollingObligation struct { // vanish while its configured scope still exists, and reconciling // the scope then would tombstone every session under the missing // subtree. Empty means the Roots themselves are probed. - Probe string + Probe string + NonBlockingProbe bool + Scopes []pollingScope +} + +type pollingScope struct { + Agent parser.AgentType + Root string + CoverageKey string + AuthoritativeFallback bool } type sharedUnwatchedPollCoordinator struct { @@ -58,6 +72,7 @@ type sharedUnwatchedPollCoordinator struct { // coordinator loop; each entry keeps its probe so availability is // evaluated per obligation at poll time. pollObligations []pollingObligation + completionDelay time.Duration stop chan struct{} done chan struct{} stopOnce sync.Once @@ -69,8 +84,9 @@ func newUnwatchedPollCoordinator( idleTracker *server.IdleTracker, ) *sharedUnwatchedPollCoordinator { ticker := time.NewTicker(unwatchedPollInterval) - return newUnwatchedPollCoordinatorWithTicks( + return newUnwatchedPollCoordinatorWithSchedule( ctx, engine, ticker.C, ticker.Stop, idleTracker.Do, nil, + unwatchedPollInterval, ) } @@ -81,22 +97,37 @@ func newUnwatchedPollCoordinatorWithTicks( stopTicker func(), doWork func(func()), onRootsOwned func([]string), +) *sharedUnwatchedPollCoordinator { + return newUnwatchedPollCoordinatorWithSchedule( + ctx, engine, ticks, stopTicker, doWork, onRootsOwned, 0, + ) +} + +func newUnwatchedPollCoordinatorWithSchedule( + ctx context.Context, + engine unwatchedPollSyncer, + ticks <-chan time.Time, + stopTicker func(), + doWork func(func()), + onRootsOwned func([]string), + completionDelay time.Duration, ) *sharedUnwatchedPollCoordinator { workerCtx, workerCancel := context.WithCancel(ctx) coordinator := &sharedUnwatchedPollCoordinator{ - ctx: ctx, - workerCtx: workerCtx, - workerCancel: workerCancel, - engine: engine, - ticks: ticks, - stopTicker: stopTicker, - doWork: doWork, - add: make(chan unwatchedPollAdd), - pollWake: make(chan struct{}, 1), - pollDone: make(chan struct{}), - stop: make(chan struct{}), - done: make(chan struct{}), - onRootsOwned: onRootsOwned, + ctx: ctx, + workerCtx: workerCtx, + workerCancel: workerCancel, + engine: engine, + ticks: ticks, + stopTicker: stopTicker, + doWork: doWork, + add: make(chan unwatchedPollAdd), + pollWake: make(chan struct{}, 1), + pollDone: make(chan struct{}), + stop: make(chan struct{}), + done: make(chan struct{}), + onRootsOwned: onRootsOwned, + completionDelay: completionDelay, } go coordinator.run() return coordinator @@ -120,9 +151,9 @@ func (c *sharedUnwatchedPollCoordinator) updateRoots( ) error { request := unwatchedPollAdd{ obligation: pollingObligation{ - Key: obligation.Key, - Roots: append([]string(nil), obligation.Roots...), - Probe: obligation.Probe, + Key: obligation.Key, Roots: append([]string(nil), obligation.Roots...), + Probe: obligation.Probe, NonBlockingProbe: obligation.NonBlockingProbe, + Scopes: append([]pollingScope(nil), obligation.Scopes...), }, remove: remove, done: make(chan struct{}), @@ -216,17 +247,185 @@ func (c *sharedUnwatchedPollCoordinator) runPollWorker() { if c.workerCtx.Err() != nil { return } - roots := availableUnwatchedPollRoots(c.currentPollObligations()) - if len(roots) == 0 { + obligations := c.currentPollObligations() + if len(obligations) == 0 { + continue + } + if allGenericPollingObligations(obligations) { + roots := availableUnwatchedPollRoots(obligations) + if len(roots) == 0 { + continue + } + c.doWork(func() { + if c.workerCtx.Err() == nil { + pollUnwatchedRootsOnce(c.workerCtx, c.engine, roots) + } + }) continue } - log.Printf("polling %d unwatched root(s)", len(roots)) + log.Printf("polling %d unwatched obligation(s)", len(obligations)) c.doWork(func() { if c.workerCtx.Err() != nil { return } - pollUnwatchedRootsOnce(c.workerCtx, c.engine, roots) + available := make(map[string]struct{}) + for _, root := range availableUnwatchedPollRoots(obligations) { + available[absRootPath(root)] = struct{}{} + } + genericRoots := make(map[string]struct{}) + typedRoots := make(map[string]struct{}) + typedScopes := make([]pollingScope, 0) + seenScopes := make(map[string]struct{}) + for _, obligation := range obligations { + if !pollingObligationProbeAvailable(obligation) { + continue + } + availableObligation := filterPollingObligationRoots( + obligation, available, + ) + if len(obligation.Scopes) == 0 { + for _, root := range availableObligation.Roots { + genericRoots[root] = struct{}{} + } + continue + } + for _, scope := range obligation.Scopes { + if scope.CoverageKey == "" { + if _, ok := available[absRootPath(scope.Root)]; !ok { + continue + } + } else if _, err := os.Stat(scope.Root); err != nil { + continue + } + scope.AuthoritativeFallback = coverageFallbackAvailable( + scope, obligations, c.engine, + ) + key := string(scope.Agent) + "\x00" + scope.Root + "\x00" + scope.CoverageKey + if _, ok := seenScopes[key]; ok { + continue + } + seenScopes[key] = struct{}{} + typedScopes = append(typedScopes, scope) + typedRoots[scope.Root] = struct{}{} + } + } + if len(genericRoots) > 0 { + pollUnwatchedRootsOnce( + c.workerCtx, c.engine, unwatchedPollRoots(genericRoots), + ) + } + if len(typedScopes) > 0 { + pollCoverageOnce(c.workerCtx, c.engine, pollingObligation{ + Roots: unwatchedPollRoots(typedRoots), Scopes: typedScopes, + }) + } }) + if c.completionDelay > 0 { + delay := c.completionDelay + c.drainPollWake() + timer := time.NewTimer(delay) + select { + case <-c.workerCtx.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + } + c.drainPollWake() + c.requestPoll() + } + } + } +} + +func coverageFallbackAvailable( + scope pollingScope, obligations []pollingObligation, engine unwatchedPollSyncer, +) bool { + scopeRoot := absRootPath(scope.Root) + for _, obligation := range obligations { + overlaps := false + for _, root := range obligation.Roots { + root = absRootPath(root) + if root == scopeRoot || pathWithinRoot(root, scopeRoot) || + pathWithinRoot(scopeRoot, root) { + overlaps = true + break + } + } + if !overlaps { + continue + } + if obligation.Probe != "" { + if _, err := os.Stat(obligation.Probe); err != nil { + owner, _ := engine.(activeSourceProbe) + hasArchived := false + if owner != nil { + hasArchived, err = owner.HasActiveSessionSourceBelow( + string(scope.Agent), obligation.Probe, + ) + if err != nil { + hasArchived = true + } + } + if !obligation.NonBlockingProbe || hasArchived { + return false + } + } + continue + } + for _, root := range obligation.Roots { + if _, err := os.Stat(root); err != nil { + return false + } + } + } + return true +} + +func pollingObligationProbeAvailable(obligation pollingObligation) bool { + if obligation.Probe == "" { + return true + } + _, err := os.Stat(obligation.Probe) + return err == nil +} + +func filterPollingObligationRoots( + obligation pollingObligation, available map[string]struct{}, +) pollingObligation { + obligation.Roots = slices.DeleteFunc( + append([]string(nil), obligation.Roots...), + func(root string) bool { + _, ok := available[absRootPath(root)] + return !ok + }, + ) + obligation.Scopes = slices.DeleteFunc( + append([]pollingScope(nil), obligation.Scopes...), + func(scope pollingScope) bool { + _, ok := available[absRootPath(scope.Root)] + return !ok + }, + ) + return obligation +} + +func allGenericPollingObligations(obligations []pollingObligation) bool { + for _, o := range obligations { + if len(o.Scopes) > 0 { + return false + } + } + return true +} + +func (c *sharedUnwatchedPollCoordinator) drainPollWake() { + for { + select { + case <-c.pollWake: + default: + return } } } @@ -262,10 +461,13 @@ func availableUnwatchedPollRoots(obligations []pollingObligation) []string { if root == "" { continue } - if probeMissing { + if probeMissing && !obligation.NonBlockingProbe { blocked[filepath.Clean(root)] = struct{}{} continue } + if probeMissing { + continue + } if _, err := os.Stat(root); err == nil { candidates[root] = struct{}{} } @@ -310,3 +512,54 @@ func pollUnwatchedRootsOnce( log.Printf("polling unwatched roots: %v", err) } } + +func pollCoverageOnce( + ctx context.Context, engine unwatchedPollSyncer, obligation pollingObligation, +) { + coverage, ok := engine.(boundedCoverageSyncer) + if !ok || len(obligation.Scopes) == 0 { + pollUnwatchedRootsOnce(ctx, engine, obligation.Roots) + return + } + for _, scope := range obligation.Scopes { + if scope.Agent == "" || scope.Root == "" { + continue + } + trigger := parser.CoverageTriggerDegraded + for page := 0; ; page++ { + if page >= 1024 { + log.Printf("polling %s coverage: continuation limit exceeded", scope.Agent) + break + } + result, err := coverage.ReconcileCoverage(ctx, parser.CoverageTask{ + Agent: scope.Agent, CoverageKey: scope.CoverageKey, + Root: scope.Root, Trigger: trigger, + AuthoritativeFallback: scope.AuthoritativeFallback, + }) + if err != nil { + log.Printf("polling %s coverage: %v", scope.Agent, err) + break + } + if result.AuditRequired { + log.Printf("polling %s coverage requested archive audit", scope.Agent) + } + if !result.More { + break + } + trigger = parser.CoverageTriggerContinuation + delay := result.NextDelay + if delay <= 0 { + delay = 100 * time.Millisecond + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + } + } + } +} diff --git a/cmd/agentsview/unwatched_poll_test.go b/cmd/agentsview/unwatched_poll_test.go index 56d77c60f..812323aac 100644 --- a/cmd/agentsview/unwatched_poll_test.go +++ b/cmd/agentsview/unwatched_poll_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -42,6 +43,37 @@ type cancelBlockingUnwatchedPollSyncer struct { calls int } +type recordingCoverageSyncer struct { + recordingUnwatchedPollSyncer + coverageMu sync.Mutex + tasks []parser.CoverageTask + started chan parser.CoverageTask + release chan struct{} + result parser.CoverageResult + errors map[parser.AgentType]error +} + +func (s *recordingCoverageSyncer) ReconcileCoverage( + _ context.Context, task parser.CoverageTask, +) (parser.CoverageResult, error) { + s.coverageMu.Lock() + s.tasks = append(s.tasks, task) + s.coverageMu.Unlock() + if s.started != nil { + s.started <- task + } + if s.release != nil { + <-s.release + } + return s.result, s.errors[task.Agent] +} + +func (s *recordingCoverageSyncer) coverageTasks() []parser.CoverageTask { + s.coverageMu.Lock() + defer s.coverageMu.Unlock() + return append([]parser.CoverageTask(nil), s.tasks...) +} + func (s *cancelBlockingUnwatchedPollSyncer) ReconcileWatchRoots( ctx context.Context, _ []string, _ bool, ) error { @@ -173,6 +205,151 @@ func TestUnwatchedPollTickUsesRootsAddedAfterStart(t *testing.T) { "unwatched polling must reconcile the owned scopes authoritatively") } +func TestUnwatchedCoverageDispatchesEveryTypedScopeWithoutGlobalReconciliation( + t *testing.T, +) { + root := requireExistingPollRoot(t, t.TempDir(), "shared") + syncer := &recordingCoverageSyncer{ + recordingUnwatchedPollSyncer: recordingUnwatchedPollSyncer{ + wake: make(chan struct{}, 1), + }, + started: make(chan parser.CoverageTask, 2), + } + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "shared", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{ + {Agent: parser.AgentOpenCode, Root: root, CoverageKey: "opencode:" + root}, + {Agent: parser.AgentClaude, Root: root}, + }, + })) + + coordinator.requestPoll() + for range 2 { + select { + case <-syncer.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for typed coverage dispatch") + } + } + + assert.Empty(t, syncer.snapshot()) + assert.Equal(t, []parser.CoverageTask{ + {Agent: parser.AgentOpenCode, CoverageKey: "opencode:" + root, + Root: root, Trigger: parser.CoverageTriggerDegraded, + AuthoritativeFallback: true}, + {Agent: parser.AgentClaude, Root: root, + Trigger: parser.CoverageTriggerDegraded, + AuthoritativeFallback: true}, + }, syncer.coverageTasks()) +} + +func TestUnwatchedCoverageDeduplicatesTypedScopesAcrossObligations(t *testing.T) { + root := requireExistingPollRoot(t, t.TempDir(), "shared") + scope := pollingScope{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + } + syncer := &recordingCoverageSyncer{} + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "sqlite", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{scope}, + })) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "storage", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{scope}, + })) + + coordinator.requestPoll() + assert.Eventually(t, func() bool { return len(syncer.coverageTasks()) == 1 }, + time.Second, 10*time.Millisecond) + assert.Never(t, func() bool { return len(syncer.coverageTasks()) > 1 }, + 100*time.Millisecond, 10*time.Millisecond) +} + +func TestUnwatchedCoverageAttemptsIndependentScopesAfterFailures(t *testing.T) { + root := requireExistingPollRoot(t, t.TempDir(), "shared") + syncer := &recordingCoverageSyncer{ + started: make(chan parser.CoverageTask, 3), + errors: map[parser.AgentType]error{ + parser.AgentOpenCode: errors.New("opencode failed"), + parser.AgentClaude: errors.New("claude failed"), + }, + } + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "shared", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{ + {Agent: parser.AgentOpenCode, Root: root, CoverageKey: "open"}, + {Agent: parser.AgentCowork, Root: root}, + {Agent: parser.AgentClaude, Root: root}, + }, + })) + + coordinator.requestPoll() + for range 3 { + select { + case <-syncer.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for independent coverage dispatch") + } + } + assert.Len(t, syncer.coverageTasks(), 3) +} + +func TestUnwatchedCoverageDropsTickerDebtUntilCompletionDelay(t *testing.T) { + root := requireExistingPollRoot(t, t.TempDir(), "opencode") + ticks := make(chan time.Time, 4) + syncer := &recordingCoverageSyncer{ + started: make(chan parser.CoverageTask, 4), + release: make(chan struct{}, 4), + } + coordinator := newUnwatchedPollCoordinatorWithSchedule( + t.Context(), syncer, ticks, func() {}, func(run func()) { run() }, nil, + 80*time.Millisecond, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "opencode", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode:" + root, + }}, + })) + + ticks <- time.Now() + select { + case <-syncer.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first coverage pass") + } + ticks <- time.Now() + syncer.release <- struct{}{} + assert.Never(t, func() bool { + return len(syncer.coverageTasks()) > 1 + }, 50*time.Millisecond, 5*time.Millisecond) + + select { + case <-syncer.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for completion-scheduled pass") + } + syncer.release <- struct{}{} +} + func TestUnwatchedPollSkipsAbsentObligatedRootUntilItReturns(t *testing.T) { parent := t.TempDir() root := filepath.Join(parent, "provider") @@ -290,6 +467,136 @@ func TestUnwatchedPollDefersSharedScopeWhileAnyProbeMissing(t *testing.T) { "the shared scope must resume once every probe returns") } +func TestTypedUnwatchedPollDefersOverlappingScopeAcrossObligations(t *testing.T) { + base := t.TempDir() + nested := requireExistingPollRoot(t, base, "nested") + missingProbe := filepath.Join(nested, "missing-probe") + syncer := &recordingCoverageSyncer{} + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "base", Roots: []string{base}, Probe: base, + Scopes: []pollingScope{{Agent: parser.AgentOpenHands, Root: base}}, + })) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "nested", Roots: []string{nested}, Probe: missingProbe, + Scopes: []pollingScope{{Agent: parser.AgentClaude, Root: nested}}, + })) + + coordinator.requestPoll() + assert.Never(t, func() bool { return len(syncer.coverageTasks()) > 0 }, + 100*time.Millisecond, 10*time.Millisecond, + "typed dispatch must retain cross-obligation overlap gating") + + require.NoError(t, os.Mkdir(missingProbe, 0o755)) + coordinator.requestPoll() + assert.Eventually(t, func() bool { return len(syncer.coverageTasks()) == 2 }, + time.Second, 10*time.Millisecond) +} + +func TestTypedUnwatchedPollSkipsMissingNonBlockingObligation(t *testing.T) { + root := t.TempDir() + storage := filepath.Join(root, "storage") + syncer := &recordingCoverageSyncer{} + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "sqlite", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + }}, + })) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "storage", Roots: []string{root}, Probe: storage, + NonBlockingProbe: true, + Scopes: []pollingScope{{Agent: parser.AgentOpenCode, Root: root}}, + })) + + coordinator.requestPoll() + assert.Eventually(t, func() bool { return len(syncer.coverageTasks()) == 1 }, + time.Second, 10*time.Millisecond) + assert.Equal(t, "opencode-sqlite:"+root, + syncer.coverageTasks()[0].CoverageKey) + assert.True(t, syncer.coverageTasks()[0].AuthoritativeFallback, + "startup-optional storage must not defer provider-wide fallback") + assert.Empty(t, syncer.snapshot()) + + require.NoError(t, os.Mkdir(storage, 0o755)) + coordinator.requestPoll() + assert.Eventually(t, func() bool { return len(syncer.coverageTasks()) == 3 }, + time.Second, 10*time.Millisecond) + assert.True(t, syncer.coverageTasks()[1].AuthoritativeFallback) + assert.True(t, syncer.coverageTasks()[2].AuthoritativeFallback) +} + +func TestTypedUnwatchedPollRunsKeyedCoverageWithoutFallbackAuthority(t *testing.T) { + root := t.TempDir() + storage := filepath.Join(root, "storage") + syncer := &recordingCoverageSyncer{} + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "sqlite", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + }}, + })) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "storage", Roots: []string{root}, Probe: storage, + Scopes: []pollingScope{{Agent: parser.AgentOpenCode, Root: root}}, + })) + + coordinator.requestPoll() + assert.Eventually(t, func() bool { return len(syncer.coverageTasks()) == 1 }, + time.Second, 10*time.Millisecond) + task := syncer.coverageTasks()[0] + assert.Equal(t, "opencode-sqlite:"+root, task.CoverageKey) + assert.False(t, task.AuthoritativeFallback) + assert.Empty(t, syncer.snapshot()) +} + +func TestTypedUnwatchedPollAllowsSharedGenericFallbackPastOptionalProbe(t *testing.T) { + root := t.TempDir() + storage := filepath.Join(root, "storage") + syncer := &recordingCoverageSyncer{} + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, + ) + t.Cleanup(coordinator.Stop) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "sqlite", Roots: []string{root}, Probe: root, + Scopes: []pollingScope{ + {Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root}, + {Agent: parser.AgentClaude, Root: root}, + }, + })) + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "storage", Roots: []string{root}, Probe: storage, + NonBlockingProbe: true, + Scopes: []pollingScope{{Agent: parser.AgentOpenCode, Root: root}}, + })) + + coordinator.requestPoll() + assert.Eventually(t, func() bool { return len(syncer.coverageTasks()) == 2 }, + time.Second, 10*time.Millisecond) + for _, task := range syncer.coverageTasks() { + assert.True(t, task.AuthoritativeFallback) + } +} + // TestAvailableUnwatchedPollRootsDefersRootsOverlappingBlockedScopes pins the // cross-obligation analogue of overlapsDeferredScope: a missing probe blocks // its own roots, but ReconcileWatchRoots expands every requested root to the diff --git a/internal/parser/capabilities.go b/internal/parser/capabilities.go index c9f2518fe..ef110664c 100644 --- a/internal/parser/capabilities.go +++ b/internal/parser/capabilities.go @@ -53,6 +53,7 @@ type SourceCapabilities struct { WatchSources CapabilitySupport WatchRoots CapabilitySupport ActivityHints CapabilitySupport + BoundedCoverage CapabilitySupport ClassifyChangedPath CapabilitySupport StoredSourceHints CapabilitySupport FindSource CapabilitySupport diff --git a/internal/parser/opencode.go b/internal/parser/opencode.go index 4a416e467..fc2c21a58 100644 --- a/internal/parser/opencode.go +++ b/internal/parser/opencode.go @@ -141,13 +141,6 @@ func parseOpenCodeDBSession( } defer db.Close() - projects, err := loadOpenCodeProjectsCached(db, dbPath) - if err != nil { - return nil, nil, fmt.Errorf( - "loading opencode projects: %w", err, - ) - } - hasDirectory, err := openCodeSessionHasDirectoryCached(db, dbPath) if err != nil { return nil, nil, fmt.Errorf( @@ -163,7 +156,11 @@ func parseOpenCodeDBSession( ) } - projectWorktree := strings.TrimSpace(projects[s.projectID]) + projectWorktree, err := loadOpenCodeProjectWorktree(db, s.projectID) + if err != nil { + return nil, nil, fmt.Errorf("loading opencode project: %w", err) + } + projectWorktree = strings.TrimSpace(projectWorktree) cwd := resolveOpenCodeWorktree(s.directory, projectWorktree) if !openCodeUsableWorktree(projectWorktree) { projectWorktree = cwd @@ -173,6 +170,21 @@ func parseOpenCodeDBSession( ) } +func loadOpenCodeProjectWorktree(db *sql.DB, projectID string) (string, error) { + if projectID == "" { + return "", nil + } + var worktree string + err := db.QueryRow("SELECT worktree FROM project WHERE id = ? LIMIT 1", projectID).Scan(&worktree) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil && strings.Contains(err.Error(), "no such table") { + return "", nil + } + return worktree, err +} + // resolveOpenCodeWorktree picks the session working directory used for // cwd/project. OpenCode's synthetic "global" project stores worktree="/", // while session.directory still holds the real path the session ran in. diff --git a/internal/parser/opencode_change_feed.go b/internal/parser/opencode_change_feed.go new file mode 100644 index 000000000..d137d047d --- /dev/null +++ b/internal/parser/opencode_change_feed.go @@ -0,0 +1,445 @@ +package parser + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "slices" + "time" +) + +// OpenCodeCoverage limits one journal drain. The limits are deliberately +// fixed so a degraded pass cannot become archive-scale work. +const ( + OpenCodeCoverageMaxRows = 256 + OpenCodeCoverageMaxPayloadBytes = 1 << 20 + OpenCodeCoverageMaxIDs = 256 + OpenCodeCoverageMaxDuration = 2 * time.Second +) + +type OpenCodeCoverageState struct { + Generation uint64 + LastRowID int64 + HighWaterRowID int64 + LastEventID string + HighWaterEventID string + ContainerState SQLiteContainerState + ContainerKnown bool + HighWaterKnown bool + Initialized bool + AuditLatched bool + PendingIDs []string + ReadyIDs []string + RemovedIDs []string +} + +type OpenCodeCoverageBatch struct { + SessionIDs []string + RemovedIDs []string + More bool + AuditRequired bool + Rows int + PayloadBytes int + Next OpenCodeCoverageState +} + +type openCodeCoverageJournalSupport uint8 + +type openCodeCoverageExistence uint8 + +const ( + openCodeCoverageJournalUnknown openCodeCoverageJournalSupport = iota + openCodeCoverageJournalSupported + openCodeCoverageJournalIncompatible +) + +const ( + openCodeCoverageExistenceUnchanged openCodeCoverageExistence = iota + openCodeCoverageExistencePresent + openCodeCoverageExistenceRemoved +) + +func probeOpenCodeCoverageJournal( + ctx context.Context, dbPath string, +) openCodeCoverageJournalSupport { + db, err := openOpenCodeDB(dbPath) + if err != nil { + return openCodeCoverageJournalUnknown + } + defer db.Close() + rows, err := db.QueryContext(ctx, "PRAGMA table_info(event)") + if err != nil { + return openCodeCoverageJournalUnknown + } + defer rows.Close() + required := map[string]bool{ + "id": false, "aggregate_id": false, "seq": false, + "type": false, "data": false, + } + for rows.Next() { + var cid, notNull, primaryKey int + var name, columnType string + var defaultValue any + if err := rows.Scan( + &cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey, + ); err != nil { + return openCodeCoverageJournalUnknown + } + if _, ok := required[name]; ok { + required[name] = true + } + } + if rows.Err() != nil { + return openCodeCoverageJournalUnknown + } + for _, present := range required { + if !present { + return openCodeCoverageJournalIncompatible + } + } + probe, err := db.QueryContext(ctx, "SELECT rowid FROM event LIMIT 0") + if err != nil { + return openCodeCoverageJournalIncompatible + } + if probe.Close() != nil { + return openCodeCoverageJournalUnknown + } + return openCodeCoverageJournalSupported +} + +// ReadOpenCodeCoverage reads the append-only OpenCode event journal by keyset +// pages. It validates the schema before materializing payload data. +func ReadOpenCodeCoverage(ctx context.Context, dbPath string, state OpenCodeCoverageState) (OpenCodeCoverageBatch, error) { + if state.AuditLatched { + return OpenCodeCoverageBatch{Next: state}, nil + } + ctx, cancel := context.WithTimeout(ctx, OpenCodeCoverageMaxDuration) + defer cancel() + containerState, containerKnown := StatSQLiteContainerState(dbPath) + db, err := openOpenCodeDB(dbPath) + if err != nil { + return OpenCodeCoverageBatch{}, err + } + defer db.Close() + var high int64 + if err := db.QueryRowContext(ctx, "SELECT COALESCE(MAX(rowid),0) FROM event").Scan(&high); err != nil { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + if !state.Initialized { + state.Initialized = true + state.LastRowID = high + state.ContainerState = containerState + state.ContainerKnown = containerKnown + if high > 0 { + if err := db.QueryRowContext( + ctx, "SELECT id FROM event WHERE rowid = ?", high, + ).Scan(&state.LastEventID); err != nil { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + } + return OpenCodeCoverageBatch{Next: state}, nil + } + if !containerKnown || !state.ContainerKnown || sqliteContainerReplaced(state.ContainerState, containerState) { + if containerKnown { + state.ContainerState = containerState + state.ContainerKnown = true + } + state.AuditLatched = true + captureOpenCodeCoverageHighWater(ctx, db, high, &state) + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + continuing := state.HighWaterKnown + if !state.HighWaterKnown { + if !captureOpenCodeCoverageHighWater(ctx, db, high, &state) { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + } + if continuing && state.HighWaterRowID > 0 { + var highWaterAnchor string + if err := db.QueryRowContext( + ctx, "SELECT id FROM event WHERE rowid = ?", state.HighWaterRowID, + ).Scan(&highWaterAnchor); err != nil || highWaterAnchor != state.HighWaterEventID { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + } + if state.LastRowID > 0 { + var anchor string + if err := db.QueryRowContext( + ctx, "SELECT id FROM event WHERE rowid = ?", state.LastRowID, + ).Scan(&anchor); err != nil || anchor != state.LastEventID { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + } + if high < state.LastRowID { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + rows, err := db.QueryContext(ctx, `SELECT rowid, id, aggregate_id, seq, type, length(data) FROM event WHERE rowid > ? AND rowid <= ? ORDER BY rowid LIMIT ?`, state.LastRowID, state.HighWaterRowID, OpenCodeCoverageMaxRows+1) + if err != nil { + state.AuditLatched = true + return OpenCodeCoverageBatch{AuditRequired: true, Next: state}, nil + } + defer rows.Close() + batch := OpenCodeCoverageBatch{Next: state} + tracked := make(map[string]struct{}) + for _, ids := range [][]string{ + batch.Next.PendingIDs, batch.Next.ReadyIDs, batch.Next.RemovedIDs, + } { + for _, id := range ids { + tracked[id] = struct{}{} + } + } + for rows.Next() { + if batch.Rows >= OpenCodeCoverageMaxRows { + batch.More = true + break + } + var rowID int64 + var id string + var seq int64 + var aggregate, typ string + var payloadBytes int + if err := rows.Scan(&rowID, &id, &aggregate, &seq, &typ, &payloadBytes); err != nil { + return OpenCodeCoverageBatch{}, err + } + if payloadBytes < 0 || payloadBytes > OpenCodeCoverageMaxPayloadBytes || + batch.PayloadBytes+payloadBytes > OpenCodeCoverageMaxPayloadBytes { + batch.AuditRequired = true + batch.Next.AuditLatched = true + break + } + var data []byte + if err := db.QueryRowContext(ctx, "SELECT data FROM event WHERE rowid = ?", rowID).Scan(&data); err != nil { + return OpenCodeCoverageBatch{}, err + } + batch.Rows++ + batch.PayloadBytes += payloadBytes + batch.Next.LastRowID = rowID + batch.Next.LastEventID = id + batch.Next.ContainerState = containerState + batch.Next.ContainerKnown = true + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + batch.AuditRequired = true + batch.Next.AuditLatched = true + break + } + sid := aggregate + if sid == "" { + continue + } + if _, exists := tracked[sid]; !exists && len(tracked) >= OpenCodeCoverageMaxIDs { + batch.AuditRequired = true + batch.Next.AuditLatched = true + break + } + tracked[sid] = struct{}{} + settled, existence, known := classifyOpenCodeCoverageEvent( + typ, aggregate, payload, + ) + if !known { + batch.AuditRequired = true + batch.Next.AuditLatched = true + break + } + switch existence { + case openCodeCoverageExistenceRemoved: + batch.Next.PendingIDs = removeCoverageID(batch.Next.PendingIDs, sid) + batch.Next.ReadyIDs = removeCoverageID(batch.Next.ReadyIDs, sid) + batch.Next.RemovedIDs = appendCoverageID(batch.Next.RemovedIDs, sid) + continue + case openCodeCoverageExistencePresent: + batch.Next.RemovedIDs = removeCoverageID(batch.Next.RemovedIDs, sid) + case openCodeCoverageExistenceUnchanged: + if slices.Contains(batch.Next.RemovedIDs, sid) { + continue + } + } + if settled { + batch.Next.PendingIDs = removeCoverageID(batch.Next.PendingIDs, sid) + batch.Next.ReadyIDs = appendCoverageID(batch.Next.ReadyIDs, sid) + } else { + batch.Next.ReadyIDs = removeCoverageID(batch.Next.ReadyIDs, sid) + batch.Next.PendingIDs = appendCoverageID(batch.Next.PendingIDs, sid) + } + } + if err := openCodeCoverageIterationError(rows.Err(), ctx.Err()); err != nil { + return batch, err + } + if !batch.More && !batch.AuditRequired { + batch.SessionIDs = append(batch.SessionIDs, batch.Next.ReadyIDs...) + batch.SessionIDs = append(batch.SessionIDs, batch.Next.PendingIDs...) + batch.RemovedIDs = append(batch.RemovedIDs, batch.Next.RemovedIDs...) + batch.Next.HighWaterRowID = 0 + batch.Next.HighWaterEventID = "" + batch.Next.HighWaterKnown = false + batch.Next.PendingIDs = nil + batch.Next.ReadyIDs = nil + batch.Next.RemovedIDs = nil + } + return batch, nil +} + +func openCodeCoverageIterationError(rowsErr, ctxErr error) error { + if rowsErr != nil { + return rowsErr + } + return ctxErr +} + +func captureOpenCodeCoverageHighWater( + ctx context.Context, db *sql.DB, high int64, state *OpenCodeCoverageState, +) bool { + state.HighWaterRowID = high + state.HighWaterEventID = "" + state.HighWaterKnown = true + if high == 0 { + return true + } + if err := db.QueryRowContext( + ctx, "SELECT id FROM event WHERE rowid = ?", high, + ).Scan(&state.HighWaterEventID); err != nil { + state.HighWaterKnown = false + return false + } + return true +} + +func sqliteContainerReplaced(before, after SQLiteContainerState) bool { + return before.DBInode != 0 && after.DBInode != 0 && + (before.DBInode != after.DBInode || before.DBDevice != after.DBDevice) +} + +func classifyOpenCodeCoverageEvent( + typ, aggregate string, payload map[string]any, +) (settled bool, existence openCodeCoverageExistence, known bool) { + sessionID, ok := payloadString(payload, "sessionID") + if !ok || sessionID != aggregate { + return false, openCodeCoverageExistenceUnchanged, false + } + switch typ { + case "session.created.1", "session.updated.1": + info, ok := payloadMap(payload, "info") + if !ok || !payloadStringEquals(info, "id", sessionID) { + return false, openCodeCoverageExistenceUnchanged, false + } + if _, ok := payloadString(info, "projectID"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + return true, openCodeCoverageExistencePresent, true + case "session.deleted.1": + info, ok := payloadMap(payload, "info") + if !ok || !payloadStringEquals(info, "id", sessionID) { + return false, openCodeCoverageExistenceUnchanged, false + } + return true, openCodeCoverageExistenceRemoved, true + case "message.removed.1": + if _, ok := payloadString(payload, "messageID"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + return true, openCodeCoverageExistenceUnchanged, true + case "message.part.updated.1": + part, ok := payloadMap(payload, "part") + if !ok || !payloadStringEquals(part, "sessionID", sessionID) { + return false, openCodeCoverageExistenceUnchanged, false + } + if _, ok := payloadString(part, "id"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + if _, ok := payloadString(part, "messageID"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + if _, ok := payloadNumber(payload, "time"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + return false, openCodeCoverageExistenceUnchanged, true + case "message.part.removed.1": + if _, ok := payloadString(payload, "messageID"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + if _, ok := payloadString(payload, "partID"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + return true, openCodeCoverageExistenceUnchanged, true + case "message.updated.1": + info, ok := payloadMap(payload, "info") + if !ok || !payloadStringEquals(info, "sessionID", sessionID) { + return false, openCodeCoverageExistenceUnchanged, false + } + if _, ok := payloadString(info, "id"); !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + role, ok := payloadString(info, "role") + if !ok { + return false, openCodeCoverageExistenceUnchanged, false + } + if role == "user" { + return true, openCodeCoverageExistenceUnchanged, true + } + if role == "assistant" { + timing, _ := info["time"].(map[string]any) + _, completed := payloadNumber(timing, "completed") + failure, failed := info["error"] + failed = failed && failure != nil + return completed || failed, openCodeCoverageExistenceUnchanged, true + } + return false, openCodeCoverageExistenceUnchanged, true + } + return false, openCodeCoverageExistenceUnchanged, false +} + +func payloadString(payload map[string]any, key string) (string, bool) { + value, ok := payload[key].(string) + return value, ok && value != "" +} + +func payloadStringEquals(payload map[string]any, key, want string) bool { + value, ok := payloadString(payload, key) + return ok && value == want +} + +func payloadMap(payload map[string]any, key string) (map[string]any, bool) { + value, ok := payload[key].(map[string]any) + return value, ok +} + +func payloadNumber(payload map[string]any, key string) (float64, bool) { + value, ok := payload[key].(float64) + return value, ok +} + +func appendCoverageID(ids []string, id string) []string { + for _, existing := range ids { + if existing == id { + return ids + } + } + return append(ids, id) +} + +func removeCoverageID(ids []string, id string) []string { + for i, existing := range ids { + if existing == id { + return append(ids[:i], ids[i+1:]...) + } + } + return ids +} + +func (b OpenCodeCoverageBatch) Validate() error { + if b.Rows < 0 || b.Rows > OpenCodeCoverageMaxRows { + return fmt.Errorf("coverage rows exceed %d", OpenCodeCoverageMaxRows) + } + if b.PayloadBytes < 0 || b.PayloadBytes > OpenCodeCoverageMaxPayloadBytes { + return fmt.Errorf("coverage bytes exceed %d", OpenCodeCoverageMaxPayloadBytes) + } + return nil +} + +var _ = sql.ErrNoRows diff --git a/internal/parser/opencode_change_feed_test.go b/internal/parser/opencode_change_feed_test.go new file mode 100644 index 000000000..60cf9fc74 --- /dev/null +++ b/internal/parser/opencode_change_feed_test.go @@ -0,0 +1,424 @@ +package parser + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenCodeCoverageIterationErrorPreservesTimeout(t *testing.T) { + assert.ErrorIs(t, + openCodeCoverageIterationError(nil, context.DeadlineExceeded), + context.DeadlineExceeded, + ) + rowsErr := errors.New("rows failed") + assert.ErrorIs(t, + openCodeCoverageIterationError(rowsErr, context.DeadlineExceeded), + rowsErr, + ) +} + +func TestOpenCodeChangeFeedUsesFixedHighWaterAndRowBound(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + insertOpenCodeEvents(t, writer, 1, 3) + + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + assert.Empty(t, baseline.SessionIDs) + assert.Equal(t, int64(3), baseline.Next.LastRowID) + + insertOpenCodeEvents(t, writer, 4, OpenCodeCoverageMaxRows+47) + first, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + require.NoError(t, first.Validate()) + assert.Equal(t, OpenCodeCoverageMaxRows, first.Rows) + assert.True(t, first.More) + fixedHighWater := first.Next.HighWaterRowID + + insertOpenCodeEvents(t, writer, OpenCodeCoverageMaxRows+48, OpenCodeCoverageMaxRows+48) + second, err := ReadOpenCodeCoverage(t.Context(), dbPath, first.Next) + require.NoError(t, err) + assert.False(t, second.More) + assert.Zero(t, second.Next.HighWaterRowID) + assert.Equal(t, fixedHighWater, second.Next.LastRowID) + + third, err := ReadOpenCodeCoverage(t.Context(), dbPath, second.Next) + require.NoError(t, err) + assert.Equal(t, 1, third.Rows) + assert.Equal(t, int64(OpenCodeCoverageMaxRows+48), third.Next.LastRowID) +} + +func TestOpenCodeChangeFeedContinuationValidatesHighWaterAnchor(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + insertOpenCodeEvents(t, writer, 1, OpenCodeCoverageMaxRows+1) + first, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + require.True(t, first.More) + _, err = writer.Exec( + "UPDATE event SET id = ? WHERE rowid = ?", + "evt_replaced_tail", first.Next.HighWaterRowID, + ) + require.NoError(t, err) + + continuation, err := ReadOpenCodeCoverage(t.Context(), dbPath, first.Next) + require.NoError(t, err) + assert.True(t, continuation.AuditRequired) + assert.True(t, continuation.Next.AuditLatched) +} + +func TestOpenCodeChangeFeedRejectsOversizedPayloadBeforeAdvancing(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_big', 1, 'session.updated.1', ?)", + eventID(1), + strings.Repeat("x", OpenCodeCoverageMaxPayloadBytes+1), + ) + require.NoError(t, err) + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.True(t, batch.AuditRequired) + assert.True(t, batch.Next.AuditLatched) + assert.Zero(t, batch.Rows) + assert.Zero(t, batch.Next.LastRowID) +} + +func TestOpenCodeChangeFeedLatchesUnknownDurableEvent(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_unknown', 1, 'future.event.1', '{}')", + eventID(1), + ) + require.NoError(t, err) + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.True(t, batch.AuditRequired) + assert.True(t, batch.Next.AuditLatched) + + repeated, err := ReadOpenCodeCoverage(t.Context(), dbPath, batch.Next) + require.NoError(t, err) + assert.False(t, repeated.AuditRequired) + assert.Zero(t, repeated.Rows) +} + +func TestOpenCodeChangeFeedAuditsMalformedAcceptedPayloads(t *testing.T) { + for _, eventType := range []string{ + "session.created.1", + "session.updated.1", + "session.deleted.1", + "message.updated.1", + "message.removed.1", + "message.part.updated.1", + "message.part.removed.1", + } { + t.Run(eventType, func(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage( + t.Context(), dbPath, OpenCodeCoverageState{}, + ) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_bad', 'ses_bad', 1, ?, '{}')", + eventType, + ) + require.NoError(t, err) + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.True(t, batch.AuditRequired) + assert.True(t, batch.Next.AuditLatched) + }) + } +} + +func TestOpenCodeChangeFeedDefersSettlementUntilFixedHighWaterDrains(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + insertOpenCodeEvents(t, writer, 1, OpenCodeCoverageMaxRows) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_stream', ?, 'message.part.updated.1', ?)", + eventID(OpenCodeCoverageMaxRows+1), OpenCodeCoverageMaxRows+1, + partUpdatedPayload("ses_stream"), + ) + require.NoError(t, err) + + first, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.True(t, first.More) + assert.Empty(t, first.SessionIDs) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_stream', ?, 'session.updated.1', ?)", + eventID(OpenCodeCoverageMaxRows+2), OpenCodeCoverageMaxRows+2, + sessionUpdatedPayload("ses_stream"), + ) + require.NoError(t, err) + + second, err := ReadOpenCodeCoverage(t.Context(), dbPath, first.Next) + require.NoError(t, err) + assert.Equal(t, []string{"ses_stream"}, second.SessionIDs) + + third, err := ReadOpenCodeCoverage(t.Context(), dbPath, second.Next) + require.NoError(t, err) + assert.Equal(t, []string{"ses_stream"}, third.SessionIDs) +} + +func TestOpenCodeChangeFeedEmitsInterruptedStreamAtHighWater(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_pending', 'ses_pending', 1, 'message.part.updated.1', ?)", + partUpdatedPayload("ses_pending"), + ) + require.NoError(t, err) + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.Equal(t, []string{"ses_pending"}, batch.SessionIDs) + assert.Empty(t, batch.Next.PendingIDs) + assert.Equal(t, int64(1), batch.Next.LastRowID) +} + +func TestOpenCodeChangeFeedTracksDeletionExistenceAcrossLaterEvents(t *testing.T) { + t.Run("message event preserves deletion", func(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage( + t.Context(), dbPath, OpenCodeCoverageState{}, + ) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_delete', 'ses_mixed', 1, 'session.deleted.1', ?)", + `{"sessionID":"ses_mixed","info":{"id":"ses_mixed"}}`, + ) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_part', 'ses_mixed', 2, 'message.part.updated.1', ?)", + partUpdatedPayload("ses_mixed"), + ) + require.NoError(t, err) + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.Equal(t, []string{"ses_mixed"}, batch.RemovedIDs) + assert.Empty(t, batch.SessionIDs) + }) + + t.Run("session event revives deletion", func(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage( + t.Context(), dbPath, OpenCodeCoverageState{}, + ) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_delete', 'ses_mixed', 1, 'session.deleted.1', ?)", + `{"sessionID":"ses_mixed","info":{"id":"ses_mixed"}}`, + ) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_update', 'ses_mixed', 2, 'session.updated.1', ?)", + sessionUpdatedPayload("ses_mixed"), + ) + require.NoError(t, err) + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.Empty(t, batch.RemovedIDs) + assert.Equal(t, []string{"ses_mixed"}, batch.SessionIDs) + }) +} + +func TestOpenCodeChangeFeedArchiveCardinalityIndependence(t *testing.T) { + for _, count := range []int{10, 100000} { + t.Run(fmt.Sprintf("events=%d", count), func(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + insertOpenCodeEventsTx(t, writer, 1, count) + baseline, err := ReadOpenCodeCoverage( + t.Context(), dbPath, OpenCodeCoverageState{}, + ) + require.NoError(t, err) + assert.Zero(t, baseline.Rows) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_changed', ?, 'session.updated.1', ?)", + eventID(count+1), count+1, sessionUpdatedPayload("ses_changed"), + ) + require.NoError(t, err) + + changed, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.Equal(t, 1, changed.Rows) + assert.Equal(t, []string{"ses_changed"}, changed.SessionIDs) + }) + } +} + +func TestOpenCodeChangeFeedUsesInsertionOrderForNonMonotonicTextIDs(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + for _, event := range []struct { + id, session string + }{ + {id: "evt_zzzz", session: "ses_first"}, + {id: "evt_aaaa", session: "ses_second"}, + } { + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, ?, 1, 'session.updated.1', ?)", + event.id, event.session, sessionUpdatedPayload(event.session), + ) + require.NoError(t, err) + } + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.Equal(t, []string{"ses_first", "ses_second"}, batch.SessionIDs) + assert.Equal(t, int64(2), batch.Next.LastRowID) +} + +func TestOpenCodeChangeFeedAuditsReplacedContainerWithPreservedEndpoint(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + insertOpenCodeEvents(t, writer, 1, 1) + baseline, err := ReadOpenCodeCoverage(t.Context(), dbPath, OpenCodeCoverageState{}) + require.NoError(t, err) + require.NoError(t, writer.Close()) + replaceOpenCodeEventDBPreservingEndpoint(t, dbPath, "ses_replaced") + + batch, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + assert.True(t, batch.AuditRequired) + assert.True(t, batch.Next.AuditLatched) + assert.Equal(t, baseline.Next.LastRowID, batch.Next.HighWaterRowID) + assert.Equal(t, baseline.Next.LastEventID, batch.Next.HighWaterEventID) +} + +func TestOpenCodeChangeFeedDoesNotAuditRoutineWALCheckpoint(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + _, err := writer.Exec("PRAGMA journal_mode=WAL") + require.NoError(t, err) + baseline, err := ReadOpenCodeCoverage( + t.Context(), dbPath, OpenCodeCoverageState{}, + ) + require.NoError(t, err) + insertOpenCodeEvents(t, writer, 1, 1) + + changed, err := ReadOpenCodeCoverage(t.Context(), dbPath, baseline.Next) + require.NoError(t, err) + require.False(t, changed.AuditRequired) + require.Equal(t, []string{"ses_stream"}, changed.SessionIDs) + _, err = writer.Exec("PRAGMA wal_checkpoint(TRUNCATE)") + require.NoError(t, err) + + afterCheckpoint, err := ReadOpenCodeCoverage( + t.Context(), dbPath, changed.Next, + ) + require.NoError(t, err) + assert.False(t, afterCheckpoint.AuditRequired) + assert.Zero(t, afterCheckpoint.Rows) +} + +func replaceOpenCodeEventDBPreservingEndpoint( + t *testing.T, dbPath, sessionID string, +) { + t.Helper() + replacement := dbPath + ".replacement" + replacementDB, err := sql.Open("sqlite3", replacement) + require.NoError(t, err) + _, err = replacementDB.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + _, err = replacementDB.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, ?, 1, 'session.updated.1', ?)", + eventID(1), sessionID, sessionUpdatedPayload(sessionID), + ) + require.NoError(t, err) + _, err = replacementDB.Exec( + "CREATE TABLE replacement_padding(value BLOB); INSERT INTO replacement_padding VALUES(zeroblob(8192))", + ) + require.NoError(t, err) + require.NoError(t, replacementDB.Close()) + require.NoError(t, os.Remove(dbPath)) + require.NoError(t, os.Rename(replacement, dbPath)) +} + +func newOpenCodeEventDB(t *testing.T) (string, *sql.DB) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "opencode.db") + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + _, err = db.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + return dbPath, db +} + +func insertOpenCodeEvents(t *testing.T, db *sql.DB, first, last int) { + t.Helper() + for id := first; id <= last; id++ { + _, err := db.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, ?, ?, 'session.updated.1', ?)", + eventID(id), "ses_stream", id, sessionUpdatedPayload("ses_stream"), + ) + require.NoError(t, err) + } +} + +func insertOpenCodeEventsTx(t *testing.T, db *sql.DB, first, last int) { + t.Helper() + tx, err := db.Begin() + require.NoError(t, err) + stmt, err := tx.Prepare( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_history', ?, 'session.updated.1', ?)", + ) + require.NoError(t, err) + for id := first; id <= last; id++ { + _, err = stmt.Exec(eventID(id), id, sessionUpdatedPayload("ses_history")) + require.NoError(t, err) + } + require.NoError(t, stmt.Close()) + require.NoError(t, tx.Commit()) +} + +func eventID(id int) string { + return fmt.Sprintf("evt_%08d", id) +} + +func sessionUpdatedPayload(sessionID string) string { + return fmt.Sprintf( + `{"sessionID":%q,"info":{"id":%q,"projectID":"global"}}`, + sessionID, sessionID, + ) +} + +func partUpdatedPayload(sessionID string) string { + return fmt.Sprintf( + `{"sessionID":%q,"part":{"id":"prt_1","messageID":"msg_1","sessionID":%q},"time":1}`, + sessionID, sessionID, + ) +} diff --git a/internal/parser/opencode_provider.go b/internal/parser/opencode_provider.go index 7b0fdbd1e..38ffde957 100644 --- a/internal/parser/opencode_provider.go +++ b/internal/parser/opencode_provider.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" ) var _ Provider = (*openCodeFormatProvider)(nil) @@ -21,6 +22,13 @@ var _ Provider = (*openCodeFormatProvider)(nil) // make the watcher trigger itself. const sqliteWALHeaderSize = int64(32) +const openCodeCoverageMaxStorageProbes = 4096 + +var ( + errOpenCodeCoverageStorageBound = errors.New("OpenCode coverage storage index bound reached") + errOpenCodeCoverageStorageDone = errors.New("OpenCode coverage storage index complete") +) + type openCodeFormatProviderFactory struct { def AgentDef spec openCodeProviderSpec @@ -59,7 +67,7 @@ func (f openCodeFormatProviderFactory) Definition() AgentDef { } func (f openCodeFormatProviderFactory) Capabilities() Capabilities { - return openCodeFormatProviderCapabilities() + return openCodeFormatProviderCapabilities(f.spec.agent) } func (f openCodeFormatProviderFactory) NewProvider(cfg ProviderConfig) Provider { @@ -67,7 +75,7 @@ func (f openCodeFormatProviderFactory) NewProvider(cfg ProviderConfig) Provider return &openCodeFormatProvider{ ProviderBase: ProviderBase{ Def: cloneAgentDef(f.def), - Caps: openCodeFormatProviderCapabilities(), + Caps: openCodeFormatProviderCapabilities(f.spec.agent), Config: cfg, }, sources: newOpenCodeFormatSourceSet(cfg.Roots, f.spec), @@ -76,7 +84,182 @@ func (f openCodeFormatProviderFactory) NewProvider(cfg ProviderConfig) Provider type openCodeFormatProvider struct { ProviderBase - sources openCodeFormatSourceSet + sources openCodeFormatSourceSet + coverageMu sync.Mutex + coverage map[string]OpenCodeCoverageState + coverageSchemaMu sync.Mutex + coverageSchemaKnown map[string]openCodeCoverageSchemaState +} + +type openCodeCoverageSchemaState struct { + support openCodeCoverageJournalSupport + container SQLiteContainerState + known bool +} + +var probeOpenCodeCoverageJournalFn = probeOpenCodeCoverageJournal + +func (p *openCodeFormatProvider) PollCoverage(ctx context.Context, task CoverageTask) (CoverageResult, error) { + if p.Def.Type != AgentOpenCode { + return CoverageResult{}, UnsupportedProviderFeatureError{Provider: p.Def.Type, Feature: ProviderFeatureCoverageFeed} + } + ctx, cancel := context.WithTimeout(ctx, OpenCodeCoverageMaxDuration) + defer cancel() + root := task.Root + if root == "" && len(p.Config.Roots) > 0 { + root = p.Config.Roots[0] + } + root = filepath.Clean(root) + dbPath := filepath.Join(root, p.sources.spec.dbName) + key := task.CoverageKey + expectedKey := "opencode-sqlite:" + root + if key != expectedKey { + return CoverageResult{}, UnsupportedProviderFeatureError{ + Provider: p.Def.Type, Feature: ProviderFeatureCoverageFeed, + } + } + p.coverageMu.Lock() + state := cloneOpenCodeCoverageState(p.coverage[key]) + p.coverageMu.Unlock() + if task.Trigger == CoverageTriggerNative && len(task.ChangedPaths) > 0 { + walOnly := true + for _, changed := range task.ChangedPaths { + name := filepath.Base(changed) + if name == p.sources.spec.dbName+"-wal" { + if sqliteWALHasFrames(changed) { + walOnly = false + break + } + continue + } + if name == p.sources.spec.dbName+"-shm" { + continue + } + walOnly = false + break + } + if walOnly { + state.Generation++ + return CoverageResult{Checkpoint: state}, nil + } + } + if _, err := os.Stat(dbPath); err != nil { + if errors.Is(err, os.ErrNotExist) && + (state.Initialized || task.Trigger == CoverageTriggerNative) { + return CoverageResult{}, fmt.Errorf( + "%w: %s", ErrProviderCoverageSourceMissing, dbPath, + ) + } + return CoverageResult{}, fmt.Errorf( + "%w: OpenCode event journal", ErrProviderCoverageUnavailable, + ) + } + support := p.coverageJournalSupport(ctx, dbPath) + if support == openCodeCoverageJournalIncompatible { + return CoverageResult{}, UnsupportedProviderFeatureError{ + Provider: p.Def.Type, Feature: ProviderFeatureCoverageFeed, + } + } + if support == openCodeCoverageJournalUnknown { + return CoverageResult{}, fmt.Errorf( + "%w: OpenCode event journal", ErrProviderCoverageUnavailable, + ) + } + wasInitialized := state.Initialized + batch, err := ReadOpenCodeCoverage(ctx, dbPath, state) + if err != nil { + return CoverageResult{}, err + } + batch.Next.Generation = state.Generation + 1 + result := CoverageResult{ + More: batch.More, + AuditRequired: (!wasInitialized && task.Trigger != CoverageTriggerBaseline) || + batch.AuditRequired, + Checkpoint: batch.Next, + } + coverageIDs := append([]string(nil), batch.SessionIDs...) + coverageIDs = append(coverageIDs, batch.RemovedIDs...) + expectedSource := p.sources.spec.resolve(root) + storageIndex, complete, err := p.sources.coverageStorageIndexWithMode( + ctx, root, coverageIDs, !task.AuthoritativeFallback && (len(coverageIDs) > 0 || batch.More), expectedSource, + ) + if err != nil { + return CoverageResult{}, err + } + if !complete { + result.AuditRequired = true + } + for _, id := range batch.RemovedIDs { + if source, shadowed := storageIndex[id]; shadowed { + result.Sources = append(result.Sources, source) + continue + } + result.Removed = append(result.Removed, CoverageRemoval{ + SessionID: id, + Source: p.sources.newSourceRef(root, dbPath+"#"+id, ""), + }) + } + for _, id := range batch.SessionIDs { + if result.AuditRequired { + break + } + if src, ok := p.sources.coverageVirtualSource( + root, dbPath+"#"+id, storageIndex, + ); ok { + result.Sources = append(result.Sources, src) + } + } + return result, nil +} + +func (p *openCodeFormatProvider) CommitCoverage( + ctx context.Context, task CoverageTask, result CoverageResult, audited bool, +) error { + root := task.Root + if root == "" && len(p.Config.Roots) > 0 { + root = p.Config.Roots[0] + } + key := task.CoverageKey + if key == "" { + key = "opencode-sqlite:" + root + } + next, ok := result.Checkpoint.(OpenCodeCoverageState) + if !ok { + return errors.New("opencode coverage checkpoint is missing") + } + if audited { + if next.HighWaterKnown { + next.LastRowID = next.HighWaterRowID + next.LastEventID = next.HighWaterEventID + } + next.HighWaterRowID = 0 + next.HighWaterEventID = "" + next.HighWaterKnown = false + next.AuditLatched = false + next.PendingIDs = nil + next.ReadyIDs = nil + next.RemovedIDs = nil + } + p.coverageMu.Lock() + defer p.coverageMu.Unlock() + current := p.coverage[key] + if audited { + next.Generation = current.Generation + 1 + } else if next.Generation != current.Generation+1 { + return fmt.Errorf("opencode coverage checkpoint changed concurrently") + } + if p.coverage == nil { + p.coverage = make(map[string]OpenCodeCoverageState) + } + p.coverage[key] = cloneOpenCodeCoverageState(next) + return nil +} + +func cloneOpenCodeCoverageState(state OpenCodeCoverageState) OpenCodeCoverageState { + state.PendingIDs = append([]string(nil), state.PendingIDs...) + state.ReadyIDs = append([]string(nil), state.ReadyIDs...) + state.RemovedIDs = append([]string(nil), state.RemovedIDs...) + return state } func (p *openCodeFormatProvider) Discover(ctx context.Context) ([]SourceRef, error) { @@ -91,6 +274,37 @@ func (p *openCodeFormatProvider) WatchPlan(ctx context.Context) (WatchPlan, erro return p.sources.WatchPlan(ctx) } +func (p *openCodeFormatProvider) coverageJournalSupport( + ctx context.Context, dbPath string, +) openCodeCoverageJournalSupport { + container, known := StatSQLiteContainerState(dbPath) + if !known { + return openCodeCoverageJournalUnknown + } + p.coverageSchemaMu.Lock() + if p.coverageSchemaKnown != nil { + if cached, ok := p.coverageSchemaKnown[dbPath]; ok && + cached.known == known && (!known || cached.container == container) { + p.coverageSchemaMu.Unlock() + return cached.support + } + } + p.coverageSchemaMu.Unlock() + support := probeOpenCodeCoverageJournalFn(ctx, dbPath) + if support == openCodeCoverageJournalUnknown { + return support + } + p.coverageSchemaMu.Lock() + if p.coverageSchemaKnown == nil { + p.coverageSchemaKnown = make(map[string]openCodeCoverageSchemaState) + } + p.coverageSchemaKnown[dbPath] = openCodeCoverageSchemaState{ + support: support, container: container, known: known, + } + p.coverageSchemaMu.Unlock() + return support +} + func (p *openCodeFormatProvider) SourcesForChangedPath( ctx context.Context, req ChangedPathRequest, @@ -556,8 +770,28 @@ func (s openCodeFormatSourceSet) discoverStorageEach( } func (s openCodeFormatSourceSet) WatchPlan(context.Context) (WatchPlan, error) { - roots := make([]WatchRoot, 0, len(s.roots)) + roots := make([]WatchRoot, 0, len(s.roots)*2) for _, root := range s.roots { + if s.spec.agent == AgentOpenCode { + roots = append(roots, WatchRoot{ + Path: root, Recursive: false, + IncludeGlobs: []string{ + s.spec.dbName, s.spec.dbName + "-wal", + }, + DebounceKey: string(s.spec.agent) + ":sqlite:" + root, + CoverageKey: "opencode-sqlite:" + root, + }) + storageRoot := filepath.Join(root, "storage") + storageInfo, storageErr := os.Stat(storageRoot) + hasStorage := storageErr == nil && storageInfo.IsDir() + roots = append(roots, WatchRoot{ + Path: storageRoot, Recursive: true, + NonBlockingProbe: !hasStorage, + IncludeGlobs: []string{"*.json"}, + DebounceKey: string(s.spec.agent) + ":storage:" + storageRoot, + }) + continue + } for _, watchRoot := range s.spec.watchRoots(root) { roots = append(roots, WatchRoot{ Path: watchRoot, @@ -631,38 +865,111 @@ func (s openCodeFormatSourceSet) SourceForReconciliation( return SourceRef{}, false, nil } -var errOpenCodeCanonicalSourceFound = errors.New("opencode canonical source found") - func (s openCodeFormatSourceSet) canonicalVirtualSource( - ctx context.Context, root, virtualPath string, + _ context.Context, root, virtualPath string, ) (SourceRef, bool, error) { - _, sessionID, ok := s.spec.parseVirtual(virtualPath) - if !ok { + dbPath, sessionID, ok := s.spec.parseVirtual(virtualPath) + root = filepath.Clean(root) + if !ok || !IsValidSessionID(sessionID) || + !samePath(dbPath, filepath.Join(root, s.spec.dbName)) { return SourceRef{}, false, nil } - src := s.spec.resolve(root) - if src.Mode == OpenCodeSourceStorage { - var found SourceRef - err := streamDirectoryEntries(ctx, src.SessionRoot, func(project os.DirEntry) error { - if !isDirOrSymlink(project, src.SessionRoot) { - return nil + if source, found := s.sourceForRawID(root, sessionID); found { + return source, true, nil + } + source, found := s.sourceRef(root, virtualPath, false) + return source, found, nil +} + +func (s openCodeFormatSourceSet) coverageStorageIndex(ctx context.Context, root string, sessionIDs []string) (map[string]SourceRef, bool, error) { + return s.coverageStorageIndexWithMode(ctx, root, sessionIDs, false, s.spec.resolve(root)) +} + +func (s openCodeFormatSourceSet) coverageStorageIndexWithMode( + ctx context.Context, root string, sessionIDs []string, requireStorage bool, expectedSource OpenCodeSource, +) (map[string]SourceRef, bool, error) { + src := expectedSource + if requireStorage { + if src.Mode != OpenCodeSourceStorage { + return nil, false, fmt.Errorf("%w: OpenCode storage mode changed", ErrProviderCoverageUnavailable) + } + info, err := os.Stat(src.SessionRoot) + if err != nil || !info.IsDir() { + return nil, false, fmt.Errorf("%w: OpenCode storage shadow scope %s", ErrProviderCoverageUnavailable, src.SessionRoot) + } + } + index := make(map[string]SourceRef) + wanted := make(map[string]struct{}, len(sessionIDs)) + for _, id := range sessionIDs { + if IsValidSessionID(id) { + wanted[id] = struct{}{} + } + } + if len(wanted) == 0 { + return index, true, nil + } + if src.Mode != OpenCodeSourceStorage { + return index, true, nil + } + probes := 0 + err := streamDirectoryEntries(ctx, src.SessionRoot, func(entry os.DirEntry) error { + probes++ + if probes > openCodeCoverageMaxStorageProbes { + return errOpenCodeCoverageStorageBound + } + if !isDirOrSymlink(entry, src.SessionRoot) { + return nil + } + for id := range wanted { + probes++ + if probes > openCodeCoverageMaxStorageProbes { + return errOpenCodeCoverageStorageBound } - path := filepath.Join(src.SessionRoot, project.Name(), sessionID+".json") - if source, ok := s.sourceRef(root, path, false); ok { - found = source - return errOpenCodeCanonicalSourceFound + path := filepath.Join(src.SessionRoot, entry.Name(), id+".json") + source, ok := s.sourceRef(root, path, false) + if !ok { + continue } - return nil - }) - switch { - case errors.Is(err, errOpenCodeCanonicalSourceFound): - return found, true, nil - case ctx.Err() != nil: - return SourceRef{}, false, ctx.Err() + index[id] = source + delete(wanted, id) + } + if len(wanted) == 0 { + return errOpenCodeCoverageStorageDone + } + return nil + }) + if requireStorage && err == nil { + info, statErr := os.Stat(src.SessionRoot) + if statErr != nil || !info.IsDir() { + return nil, false, fmt.Errorf("%w: OpenCode storage shadow scope %s", ErrProviderCoverageUnavailable, src.SessionRoot) } } - source, ok := s.sourceRef(root, virtualPath, false) - return source, ok, nil + switch { + case errors.Is(err, errOpenCodeCoverageStorageDone), err == nil: + return index, true, nil + case errors.Is(err, errOpenCodeCoverageStorageBound): + return index, false, nil + default: + if requireStorage { + return nil, false, fmt.Errorf("%w: indexing OpenCode storage shadow scope: %v", ErrProviderCoverageUnavailable, err) + } + return nil, false, err + } +} + +func (s openCodeFormatSourceSet) coverageVirtualSource( + root, virtualPath string, storageIndex map[string]SourceRef, +) (SourceRef, bool) { + dbPath, sessionID, ok := s.spec.parseVirtual(virtualPath) + root = filepath.Clean(root) + if !ok || !IsValidSessionID(sessionID) || + !samePath(dbPath, filepath.Join(root, s.spec.dbName)) { + return SourceRef{}, false + } + if source, shadowed := storageIndex[sessionID]; shadowed { + return source, true + } + return s.sourceRef(root, virtualPath, false) } func (s openCodeFormatSourceSet) FindSource( @@ -1060,11 +1367,26 @@ func (s openCodeFormatSourceSet) isStorageSessionPath( return false } parts := strings.Split(rel, string(filepath.Separator)) - return len(parts) == 4 && + validShape := len(parts) == 4 && parts[0] == "storage" && parts[1] == filepath.Base(src.SessionRoot) && - strings.HasSuffix(parts[3], ".json") && - (!requireExisting || IsRegularFile(path)) + strings.HasSuffix(parts[3], ".json") + if !validShape || !requireExisting { + return validShape + } + if !IsRegularFile(path) { + return false + } + resolvedRoot, err := filepath.EvalSymlinks(src.SessionRoot) + if err != nil { + return false + } + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + return false + } + _, under := relUnder(resolvedRoot, resolvedPath) + return under } func readOpenCodeProviderStorageSessionID(path string) string { @@ -1101,7 +1423,7 @@ func findOpenCodeProviderStorageSessionIDByMessageID( return "" } -func openCodeFormatProviderCapabilities() Capabilities { +func openCodeFormatProviderCapabilities(agent AgentType) Capabilities { return Capabilities{ Source: SourceCapabilities{ DiscoverSources: CapabilitySupported, @@ -1116,6 +1438,12 @@ func openCodeFormatProviderCapabilities() Capabilities { PerSessionErrors: CapabilityNotApplicable, ExcludedSessions: CapabilityNotApplicable, ForceReplaceOnParse: CapabilityNotApplicable, + BoundedCoverage: func() CapabilitySupport { + if agent == AgentOpenCode { + return CapabilitySupported + } + return CapabilityUnsupported + }(), }, Content: ContentCapabilities{ FirstMessage: CapabilitySupported, diff --git a/internal/parser/opencode_provider_test.go b/internal/parser/opencode_provider_test.go index 1adef6b05..948004b51 100644 --- a/internal/parser/opencode_provider_test.go +++ b/internal/parser/opencode_provider_test.go @@ -389,9 +389,12 @@ func TestOpenCodeProviderStorageSourceMethods(t *testing.T) { plan, err := provider.WatchPlan(context.Background()) require.NoError(t, err) - require.Len(t, plan.Roots, 1) - assert.Equal(t, filepath.Join(root, "storage"), plan.Roots[0].Path) - assert.True(t, plan.Roots[0].Recursive) + require.Len(t, plan.Roots, 2) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, "opencode-sqlite:"+root, plan.Roots[0].CoverageKey) + assert.Equal(t, filepath.Join(root, "storage"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) discovered, err := provider.Discover(context.Background()) require.NoError(t, err) @@ -494,12 +497,16 @@ func TestOpenCodeProviderSQLiteSourceMethods(t *testing.T) { plan, err := provider.WatchPlan(context.Background()) require.NoError(t, err) - require.Len(t, plan.Roots, 1) + require.Len(t, plan.Roots, 2) assert.Equal(t, root, plan.Roots[0].Path) - assert.True(t, plan.Roots[0].Recursive) + assert.False(t, plan.Roots[0].Recursive) assert.Equal(t, []string{ - "*.json", "opencode.db", "opencode.db-wal", + "opencode.db", "opencode.db-wal", }, plan.Roots[0].IncludeGlobs) + assert.Equal(t, "opencode-sqlite:"+root, plan.Roots[0].CoverageKey, + "prospective coverage re-probes compatibility during dispatch") + assert.Equal(t, filepath.Join(root, "storage"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) discovered, err := provider.Discover(context.Background()) require.NoError(t, err) @@ -575,6 +582,713 @@ func TestOpenCodeProviderSQLiteSourceMethods(t *testing.T) { assert.Empty(t, removed, "removed sqlite DBs have no stateless virtual source list") } +func TestOpenCodeProviderWatchPlanCachesJournalCompatibility(t *testing.T) { + dbPath, _ := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + for range 2 { + plan, err := provider.WatchPlan(t.Context()) + require.NoError(t, err) + require.Len(t, plan.Roots, 2) + assert.Equal(t, "opencode-sqlite:"+root, plan.Roots[0].CoverageKey) + assert.Equal(t, filepath.Join(root, "storage"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + } +} + +func TestOpenCodeProviderProspectiveCoverageActivatesAfterDatabaseCreation(t *testing.T) { + root := t.TempDir() + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerDegraded, + } + plan, err := provider.WatchPlan(t.Context()) + require.NoError(t, err) + require.Equal(t, task.CoverageKey, plan.Roots[0].CoverageKey) + missing, err := feed.PollCoverage(t.Context(), task) + require.ErrorIs(t, err, ErrProviderCoverageUnavailable) + assert.Nil(t, missing.Checkpoint) + + dbPath := filepath.Join(root, "opencode.db") + writer, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = writer.Close() }) + _, err = writer.Exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL); + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + title TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL + ); + CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + ); + INSERT INTO project(id, worktree) VALUES('prj_new', '/tmp/project'); + INSERT INTO session(id, project_id, title, time_created, time_updated) + VALUES('ses_new', 'prj_new', 'New', 1, 2); + `) + require.NoError(t, err) + task.Trigger = CoverageTriggerBaseline + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, baseline.AuditRequired) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + task.Trigger = CoverageTriggerDegraded + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_pending', 'ses_new', 1, 'message.part.updated.1', ?)", + partUpdatedPayload("ses_new"), + ) + require.NoError(t, err) + changed, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + checkpoint := changed.Checkpoint.(OpenCodeCoverageState) + assert.Equal(t, int64(1), checkpoint.LastRowID) + assert.False(t, changed.AuditRequired) +} + +func TestOpenCodeProviderDoesNotCacheTransientJournalProbe(t *testing.T) { + dbPath, _ := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerDegraded, + } + originalProbe := probeOpenCodeCoverageJournalFn + t.Cleanup(func() { probeOpenCodeCoverageJournalFn = originalProbe }) + calls := 0 + probeOpenCodeCoverageJournalFn = func( + context.Context, string, + ) openCodeCoverageJournalSupport { + calls++ + if calls == 1 { + return openCodeCoverageJournalUnknown + } + return openCodeCoverageJournalSupported + } + + unknown, err := feed.PollCoverage(t.Context(), task) + require.ErrorIs(t, err, ErrProviderCoverageUnavailable) + assert.Nil(t, unknown.Checkpoint) + task.Trigger = CoverageTriggerBaseline + supported, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, supported.AuditRequired) + assert.Equal(t, 2, calls) +} + +func TestOpenCodeProviderCoverageCheckpointSlicesAreIsolated(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + } + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + impl := provider.(*openCodeFormatProvider) + impl.coverage[task.CoverageKey] = cloneOpenCodeCoverageState( + baseline.Checkpoint.(OpenCodeCoverageState), + ) + impl.coverage[task.CoverageKey] = func() OpenCodeCoverageState { + state := impl.coverage[task.CoverageKey] + state.PendingIDs = []string{"ses_changed"} + return state + }() + _, err = writer.Exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL); + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + title TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL + ); + INSERT INTO project(id, worktree) VALUES('prj_changed', '/tmp/project'); + INSERT INTO session(id, project_id, title, time_created, time_updated) + VALUES('ses_changed', 'prj_changed', 'Changed', 1, 2); + `) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_changed', 'ses_changed', 1, 'session.updated.1', ?)", + sessionUpdatedPayload("ses_changed"), + ) + require.NoError(t, err) + task.Trigger = CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.Equal(t, []string{"ses_changed"}, impl.coverage[task.CoverageKey].PendingIDs) + require.NoError(t, feed.CommitCoverage(t.Context(), task, result, false)) + checkpoint := result.Checkpoint.(OpenCodeCoverageState) + checkpoint.ReadyIDs = append(checkpoint.ReadyIDs, "mutated") + assert.NotContains(t, impl.coverage[task.CoverageKey].ReadyIDs, "mutated") +} + +func TestOpenCodeProviderFirstNativeChangeRequestsAudit(t *testing.T) { + dbPath, _ := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + result, err := provider.(BoundedCoverageProvider).PollCoverage( + t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerNative, ChangedPaths: []string{dbPath}, + }, + ) + require.NoError(t, err) + assert.True(t, result.AuditRequired) +} + +func TestOpenCodeProviderNativeCoverageIgnoresNonDataSidecars(t *testing.T) { + for _, tc := range []struct { + name string + suffix string + size int + }{ + {name: "missing WAL", suffix: "-wal"}, + {name: "header-only WAL", suffix: "-wal", size: int(sqliteWALHeaderSize)}, + {name: "SHM", suffix: "-shm", size: 32 * 1024}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "opencode.db") + tc.suffix + if tc.size > 0 { + require.NoError(t, os.WriteFile(path, make([]byte, tc.size), 0o600)) + } + provider, ok := NewProvider( + AgentOpenCode, ProviderConfig{Roots: []string{root}}, + ) + require.True(t, ok) + + result, err := provider.(BoundedCoverageProvider).PollCoverage( + t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerNative, ChangedPaths: []string{path}, + }, + ) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + assert.Empty(t, result.Sources) + assert.Empty(t, result.Removed) + checkpoint := result.Checkpoint.(OpenCodeCoverageState) + assert.Equal(t, uint64(1), checkpoint.Generation) + }) + } +} + +func TestOpenCodeProviderNativeCoverageProcessesFramedWAL(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + var journalMode string + require.NoError(t, writer.QueryRow("PRAGMA journal_mode=WAL").Scan(&journalMode)) + require.Equal(t, "wal", journalMode) + _, err := writer.Exec("PRAGMA wal_autocheckpoint=0") + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_wal', 'ses_wal', 1, 'session.updated.1', ?)", + sessionUpdatedPayload("ses_wal"), + ) + require.NoError(t, err) + walPath := dbPath + "-wal" + walInfo, err := os.Stat(walPath) + require.NoError(t, err) + require.Greater(t, walInfo.Size(), sqliteWALHeaderSize) + provider, ok := NewProvider( + AgentOpenCode, ProviderConfig{Roots: []string{root}}, + ) + require.True(t, ok) + + result, err := provider.(BoundedCoverageProvider).PollCoverage( + t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerNative, ChangedPaths: []string{walPath}, + }, + ) + require.NoError(t, err) + assert.True(t, result.AuditRequired) +} + +func TestOpenCodeProviderUnprimedDegradedPollRequestsAudit(t *testing.T) { + dbPath, _ := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + result, err := provider.(BoundedCoverageProvider).PollCoverage( + t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerDegraded, + }, + ) + require.NoError(t, err) + assert.True(t, result.AuditRequired) +} + +func TestOpenCodeProviderRevalidatesJournalAfterContainerReplacement(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + plan, err := provider.WatchPlan(t.Context()) + require.NoError(t, err) + require.Equal(t, "opencode-sqlite:"+root, plan.Roots[0].CoverageKey) + require.NoError(t, writer.Close()) + + replacement := dbPath + ".replacement" + replacementDB, err := sql.Open("sqlite3", replacement) + require.NoError(t, err) + _, err = replacementDB.Exec( + "CREATE TABLE event(id TEXT PRIMARY KEY); CREATE TABLE padding(value BLOB); INSERT INTO padding VALUES(zeroblob(8192))", + ) + require.NoError(t, err) + require.NoError(t, replacementDB.Close()) + require.NoError(t, os.Remove(dbPath)) + require.NoError(t, os.Rename(replacement, dbPath)) + + plan, err = provider.WatchPlan(t.Context()) + require.NoError(t, err) + assert.Equal(t, "opencode-sqlite:"+root, plan.Roots[0].CoverageKey) + _, err = provider.(BoundedCoverageProvider).PollCoverage( + t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerDegraded, + }, + ) + require.ErrorIs(t, err, ErrUnsupportedProviderFeature) +} + +func TestOpenCodeProviderCoverageResolvesOneSettledSession(t *testing.T) { + dbPath, seeder, writer := newTestDB(t) + defer writer.Close() + seeder.AddProject("prj_coverage", t.TempDir()) + seeder.AddSession( + "ses_coverage", "prj_coverage", "", "Coverage", + 1700000000000, 1700000010000, + ) + _, err := writer.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + } + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.Empty(t, baseline.Sources) + assert.False(t, baseline.AuditRequired) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + task.Trigger = CoverageTriggerDegraded + task.AuthoritativeFallback = true + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_0001', 'ses_coverage', 1, 'session.updated.1', ?)", + `{"sessionID":"ses_coverage","info":{"id":"ses_coverage","projectID":"prj_coverage"}}`, + ) + require.NoError(t, err) + + result, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, result.Sources, 1) + assert.Equal(t, OpenCodeSQLiteVirtualPath(dbPath, "ses_coverage"), + result.Sources[0].DisplayPath) + require.NoError(t, feed.CommitCoverage(t.Context(), task, result, false)) + + storagePath := writeOpenCodeProviderStorageSession( + t, root, "session", "ses_coverage", "prj_coverage", "Coverage", + ) + unexpectedProjectDir := filepath.Join( + root, "storage", "session", "unexpected-project", + ) + require.NoError(t, os.MkdirAll(unexpectedProjectDir, 0o755)) + unexpectedStoragePath := filepath.Join( + unexpectedProjectDir, "ses_coverage.json", + ) + require.NoError(t, os.Rename(storagePath, unexpectedStoragePath)) + storagePath = unexpectedStoragePath + _, err = provider.Discover(t.Context()) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_0002', 'ses_coverage', 2, 'session.updated.1', ?)", + `{"sessionID":"ses_coverage","info":{"id":"ses_coverage","projectID":"prj_coverage"}}`, + ) + require.NoError(t, err) + transitioned, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, transitioned.Sources, 1) + assert.Equal(t, storagePath, transitioned.Sources[0].DisplayPath) + require.NoError(t, feed.CommitCoverage(t.Context(), task, transitioned, false)) + + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_0003', 'ses_coverage', 3, 'future.event.1', '{}')", + ) + require.NoError(t, err) + audit, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.True(t, audit.AuditRequired) + retriedAudit, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.True(t, retriedAudit.AuditRequired) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_0004', 'ses_coverage', 4, 'session.updated.1', ?)", + `{"sessionID":"ses_coverage","info":{"id":"ses_coverage","projectID":"prj_coverage"}}`, + ) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, audit, true)) + recovered, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, recovered.Sources, 1) +} + +func TestOpenCodeProviderCoverageDefersMissingStorageShadowScope(t *testing.T) { + root := t.TempDir() + _, seeder, writer := newTestDBAt( + t, filepath.Join(root, "opencode.db"), + ) + t.Cleanup(func() { require.NoError(t, writer.Close()) }) + seeder.AddProject("proj", t.TempDir()) + seeder.AddSession( + "ses_deferred", "proj", "", "Deferred", + 1700000000000, 1700000010000, + ) + _, err := writer.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + writeOpenCodeProviderStorageSession( + t, root, "session", "ses_deferred", "storage", "Deferred", + ) + provider, ok := NewProvider( + AgentOpenCode, ProviderConfig{Roots: []string{root}}, + ) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + } + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + require.NoError(t, os.RemoveAll(filepath.Join(root, "storage"))) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_deferred', 'ses_deferred', 1, 'session.updated.1', ?)", + sessionUpdatedPayload("ses_deferred"), + ) + require.NoError(t, err) + task.Trigger = CoverageTriggerDegraded + task.AuthoritativeFallback = false + + _, err = feed.PollCoverage(t.Context(), task) + require.ErrorIs(t, err, ErrProviderCoverageUnavailable) + storagePath := writeOpenCodeProviderStorageSession( + t, root, "session", "ses_deferred", "storage", "Deferred", + ) + task.AuthoritativeFallback = true + retried, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, retried.Sources, 1) + assert.Equal(t, storagePath, retried.Sources[0].DisplayPath) + assert.Empty(t, retried.Removed) +} + +func TestOpenCodeProviderCoverageContinuationRequiresStorageBeforeCheckpoint(t *testing.T) { + root := t.TempDir() + _, seeder, writer := newTestDBAt(t, filepath.Join(root, "opencode.db")) + t.Cleanup(func() { require.NoError(t, writer.Close()) }) + seeder.AddProject("proj", t.TempDir()) + seeder.AddSession("ses_cont", "proj", "", "Continuation", 1700000000000, 1700000010000) + _, err := writer.Exec(`CREATE TABLE event (id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, type TEXT NOT NULL, data TEXT NOT NULL)`) + require.NoError(t, err) + writeOpenCodeProviderStorageSession(t, root, "session", "ses_cont", "storage", "Continuation") + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{Agent: AgentOpenCode, Root: root, CoverageKey: "opencode-sqlite:" + root, Trigger: CoverageTriggerBaseline} + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + for i := 0; i < OpenCodeCoverageMaxRows+1; i++ { + _, err = writer.Exec("INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_cont', ?, 'session.updated.1', ?)", fmt.Sprintf("evt_cont_%03d", i), i+1, sessionUpdatedPayload("ses_cont")) + require.NoError(t, err) + } + require.NoError(t, os.RemoveAll(filepath.Join(root, "storage"))) + task.Trigger = CoverageTriggerDegraded + _, err = feed.PollCoverage(t.Context(), task) + require.ErrorIs(t, err, ErrProviderCoverageUnavailable) + writeOpenCodeProviderStorageSession(t, root, "session", "ses_cont", "storage", "Continuation") + retried, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.True(t, retried.More || len(retried.Sources) > 0) +} + +func TestOpenCodeProviderCoverageDefersStorageDisappearanceDuringIndex(t *testing.T) { + root := t.TempDir() + _, seeder, writer := newTestDBAt(t, filepath.Join(root, "opencode.db")) + t.Cleanup(func() { require.NoError(t, writer.Close()) }) + seeder.AddProject("proj", t.TempDir()) + seeder.AddSession("ses_race", "proj", "", "Race", 1700000000000, 1700000010000) + _, err := writer.Exec(`CREATE TABLE event (id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, type TEXT NOT NULL, data TEXT NOT NULL)`) + require.NoError(t, err) + writeOpenCodeProviderStorageSession(t, root, "session", "ses_race", "storage", "Race") + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{Agent: AgentOpenCode, Root: root, CoverageKey: "opencode-sqlite:" + root, Trigger: CoverageTriggerBaseline} + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + _, err = writer.Exec("INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_race', 'ses_race', 1, 'session.updated.1', ?)", sessionUpdatedPayload("ses_race")) + require.NoError(t, err) + task.Trigger = CoverageTriggerDegraded + storageRoot := filepath.Join(root, "storage") + sessionRoot := filepath.Join(storageRoot, "session") + ctx := withStreamingDirectoryReader(t.Context(), func(ctx context.Context, dir string, yield func(os.DirEntry) error) error { + if samePath(dir, sessionRoot) { + require.NoError(t, os.RemoveAll(storageRoot)) + } + return streamDirectoryEntriesDirect(ctx, dir, yield) + }) + + _, err = feed.PollCoverage(ctx, task) + require.ErrorIs(t, err, ErrProviderCoverageUnavailable) + storagePath := writeOpenCodeProviderStorageSession(t, root, "session", "ses_race", "storage", "Race") + retried, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, retried.Sources, 1) + assert.Equal(t, storagePath, retried.Sources[0].DisplayPath) +} + +func TestOpenCodeProviderCoverageRejectsDatabasePathComponents(t *testing.T) { + root := t.TempDir() + dbPath, seeder, writer := newTestDBAt( + t, filepath.Join(root, "opencode.db"), + ) + t.Cleanup(func() { require.NoError(t, writer.Close()) }) + seeder.AddProject("..", t.TempDir()) + seeder.AddSession( + "ses_safe", "..", "", "Coverage", + 1700000000000, 1700000010000, + ) + _, err := writer.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + escapedPath := filepath.Join(root, "storage", "ses_safe.json") + writeOpenCodeStorageFile(t, escapedPath, map[string]any{ + "id": "ses_safe", "directory": "/home/user/code/escaped", + }) + provider, ok := NewProvider( + AgentOpenCode, ProviderConfig{Roots: []string{root}}, + ) + require.True(t, ok) + _, err = provider.Discover(t.Context()) + require.NoError(t, err) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + } + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_safe', 'ses_safe', 1, 'session.updated.1', ?)", + `{"sessionID":"ses_safe","info":{"id":"ses_safe","projectID":".."}}`, + ) + require.NoError(t, err) + task.Trigger = CoverageTriggerDegraded + task.AuthoritativeFallback = true + + result, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, result.Sources, 1) + assert.Equal(t, + OpenCodeSQLiteVirtualPath(dbPath, "ses_safe"), + result.Sources[0].DisplayPath, + ) +} + +func TestOpenCodeProviderCoverageRejectsEscapedStorageSymlink(t *testing.T) { + root := t.TempDir() + dbPath, seeder, writer := newTestDBAt( + t, filepath.Join(root, "opencode.db"), + ) + t.Cleanup(func() { require.NoError(t, writer.Close()) }) + seeder.AddProject("linked", t.TempDir()) + seeder.AddSession( + "ses_safe", "linked", "", "Coverage", + 1700000000000, 1700000010000, + ) + _, err := writer.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + outside := t.TempDir() + writeOpenCodeStorageFile(t, + filepath.Join(outside, "ses_safe.json"), + map[string]any{"id": "ses_safe", "directory": "/outside"}, + ) + sessionRoot := filepath.Join(root, "storage", "session") + require.NoError(t, os.MkdirAll(sessionRoot, 0o755)) + if err := os.Symlink(outside, filepath.Join(sessionRoot, "linked")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + provider, ok := NewProvider( + AgentOpenCode, ProviderConfig{Roots: []string{root}}, + ) + require.True(t, ok) + _, err = provider.Discover(t.Context()) + require.NoError(t, err) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + } + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + _, err = writer.Exec( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES('evt_safe', 'ses_safe', 1, 'session.updated.1', ?)", + `{"sessionID":"ses_safe","info":{"id":"ses_safe","projectID":"linked"}}`, + ) + require.NoError(t, err) + task.Trigger = CoverageTriggerDegraded + + result, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, result.Sources, 1) + assert.Equal(t, + OpenCodeSQLiteVirtualPath(dbPath, "ses_safe"), + result.Sources[0].DisplayPath, + ) +} + +func TestOpenCodeCoverageStorageIndexHasFixedProbeBound(t *testing.T) { + root := t.TempDir() + sessionRoot := filepath.Join(root, "storage", "session") + require.NoError(t, os.MkdirAll(sessionRoot, 0o755)) + for i := 0; i <= openCodeCoverageMaxStorageProbes/2; i++ { + require.NoError(t, os.Mkdir( + filepath.Join(sessionRoot, fmt.Sprintf("project-%04d", i)), + 0o755, + )) + } + sources := newOpenCodeFormatSourceSet( + []string{root}, openCodeProviderSpecForAgent(AgentOpenCode), + ) + + index, complete, err := sources.coverageStorageIndex( + t.Context(), root, []string{"missing-session"}, + ) + require.NoError(t, err) + assert.False(t, complete) + assert.Empty(t, index) +} + +func TestOpenCodeProviderCoverageAcknowledgesReplacementAudit(t *testing.T) { + dbPath, writer := newOpenCodeEventDB(t) + insertOpenCodeEvents(t, writer, 1, 1) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + task := CoverageTask{ + Agent: AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + } + baseline, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.False(t, baseline.AuditRequired) + require.NoError(t, feed.CommitCoverage(t.Context(), task, baseline, false)) + task.Trigger = CoverageTriggerDegraded + require.NoError(t, writer.Close()) + replaceOpenCodeEventDBPreservingEndpoint(t, dbPath, "ses_replaced") + + replacement, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + require.True(t, replacement.AuditRequired) + require.NoError(t, feed.CommitCoverage(t.Context(), task, replacement, true)) + + idle, err := feed.PollCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, idle.AuditRequired) + assert.Empty(t, idle.Sources) +} + +func TestOpenCodeProviderCoverageRejectsStorageScope(t *testing.T) { + root := t.TempDir() + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + + _, err := feed.PollCoverage(t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root, Trigger: CoverageTriggerDegraded, + }) + require.ErrorIs(t, err, ErrUnsupportedProviderFeature) +} + +func TestOpenCodeProviderCoverageNormalizesTaskRoot(t *testing.T) { + dbPath, _ := newOpenCodeEventDB(t) + root := filepath.Dir(dbPath) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + feed := provider.(BoundedCoverageProvider) + + result, err := feed.PollCoverage(t.Context(), CoverageTask{ + Agent: AgentOpenCode, Root: root + string(filepath.Separator), + CoverageKey: "opencode-sqlite:" + root, + Trigger: CoverageTriggerBaseline, + }) + require.NoError(t, err) + assert.False(t, result.AuditRequired) +} + func TestOpenCodeProviderIgnoresNonDataSQLiteSidecars(t *testing.T) { tests := []struct { name string diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 2d8ad17f3..c19b85b1d 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -11,6 +11,7 @@ const ( ProviderFeatureParse = "parse" ProviderFeatureWatchRoots = "watch roots" ProviderFeatureActivityHints = "activity hints" + ProviderFeatureCoverageFeed = "bounded coverage feed" ) // ErrUnsupportedProviderFeature identifies optional provider behavior that is @@ -18,6 +19,11 @@ const ( // parse failures. var ErrUnsupportedProviderFeature = errors.New("unsupported provider feature") +// ErrProviderCoverageUnavailable marks a transient coverage feed failure. +var ErrProviderCoverageUnavailable = errors.New("provider coverage unavailable") + +var ErrProviderCoverageSourceMissing = errors.New("provider coverage source missing") + // UnsupportedProviderFeatureError wraps ErrUnsupportedProviderFeature with the // provider and feature names that produced it. type UnsupportedProviderFeatureError struct { @@ -95,6 +101,55 @@ type Provider interface { ) (IncrementalOutcome, IncrementalStatus, error) } +// CoverageTrigger identifies why a provider coverage unit was scheduled. +type CoverageTrigger uint8 + +const ( + CoverageTriggerUnknown CoverageTrigger = iota + CoverageTriggerBaseline + CoverageTriggerNative + CoverageTriggerDegraded + CoverageTriggerContinuation +) + +// CoverageTask is the provider-owned identity carried through watcher and +// degraded-poll handoffs. +type CoverageTask struct { + Agent AgentType + CoverageKey string + Root string + Trigger CoverageTrigger + // AuthoritativeFallback confirms every physical scope needed for a + // provider-root audit is currently available. + AuthoritativeFallback bool + ChangedPaths []string +} + +// CoverageResult is a bounded provider change batch. Sources are fed back into +// the ordinary provider parse path by the engine. +type CoverageResult struct { + Sources []SourceRef + Removed []CoverageRemoval + More bool + AuditRequired bool + NextDelay time.Duration + Checkpoint any +} + +// CoverageRemoval identifies the exact provider source ownership invalidated +// by a bounded feed event. +type CoverageRemoval struct { + SessionID string + Source SourceRef +} + +// BoundedCoverageProvider is optional. Providers without it retain generic +// provider-scoped reconciliation semantics. +type BoundedCoverageProvider interface { + PollCoverage(context.Context, CoverageTask) (CoverageResult, error) + CommitCoverage(context.Context, CoverageTask, CoverageResult, bool) error +} + // ReconciliationSourceResolver rebuilds the exact source emitted by streaming // discovery without routing the virtual path back through changed-path // classification. Shared-container providers use it to avoid rescanning every @@ -299,8 +354,8 @@ type WatchRootPlanner interface { // ResolveWatchRoots returns the bounded root-planning capability when a // provider advertises it. Providers that have not migrated yet retain their -// WatchPlan behavior, but parser-only include/exclude globs are never carried -// into watcher scheduling. +// WatchPlan behavior. Parser globs remain excluded from watcher scheduling, +// except coverage-keyed roots retain them for bounded event routing. func ResolveWatchRoots( ctx context.Context, provider Provider, @@ -329,11 +384,18 @@ func ResolveWatchRoots( func watchRootMetadata(roots []WatchRoot) []WatchRoot { out := make([]WatchRoot, 0, len(roots)) for _, root := range roots { - out = append(out, WatchRoot{ - Path: root.Path, - Recursive: root.Recursive, - DebounceKey: root.DebounceKey, - }) + metadata := WatchRoot{ + Path: root.Path, + Recursive: root.Recursive, + DebounceKey: root.DebounceKey, + CoverageKey: root.CoverageKey, + NonBlockingProbe: root.NonBlockingProbe, + } + if root.CoverageKey != "" { + metadata.IncludeGlobs = append([]string(nil), root.IncludeGlobs...) + metadata.ExcludeGlobs = append([]string(nil), root.ExcludeGlobs...) + } + out = append(out, metadata) } return out } @@ -348,6 +410,9 @@ type WatchRoot struct { IncludeGlobs []string ExcludeGlobs []string DebounceKey string + // CoverageKey is a stable provider-owned identity for degraded polling. + CoverageKey string + NonBlockingProbe bool } // ActivityHintSource is one bounded append-only signal a provider exposes to diff --git a/internal/parser/provider_capabilities_test.go b/internal/parser/provider_capabilities_test.go index 38fcd75e5..6b93abf73 100644 --- a/internal/parser/provider_capabilities_test.go +++ b/internal/parser/provider_capabilities_test.go @@ -229,6 +229,22 @@ func TestProviderCapabilitiesFallbackWatchPlanRetainsOnlyRootMetadata(t *testing assert.Equal(t, 1, provider.watchPlanCalls) } +func TestProviderCapabilitiesCoverageRootRetainsRoutingGlobs(t *testing.T) { + provider := &watchRootCapabilityTestProvider{ + ProviderBase: ProviderBase{Def: AgentDef{Type: "coverage-watch-root-test"}}, + plan: WatchPlan{Roots: []WatchRoot{{ + Path: "/sessions", IncludeGlobs: []string{"state.db", "state.db-wal"}, + ExcludeGlobs: []string{"*.tmp"}, CoverageKey: "sqlite:/sessions", + }}}, + } + + roots, err := ResolveWatchRoots(context.Background(), provider) + require.NoError(t, err) + require.Len(t, roots, 1) + assert.Equal(t, []string{"state.db", "state.db-wal"}, roots[0].IncludeGlobs) + assert.Equal(t, []string{"*.tmp"}, roots[0].ExcludeGlobs) +} + func TestProviderCapabilitiesSourceSetAdapterImplementsWatchRootPlanner(t *testing.T) { root := filepath.Clean("/sessions") factory := NewSourceSetFactory( diff --git a/internal/parser/sqlite_container_state.go b/internal/parser/sqlite_container_state.go index 82f927333..a57ea5276 100644 --- a/internal/parser/sqlite_container_state.go +++ b/internal/parser/sqlite_container_state.go @@ -49,14 +49,10 @@ type SQLiteContainerState struct { DBMtimeSec int64 DBChangeCounter uint32 // DBInode and DBDevice distinguish a replaced or restored container - // file (new inode) from in-place transaction writes, which the header - // markers alone cannot: a byte-identical copy carries the same size, - // counter, and salts. Zero on platforms without cheap file identity - // (Windows), where replacement detection degrades to the other - // markers. In-place byte surgery that preserves every marker and - // lands within the trusted mtime's second is explicitly out of - // scope — the same exposure class every mtime-based sync tool - // accepts. + // file from in-place transaction writes, which the header markers + // alone cannot: a byte-identical copy carries the same size, counter, + // and salts. In-place byte surgery that preserves every marker and + // lands within the trusted mtime's second remains out of scope. DBInode uint64 DBDevice uint64 @@ -98,7 +94,7 @@ func StatSQLiteContainerState(dbPath string) (SQLiteContainerState, bool) { DBSize: info.Size(), DBMtimeSec: info.ModTime().Unix(), } - state.DBInode, state.DBDevice = sourceFileIdentity(info) + state.DBInode, state.DBDevice = sqliteFileIdentity(dbPath, info) counter, ok := readSQLiteChangeCounter(dbPath) if !ok { return SQLiteContainerState{}, false diff --git a/internal/parser/sqlite_file_identity_unix.go b/internal/parser/sqlite_file_identity_unix.go new file mode 100644 index 000000000..1079acaa4 --- /dev/null +++ b/internal/parser/sqlite_file_identity_unix.go @@ -0,0 +1,9 @@ +//go:build unix + +package parser + +import "os" + +func sqliteFileIdentity(_ string, info os.FileInfo) (inode, device uint64) { + return sourceFileIdentity(info) +} diff --git a/internal/parser/sqlite_file_identity_windows.go b/internal/parser/sqlite_file_identity_windows.go new file mode 100644 index 000000000..d9fe4e809 --- /dev/null +++ b/internal/parser/sqlite_file_identity_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package parser + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func sqliteFileIdentity(path string, _ os.FileInfo) (inode, device uint64) { + file, err := os.Open(path) + if err != nil { + return 0, 0 + } + defer file.Close() + + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle( + windows.Handle(file.Fd()), &info, + ); err != nil { + return 0, 0 + } + fileIndex := uint64(info.FileIndexHigh)<<32 | uint64(info.FileIndexLow) + return fileIndex, uint64(info.VolumeSerialNumber) +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 91f40d787..1732491c6 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -408,6 +408,9 @@ type Engine struct { reconciliationMu gosync.RWMutex lastReconciliation ReconciliationResult reconciliationSpoolFactory func(string) (reconciliationSpoolStore, error) + coverageMu gosync.Mutex + coverageProviders map[string]parser.Provider + coverageLocks map[string]*gosync.Mutex } // ReconciliationResult is the structured acknowledgement for the most recent @@ -839,6 +842,20 @@ func (e *Engine) SyncPaths(paths []string) { _ = e.SyncPathsContext(context.Background(), paths) } +type excludedProviderContextKey struct{} + +func (e *Engine) SyncPathsExcludingProvidersContext( + ctx context.Context, paths []string, agents []parser.AgentType, +) error { + excluded := make(map[parser.AgentType]struct{}, len(agents)) + for _, agent := range agents { + excluded[agent] = struct{}{} + } + return e.SyncPathsContext( + context.WithValue(ctx, excludedProviderContextKey{}, excluded), paths, + ) +} + // SyncPathsContext is SyncPaths with caller-controlled cancellation. The // file watcher threads the serve shutdown context through here: its stop // path waits for the in-flight onChange callback, so a watcher-driven sync @@ -1027,6 +1044,11 @@ func (e *Engine) classifyProviderChangedPath( }) for _, agentType := range agents { + if excluded, _ := ctx.Value(excludedProviderContextKey{}).(map[parser.AgentType]struct{}); excluded != nil { + if _, skip := excluded[agentType]; skip { + continue + } + } mode := e.providerMigrationModes[agentType] switch mode { case parser.ProviderMigrationProviderAuthoritative: @@ -3174,6 +3196,264 @@ func (e *Engine) ReconcileProviderRoots( return err } +// coverageProvider returns the stateful provider for one coverage identity. +func (e *Engine) coverageProvider(task parser.CoverageTask) (parser.Provider, error) { + factory := e.providerFactories[task.Agent] + if factory == nil { + return nil, fmt.Errorf("provider %s is not registered", task.Agent) + } + providerKey := coverageOperationKey(task) + e.coverageMu.Lock() + if e.coverageProviders == nil { + e.coverageProviders = make(map[string]parser.Provider) + } + provider := e.coverageProviders[providerKey] + if provider == nil { + provider = factory.NewProvider(parser.ProviderConfig{ + Roots: e.agentDirs[task.Agent], Machine: e.machine, + PathRewriter: e.pathRewriter, + }) + e.coverageProviders[providerKey] = provider + } + e.coverageMu.Unlock() + return provider, nil +} + +func coverageOperationKey(task parser.CoverageTask) string { + return string(task.Agent) + "\x00" + task.CoverageKey +} + +func (e *Engine) coverageOperationLock(task parser.CoverageTask) *gosync.Mutex { + key := coverageOperationKey(task) + e.coverageMu.Lock() + defer e.coverageMu.Unlock() + if e.coverageLocks == nil { + e.coverageLocks = make(map[string]*gosync.Mutex) + } + lock := e.coverageLocks[key] + if lock == nil { + lock = &gosync.Mutex{} + e.coverageLocks[key] = lock + } + return lock +} + +// PrimeCoverage captures a provider feed checkpoint before startup +// reconciliation. The startup pass then covers the captured history while +// later bounded polls retain every change that races with that pass. +func (e *Engine) PrimeCoverage(ctx context.Context, task parser.CoverageTask) error { + task.Trigger = parser.CoverageTriggerBaseline + operationLock := e.coverageOperationLock(task) + operationLock.Lock() + defer operationLock.Unlock() + provider, err := e.coverageProvider(task) + if err != nil { + return err + } + feed, ok := provider.(parser.BoundedCoverageProvider) + if !ok || provider.Capabilities().Source.BoundedCoverage != parser.CapabilitySupported { + return nil + } + result, err := feed.PollCoverage(ctx, task) + if err != nil { + if errors.Is(err, parser.ErrUnsupportedProviderFeature) || + errors.Is(err, parser.ErrProviderCoverageUnavailable) { + return nil + } + return err + } + if result.AuditRequired || result.More || len(result.Sources) > 0 { + return errors.New("coverage baseline returned work") + } + return feed.CommitCoverage(ctx, task, result, false) +} + +// ReconcileCoverage dispatches one provider-owned bounded coverage task. The +// provider retains journal state; unsupported providers use scoped discovery. +func (e *Engine) ReconcileCoverage(ctx context.Context, task parser.CoverageTask) (parser.CoverageResult, error) { + operationLock := e.coverageOperationLock(task) + operationLock.Lock() + defer operationLock.Unlock() + provider, err := e.coverageProvider(task) + if err != nil { + return parser.CoverageResult{}, err + } + feed, ok := provider.(parser.BoundedCoverageProvider) + if !ok || provider.Capabilities().Source.BoundedCoverage != parser.CapabilitySupported { + if !task.AuthoritativeFallback { + return parser.CoverageResult{}, fmt.Errorf( + "%w: %s provider fallback is unavailable", + parser.ErrProviderCoverageUnavailable, task.Agent, + ) + } + return parser.CoverageResult{}, e.ReconcileProviderRoots(ctx, task.Agent, []string{task.Root}) + } + result, err := feed.PollCoverage(ctx, task) + if err != nil { + if errors.Is(err, parser.ErrProviderCoverageSourceMissing) { + if !task.AuthoritativeFallback { + return result, err + } + return parser.CoverageResult{}, e.ReconcileProviderRoots( + ctx, task.Agent, []string{task.Root}, + ) + } + if errors.Is(err, parser.ErrUnsupportedProviderFeature) { + if !task.AuthoritativeFallback { + return result, fmt.Errorf( + "%w: %s provider fallback is unavailable", + parser.ErrProviderCoverageUnavailable, task.Agent, + ) + } + return parser.CoverageResult{}, e.ReconcileProviderRoots( + ctx, task.Agent, []string{task.Root}, + ) + } + if errors.Is(err, parser.ErrProviderCoverageUnavailable) && + task.Trigger == parser.CoverageTriggerNative && task.AuthoritativeFallback { + return parser.CoverageResult{}, e.ReconcileProviderRoots( + ctx, task.Agent, []string{task.Root}, + ) + } + return result, err + } + if result.AuditRequired { + if !task.AuthoritativeFallback { + return result, fmt.Errorf( + "%w: %s coverage audit is unavailable", + parser.ErrProviderCoverageUnavailable, task.Agent, + ) + } + if err := e.ReconcileProviderRoots(ctx, task.Agent, []string{task.Root}); err != nil { + return result, fmt.Errorf("%s coverage audit: %w", task.Agent, err) + } + if err := feed.CommitCoverage(ctx, task, result, true); err != nil { + return result, err + } + return result, nil + } + if len(result.Sources) > 0 { + if err := e.syncCoverageSources(ctx, task.Agent, result.Sources); err != nil { + return result, err + } + } + if len(result.Removed) > 0 { + if err := e.tombstoneCoverageSessions(ctx, task.Agent, result.Removed); err != nil { + return result, err + } + } + if err := feed.CommitCoverage(ctx, task, result, false); err != nil { + return result, err + } + return result, nil +} + +func (e *Engine) tombstoneCoverageSessions( + ctx context.Context, agent parser.AgentType, removals []parser.CoverageRemoval, +) error { + changed := false + err := func() error { + e.syncMu.Lock() + defer e.syncMu.Unlock() + for _, removal := range removals { + id := string(agent) + ":" + removal.SessionID + allowed, err := e.missingMemberTombstoneAllowed(ctx, id) + if err != nil { + return err + } + if !allowed { + continue + } + session, err := e.db.GetSessionFull(ctx, id) + if err != nil { + return err + } + expectedPath := providerDiscoveredPath(removal.Source) + if session == nil { + continue + } + if session.FilePath == nil || expectedPath == "" { + return fmt.Errorf("%w: coverage deletion ownership unresolved", parser.ErrProviderCoverageUnavailable) + } + storedPath := *session.FilePath + if !sameReconciliationSourcePath(storedPath, expectedPath) { + _, statErr := os.Stat(storedPath) + if statErr == nil || !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("%w: coverage deletion ownership %s remains unproven", parser.ErrProviderCoverageUnavailable, storedPath) + } + } + tombstoned, err := e.tombstoneSessionSourceOwnership( + ctx, e.machine, string(agent), id, storedPath, + ) + if err != nil { + return err + } + changed = changed || tombstoned + } + return nil + }() + if changed { + e.emit("sessions") + } + return err +} + +func (e *Engine) syncCoverageSources( + ctx context.Context, agent parser.AgentType, sources []parser.SourceRef, +) error { + files := make([]parser.DiscoveredFile, 0, len(sources)) + paths := make([]string, 0, len(sources)) + for i := range sources { + path := providerDiscoveredPath(sources[i]) + if path == "" { + continue + } + source := sources[i] + files = append(files, parser.DiscoveredFile{ + Path: path, Project: source.ProjectHint, Agent: agent, + ForceParse: true, ProviderSource: &source, ProviderProcess: true, + }) + paths = append(paths, path) + } + if len(files) == 0 { + return nil + } + preContainerStates := e.captureSQLiteContainerStates(paths) + stats := func() SyncStats { + e.syncMu.Lock() + defer e.syncMu.Unlock() + defer e.clearCurrentProgress() + e.resetS3CodexIndexCache() + e.anomalies.reset() + e.beginSQLiteContainerPass(files, preContainerStates) + stats := e.collectAndBatch( + ctx, e.startWorkers(ctx, files), len(files), len(files), nil, + syncWriteDefault, + ) + e.finishSQLiteContainerPass(true, false) + e.anomalies.applyTo(&stats) + e.persistSkipCache() + e.mu.Lock() + e.lastSync = time.Now() + e.lastSyncStats = stats + e.mu.Unlock() + return stats + }() + if stats.Synced > 0 { + e.emit("sessions") + } + if err := ctx.Err(); err != nil { + return err + } + if stats.Aborted || stats.Failed > 0 || stats.providerFailures > 0 { + return fmt.Errorf( + "coverage sync incomplete: %d source or archive failures", + stats.Failed, + ) + } + return nil +} + func (e *Engine) reconcileScopedWatchRoots( ctx context.Context, agent parser.AgentType, roots []string, full, force bool, ) (SyncStats, int, error) { diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 21af8036e..27ce86b3c 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -867,6 +867,384 @@ func TestSyncEngineOpenCodeSQLiteWALOnlyChangeStillReemits(t *testing.T) { ) } +func TestOpenCodeCoverageForceParsesWALOnlyChildUpdate(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) + oc := createOpenCodeDB(t, env.opencodeDir) + oc.mustExec(t, "enable WAL", "PRAGMA journal_mode=WAL") + oc.mustExec(t, "create event journal", `CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + oc.addProject(t, "proj", "/home/user/code/opencode-app") + seedOpenCodeSQLiteTextSession( + t, oc, "proj", "wal-coverage", + 1779012000000, 1779012030000, + "original prompt", "original answer", + ) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: env.opencodeDir, + CoverageKey: "opencode-sqlite:" + env.opencodeDir, + } + require.NoError(t, env.engine.PrimeCoverage(t.Context(), task)) + stats := env.engine.SyncAll(context.Background(), nil) + require.False(t, stats.Aborted, "first sync aborted: %+v", stats) + + dbPath := filepath.Join(env.opencodeDir, "opencode.db") + before, err := os.Stat(dbPath) + require.NoError(t, err) + oc.mustExec(t, "update child row without session timestamp", + `UPDATE part SET data = '{"type":"text","content":"changed prompt"}' + WHERE id = 'wal-coverage-msg-user-part'`, + ) + oc.mustExec(t, "insert child event", + `INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_wal_child', 'wal-coverage', 1, 'message.part.updated.1', ?)`, + `{"sessionID":"wal-coverage","part":{"id":"wal-coverage-msg-user-part","messageID":"wal-coverage-msg-user","sessionID":"wal-coverage"},"time":1}`, + ) + after, err := os.Stat(dbPath) + require.NoError(t, err) + require.Equal(t, before.Size(), after.Size()) + require.Equal(t, before.ModTime(), after.ModTime()) + + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := env.engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, result.Sources, 1) + assertMessageContent( + t, env.db, "opencode:wal-coverage", "changed prompt", "original answer", + ) +} + +func TestOpenCodeCoverageDeletionEventTombstonesOnlyRemovedSession(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) + oc := createOpenCodeDB(t, env.opencodeDir) + oc.mustExec(t, "create event journal", `CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + oc.addProject(t, "proj", "/home/user/code/opencode-app") + seedOpenCodeSQLiteTextSession( + t, oc, "proj", "deleted-session", + 1779012000000, 1779012030000, + "deleted prompt", "deleted answer", + ) + seedOpenCodeSQLiteTextSession( + t, oc, "proj", "surviving-session", + 1779012100000, 1779012130000, + "surviving prompt", "surviving answer", + ) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: env.opencodeDir, + CoverageKey: "opencode-sqlite:" + env.opencodeDir, + } + require.NoError(t, env.engine.PrimeCoverage(t.Context(), task)) + stats := env.engine.SyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "initial sync aborted: %+v", stats) + require.Equal(t, 2, stats.Synced) + + oc.mustExec(t, "delete session rows", ` + DELETE FROM part WHERE session_id = 'deleted-session'; + DELETE FROM message WHERE session_id = 'deleted-session'; + DELETE FROM session WHERE id = 'deleted-session'; + `) + oc.mustExec(t, "insert deletion event", ` + INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_deleted', 'deleted-session', 1, 'session.deleted.1', ?) + `, `{"sessionID":"deleted-session","info":{"id":"deleted-session"}}`) + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := env.engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + require.Len(t, result.Removed, 1) + assert.Equal(t, "deleted-session", result.Removed[0].SessionID) + assert.Equal(t, + parser.OpenCodeSQLiteVirtualPath(oc.path, "deleted-session"), + result.Removed[0].Source.DisplayPath, + ) + + deleted, err := env.db.GetSession(t.Context(), "opencode:deleted-session") + require.NoError(t, err) + assert.Nil(t, deleted) + archived, err := env.db.GetSessionFull( + t.Context(), "opencode:deleted-session", + ) + require.NoError(t, err) + require.NotNil(t, archived) + assert.NotNil(t, archived.DeletedAt) + surviving, err := env.db.GetSession( + t.Context(), "opencode:surviving-session", + ) + require.NoError(t, err) + assert.NotNil(t, surviving) +} + +func TestOpenCodeCoverageDeletionPreservesStorageShadow(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) + storage := createOpenCodeStorageFixture(t, env.opencodeDir) + const sessionID = "storage-shadow" + storage.addSession( + t, "global", sessionID, + "/home/user/code/storage-app", "Storage Shadow", + 1779012000000, 1779012030000, + ) + storage.addMessage( + t, sessionID, "storage-msg", "assistant", 1779012001000, nil, + ) + storage.addTextPart( + t, sessionID, "storage-msg", "storage-part", + "storage answer", 1779012001000, + ) + oc := createOpenCodeDB(t, env.opencodeDir) + oc.mustExec(t, "create event journal", `CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + oc.addProject(t, "proj", "/home/user/code/sqlite-app") + seedOpenCodeSQLiteTextSession( + t, oc, "proj", sessionID, + 1779012000000, 1779012030000, + "sqlite prompt", "sqlite answer", + ) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: env.opencodeDir, + CoverageKey: "opencode-sqlite:" + env.opencodeDir, + } + require.NoError(t, env.engine.PrimeCoverage(t.Context(), task)) + stats := env.engine.SyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "initial sync aborted: %+v", stats) + assertMessageContent( + t, env.db, "opencode:"+sessionID, "storage answer", + ) + + oc.mustExec(t, "delete shadowed sqlite rows", ` + DELETE FROM part WHERE session_id = 'storage-shadow'; + DELETE FROM message WHERE session_id = 'storage-shadow'; + DELETE FROM session WHERE id = 'storage-shadow'; + `) + oc.mustExec(t, "insert shadowed deletion event", ` + INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_shadow_deleted', 'storage-shadow', 1, 'session.deleted.1', ?) + `, `{"sessionID":"storage-shadow","info":{"id":"storage-shadow"}}`) + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := env.engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + require.Len(t, result.Sources, 1) + assert.Empty(t, result.Removed) + + active, err := env.db.GetSession(t.Context(), "opencode:"+sessionID) + require.NoError(t, err) + require.NotNil(t, active) + assertMessageContent( + t, env.db, "opencode:"+sessionID, "storage answer", + ) +} + +func TestOpenCodeCoverageDeletionTombstonesMissingStorageOwnership(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) + storage := createOpenCodeStorageFixture(t, env.opencodeDir) + const sessionID = "deleted-storage-owner" + storagePath := storage.addSession( + t, "global", sessionID, + "/home/user/code/storage-app", "Deleted Storage Owner", + 1779012000000, 1779012030000, + ) + storage.addMessage( + t, sessionID, "storage-msg", "assistant", 1779012001000, nil, + ) + storage.addTextPart( + t, sessionID, "storage-msg", "storage-part", + "storage answer", 1779012001000, + ) + oc := createOpenCodeDB(t, env.opencodeDir) + oc.mustExec(t, "create event journal", `CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + oc.addProject(t, "proj", "/home/user/code/sqlite-app") + seedOpenCodeSQLiteTextSession( + t, oc, "proj", sessionID, + 1779012000000, 1779012030000, + "sqlite prompt", "sqlite answer", + ) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: env.opencodeDir, + CoverageKey: "opencode-sqlite:" + env.opencodeDir, + } + require.NoError(t, env.engine.PrimeCoverage(t.Context(), task)) + stats := env.engine.SyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "initial sync aborted: %+v", stats) + archived, err := env.db.GetSessionFull(t.Context(), "opencode:"+sessionID) + require.NoError(t, err) + require.NotNil(t, archived) + require.NotNil(t, archived.FilePath) + assert.Equal(t, storagePath, *archived.FilePath) + + require.NoError(t, os.Remove(storagePath)) + oc.mustExec(t, "delete sqlite rows for missing storage owner", ` + DELETE FROM part WHERE session_id = 'deleted-storage-owner'; + DELETE FROM message WHERE session_id = 'deleted-storage-owner'; + DELETE FROM session WHERE id = 'deleted-storage-owner'; + `) + oc.mustExec(t, "insert deletion event for missing storage owner", ` + INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_deleted_storage_owner', 'deleted-storage-owner', 1, 'session.deleted.1', ?) + `, `{"sessionID":"deleted-storage-owner","info":{"id":"deleted-storage-owner"}}`) + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := env.engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + require.Len(t, result.Removed, 1) + + active, err := env.db.GetSession(t.Context(), "opencode:"+sessionID) + require.NoError(t, err) + assert.Nil(t, active) + deleted, err := env.db.GetSessionFull(t.Context(), "opencode:"+sessionID) + require.NoError(t, err) + require.NotNil(t, deleted) + assert.NotNil(t, deleted.DeletedAt) + assert.Equal(t, storagePath, *deleted.FilePath) + + replayed, err := env.engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.Empty(t, replayed.Removed) +} + +func TestOpenCodeCoverageDeletionPromotesNewStorageShadow(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) + oc := createOpenCodeDB(t, env.opencodeDir) + oc.mustExec(t, "create event journal", `CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + oc.addProject(t, "proj", "/home/user/code/sqlite-app") + const sessionID = "late-storage-shadow" + seedOpenCodeSQLiteTextSession( + t, oc, "proj", sessionID, + 1779012000000, 1779012030000, + "sqlite prompt", "sqlite answer", + ) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: env.opencodeDir, + CoverageKey: "opencode-sqlite:" + env.opencodeDir, + } + require.NoError(t, env.engine.PrimeCoverage(t.Context(), task)) + stats := env.engine.SyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "initial sync aborted: %+v", stats) + assertMessageContent( + t, env.db, "opencode:"+sessionID, "sqlite prompt", "sqlite answer", + ) + + storage := createOpenCodeStorageFixture(t, env.opencodeDir) + storage.addSession( + t, "global", sessionID, + "/home/user/code/storage-app", "Late Storage Shadow", + 1779012100000, 1779012130000, + ) + storage.addMessage( + t, sessionID, "storage-user", "user", 1779012101000, nil, + ) + storage.addTextPart( + t, sessionID, "storage-user", "storage-user-part", + "late storage prompt", 1779012101000, + ) + storage.addMessage( + t, sessionID, "storage-assistant", "assistant", 1779012102000, nil, + ) + storage.addTextPart( + t, sessionID, "storage-assistant", "storage-assistant-part", + "late storage answer", 1779012102000, + ) + oc.mustExec(t, "delete newly shadowed sqlite rows", ` + DELETE FROM part WHERE session_id = 'late-storage-shadow'; + DELETE FROM message WHERE session_id = 'late-storage-shadow'; + DELETE FROM session WHERE id = 'late-storage-shadow'; + `) + oc.mustExec(t, "insert newly shadowed deletion event", ` + INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_late_shadow_deleted', 'late-storage-shadow', 1, 'session.deleted.1', ?) + `, `{"sessionID":"late-storage-shadow","info":{"id":"late-storage-shadow"}}`) + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := env.engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + require.Len(t, result.Sources, 1) + assert.Empty(t, result.Removed) + + active, err := env.db.GetSession(t.Context(), "opencode:"+sessionID) + require.NoError(t, err) + require.NotNil(t, active) + assertMessageContent( + t, env.db, "opencode:"+sessionID, + "late storage prompt", "late storage answer", + ) +} + +func TestOpenCodeCoverageDeletionHonorsNarrowedCwdFilter(t *testing.T) { + root := t.TempDir() + database := dbtest.OpenTestDB(t) + initial := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root}, + }, + Machine: "local", + }) + oc := createOpenCodeDB(t, root) + oc.mustExec(t, "create event journal", `CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + oc.addProject(t, "proj", "/home/user/code/excluded-app") + seedOpenCodeSQLiteTextSession( + t, oc, "proj", "filtered-deletion", + 1779012000000, 1779012030000, + "excluded prompt", "excluded answer", + ) + stats := initial.SyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "initial sync aborted: %+v", stats) + require.Equal(t, 1, stats.Synced) + initial.Close() + + filtered := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root}, + }, + Machine: "local", + IncludeCwdPrefixes: []string{"/home/user/code/included-app"}, + }) + t.Cleanup(filtered.Close) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + } + require.NoError(t, filtered.PrimeCoverage(t.Context(), task)) + oc.mustExec(t, "delete filtered session rows", ` + DELETE FROM part WHERE session_id = 'filtered-deletion'; + DELETE FROM message WHERE session_id = 'filtered-deletion'; + DELETE FROM session WHERE id = 'filtered-deletion'; + `) + oc.mustExec(t, "insert filtered deletion event", ` + INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_filtered_deleted', 'filtered-deletion', 1, 'session.deleted.1', ?) + `, `{"sessionID":"filtered-deletion","info":{"id":"filtered-deletion"}}`) + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + result, err := filtered.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + require.Len(t, result.Removed, 1) + + active, err := database.GetSession( + t.Context(), "opencode:filtered-deletion", + ) + require.NoError(t, err) + assert.NotNil(t, active) +} + // TestSyncEngineOpenCodeSQLiteCwdFilteredContainerStaysUntrusted pins the // promotion invariant against the cwd allow-list: a session that parses but // is vetoed by the filter was deliberately not persisted, so its container diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index ba1fb49d9..8234a498d 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -108,6 +108,68 @@ func TestClaudeIDFreshnessRejectsSourceMissingTombstone(t *testing.T) { ), "ordinary user trash keeps the established freshness behavior") } +func TestCoverageTombstoneUsesStoredWindowsPathSpelling(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows path equivalence regression") + } + database := openTestDB(t) + dbPath := filepath.Join(t.TempDir(), "opencode.db") + storedPath := dbPath + "#deleted" + seedActiveBaselineSource( + t, database, parser.AgentOpenCode, "opencode:deleted", storedPath, + ) + require.NoError(t, database.BaselineActiveSessionSourcePaths( + t.Context(), "local", []db.SessionSourcePath{{ + Agent: string(parser.AgentOpenCode), FilePath: storedPath, + }}, + )) + expectedPath := strings.ReplaceAll(strings.ToUpper(dbPath), `\`, "/") + "#deleted" + require.NotEqual(t, storedPath, expectedPath) + engine := &Engine{db: database, machine: "local"} + + err := engine.tombstoneCoverageSessions( + t.Context(), parser.AgentOpenCode, []parser.CoverageRemoval{{ + SessionID: "deleted", + Source: parser.SourceRef{ + Provider: parser.AgentOpenCode, + DisplayPath: expectedPath, + }, + }}, + ) + require.NoError(t, err) + active, err := database.GetSession(t.Context(), "opencode:deleted") + require.NoError(t, err) + assert.Nil(t, active) +} + +func TestCoverageTombstoneDefersWhenMismatchedOwnershipStillExists(t *testing.T) { + database := openTestDB(t) + root := t.TempDir() + storedPath := filepath.Join(root, "storage", "session", "global", "owned.json") + require.NoError(t, os.MkdirAll(filepath.Dir(storedPath), 0o755)) + require.NoError(t, os.WriteFile(storedPath, []byte(`{}`), 0o644)) + seedActiveBaselineSource( + t, database, parser.AgentOpenCode, "opencode:owned", storedPath, + ) + engine := &Engine{db: database, machine: "local"} + + err := engine.tombstoneCoverageSessions( + t.Context(), parser.AgentOpenCode, []parser.CoverageRemoval{{ + SessionID: "owned", + Source: parser.SourceRef{ + Provider: parser.AgentOpenCode, + DisplayPath: parser.OpenCodeSQLiteVirtualPath( + filepath.Join(root, "opencode.db"), "owned", + ), + }, + }}, + ) + require.ErrorIs(t, err, parser.ErrProviderCoverageUnavailable) + active, err := database.GetSession(t.Context(), "opencode:owned") + require.NoError(t, err) + assert.NotNil(t, active) +} + func TestClassifyProviderChangedPathWatchRootPlanCached(t *testing.T) { root := t.TempDir() var watchRootsCalls atomic.Int32 diff --git a/internal/sync/provider_process_test.go b/internal/sync/provider_process_test.go index c3a234d52..e6959892b 100644 --- a/internal/sync/provider_process_test.go +++ b/internal/sync/provider_process_test.go @@ -542,6 +542,419 @@ func TestProcessFileProviderAuthoritativeNotFoundFails(t *testing.T) { assert.Equal(t, []string{"find-source"}, provider.calls) } +func TestOpenCodeCoverageProcessesProviderSourcesThroughArchiveWrite(t *testing.T) { + root := t.TempDir() + sourcePath, fingerprint := writeProcessProviderSource(t, root, "coverage.jsonl") + source := processFixtureSource(sourcePath) + provider := newProcessFixtureProvider( + source, + fingerprint, + parser.ParseOutcome{ + Results: []parser.ParseResultOutcome{{ + Result: processFixtureResult( + "cowork:coverage", parser.AgentCowork, + "fixture-project", sourcePath, fingerprint, + ), + DataVersion: parser.DataVersionCurrent, + }}, + ResultSetComplete: true, + }, + ) + provider.Caps.Source.BoundedCoverage = parser.CapabilitySupported + provider.Caps.Source.StreamingDiscovery = parser.CapabilitySupported + provider.Caps.Source.WatchRoots = parser.CapabilitySupported + provider.coverageResult = parser.CoverageResult{Sources: []parser.SourceRef{source}} + engine := newProcessFixtureEngine(t, root, provider) + t.Cleanup(engine.Close) + engine.writeBatchOverride = func( + []pendingWrite, syncWriteMode, bool, + ) (int, int, int, int) { + return 0, 0, 1, 0 + } + + _, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentCowork, CoverageKey: "cowork:coverage", Root: root, + Trigger: parser.CoverageTriggerDegraded, + }) + require.Error(t, err) + assert.Zero(t, provider.coverageCommits) + engine.writeBatchOverride = nil + + result, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentCowork, CoverageKey: "cowork:coverage", Root: root, + Trigger: parser.CoverageTriggerDegraded, + }) + require.NoError(t, err) + require.Len(t, result.Sources, 1) + require.Len(t, provider.coverageTasks, 2) + assert.Equal(t, 1, provider.coverageCommits) + stored, err := engine.db.GetSession(t.Context(), "cowork:coverage") + require.NoError(t, err) + require.NotNil(t, stored) + require.NotNil(t, stored.FirstMessage) + assert.Equal(t, "fixture prompt", *stored.FirstMessage) +} + +func TestCoverageUnsupportedKeyFallsBackToProviderRootReconciliation(t *testing.T) { + root := t.TempDir() + sourcePath, fingerprint := writeProcessProviderSource(t, root, "storage.jsonl") + source := processFixtureSource(sourcePath) + provider := newProcessFixtureProvider( + source, + fingerprint, + parser.ParseOutcome{ + Results: []parser.ParseResultOutcome{{ + Result: processFixtureResult( + "cowork:storage", parser.AgentCowork, + "fixture-project", sourcePath, fingerprint, + ), + DataVersion: parser.DataVersionCurrent, + }}, + ResultSetComplete: true, + }, + ) + provider.Caps.Source.BoundedCoverage = parser.CapabilitySupported + provider.Caps.Source.StreamingDiscovery = parser.CapabilitySupported + provider.Caps.Source.WatchRoots = parser.CapabilitySupported + provider.coverageErr = parser.UnsupportedProviderFeatureError{ + Provider: parser.AgentCowork, Feature: parser.ProviderFeatureCoverageFeed, + } + engine := newProcessFixtureEngine(t, root, provider) + t.Cleanup(engine.Close) + + _, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentCowork, Root: root, + Trigger: parser.CoverageTriggerDegraded, AuthoritativeFallback: true, + }) + require.NoError(t, err) + assert.Len(t, provider.coverageTasks, 1) + assert.Zero(t, provider.coverageCommits) + stored, err := engine.db.GetSession(t.Context(), "cowork:storage") + require.NoError(t, err) + require.NotNil(t, stored) +} + +func TestOpenCodeInitialCoverageBaselineAvoidsArchiveAudit(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + writer, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = writer.Close() }) + _, err = writer.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + )`) + require.NoError(t, err) + tx, err := writer.Begin() + require.NoError(t, err) + stmt, err := tx.Prepare( + "INSERT INTO event(id, aggregate_id, seq, type, data) VALUES(?, 'ses_history', ?, 'message.part.updated.1', ?)", + ) + require.NoError(t, err) + for id := 1; id <= 1000; id++ { + _, err = stmt.Exec( + fmt.Sprintf("evt_%08d", id), id, + `{"sessionID":"ses_history","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_history"},"time":1}`, + ) + require.NoError(t, err) + } + require.NoError(t, stmt.Close()) + require.NoError(t, tx.Commit()) + engine := NewEngine(openTestDB(t), EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "devbox", + }) + t.Cleanup(engine.Close) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + } + require.NoError(t, engine.PrimeCoverage(t.Context(), task)) + + task.Trigger = parser.CoverageTriggerDegraded + result, err := engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + assert.Empty(t, result.Sources) +} + +func TestOpenCodeCoverageBaselineRetainsStartupGapChange(t *testing.T) { + root := t.TempDir() + writer, err := sql.Open("sqlite3", filepath.Join(root, "opencode.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = writer.Close() }) + _, err = writer.Exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL); + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + title TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + data TEXT NOT NULL, time_created INTEGER NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, message_id TEXT NOT NULL, + data TEXT NOT NULL, time_created INTEGER NOT NULL + ); + CREATE TABLE event ( + id TEXT PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data TEXT NOT NULL + ); + `) + require.NoError(t, err) + _, err = writer.Exec( + "INSERT INTO project(id, worktree) VALUES('prj_gap', ?)", t.TempDir(), + ) + require.NoError(t, err) + database := openTestDB(t) + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "devbox", + }) + t.Cleanup(engine.Close) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + } + require.NoError(t, engine.PrimeCoverage(t.Context(), task)) + engine.SyncAll(t.Context(), nil) + + _, err = writer.Exec(` + INSERT INTO session(id, project_id, title, time_created, time_updated) + VALUES('ses_gap', 'prj_gap', 'Gap', 1, 2); + INSERT INTO message(id, session_id, data, time_created) + VALUES('msg_gap', 'ses_gap', '{"role":"user"}', 1); + INSERT INTO part(id, session_id, message_id, data, time_created) + VALUES('prt_gap', 'ses_gap', 'msg_gap', '{"type":"text","content":"gap prompt"}', 1); + `) + require.NoError(t, err) + _, err = writer.Exec( + `INSERT INTO event(id, aggregate_id, seq, type, data) + VALUES('evt_gap', 'ses_gap', 1, 'session.updated.1', ?)`, + `{"sessionID":"ses_gap","info":{"id":"ses_gap","projectID":"prj_gap"}}`, + ) + require.NoError(t, err) + task.Trigger = parser.CoverageTriggerDegraded + result, err := engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + assert.False(t, result.AuditRequired) + require.Len(t, result.Sources, 1) + stored, err := database.GetSession(t.Context(), "opencode:ses_gap") + require.NoError(t, err) + assert.NotNil(t, stored) +} + +func TestOpenCodeNativeUnknownCoverageFallsBackToProviderRoot(t *testing.T) { + root := t.TempDir() + engine := NewEngine(openTestDB(t), EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "devbox", + }) + t.Cleanup(engine.Close) + + _, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + Trigger: parser.CoverageTriggerNative, + AuthoritativeFallback: true, + ChangedPaths: []string{filepath.Join(root, "opencode.db")}, + }) + require.NoError(t, err) +} + +func TestOpenCodeDegradedMissingActiveDatabaseFallsBack(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + writer, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = writer.Exec(`CREATE TABLE event ( + id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, data TEXT NOT NULL + )`) + require.NoError(t, err) + engine := NewEngine(openTestDB(t), EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "devbox", + }) + t.Cleanup(engine.Close) + task := parser.CoverageTask{ + Agent: parser.AgentOpenCode, Root: root, + CoverageKey: "opencode-sqlite:" + root, + } + require.NoError(t, engine.PrimeCoverage(t.Context(), task)) + require.NoError(t, writer.Close()) + require.NoError(t, os.Remove(dbPath)) + task.Trigger = parser.CoverageTriggerDegraded + task.AuthoritativeFallback = true + _, err = engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) +} + +func TestCoverageAuditDefersWithoutAvailabilityProof(t *testing.T) { + root := t.TempDir() + sourcePath, fingerprint := writeProcessProviderSource(t, root, "coverage.jsonl") + provider := newProcessFixtureProvider( + processFixtureSource(sourcePath), fingerprint, parser.ParseOutcome{}, + ) + provider.Caps.Source.BoundedCoverage = parser.CapabilitySupported + provider.coverageResult = parser.CoverageResult{AuditRequired: true} + engine := newProcessFixtureEngine(t, root, provider) + t.Cleanup(engine.Close) + + _, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentCowork, CoverageKey: "cowork:coverage", Root: root, + Trigger: parser.CoverageTriggerNative, + }) + require.ErrorIs(t, err, parser.ErrProviderCoverageUnavailable) + assert.Zero(t, provider.coverageCommits) +} + +func TestCoverageProviderFallbackDefersWithoutAvailabilityProof(t *testing.T) { + root := t.TempDir() + sourcePath, fingerprint := writeProcessProviderSource(t, root, "generic.jsonl") + provider := newProcessFixtureProvider( + processFixtureSource(sourcePath), fingerprint, parser.ParseOutcome{}, + ) + engine := newProcessFixtureEngine(t, root, provider) + t.Cleanup(engine.Close) + + _, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentCowork, Root: root, + Trigger: parser.CoverageTriggerDegraded, + }) + require.ErrorIs(t, err, parser.ErrProviderCoverageUnavailable) +} + +func TestCoverageEmissionRunsAfterSyncLockRelease(t *testing.T) { + root := t.TempDir() + sourcePath, fingerprint := writeProcessProviderSource(t, root, "coverage.jsonl") + source := processFixtureSource(sourcePath) + provider := newProcessFixtureProvider( + source, fingerprint, + parser.ParseOutcome{ + Results: []parser.ParseResultOutcome{{ + Result: processFixtureResult( + "cowork:coverage-emit", parser.AgentCowork, + "fixture-project", sourcePath, fingerprint, + ), + DataVersion: parser.DataVersionCurrent, + }}, + ResultSetComplete: true, + }, + ) + provider.Caps.Source.BoundedCoverage = parser.CapabilitySupported + provider.coverageResult = parser.CoverageResult{Sources: []parser.SourceRef{source}} + engine := newProcessFixtureEngine(t, root, provider) + t.Cleanup(engine.Close) + acquired := false + engine.emitter = emitterFunc(func(string) { + if engine.syncMu.TryLock() { + engine.syncMu.Unlock() + acquired = true + } + }) + + _, err := engine.ReconcileCoverage(t.Context(), parser.CoverageTask{ + Agent: parser.AgentCowork, CoverageKey: "cowork:coverage", Root: root, + Trigger: parser.CoverageTriggerDegraded, + }) + require.NoError(t, err) + assert.True(t, acquired) +} + +func TestCoverageReconciliationSerializesDeletionBeforeRecreation(t *testing.T) { + root := t.TempDir() + sourcePath, fingerprint := writeProcessProviderSource(t, root, "race.jsonl") + source := processFixtureSource(sourcePath) + provider := newProcessFixtureProvider( + source, fingerprint, + parser.ParseOutcome{ + Results: []parser.ParseResultOutcome{{ + Result: processFixtureResult( + "cowork:coverage-race", parser.AgentCowork, + "fixture-project", sourcePath, fingerprint, + ), + DataVersion: parser.DataVersionCurrent, + }}, + ResultSetComplete: true, + }, + ) + provider.Caps.Source.BoundedCoverage = parser.CapabilitySupported + engine := newProcessFixtureEngine(t, root, provider) + t.Cleanup(engine.Close) + task := parser.CoverageTask{ + Agent: parser.AgentCowork, CoverageKey: "cowork:race", Root: root, + Trigger: parser.CoverageTriggerDegraded, + } + provider.coverageResult = parser.CoverageResult{ + Sources: []parser.SourceRef{source}, + } + _, err := engine.ReconcileCoverage(t.Context(), task) + require.NoError(t, err) + + firstEntered := make(chan struct{}) + secondEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + var once stdsync.Once + t.Cleanup(func() { once.Do(func() { close(releaseFirst) }) }) + calls := 0 + var callsMu stdsync.Mutex + provider.pollCoverageFn = func( + ctx context.Context, _ parser.CoverageTask, + ) (parser.CoverageResult, error) { + callsMu.Lock() + calls++ + call := calls + callsMu.Unlock() + if call == 1 { + close(firstEntered) + select { + case <-ctx.Done(): + return parser.CoverageResult{}, ctx.Err() + case <-releaseFirst: + } + return parser.CoverageResult{Removed: []parser.CoverageRemoval{{ + SessionID: "coverage-race", Source: source, + }}}, nil + } + close(secondEntered) + return parser.CoverageResult{Sources: []parser.SourceRef{source}}, nil + } + + errs := make(chan error, 2) + go func() { + _, reconcileErr := engine.ReconcileCoverage(t.Context(), task) + errs <- reconcileErr + }() + <-firstEntered + go func() { + _, reconcileErr := engine.ReconcileCoverage(t.Context(), task) + errs <- reconcileErr + }() + assert.Never(t, func() bool { + select { + case <-secondEntered: + return true + default: + return false + } + }, 100*time.Millisecond, 10*time.Millisecond) + once.Do(func() { close(releaseFirst) }) + require.NoError(t, <-errs) + require.NoError(t, <-errs) + + active, err := engine.db.GetSession(t.Context(), "cowork:coverage-race") + require.NoError(t, err) + assert.NotNil(t, active) +} + func TestSyncSingleSessionProviderAuthoritativeBypassesProviderSkipCache(t *testing.T) { root := t.TempDir() @@ -1383,13 +1796,68 @@ func (f processFixtureFactory) NewProvider(parser.ProviderConfig) parser.Provide type processFixtureProvider struct { parser.ProviderBase - source parser.SourceRef - findFound bool - fingerprint parser.SourceFingerprint - outcome parser.ParseOutcome - calls []string - findRequests []parser.FindSourceRequest - parseRequests []parser.ParseRequest + source parser.SourceRef + findFound bool + fingerprint parser.SourceFingerprint + outcome parser.ParseOutcome + calls []string + findRequests []parser.FindSourceRequest + parseRequests []parser.ParseRequest + coverageResult parser.CoverageResult + coverageErr error + pollCoverageFn func(context.Context, parser.CoverageTask) (parser.CoverageResult, error) + commitCoverageFn func(context.Context, parser.CoverageTask, parser.CoverageResult, bool) error + coverageTasks []parser.CoverageTask + coverageCommits int +} + +func (p *processFixtureProvider) Discover(context.Context) ([]parser.SourceRef, error) { + return []parser.SourceRef{p.source}, nil +} + +func (p *processFixtureProvider) DiscoverEach( + _ context.Context, yield func(parser.SourceRef) error, +) error { + return yield(p.source) +} + +func (p *processFixtureProvider) WatchPlan(context.Context) (parser.WatchPlan, error) { + return parser.WatchPlan{Roots: []parser.WatchRoot{{ + Path: filepath.Dir(p.source.DisplayPath), Recursive: true, + }}}, nil +} + +func (p *processFixtureProvider) WatchRoots(context.Context) ([]parser.WatchRoot, error) { + return []parser.WatchRoot{{ + Path: filepath.Dir(p.source.DisplayPath), Recursive: true, + }}, nil +} + +func (p *processFixtureProvider) SourceForReconciliation( + context.Context, string, string, +) (parser.SourceRef, bool, error) { + return p.source, true, nil +} + +func (p *processFixtureProvider) PollCoverage( + ctx context.Context, task parser.CoverageTask, +) (parser.CoverageResult, error) { + if p.pollCoverageFn != nil { + return p.pollCoverageFn(ctx, task) + } + p.coverageTasks = append(p.coverageTasks, task) + return p.coverageResult, p.coverageErr +} + +func (p *processFixtureProvider) CommitCoverage( + ctx context.Context, task parser.CoverageTask, + result parser.CoverageResult, audited bool, +) error { + if p.commitCoverageFn != nil { + return p.commitCoverageFn(ctx, task, result, audited) + } + p.coverageCommits++ + return nil } func (p *processFixtureProvider) FindSource( diff --git a/internal/sync/watch_backend.go b/internal/sync/watch_backend.go index db607295a..df7d11f63 100644 --- a/internal/sync/watch_backend.go +++ b/internal/sync/watch_backend.go @@ -6,18 +6,20 @@ import "slices" // by a logical watcher root. A physical root may cover multiple configured // directories or providers, so registration must preserve every exact scope. type WatchScope struct { - Agent string - SyncDir string + Agent string + SyncDir string + CoverageKey string } // WatchRoot is one desired logical watcher root. Exists describes startup // state, not whether the root remains desired: missing roots stay in the plan // so a lifecycle-aware backend can establish ancestor coverage later. type WatchRoot struct { - Path string - Recursive bool - Exists bool - Scopes []WatchScope + Path string + Recursive bool + Exists bool + NonBlockingProbe bool + Scopes []WatchScope } // RegisterRoots passes the complete desired root plan to the watcher before diff --git a/internal/sync/watch_backend_factory_darwin.go b/internal/sync/watch_backend_factory_darwin.go index 4cfed6a7e..3993a7541 100644 --- a/internal/sync/watch_backend_factory_darwin.go +++ b/internal/sync/watch_backend_factory_darwin.go @@ -48,8 +48,10 @@ const ( // probe on the collapsed scope roots would let authoritative reconciliation // tombstone every session under the absent subtree. type darwinFallbackPollPlan struct { - path string - roots []string + path string + roots []string + nonBlockingProbe bool + scopes []WatchScope } // darwinFallbackPollingObligationKey names the per-plan fallback obligation so @@ -62,6 +64,7 @@ func darwinFallbackPollingObligationKey(path string) string { // merging into an existing entry for the same path. func appendFallbackPollPlan( plans []darwinFallbackPollPlan, path string, scopes []WatchScope, + nonBlockingProbe bool, ) []darwinFallbackPollPlan { roots := appendWatchScopeRoots(nil, scopes) if len(roots) == 0 { @@ -69,6 +72,7 @@ func appendFallbackPollPlan( } for i := range plans { if plans[i].path == path { + plans[i].nonBlockingProbe = plans[i].nonBlockingProbe && nonBlockingProbe merged := plans[i].roots for _, root := range roots { if !slices.Contains(merged, root) { @@ -77,10 +81,18 @@ func appendFallbackPollPlan( } slices.Sort(merged) plans[i].roots = merged + for _, scope := range scopes { + if !slices.Contains(plans[i].scopes, scope) { + plans[i].scopes = append(plans[i].scopes, scope) + } + } return plans } } - plans = append(plans, darwinFallbackPollPlan{path: path, roots: roots}) + plans = append(plans, darwinFallbackPollPlan{ + path: path, roots: roots, nonBlockingProbe: nonBlockingProbe, + scopes: append([]WatchScope(nil), scopes...), + }) slices.SortFunc(plans, func(a, b darwinFallbackPollPlan) int { return strings.Compare(a.path, b.path) }) @@ -430,7 +442,9 @@ func (b *darwinWatchBackend) prepareStartupFallbackLocked( if !plan.Recursive { continue } - pollPlans = appendFallbackPollPlan(pollPlans, plan.Path, plan.Scopes) + pollPlans = appendFallbackPollPlan( + pollPlans, plan.Path, plan.Scopes, plan.NonBlockingProbe, + ) results[i] = RecursiveWatchResult{Watched: 1} state := b.logical[plan.Path] if state != nil { @@ -540,6 +554,7 @@ func (b *darwinWatchBackend) Start() error { for _, state := range b.logical { b.fallbackPollPlans = appendFallbackPollPlan( b.fallbackPollPlans, state.plan.Path, state.plan.Scopes, + state.plan.NonBlockingProbe, ) } } @@ -1056,6 +1071,8 @@ func (b *darwinWatchBackend) installRootPollingLocked( if b.onPollingRequired != nil { return b.onPollingRequired(PollingObligation{ Key: state.plan.Path, Roots: roots, Probe: state.plan.Path, + NonBlockingProbe: state.plan.NonBlockingProbe, + Scopes: append([]WatchScope(nil), state.plan.Scopes...), }) } if b.onCoverageDegraded != nil { @@ -1143,6 +1160,7 @@ func (b *darwinWatchBackend) prepareRuntimeFallback() { for _, state := range b.logical { pollPlans = appendFallbackPollPlan( pollPlans, state.plan.Path, state.plan.Scopes, + state.plan.NonBlockingProbe, ) } b.fallbackPollPlans = pollPlans @@ -1251,9 +1269,11 @@ func (b *darwinWatchBackend) requireFallbackPolling( if required != nil { for _, plan := range plans { if err := required(PollingObligation{ - Key: darwinFallbackPollingObligationKey(plan.path), - Roots: plan.roots, - Probe: plan.path, + Key: darwinFallbackPollingObligationKey(plan.path), + Roots: plan.roots, + Probe: plan.path, + NonBlockingProbe: plan.nonBlockingProbe, + Scopes: append([]WatchScope(nil), plan.scopes...), }); err != nil { return err } diff --git a/internal/sync/watch_backend_fsnotify.go b/internal/sync/watch_backend_fsnotify.go index 736a70344..404c1406a 100644 --- a/internal/sync/watch_backend_fsnotify.go +++ b/internal/sync/watch_backend_fsnotify.go @@ -16,28 +16,30 @@ import ( ) type fsnotifyBackend struct { - watcher *fsnotify.Watcher - errorInput <-chan error - watchOps fsnotifyWatchOps - events chan backendEvent - errors chan error - excludes []string - roots []string - recursive []string - shallow []string - rootsMu sync.RWMutex - watchMu sync.Mutex - watchOwners map[string]map[string]struct{} - watchBudgetCost map[string]int - runtimeBudget int - rootScopes map[string][]string - degradedRoots map[string]struct{} - onPollingRequired func(PollingObligation) error - lifecycleMu sync.Mutex - lifecycle fsnotifyBackendLifecycle - stop chan struct{} - done chan struct{} - finishOnce sync.Once + watcher *fsnotify.Watcher + errorInput <-chan error + watchOps fsnotifyWatchOps + events chan backendEvent + errors chan error + excludes []string + roots []string + recursive []string + shallow []string + rootsMu sync.RWMutex + watchMu sync.Mutex + watchOwners map[string]map[string]struct{} + watchBudgetCost map[string]int + runtimeBudget int + rootScopes map[string][]string + rootCoverageScopes map[string][]WatchScope + rootNonBlocking map[string]bool + degradedRoots map[string]struct{} + onPollingRequired func(PollingObligation) error + lifecycleMu sync.Mutex + lifecycle fsnotifyBackendLifecycle + stop chan struct{} + done chan struct{} + finishOnce sync.Once } type fsnotifyWatchOps interface { @@ -59,18 +61,20 @@ func newFSNotifyBackend(excludes []string) (*fsnotifyBackend, error) { return nil, err } return &fsnotifyBackend{ - watcher: watcher, - errorInput: watcher.Errors, - watchOps: watcher, - events: make(chan backendEvent), - errors: make(chan error, 1), - excludes: normalizeExcludePatterns(excludes), - watchOwners: make(map[string]map[string]struct{}), - watchBudgetCost: make(map[string]int), - rootScopes: make(map[string][]string), - degradedRoots: make(map[string]struct{}), - stop: make(chan struct{}), - done: make(chan struct{}), + watcher: watcher, + errorInput: watcher.Errors, + watchOps: watcher, + events: make(chan backendEvent), + errors: make(chan error, 1), + excludes: normalizeExcludePatterns(excludes), + watchOwners: make(map[string]map[string]struct{}), + watchBudgetCost: make(map[string]int), + rootScopes: make(map[string][]string), + rootCoverageScopes: make(map[string][]WatchScope), + rootNonBlocking: make(map[string]bool), + degradedRoots: make(map[string]struct{}), + stop: make(chan struct{}), + done: make(chan struct{}), }, nil } @@ -135,6 +139,8 @@ func (b *fsnotifyBackend) setWatchRootPlan(roots []WatchRoot) { b.watchMu.Lock() defer b.watchMu.Unlock() b.rootScopes = make(map[string][]string, len(roots)) + b.rootCoverageScopes = make(map[string][]WatchScope, len(roots)) + b.rootNonBlocking = make(map[string]bool, len(roots)) for _, root := range roots { path := filepath.Clean(root.Path) for _, scope := range root.Scopes { @@ -142,6 +148,8 @@ func (b *fsnotifyBackend) setWatchRootPlan(roots []WatchRoot) { b.rootScopes[path] = append(b.rootScopes[path], scope.SyncDir) } } + b.rootCoverageScopes[path] = append([]WatchScope(nil), root.Scopes...) + b.rootNonBlocking[path] = root.NonBlockingProbe slices.Sort(b.rootScopes[path]) b.rootScopes[path] = slices.Compact(b.rootScopes[path]) } @@ -494,6 +502,8 @@ func (b *fsnotifyBackend) requireRuntimePolling(roots []string) { } required := b.onPollingRequired scopes := append([]string(nil), b.rootScopes[root]...) + coverageScopes := append([]WatchScope(nil), b.rootCoverageScopes[root]...) + nonBlockingProbe := b.rootNonBlocking[root] b.watchMu.Unlock() if len(scopes) == 0 { scopes = []string{root} @@ -506,6 +516,7 @@ func (b *fsnotifyBackend) requireRuntimePolling(roots []string) { } if err := required(PollingObligation{ Key: "fsnotify-runtime:" + root, Roots: scopes, Probe: root, + NonBlockingProbe: nonBlockingProbe, Scopes: coverageScopes, }); err != nil { b.reportError(fmt.Errorf( "transfer fsnotify coverage for %s to polling: %w", root, err, diff --git a/internal/sync/watcher.go b/internal/sync/watcher.go index c15c8e4cc..bd6c8bf5f 100644 --- a/internal/sync/watcher.go +++ b/internal/sync/watcher.go @@ -58,6 +58,7 @@ type WatchRename struct { // reconciliation so freshness shortcuts can be invalidated only when needed. type WatchBatch struct { Paths []string + Coverage []WatchCoverage Renames []WatchRename ReconcileRoots []string FullSync bool @@ -65,6 +66,15 @@ type WatchBatch struct { lifecycleTokens []backendLifecycleToken } +type WatchCoverage struct { + Agent string + Root string + CoverageKey string + AuthoritativeFallback bool + Paths []string + ConsumePath bool +} + type WatchCallback func(context.Context, WatchBatch) error // PollingObligation identifies one independently releasable reason that a set @@ -79,7 +89,9 @@ type PollingObligation struct { // authoritatively reconcile a present while the missing physical // subtree holds every session, tombstoning all of them. Empty means the // Roots themselves are the physical paths to probe. - Probe string + Probe string + NonBlockingProbe bool + Scopes []WatchScope } // WatcherOptions configures runtime ownership handoffs that are not needed by @@ -90,10 +102,9 @@ type WatcherOptions struct { OnPollingReleased func(string) error } -// WatchRetryError carries the authoritative reconciliation scope selected by a -// callback after it classifies a batch. The watcher consumes only FullSync and -// ReconcileRoots from WatchRetryBatch; ordinary paths and rename metadata are -// never replayed through this protocol. +// WatchRetryError carries the bounded work selected by a callback after it +// classifies a batch. The watcher retains paths, renames, and reconciliation +// scope so failures before rename handling cannot discard authoritative work. type WatchRetryError interface { error WatchRetryBatch() WatchBatch @@ -1210,11 +1221,12 @@ func callbackRetryBatch(err error) (WatchBatch, bool) { if retry.FullSync { return WatchBatch{FullSync: true, LostEvents: retry.LostEvents}, true } - if len(retry.Paths) == 0 && len(retry.ReconcileRoots) == 0 { + if len(retry.Paths) == 0 && len(retry.ReconcileRoots) == 0 && len(retry.Renames) == 0 { return WatchBatch{}, false } return WatchBatch{ Paths: append([]string(nil), retry.Paths...), + Renames: append([]WatchRename(nil), retry.Renames...), ReconcileRoots: append([]string(nil), retry.ReconcileRoots...), LostEvents: retry.LostEvents, }, true @@ -1230,6 +1242,9 @@ func retainWatchRetry(pending *pendingWatchBatch, retry WatchBatch) { for _, root := range retry.ReconcileRoots { pending.AddReconcileRoot(root) } + for _, rename := range retry.Renames { + pending.AddRename(rename) + } } pending.lostEvents = pending.lostEvents || retry.LostEvents for _, token := range retry.lifecycleTokens { diff --git a/internal/sync/watcher_darwin_test.go b/internal/sync/watcher_darwin_test.go index 2b7bdf414..d45eb6ebe 100644 --- a/internal/sync/watcher_darwin_test.go +++ b/internal/sync/watcher_darwin_test.go @@ -1512,9 +1512,10 @@ func TestDarwinWatcherFallbackRecoversNativeStreamsAndReleasesPolling(t *testing initialSink([]fsevents.Event{{Flags: fseventFlagKernelDropped}}) assert.Equal(t, PollingObligation{ - Key: darwinFallbackPollingObligationKey(root), - Roots: []string{root}, - Probe: root, + Key: darwinFallbackPollingObligationKey(root), + Roots: []string{root}, + Probe: root, + Scopes: []WatchScope{{Agent: "agent-a", SyncDir: root}}, }, requireReceiveWithin(t, polling, time.Second)) waitForDarwinBatch(t, batches, func(batch WatchBatch) bool { return batch.FullSync }) assert.Equal(t, darwinFallbackPollingObligationKey(root), @@ -1585,9 +1586,10 @@ func TestDarwinWatcherFallbackKeepsPollingUntilNativeRetrySucceeds(t *testing.T) backend.requestFallback(darwinFallbackNativeDrop) assert.Equal(t, PollingObligation{ - Key: darwinFallbackPollingObligationKey(root), - Roots: []string{root}, - Probe: root, + Key: darwinFallbackPollingObligationKey(root), + Roots: []string{root}, + Probe: root, + Scopes: []WatchScope{{SyncDir: root}}, }, requireReceiveWithin(t, polling, time.Second)) waitForDarwinBatch(t, batches, func(batch WatchBatch) bool { return batch.FullSync }) requireReceiveWithin(t, failedRecovery, time.Second) @@ -1721,19 +1723,24 @@ func TestDarwinWatcherFallbackTransfersMissingRootPollingBeforeRecovery(t *testi backend.requestFallback(darwinFallbackNativeDrop) assert.Equal(t, PollingObligation{ - Key: darwinFallbackPollingObligationKey(missing), - Roots: []string{missing}, - Probe: missing, + Key: darwinFallbackPollingObligationKey(missing), + Roots: []string{missing}, + Probe: missing, + Scopes: []WatchScope{{SyncDir: missing}}, }, requireReceiveWithin(t, polling, time.Second), "each watch plan owns its own fallback obligation probed on its path") assert.Equal(t, PollingObligation{ - Key: darwinFallbackPollingObligationKey(present), - Roots: []string{present}, - Probe: present, + Key: darwinFallbackPollingObligationKey(present), + Roots: []string{present}, + Probe: present, + Scopes: []WatchScope{{SyncDir: present}}, }, requireReceiveWithin(t, polling, time.Second)) waitForDarwinBatch(t, batches, func(batch WatchBatch) bool { return batch.FullSync }) assert.Equal(t, - PollingObligation{Key: missing, Roots: []string{missing}, Probe: missing}, + PollingObligation{ + Key: missing, Roots: []string{missing}, Probe: missing, + Scopes: []WatchScope{{SyncDir: missing}}, + }, requireReceiveWithin(t, polling, time.Second), "the skipped root must own polling before generic fallback polling is released") assert.Equal(t, darwinFallbackPollingObligationKey(missing), @@ -1788,9 +1795,9 @@ func TestDarwinWatcherStartupCreateFailureSelectsRecursiveFallbackOnce(t *testin }, results) assert.Equal(t, int32(1), creates.Load(), "one failed stream selects global fallback") assert.Equal(t, []darwinFallbackPollPlan{ - {path: first, roots: []string{first}}, - {path: missing, roots: []string{missing}}, - {path: second, roots: []string{second}}, + {path: first, roots: []string{first}, scopes: []WatchScope{{SyncDir: first}}}, + {path: missing, roots: []string{missing}, scopes: []WatchScope{{SyncDir: missing}}}, + {path: second, roots: []string{second}, scopes: []WatchScope{{SyncDir: second}}}, }, backend.fallbackPollPlans) assert.Equal(t, uint32(1), backend.fallbackActivations.Load()) } @@ -2368,7 +2375,10 @@ func TestDarwinWatcherMissingRecursiveSymlinkStaysPolled(t *testing.T) { return slices.Contains(batch.ReconcileRoots, ancestor) }) assert.Equal(t, - PollingObligation{Key: root, Roots: []string{ancestor}, Probe: root}, + PollingObligation{ + Key: root, Roots: []string{ancestor}, Probe: root, + Scopes: []WatchScope{{Agent: "agent-a", SyncDir: ancestor}}, + }, requireReceiveWithin(t, required, time.Second)) require.True(t, firstRootInspection.Load()) backend.mu.Lock() @@ -2461,7 +2471,10 @@ func TestDarwinWatcherPendingLossWinsOverActivationAcknowledgement(t *testing.T) assert.Equal(t, darwinRootLossCollecting, state.phase) assert.False(t, state.active) assert.Equal(t, - PollingObligation{Key: root, Roots: []string{ancestor}, Probe: root}, + PollingObligation{ + Key: root, Roots: []string{ancestor}, Probe: root, + Scopes: []WatchScope{{Agent: "agent-a", SyncDir: ancestor}}, + }, requireReceiveWithin(t, required, time.Second)) requireReceiveWithin(t, closed, time.Second) } diff --git a/internal/sync/watcher_test.go b/internal/sync/watcher_test.go index dcc7c27cb..68ba763e7 100644 --- a/internal/sync/watcher_test.go +++ b/internal/sync/watcher_test.go @@ -1551,6 +1551,40 @@ func TestWatcherUsesCallbackChangedPathsForRetry(t *testing.T) { assert.False(t, second.FullSync) } +func TestWatcherUsesCallbackRenamesForRetry(t *testing.T) { + backend := newFakeWatchBackend() + calls := make(chan WatchBatch, 2) + var attempts atomic.Int32 + w, err := newWatcherWithBackend( + 0, 10*time.Millisecond, + func(_ context.Context, batch WatchBatch) error { + calls <- batch + if attempts.Add(1) == 1 { + return retryScopedWatchError{retry: WatchBatch{ + Renames: batch.Renames, + }} + } + return nil + }, + backend, 8, 1_000, + ) + require.NoError(t, err) + w.SetRootAgents("/sessions", []string{"codex"}) + w.Start() + t.Cleanup(w.Stop) + + backend.sendBackendEvent(t, backendEvent{ + Path: "/sessions/renamed", Root: "/sessions", + Op: backendOpRename, ItemType: backendItemDirectory, + }) + first := receiveWatchBatch(t, calls) + second := receiveWatchBatch(t, calls) + + require.Len(t, first.Renames, 1) + assert.Equal(t, first.Renames, second.Renames) + assert.Equal(t, ItemIsDir, second.Renames[0].ItemType) +} + func TestRetainWatchRetryPathOverflowPromotesFullSync(t *testing.T) { pending := newPendingWatchBatch(1, 1_000)