diff --git a/Makefile b/Makefile index d90267e27..6af6c28c2 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,11 @@ GOLANGCI_LINT_VERSION ?= v2.11.4 # Isolate each checkout from stale sibling-worktree fixes and issue positions. GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache export GOLANGCI_LINT_CACHE +ifeq ($(findstring NT-,$(shell uname -s 2>/dev/null)),NT-) +CUSTOM_GCL := ./custom-gcl.exe +else CUSTOM_GCL := ./custom-gcl +endif PRICING_SNAPSHOT_FILE := internal/pricing/snapshot/litellm_snapshot.json.gz # sqlite-vec's cgo bindings #include "sqlite3.h". Without an override the @@ -424,7 +428,7 @@ nilaway-golangci-build: exit 1; \ fi @unset_args=$$(git rev-parse --local-env-vars 2>/dev/null | sed 's/^/-u /' | tr '\n' ' '); \ - env $$unset_args GOFLAGS=-buildvcs=false \ + env $$unset_args GOFLAGS=-buildvcs=false GOTOOLCHAIN=$(shell go env GOVERSION) \ golangci-lint custom --version "$(GOLANGCI_LINT_VERSION)" --name custom-gcl # Run NilAway through the custom golangci-lint module plugin. @@ -461,6 +465,9 @@ nilaway: pricing-snapshot ensure-embed-dir nilaway-golangci-build root=$$(pwd); \ dirs=$$(go list -f '{{.Dir}}' ./...); \ pkgs=$$(for dir in $$dirs; do \ + case "$$(uname -s 2>/dev/null)" in \ + *_NT-*) dir=$$(cygpath -u "$$dir");; \ + esac; \ if [ "$$dir" = "$$root" ]; then \ printf '%s\n' "."; \ else \ @@ -468,6 +475,7 @@ nilaway: pricing-snapshot ensure-embed-dir nilaway-golangci-build fi; \ done); \ if [ -z "$$pkgs" ]; then echo "nilaway: no packages to lint" >&2; exit 1; fi; \ + count=$$(printf '%s\n' "$$pkgs" | awk 'END { print NR }'); \ NILAWAY_OUT_DIR=$$(mktemp -d); \ export NILAWAY_OUT_DIR; \ trap 'rm -f "$$NILAWAY_OUT_DIR"/*; rmdir "$$NILAWAY_OUT_DIR"' EXIT HUP INT TERM; \ @@ -487,8 +495,10 @@ nilaway: pricing-snapshot ensure-embed-dir nilaway-golangci-build exit $$status' sh; \ rc=$$?; \ set -e; \ - ls "$$NILAWAY_OUT_DIR" | sort -n | while IFS= read -r n; do \ - cat "$$NILAWAY_OUT_DIR/$$n"; \ + n=1; \ + while [ "$$n" -le "$$count" ]; do \ + if [ -f "$$NILAWAY_OUT_DIR/$$n" ]; then cat "$$NILAWAY_OUT_DIR/$$n"; fi; \ + n=$$((n + 1)); \ done; \ exit $$rc diff --git a/cmd/agentsview/archive_write_backend.go b/cmd/agentsview/archive_write_backend.go index fb662bf5a..af013666b 100644 --- a/cmd/agentsview/archive_write_backend.go +++ b/cmd/agentsview/archive_write_backend.go @@ -106,10 +106,12 @@ func newArchivePushUnwatchedPoller( return hooks.newUnwatchedPoller(ctx, engine) } ticker := time.NewTicker(unwatchedPollInterval) - return newUnwatchedPollCoordinatorWithTicks( + poller := newUnwatchedPollCoordinatorWithTicks( ctx, engine, ticker.C, ticker.Stop, func(work func()) { work() }, nil, time.Now, time.After, ) + poller.DisableBoundedCoverage() + return poller } func startArchivePushWatcher( diff --git a/cmd/agentsview/bounded_coverage_integration_test.go b/cmd/agentsview/bounded_coverage_integration_test.go new file mode 100644 index 000000000..73be4d193 --- /dev/null +++ b/cmd/agentsview/bounded_coverage_integration_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/parser" + agentsync "go.kenn.io/agentsview/internal/sync" +) + +// TestBoundedCoverageCoordinatorCardinality uses the production coordinator, +// Engine resolver, journal drain, and source-application seam. Only the +// journal's changed row is observed for both archive cardinalities. +func TestBoundedCoverageCoordinatorCardinality(t *testing.T) { + if testing.Short() { + t.Skip("skipping bounded coverage cardinality integration") + } + for _, mode := range []struct { + name string + }{ + {name: "native"}, + {name: "degraded"}, + } { + for _, sessions := range []int{10, 5000} { + t.Run(fmt.Sprintf("%s_sessions_%d", mode.name, sessions), func(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + journal, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = journal.Close() }) + _, err = journal.Exec("PRAGMA wal_autocheckpoint=0") + require.NoError(t, err) + _, err = journal.Exec(boundedCoverageFixtureSchema) + require.NoError(t, err) + var journalMode string + err = journal.QueryRow("PRAGMA journal_mode=WAL").Scan(&journalMode) + require.NoError(t, err) + require.Equal(t, "wal", journalMode) + _, err = journal.Exec("INSERT INTO project (id, worktree, time_updated) VALUES ('proj', ?, 1)", root) + require.NoError(t, err) + for i := range sessions { + id := fmt.Sprintf("ses%05d", i) + _, err = journal.Exec( + "INSERT INTO session (id, project_id, time_created, time_updated) VALUES (?, 'proj', 1, 1)", id, + ) + require.NoError(t, err) + } + _, err = journal.Exec(`INSERT INTO message + (id, session_id, data, time_created, time_updated) + VALUES ('msg-0', 'ses00000', '{"role":"assistant"}', 1, 1)`) + require.NoError(t, err) + _, err = journal.Exec(`INSERT INTO part + (id, session_id, message_id, data, time_created, time_updated) + VALUES ('part-0', 'ses00000', 'msg-0', + '{"type":"text","content":"changed"}', 1, 1)`) + require.NoError(t, err) + archive := dbtest.OpenTestDB(t) + engine := agentsync.NewEngine(archive, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "local", + }) + t.Cleanup(engine.Close) + ticks := make(chan time.Time) + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), engine, ticks, func() {}, func(func()) {}, nil, + time.Now, time.After, + ) + t.Cleanup(coordinator.Stop) + var rows, applied int + coordinator.onBoundedCoveragePage = func(result parser.OpenCodeFeedResult) { + rows += result.RowsRead + } + coordinator.onBoundedCoverageApply = func(stats agentsync.SyncStats) { + applied += stats.Synced + } + roots := []agentsync.BoundedCoverageRoot{{Agent: parser.AgentOpenCode, Root: root}} + bindings, err := engine.BoundedCoverageBindings(t.Context(), roots) + require.NoError(t, err) + eventTypes := []string{"message.updated", "message.part.updated", "session.updated"} + for i, eventType := range eventTypes { + payload := "{}" + if eventType == "message.updated" { + payload = `{"sessionID":"ses00000","info":{"id":"msg-0","sessionID":"ses00000","role":"user"}}` + } + _, err = journal.Exec(`INSERT INTO event + (id, aggregate_id, seq, type, data) + VALUES (?, 'ses00000', ?, ?, ?)`, fmt.Sprintf("event-before-%d", i), i+1, eventType, payload) + require.NoError(t, err) + } + if mode.name == "native" { + _, err = coordinator.AdmitBoundedCoverage(t.Context(), bindings, true) + require.NoError(t, err) + } else { + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "degraded", Scopes: []pollingScope{{Agent: parser.AgentOpenCode, Root: root}}, + })) + } + for i, eventType := range eventTypes { + payload := "{}" + if eventType == "message.updated" { + payload = `{"sessionID":"ses00000","info":{"id":"msg-0","sessionID":"ses00000","role":"user"}}` + } + _, err = journal.Exec(`INSERT INTO event + (id, aggregate_id, seq, type, data) + VALUES (?, 'ses00000', ?, ?, ?)`, fmt.Sprintf("event-after-%d", i), i+4, eventType, payload) + require.NoError(t, err) + } + walInfo, err := os.Stat(dbPath + "-wal") + require.NoError(t, err) + require.Greater(t, walInfo.Size(), int64(32), + "the measured mutation must retain WAL frames beyond its header") + require.NoError(t, coordinator.pollBoundedCoverageOnce(t.Context())) + _, err = archive.GetSession(t.Context(), "ses00000") + require.NoError(t, err) + t.Logf("bounded_admission mode=%s sessions=%d event_types=%s observed_journal_rows=%d applied_sources=%d source=%s wal_bytes=%d", mode.name, sessions, strings.Join(eventTypes, ","), rows, applied, bindings[0].PhysicalDBPath, walInfo.Size()) + require.Equal(t, len(eventTypes)*2, rows, + "native and degraded production admission must retain every producer event") + require.Equal(t, 1, applied) + require.LessOrEqual(t, rows, parser.OpenCodeCoverageMaxRows) + }) + } + } +} + +func TestBoundedCoverageLeaseRejectsReplacedDatabase(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + journal, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = journal.Exec(boundedCoverageFixtureSchema) + require.NoError(t, err) + require.NoError(t, journal.Close()) + + archive := dbtest.OpenTestDB(t) + engine := agentsync.NewEngine(archive, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "local", + }) + t.Cleanup(engine.Close) + bindings, err := engine.BoundedCoverageBindings(t.Context(), []agentsync.BoundedCoverageRoot{{Agent: parser.AgentOpenCode, Root: root}}) + require.NoError(t, err) + require.Len(t, bindings, 1) + bindings[0].Generation = 1 + lease, err := engine.AdmitBoundedCoverageLease(t.Context(), bindings[0]) + require.NoError(t, err) + _, err = engine.TransitionBoundedCoverageRequest(t.Context(), lease, nil, lease.AdmissionCheckpoint, true) + require.NoError(t, err) + + backup := filepath.Join(root, "opencode.old.db") + require.NoError(t, os.Rename(dbPath, backup)) + replacement, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = replacement.Exec(boundedCoverageFixtureSchema) + require.NoError(t, err) + require.NoError(t, replacement.Close()) + + _, err = engine.TransitionBoundedCoverageRequest(t.Context(), lease, nil, lease.AdmissionCheckpoint, false) + require.Error(t, err, "replacement must invalidate the old physical lease before commit") +} + +func TestBoundedCoverageBindingsDeduplicateSymlinkedRoots(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "opencode.db") + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = db.Exec(boundedCoverageFixtureSchema) + require.NoError(t, err) + require.NoError(t, db.Close()) + + aliasParent := t.TempDir() + alias := filepath.Join(aliasParent, "alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + + archive := dbtest.OpenTestDB(t) + engine := agentsync.NewEngine(archive, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root, alias}}, + Machine: "local", + }) + defer engine.Close() + bindings, err := engine.BoundedCoverageBindings(t.Context(), []agentsync.BoundedCoverageRoot{ + {Agent: parser.AgentOpenCode, Root: root}, + {Agent: parser.AgentOpenCode, Root: alias}, + }) + require.NoError(t, err) + require.Len(t, bindings, 1, + "lexical aliases of one physical database must share one coverage binding") + assert.Equal(t, filepath.Clean(dbPath), bindings[0].DBPath) + assert.Equal(t, filepath.Clean(root), bindings[0].Scope) +} + +const boundedCoverageFixtureSchema = ` +CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, time_updated INTEGER 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, time_updated 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, time_updated INTEGER NOT NULL); +CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id); +CREATE INDEX part_session_idx ON part (session_id); +CREATE INDEX part_message_id_id_idx ON part (message_id, id); +CREATE TABLE event (id TEXT NOT NULL PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT NULL, type TEXT NOT NULL, data BLOB NOT NULL); +CREATE TABLE event_sequence (id TEXT NOT NULL PRIMARY KEY, owner_id TEXT); +` diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index 96cc54834..5140a7704 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -311,6 +312,18 @@ func runServe(cfg config.Config, opts serveOptions) { defer engine.Close() unwatchedPoller = newUnwatchedPollCoordinator(ctx, engine, idleTracker) defer unwatchedPoller.Stop() + unwatchedPoller.SetBoundedCoverageAuditRequester( + func(auditCtx context.Context, binding sync.BoundedCoverageBinding, reason string) error { + log.Printf("bounded coverage audit for %s: %s", binding.Key, reason) + return runBoundedCoverageAudit(auditCtx, unwatchedPoller.workerCtx, cfg, engine, database, writeLock, emitter, binding, reason) + }, + ) + unwatchedPoller.SetBoundedCoverageLeaseAuditRequester( + func(auditCtx context.Context, lease *sync.BoundedCoverageLease, reason string) error { + log.Printf("bounded coverage lease audit for %s: %s", lease.Binding.Key, reason) + return runBoundedCoverageLeaseAudit(auditCtx, unwatchedPoller.workerCtx, cfg, engine, database, writeLock, emitter, lease, reason) + }, + ) stopWatcher, openWatcherDispatch, _, queueWatchRetry = startFileWatcher( cfg, engine, func(_ context.Context, batch sync.WatchBatch) error { done, ok := idleTracker.BeginWork() @@ -320,6 +333,40 @@ func runServe(cfg config.Config, opts serveOptions) { defer done() // The serve ctx reaches watcher-driven syncs so SIGTERM can // interrupt database reconciliation before Stop waits for it. + if resolver, ok := any(engine).(sync.BoundedCoverageResolver); ok { + batch = sync.CanonicalizeWatchBatch(batch) + originalBatch := batch + originalPaths := append([]string(nil), batch.Paths...) + bindings, remaining, err := resolver.BoundedCoverageBindingsForPaths(ctx, batch.Paths) + if err != nil { + originalBatch.Paths = originalPaths + return &WatchRetryError{cause: err, batch: originalBatch} + } + admitted, err := unwatchedPoller.AdmitBoundedCoverage(ctx, bindings, true) + if err != nil { + originalBatch.Paths = originalPaths + return &WatchRetryError{cause: err, batch: originalBatch} + } + for _, binding := range bindings { + if slices.ContainsFunc(admitted, func(current sync.BoundedCoverageBinding) bool { + return current.Key == binding.Key + }) { + for _, path := range batch.Paths { + if sameCoverageEventPath(path, binding.DBPath) { + remaining = appendUniqueString(remaining, path) + } + } + } + } + unwatchedPoller.RetireBoundedCoveragePaths(originalPaths) + batch.Paths = remaining + if err := syncWatchBatch(ctx, engine, batch, func() watchRecoveryScope { + return probeWatchRecoveryScope(cfg) + }); err != nil { + return err + } + return nil + } return syncWatchBatch(ctx, engine, batch, func() watchRecoveryScope { return probeWatchRecoveryScope(cfg) }) @@ -341,6 +388,21 @@ func runServe(cfg config.Config, opts serveOptions) { }, ) defer stopWatcher() + if bindings, _, _, _ := collectWatchRoots(cfg); len(bindings) > 0 { + roots := make([]sync.BoundedCoverageRoot, 0) + for _, watchRoot := range bindings { + for _, scope := range watchRoot.scopes { + roots = append(roots, sync.BoundedCoverageRoot{Agent: scope.agent, Root: watchRoot.path}) + } + } + if bindings, err := engine.BoundedCoverageBindings(ctx, roots); err == nil { + if _, err := unwatchedPoller.AdmitBoundedCoverage(ctx, bindings, true); err != nil && ctx.Err() == nil { + log.Printf("bounded coverage startup admission: %v", err) + } + } else if ctx.Err() == nil { + log.Printf("bounded coverage startup admission: %v", err) + } + } onStartupReconciled = newStartupReconciliationHandler( ctx, database.CheckpointWALTruncateWithRetry, @@ -901,6 +963,12 @@ func pollingObligationKey(reason, path string) string { return reason + ":" + path } +func sameCoverageEventPath(path, dbPath string) bool { + clean := filepath.Clean(path) + base := filepath.Clean(dbPath) + return clean == base || clean == base+"-wal" || clean == base+"-shm" +} + // syncObligationToPoller converts a sync.PollingObligation to the local // pollingObligation type used by the coordinator. func syncObligationToPoller(o sync.PollingObligation) pollingObligation { @@ -1952,6 +2020,9 @@ func watchPollingObligations( if _, ok := byKey[key]; !ok { byKey[key] = &draft{probe: probe} } + if probe != "" { + represented[filepath.Clean(probe)] = struct{}{} + } for _, scope := range scopes { if scope.Root == "" { continue @@ -1971,7 +2042,8 @@ func watchPollingObligations( } if !result.MissingRootLifecycleOwned { // Pending dirs: keyed on the physical root path; derive agent from scopes. - addScope(root.path, root.path, root.pollingScopesForDirs(root.pendingPollingDirs)...) + addScope(root.path, root.path, + pollingScopesForWatchUnit(root, root.pollingScopesForDirs(root.pendingPollingDirs))...) } for _, dir := range root.persistentPollingDirs { cleanDir := filepath.Clean(dir) @@ -1981,8 +2053,9 @@ func watchPollingObligations( pollingScope{Root: dir}) } else { for _, agent := range agents { + ps := pollingScope{Agent: agent, Root: dir} addScope(pollingObligationKey("persistent", cleanDir), dir, - pollingScope{Agent: agent, Root: dir}) + pollingScopesForWatchUnit(root, []pollingScope{ps})...) } } } @@ -1991,7 +2064,9 @@ func watchPollingObligations( // one obligation per agent so releases are independent. for _, scope := range root.scopes { key := pollingObligationKey("nowatcher:"+string(scope.agent), root.path) - addScope(key, root.path, pollingScope{Agent: scope.agent, Root: scope.syncDir}) + ps := pollingScope{Agent: scope.agent, Root: scope.syncDir} + addScope(key, root.path, + pollingScopesForWatchUnit(root, []pollingScope{ps})...) } continue } @@ -1999,7 +2074,9 @@ func watchPollingObligations( result.ResourceExhausted || result.Err != nil { for _, scope := range root.scopes { key := pollingObligationKey("degraded:"+string(scope.agent), root.path) - addScope(key, root.path, pollingScope{Agent: scope.agent, Root: scope.syncDir}) + ps := pollingScope{Agent: scope.agent, Root: scope.syncDir} + addScope(key, root.path, + pollingScopesForWatchUnit(root, []pollingScope{ps})...) } } } @@ -2012,8 +2089,11 @@ func watchPollingObligations( pollingScope{Root: dir}) } else { for _, agent := range agents { + ps := pollingScope{Agent: agent, Root: dir} addScope(pollingObligationKey("persistent", cleanDir), cleanDir, - pollingScope{Agent: agent, Root: dir}) + pollingScopesForWatchUnit( + watchRoot{path: cleanDir}, []pollingScope{ps}, + )...) } } } @@ -2044,6 +2124,50 @@ func watchPollingObligations( return obligations } +func pollingScopesForWatchUnit( + root watchRoot, scopes []pollingScope, +) []pollingScope { + for i := range scopes { + if physical, ok := boundedWatchUnitPath(root, scopes[i]); ok { + scopes[i].Root = physical + } + } + return scopes +} + +func boundedWatchUnitPath(root watchRoot, scope pollingScope) (string, bool) { + if scope.Agent == "" { + return "", false + } + factory, ok := parser.ProviderFactoryByType(scope.Agent) + if !ok || factory.Capabilities().Source.BoundedCoverage != parser.CapabilitySupported { + return "", false + } + if root.recursive { + return filepath.Clean(root.path), true + } + provider := factory.NewProvider(parser.ProviderConfig{Roots: []string{scope.Root}}) + plan, err := provider.WatchPlan(context.Background()) + if err != nil { + return "", false + } + for _, watchRoot := range plan.Roots { + if watchRoot.Recursive || filepath.Clean(watchRoot.Path) != filepath.Clean(root.path) { + continue + } + for _, include := range watchRoot.IncludeGlobs { + path := filepath.Clean(filepath.Join(watchRoot.Path, include)) + for _, suffix := range []string{"-wal", "-shm"} { + if before, ok0 := strings.CutSuffix(path, suffix); ok0 { + path = before + } + } + return path, true + } + } + return "", false +} + // 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 @@ -2095,6 +2219,7 @@ func registerWatcherUnavailableObligations( probeGated := make(map[string]struct{}) for _, ob := range obligations { if ob.Probe != "" { + probeGated[filepath.Clean(ob.Probe)] = struct{}{} for _, scope := range ob.Scopes { probeGated[scope.Root] = struct{}{} } @@ -2129,12 +2254,18 @@ func symlinkPollingObligations( for symRoot, gatedScopes := range symlinkGatedDirs { scopes := make([]sync.PollingScope, 0, len(gatedScopes)) for _, scope := range gatedScopes { - ps := sync.PollingScope{ + pollScope := pollingScope{Agent: scope.agent, Root: scope.syncDir} + if physical, ok := boundedWatchUnitPath( + watchRoot{path: symRoot, recursive: true}, pollScope, + ); ok { + pollScope.Root = physical + } + syncScope := sync.PollingScope{ Agent: string(scope.agent), - Root: filepath.Clean(scope.syncDir), + Root: filepath.Clean(pollScope.Root), } - if !slices.Contains(scopes, ps) { - scopes = append(scopes, ps) + if !slices.Contains(scopes, syncScope) { + scopes = append(scopes, syncScope) } } slices.SortFunc(scopes, func(a, b sync.PollingScope) int { @@ -2201,32 +2332,73 @@ type watchSyncer interface { ReconcileWatchRootsAfterLostEvents(context.Context, []string, bool) error } +type watchSyncerExcludingAgents interface { + ReconcileWatchRootsExcludingAgents( + context.Context, []string, []parser.AgentType, bool, + ) error +} + +type groupedWatchSyncer interface { + ReconcileProviderRootsGrouped(context.Context, []sync.ProviderRootsGroup) error +} + type watchReconciliationError struct { cause error retry sync.WatchBatch } +type WatchRetryError struct { + cause error + batch sync.WatchBatch +} + +func (e *WatchRetryError) Error() string { return e.cause.Error() } +func (e *WatchRetryError) Unwrap() error { return e.cause } +func (e *WatchRetryError) WatchRetryBatch() sync.WatchBatch { + return sync.CanonicalizeWatchBatch(e.batch) +} + func newWatchReconciliationError( cause error, roots []string, full, lostEvents bool, + groupSets ...[]sync.ProviderRootsGroup, ) error { + var groups []sync.ProviderRootsGroup + if len(groupSets) > 0 { + for _, group := range groupSets[0] { + groups = append(groups, sync.ProviderRootsGroup{ + Agent: group.Agent, Roots: append([]string(nil), group.Roots...), + }) + } + } var scoped interface{ ReconciliationRetryRoots() []string } if errors.As(cause, &scoped) { if failedRoots := deduplicateStrings(scoped.ReconciliationRetryRoots()); len(failedRoots) > 0 { + if full { + return &watchReconciliationError{ + cause: cause, + retry: sync.CanonicalizeWatchBatch(sync.WatchBatch{ + FullSync: true, LostEvents: lostEvents, + }), + } + } + mergedRoots := append([]string(nil), roots...) + mergedRoots = append(mergedRoots, failedRoots...) return &watchReconciliationError{ cause: cause, - retry: sync.WatchBatch{ - ReconcileRoots: failedRoots, - LostEvents: lostEvents, - }, + retry: sync.CanonicalizeWatchBatch(sync.WatchBatch{ + ReconcileRoots: deduplicateStrings(mergedRoots), + ReconcileGroups: groups, + LostEvents: lostEvents, + }), } } } - retry := sync.WatchBatch{FullSync: full} + retry := sync.WatchBatch{FullSync: full, ReconcileGroups: groups} retry.LostEvents = lostEvents if !full { retry.ReconcileRoots = append([]string(nil), roots...) } - return &watchReconciliationError{cause: cause, retry: retry} + return &watchReconciliationError{cause: cause, retry: sync.CanonicalizeWatchBatch(retry)} } // gapReconciliationRetryBatch classifies a failed worker-to-watcher gap @@ -2252,6 +2424,10 @@ func (e *watchReconciliationError) WatchRetryBatch() sync.WatchBatch { retry := e.retry retry.Paths = append([]string(nil), retry.Paths...) retry.ReconcileRoots = append([]string(nil), retry.ReconcileRoots...) + retry.ReconcileGroups = append([]sync.ProviderRootsGroup(nil), retry.ReconcileGroups...) + for i := range retry.ReconcileGroups { + retry.ReconcileGroups[i].Roots = append([]string(nil), retry.ReconcileGroups[i].Roots...) + } return retry } @@ -2268,9 +2444,12 @@ func syncWatchBatch( batch sync.WatchBatch, recoveryScope func() watchRecoveryScope, ) error { + batch = sync.CanonicalizeWatchBatch(batch) paths := append([]string(nil), batch.Paths...) full := batch.FullSync reconcileRoots := append([]string(nil), batch.ReconcileRoots...) + reconcileGroups := append([]sync.ProviderRootsGroup(nil), batch.ReconcileGroups...) + groupedAgents := providerGroupAgents(reconcileGroups) lostEvents := batch.LostEvents type renameOwner struct { path string @@ -2349,9 +2528,14 @@ func syncWatchBatch( } } } + if full { + reconcileRoots = nil + reconcileGroups = nil + groupedAgents = nil + } if len(paths) > 0 { if err := engine.SyncPathsContext(ctx, paths); err != nil { - retry := sync.WatchBatch{FullSync: full, LostEvents: lostEvents} + retry := sync.WatchBatch{FullSync: full, LostEvents: lostEvents, ReconcileGroups: reconcileGroups} if !full { retry.Paths = append([]string(nil), paths...) retry.ReconcileRoots = deduplicateStrings(reconcileRoots) @@ -2362,6 +2546,34 @@ func syncWatchBatch( } } } + if len(reconcileGroups) > 0 { + grouped, ok := engine.(groupedWatchSyncer) + if !ok { + return fmt.Errorf("watch engine cannot preserve provider retry groups") + } + if err := grouped.ReconcileProviderRootsGrouped(ctx, reconcileGroups); err != nil { + return &watchReconciliationError{ + cause: err, + retry: sync.WatchBatch{ + FullSync: full, ReconcileRoots: deduplicateStrings(reconcileRoots), + ReconcileGroups: reconcileGroups, LostEvents: lostEvents, + }, + } + } + } + reconcileGeneric := func(roots []string) error { + if len(groupedAgents) > 0 { + if scoped, ok := engine.(watchSyncerExcludingAgents); ok { + return scoped.ReconcileWatchRootsExcludingAgents( + ctx, roots, groupedAgents, lostEvents, + ) + } + } + if lostEvents { + return engine.ReconcileWatchRootsAfterLostEvents(ctx, roots, false) + } + return engine.ReconcileWatchRoots(ctx, roots, false) + } if full { // Scope the recovery to the currently available roots, exactly like // the startup gap reconciliation and the archive audit. An engine-side @@ -2374,32 +2586,39 @@ func syncWatchBatch( if len(fullRoots) == 0 { return nil } - var err error - if lostEvents { - err = engine.ReconcileWatchRootsAfterLostEvents(ctx, fullRoots, false) - } else { - err = engine.ReconcileWatchRoots(ctx, fullRoots, false) - } + err := reconcileGeneric(fullRoots) if err != nil { - return newWatchReconciliationError(err, nil, true, lostEvents) + return newWatchReconciliationError(err, nil, true, lostEvents, reconcileGroups) } return nil } roots := deduplicateStrings(reconcileRoots) if len(roots) > 0 { - var err error - if lostEvents { - err = engine.ReconcileWatchRootsAfterLostEvents(ctx, roots, false) - } else { - err = engine.ReconcileWatchRoots(ctx, roots, false) - } + err := reconcileGeneric(roots) if err != nil { - return newWatchReconciliationError(err, roots, false, lostEvents) + return newWatchReconciliationError(err, roots, false, lostEvents, reconcileGroups) } } return nil } +func providerGroupAgents(groups []sync.ProviderRootsGroup) []parser.AgentType { + seen := make(map[parser.AgentType]struct{}, len(groups)) + for _, group := range groups { + if group.Agent != "" { + seen[group.Agent] = struct{}{} + } + } + agents := make([]parser.AgentType, 0, len(seen)) + for agent := range seen { + agents = append(agents, agent) + } + slices.SortFunc(agents, func(a, b parser.AgentType) int { + return strings.Compare(string(a), string(b)) + }) + return agents +} + func removeString(values []string, remove string) []string { return slices.DeleteFunc(values, func(value string) bool { return value == remove }) } @@ -2637,7 +2856,7 @@ func collectProviderWatchRoots( _, err := os.Stat(root) exists := err == nil addRoot(dir, root, providerRoot.Recursive, exists) - if exists { + if exists || providerRoot.Optional { continue } missingRoots = append(missingRoots, root) @@ -2899,6 +3118,83 @@ func runArchiveAudit( return err } +func runBoundedCoverageAudit( + ctx context.Context, + recoveryCtx context.Context, + cfg config.Config, + engine *sync.Engine, + database *db.DB, + lock *writeOwnerLock, + emitter sync.Emitter, + binding sync.BoundedCoverageBinding, + reason string, +) error { + log.Printf("bounded coverage scoped audit %s: %s", binding.Key, reason) + operationCtx, cancel := context.WithCancel(ctx) + defer cancel() + request, err := json.Marshal(struct { + Binding sync.BoundedCoverageBinding `json:"binding"` + Reason string `json:"reason"` + }{Binding: binding, Reason: reason}) + if err != nil { + return err + } + mode := "audit-scoped-v2|" + base64.RawURLEncoding.EncodeToString(request) + result, err := runWorkerWritePass( + operationCtx, recoveryCtx, cfg, engine, database, lock, + mode, nil, + ) + if (result.Synced > 0 || result.Tombstoned > 0) && emitter != nil { + emitter.Emit("sessions") + } + return err +} + +func runBoundedCoverageLeaseAudit( + ctx context.Context, + recoveryCtx context.Context, + cfg config.Config, + engine *sync.Engine, + database *db.DB, + lock *writeOwnerLock, + emitter sync.Emitter, + lease *sync.BoundedCoverageLease, + reason string, +) error { + if reason == "" { + return errors.New("invalid bounded coverage lease audit request") + } + if err := sync.ValidateBoundedCoverageLeaseIdentity(lease); err != nil { + return err + } + request, err := json.Marshal(struct { + Lease *sync.BoundedCoverageLease `json:"lease"` + Reason string `json:"reason"` + }{Lease: lease, Reason: reason}) + if err != nil { + return err + } + mode := "audit-scoped-v3|" + base64.RawURLEncoding.EncodeToString(request) + result, err := runWorkerWritePass(ctx, recoveryCtx, cfg, engine, database, lock, mode, nil) + if err == nil && (!result.BoundedCoverageRepairAccepted || result.BoundedCoverageLease == nil || + sync.ValidateBoundedCoverageLeaseIdentity(result.BoundedCoverageLease) != nil || + result.BoundedCoverageLease.Binding.Agent != lease.Binding.Agent || + filepath.Clean(result.BoundedCoverageLease.Binding.PhysicalDBPath) != filepath.Clean(lease.Binding.PhysicalDBPath) || + filepath.Clean(result.BoundedCoverageLease.Binding.Scope) != filepath.Clean(lease.Binding.Scope) || + result.BoundedCoverageLease.Binding.Generation != lease.Binding.Generation || + result.BoundedCoverageLease.Provider != lease.Provider || + filepath.Clean(result.BoundedCoverageLease.PhysicalDBPath) != filepath.Clean(lease.PhysicalDBPath) || + result.BoundedCoverageLease.Generation != lease.Generation || + result.BoundedCoverageLease.FileIdentity != lease.FileIdentity || + filepath.Clean(result.BoundedCoverageLease.ExactProviderScope) != filepath.Clean(lease.ExactProviderScope)) { + err = errors.New("bounded coverage worker returned a stale or incomplete repair result") + } + if (result.Synced > 0 || result.Tombstoned > 0) && emitter != nil { + emitter.Emit("sessions") + } + return err +} + // scheduledSyncEngine is the reconciliation surface the scheduled pass needs. // Native-watched providers already get event-driven sync plus degraded-coverage // polling, so the scheduled pass only reconciles the opted-in providers. diff --git a/cmd/agentsview/main_test.go b/cmd/agentsview/main_test.go index d7b8b5733..e08e1bc2b 100644 --- a/cmd/agentsview/main_test.go +++ b/cmd/agentsview/main_test.go @@ -33,6 +33,113 @@ import ( "go.kenn.io/agentsview/internal/testjsonl" ) +type groupedRootDispatchRecorder struct { + groups []agentsync.ProviderRootsGroup + generic [][]string + excluded [][]parser.AgentType + groupErr error + genericErr error +} + +func (r *groupedRootDispatchRecorder) SyncPathsContext(context.Context, []string) error { + return nil +} + +func (r *groupedRootDispatchRecorder) HasActiveSessionSourceBelow(string, string) (bool, error) { + return false, nil +} + +func (r *groupedRootDispatchRecorder) ReconciliationRootsForAgent(string) []string { + return nil +} + +func (r *groupedRootDispatchRecorder) ReconcileWatchRoots( + _ context.Context, roots []string, _ bool, +) error { + r.generic = append(r.generic, append([]string(nil), roots...)) + return nil +} + +func (r *groupedRootDispatchRecorder) ReconcileWatchRootsAfterLostEvents( + ctx context.Context, roots []string, lost bool, +) error { + return r.ReconcileWatchRoots(ctx, roots, lost) +} + +func (r *groupedRootDispatchRecorder) ReconcileProviderRootsGrouped( + _ context.Context, groups []agentsync.ProviderRootsGroup, +) error { + r.groups = append(r.groups, groups...) + return r.groupErr +} + +func (r *groupedRootDispatchRecorder) ReconcileWatchRootsExcludingAgents( + _ context.Context, roots []string, excluded []parser.AgentType, _ bool, +) error { + r.generic = append(r.generic, append([]string(nil), roots...)) + r.excluded = append(r.excluded, append([]parser.AgentType(nil), excluded...)) + return r.genericErr +} + +func TestSyncWatchBatchKeepsSharedRootsForOtherProviders(t *testing.T) { + recorder := new(groupedRootDispatchRecorder) + sharedRoot := filepath.Join(t.TempDir(), "shared") + groupRoot := filepath.Join(sharedRoot, "opencode") + err := syncWatchBatch(t.Context(), recorder, agentsync.WatchBatch{ + ReconcileRoots: []string{sharedRoot}, + ReconcileGroups: []agentsync.ProviderRootsGroup{{ + Agent: parser.AgentOpenCode, Roots: []string{groupRoot}, + }}, + }, func() watchRecoveryScope { + return watchRecoveryScope{} + }) + require.NoError(t, err) + require.Len(t, recorder.groups, 1) + require.Equal(t, []string{sharedRoot}, recorder.generic[0]) + require.Equal(t, []parser.AgentType{parser.AgentOpenCode}, recorder.excluded[0]) +} + +func TestSyncWatchBatchRetriesGroupedAndGenericScopesTogether(t *testing.T) { + root := filepath.Join(t.TempDir(), "shared") + group := agentsync.ProviderRootsGroup{Agent: parser.AgentOpenCode, Roots: []string{root}} + + t.Run("grouped failure retains both scopes", func(t *testing.T) { + recorder := &groupedRootDispatchRecorder{groupErr: errors.New("grouped failed")} + err := syncWatchBatch(t.Context(), recorder, agentsync.WatchBatch{ + ReconcileRoots: []string{root}, ReconcileGroups: []agentsync.ProviderRootsGroup{group}, + }, staticFullRoots()) + retry := requireWatchRetryBatch(t, err) + assert.Equal(t, []string{root}, retry.ReconcileRoots) + assert.Equal(t, []agentsync.ProviderRootsGroup{group}, retry.ReconcileGroups) + }) + + t.Run("generic failure retains both scopes", func(t *testing.T) { + recorder := &groupedRootDispatchRecorder{genericErr: errors.New("generic failed")} + err := syncWatchBatch(t.Context(), recorder, agentsync.WatchBatch{ + ReconcileRoots: []string{root}, ReconcileGroups: []agentsync.ProviderRootsGroup{group}, + }, staticFullRoots()) + retry := requireWatchRetryBatch(t, err) + assert.Equal(t, []string{root}, retry.ReconcileRoots) + assert.Equal(t, []agentsync.ProviderRootsGroup{group}, retry.ReconcileGroups) + }) +} + +func TestSyncWatchBatchLateFullSyncPromotionDropsProviderGroups(t *testing.T) { + recorder := new(groupedRootDispatchRecorder) + root := filepath.Join(t.TempDir(), "shared") + err := syncWatchBatch(t.Context(), recorder, agentsync.WatchBatch{ + ReconcileGroups: []agentsync.ProviderRootsGroup{{ + Agent: parser.AgentOpenCode, Roots: []string{root}, + }}, + Renames: []agentsync.WatchRename{{ + Path: filepath.Join(root, "renamed"), Root: root, ItemType: agentsync.ItemIsDir, + }}, + }, staticFullRoots(root)) + require.NoError(t, err) + assert.Empty(t, recorder.groups) + assert.Equal(t, [][]string{{root}}, recorder.generic) +} + func TestRuntimeWarningHelper(t *testing.T) { logOutput := captureLogOutput(t) var visible bytes.Buffer @@ -1545,8 +1652,9 @@ func TestOpenCodeFormatMissingRootsUseNativeLifecycleWithoutPolling(t *testing.T }} roots, unwatched, _, persistentDirAgents := collectWatchRoots(cfg) - require.Len(t, roots, rootCount) - results := make([]agentsync.RecursiveWatchResult, rootCount) + require.Len(t, roots, rootCount*2, + "bounded database and recursive storage roots remain separate") + results := make([]agentsync.RecursiveWatchResult, len(roots)) for i := range results { results[i] = agentsync.RecursiveWatchResult{ Watched: 1, @@ -2347,6 +2455,24 @@ func (e scopedReconciliationError) ReconciliationRetryRoots() []string { return append([]string(nil), e.roots...) } +func TestWatchRetryErrorRetainsProviderGroups(t *testing.T) { + group := agentsync.ProviderRootsGroup{Agent: parser.AgentOpenCode, Roots: []string{"/provider/root"}} + err := &WatchRetryError{cause: errors.New("admission failed"), batch: agentsync.WatchBatch{ + Paths: []string{"/provider/opencode.db"}, + Renames: []agentsync.WatchRename{{Path: "/provider/opencode.db", ItemType: agentsync.ItemIsFile}}, + ReconcileGroups: []agentsync.ProviderRootsGroup{group}, LostEvents: true, + }} + retry := err.WatchRetryBatch() + assert.Equal(t, []agentsync.ProviderRootsGroup{group}, retry.ReconcileGroups) + assert.Equal(t, []string{"/provider/opencode.db"}, retry.Paths) + assert.Len(t, retry.Renames, 1) + assert.True(t, retry.LostEvents) + + retry.ReconcileGroups[0].Roots[0] = "/mutated" + assert.Equal(t, "/provider/root", err.batch.ReconcileGroups[0].Roots[0], + "retry snapshots must not let a later accumulation mutate provider-owned work") +} + // staticFullRoots stubs syncWatchBatch's probed recovery scope with a fixed // available root list; no arguments means no scope is physically available. func staticFullRoots(roots ...string) func() watchRecoveryScope { @@ -2658,10 +2784,8 @@ func TestSyncWatchBatch(t *testing.T) { }, staticFullRoots(probedRoot)) assert.ErrorContains(t, err, reconcileErr.Error()) - assert.Equal(t, agentsync.WatchBatch{ - ReconcileRoots: []string{failedRoot}, - LostEvents: true, - }, requireWatchRetryBatch(t, err)) + assert.Equal(t, agentsync.WatchBatch{FullSync: true, LostEvents: true}, + requireWatchRetryBatch(t, err)) }) t.Run("full recovery failure without scope retries the full batch", func(t *testing.T) { diff --git a/cmd/agentsview/poll_coordinator_scope_test.go b/cmd/agentsview/poll_coordinator_scope_test.go index 12de78f61..e8d66c5c1 100644 --- a/cmd/agentsview/poll_coordinator_scope_test.go +++ b/cmd/agentsview/poll_coordinator_scope_test.go @@ -145,6 +145,266 @@ func TestUnwatchedPollIssuesOneGroupedReconcilePerPass(t *testing.T) { } } +func TestUnwatchedPollCoverageFilteringKeepsUncoveredUnits(t *testing.T) { + coordinator := &sharedUnwatchedPollCoordinator{ + coverageState: map[string]*boundedCoverageState{ + "opencode-db": { + binding: agentsync.BoundedCoverageBinding{ + Key: "opencode-db", Agent: parser.AgentOpenCode, + DBPath: filepath.Join("/data", "opencode.db"), + }, nativeAdmitted: true, + }, + "poll-owned": { + binding: agentsync.BoundedCoverageBinding{ + Key: "poll-owned", Agent: parser.AgentOpenCode, + DBPath: filepath.Join("/data", "poll.db"), + }, pollOwned: true, + }, + }, + } + groups := map[parser.AgentType][]string{ + "": {filepath.Join("/data")}, + parser.AgentOpenCode: {filepath.Join("/data")}, + parser.AgentClaude: {filepath.Join("/data")}, + } + filtered := coordinator.excludeAdmittedCoverageScopes(groups) + assert.Contains(t, filtered[""], filepath.Join("/data"), + "an empty-agent pass must retain providers and units not covered by one binding") + assert.Contains(t, filtered[parser.AgentOpenCode], filepath.Join("/data"), + "a provider root must remain when only a child DB unit is admitted") + assert.Contains(t, filtered[parser.AgentClaude], filepath.Join("/data"), + "an unrelated provider must remain pollable") + + filtered = coordinator.excludeAdmittedCoverageScopes(map[parser.AgentType][]string{ + parser.AgentOpenCode: {filepath.Join("/data", "poll.db")}, + }) + assert.Empty(t, filtered[parser.AgentOpenCode], + "only the exact admitted physical unit may be excluded") +} + +type coverageCoordinatorFixture struct { + bindings []agentsync.BoundedCoverageBinding + checkpoint parser.OpenCodeCoverageCheckpoint +} + +type concurrentAdmissionCoverageFixture struct { + coverageCoordinatorFixture + mu sync.Mutex + calls int + started chan struct{} + release chan struct{} +} + +func (f *concurrentAdmissionCoverageFixture) InitializeBoundedCoverage( + context.Context, agentsync.BoundedCoverageBinding, +) (parser.OpenCodeCoverageCheckpoint, error) { + f.mu.Lock() + f.calls++ + if f.calls == 1 { + close(f.started) + } + f.mu.Unlock() + <-f.release + return parser.OpenCodeCoverageCheckpoint{Initialized: true, SchemaVersion: 7}, nil +} + +func (f *coverageCoordinatorFixture) BoundedCoverageBindings( + context.Context, []agentsync.BoundedCoverageRoot, +) ([]agentsync.BoundedCoverageBinding, error) { + return append([]agentsync.BoundedCoverageBinding(nil), f.bindings...), nil +} + +func (f *coverageCoordinatorFixture) BoundedCoverageBindingsForPaths( + context.Context, []string, +) ([]agentsync.BoundedCoverageBinding, []string, error) { + return append([]agentsync.BoundedCoverageBinding(nil), f.bindings...), nil, nil +} + +func (f *coverageCoordinatorFixture) DrainBoundedCoverage( + context.Context, agentsync.BoundedCoverageBinding, + parser.OpenCodeCoverageCheckpoint, +) (parser.OpenCodeFeedResult, []parser.SourceRef, error) { + return parser.OpenCodeFeedResult{Next: f.checkpoint}, nil, nil +} + +func (f *coverageCoordinatorFixture) ApplyBoundedCoverageSources( + context.Context, []parser.SourceRef, +) (agentsync.SyncStats, error) { + return agentsync.SyncStats{}, nil +} + +func (f *coverageCoordinatorFixture) InitializeBoundedCoverage( + context.Context, agentsync.BoundedCoverageBinding, +) (parser.OpenCodeCoverageCheckpoint, error) { + return parser.OpenCodeCoverageCheckpoint{Initialized: true, SchemaVersion: 7}, nil +} + +func TestBoundedCoverageAdmissionInstallsRowZeroLease(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "opencode.db") + require.NoError(t, os.WriteFile(dbPath, []byte("journal"), 0o600)) + binding := agentsync.BoundedCoverageBinding{Key: "db", DBPath: dbPath, PhysicalDBPath: dbPath, Scope: filepath.Dir(dbPath)} + coordinator := &sharedUnwatchedPollCoordinator{ + coverage: &coverageCoordinatorFixture{}, coverageState: make(map[string]*boundedCoverageState), + } + admitted, err := coordinator.AdmitBoundedCoverage(t.Context(), []agentsync.BoundedCoverageBinding{binding}, false) + require.NoError(t, err) + require.Len(t, admitted, 1) + state := coordinator.coverageState[binding.Key] + require.NotNil(t, state) + assert.True(t, state.checkpoint.Initialized) + assert.Empty(t, state.checkpoint.Anchors) + assert.True(t, state.pendingWake) + assert.Equal(t, uint64(1), state.generation) +} + +func TestBoundedCoverageAdmissionRetriesBindingAfterCoveragePass(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "opencode.db") + require.NoError(t, os.WriteFile(dbPath, []byte("journal"), 0o600)) + binding := agentsync.BoundedCoverageBinding{ + Key: "db", DBPath: dbPath, PhysicalDBPath: dbPath, + Scope: filepath.Dir(dbPath), + } + coordinator := &sharedUnwatchedPollCoordinator{ + coverage: &coverageCoordinatorFixture{}, + coverageState: make(map[string]*boundedCoverageState), + } + passDone := make(chan struct{}) + coordinator.coverageMu.Lock() + coordinator.coveragePassRunning = true + coordinator.coveragePassDone = passDone + coordinator.coverageMu.Unlock() + + type admissionResult struct { + admitted []agentsync.BoundedCoverageBinding + err error + } + resultCh := make(chan admissionResult, 1) + go func() { + admitted, err := coordinator.AdmitBoundedCoverage( + context.Background(), []agentsync.BoundedCoverageBinding{binding}, false, + ) + resultCh <- admissionResult{admitted: admitted, err: err} + }() + select { + case result := <-resultCh: + t.Fatalf("admission skipped the binding during the active pass: %+v", result) + case <-time.After(25 * time.Millisecond): + } + + coordinator.coverageMu.Lock() + coordinator.coveragePassRunning = false + close(passDone) + coordinator.coveragePassDone = nil + coordinator.coverageMu.Unlock() + select { + case result := <-resultCh: + require.NoError(t, result.err) + require.Len(t, result.admitted, 1) + case <-time.After(time.Second): + t.Fatal("admission did not retry the binding after the coverage pass") + } +} + +func TestBoundedCoverageAdmissionSerializesConcurrentReplacements(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "opencode.db") + require.NoError(t, os.WriteFile(dbPath, []byte("journal"), 0o600)) + binding := agentsync.BoundedCoverageBinding{ + Key: "db", DBPath: dbPath, PhysicalDBPath: dbPath, + Scope: filepath.Dir(dbPath), + } + fixture := &concurrentAdmissionCoverageFixture{ + started: make(chan struct{}), release: make(chan struct{}), + } + coordinator := &sharedUnwatchedPollCoordinator{ + coverage: fixture, coverageState: make(map[string]*boundedCoverageState), + } + results := make(chan error, 2) + for range 2 { + go func() { + _, err := coordinator.AdmitBoundedCoverage( + context.Background(), []agentsync.BoundedCoverageBinding{binding}, false, + ) + results <- err + }() + } + select { + case <-fixture.started: + case <-time.After(time.Second): + t.Fatal("concurrent admission did not start") + } + select { + case <-time.After(25 * time.Millisecond): + case <-results: + t.Fatal("the first admission completed before its release") + } + close(fixture.release) + for range 2 { + require.NoError(t, <-results) + } + fixture.mu.Lock() + calls := fixture.calls + fixture.mu.Unlock() + assert.Equal(t, 1, calls, + "one physical lease must be admitted when callbacks overlap") +} + +func TestBoundedCoverageSameKeyReplacementKeepsNewState(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "opencode.db") + oldPath := filepath.Join(dir, "opencode.old.db") + require.NoError(t, os.WriteFile(dbPath, []byte("old"), 0o600)) + oldInfo, err := os.Stat(dbPath) + require.NoError(t, err) + require.NoError(t, os.Rename(dbPath, oldPath)) + require.NoError(t, os.WriteFile(dbPath, []byte("new"), 0o600)) + newInfo, err := os.Stat(dbPath) + require.NoError(t, err) + binding := agentsync.BoundedCoverageBinding{ + Key: "db", DBPath: dbPath, PhysicalDBPath: dbPath, Scope: dir, + } + coordinator := &sharedUnwatchedPollCoordinator{ + coverage: &coverageCoordinatorFixture{}, + coverageState: map[string]*boundedCoverageState{ + binding.Key: {binding: binding, dbFile: oldInfo, generation: 1, pollOwned: true}, + }, + } + _, err = coordinator.AdmitBoundedCoverage(t.Context(), []agentsync.BoundedCoverageBinding{binding}, false) + require.NoError(t, err) + state := coordinator.coverageState[binding.Key] + require.NotNil(t, state, "same-key replacement must retain the newly installed state") + assert.True(t, sameBoundedFile(state.dbFile, newInfo)) +} + +func TestOrdinaryRefreshKeepsRunningLeaseGeneration(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "opencode.db") + require.NoError(t, os.WriteFile(dbPath, []byte("journal"), 0o600)) + binding := agentsync.BoundedCoverageBinding{Key: "db", DBPath: dbPath, PhysicalDBPath: dbPath, Scope: filepath.Dir(dbPath)} + file, err := os.Stat(dbPath) + require.NoError(t, err) + coordinator := &sharedUnwatchedPollCoordinator{ + coverage: &coverageCoordinatorFixture{bindings: []agentsync.BoundedCoverageBinding{binding}}, + coverageState: map[string]*boundedCoverageState{ + binding.Key: {binding: binding, dbFile: file, generation: 9, running: true, + pollOwned: true, checkpoint: parser.OpenCodeCoverageCheckpoint{Initialized: true}}, + }, + } + require.NoError(t, coordinator.refreshBoundedCoverage(map[string]pollingObligation{ + "degraded": {Key: "degraded", Scopes: []pollingScope{{Agent: parser.AgentOpenCode, Root: filepath.Dir(dbPath)}}}, + })) + assert.Equal(t, uint64(9), coordinator.coverageState[binding.Key].generation) + assert.True(t, coordinator.coverageState[binding.Key].running) +} + +func TestStaleDrainCompletionRetainsCurrentWake(t *testing.T) { + coordinator := &sharedUnwatchedPollCoordinator{coverageState: make(map[string]*boundedCoverageState)} + coordinator.coverageState["db"] = &boundedCoverageState{generation: 2, pendingWake: true} + assert.False(t, coordinator.commitCoverageState("db", 1, func(state *boundedCoverageState) { + state.running = false + state.pendingWake = false + })) + assert.True(t, coordinator.coverageState["db"].pendingWake) +} + // TestUnwatchedPollDoesNotDragUnrelatedProvidersThroughOneProvidersGap is the // reproduction test: one provider's degraded coverage must not cause an // authoritative pass for any other provider. diff --git a/cmd/agentsview/session_list_resume_test.go b/cmd/agentsview/session_list_resume_test.go index 66cddab72..8a69235fe 100644 --- a/cmd/agentsview/session_list_resume_test.go +++ b/cmd/agentsview/session_list_resume_test.go @@ -17,8 +17,8 @@ import ( func activitySeed(id string, ago time.Duration) sessionSeed { ts := time.Now().Add(-ago).UTC().Format(time.RFC3339) return sessionSeed{id: id, project: "p", mut: func(s *db.Session) { - s.StartedAt = new(ts) - s.EndedAt = new(ts) + s.StartedAt = &ts + s.EndedAt = &ts }} } diff --git a/cmd/agentsview/sync_worker.go b/cmd/agentsview/sync_worker.go index b507dc067..dad83f6c2 100644 --- a/cmd/agentsview/sync_worker.go +++ b/cmd/agentsview/sync_worker.go @@ -2,14 +2,17 @@ package main import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" "os" + "strings" "github.com/spf13/cobra" "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/sync" ) @@ -51,7 +54,24 @@ type workerResult struct { // /sync and /resync responses keep result parity with in-process passes // (total sessions, orphan counts, warnings, anomalies). The summary // counters above remain the authoritative status inputs. - Stats *sync.SyncStats `json:"stats,omitempty"` + Stats *sync.SyncStats `json:"stats,omitempty"` + BoundedCoverageLease *sync.BoundedCoverageLease `json:"boundedCoverageLease,omitempty"` + BoundedCoverageRepairAccepted bool `json:"boundedCoverageRepairAccepted,omitempty"` +} + +var registeredScopedWorkerModePrefixes = []string{ + "audit-scoped|", + "audit-scoped-v2|", + "audit-scoped-v3|", +} + +func registeredScopedWorkerMode(mode string) bool { + for _, prefix := range registeredScopedWorkerModePrefixes { + if strings.HasPrefix(mode, prefix) { + return true + } + } + return false } // newSyncWorkerCommand registers the hidden self-exec'd worker. The daemon runs @@ -117,8 +137,8 @@ func runSyncWorkerContext( } onProgress := func(p sync.Progress) { emit(workerLine{Progress: &p}) } var err error - switch mode { - case "startup", "sync", "audit": + switch { + case mode == "startup" || mode == "sync" || mode == "audit": // All three share the sync body. Only "startup" may resync-and-swap: it // runs before the daemon opens the DB, so no live reader pins the old // inode. "sync" (live foreground pass) and "audit" (daily safety net) @@ -127,7 +147,9 @@ func runSyncWorkerContext( // under those readers; the real resync path is the resync-build flow, // which swaps and resets caches daemon-side. err = runSyncWorkerStartup(ctx, cfg, mode, emit, onProgress) - case "resync-build": + case registeredScopedWorkerMode(mode): + err = runSyncWorkerStartup(ctx, cfg, mode, emit, onProgress) + case mode == "resync-build": err = runSyncWorkerResyncBuild(ctx, cfg, mode, emit, onProgress) default: return fmt.Errorf("unknown sync-worker mode %q", mode) @@ -174,6 +196,7 @@ func runSyncWorkerStartup( } var result workerResult + var repairedLease *sync.BoundedCoverageLease switch { case database.NeedsResync(): stats := engine.ResyncAll(ctx, onProgress) @@ -185,7 +208,7 @@ func runSyncWorkerStartup( stats = engine.SyncAll(ctx, onProgress) } result = workerResultFromStats(ctx, stats) - case mode == "audit": + case mode == "audit" || strings.HasPrefix(mode, "audit-scoped|") || strings.HasPrefix(mode, "audit-scoped-v2|") || strings.HasPrefix(mode, "audit-scoped-v3|"): // The audit is the safety net for watcher deletions the daemon // missed, so it must run the authoritative reconciliation that // tombstones sessions whose sources disappeared; SyncAll never @@ -195,7 +218,55 @@ func runSyncWorkerStartup( var stats sync.SyncStats var tombstoned int var auditErr error - if auditRoots := reconcileRootPaths(cfg); len(auditRoots) > 0 { + if after, ok := strings.CutPrefix(mode, "audit-scoped-v3|"); ok { + encoded := after + payload, decodeErr := base64.RawURLEncoding.DecodeString(encoded) + var request struct { + Lease *sync.BoundedCoverageLease `json:"lease"` + Reason string `json:"reason"` + } + if decodeErr != nil { + auditErr = decodeErr + } else if unmarshalErr := json.Unmarshal(payload, &request); unmarshalErr != nil { + if unmarshalErr != nil { + auditErr = unmarshalErr + } + } else { + if request.Reason == "" { + auditErr = fmt.Errorf("invalid lease-bound scoped audit request") + } else if identityErr := sync.ValidateBoundedCoverageLeaseIdentity(request.Lease); identityErr != nil { + auditErr = identityErr + } else { + repairedLease = request.Lease + auditErr = engine.ReconcileBoundedCoverageSourceLease(ctx, request.Lease, request.Reason) + } + } + } else if after, ok := strings.CutPrefix(mode, "audit-scoped-v2|"); ok { + encoded := after + payload, decodeErr := base64.RawURLEncoding.DecodeString(encoded) + var request struct { + Binding sync.BoundedCoverageBinding `json:"binding"` + Reason string `json:"reason"` + } + if decodeErr != nil { + auditErr = decodeErr + } else if unmarshalErr := json.Unmarshal(payload, &request); unmarshalErr != nil || request.Binding.Agent == "" || request.Binding.Scope == "" || request.Binding.PhysicalDBPath == "" || request.Binding.Generation == 0 { + if unmarshalErr != nil { + auditErr = unmarshalErr + } else { + auditErr = fmt.Errorf("invalid structured scoped audit request") + } + } else { + auditErr = engine.ReconcileProviderRoots(ctx, request.Binding.Agent, []string{request.Binding.Scope}) + } + } else if after, ok := strings.CutPrefix(mode, "audit-scoped|"); ok { + parts := strings.SplitN(after, "|", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + auditErr = fmt.Errorf("invalid scoped audit request %q", mode) + } else { + auditErr = engine.ReconcileProviderRoots(ctx, parser.AgentType(parts[0]), []string{parts[1]}) + } + } else if auditRoots := reconcileRootPaths(cfg); len(auditRoots) > 0 { stats, tombstoned, auditErr = engine.ReconcileWatchRootsWithStats( ctx, auditRoots, false, ) @@ -209,6 +280,10 @@ func runSyncWorkerStartup( default: result = workerResultFromStats(ctx, engine.SyncAll(ctx, onProgress)) } + if repairedLease != nil && result.Status == "ok" { + result.BoundedCoverageLease = repairedLease + result.BoundedCoverageRepairAccepted = true + } emit(workerLine{Result: &result}) if result.Status != "ok" || !result.DiscoveryComplete { diff --git a/cmd/agentsview/unwatched_poll.go b/cmd/agentsview/unwatched_poll.go index 4959b946a..b7e1bcb2c 100644 --- a/cmd/agentsview/unwatched_poll.go +++ b/cmd/agentsview/unwatched_poll.go @@ -50,27 +50,83 @@ type pollingObligation struct { Probe string } +type boundedBindingMode uint8 + +const ( + boundedModeAdmitted boundedBindingMode = iota + boundedModeNative + boundedModePolling + boundedModeAudit + boundedModeRetired +) + +type boundedWake uint8 + +const ( + boundedWakeNone boundedWake = iota + boundedWakePending +) + +type boundedCoverageState struct { + lease *agentsync.BoundedCoverageLease + binding agentsync.BoundedCoverageBinding + checkpoint parser.OpenCodeCoverageCheckpoint + nativeAdmitted bool + pollOwned bool + dbFile os.FileInfo + generation uint64 + pendingWake bool + running bool + retry bool + auditPending bool + auditBoundary parser.OpenCodeCoverageCheckpoint + mode boundedBindingMode + wake boundedWake + frozen bool +} + +func sameBoundedFile(a, b os.FileInfo) bool { + if a == nil || b == nil { + return a == b + } + return os.SameFile(a, b) +} + type sharedUnwatchedPollCoordinator struct { - ctx context.Context - workerCtx context.Context - workerCancel context.CancelFunc - engine unwatchedPollSyncer - ticks <-chan time.Time - stopTicker func() - doWork func(func()) + ctx context.Context + workerCtx context.Context + workerCancel context.CancelFunc + engine unwatchedPollSyncer + coverage agentsync.BoundedCoverageResolver + coverageMu sync.Mutex + coverageAdmissionMu sync.Mutex + coverageState map[string]*boundedCoverageState + coverageEpoch map[string]uint64 + requestAudit func(context.Context, agentsync.BoundedCoverageBinding, string) error + requestLeaseAudit func(context.Context, *agentsync.BoundedCoverageLease, string) error + ticks <-chan time.Time + stopTicker func() + doWork func(func()) // onRootsOwned is a test observer invoked after installation and before ack. onRootsOwned func([]string) - now func() time.Time - after func(time.Duration) <-chan time.Time - add chan unwatchedPollAdd + // onBoundedCoveragePage is a test observer for bounded work counters. + onBoundedCoveragePage func(parser.OpenCodeFeedResult) + // onBoundedCoverageApply is a test observer for committed source writes. + onBoundedCoverageApply func(agentsync.SyncStats) + now func() time.Time + after func(time.Duration) <-chan time.Time + add chan unwatchedPollAdd // pollWake coalesces ticks and explicit wakes while the serialized worker runs. - pollWake chan struct{} - pollDone chan struct{} - pollMu sync.Mutex + pollWake chan struct{} + pollDone chan struct{} + coveragePassDone chan struct{} + coveragePassRunning bool + pollMu sync.Mutex // pollObligations is the latest complete snapshot owned by the // coordinator loop; each entry keeps its probe so availability is // evaluated per obligation at poll time. pollObligations []pollingObligation + ownedBindings map[string]map[string]struct{} // lastCompletion is the wall-clock time the most recent pass completed. // Zero means no prior pass; a zero value skips the cooldown on the first wake. lastCompletion time.Time @@ -102,26 +158,293 @@ func newUnwatchedPollCoordinatorWithTicks( ) *sharedUnwatchedPollCoordinator { workerCtx, workerCancel := context.WithCancel(ctx) coordinator := &sharedUnwatchedPollCoordinator{ - ctx: ctx, - workerCtx: workerCtx, - workerCancel: workerCancel, - engine: engine, - ticks: ticks, - stopTicker: stopTicker, - doWork: doWork, - now: now, - after: after, - 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, + now: now, + after: after, + add: make(chan unwatchedPollAdd), + pollWake: make(chan struct{}, 1), + pollDone: make(chan struct{}), + stop: make(chan struct{}), + done: make(chan struct{}), + onRootsOwned: onRootsOwned, + coverageState: make(map[string]*boundedCoverageState), + ownedBindings: make(map[string]map[string]struct{}), + } + if resolver, ok := engine.(agentsync.BoundedCoverageResolver); ok { + coordinator.coverage = resolver } go coordinator.run() return coordinator } +func (c *sharedUnwatchedPollCoordinator) SetBoundedCoverageAuditRequester( + request func(context.Context, agentsync.BoundedCoverageBinding, string) error, +) { + c.coverageMu.Lock() + c.requestAudit = request + c.coverageMu.Unlock() +} + +func (c *sharedUnwatchedPollCoordinator) SetBoundedCoverageLeaseAuditRequester( + request func(context.Context, *agentsync.BoundedCoverageLease, string) error, +) { + c.coverageMu.Lock() + c.requestLeaseAudit = request + c.coverageMu.Unlock() +} + +func (c *sharedUnwatchedPollCoordinator) DisableBoundedCoverage() { + c.coverageMu.Lock() + c.coverage = nil + c.coverageState = make(map[string]*boundedCoverageState) + c.coverageMu.Unlock() +} + +func (c *sharedUnwatchedPollCoordinator) WakeBoundedCoverage( + bindings []agentsync.BoundedCoverageBinding, +) { + if len(bindings) == 0 { + return + } + c.coverageMu.Lock() + for _, binding := range bindings { + state := c.coverageState[binding.Key] + current, statErr := os.Stat(binding.DBPath) + if state != nil && ((statErr == nil && state.dbFile != nil && !sameBoundedFile(state.dbFile, current)) || + (statErr != nil && errors.Is(statErr, os.ErrNotExist))) { + state.mode = boundedModeRetired + delete(c.coverageState, binding.Key) + state = nil + } + if state == nil { + state = &boundedCoverageState{binding: binding} + state.generation = c.nextCoverageGenerationLocked(boundedCoverageGenerationKey(binding)) + c.coverageState[binding.Key] = state + } + state.binding = binding + if state.dbFile == nil { + state.dbFile, _ = os.Stat(binding.DBPath) + } + state.mode = boundedModeNative + state.nativeAdmitted = true + state.wake = boundedWakePending + state.pendingWake = true + } + c.coverageMu.Unlock() + c.requestPoll() +} + +// AdmitBoundedCoverage installs a complete physical lease before the caller +// diverts ordinary ownership. New leases start at row zero; an existing lease +// keeps its committed checkpoint and generation. +func (c *sharedUnwatchedPollCoordinator) AdmitBoundedCoverage( + ctx context.Context, bindings []agentsync.BoundedCoverageBinding, native bool, +) ([]agentsync.BoundedCoverageBinding, error) { + return c.admitBoundedCoverage(ctx, bindings, native) +} + +func (c *sharedUnwatchedPollCoordinator) admitBoundedCoverage( + ctx context.Context, bindings []agentsync.BoundedCoverageBinding, native bool, +) ([]agentsync.BoundedCoverageBinding, error) { + c.coverageAdmissionMu.Lock() + defer c.coverageAdmissionMu.Unlock() + if c.coverage == nil { + return nil, nil + } + admitter, ok := c.coverage.(agentsync.BoundedCoverageAdmitter) + if !ok { + return nil, errors.New("bounded coverage resolver cannot admit a lease") + } + admitted := make([]agentsync.BoundedCoverageBinding, 0, len(bindings)) + for _, binding := range bindings { + for { + c.coverageMu.Lock() + passRunning := c.coveragePassRunning + passDone := c.coveragePassDone + c.coverageMu.Unlock() + if !passRunning { + break + } + select { + case <-ctx.Done(): + return admitted, ctx.Err() + case <-passDone: + } + } + file, err := os.Stat(binding.DBPath) + if err != nil { + return admitted, err + } + c.coverageMu.Lock() + state := c.coverageState[binding.Key] + needsLease := state == nil || state.lease == nil || state.dbFile == nil || + !sameBoundedFile(state.dbFile, file) + existingLease := (*agentsync.BoundedCoverageLease)(nil) + existingCheckpoint := parser.OpenCodeCoverageCheckpoint{} + if !needsLease { + existingLease = state.lease + existingCheckpoint = state.checkpoint + } + c.coverageMu.Unlock() + if !needsLease { + if leaseResolver, ok := c.coverage.(agentsync.BoundedCoverageLeaseResolver); ok { + if _, validateErr := leaseResolver.TransitionBoundedCoverageRequest( + ctx, existingLease, nil, existingCheckpoint, false, + ); validateErr != nil { + needsLease = true + } + } + } + c.coverageMu.Lock() + state = c.coverageState[binding.Key] + if state == nil || state.lease == nil || state.dbFile == nil || + !sameBoundedFile(state.dbFile, file) { + needsLease = true + } + generation := uint64(0) + if needsLease { + generation = c.nextCoverageGenerationLocked(boundedCoverageGenerationKey(binding)) + } + frozen := make(map[*boundedCoverageState]bool) + freeze := func(candidate *boundedCoverageState) { + if _, recorded := frozen[candidate]; !recorded { + frozen[candidate] = candidate.frozen + } + candidate.frozen = true + } + restoreFrozen := func() { + c.coverageMu.Lock() + for candidate, wasFrozen := range frozen { + candidate.frozen = wasFrozen + } + c.coverageMu.Unlock() + } + if needsLease { + if state != nil { + freeze(state) + } + } + c.coverageMu.Unlock() + var lease *agentsync.BoundedCoverageLease + admissionCheckpoint := parser.OpenCodeCoverageCheckpoint{} + if needsLease { + binding.Generation = generation + if leaseResolver, ok := c.coverage.(agentsync.BoundedCoverageLeaseResolver); ok { + lease, err = leaseResolver.AdmitBoundedCoverageLease(ctx, binding) + } else { + checkpoint, admitErr := admitter.InitializeBoundedCoverage(ctx, binding) + err = admitErr + if err == nil { + lease = &agentsync.BoundedCoverageLease{Binding: binding, Provider: binding.Agent, + PhysicalDBPath: binding.PhysicalDBPath, ExactProviderScope: binding.Scope, + Generation: binding.Generation, AdmissionCheckpoint: checkpoint, AdmissionRowZero: true} + } + } + if err != nil { + restoreFrozen() + return admitted, err + } + if lease == nil { + restoreFrozen() + return admitted, errors.New("bounded coverage admission returned a nil lease") + } + admissionCheckpoint = lease.AdmissionCheckpoint + if leaseResolver, ok := c.coverage.(agentsync.BoundedCoverageLeaseResolver); ok { + if _, err = leaseResolver.TransitionBoundedCoverageRequest( + ctx, lease, nil, lease.AdmissionCheckpoint, true, + ); err != nil { + restoreFrozen() + return admitted, err + } + } + } + c.coverageMu.Lock() + state = c.coverageState[binding.Key] + if needsLease && state != nil && state.generation > generation { + state.nativeAdmitted = state.nativeAdmitted || native + state.pollOwned = true + c.coverageMu.Unlock() + continue + } + if !needsLease && state != nil && state.dbFile != nil && sameBoundedFile(state.dbFile, file) { + state.nativeAdmitted = state.nativeAdmitted || native + state.pollOwned = true + if native { + state.mode = boundedModeNative + state.pendingWake = true + state.wake = boundedWakePending + } else if !state.nativeAdmitted { + state.mode = boundedModePolling + } + admitted = append(admitted, state.binding) + c.coverageMu.Unlock() + continue + } + if !needsLease { + if state == nil || state.lease == nil { + c.coverageMu.Unlock() + return admitted, errors.New("bounded coverage admission lost its active lease") + } + lease = state.lease + admissionCheckpoint = state.checkpoint + } + state = &boundedCoverageState{lease: lease, binding: binding, checkpoint: admissionCheckpoint, + dbFile: file, generation: generation, pendingWake: true, + wake: boundedWakePending, nativeAdmitted: native, pollOwned: true} + state.mode = boundedModePolling + if native { + state.mode = boundedModeNative + } + c.coverageState[binding.Key] = state + admitted = append(admitted, binding) + c.coverageMu.Unlock() + } + c.requestPoll() + return admitted, nil +} + +func boundedCoverageGenerationKey(binding agentsync.BoundedCoverageBinding) string { + return string(binding.Agent) + "\x00" + filepath.Clean(binding.PhysicalDBPath) + "\x00" + filepath.Clean(binding.Scope) +} + +func (c *sharedUnwatchedPollCoordinator) PrimedBoundedCoverage( + bindings []agentsync.BoundedCoverageBinding, +) []agentsync.BoundedCoverageBinding { + c.coverageMu.Lock() + defer c.coverageMu.Unlock() + primed := make([]agentsync.BoundedCoverageBinding, 0, len(bindings)) + for _, binding := range bindings { + if state := c.coverageState[binding.Key]; state != nil && + state.nativeAdmitted { + primed = append(primed, binding) + } + } + return primed +} + +func (c *sharedUnwatchedPollCoordinator) RetireBoundedCoveragePaths(paths []string) { + c.coverageMu.Lock() + defer c.coverageMu.Unlock() + for key, state := range c.coverageState { + current, err := os.Stat(state.binding.DBPath) + if err == nil && (state.dbFile == nil || os.SameFile(state.dbFile, current)) { + continue + } + for _, path := range paths { + if sameCoverageEventPathForPoll(path, state.binding.DBPath) { + delete(c.coverageState, key) + break + } + } + } +} + func (c *sharedUnwatchedPollCoordinator) AddObligation( obligation pollingObligation, ) error { @@ -179,20 +502,117 @@ func (c *sharedUnwatchedPollCoordinator) run() { case request := <-c.add: if request.remove { delete(obligations, request.obligation.Key) + delete(c.ownedBindings, request.obligation.Key) + c.coverageMu.Lock() + c.rebuildPollingOwnershipLocked() + c.coverageMu.Unlock() } else { obligations[request.obligation.Key] = request.obligation } + if !request.remove && c.coverage != nil { + roots := make([]agentsync.BoundedCoverageRoot, 0, len(request.obligation.Scopes)) + for _, scope := range request.obligation.Scopes { + roots = append(roots, agentsync.BoundedCoverageRoot{Agent: scope.Agent, Root: scope.Root}) + } + bindings, err := c.coverage.BoundedCoverageBindings(c.ctx, roots) + if err != nil { + log.Printf("bounded coverage admission: %v", err) + } else { + owned := make(map[string]struct{}, len(bindings)) + for _, binding := range bindings { + owned[binding.Key] = struct{}{} + } + if _, err := c.admitBoundedCoverage(c.ctx, bindings, false); err != nil { + log.Printf("bounded coverage lease admission: %v", err) + } + c.ownedBindings[request.obligation.Key] = owned + c.coverageMu.Lock() + c.rebuildPollingOwnershipLocked() + c.coverageMu.Unlock() + } + } c.setPollObligations(obligations) if c.onRootsOwned != nil { c.onRootsOwned(unwatchedPollObligationRoots(obligations)) } close(request.done) case <-c.ticks: + if c.coverage != nil { + if err := c.refreshBoundedCoverage(obligations); err != nil { + log.Printf("bounded coverage admission: %v", err) + } + } c.requestPoll() } } } +func (c *sharedUnwatchedPollCoordinator) refreshBoundedCoverage( + obligations map[string]pollingObligation, +) error { + refreshed := make(map[string]map[string]struct{}, len(obligations)) + allBindings := make([]agentsync.BoundedCoverageBinding, 0) + for key, obligation := range obligations { + roots := make([]agentsync.BoundedCoverageRoot, 0, len(obligation.Scopes)) + for _, scope := range obligation.Scopes { + roots = append(roots, agentsync.BoundedCoverageRoot{Agent: scope.Agent, Root: scope.Root}) + } + bindings, err := c.coverage.BoundedCoverageBindings(c.ctx, roots) + if err != nil { + c.markBoundedCoverageRetry() + return err + } + owned := make(map[string]struct{}, len(bindings)) + for _, binding := range bindings { + owned[binding.Key] = struct{}{} + allBindings = append(allBindings, binding) + } + refreshed[key] = owned + } + if _, err := c.admitBoundedCoverage(c.ctx, allBindings, false); err != nil { + c.markBoundedCoverageRetry() + return err + } + c.coverageMu.Lock() + c.ownedBindings = refreshed + c.rebuildPollingOwnershipLocked() + c.coverageMu.Unlock() + return nil +} + +func (c *sharedUnwatchedPollCoordinator) rebuildPollingOwnershipLocked() { + owned := make(map[string]struct{}) + for _, bindings := range c.ownedBindings { + for key := range bindings { + owned[key] = struct{}{} + } + } + for key, state := range c.coverageState { + _, state.pollOwned = owned[key] + if !state.pollOwned && !state.nativeAdmitted && !state.running { + delete(c.coverageState, key) + } + } + for key := range owned { + if state := c.coverageState[key]; state != nil && !state.nativeAdmitted { + state.pollOwned = true + state.mode = boundedModePolling + } + } +} + +func (c *sharedUnwatchedPollCoordinator) markBoundedCoverageRetry() { + c.coverageMu.Lock() + defer c.coverageMu.Unlock() + for _, state := range c.coverageState { + if state.lease != nil { + state.retry = true + state.pendingWake = true + state.wake = boundedWakePending + } + } +} + func (c *sharedUnwatchedPollCoordinator) setPollObligations( obligations map[string]pollingObligation, ) { @@ -255,8 +675,9 @@ func (c *sharedUnwatchedPollCoordinator) runPollWorker() { } } groups := availableUnwatchedPollScopes(c.currentPollObligations()) + groups = c.excludeAdmittedCoverageScopes(groups) totalRoots := countUniqueRoots(groups) - if totalRoots == 0 { + if totalRoots == 0 && !c.hasBoundedCoverageWork() { continue } log.Printf("polling %d unwatched root(s)", totalRoots) @@ -264,6 +685,9 @@ func (c *sharedUnwatchedPollCoordinator) runPollWorker() { if c.workerCtx.Err() != nil { return } + if err := c.pollBoundedCoverageOnce(c.workerCtx); err != nil { + log.Printf("polling bounded coverage: %v", err) + } if err := pollUnwatchedScopesOnce(c.workerCtx, c.engine, groups); err != nil { log.Printf("polling unwatched roots: %v", err) } @@ -275,6 +699,340 @@ func (c *sharedUnwatchedPollCoordinator) runPollWorker() { } } +func (c *sharedUnwatchedPollCoordinator) excludeAdmittedCoverageScopes( + groups map[parser.AgentType][]string, +) map[parser.AgentType][]string { + c.coverageMu.Lock() + bindings := make([]agentsync.BoundedCoverageBinding, 0, len(c.coverageState)) + for _, state := range c.coverageState { + if !state.pollOwned { + continue + } + bindings = append(bindings, state.binding) + } + c.coverageMu.Unlock() + if len(bindings) == 0 { + return groups + } + filtered := make(map[parser.AgentType][]string, len(groups)) + for agent, roots := range groups { + for _, root := range roots { + covered := false + for _, binding := range bindings { + if agent != "" && binding.Agent == agent && + filepath.Clean(binding.DBPath) == filepath.Clean(root) { + covered = true + break + } + } + if !covered { + filtered[agent] = append(filtered[agent], root) + } + } + } + return filtered +} + +func sameCoverageEventPathForPoll(path, dbPath string) bool { + cleanPath := filepath.Clean(path) + cleanDB := filepath.Clean(dbPath) + return cleanPath == cleanDB || cleanPath == cleanDB+"-wal" || cleanPath == cleanDB+"-shm" +} + +func (c *sharedUnwatchedPollCoordinator) nextCoverageGenerationLocked( + key string, +) uint64 { + if c.coverageEpoch == nil { + c.coverageEpoch = make(map[string]uint64) + } + c.coverageEpoch[key]++ + return c.coverageEpoch[key] +} + +func (c *sharedUnwatchedPollCoordinator) hasBoundedCoverageWork() bool { + c.coverageMu.Lock() + defer c.coverageMu.Unlock() + for _, state := range c.coverageState { + if state.pendingWake || state.auditPending || state.retry { + return true + } + } + return false +} + +func (c *sharedUnwatchedPollCoordinator) commitCoverageState( + key string, generation uint64, update func(*boundedCoverageState), +) bool { + c.coverageMu.Lock() + defer c.coverageMu.Unlock() + state := c.coverageState[key] + if state == nil || state.generation != generation { + return false + } + update(state) + return true +} + +func (c *sharedUnwatchedPollCoordinator) retireCoverageState( + key string, generation uint64, +) bool { + c.coverageMu.Lock() + defer c.coverageMu.Unlock() + state := c.coverageState[key] + if state == nil || state.generation != generation { + return false + } + delete(c.coverageState, key) + return true +} + +func checkpointBeforeBoundedAudit( + ctx context.Context, binding agentsync.BoundedCoverageBinding, + boundary parser.OpenCodeCoverageCheckpoint, +) (parser.OpenCodeCoverageCheckpoint, error) { + dbPath := binding.PhysicalDBPath + if dbPath == "" { + dbPath = binding.DBPath + } + return parser.RebaselineOpenCodeCoverageCheckpoint(ctx, dbPath, boundary) +} + +func (c *sharedUnwatchedPollCoordinator) pollBoundedCoverageOnce(ctx context.Context) error { + c.coverageMu.Lock() + if c.coveragePassRunning { + c.coverageMu.Unlock() + return nil + } + c.coveragePassRunning = true + passDone := make(chan struct{}) + c.coveragePassDone = passDone + defer func() { + c.coverageMu.Lock() + c.coveragePassRunning = false + close(passDone) + c.coveragePassDone = nil + c.coverageMu.Unlock() + }() + type workItem struct { + key string + generation uint64 + lease *agentsync.BoundedCoverageLease + binding agentsync.BoundedCoverageBinding + checkpoint parser.OpenCodeCoverageCheckpoint + dbFile os.FileInfo + auditPending bool + auditBoundary parser.OpenCodeCoverageCheckpoint + auditRebased bool + retired bool + } + states := make([]workItem, 0, len(c.coverageState)) + for _, state := range c.coverageState { + if !state.frozen && (state.nativeAdmitted || state.pollOwned) && (state.pendingWake || state.auditPending || state.retry) { + state.running = true + state.pendingWake = false + binding := state.binding + binding.Generation = state.generation + states = append(states, workItem{key: state.binding.Key, lease: state.lease, + generation: state.generation, binding: binding, + checkpoint: state.checkpoint, dbFile: state.dbFile, auditPending: state.auditPending, + auditBoundary: state.auditBoundary}) + } + } + requestAudit := c.requestAudit + requestLeaseAudit := c.requestLeaseAudit + c.coverageMu.Unlock() + for _, work := range states { + leaseResolver, hasLeaseResolver := c.coverage.(agentsync.BoundedCoverageLeaseResolver) + if work.auditPending { + var rebaseErr error + work.checkpoint, rebaseErr = checkpointBeforeBoundedAudit( + ctx, work.binding, work.auditBoundary, + ) + if rebaseErr != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.running = false + s.retry = true + s.auditPending = true + s.auditBoundary = work.auditBoundary + }) + return rebaseErr + } + work.auditRebased = true + if requestLeaseAudit != nil && work.lease != nil { + if err := requestLeaseAudit(ctx, work.lease, "bounded coverage repair"); err != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.running = false; s.retry = true }) + return err + } + } else if requestAudit == nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.running = false; s.retry = true }) + continue + } else if err := requestAudit(ctx, work.binding, "bounded coverage repair"); err != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.running = false; s.retry = true }) + return err + } + work.auditPending = false + } + more := false + for range 32 { + var result parser.OpenCodeFeedResult + var sources []parser.SourceRef + var err error + if hasLeaseResolver && work.lease != nil { + result, sources, err = leaseResolver.DrainBoundedCoverageLease(ctx, work.lease, work.checkpoint) + } else { + result, sources, err = c.coverage.DrainBoundedCoverage(ctx, work.binding, work.checkpoint) + } + if err == nil && c.onBoundedCoveragePage != nil { + c.onBoundedCoveragePage(result) + } + if err != nil { + work.auditBoundary = result.Next + if errors.Is(err, parser.ErrOpenCodeCoverageDatabaseMissing) { + work.auditPending = true + if requestLeaseAudit != nil && work.lease != nil || requestAudit != nil { + var auditErr error + if requestLeaseAudit != nil && work.lease != nil { + auditErr = requestLeaseAudit(ctx, work.lease, err.Error()) + } else { + auditErr = requestAudit(ctx, work.binding, err.Error()) + } + if auditErr != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.running = false; s.retry = true }) + return auditErr + } + if errors.Is(err, parser.ErrOpenCodeCoverageDatabaseMissing) { + c.retireCoverageState(work.key, work.generation) + work.retired = true + break + } + } + } + if errors.Is(err, agentsync.ErrBoundedCoverageUnresolved) { + // An unresolved identity is a retryable source-resolution failure, + // not structural evidence. Keep the checkpoint and wake intact. + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.running = false + s.retry = true + s.pendingWake = true + s.wake = boundedWakePending + }) + continue + } + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.running = false + s.retry = true + s.pendingWake = true + s.wake = boundedWakePending + s.auditPending = work.auditPending + s.auditBoundary = work.auditBoundary + }) + return err + } + if result.AuditRequired { + work.auditBoundary = result.Next + work.auditPending = true + if requestLeaseAudit == nil && requestAudit == nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.running = false + s.auditPending = true + s.auditBoundary = work.auditBoundary + }) + continue + } + if !work.auditRebased { + var rebaseErr error + work.checkpoint, rebaseErr = checkpointBeforeBoundedAudit( + ctx, work.binding, work.auditBoundary, + ) + if rebaseErr != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.running = false + s.retry = true + s.auditPending = true + s.auditBoundary = work.auditBoundary + }) + return rebaseErr + } + work.auditRebased = true + } + var auditErr error + if requestLeaseAudit != nil && work.lease != nil { + auditErr = requestLeaseAudit(ctx, work.lease, "structural journal evidence") + } else if requestAudit != nil { + auditErr = requestAudit(ctx, work.binding, "structural journal evidence") + } + if auditErr != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.running = false; s.retry = true; s.auditBoundary = work.auditBoundary }) + return auditErr + } + work.auditPending = false + continue + } + if len(sources) > 0 { + var stats agentsync.SyncStats + nextCheckpoint := result.Next + if !result.More { + nextCheckpoint.PendingIDs = append([]string(nil), result.PendingIDs...) + } + if hasLeaseResolver && work.lease != nil { + transition, transitionErr := leaseResolver.TransitionBoundedCoverageRequest( + ctx, work.lease, sources, nextCheckpoint, false, + ) + stats, err = transition.Stats, transitionErr + if err == nil { + work.checkpoint = transition.Checkpoint + } + } else { + stats, err = c.coverage.ApplyBoundedCoverageSources(ctx, sources) + } + if err != nil { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.running = false; s.retry = true }) + return err + } + if c.onBoundedCoverageApply != nil { + c.onBoundedCoverageApply(stats) + } + if !hasLeaseResolver || work.lease == nil { + work.checkpoint = nextCheckpoint + } + } + work.checkpoint = result.Next + if !result.More { + work.checkpoint.PendingIDs = append([]string(nil), result.PendingIDs...) + } + if !result.More { + break + } + more = true + } + if work.retired { + continue + } + currentFile, statErr := os.Stat(work.binding.DBPath) + if statErr != nil || !sameBoundedFile(work.dbFile, currentFile) { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.running = false + s.retry = true + s.pendingWake = true + s.wake = boundedWakePending + }) + continue + } + if more { + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { s.pendingWake = true }) + c.requestPoll() + } + c.commitCoverageState(work.key, work.generation, func(s *boundedCoverageState) { + s.checkpoint = work.checkpoint + s.auditBoundary = work.auditBoundary + s.auditPending = work.auditPending + s.retry = false + s.running = false + }) + } + return nil +} + // availableUnwatchedPollScopes selects the reconciliation scopes whose // obligations are currently pollable, grouped by agent. An obligation with a // probe path is gated on that physical path: while it is missing, its scopes diff --git a/cmd/agentsview/unwatched_poll_test.go b/cmd/agentsview/unwatched_poll_test.go index eb3b30b1f..5466a9b58 100644 --- a/cmd/agentsview/unwatched_poll_test.go +++ b/cmd/agentsview/unwatched_poll_test.go @@ -20,6 +20,91 @@ import ( agentsync "go.kenn.io/agentsview/internal/sync" ) +type failingBoundedCoverageAdmission struct{} + +func (failingBoundedCoverageAdmission) BoundedCoverageBindings( + context.Context, []agentsync.BoundedCoverageRoot, +) ([]agentsync.BoundedCoverageBinding, error) { + return nil, nil +} + +func (failingBoundedCoverageAdmission) BoundedCoverageBindingsForPaths( + context.Context, []string, +) ([]agentsync.BoundedCoverageBinding, []string, error) { + return nil, nil, nil +} + +func (failingBoundedCoverageAdmission) DrainBoundedCoverage( + context.Context, agentsync.BoundedCoverageBinding, parser.OpenCodeCoverageCheckpoint, +) (parser.OpenCodeFeedResult, []parser.SourceRef, error) { + return parser.OpenCodeFeedResult{}, nil, nil +} + +func (failingBoundedCoverageAdmission) ApplyBoundedCoverageSources( + context.Context, []parser.SourceRef, +) (agentsync.SyncStats, error) { + return agentsync.SyncStats{}, nil +} + +func (failingBoundedCoverageAdmission) InitializeBoundedCoverage( + context.Context, agentsync.BoundedCoverageBinding, +) (parser.OpenCodeCoverageCheckpoint, error) { + return parser.OpenCodeCoverageCheckpoint{}, errors.New("admission failed") +} + +func TestFailedBoundedCoverageAdmissionRestoresFrozenState(t *testing.T) { + oldPath := filepath.Join(t.TempDir(), "old.db") + newPath := filepath.Join(t.TempDir(), "new.db") + require.NoError(t, os.WriteFile(oldPath, []byte("old"), 0o600)) + require.NoError(t, os.WriteFile(newPath, []byte("new"), 0o600)) + oldInfo, err := os.Stat(oldPath) + require.NoError(t, err) + binding := agentsync.BoundedCoverageBinding{ + Key: "opencode:new", Agent: parser.AgentOpenCode, + DBPath: newPath, PhysicalDBPath: newPath, Scope: filepath.Dir(newPath), + } + old := &boundedCoverageState{ + binding: binding, dbFile: oldInfo, nativeAdmitted: true, pollOwned: true, + } + coordinator := &sharedUnwatchedPollCoordinator{ + coverage: failingBoundedCoverageAdmission{}, + coverageState: map[string]*boundedCoverageState{binding.Key: old}, + } + _, err = coordinator.admitBoundedCoverage(t.Context(), []agentsync.BoundedCoverageBinding{binding}, true) + require.Error(t, err) + assert.False(t, old.frozen, "failed replacement must return the previous state to polling") +} + +func TestPollingOwnershipRebuildPreservesOverlapAndNativeAdmission(t *testing.T) { + coordinator := &sharedUnwatchedPollCoordinator{ + coverageState: make(map[string]*boundedCoverageState), + ownedBindings: map[string]map[string]struct{}{ + "one": {"shared": {}, "one-only": {}}, + "two": {"shared": {}}, + }, + } + coordinator.coverageState["shared"] = &boundedCoverageState{} + coordinator.coverageState["one-only"] = &boundedCoverageState{} + coordinator.coverageState["native"] = &boundedCoverageState{nativeAdmitted: true} + coordinator.coverageState["retired"] = &boundedCoverageState{} + coordinator.rebuildPollingOwnershipLocked() + + assert.True(t, coordinator.coverageState["shared"].pollOwned) + assert.True(t, coordinator.coverageState["one-only"].pollOwned) + assert.False(t, coordinator.coverageState["native"].pollOwned) + _, exists := coordinator.coverageState["retired"] + assert.False(t, exists) + + delete(coordinator.ownedBindings, "one") + coordinator.rebuildPollingOwnershipLocked() + assert.True(t, coordinator.coverageState["shared"].pollOwned) + delete(coordinator.ownedBindings, "two") + coordinator.rebuildPollingOwnershipLocked() + if state := coordinator.coverageState["shared"]; state != nil { + assert.False(t, state.pollOwned) + } +} + // reconcileGroupsSequentially adapts a per-group fake to the grouped syncer // interface, mirroring the engine contract pinned by // TestReconcileProviderRootsGrouped*: every group is attempted in order and diff --git a/cmd/agentsview/worker_pass_test.go b/cmd/agentsview/worker_pass_test.go index 9fae4dedf..dad6e791c 100644 --- a/cmd/agentsview/worker_pass_test.go +++ b/cmd/agentsview/worker_pass_test.go @@ -931,6 +931,9 @@ func TestRunWorkerWritePassShutdownStopsPersistentRecovery(t *testing.T) { daemonCtx, shutdown := context.WithCancel(context.Background()) defer shutdown() var contender *writeOwnerLock + operationCtx, cancelOperation := context.WithCancel(context.Background()) + defer cancelOperation() + workerReady := make(chan struct{}) restore := stubLaunchSyncWorker(t, func( _ context.Context, _ config.Config, _ string, _ func(workerLine), ) (workerResult, error) { @@ -939,7 +942,8 @@ func TestRunWorkerWritePassShutdownStopsPersistentRecovery(t *testing.T) { taken, err := tryAcquireWriteOwnerLock(cfg.DataDir) require.NoError(t, err, "contender takes the freed lock") contender = taken - shutdown() + cancelOperation() + close(workerReady) return workerResult{Status: "ok", DiscoveryComplete: true}, nil }) defer restore() @@ -952,12 +956,25 @@ func TestRunWorkerWritePassShutdownStopsPersistentRecovery(t *testing.T) { done := make(chan error, 1) go func() { _, err := runWorkerWritePass( - context.Background(), daemonCtx, cfg, engine, database, lock, + operationCtx, daemonCtx, cfg, engine, database, lock, "sync", nil, ) done <- err }() select { + case <-workerReady: + case <-time.After(time.Second): + t.Fatal("worker did not complete its operation") + } + select { + case <-done: + t.Fatal("operation cancellation must not cancel daemon-lifetime recovery") + case <-time.After(100 * time.Millisecond): + } + shutdown() + assert.NoError(t, contender.Close()) + contender = nil + select { case err := <-done: require.Error(t, err, "an unrecovered pass must surface its failure") diff --git a/cmd/testfixture/main.go b/cmd/testfixture/main.go index 77d957797..071a115b6 100644 --- a/cmd/testfixture/main.go +++ b/cmd/testfixture/main.go @@ -156,6 +156,7 @@ func createProjectReclassificationFixture( sessionID := "test-session-project-reclassification-" + item.suffix startedAt := start.Add(time.Duration(index) * time.Hour) endedAt := startedAt.Add(12 * time.Minute) + endedAtText := endedAt.Format(time.RFC3339Nano) firstMessage := "Inspect the sample service worktree." session := db.Session{ ID: sessionID, @@ -163,7 +164,7 @@ func createProjectReclassificationFixture( Machine: machine, Agent: "claude", StartedAt: new(startedAt.Format(time.RFC3339Nano)), - EndedAt: new(endedAt.Format(time.RFC3339Nano)), + EndedAt: &endedAtText, MessageCount: 2, UserMessageCount: 1, FirstMessage: new(firstMessage), @@ -238,6 +239,7 @@ func createSessionFixture( endedAt := startedAt.Add( time.Duration(spec.msgCount) * time.Minute, ) + endedAtText := endedAt.Format(time.RFC3339Nano) sess := db.Session{ ID: sessionID, @@ -245,7 +247,7 @@ func createSessionFixture( Machine: "test-machine", Agent: "claude", StartedAt: new(startedAt.Format(time.RFC3339Nano)), - EndedAt: new(endedAt.Format(time.RFC3339Nano)), + EndedAt: &endedAtText, MessageCount: spec.msgCount, UserMessageCount: spec.userMsgCount, RelationshipType: spec.relationshipType, @@ -479,6 +481,7 @@ func createDurationShowcaseFixture( t5 := start.Add(2*time.Minute + 24*time.Second) t6 := start.Add(2*time.Minute + 52*time.Second) endParent := start.Add(2*time.Minute + 55*time.Second) + endParentText := endParent.Format(time.RFC3339Nano) // Sub-agent runs alongside the parallel turn so its // duration covers the full ~2 minutes of that turn. @@ -532,7 +535,7 @@ func createDurationShowcaseFixture( Agent: "claude", Cwd: "/workspace/مشروع/.worktrees/שלוםfeaturewithalongcheckoutnamefortooltipwrappingwithoutbreakopportunities", StartedAt: new(t0.Format(time.RFC3339Nano)), - EndedAt: new(endParent.Format(time.RFC3339Nano)), + EndedAt: &endParentText, MessageCount: len(parentMessages), UserMessageCount: countUserMessages(parentMessages), FirstMessage: new( @@ -871,6 +874,7 @@ func createRecentEditsFixture( ) endedAt := start.Add(5 * time.Minute) + endedAtText := endedAt.Format(time.RFC3339Nano) firstMsg := "Add request logging to the HTTP handler." sess := db.Session{ @@ -879,7 +883,7 @@ func createRecentEditsFixture( Machine: "test-machine", Agent: "claude", StartedAt: new(start.Format(time.RFC3339Nano)), - EndedAt: new(endedAt.Format(time.RFC3339Nano)), + EndedAt: &endedAtText, MessageCount: 3, UserMessageCount: 1, FirstMessage: new(firstMsg), diff --git a/internal/db/db_test.go b/internal/db/db_test.go index d260b04a1..80e9b6203 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -228,8 +228,8 @@ func sessionSet(t *testing.T, d *DB) { end := fmt.Sprintf("2024-06-0%dT11:00:00Z", i+1) insertSession(t, d, fmt.Sprintf("s%d", i+1), "proj", func(s *Session) { - s.StartedAt = new(day) - s.EndedAt = new(end) + s.StartedAt = &day + s.EndedAt = &end s.MessageCount = mc }) } @@ -1407,7 +1407,7 @@ func TestListSessions(t *testing.T) { insertSession(t, d, fmt.Sprintf("session-%c", 'a'+i), "proj", func(s *Session) { - s.EndedAt = new(ea) + s.EndedAt = &ea s.MessageCount = i + 1 }, ) @@ -1443,7 +1443,7 @@ func TestListSessionsPaginationNoDuplicates(t *testing.T) { for i, ea := range times { insertSession(t, d, fmt.Sprintf("page-%c", 'a'+i), "proj", - func(s *Session) { s.EndedAt = new(ea) }, + func(s *Session) { s.EndedAt = &ea }, ) } @@ -1520,7 +1520,7 @@ func TestListSessionsProjectFilter(t *testing.T) { ea := fmt.Sprintf("2024-01-01T00:00:0%dZ", i) insertSession(t, d, fmt.Sprintf("%s-%d", proj, i), proj, - func(s *Session) { s.EndedAt = new(ea) }, + func(s *Session) { s.EndedAt = &ea }, ) } diff --git a/internal/parser/capabilities.go b/internal/parser/capabilities.go index cf8c5e6f8..4cdaa82ae 100644 --- a/internal/parser/capabilities.go +++ b/internal/parser/capabilities.go @@ -48,11 +48,17 @@ type ProviderSyncSemantics struct { // SourceCapabilities declares optional source mechanics implemented by a // provider. type SourceCapabilities struct { - DiscoverSources CapabilitySupport - StreamingDiscovery CapabilitySupport - WatchSources CapabilitySupport - WatchRoots CapabilitySupport - ActivityHints CapabilitySupport + DiscoverSources CapabilitySupport + StreamingDiscovery CapabilitySupport + WatchSources CapabilitySupport + WatchRoots CapabilitySupport + ActivityHints CapabilitySupport + // BoundedCoverage means the provider can construct a bounded journal + // change feed for its shared SQLite container, so recurring work is + // proportional to the changed batch rather than the archive. The feed + // is constructed per coverage unit; providers that do not declare this + // keep their existing provider-scoped discovery strategy. + BoundedCoverage CapabilitySupport ClassifyChangedPath CapabilitySupport ChangedPathRelevance CapabilitySupport StoredSourceHints CapabilitySupport diff --git a/internal/parser/opencode_change_feed.go b/internal/parser/opencode_change_feed.go new file mode 100644 index 000000000..f07aaad11 --- /dev/null +++ b/internal/parser/opencode_change_feed.go @@ -0,0 +1,1154 @@ +// ABOUTME: Bounded OpenCode event-journal change feed. Journal rows in; +// ABOUTME: ready/pending identities, continuation state, and an audit reason out. +// ABOUTME: No SourceRef, no filesystem walk, no engine dependency. +package parser + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "time" +) + +// OpenCode journal drain limits. All are fixed so a degraded pass cannot +// become archive-scale work. Pin them as exported constants so tests can assert +// the exact boundaries. +const ( + // OpenCodeCoverageMaxRows is the maximum number of event rows read in one + // drain call, before the +1 sentinel that detects continuation. + OpenCodeCoverageMaxRows = 256 + + // OpenCodeCoverageMaxPayloadBytes is the aggregate payload budget for + // stage-2 fetches in one drain. message.part.updated.1 never enters this + // budget; the three other measured types are all under 700 bytes max. + OpenCodeCoverageMaxPayloadBytes = 1 << 20 + + // OpenCodeCoverageMaxIDs is the maximum number of distinct session IDs + // tracked across ReadyIDs and PendingIDs in one checkpoint. + OpenCodeCoverageMaxIDs = 256 + + // OpenCodeCoverageMaxDuration is the wall-clock budget for one drain call. + OpenCodeCoverageMaxDuration = 2 * time.Second + + // openCodeMaxAnchors is the maximum number of committed anchors kept for + // cursor continuity. Spanning at least openCodeMinAnchorAggregates ensures + // a single session deletion cannot erase every witness. + openCodeMaxAnchors = 8 + + // openCodeMinAnchorAggregates is the minimum number of distinct aggregate + // IDs that committed anchors must span. + openCodeMinAnchorAggregates = 2 + + // openCodeAnchorSampleWindow bounds the rebaseline diversity scan. The + // normal newest-row sample remains the cursor; this tail window only adds + // nearby aggregate witnesses without grouping the archive. + openCodeAnchorSampleWindow = openCodeMaxAnchors * openCodeMaxAnchors +) + +// ErrOpenCodeCoverageDatabaseMissing identifies a coverage unit that +// disappeared between a wake and its drain. The coordinator retires that unit +// so normal provider reconciliation can prove the deletion. +var ErrOpenCodeCoverageDatabaseMissing = errors.New( + "opencode coverage database missing", +) + +// OpenCodeFeedOutcome keeps parser classification explicit at the scheduler +// boundary. Operational failures are retryable; structural audit is the only +// outcome that may enter repair. +type OpenCodeFeedOutcome uint8 + +const ( + OpenCodeFeedOutcomeNone OpenCodeFeedOutcome = iota + OpenCodeFeedOutcomeReady + OpenCodeFeedOutcomeContinuation + OpenCodeFeedOutcomeOperationalError + OpenCodeFeedOutcomeStructuralAudit +) + +type OpenCodeFeedError struct { + Kind OpenCodeFeedOutcome + Err error +} + +func (e *OpenCodeFeedError) Error() string { return e.Err.Error() } +func (e *OpenCodeFeedError) Unwrap() error { return e.Err } + +// OpenCodeJournalAnchor is one verified (rowid, eventID, aggregateID) triple +// committed into a checkpoint. Multiple anchors spanning multiple aggregates +// mean a single session deletion cannot erase every continuity witness. +type OpenCodeJournalAnchor struct { + RowID int64 + EventID string + AggregateID string +} + +// OpenCodeCoverageCheckpoint is the committed state of one coverage unit's +// journal reader. It is immutable from the adapter's perspective: the worker +// passes it in as read-only, the adapter returns a proposed next checkpoint. +// The worker commits the proposed checkpoint only after a successful archive +// write for all ready identities. +type OpenCodeCoverageCheckpoint struct { + // Anchors are committed (rowid, eventID, aggregateID) triples. The + // maximum anchor rowid is the position cursor; the set spans multiple + // aggregates so a single deletion cannot void every witness. + Anchors []OpenCodeJournalAnchor + + // SchemaVersion is PRAGMA schema_version at the last capability probe or + // drain. A change triggers an audit and rebaseline. + SchemaVersion int64 + // SchemaFingerprint is a deterministic digest of sqlite_master's schema + // definitions. It catches compatible schema changes that leave the pragma + // version unchanged across a copied or forked journal. + SchemaFingerprint string + + // PendingIDs accumulates sessions that appeared in the current logical + // drain but have not yet settled. The worker carries them across pages + // and emits them with ReadyIDs when the drain reaches its high-water. + PendingIDs []string + + // ReadyIDs accumulates sessions that settled in the current logical + // drain. Emitted to the worker when the drain reaches its high-water. + ReadyIDs []string + + // AuditLatched means a durable anomaly was detected. The worker will + // request a full audit and rebaseline before draining again. Latched + // forever; no hot retry. + AuditLatched bool + + // Initialized means the baseline was captured on the first drain call. + // Before initialization DrainOpenCodeJournal captures the pre-startup + // baseline and returns an empty result. + Initialized bool + + // HighWaterRowID and HighWaterEventID are the fixed upper boundary for + // the current logical drain. Captured once per logical drain; all + // continuation pages use the same boundary so a write between pages + // does not shift the window. + HighWaterRowID int64 + HighWaterEventID string + HighWaterAggregateID string + // HighWaterKnown is true while a logical drain is active. False means + // the next call is the first page of a new drain. + HighWaterKnown bool +} + +// OpenCodeFeedResult is what DrainOpenCodeJournal returns from one call. +type OpenCodeFeedResult struct { + Outcome OpenCodeFeedOutcome + // ReadyIDs lists sessions that settled (info.time.completed) in this + // logical drain. Non-nil only when More is false. The worker archives + // these before committing Next. + ReadyIDs []string + + // PendingIDs lists sessions still streaming when the drain reached its + // high-water. Non-nil only when More is false. The worker carries these + // as hints for the next drain. + PendingIDs []string + + // More is true when the drain hit the row limit before the high-water. + // The worker should schedule an immediate continuation from Next. + More bool + + // AuditRequired is true when a durable anomaly was detected. The worker + // latches an audit job and rebaselines before draining again. + AuditRequired bool + + // RowsRead is the number of metadata rows examined (stage 1). Bounded + // by OpenCodeCoverageMaxRows independent of archive size. + RowsRead int + + // PayloadBytes is the total bytes fetched in stage-2 queries. Bounded + // by OpenCodeCoverageMaxPayloadBytes. + PayloadBytes int + + // Next is the proposed next checkpoint. The worker commits it only after + // all ready identities have been successfully archived. + Next OpenCodeCoverageCheckpoint +} + +// openCodeEventMeta is a metadata-only row from stage-1 SELECT. +type openCodeEventMeta struct { + RowID int64 + EventID string + AggregateID string + Type string + PayloadSize int +} + +// openCodeEventStatus is the classification state of one session identity +// within a drain. +type openCodeEventStatus uint8 + +const ( + openCodeStatusPending openCodeEventStatus = iota + openCodeStatusReady +) + +// ProbeOpenCodeJournalCapability checks whether a container's event journal is +// compatible with the bounded feed. Eligibility requires: +// - event table with required columns (id, aggregate_id, seq, type, data) +// - event_sequence table with owner_id column +// - PRAGMA schema_version readable +// +// Returns the schema version, whether the container is compatible, and any +// unexpected error. Unknown schema returns (0, false, nil) — incompatible but +// not an error: the capability gate falls back to existing base behavior. +func ProbeOpenCodeJournalCapability( + ctx context.Context, dbPath string, +) (schemaVersion int64, compatible bool, err error) { + if _, statErr := os.Stat(dbPath); statErr != nil { + if errors.Is(statErr, os.ErrNotExist) { + return 0, false, nil + } + return 0, false, statErr + } + db, err := openOpenCodeDB(dbPath) + if err != nil { + return 0, false, opencodeCoverageDatabaseError(dbPath, err) + } + defer db.Close() + + // Check PRAGMA schema_version first; if it's unreadable, the DB is inaccessible. + if err := db.QueryRowContext(ctx, "PRAGMA schema_version").Scan(&schemaVersion); err != nil { + return 0, false, openCodeJournalContextError(ctx, err) + } + + // Required columns in the event table. + requiredEventCols := map[string]bool{ + "id": false, "aggregate_id": false, "seq": false, + "type": false, "data": false, + } + rows, err := db.QueryContext(ctx, "SELECT name FROM pragma_table_info('event')") + if err != nil { + return 0, false, openCodeJournalContextError(ctx, err) + } + defer rows.Close() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return 0, false, openCodeJournalContextError(ctx, err) + } + if _, ok := requiredEventCols[name]; ok { + requiredEventCols[name] = true + } + } + if rows.Err() != nil { + return 0, false, openCodeJournalContextError(ctx, rows.Err()) + } + for _, present := range requiredEventCols { + if !present { + return 0, false, nil + } + } + + // event_sequence must have owner_id column. + ownerIDPresent := false + seqRows, err := db.QueryContext(ctx, "SELECT name FROM pragma_table_info('event_sequence')") + if err != nil { + return 0, false, openCodeJournalContextError(ctx, err) + } + defer seqRows.Close() + for seqRows.Next() { + var name string + if err := seqRows.Scan(&name); err != nil { + return 0, false, openCodeJournalContextError(ctx, err) + } + if name == "owner_id" { + ownerIDPresent = true + } + } + if seqRows.Err() != nil { + return 0, false, openCodeJournalContextError(ctx, seqRows.Err()) + } + if !ownerIDPresent { + return 0, false, nil + } + + return schemaVersion, true, nil +} + +// InitializeOpenCodeCoverageCheckpoint installs a row-zero checkpoint for a +// newly admitted physical journal. The first bounded drain owns all existing +// and triggering rows, so admission never captures a high-water baseline. +func InitializeOpenCodeCoverageCheckpoint(ctx context.Context, dbPath string) (OpenCodeCoverageCheckpoint, error) { + schemaVersion, compatible, err := ProbeOpenCodeJournalCapability(ctx, dbPath) + if err != nil { + return OpenCodeCoverageCheckpoint{}, err + } + if !compatible { + return OpenCodeCoverageCheckpoint{}, fmt.Errorf("opencode journal is not compatible: %s", dbPath) + } + db, err := openOpenCodeDB(dbPath) + if err != nil { + return OpenCodeCoverageCheckpoint{}, opencodeCoverageDatabaseError(dbPath, err) + } + defer db.Close() + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return OpenCodeCoverageCheckpoint{}, openCodeJournalContextError(ctx, err) + } + defer func() { _ = tx.Rollback() }() + fingerprint, err := openCodeJournalSchemaFingerprint(ctx, tx) + if err != nil { + return OpenCodeCoverageCheckpoint{}, openCodeJournalContextError(ctx, err) + } + return OpenCodeCoverageCheckpoint{Initialized: true, SchemaVersion: schemaVersion, SchemaFingerprint: fingerprint}, nil +} + +// RebaselineOpenCodeCoverageCheckpoint records the repaired journal boundary +// and fresh row witnesses after an authoritative repair. When prior carries an +// observed high-water, the snapshot is capped there so events committed while +// repair runs remain eligible for the next bounded drain. +func RebaselineOpenCodeCoverageCheckpoint( + ctx context.Context, dbPath string, prior OpenCodeCoverageCheckpoint, +) (OpenCodeCoverageCheckpoint, error) { + schemaVersion, compatible, err := ProbeOpenCodeJournalCapability(ctx, dbPath) + if err != nil { + return OpenCodeCoverageCheckpoint{}, err + } + if !compatible { + return OpenCodeCoverageCheckpoint{}, fmt.Errorf("opencode journal is not compatible: %s", dbPath) + } + db, err := openOpenCodeDB(dbPath) + if err != nil { + return OpenCodeCoverageCheckpoint{}, opencodeCoverageDatabaseError(dbPath, err) + } + defer db.Close() + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return OpenCodeCoverageCheckpoint{}, openCodeJournalContextError(ctx, err) + } + defer func() { _ = tx.Rollback() }() + fingerprint, err := openCodeJournalSchemaFingerprint(ctx, tx) + if err != nil { + return OpenCodeCoverageCheckpoint{}, openCodeJournalContextError(ctx, err) + } + var maxRowID int64 + if err := tx.QueryRowContext(ctx, + "SELECT COALESCE(MAX(rowid), 0) FROM event", + ).Scan(&maxRowID); err != nil { + return OpenCodeCoverageCheckpoint{}, openCodeJournalContextError(ctx, err) + } + if prior.HighWaterKnown && prior.HighWaterRowID > 0 && + prior.HighWaterRowID < maxRowID { + maxRowID = prior.HighWaterRowID + } + var anchors []OpenCodeJournalAnchor + if maxRowID > 0 { + newest, _, newestErr := sampleAnchors(ctx, tx, 0, maxRowID, openCodeMaxAnchors) + if newestErr != nil { + return OpenCodeCoverageCheckpoint{}, newestErr + } + diverse, _, diverseErr := sampleLatestAggregateAnchors( + ctx, tx, maxRowID, openCodeMaxAnchors, + ) + if diverseErr != nil { + return OpenCodeCoverageCheckpoint{}, diverseErr + } + anchors = mergeAnchors(nil, append(newest, diverse...)) + } + next := prior + next.Initialized = true + next.SchemaVersion = schemaVersion + next.SchemaFingerprint = fingerprint + next.Anchors = anchors + next.AuditLatched = false + next.HighWaterKnown = false + next.HighWaterRowID = 0 + next.HighWaterEventID = "" + next.HighWaterAggregateID = "" + next.PendingIDs = nil + next.ReadyIDs = nil + return next, nil +} + +func openCodeJournalSchemaFingerprint( + ctx context.Context, tx *sql.Tx, +) (string, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT type, name, tbl_name, COALESCE(sql, '') + FROM sqlite_master + WHERE type IN ('table', 'index', 'trigger', 'view') + ORDER BY type, name, tbl_name, sql`) + if err != nil { + return "", err + } + defer rows.Close() + hash := sha256.New() + for rows.Next() { + var typ, name, table, sqlText string + if err := rows.Scan(&typ, &name, &table, &sqlText); err != nil { + return "", err + } + _, _ = hash.Write([]byte(typ)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(name)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(table)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(sqlText)) + _, _ = hash.Write([]byte{0}) + } + if err := rows.Err(); err != nil { + return "", err + } + return fmt.Sprintf("sha256:%x", hash.Sum(nil)), nil +} + +// DrainOpenCodeJournal reads one bounded page of the OpenCode event journal +// starting from the checkpoint. It uses two-stage admission: stage 1 reads +// only metadata (rowid, id, aggregate_id, type, octet_length(data)); stage 2 +// fetches payload only for settlement-bearing types within the byte budget. +// message.part.updated.1 is always handled from metadata alone. +// +// DrainOpenCodeJournal never performs archive-scale discovery: it reads at +// most OpenCodeCoverageMaxRows rows per call and tracks at most +// OpenCodeCoverageMaxIDs session IDs. +func DrainOpenCodeJournal( + ctx context.Context, + dbPath string, + checkpoint OpenCodeCoverageCheckpoint, +) (OpenCodeFeedResult, error) { + if checkpoint.AuditLatched { + return OpenCodeFeedResult{Next: checkpoint}, nil + } + + ctx, cancel := context.WithTimeout(ctx, OpenCodeCoverageMaxDuration) + defer cancel() + + if _, err := os.Stat(dbPath); err != nil { + return OpenCodeFeedResult{Next: checkpoint}, opencodeCoverageDatabaseError(dbPath, err) + } + + db, err := openOpenCodeDB(dbPath) + if err != nil { + return OpenCodeFeedResult{Next: checkpoint}, opencodeCoverageDatabaseError(dbPath, err) + } + defer db.Close() + + // Use a read transaction so stage-1 and stage-2 queries see a consistent snapshot. + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return OpenCodeFeedResult{Next: checkpoint}, fmt.Errorf( + "opencode coverage begin read transaction %q: %w", dbPath, err, + ) + } + defer func() { _ = tx.Rollback() }() + + // Read schema version. + var schemaVersion int64 + if err := tx.QueryRowContext(ctx, "PRAGMA schema_version").Scan(&schemaVersion); err != nil { + return OpenCodeFeedResult{Next: checkpoint}, err + } + schemaFingerprint, err := openCodeJournalSchemaFingerprint(ctx, tx) + if err != nil { + return OpenCodeFeedResult{Next: checkpoint}, err + } + if checkpoint.Initialized && checkpoint.SchemaFingerprint != "" && + schemaFingerprint != checkpoint.SchemaFingerprint { + next := checkpoint + next.AuditLatched = true + next.SchemaFingerprint = schemaFingerprint + return OpenCodeFeedResult{AuditRequired: true, Next: next}, nil + } + if checkpoint.Initialized && checkpoint.SchemaVersion != 0 && + schemaVersion != checkpoint.SchemaVersion { + next := checkpoint + next.AuditLatched = true + next.SchemaVersion = schemaVersion + return OpenCodeFeedResult{AuditRequired: true, Next: next}, nil + } + + // Current MAX(rowid) in the event table. + var maxRowID int64 + if err := tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(rowid), 0) FROM event").Scan(&maxRowID); err != nil { + return OpenCodeFeedResult{Next: checkpoint}, err + } + + // Initialize on first wake: capture the pre-startup baseline position so + // the first actual drain reads only events committed after baseline, not + // the entire journal history. HighWaterKnown is left false so the next + // drain call captures the high-water from the live max rowid at that time, + // retaining any events committed between baseline preparation and startup + // completion. + if !checkpoint.Initialized { + next := checkpoint + next.Initialized = true + next.SchemaVersion = schemaVersion + next.SchemaFingerprint = schemaFingerprint + // Baseline anchors: sample the most-recent events at the current + // position. positionRowID on the next drain call equals max(anchor.RowID), + // so stage-1 reads only events committed after this snapshot. + if maxRowID > 0 { + anchors, ok, sampleErr := sampleAnchors( + ctx, tx, 0, maxRowID, openCodeMaxAnchors, + ) + if sampleErr != nil { + return OpenCodeFeedResult{Next: checkpoint}, sampleErr + } + if !ok { + // Non-fatal: start with no anchors. The first drain uses + // positionRowID=0 and reads from the start of the journal, + // which is correct for a newly empty or unreadable DB. + anchors = nil + } + next.Anchors = anchors + } + // HighWaterKnown stays false: the next drain call sets the high-water + // from the live max rowid so events committed during startup are retained. + return OpenCodeFeedResult{Next: next}, nil + } + + // The maximum anchor is the cursor. A lower surviving witness cannot prove + // that the cursor row was not deleted and reused, so validate the maximum + // anchor specifically and rewind to the highest verified witness when it is + // gone. If no witness survives, continuity is unprovable and the audit lane + // must establish a new boundary. + if len(checkpoint.Anchors) > 0 { + maxAnchor := checkpoint.Anchors[0] + for _, anchor := range checkpoint.Anchors[1:] { + if anchor.RowID > maxAnchor.RowID { + maxAnchor = anchor + } + } + verified := make([]OpenCodeJournalAnchor, 0, len(checkpoint.Anchors)) + for _, anchor := range checkpoint.Anchors { + var id, aggregateID string + err := tx.QueryRowContext(ctx, + "SELECT id, aggregate_id FROM event WHERE rowid = ?", anchor.RowID, + ).Scan(&id, &aggregateID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return OpenCodeFeedResult{Next: checkpoint}, openCodeJournalContextError(ctx, err) + } + if err == nil && id == anchor.EventID && aggregateID == anchor.AggregateID { + verified = append(verified, anchor) + } + } + maxVerified := int64(0) + for _, anchor := range verified { + if anchor.RowID > maxVerified { + maxVerified = anchor.RowID + } + } + if maxVerified != maxAnchor.RowID { + if maxVerified == 0 { + // The cursor has no verified witness, so continuity cannot be + // established even when the current max rowid is higher. + next := checkpoint + next.AuditLatched = true + next.SchemaVersion = schemaVersion + return OpenCodeFeedResult{AuditRequired: true, Next: next}, nil + } else { + checkpoint.Anchors = verified + } + } + } + + // Determine position cursor: the maximum anchor rowid, or the stored + // high-water if we're at the start of a new drain with no session changes. + positionRowID := anchorsMaxRowID(checkpoint.Anchors) + + // Manage the high-water boundary. + var hwRowID int64 + var hwEventID, hwAggregateID string + if !checkpoint.HighWaterKnown { + // First page of a new drain: capture the fixed high-water. + hwRowID = maxRowID + if hwRowID > 0 { + if err := tx.QueryRowContext(ctx, + "SELECT id, aggregate_id FROM event WHERE rowid = ?", hwRowID, + ).Scan(&hwEventID, &hwAggregateID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + next := checkpoint + next.AuditLatched = true + return OpenCodeFeedResult{AuditRequired: true, Next: next}, nil + } + return OpenCodeFeedResult{Next: checkpoint}, err + } + } + } else { + // Continuation page: verify the high-water anchor is still intact. + hwRowID = checkpoint.HighWaterRowID + hwEventID = checkpoint.HighWaterEventID + hwAggregateID = checkpoint.HighWaterAggregateID + if hwRowID > 0 { + var currentHWID, currentHWAggregateID string + err := tx.QueryRowContext(ctx, + "SELECT id, aggregate_id FROM event WHERE rowid = ?", hwRowID, + ).Scan(¤tHWID, ¤tHWAggregateID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return OpenCodeFeedResult{Next: checkpoint}, openCodeJournalContextError(ctx, err) + } + if err != nil || currentHWID != hwEventID || currentHWAggregateID != hwAggregateID { + next := checkpoint + next.AuditLatched = true + return OpenCodeFeedResult{AuditRequired: true, Next: next}, nil + } + } + } + + // If already at or beyond the high-water, the drain is complete. + if positionRowID >= hwRowID { + result := OpenCodeFeedResult{ + Next: checkpoint, + } + result.ReadyIDs = append([]string(nil), checkpoint.ReadyIDs...) + result.PendingIDs = append([]string(nil), checkpoint.PendingIDs...) + result.Next.ReadyIDs = nil + result.Next.PendingIDs = nil + result.Next.HighWaterKnown = false + result.Next.HighWaterRowID = 0 + result.Next.HighWaterEventID = "" + result.Next.HighWaterAggregateID = "" + return result, nil + } + + // Stage 1: read metadata for events (positionRowID, hwRowID]. + metaRows, err := tx.QueryContext(ctx, ` + SELECT rowid, id, aggregate_id, type, octet_length(data) + FROM event + WHERE rowid > ? AND rowid <= ? + ORDER BY rowid + LIMIT ?`, + positionRowID, hwRowID, OpenCodeCoverageMaxRows+1, + ) + if err != nil { + return OpenCodeFeedResult{Next: checkpoint}, err + } + defer metaRows.Close() + + meta := make([]openCodeEventMeta, 0, OpenCodeCoverageMaxRows+1) + for metaRows.Next() { + var m openCodeEventMeta + if err := metaRows.Scan(&m.RowID, &m.EventID, &m.AggregateID, &m.Type, &m.PayloadSize); err != nil { + return OpenCodeFeedResult{Next: checkpoint}, err + } + meta = append(meta, m) + } + if err := metaRows.Err(); err != nil { + return OpenCodeFeedResult{Next: checkpoint}, err + } + + // Determine continuation: more than maxRows rows available? + more := len(meta) > OpenCodeCoverageMaxRows + if more { + meta = meta[:OpenCodeCoverageMaxRows] + } + + // Accumulate identity states from the checkpoint. + status := make(map[string]openCodeEventStatus) + for _, id := range checkpoint.ReadyIDs { + status[id] = openCodeStatusReady + } + for _, id := range checkpoint.PendingIDs { + if _, exists := status[id]; !exists { + status[id] = openCodeStatusPending + } + } + + // Track new anchors from this drain. + var newAnchors []OpenCodeJournalAnchor + + remainingBudget := OpenCodeCoverageMaxPayloadBytes + totalPayloadBytes := 0 + rowsRead := 0 + auditRequired := false + + for _, m := range meta { + if ctx.Err() != nil { + return OpenCodeFeedResult{Next: checkpoint}, ctx.Err() + } + + rowsRead++ + + switch m.Type { + case "message.part.updated.1", "message.part.updated": + // Never read payload for streaming parts: metadata alone suffices. + // A streaming part always sets the session to pending. + if len(status) < OpenCodeCoverageMaxIDs || hasID(status, m.AggregateID) { + status[m.AggregateID] = openCodeStatusPending + } else { + auditRequired = true + break + } + + case "message.updated.1", "message.updated": + // Settlement candidate: read payload to check info.time.completed. + if m.PayloadSize < 0 || m.PayloadSize > remainingBudget { + auditRequired = true + break + } + var data []byte + err := tx.QueryRowContext(ctx, + "SELECT data FROM event WHERE rowid = ? AND octet_length(data) <= ?", + m.RowID, remainingBudget, + ).Scan(&data) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + auditRequired = true + break + } + return OpenCodeFeedResult{Next: checkpoint}, err + } + totalPayloadBytes += len(data) + remainingBudget -= len(data) + + settled, ok := classifyMessageUpdated(m.AggregateID, data) + if !ok { + auditRequired = true + break + } + if len(status) >= OpenCodeCoverageMaxIDs && !hasID(status, m.AggregateID) { + auditRequired = true + break + } + if settled { + status[m.AggregateID] = openCodeStatusReady + } else { + // Do not downgrade from ready: trailing message.updated.1 events + // routinely follow settlement and must not reset it. + if _, exists := status[m.AggregateID]; !exists { + status[m.AggregateID] = openCodeStatusPending + } + } + + case "session.updated.1", "session.updated", "session.created.1", "session.created": + // Session metadata is durable source state, so apply it at the end + // of this drain even when no settlement-bearing message exists. + if len(status) >= OpenCodeCoverageMaxIDs && !hasID(status, m.AggregateID) { + auditRequired = true + break + } + status[m.AggregateID] = openCodeStatusReady + + default: + // Unrecognized event type or version: latch audit. + auditRequired = true + } + + if auditRequired { + break + } + + // Collect anchor candidate. + newAnchors = append(newAnchors, OpenCodeJournalAnchor{ + RowID: m.RowID, + EventID: m.EventID, + AggregateID: m.AggregateID, + }) + } + + // Build next checkpoint. + next := checkpoint + next.SchemaVersion = schemaVersion + next.SchemaFingerprint = schemaFingerprint + next.HighWaterRowID = hwRowID + next.HighWaterEventID = hwEventID + next.HighWaterAggregateID = hwAggregateID + next.HighWaterKnown = true + + if auditRequired { + next.AuditLatched = true + return OpenCodeFeedResult{ + Outcome: OpenCodeFeedOutcomeStructuralAudit, + AuditRequired: true, + RowsRead: rowsRead, + PayloadBytes: totalPayloadBytes, + Next: next, + }, nil + } + + // Update anchor set: merge old and new, keep up to 8 across ≥2 aggregates. + next.Anchors = mergeAnchors(checkpoint.Anchors, newAnchors) + + // Convert status map to ReadyIDs/PendingIDs for the next checkpoint. + var pendingIDs, readyIDs []string + for id, s := range status { + if s == openCodeStatusReady { + readyIDs = append(readyIDs, id) + } else { + pendingIDs = append(pendingIDs, id) + } + } + + if more { + // More pages remain in this logical drain. + next.PendingIDs = pendingIDs + next.ReadyIDs = readyIDs + return OpenCodeFeedResult{ + Outcome: OpenCodeFeedOutcomeContinuation, + More: true, + RowsRead: rowsRead, + PayloadBytes: totalPayloadBytes, + Next: next, + }, nil + } + + // Drain reached the high-water: emit ready/pending and reset. + next.PendingIDs = nil + next.ReadyIDs = nil + next.HighWaterKnown = false + next.HighWaterRowID = 0 + next.HighWaterEventID = "" + next.HighWaterAggregateID = "" + return OpenCodeFeedResult{ + Outcome: OpenCodeFeedOutcomeReady, + ReadyIDs: readyIDs, + PendingIDs: pendingIDs, + RowsRead: rowsRead, + PayloadBytes: totalPayloadBytes, + Next: next, + }, nil +} + +func opencodeCoverageDatabaseError(dbPath string, err error) error { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%w: %s", ErrOpenCodeCoverageDatabaseMissing, dbPath) + } + return &OpenCodeFeedError{Kind: OpenCodeFeedOutcomeOperationalError, + Err: fmt.Errorf("opencode coverage database %q: %w", dbPath, err)} +} + +// OpenCodeJournalEventInput is one event fed to the reference model. +// Data is nil for types that do not require payload (message.part.updated.1). +type OpenCodeJournalEventInput struct { + RowID int64 + EventID string + AggregateID string + Type string + Data []byte +} + +// ReduceOpenCodeJournalEvents applies a sequence of journal events to a +// checkpoint using the same reducer logic as DrainOpenCodeJournal. Pure +// function: no database, no filesystem, no clock. Used by tests to verify +// ordering, settlement, downgrade, and bound invariants without I/O. +func ReduceOpenCodeJournalEvents( + checkpoint OpenCodeCoverageCheckpoint, + events []OpenCodeJournalEventInput, +) (next OpenCodeCoverageCheckpoint, auditRequired bool) { + next = checkpoint + status := make(map[string]openCodeEventStatus) + for _, id := range checkpoint.ReadyIDs { + status[id] = openCodeStatusReady + } + for _, id := range checkpoint.PendingIDs { + if _, exists := status[id]; !exists { + status[id] = openCodeStatusPending + } + } + + var newAnchors []OpenCodeJournalAnchor + for _, e := range events { + switch e.Type { + case "message.part.updated.1", "message.part.updated": + if len(status) >= OpenCodeCoverageMaxIDs && !hasID(status, e.AggregateID) { + return next, true + } + status[e.AggregateID] = openCodeStatusPending + + case "message.updated.1", "message.updated": + if len(status) >= OpenCodeCoverageMaxIDs && !hasID(status, e.AggregateID) { + return next, true + } + if e.Data == nil { + // Treat as pending (metadata-only in reference model = no payload = no settlement). + if _, exists := status[e.AggregateID]; !exists { + status[e.AggregateID] = openCodeStatusPending + } + } else { + settled, ok := classifyMessageUpdated(e.AggregateID, e.Data) + if !ok { + return next, true + } + if settled { + status[e.AggregateID] = openCodeStatusReady + } else { + // No downgrade from ready for trailing non-settling events. + if _, exists := status[e.AggregateID]; !exists { + status[e.AggregateID] = openCodeStatusPending + } + } + } + + case "session.updated.1", "session.updated", "session.created.1", "session.created": + if len(status) >= OpenCodeCoverageMaxIDs && !hasID(status, e.AggregateID) { + return next, true + } + status[e.AggregateID] = openCodeStatusReady + + default: + return next, true + } + + if e.EventID != "" { + newAnchors = append(newAnchors, OpenCodeJournalAnchor{ + RowID: e.RowID, + EventID: e.EventID, + AggregateID: e.AggregateID, + }) + } + } + + next.Anchors = mergeAnchors(checkpoint.Anchors, newAnchors) + var pendingIDs, readyIDs []string + for id, s := range status { + if s == openCodeStatusReady { + readyIDs = append(readyIDs, id) + } else { + pendingIDs = append(pendingIDs, id) + } + } + next.ReadyIDs = readyIDs + next.PendingIDs = pendingIDs + return next, false +} + +// classifyMessageUpdated decodes the narrow envelope of a message.updated.1 +// payload and determines whether the event represents settlement (assistant +// turn completed). Returns (settled, ok). ok=false means the payload is +// malformed or the envelope fails the identity cross-check → audit. +// +// Settlement requires: +// - info.role == "assistant" +// - info.time.completed is a JSON number (integer milliseconds) +// - sessionID == aggregate_id +// - info.sessionID == aggregate_id +// +// A missing info.time.completed is an ordinary deferral (settled=false, ok=true). +func classifyMessageUpdated(aggregateID string, data []byte) (settled, ok bool) { + var envelope struct { + SessionID string `json:"sessionID"` + Info struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` + Time *struct { + Completed *json.Number `json:"completed"` + } `json:"time"` + } `json:"info"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return false, false + } + // Identity cross-check. + if envelope.SessionID != aggregateID { + return false, false + } + if envelope.Info.SessionID != aggregateID { + return false, false + } + if envelope.Info.ID == "" { + return false, false + } + role := envelope.Info.Role + if role == "" { + return false, false + } + if role != "assistant" { + // User or other role: ordinary pending event, not an anomaly. + return false, true + } + // Assistant: check for completion timestamp. + if envelope.Info.Time == nil || envelope.Info.Time.Completed == nil { + // No completion field: ordinary deferral, not an anomaly. + return false, true + } + // Completed must be a number (integer milliseconds). + if _, err := envelope.Info.Time.Completed.Int64(); err != nil { + // Non-integer completion value → malformed → audit. + return false, false + } + return true, true +} + +// hasID reports whether id is a key in the status map. +func hasID(status map[string]openCodeEventStatus, id string) bool { + _, ok := status[id] + return ok +} + +// anchorsMaxRowID returns the maximum RowID in the anchor set, or 0. +func anchorsMaxRowID(anchors []OpenCodeJournalAnchor) int64 { + var max int64 + for _, a := range anchors { + if a.RowID > max { + max = a.RowID + } + } + return max +} + +// mergeAnchors produces the next committed anchor set from old (verified at +// drain start) and new (collected during this drain). Keeps up to +// openCodeMaxAnchors, preferring recent entries, spanning at least +// openCodeMinAnchorAggregates distinct aggregates where possible. +func mergeAnchors(old, newAnchors []OpenCodeJournalAnchor) []OpenCodeJournalAnchor { + // Index by rowid to deduplicate. + byRowID := make(map[int64]OpenCodeJournalAnchor, len(old)+len(newAnchors)) + for _, a := range old { + byRowID[a.RowID] = a + } + for _, a := range newAnchors { + byRowID[a.RowID] = a + } + + // Collect all and sort descending by rowid (most recent first). + all := make([]OpenCodeJournalAnchor, 0, len(byRowID)) + for _, a := range byRowID { + all = append(all, a) + } + sortAnchorsDesc(all) + + // Greedily select up to openCodeMaxAnchors, ensuring at least + // openCodeMinAnchorAggregates distinct aggregates. + selected := make([]OpenCodeJournalAnchor, 0, openCodeMaxAnchors) + aggSeen := make(map[string]bool) + for _, a := range all { + if len(selected) >= openCodeMaxAnchors { + break + } + selected = append(selected, a) + aggSeen[a.AggregateID] = true + } + // If we don't span enough aggregates, try again prioritizing coverage. + if len(aggSeen) < openCodeMinAnchorAggregates && len(all) > len(selected) { + selected = selected[:0] + aggSeen = make(map[string]bool) + // First pass: one from each aggregate (most recent per aggregate). + aggBest := make(map[string]OpenCodeJournalAnchor) + for _, a := range all { // all is descending; first per aggregate is best + if _, exists := aggBest[a.AggregateID]; !exists { + aggBest[a.AggregateID] = a + } + } + for _, a := range all { + if best, ok := aggBest[a.AggregateID]; ok && best.RowID == a.RowID { + selected = append(selected, a) + aggSeen[a.AggregateID] = true + if len(selected) >= openCodeMaxAnchors { + break + } + } + } + // Fill remaining slots from any anchor. + for _, a := range all { + if len(selected) >= openCodeMaxAnchors { + break + } + alreadyIn := false + for _, s := range selected { + if s.RowID == a.RowID { + alreadyIn = true + break + } + } + if !alreadyIn { + selected = append(selected, a) + } + } + sortAnchorsDesc(selected) + } + return selected +} + +// sortAnchorsDesc sorts anchors by RowID descending in place. +func sortAnchorsDesc(anchors []OpenCodeJournalAnchor) { + for i := 1; i < len(anchors); i++ { + for j := i; j > 0 && anchors[j].RowID > anchors[j-1].RowID; j-- { + anchors[j], anchors[j-1] = anchors[j-1], anchors[j] + } + } +} + +// sampleAnchors samples up to n (rowid, eventID, aggregateID) triples from +// [afterRowID, upToRowID], preferring the most recent rows. +func sampleAnchors( + ctx context.Context, tx *sql.Tx, + afterRowID, upToRowID int64, n int, +) ([]OpenCodeJournalAnchor, bool, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT rowid, id, aggregate_id + FROM event + WHERE rowid > ? AND rowid <= ? + ORDER BY rowid DESC + LIMIT ?`, + afterRowID, upToRowID, n, + ) + if err != nil { + return nil, false, openCodeJournalContextError(ctx, err) + } + defer rows.Close() + anchors := make([]OpenCodeJournalAnchor, 0, openCodeMaxAnchors) + for rows.Next() { + var a OpenCodeJournalAnchor + if err := rows.Scan(&a.RowID, &a.EventID, &a.AggregateID); err != nil { + return nil, false, openCodeJournalContextError(ctx, err) + } + anchors = append(anchors, a) + } + if rows.Err() != nil { + err := rows.Err() + return nil, false, openCodeJournalContextError(ctx, err) + } + // Return in ascending order. + for i, j := 0, len(anchors)-1; i < j; i, j = i+1, j-1 { + anchors[i], anchors[j] = anchors[j], anchors[i] + } + return anchors, true, nil +} + +func sampleLatestAggregateAnchors( + ctx context.Context, tx *sql.Tx, upToRowID int64, n int, +) ([]OpenCodeJournalAnchor, bool, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT recent.rowid, recent.id, recent.aggregate_id + FROM event AS recent + JOIN ( + SELECT aggregate_id, MAX(rowid) AS rowid + FROM ( + SELECT rowid, id, aggregate_id + FROM event + WHERE rowid <= ? + ORDER BY rowid DESC + LIMIT ? + ) AS tail + GROUP BY aggregate_id + ) AS latest ON latest.rowid = recent.rowid + ORDER BY recent.rowid DESC + LIMIT ? + `, upToRowID, openCodeAnchorSampleWindow, n) + if err != nil { + return nil, false, openCodeJournalContextError(ctx, err) + } + defer rows.Close() + anchors := make([]OpenCodeJournalAnchor, 0, n) + for rows.Next() { + var a OpenCodeJournalAnchor + if err := rows.Scan(&a.RowID, &a.EventID, &a.AggregateID); err != nil { + return nil, false, openCodeJournalContextError(ctx, err) + } + anchors = append(anchors, a) + } + if rows.Err() != nil { + return nil, false, openCodeJournalContextError(ctx, rows.Err()) + } + return anchors, true, nil +} + +func openCodeJournalContextError(ctx context.Context, err error) error { + if err == nil { + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return &OpenCodeFeedError{Kind: OpenCodeFeedOutcomeOperationalError, Err: err} +} diff --git a/internal/parser/opencode_change_feed_test.go b/internal/parser/opencode_change_feed_test.go new file mode 100644 index 000000000..b680628b7 --- /dev/null +++ b/internal/parser/opencode_change_feed_test.go @@ -0,0 +1,1142 @@ +// ABOUTME: Tests for the bounded OpenCode journal change feed covering the +// ABOUTME: reference model, two-stage admission, capability probe, cursor +// ABOUTME: continuity, and consumer routing. +package parser + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// openCodeJournalFixture is a writable OpenCode event journal fixture. +type openCodeJournalFixture struct { + db *sql.DB + path string +} + +// openCodeJournalSchema is the measured v1.18.10 journal DDL. +const openCodeJournalSchema = ` +CREATE TABLE IF NOT EXISTS event ( + id TEXT NOT NULL PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data BLOB NOT NULL +); +CREATE TABLE IF NOT EXISTS event_sequence ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT +); +` + +// openCodeJournalSchemaNoOwnerID is the DDL for a Kilo/MiMo fork that has +// the event tables but no owner_id column in event_sequence. Used by the fork +// exclusion test (row 9). +const openCodeJournalSchemaNoOwnerID = ` +CREATE TABLE IF NOT EXISTS event ( + id TEXT NOT NULL PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data BLOB NOT NULL +); +CREATE TABLE IF NOT EXISTS event_sequence ( + id TEXT NOT NULL PRIMARY KEY +); +` + +func newOpenCodeJournalFixture(t *testing.T) *openCodeJournalFixture { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "opencode.db") + db, err := sql.Open("sqlite3", path) + require.NoError(t, err) + _, err = db.Exec(openCodeJournalSchema) + require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + return &openCodeJournalFixture{db: db, path: path} +} + +// insertEvent appends one row to the event table and returns its rowid. +func (f *openCodeJournalFixture) insertEvent( + t *testing.T, id, aggregateID, typ string, data []byte, +) int64 { + t.Helper() + // seq is just the next integer for simplicity. + var seq int + _ = f.db.QueryRow("SELECT COALESCE(MAX(seq)+1,1) FROM event WHERE aggregate_id = ?", aggregateID).Scan(&seq) + res, err := f.db.Exec( + "INSERT INTO event (id, aggregate_id, seq, type, data) VALUES (?, ?, ?, ?, ?)", + id, aggregateID, seq, typ, data, + ) + require.NoError(t, err) + rowid, _ := res.LastInsertId() + return rowid +} + +// insertPartEvent inserts a message.part.updated.1 event (no settlement). +func (f *openCodeJournalFixture) insertPartEvent(t *testing.T, id, aggID string) int64 { + t.Helper() + payload, _ := json.Marshal(map[string]any{ + "sessionID": aggID, + "part": map[string]any{"id": id}, + }) + return f.insertEvent(t, id, aggID, "message.part.updated.1", payload) +} + +// insertSettledEvent inserts a message.updated.1 event with an integer +// info.time.completed (settlement). +func (f *openCodeJournalFixture) insertSettledEvent(t *testing.T, id, aggID string) int64 { + t.Helper() + payload := makeSettledPayload(t, id, aggID) + return f.insertEvent(t, id, aggID, "message.updated.1", payload) +} + +// insertPendingMsgEvent inserts a message.updated.1 event without settlement +// (assistant role, no completed field). +func (f *openCodeJournalFixture) insertPendingMsgEvent(t *testing.T, id, aggID string) int64 { + t.Helper() + payload, _ := json.Marshal(map[string]any{ + "sessionID": aggID, + "info": map[string]any{ + "id": id, "sessionID": aggID, "role": "assistant", + }, + }) + return f.insertEvent(t, id, aggID, "message.updated.1", payload) +} + +// insertUserMsgEvent inserts a message.updated.1 event for a user role. +func (f *openCodeJournalFixture) insertUserMsgEvent(t *testing.T, id, aggID string) int64 { + t.Helper() + payload, _ := json.Marshal(map[string]any{ + "sessionID": aggID, + "info": map[string]any{ + "id": id, "sessionID": aggID, "role": "user", + }, + }) + return f.insertEvent(t, id, aggID, "message.updated.1", payload) +} + +// insertSessionUpdatedEvent inserts a session.updated.1 event. +func (f *openCodeJournalFixture) insertSessionUpdatedEvent(t *testing.T, id, aggID string) int64 { + t.Helper() + payload, _ := json.Marshal(map[string]any{ + "sessionID": aggID, + "info": map[string]any{"id": aggID}, + }) + return f.insertEvent(t, id, aggID, "session.updated.1", payload) +} + +// deleteEventsForSession removes all event rows for one aggregate. +func (f *openCodeJournalFixture) deleteEventsForSession(t *testing.T, aggID string) { + t.Helper() + _, err := f.db.Exec("DELETE FROM event WHERE aggregate_id = ?", aggID) + require.NoError(t, err) +} + +// initDrain performs the initialization drain call and returns the initialized +// checkpoint. The caller must then add new events and call DrainOpenCodeJournal +// again to obtain real drain results. +func initDrain(t *testing.T, path string) OpenCodeCoverageCheckpoint { + t.Helper() + result, err := DrainOpenCodeJournal(context.Background(), path, OpenCodeCoverageCheckpoint{}) + require.NoError(t, err) + require.True(t, result.Next.Initialized, "first drain must initialize the checkpoint") + require.False(t, result.AuditRequired, "initialization must not latch an audit") + return result.Next +} + +// sortedIDs returns a sorted copy of ids for deterministic comparison. +func sortedIDs(ids []string) []string { + cp := append([]string(nil), ids...) + sort.Strings(cp) + return cp +} + +// makeSettledPayload builds a message.updated.1 JSON payload with a +// completed assistant turn. +func makeSettledPayload(t *testing.T, msgID, aggID string) []byte { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "sessionID": aggID, + "info": map[string]any{ + "id": msgID, + "sessionID": aggID, + "role": "assistant", + "time": map[string]any{"completed": json.Number("1234567890000")}, + }, + }) + require.NoError(t, err) + return payload +} + +// makeReferenceEvents builds OpenCodeJournalEventInput slices for the +// reference model tests. It includes no payload for part events and a full +// settled payload for settlement events. +func makeSettledRefEvent(rowid int64, eventID, aggID string) OpenCodeJournalEventInput { + payload := map[string]any{ + "sessionID": aggID, + "info": map[string]any{ + "id": eventID, + "sessionID": aggID, + "role": "assistant", + "time": map[string]any{"completed": json.Number("1234567890000")}, + }, + } + data, _ := json.Marshal(payload) + return OpenCodeJournalEventInput{ + RowID: rowid, EventID: eventID, AggregateID: aggID, + Type: "message.updated.1", Data: data, + } +} + +func makePartRefEvent(rowid int64, eventID, aggID string) OpenCodeJournalEventInput { + return OpenCodeJournalEventInput{ + RowID: rowid, EventID: eventID, AggregateID: aggID, + Type: "message.part.updated.1", Data: nil, // never read + } +} + +func makePendingMsgRefEvent(rowid int64, eventID, aggID string) OpenCodeJournalEventInput { + payload := map[string]any{ + "sessionID": aggID, + "info": map[string]any{ + "id": eventID, "sessionID": aggID, "role": "assistant", + }, + } + data, _ := json.Marshal(payload) + return OpenCodeJournalEventInput{ + RowID: rowid, EventID: eventID, AggregateID: aggID, + Type: "message.updated.1", Data: data, + } +} + +func makeSessionUpdatedRefEvent(rowid int64, eventID, aggID string) OpenCodeJournalEventInput { + return OpenCodeJournalEventInput{ + RowID: rowid, EventID: eventID, AggregateID: aggID, + Type: "session.updated.1", Data: nil, + } +} + +// TestOpenCodeStreamingPartsNoParse verifies the proof matrix row 2: +// streaming parts cause no parse. A reference-model sequence of many part +// updates followed by a settlement shows zero sessions in ReadyIDs until the +// settlement arrives. +func TestOpenCodeStreamingPartsNoParse(t *testing.T) { + const N = 20 + const aggID = "ses-stream" + + // Build N part updates, then one settlement. + events := make([]OpenCodeJournalEventInput, N+1) + for i := range N { + events[i] = makePartRefEvent(int64(i+1), fmt.Sprintf("part-%03d", i), aggID) + } + events[N] = makeSettledRefEvent(int64(N+1), "settled-msg", aggID) + + // After each part update: session should be pending, never ready. + cp := OpenCodeCoverageCheckpoint{} + for i := range N { + cp, _ = ReduceOpenCodeJournalEvents(cp, events[i:i+1]) + assert.Empty(t, cp.ReadyIDs, + "no session must be ready after only part updates (step %d)", i) + require.Contains(t, cp.PendingIDs, aggID, + "session must be pending after a part update (step %d)", i) + } + + // After the settlement: session moves to ReadyIDs. + cp, audit := ReduceOpenCodeJournalEvents(cp, events[N:N+1]) + assert.False(t, audit, "settlement must not trigger an audit") + assert.Contains(t, cp.ReadyIDs, aggID, "session must be ready after settlement") + assert.NotContains(t, cp.PendingIDs, aggID, + "session must not remain pending after settlement") +} + +// TestOpenCodeSettlementField verifies proof matrix row 3: settlement field. +// An adapter test over a real temporary SQLite fixture verifies that: +// - an assistant message.updated.1 with integer info.time.completed → ReadyIDs +// - a user message.updated.1 stays in PendingIDs +// - an assistant message.updated.1 without completed stays in PendingIDs +func TestOpenCodeSettlementField(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx := context.Background() + + // Initialize checkpoint against the empty fixture. + checkpoint := initDrain(t, f.path) + + // Insert three events for three distinct sessions. + f.insertSettledEvent(t, "settled-msg", "ses-settled") + f.insertUserMsgEvent(t, "user-msg", "ses-user") + f.insertPendingMsgEvent(t, "pending-msg", "ses-pending") + + // Drain should classify settled as ready, user and no-completed as pending. + result, err := DrainOpenCodeJournal(ctx, f.path, checkpoint) + require.NoError(t, err) + assert.False(t, result.AuditRequired, "valid events must not trigger an audit") + + // For the high-water drain that moves through all three events. + // If More=true (shouldn't be for 3 events), drain until done. + for result.More { + result, err = DrainOpenCodeJournal(ctx, f.path, result.Next) + require.NoError(t, err) + } + + assert.Contains(t, result.ReadyIDs, "ses-settled", + "assistant with completed must settle") + assert.NotContains(t, result.ReadyIDs, "ses-user", + "user update must not settle") + assert.NotContains(t, result.ReadyIDs, "ses-pending", + "assistant without completed must not settle") + assert.Contains(t, result.PendingIDs, "ses-user", + "user update must be pending") + assert.Contains(t, result.PendingIDs, "ses-pending", + "assistant without completed must be pending") +} + +func TestOpenCodeSessionMetadataEventIsReady(t *testing.T) { + cp, audit := ReduceOpenCodeJournalEvents( + OpenCodeCoverageCheckpoint{}, + []OpenCodeJournalEventInput{{ + RowID: 1, EventID: "session-update", AggregateID: "ses-metadata", + Type: "session.updated.1", + }}, + ) + assert.False(t, audit) + assert.Contains(t, cp.ReadyIDs, "ses-metadata", + "durable session metadata must reach the archive even without a settlement event") + assert.NotContains(t, cp.PendingIDs, "ses-metadata") +} + +func TestOpenCodeCapturedUnversionedEventNames(t *testing.T) { + cp, audit := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, []OpenCodeJournalEventInput{ + {RowID: 1, EventID: "part", AggregateID: "ses-part", Type: "message.part.updated"}, + {RowID: 2, EventID: "message", AggregateID: "ses-message", Type: "message.updated"}, + {RowID: 3, EventID: "session", AggregateID: "ses-session", Type: "session.updated"}, + }) + assert.False(t, audit, "captured unversioned producer names are valid") + assert.Contains(t, cp.PendingIDs, "ses-part") + assert.Contains(t, cp.PendingIDs, "ses-message") + assert.Contains(t, cp.ReadyIDs, "ses-session") +} + +func TestOpenCodeMissingDatabaseReturnsError(t *testing.T) { + _, err := DrainOpenCodeJournal( + context.Background(), filepath.Join(t.TempDir(), "missing.db"), + OpenCodeCoverageCheckpoint{Initialized: true}, + ) + assert.ErrorIs(t, err, ErrOpenCodeCoverageDatabaseMissing) +} + +func TestOpenCodeCancelledDrainReturnsContextError(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := DrainOpenCodeJournal(ctx, f.path, OpenCodeCoverageCheckpoint{}) + + assert.ErrorIs(t, err, context.Canceled) + assert.False(t, result.AuditRequired, + "caller cancellation must not latch a structural audit") +} + +// TestOpenCodeTrailingEventsDoNotDowngrade verifies proof matrix row 4: +// trailing events do not downgrade. A reference-model sequence of settlement +// followed by a message.updated.1 and then session.updated.1 leaves the +// identity in ReadyIDs throughout. +func TestOpenCodeTrailingEventsDoNotDowngrade(t *testing.T) { + const aggID = "ses-settled" + + settled := makeSettledRefEvent(1, "settled-msg", aggID) + trailingMsg := makePendingMsgRefEvent(2, "trailing-msg", aggID) + sessionUpdated := makeSessionUpdatedRefEvent(3, "session-update", aggID) + + cp, _ := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, []OpenCodeJournalEventInput{settled}) + assert.Contains(t, cp.ReadyIDs, aggID, "session must be ready after settlement") + + cp, audit := ReduceOpenCodeJournalEvents(cp, []OpenCodeJournalEventInput{trailingMsg}) + assert.False(t, audit, "trailing message.updated.1 must not audit") + assert.Contains(t, cp.ReadyIDs, aggID, + "trailing message.updated.1 must not downgrade from ready") + + cp, audit = ReduceOpenCodeJournalEvents(cp, []OpenCodeJournalEventInput{sessionUpdated}) + assert.False(t, audit, "session.updated.1 must not audit") + assert.Contains(t, cp.ReadyIDs, aggID, + "session.updated.1 must not downgrade from ready") +} + +// TestOpenCodePartAfterSettlementDowngrades verifies proof matrix row 5: +// a message.part.updated.1 after settlement returns the identity to pending. +func TestOpenCodePartAfterSettlementDowngrades(t *testing.T) { + const aggID = "ses-downgrade" + + settled := makeSettledRefEvent(1, "settled-msg", aggID) + part := makePartRefEvent(2, "late-part", aggID) + + cp, _ := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, []OpenCodeJournalEventInput{settled}) + assert.Contains(t, cp.ReadyIDs, aggID, "session must be ready after settlement") + + cp, audit := ReduceOpenCodeJournalEvents(cp, []OpenCodeJournalEventInput{part}) + assert.False(t, audit, "part update must not audit") + assert.NotContains(t, cp.ReadyIDs, aggID, + "part after settlement must downgrade from ready") + assert.Contains(t, cp.PendingIDs, aggID, + "part after settlement must return to pending") +} + +// TestOpenCodePageSplitEquivalence verifies proof matrix row 6: page-split +// equivalence. The reference model applied to all events in one pass equals +// the reference model applied in arbitrary two-way splits. +func TestOpenCodePageSplitEquivalence(t *testing.T) { + // Build an event sequence mixing part updates, settlements, and trailing events. + events := []OpenCodeJournalEventInput{ + makePartRefEvent(1, "part-a1", "ses-a"), + makePartRefEvent(2, "part-a2", "ses-a"), + makeSettledRefEvent(3, "settled-a", "ses-a"), + makePartRefEvent(4, "part-b1", "ses-b"), + makeSettledRefEvent(5, "settled-b", "ses-b"), + makePartRefEvent(6, "late-part-a", "ses-a"), // downgrades a again + makeSettledRefEvent(7, "re-settled-a", "ses-a"), + makeSessionUpdatedRefEvent(8, "su-b", "ses-b"), // trailing, no downgrade + } + + // Full-sequence result is the reference. + fullCP, fullAudit := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events) + assert.False(t, fullAudit, "no audit expected from valid events") + + // Every two-way split must agree with the full result. + n := len(events) + for split := 0; split <= n; split++ { + firstCP, _ := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events[:split]) + secondCP, audit := ReduceOpenCodeJournalEvents(firstCP, events[split:]) + assert.False(t, audit, "no audit for split at %d", split) + assert.Equal(t, + sortedIDs(fullCP.ReadyIDs), sortedIDs(secondCP.ReadyIDs), + "ReadyIDs must agree for split at %d", split) + assert.Equal(t, + sortedIDs(fullCP.PendingIDs), sortedIDs(secondCP.PendingIDs), + "PendingIDs must agree for split at %d", split) + } +} + +// TestOpenCodeOversizedPayloadNeverMaterialized verifies proof matrix row 7: +// a message.updated.1 row whose payload exceeds the byte budget triggers an +// audit without materializing the payload. +func TestOpenCodeOversizedPayloadNeverMaterialized(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx := context.Background() + + // Initialize against the empty fixture. + checkpoint := initDrain(t, f.path) + + // Insert an oversized message.updated.1 payload (> 1 MB). + // We use a large but otherwise structurally valid JSON blob. + oversized := make([]byte, OpenCodeCoverageMaxPayloadBytes+1) + // Fill with a JSON-like prefix so it doesn't fail fast on len check. + copy(oversized, []byte(`{"sessionID":"ses-big","info":{"id":"big","sessionID":"ses-big","role":"assistant"},"padding":"`)) + oversized[len(oversized)-2] = '"' + oversized[len(oversized)-1] = '}' + f.insertEvent(t, "big-msg", "ses-big", "message.updated.1", oversized) + + result, err := DrainOpenCodeJournal(ctx, f.path, checkpoint) + require.NoError(t, err) + + assert.True(t, result.AuditRequired, + "oversized payload must trigger an audit") + assert.Equal(t, 0, result.PayloadBytes, + "oversized payload must not be materialized") + assert.Equal(t, 1, result.RowsRead, + "the metadata row must be counted") +} + +// TestOpenCodeCapabilityRequiresFullSchema verifies proof matrix row 8: +// a container with owner_id present but a required event column missing is +// incompatible, so zero feed calls are made and base behavior is preserved. +func TestOpenCodeCapabilityRequiresFullSchema(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "opencode.db") + + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer db.Close() + + // event table is missing the 'data' column; event_sequence has owner_id. + _, err = db.Exec(` + CREATE TABLE event ( + id TEXT NOT NULL PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL + ); + CREATE TABLE event_sequence ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT + ); + `) + require.NoError(t, err) + + _, compatible, probeErr := ProbeOpenCodeJournalCapability( + context.Background(), dbPath, + ) + require.NoError(t, probeErr) + assert.False(t, compatible, + "missing 'data' column must make the container incompatible "+ + "even when owner_id is present") +} + +// TestOpenCodeForkExclusionByEvidence verifies proof matrix row 9: a +// Kilo-shaped fixture with the event DDL but no owner_id in event_sequence is +// incompatible. The check is purely schema-based, not agent-name-based. +func TestOpenCodeForkExclusionByEvidence(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "opencode.db") + + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer db.Close() + + // Full event DDL, but event_sequence lacks owner_id. + _, err = db.Exec(openCodeJournalSchemaNoOwnerID) + require.NoError(t, err) + + _, compatible, probeErr := ProbeOpenCodeJournalCapability( + context.Background(), dbPath, + ) + require.NoError(t, probeErr) + assert.False(t, compatible, + "absent owner_id must make the container incompatible; "+ + "no agent name is checked") +} + +// TestOpenCodeDeletedSessionAnchorAudits verifies that deleting every cursor +// witness requests an audit instead of trusting an unverifiable position. +func TestOpenCodeDeletedSessionAnchorContinues(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx := context.Background() + + // Insert events for session A (rowids 1-3) and B (rowids 4-6). + for i := 1; i <= 3; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-a%d", i), "ses-a", "session.created.1", + []byte(`{}`), + ) + } + for i := 1; i <= 3; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-b%d", i), "ses-b", "session.created.1", + []byte(`{}`), + ) + } + + // Initialize baseline: anchors across both sessions. + checkpoint := initDrain(t, f.path) + + // Override: keep only session A's anchors (simulating checkpoint where only A + // was witnessed). MAX(rowid)=6 will remain >= max committed anchor rowid (3). + checkpoint.Anchors = []OpenCodeJournalAnchor{ + {RowID: 3, EventID: "evt-a3", AggregateID: "ses-a"}, + {RowID: 2, EventID: "evt-a2", AggregateID: "ses-a"}, + {RowID: 1, EventID: "evt-a1", AggregateID: "ses-a"}, + } + checkpoint.HighWaterKnown = false + + // Delete session A's events: all committed anchors are now missing. + f.deleteEventsForSession(t, "ses-a") + + result, err := DrainOpenCodeJournal(ctx, f.path, checkpoint) + require.NoError(t, err) + assert.True(t, result.AuditRequired, + "missing cursor witnesses must request an audit") +} + +func TestOpenCodeDeletedCursorAnchorRewindsOnRowIDReuse(t *testing.T) { + f := newOpenCodeJournalFixture(t) + f.insertEvent(t, "evt-a", "ses-a", "session.created.1", []byte(`{}`)) + f.insertEvent(t, "evt-b", "ses-b", "session.created.1", []byte(`{}`)) + checkpoint := initDrain(t, f.path) + f.deleteEventsForSession(t, "ses-b") + f.insertSettledEvent(t, "evt-reused", "ses-reused") + + result, err := DrainOpenCodeJournal(context.Background(), f.path, checkpoint) + require.NoError(t, err) + assert.False(t, result.AuditRequired, + "a lower verified anchor permits safe rewind instead of an audit") + assert.Contains(t, result.ReadyIDs, "ses-reused", + "rewinding below a reused cursor row must replay the replacement event") +} + +func TestRebaselineOpenCodeCoverageCheckpointReplacesDeletedAnchors(t *testing.T) { + f := newOpenCodeJournalFixture(t) + for i := 1; i <= 3; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-a%d", i), "ses-a", "session.created.1", []byte(`{}`), + ) + } + for i := 1; i <= 3; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-b%d", i), "ses-b", "session.created.1", []byte(`{}`), + ) + } + checkpoint := initDrain(t, f.path) + checkpoint.Anchors = []OpenCodeJournalAnchor{ + {RowID: 3, EventID: "evt-a3", AggregateID: "ses-a"}, + } + f.deleteEventsForSession(t, "ses-a") + + rebased, err := RebaselineOpenCodeCoverageCheckpoint( + context.Background(), f.path, checkpoint, + ) + require.NoError(t, err) + assert.False(t, rebased.AuditLatched) + assert.NotEmpty(t, rebased.Anchors, + "an authoritative repair must install witnesses from the repaired high-water") + + f.insertSettledEvent(t, "evt-after-repair", "ses-c") + result, err := DrainOpenCodeJournal(context.Background(), f.path, rebased) + require.NoError(t, err) + assert.False(t, result.AuditRequired, + "the fresh post-audit witnesses must not trigger another audit") + assert.Contains(t, result.ReadyIDs, "ses-c", + "events committed after rebaseline must remain in the next drain") +} + +func TestRebaselineOpenCodeCoverageCheckpointPreservesAnchorDiversity(t *testing.T) { + f := newOpenCodeJournalFixture(t) + for i := 1; i <= 3; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-first%d", i), "ses-first", "session.created.1", []byte(`{}`), + ) + } + for i := 1; i <= 2; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-middle%d", i), "ses-middle", "session.created.1", []byte(`{}`), + ) + } + for i := 1; i <= openCodeMaxAnchors+4; i++ { + f.insertEvent(t, + fmt.Sprintf("evt-last%d", i), "ses-last", "session.created.1", []byte(`{}`), + ) + } + + rebased, err := RebaselineOpenCodeCoverageCheckpoint( + context.Background(), f.path, OpenCodeCoverageCheckpoint{Initialized: true}, + ) + require.NoError(t, err) + aggregates := make(map[string]struct{}) + for _, anchor := range rebased.Anchors { + aggregates[anchor.AggregateID] = struct{}{} + } + assert.GreaterOrEqual(t, len(aggregates), 2, + "post-audit anchors must retain multiple aggregate witnesses when available") +} + +// TestOpenCodeReplacementStillAudits verifies proof matrix row 11: +// even when MAX(rowid) is above the committed max (which would normally +// indicate ordinary deletion), a changed file identity overrides the inference +// and requests an audit. +func TestOpenCodeReplacementStillAudits(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "opencode.db") + ctx := context.Background() + + // Create DB1: the original container. + db1, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = db1.Exec(openCodeJournalSchema) + require.NoError(t, err) + + // Insert 3 events into DB1. + for i := 1; i <= 3; i++ { + _, err = db1.Exec( + "INSERT INTO event (id, aggregate_id, seq, type, data) VALUES (?,?,?,?,?)", + fmt.Sprintf("evt-%d", i), "ses-x", i, "session.created.1", []byte(`{}`), + ) + require.NoError(t, err) + } + db1.Close() + + // Initialize the checkpoint: captures DB1's file identity. + checkpoint := initDrain(t, dbPath) + require.True(t, checkpoint.Initialized) + // Sanity: identity was captured (non-zero on platforms that support it). + // On platforms where both are zero, skip the identity-change sub-check but + // still verify the high-rowid path: replace with a DB with MORE events. + + // Create DB2 beside DB1 before replacing the path, so filesystem identity + // allocation cannot reuse DB1's inode. + replacementPath := filepath.Join(dir, "replacement.db") + db2, err := sql.Open("sqlite3", replacementPath) + require.NoError(t, err) + _, err = db2.Exec(openCodeJournalSchema) + require.NoError(t, err) + for i := 1; i <= 10; i++ { + _, err = db2.Exec( + "INSERT INTO event (id, aggregate_id, seq, type, data) VALUES (?,?,?,?,?)", + fmt.Sprintf("new-evt-%d", i), "ses-y", i, "session.created.1", []byte(`{}`), + ) + require.NoError(t, err) + } + db2.Close() + require.NoError(t, os.Remove(dbPath)) + require.NoError(t, os.Rename(replacementPath, dbPath)) + + // Anchor identity, rather than a platform-specific file identity, detects + // replacement even when the replacement has a larger rowid high-water. + result, err := DrainOpenCodeJournal(ctx, dbPath, checkpoint) + require.NoError(t, err) + assert.True(t, result.AuditRequired, + "changed anchors must trigger audit even when MAX(rowid) > committed") +} + +func TestOpenCodeAnchorAggregateReuseStillAudits(t *testing.T) { + f := newOpenCodeJournalFixture(t) + f.insertEvent(t, "evt-a", "ses-a", "session.created.1", []byte(`{}`)) + checkpoint := initDrain(t, f.path) + _, err := f.db.Exec("UPDATE event SET aggregate_id = ? WHERE id = ?", "ses-reused", "evt-a") + require.NoError(t, err) + + result, err := DrainOpenCodeJournal(context.Background(), f.path, checkpoint) + require.NoError(t, err) + assert.True(t, result.AuditRequired, + "an anchor with a reused aggregate must not be accepted by event id alone") +} + +// TestOpenCodeContractIsolationReducer verifies proof matrix row 17: +// the reference model (ReduceOpenCodeJournalEvents) has no database, +// filesystem, or engine dependency. It is exercised here with pure event +// sequences to verify ordering, settlement, downgrade, and bound invariants. +func TestOpenCodeContractIsolationReducer(t *testing.T) { + t.Run("ordering/settlement", func(t *testing.T) { + // Settlement on any event causes the session to be ready. + events := []OpenCodeJournalEventInput{ + makePartRefEvent(1, "p1", "ses-a"), + makeSettledRefEvent(2, "s1", "ses-a"), + } + cp, audit := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events) + assert.False(t, audit) + assert.Contains(t, cp.ReadyIDs, "ses-a") + }) + + t.Run("downgrade/upward", func(t *testing.T) { + // Part after settlement downgrades; subsequent settlement re-upgrades. + events := []OpenCodeJournalEventInput{ + makeSettledRefEvent(1, "s1", "ses-b"), + makePartRefEvent(2, "p1", "ses-b"), + makeSettledRefEvent(3, "s2", "ses-b"), + } + cp, audit := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events) + assert.False(t, audit) + assert.Contains(t, cp.ReadyIDs, "ses-b") + assert.NotContains(t, cp.PendingIDs, "ses-b") + }) + + t.Run("bound/maxIDs", func(t *testing.T) { + // Inserting more than OpenCodeCoverageMaxIDs distinct sessions triggers audit. + events := make([]OpenCodeJournalEventInput, OpenCodeCoverageMaxIDs+1) + for i := range OpenCodeCoverageMaxIDs + 1 { + id := fmt.Sprintf("ses-%04d", i) + events[i] = makePartRefEvent(int64(i+1), fmt.Sprintf("p-%04d", i), id) + } + _, audit := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events) + assert.True(t, audit, + "more than MaxIDs distinct sessions must trigger audit") + }) + + t.Run("unrecognized type triggers audit", func(t *testing.T) { + events := []OpenCodeJournalEventInput{ + {RowID: 1, EventID: "e1", AggregateID: "ses-c", + Type: "message.unknown.99", Data: nil}, + } + _, audit := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events) + assert.True(t, audit, "unrecognized event type must trigger audit") + }) + + t.Run("no imports", func(t *testing.T) { + // This sub-test verifies the pure-function nature by calling + // ReduceOpenCodeJournalEvents without any I/O side-effects. + // If it compiles and runs without a database or filesystem, + // the contract-isolation property holds. + events := []OpenCodeJournalEventInput{ + makeSettledRefEvent(1, "e1", "ses-d"), + } + cp, _ := ReduceOpenCodeJournalEvents(OpenCodeCoverageCheckpoint{}, events) + assert.Contains(t, cp.ReadyIDs, "ses-d") + }) +} + +// TestOpenCodeAdapterFidelitySchemaMapping verifies proof matrix row 18: +// the adapter correctly maps v1.18.10 event types against a real fixture. +// An unrecognized event type version triggers an audit. +func TestOpenCodeAdapterFidelitySchemaMapping(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx := context.Background() + + t.Run("known event types map correctly", func(t *testing.T) { + checkpoint := initDrain(t, f.path) + + // One of each recognized type. + f.insertPartEvent(t, "known-part", "ses-known") + f.insertSettledEvent(t, "known-settled", "ses-known") + f.insertSessionUpdatedEvent(t, "known-su", "ses-known2") + f.insertEvent(t, "known-created", "ses-known2", "session.created.1", []byte(`{}`)) + + result, err := DrainOpenCodeJournal(ctx, f.path, checkpoint) + require.NoError(t, err) + for result.More { + result, err = DrainOpenCodeJournal(ctx, f.path, result.Next) + require.NoError(t, err) + } + assert.False(t, result.AuditRequired, + "all v1.18.10 event types must be recognized") + }) + + t.Run("unknown event type version triggers audit", func(t *testing.T) { + // Reset to a fresh fixture. + f2 := newOpenCodeJournalFixture(t) + checkpoint := initDrain(t, f2.path) + + f2.insertEvent(t, "unknown-type", "ses-unk", "message.unknown.99", []byte(`{}`)) + + result, err := DrainOpenCodeJournal(ctx, f2.path, checkpoint) + require.NoError(t, err) + assert.True(t, result.AuditRequired, + "unrecognized event type must latch audit") + }) +} + +// openCodeFormatAgents is the set of agents that share the OpenCode SQLite +// container format and therefore declare BoundedCoverage=CapabilitySupported. +// Per-container schema admission (ProbeOpenCodeJournalCapability) gates whether +// the feed is actually used; incompatible containers (e.g. Kilo, MiMoCode, +// ICodeMate, which ship the journal tables empty or absent) degrade to existing +// base behavior without reading the journal. +var openCodeFormatAgents = map[AgentType]bool{ + AgentOpenCode: true, + AgentKilo: true, + AgentMiMoCode: true, + AgentIcodemate: true, +} + +// TestOpenCodeConsumerRoutingCapabilityOptIn verifies proof matrix row 19: +// all opencode-format providers declare BoundedCoverage=CapabilitySupported; +// non-opencode providers do not. Per-container schema evidence (not agent name) +// gates the actual feed construction. +func TestOpenCodeConsumerRoutingCapabilityOptIn(t *testing.T) { + factories := ProviderFactories() + require.NotEmpty(t, factories, "ProviderFactories must return at least one factory") + + foundOpenCode := false + for _, factory := range factories { + caps := factory.Capabilities() + def := factory.Definition() + agent := def.Type + + if openCodeFormatAgents[agent] { + if agent == AgentOpenCode { + foundOpenCode = true + } + assert.Equal(t, CapabilitySupported, caps.Source.BoundedCoverage, + "opencode-format agent %q must declare BoundedCoverage=CapabilitySupported; "+ + "per-container schema evidence gates actual feed use, not agent name", agent) + } else { + assert.NotEqual(t, CapabilitySupported, caps.Source.BoundedCoverage, + "non-opencode-format agent %q must not declare BoundedCoverage=CapabilitySupported", agent) + } + } + + assert.True(t, foundOpenCode, + "AgentOpenCode must appear in ProviderFactories") +} + +// TestOpenCodeProbeCompatibleContainer verifies that ProbeOpenCodeJournalCapability +// returns true for a valid v1.18.10 schema. +func TestOpenCodeProbeCompatibleContainer(t *testing.T) { + f := newOpenCodeJournalFixture(t) + + schemaVersion, compatible, err := ProbeOpenCodeJournalCapability( + context.Background(), f.path, + ) + require.NoError(t, err) + assert.True(t, compatible, "valid journal schema must be compatible") + assert.NotZero(t, schemaVersion, "schema version must be readable") +} + +func TestOpenCodeProbePropagatesCancellation(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, err := ProbeOpenCodeJournalCapability(ctx, f.path) + assert.ErrorIs(t, err, context.Canceled) +} + +// TestOpenCodeProbeIncompatibleContainers covers rejection cases for +// ProbeOpenCodeJournalCapability: missing owner_id, missing required event +// column, and an extra additive column that must not block admission. +func TestOpenCodeProbeIncompatibleContainers(t *testing.T) { + ctx := context.Background() + + t.Run("owner_id absent", func(t *testing.T) { + // event_sequence without owner_id (Kilo/MiMo fork DDL). + dir := t.TempDir() + path := filepath.Join(dir, "opencode.db") + db, err := sql.Open("sqlite3", path) + require.NoError(t, err) + _, err = db.Exec(openCodeJournalSchemaNoOwnerID) + require.NoError(t, err) + db.Close() + + _, compatible, err := ProbeOpenCodeJournalCapability(ctx, path) + require.NoError(t, err) + assert.False(t, compatible, + "container without owner_id must not be admitted to the bounded feed") + }) + + t.Run("required event column absent", func(t *testing.T) { + // event table missing the data column. + dir := t.TempDir() + path := filepath.Join(dir, "opencode.db") + db, err := sql.Open("sqlite3", path) + require.NoError(t, err) + _, err = db.Exec(` +CREATE TABLE IF NOT EXISTS event ( + id TEXT NOT NULL PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS event_sequence ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT +);`) + require.NoError(t, err) + db.Close() + + _, compatible, err := ProbeOpenCodeJournalCapability(ctx, path) + require.NoError(t, err) + assert.False(t, compatible, + "container missing a required event column must not be admitted") + }) + + t.Run("additive column still admits", func(t *testing.T) { + // event table has all required columns plus an extra one. + dir := t.TempDir() + path := filepath.Join(dir, "opencode.db") + db, err := sql.Open("sqlite3", path) + require.NoError(t, err) + _, err = db.Exec(` +CREATE TABLE IF NOT EXISTS event ( + id TEXT NOT NULL PRIMARY KEY, + aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + data BLOB NOT NULL, + extra TEXT +); +CREATE TABLE IF NOT EXISTS event_sequence ( + id TEXT NOT NULL PRIMARY KEY, + owner_id TEXT +);`) + require.NoError(t, err) + db.Close() + + _, compatible, err := ProbeOpenCodeJournalCapability(ctx, path) + require.NoError(t, err) + assert.True(t, compatible, + "schema with an additive column must still be admitted") + }) + + t.Run("empty tables admit and drain nothing", func(t *testing.T) { + // Valid schema, no rows. Probe must admit; drain must return empty. + f := newOpenCodeJournalFixture(t) + + _, compatible, err := ProbeOpenCodeJournalCapability(ctx, f.path) + require.NoError(t, err) + assert.True(t, compatible, "empty tables must be admitted") + + // Baseline drain on empty DB. + init0, err := DrainOpenCodeJournal(ctx, f.path, OpenCodeCoverageCheckpoint{}) + require.NoError(t, err) + assert.True(t, init0.Next.Initialized, "first drain must initialize") + assert.Empty(t, init0.ReadyIDs, "empty DB must produce no ready IDs on init") + + // Second drain must also produce nothing. + result, err := DrainOpenCodeJournal(ctx, f.path, init0.Next) + require.NoError(t, err) + assert.False(t, result.More, "empty DB must not report continuation") + assert.Empty(t, result.ReadyIDs, "empty DB must produce no ready IDs") + assert.Empty(t, result.PendingIDs, "empty DB must produce no pending IDs") + }) +} + +// TestOpenCodeDrainBoundaryConstants verifies that the exported constants used +// by the proof matrix assertions are present and sensible. +func TestOpenCodeDrainBoundaryConstants(t *testing.T) { + assert.Equal(t, 256, OpenCodeCoverageMaxRows, + "MaxRows must be pinned at 256") + assert.Equal(t, 1<<20, OpenCodeCoverageMaxPayloadBytes, + "MaxPayloadBytes must be pinned at 1<<20") + assert.Equal(t, 256, OpenCodeCoverageMaxIDs, + "MaxIDs must be pinned at 256") +} + +// TestOpenCodeFirstWakeBaselinePreservation is a light reference-model check +// for proof matrix row 15 (first-wake baseline). The production test lives in +// internal/sync. Here we verify that DrainOpenCodeJournal on a freshly +// initialized checkpoint does NOT consume events committed before +// initialization — those events appear in the NEXT drain. +func TestOpenCodeFirstWakeBaselinePreservation(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx := context.Background() + + // Seed 3 events BEFORE initialization. + for i := 1; i <= 3; i++ { + f.insertPartEvent(t, fmt.Sprintf("pre-init-%d", i), "ses-pre") + } + + // Initialize: baseline should anchor at rowid=3. No events returned. + initResult, err := DrainOpenCodeJournal(ctx, f.path, OpenCodeCoverageCheckpoint{}) + require.NoError(t, err) + assert.Empty(t, initResult.ReadyIDs) + assert.Empty(t, initResult.PendingIDs) + checkpoint := initResult.Next + + // Add a settling event AFTER baseline. + f.insertSettledEvent(t, "post-init-settled", "ses-post") + + // First real drain: should capture the post-init settled event. + result, err := DrainOpenCodeJournal(ctx, f.path, checkpoint) + require.NoError(t, err) + for result.More { + result, err = DrainOpenCodeJournal(ctx, f.path, result.Next) + require.NoError(t, err) + } + assert.Contains(t, result.ReadyIDs, "ses-post", + "event committed after baseline must appear in the first post-init drain") + assert.NotContains(t, result.ReadyIDs, "ses-pre", + "events committed before baseline must not appear in ReadyIDs "+ + "(they were already settled when baseline was captured)") +} + +// TestOpenCodeSchemaVersionChangeTriggersAudit verifies that a schema version +// change between two drain calls latches an audit. +func TestOpenCodeSchemaVersionChangeTriggersAudit(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "opencode.db") + ctx := context.Background() + + db, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + _, err = db.Exec(openCodeJournalSchema) + require.NoError(t, err) + + // Initialize. + checkpoint := initDrain(t, dbPath) + + // Bump the schema version by adding a column. + _, err = db.Exec("ALTER TABLE event ADD COLUMN extra TEXT") + require.NoError(t, err) + db.Close() + + // Next drain must detect the schema change. + result, err := DrainOpenCodeJournal(ctx, dbPath, checkpoint) + require.NoError(t, err) + assert.True(t, result.AuditRequired, + "schema version change must latch an audit") + + _ = strings.Contains // suppress unused import +} + +// TestOpenCodeDrainRowCapAndContinuation drives a real SQLite feed past +// OpenCodeCoverageMaxRows (256) rows and verifies that the first page reports +// RowsRead == 256 with More == true, then a continuation drain captures the +// overflow and terminates cleanly. +// +// The test uses many message.part.updated.1 events on a small set of sessions +// so the row count exceeds 256 without bumping into the 256-session ID cap. +func TestOpenCodeDrainRowCapAndContinuation(t *testing.T) { + f := newOpenCodeJournalFixture(t) + ctx := context.Background() + + // Baseline: no prior events. + checkpoint := initDrain(t, f.path) + + // Insert 260 part events across 4 sessions (65 parts each). Streaming + // parts never settle, so no stage-2 payload fetch occurs. The session count + // (4) stays far below OpenCodeCoverageMaxIDs (256), isolating the row cap. + const ( + sessions = 4 + perSession = 65 // 4 * 65 = 260 rows > OpenCodeCoverageMaxRows (256) + ) + for i := range sessions * perSession { + agg := fmt.Sprintf("ses-%d", i%sessions) + id := fmt.Sprintf("part-%04d", i) + f.insertPartEvent(t, id, agg) + } + + // First drain must hit the row cap. + r1, err := DrainOpenCodeJournal(ctx, f.path, checkpoint) + require.NoError(t, err) + require.False(t, r1.AuditRequired, "row cap must not latch an audit") + assert.True(t, r1.More, + "drain must report More=true when row count exceeds OpenCodeCoverageMaxRows") + assert.Equal(t, OpenCodeCoverageMaxRows, r1.RowsRead, + "RowsRead must equal OpenCodeCoverageMaxRows on a capped page") + + // Continuation drain must terminate cleanly and read the overflow rows. + r2, err := DrainOpenCodeJournal(ctx, f.path, r1.Next) + require.NoError(t, err) + require.False(t, r2.AuditRequired, "continuation must not latch an audit") + assert.False(t, r2.More, + "continuation drain must exhaust the remaining rows and return More=false") + overflow := sessions*perSession - OpenCodeCoverageMaxRows + assert.GreaterOrEqual(t, r2.RowsRead, overflow, + "continuation must read at least the overflow rows") + + // Final page emits PendingIDs (all sessions still streaming). + // All 4 sessions must appear somewhere in the final result. + seenPending := make(map[string]bool, len(r2.PendingIDs)) + for _, id := range r2.PendingIDs { + seenPending[id] = true + } + for i := range sessions { + agg := fmt.Sprintf("ses-%d", i) + assert.True(t, seenPending[agg], + "session %s must appear in PendingIDs after full drain", agg) + } +} + +func TestOpenCodeDrainChangedSessionWorkIsArchiveCardinalityBounded(t *testing.T) { + measure := func(sessionCount int) (int, int) { + t.Helper() + f := newOpenCodeJournalFixture(t) + for i := range sessionCount { + f.insertSessionUpdatedEvent( + t, fmt.Sprintf("baseline-event-%05d", i), fmt.Sprintf("session-%05d", i), + ) + } + checkpoint := initDrain(t, f.path) + f.insertSessionUpdatedEvent(t, "changed-event", "session-00000") + result, err := DrainOpenCodeJournal(context.Background(), f.path, checkpoint) + require.NoError(t, err) + require.False(t, result.AuditRequired) + return result.RowsRead, result.PayloadBytes + } + + smallRows, smallPayload := measure(10) + largeRows, largePayload := measure(5000) + assert.Equal(t, smallRows, largeRows, + "changed-session journal work must not scale with archive cardinality") + assert.Equal(t, smallPayload, largePayload, + "changed-session payload work must not scale with archive cardinality") + assert.Equal(t, 1, largeRows, + "the production drain must read only the changed journal row") +} diff --git a/internal/parser/opencode_coverage_units_test.go b/internal/parser/opencode_coverage_units_test.go new file mode 100644 index 000000000..43f3ede52 --- /dev/null +++ b/internal/parser/opencode_coverage_units_test.go @@ -0,0 +1,305 @@ +package parser + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var openCodeFamilyWatchUnitAgents = []struct { + agent AgentType + dbName string + sessionSubdir string +}{ + {agent: AgentOpenCode, dbName: "opencode.db", sessionSubdir: "session"}, + {agent: AgentKilo, dbName: "kilo.db", sessionSubdir: "session"}, + {agent: AgentMiMoCode, dbName: "mimocode.db", sessionSubdir: "session_diff"}, + {agent: AgentIcodemate, dbName: "icodemate.db", sessionSubdir: "session_diff"}, +} + +func openCodeFamilyContainerUnit( + agent AgentType, dbName, root string, +) WatchRoot { + return WatchRoot{ + Path: root, + Recursive: false, + IncludeGlobs: []string{dbName, dbName + "-wal"}, + DebounceKey: string(agent) + ":container:" + root, + } +} + +func openCodeFamilyStorageUnit(agent AgentType, root string) WatchRoot { + return WatchRoot{ + Path: filepath.Join(root, "storage"), + Recursive: true, + Optional: true, + IncludeGlobs: []string{"*.json"}, + DebounceKey: string(agent) + ":storage:" + root, + } +} + +// TestOpenCodeWatchUnits pins the two-unit emission shape +// for every OpenCode-family agent across resolved mode, database presence, +// and root existence: both the shallow container unit and the recursive +// storage unit are emitted from the configured plan, including a missing +// storage directory. +func TestOpenCodeWatchUnits(t *testing.T) { + for _, agentCase := range openCodeFamilyWatchUnitAgents { + t.Run(string(agentCase.agent), func(t *testing.T) { + _, ok := ProviderFactoryByType(agentCase.agent) + require.True(t, ok, + "the provider factory must exist so the typed WatchPlan, "+ + "not the legacy WatchRootsFunc fallback, owns the plan") + + for _, tc := range []struct { + name string + setup func(t *testing.T) string + withStorage bool + }{ + { + name: "missing root", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "absent") + }, + }, + { + name: "mode none", + setup: func(t *testing.T) string { + return t.TempDir() + }, + }, + { + name: "sqlite", + setup: func(t *testing.T) string { + root := t.TempDir() + writeTestFileHelper(t, + filepath.Join(root, agentCase.dbName)) + return root + }, + }, + { + name: "storage", + setup: func(t *testing.T) string { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join( + root, "storage", agentCase.sessionSubdir, + ), 0o755)) + return root + }, + withStorage: true, + }, + { + name: "hybrid", + setup: func(t *testing.T) string { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join( + root, "storage", agentCase.sessionSubdir, + ), 0o755)) + writeTestFileHelper(t, + filepath.Join(root, agentCase.dbName)) + return root + }, + withStorage: true, + }, + { + // The session subdirectory is created lazily, so a root + // can carry storage/ with nothing under it yet. The + // session tree that appears there later is a grandchild + // of the configured root, which the shallow unit cannot + // see, so this shape must still get recursive coverage. + name: "storage tree before first session", + setup: func(t *testing.T) string { + root := t.TempDir() + require.NoError(t, os.MkdirAll( + filepath.Join(root, "storage"), 0o755)) + return root + }, + withStorage: true, + }, + { + name: "sqlite with empty storage tree", + setup: func(t *testing.T) string { + root := t.TempDir() + require.NoError(t, os.MkdirAll( + filepath.Join(root, "storage"), 0o755)) + writeTestFileHelper(t, + filepath.Join(root, agentCase.dbName)) + return root + }, + withStorage: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + root := tc.setup(t) + provider, ok := NewProvider(agentCase.agent, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + + want := []WatchRoot{openCodeFamilyContainerUnit( + agentCase.agent, agentCase.dbName, root, + )} + want = append(want, openCodeFamilyStorageUnit( + agentCase.agent, root, + )) + assert.Equal(t, want, plan.Roots) + }) + } + }) + } +} + +// TestOpenCodeSourcesForChangedPathUnitScope pins the unit-scope guard: with +// two units per hybrid root, exactly one unit claims each changed path, so a +// WAL event never runs the SQLite fan-out once per unit, and an empty +// WatchRoot keeps unscoped behavior for callers that do not dispatch per +// watch root. +func TestOpenCodeSourcesForChangedPathUnitScope(t *testing.T) { + fixture := openCodeSQLiteProviderReadFixture(t) + root := fixture.Root + storageUnit := filepath.Join(root, "storage") + storageSessionPath := writeOpenCodeProviderStorageSession( + t, root, "session", "ses_hybrid_store", "hybrid-app", "Hybrid", + ) + walPath := fixture.DBPath + "-wal" + require.NoError(t, os.WriteFile( + walPath, bytes.Repeat([]byte{0x1}, 64), 0o644, + ), "a WAL larger than its header carries frames") + + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + changed := func(path, watchRoot string) []SourceRef { + t.Helper() + sources, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: path, EventKind: "write", WatchRoot: watchRoot, + }, + ) + require.NoError(t, err) + return sources + } + + t.Run("wal claimed by container unit only", func(t *testing.T) { + assert.Empty(t, changed(walPath, storageUnit), + "a WAL event against the storage unit must not fan out") + containerSources := changed(walPath, root) + require.Len(t, containerSources, len(fixture.SessionIDs), + "the container unit owns the SQLite fan-out") + assert.Equal(t, containerSources, changed(walPath, ""), + "an empty WatchRoot must behave exactly like base") + }) + + t.Run("storage session claimed by storage unit only", func(t *testing.T) { + storageSources := changed(storageSessionPath, storageUnit) + require.Len(t, storageSources, 1) + assert.Equal(t, storageSessionPath, storageSources[0].DisplayPath) + assert.Empty(t, changed(storageSessionPath, root), + "the container unit must not double-claim storage paths") + assert.Equal(t, storageSources, changed(storageSessionPath, ""), + "an empty WatchRoot must behave exactly like base") + }) + + t.Run("virtual path scoped by its database path", func(t *testing.T) { + assert.Empty(t, changed(fixture.SQLiteVirtualPath, storageUnit), + "a virtual source's database is outside the storage unit") + containerSources := changed(fixture.SQLiteVirtualPath, root) + require.Len(t, containerSources, 1) + assert.Equal(t, containerSources, + changed(fixture.SQLiteVirtualPath, ""), + "an empty WatchRoot must behave exactly like base") + }) +} + +// TestOpenCodeWatchUnitsCoverAllDiscoveredSources maps every discovered +// source's physical path onto an emitted unit: virtual SQLite sources resolve +// to a database that is a direct child of the shallow container unit, and +// storage sources sit at or under the recursive storage unit, so the split +// plan loses no coverage relative to the single recursive root. +func TestOpenCodeWatchUnitsCoverAllDiscoveredSources(t *testing.T) { + fixture := openCodeSQLiteProviderReadFixture(t) + root := fixture.Root + writeOpenCodeProviderStorageSession( + t, root, "session", "ses_cover_store", "cover-app", "Coverage", + ) + + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + var container, storage WatchRoot + for _, unit := range plan.Roots { + if unit.Recursive { + storage = unit + } else { + container = unit + } + } + require.NotEmpty(t, container.Path, "hybrid plan must emit a container unit") + require.NotEmpty(t, storage.Path, "hybrid plan must emit a storage unit") + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, discovered) + for _, source := range discovered { + physical := source.DisplayPath + if dbPath, _, virtual := parseOpenCodeFormatVirtualPath( + "opencode.db", physical, + ); virtual { + assert.Equal(t, container.Path, filepath.Dir(dbPath), + "virtual source %s must resolve to a direct child of the "+ + "container unit", physical) + continue + } + _, under := relUnder(storage.Path, physical) + assert.True(t, under, + "storage source %s must live under the storage unit", physical) + } +} + +// TestNonOpenCodeWatchPlanUnchanged pins another provider family's plan value +// so the coverage-unit split provably stays inside the OpenCode family. +func TestNonOpenCodeWatchPlanUnchanged(t *testing.T) { + root := t.TempDir() + provider, ok := NewProvider(AgentGemini, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + tmp := filepath.Join(root, "tmp") + assert.Equal(t, []WatchRoot{ + { + Path: tmp, + Recursive: true, + IncludeGlobs: []string{"session-*.json", "session-*.jsonl"}, + DebounceKey: string(AgentGemini) + ":tmp:" + tmp, + }, + { + Path: root, + Recursive: false, + IncludeGlobs: []string{"projects.json", "trustedFolders.json"}, + DebounceKey: string(AgentGemini) + ":projects:" + root, + }, + }, plan.Roots) +} + +func writeTestFileHelper(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("stub"), 0o644)) +} diff --git a/internal/parser/opencode_provider.go b/internal/parser/opencode_provider.go index c6efd6474..a6d948012 100644 --- a/internal/parser/opencode_provider.go +++ b/internal/parser/opencode_provider.go @@ -59,7 +59,7 @@ func (f openCodeFormatProviderFactory) Definition() AgentDef { } func (f openCodeFormatProviderFactory) Capabilities() Capabilities { - return openCodeFormatProviderCapabilities() + return openCodeFormatProviderCapabilities(f.def.Type) } func (f openCodeFormatProviderFactory) NewProvider(cfg ProviderConfig) Provider { @@ -67,7 +67,7 @@ func (f openCodeFormatProviderFactory) NewProvider(cfg ProviderConfig) Provider return &openCodeFormatProvider{ ProviderBase: ProviderBase{ Def: cloneAgentDef(f.def), - Caps: openCodeFormatProviderCapabilities(), + Caps: openCodeFormatProviderCapabilities(f.def.Type), Config: cfg, }, sources: newOpenCodeFormatSourceSet( @@ -81,6 +81,16 @@ type openCodeFormatProvider struct { sources openCodeFormatSourceSet } +type OpenCodeBoundedSourceRequest struct { + RawSessionID string + PhysicalDBPath string + ProviderScope string +} + +type OpenCodeBoundedSourceBinder interface { + FindBoundedSource(context.Context, OpenCodeBoundedSourceRequest) (SourceRef, bool, error) +} + func (p *openCodeFormatProvider) Discover(ctx context.Context) ([]SourceRef, error) { return p.sources.Discover(ctx) } @@ -93,6 +103,25 @@ func (p *openCodeFormatProvider) WatchPlan(ctx context.Context) (WatchPlan, erro return p.sources.WatchPlan(ctx) } +// BoundedCoverageIdentity resolves the database and its watch scope once at +// the provider boundary so callers retain physical ownership across aliases. +func (p *openCodeFormatProvider) BoundedCoverageIdentity( + ctx context.Context, dbPath, scope string, +) (string, string, error) { + if err := ctx.Err(); err != nil { + return "", "", err + } + physicalDBPath, err := filepath.EvalSymlinks(dbPath) + if err != nil { + return "", "", err + } + physicalScope, err := filepath.EvalSymlinks(scope) + if err != nil { + return "", "", err + } + return filepath.Clean(physicalDBPath), filepath.Clean(physicalScope), nil +} + func (p *openCodeFormatProvider) SourcesForChangedPath( ctx context.Context, req ChangedPathRequest, @@ -136,7 +165,13 @@ func (p *openCodeFormatProvider) FindSource( req FindSourceRequest, ) (SourceRef, bool, error) { req = ProviderFindRequestWithRawSessionID(p.Def, req) - return p.sources.FindSource(ctx, req) + return p.sources.findSource(ctx, req, "", "") +} + +func (p *openCodeFormatProvider) FindBoundedSource( + ctx context.Context, req OpenCodeBoundedSourceRequest, +) (SourceRef, bool, error) { + return p.sources.findSource(ctx, FindSourceRequest{RawSessionID: req.RawSessionID}, req.PhysicalDBPath, req.ProviderScope) } func (p *openCodeFormatProvider) Fingerprint( @@ -333,12 +368,6 @@ func (spec openCodeProviderSpec) find(root, sessionID string) string { return findOpenCodeFormatSourceFile(spec.format, root, sessionID) } -// watchRoots returns the directories that should be watched for live -// updates under a configured root. -func (spec openCodeProviderSpec) watchRoots(root string) []string { - return resolveOpenCodeFormatWatchRoots(spec.format, root) -} - // storageIDs returns the set of session IDs present as storage JSON // under a root, used to skip duplicate SQLite metas in hybrid roots. func (spec openCodeProviderSpec) storageIDs(root string) map[string]struct{} { @@ -666,22 +695,39 @@ func (s openCodeFormatSourceSet) discoverStorageEach( func (s openCodeFormatSourceSet) WatchPlan(context.Context) (WatchPlan, error) { roots := make([]WatchRoot, 0, len(s.roots)) for _, root := range s.roots { - for _, watchRoot := range s.spec.watchRoots(root) { - roots = append(roots, WatchRoot{ - Path: watchRoot, - Recursive: true, - IncludeGlobs: []string{ - "*.json", - s.spec.dbName, - s.spec.dbName + "-wal", - }, - DebounceKey: string(s.spec.agent) + ":opencode:" + watchRoot, - }) - } + roots = append(roots, s.watchUnits(root)...) } return WatchPlan{Roots: roots}, nil } +// watchUnits returns the coverage units for one configured root. The shallow +// container unit is always emitted: the SQLite database, its WAL, and the +// storage/ directory lifecycle are all direct children of the root, and a +// non-recursive watch never competes for the shared recursive watch budget, +// so SQLite coverage cannot be starved by archive size. The recursive storage +// unit is emitted from the configured plan even when /storage is absent. +// It remains observable for creation and removal, but its absence does not +// make the SQLite container unavailable. +func (s openCodeFormatSourceSet) watchUnits(root string) []WatchRoot { + units := []WatchRoot{{ + Path: root, + Recursive: false, + IncludeGlobs: []string{ + s.spec.dbName, + s.spec.dbName + "-wal", + }, + DebounceKey: string(s.spec.agent) + ":container:" + root, + }} + units = append(units, WatchRoot{ + Path: filepath.Join(root, "storage"), + Recursive: true, + Optional: true, + IncludeGlobs: []string{"*.json"}, + DebounceKey: string(s.spec.agent) + ":storage:" + root, + }) + return units +} + // reconciliationContainer maps a requested path to the SQLite container that // atomically owns it, without statting: a deleted database must still resolve // so its members remain reclaimable through the container proof. A virtual @@ -715,6 +761,9 @@ func (s openCodeFormatSourceSet) SourcesForChangedPath( if err := ctx.Err(); err != nil { return nil, err } + if !s.unitScopeAllows(req) { + return nil, nil + } if dbPath, _, virtual := s.spec.parseVirtual(req.Path); virtual { for _, root := range s.roots { if _, under := relUnder(root, dbPath); !under { @@ -746,6 +795,47 @@ func (s openCodeFormatSourceSet) SourcesForChangedPath( return nil, nil } +// unitScopeAllows scopes changed-path classification to the coverage unit +// that observed the path. The engine calls SourcesForChangedPath once per +// emitted watch root per event, so with two units per root an unscoped WAL +// event would run the SQLite fan-out twice. A request whose path (or, for a +// virtual path, its physical database path) lies outside req.WatchRoot is +// another unit's event and yields no sources; when the container unit's root +// also emits a recursive storage unit, paths inside that storage subtree +// belong to the storage unit so exactly one unit claims each changed path. +// An empty req.WatchRoot preserves unscoped behavior for callers that do not +// dispatch per watch root. +func (s openCodeFormatSourceSet) unitScopeAllows(req ChangedPathRequest) bool { + if req.WatchRoot == "" { + return true + } + path := req.Path + if dbPath, _, virtual := s.spec.parseVirtual(req.Path); virtual { + path = dbPath + } + if !pathAtOrUnder(req.WatchRoot, path) { + return false + } + watchRoot := filepath.Clean(req.WatchRoot) + for _, root := range s.roots { + if watchRoot != filepath.Clean(root) { + continue + } + _, insideStorage := relUnder(filepath.Join(root, "storage"), path) + return !insideStorage + } + return true +} + +// pathAtOrUnder reports whether path is root itself or contained within it. +func pathAtOrUnder(root, path string) bool { + if filepath.Clean(root) == filepath.Clean(path) { + return true + } + _, under := relUnder(root, path) + return under +} + func (s openCodeFormatSourceSet) SourceForReconciliation( ctx context.Context, path, project string, ) (SourceRef, bool, error) { @@ -802,6 +892,12 @@ func (s openCodeFormatSourceSet) canonicalVirtualSource( func (s openCodeFormatSourceSet) FindSource( ctx context.Context, req FindSourceRequest, +) (SourceRef, bool, error) { + return s.findSource(ctx, req, "", "") +} + +func (s openCodeFormatSourceSet) findSource( + ctx context.Context, req FindSourceRequest, physicalDBPath, providerScope string, ) (SourceRef, bool, error) { if err := ctx.Err(); err != nil { return SourceRef{}, false, err @@ -812,6 +908,9 @@ func (s openCodeFormatSourceSet) FindSource( } for _, root := range s.roots { if source, ok := s.sourceRef(root, path, true); ok { + if !s.matchesBoundedSource(source, physicalDBPath, providerScope) { + continue + } return source, true, nil } } @@ -825,12 +924,50 @@ func (s openCodeFormatSourceSet) FindSource( continue } if source, ok := s.sourceRef(root, path, false); ok { + if !s.matchesBoundedSource(source, physicalDBPath, providerScope) { + continue + } return source, true, nil } } return SourceRef{}, false, nil } +func (s openCodeFormatSourceSet) matchesBoundedSource(source SourceRef, physicalDBPath, providerScope string) bool { + if physicalDBPath == "" && providerScope == "" { + return true + } + if providerScope != "" { + matched := false + for _, root := range s.roots { + if filepath.Clean(root) == filepath.Clean(providerScope) { + matched = true + break + } + } + if !matched { + return false + } + } + if physicalDBPath == "" { + return true + } + path, ok := s.pathFromSource(source) + if !ok { + return false + } + dbPath := path + if resolved, _, virtual := s.spec.parseVirtual(path); virtual { + dbPath = resolved + } + physical, err := filepath.EvalSymlinks(dbPath) + if err != nil { + return false + } + requested, err := filepath.EvalSymlinks(physicalDBPath) + return err == nil && filepath.Clean(physical) == filepath.Clean(requested) +} + // sourceMtimeWithComposite resolves a source's change signal when discovery did // not carry one (FindSource lookups, storage sessions), reporting whether the // value is the per-session composite. @@ -1500,12 +1637,18 @@ func findOpenCodeProviderStorageSessionIDByMessageID( return "" } -func openCodeFormatProviderCapabilities() Capabilities { +func openCodeFormatProviderCapabilities(_ AgentType) Capabilities { + // All opencode-format providers declare BoundedCoverage as supported, + // regardless of agent name. Per-container schema admission happens at + // coverage unit creation time via ProbeOpenCodeJournalCapability, so + // containers that do not carry the live event journal (Kilo, MiMoCode, + // ICodeMate) are gated by schema evidence rather than agent name. return Capabilities{ Source: SourceCapabilities{ DiscoverSources: CapabilitySupported, StreamingDiscovery: CapabilitySupported, WatchSources: CapabilitySupported, + BoundedCoverage: CapabilitySupported, ClassifyChangedPath: CapabilitySupported, ChangedPathRelevance: CapabilitySupported, FindSource: CapabilitySupported, diff --git a/internal/parser/opencode_provider_test.go b/internal/parser/opencode_provider_test.go index ef2d0e4bb..00e01db52 100644 --- a/internal/parser/opencode_provider_test.go +++ b/internal/parser/opencode_provider_test.go @@ -389,9 +389,15 @@ 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, []string{"opencode.db", "opencode.db-wal"}, + plan.Roots[0].IncludeGlobs) + assert.Equal(t, filepath.Join(root, "storage"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.True(t, plan.Roots[1].Optional) + assert.Equal(t, []string{"*.json"}, plan.Roots[1].IncludeGlobs) discovered, err := provider.Discover(context.Background()) require.NoError(t, err) @@ -479,6 +485,29 @@ func TestOpenCodeProviderStorageSourceMethods(t *testing.T) { assert.Equal(t, "global", removed[0].ProjectHint) } +func TestOpenCodeFindSourceHonorsBoundedPhysicalBinding(t *testing.T) { + dbPath, seeder, db := newTestDB(t) + root := filepath.Dir(dbPath) + seeder.AddProject("project", root) + seeder.AddSession("same", "project", "", "same", 1, 1) + provider, ok := NewProvider(AgentOpenCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + binder, ok := provider.(OpenCodeBoundedSourceBinder) + require.True(t, ok) + _, found, err := binder.FindBoundedSource(context.Background(), OpenCodeBoundedSourceRequest{ + RawSessionID: "same", PhysicalDBPath: dbPath, ProviderScope: root, + }) + require.NoError(t, err) + assert.True(t, found) + _, found, err = binder.FindBoundedSource(context.Background(), OpenCodeBoundedSourceRequest{ + RawSessionID: "same", PhysicalDBPath: filepath.Join(t.TempDir(), "other.db"), ProviderScope: root, + }) + require.NoError(t, err) + assert.False(t, found, "a raw ID must not cross the admitted physical database") + _ = db +} + func TestOpenCodeProviderSQLiteSourceMethods(t *testing.T) { fixture := openCodeSQLiteProviderReadFixture(t) @@ -494,12 +523,18 @@ 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, + "the configured plan must retain the storage lifecycle unit") assert.Equal(t, root, plan.Roots[0].Path) - assert.True(t, plan.Roots[0].Recursive) + assert.False(t, plan.Roots[0].Recursive, + "the container unit must stay budget-exempt") assert.Equal(t, []string{ - "*.json", "opencode.db", "opencode.db-wal", + "opencode.db", "opencode.db-wal", }, plan.Roots[0].IncludeGlobs) + assert.Equal(t, filepath.Join(root, "storage"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.True(t, plan.Roots[1].Optional) + assert.Equal(t, []string{"*.json"}, plan.Roots[1].IncludeGlobs) discovered, err := provider.Discover(context.Background()) require.NoError(t, err) diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 3ec6bb49b..d67fb2914 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -365,16 +365,42 @@ func reconciliationProofSpelling(root string) string { return filepath.Clean(root) } -// reconciliationScopeSamePath and reconciliationScopeWithinOrSame compare with -// filepath.Rel semantics so equality matches the platform: case-folded per -// element on Windows, byte-exact elsewhere. +// reconciliationScopeIdentity resolves existing aliases before comparing them. +// When the leaf is deleted, resolve the longest existing parent and append the +// missing suffix so an alias-spelled container still matches its physical +// counterpart during removal reconciliation. +func reconciliationScopeIdentity(path string) string { + cleaned := cleanReconciliationScopeRoot(path) + missing := make([]string, 0, 2) + for candidate := cleaned; ; candidate = filepath.Dir(candidate) { + if resolved, err := filepath.EvalSymlinks(candidate); err == nil { + identity := cleanReconciliationScopeRoot(resolved) + for i := len(missing) - 1; i >= 0; i-- { + identity = filepath.Join(identity, missing[i]) + } + return cleanReconciliationScopeRoot(identity) + } + parent := filepath.Dir(candidate) + if parent == candidate { + break + } + missing = append(missing, filepath.Base(candidate)) + } + return cleaned +} + +// reconciliationScopeSamePath and reconciliationScopeWithinOrSame compare +// with filepath.Rel semantics so equality matches the platform: case-folded +// per element on Windows, byte-exact elsewhere. func reconciliationScopeSamePath(a, b string) bool { - rel, err := filepath.Rel(a, b) + rel, err := filepath.Rel(reconciliationScopeIdentity(a), reconciliationScopeIdentity(b)) return err == nil && rel == "." } func reconciliationScopeWithinOrSame(path, root string) bool { - rel, err := filepath.Rel(root, path) + rel, err := filepath.Rel( + reconciliationScopeIdentity(root), reconciliationScopeIdentity(path), + ) if err != nil { return false } @@ -672,6 +698,7 @@ func watchRootMetadata(roots []WatchRoot) []WatchRoot { out = append(out, WatchRoot{ Path: root.Path, Recursive: root.Recursive, + Optional: root.Optional, DebounceKey: root.DebounceKey, }) } @@ -685,6 +712,7 @@ func watchRootMetadata(roots []WatchRoot) []WatchRoot { type WatchRoot struct { Path string Recursive bool + Optional bool IncludeGlobs []string ExcludeGlobs []string DebounceKey string diff --git a/internal/sync/engine.go b/internal/sync/engine.go index ae9fa3407..d3631d205 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -316,10 +316,13 @@ type Engine struct { blockedResultCategories map[string]bool cwdFilter cwdPrefixFilter syncMu gosync.Mutex // serializes all sync operations - mu gosync.RWMutex - lastSync time.Time - lastSyncStats SyncStats - currentProgress *Progress + // boundedCoverageGenerations is owned only by syncMu. It is the engine's + // acceptance point for replacement and apply, not coordinator metadata. + boundedCoverageGenerations map[string]uint64 + mu gosync.RWMutex + lastSync time.Time + lastSyncStats SyncStats + currentProgress *Progress // skipCache tracks paths that should be skipped on // subsequent syncs, keyed by path with the file mtime // at time of caching. Covers parse errors and @@ -549,31 +552,32 @@ func NewEngine( } e := &Engine{ - db: database, - stat: os.Stat, - lstat: os.Lstat, - agentDirs: dirs, - sourceMachines: sourceMachines, - machine: cfg.Machine, - blockedResultCategories: blockedCategorySet(cfg.BlockedResultCategories), - cwdFilter: newCwdPrefixFilter(cfg.IncludeCwdPrefixes), - skipCache: skipCache, - skipFingerprints: make(map[string]string), - skipHashKeys: skipHashKeys, - s3CodexIndexCache: make(map[string]s3CodexIndexSnapshot), - ephemeral: cfg.Ephemeral, - idPrefix: cfg.IDPrefix, - pathRewriter: cfg.PathRewriter, - emitter: cfg.Emitter, - providerFactories: providerFactoryMap(providerFactories), - providerMigrationModes: providerModes, - providerWatchRoots: make(map[parser.AgentType][]parser.WatchRoot), - projectIdentityCache: make(map[string]projectIdentityCacheEntry), - projectIdentityWritten: make(map[string]struct{}), - startupMaintenanceReady: make(chan struct{}), - startupReconciledReady: make(chan struct{}), - startupAttemptReady: make(chan struct{}), - onStartupReconciled: cfg.OnStartupReconciled, + db: database, + stat: os.Stat, + lstat: os.Lstat, + agentDirs: dirs, + sourceMachines: sourceMachines, + machine: cfg.Machine, + blockedResultCategories: blockedCategorySet(cfg.BlockedResultCategories), + cwdFilter: newCwdPrefixFilter(cfg.IncludeCwdPrefixes), + skipCache: skipCache, + boundedCoverageGenerations: make(map[string]uint64), + skipFingerprints: make(map[string]string), + skipHashKeys: skipHashKeys, + s3CodexIndexCache: make(map[string]s3CodexIndexSnapshot), + ephemeral: cfg.Ephemeral, + idPrefix: cfg.IDPrefix, + pathRewriter: cfg.PathRewriter, + emitter: cfg.Emitter, + providerFactories: providerFactoryMap(providerFactories), + providerMigrationModes: providerModes, + providerWatchRoots: make(map[parser.AgentType][]parser.WatchRoot), + projectIdentityCache: make(map[string]projectIdentityCacheEntry), + projectIdentityWritten: make(map[string]struct{}), + startupMaintenanceReady: make(chan struct{}), + startupReconciledReady: make(chan struct{}), + startupAttemptReady: make(chan struct{}), + onStartupReconciled: cfg.OnStartupReconciled, reconciliationSpoolFactory: func(path string) (reconciliationSpoolStore, error) { return newReconciliationSpool(path) }, @@ -1014,21 +1018,90 @@ func (e *Engine) SyncPathsContext(ctx context.Context, paths []string) error { if len(files) == 0 && len(missingPaths) == 0 { return classificationErr } + _, err := e.syncDiscoveredFilesContext( + ctx, files, missingPaths, classificationErr, preContainerStates, + ) + return err +} + +// SyncSourceRefsContext applies a provider-resolved ready set through the same +// write stage as changed-path synchronization. It deliberately does not +// reclassify virtual paths or advance a feed checkpoint itself. +func (e *Engine) SyncSourceRefsContext( + ctx context.Context, sources []parser.SourceRef, +) (SyncStats, error) { + if e.refuseWriteInForceParse("SyncSourceRefs") || len(sources) == 0 { + return SyncStats{}, nil + } + files := make([]parser.DiscoveredFile, 0, len(sources)) + paths := make([]string, 0, len(sources)) + for i := range sources { + source := sources[i] + if source.Provider == "" || source.DisplayPath == "" { + return SyncStats{}, fmt.Errorf("bounded coverage source %q is incomplete", source.Key) + } + files = append(files, parser.DiscoveredFile{ + Path: source.DisplayPath, Agent: source.Provider, + ProviderSource: &source, ProviderProcess: true, + }) + paths = append(paths, source.DisplayPath) + } + return e.syncDiscoveredFilesContext( + ctx, files, nil, nil, e.captureSQLiteContainerStates(paths), + ) +} +func (e *Engine) syncSourceRefsContextLocked( + ctx context.Context, sources []parser.SourceRef, +) (SyncStats, error) { + if e.refuseWriteInForceParse("SyncSourceRefs") || len(sources) == 0 { + return SyncStats{}, nil + } + files := make([]parser.DiscoveredFile, 0, len(sources)) + paths := make([]string, 0, len(sources)) + for i := range sources { + source := sources[i] + if source.Provider == "" || source.DisplayPath == "" { + return SyncStats{}, fmt.Errorf("bounded coverage source %q is incomplete", source.Key) + } + files = append(files, parser.DiscoveredFile{ + Path: source.DisplayPath, Agent: source.Provider, + ProviderSource: &source, ProviderProcess: true, + }) + paths = append(paths, source.DisplayPath) + } + stats, _, err := e.syncDiscoveredFilesContextLocked( + ctx, files, nil, nil, e.captureSQLiteContainerStates(paths), + ) + return stats, err +} + +func (e *Engine) syncDiscoveredFilesContext( + ctx context.Context, + files []parser.DiscoveredFile, + missingPaths []string, + classificationErr error, + preContainerStates map[string]parser.SQLiteContainerState, +) (SyncStats, error) { e.syncMu.Lock() - // Defers run LIFO: the emit closure (declared first) runs AFTER - // syncMu.Unlock, so an Emitter implementation cannot widen the - // critical section or deadlock by re-entering sync code. The - // stats variable is captured by the closure and populated below. + stats, tombstoned, err := e.syncDiscoveredFilesContextLocked(ctx, files, missingPaths, classificationErr, preContainerStates) + e.syncMu.Unlock() + if stats.Synced > 0 || tombstoned > 0 || stats.sourceMissingTombstoned > 0 { + e.emit("sessions") + } + return stats, err +} + +func (e *Engine) syncDiscoveredFilesContextLocked( + ctx context.Context, + files []parser.DiscoveredFile, + missingPaths []string, + classificationErr error, + preContainerStates map[string]parser.SQLiteContainerState, +) (SyncStats, int, error) { + var stats SyncStats var tombstoned int - defer func() { - if stats.Synced > 0 || tombstoned > 0 || - stats.sourceMissingTombstoned > 0 { - e.emit("sessions") - } - }() - defer e.syncMu.Unlock() defer e.clearCurrentProgress() e.resetS3CodexIndexCache() @@ -1056,7 +1129,7 @@ func (e *Engine) SyncPathsContext(ctx context.Context, paths []string) error { var err error tombstoned, err = e.tombstoneMissingWatchSourcesLocked(ctx, missingPaths, nil) if err != nil { - return fmt.Errorf("watcher source tombstone: %w", err) + return stats, tombstoned, fmt.Errorf("watcher source tombstone: %w", err) } } e.mu.Lock() @@ -1070,15 +1143,15 @@ func (e *Engine) SyncPathsContext(ctx context.Context, paths []string) error { ) } if err := errors.Join(classificationErr, ctx.Err()); err != nil { - return err + return stats, tombstoned, err } if !complete { - return fmt.Errorf( + return stats, tombstoned, fmt.Errorf( "changed-path sync incomplete: %d source or archive failures", stats.Failed, ) } - return nil + return stats, tombstoned, nil } // omitMissingPersistentContainerPaths drops missing changed paths that are @@ -3624,10 +3697,11 @@ func (e *Engine) reconcileScopedWatchRoots( // exactly when the pass itself would have. func (e *Engine) reconcileScopedWatchRootsLocked( ctx context.Context, agent parser.AgentType, roots []string, full, force bool, + excluded ...map[parser.AgentType]struct{}, ) (SyncStats, int, passEpilogueEligibility, error) { fullCoverage := full || (agent == "" && len(roots) == 0) plans, excludedRemoteRoots := e.resolveReconciliationPlans( - ctx, agent, roots, full, fullCoverage, + ctx, agent, roots, full, fullCoverage, excluded..., ) if !fullCoverage && !reconciliationPlansNeedPass(plans) { // No provider resolved any scope for the request: every root was @@ -3667,6 +3741,30 @@ func (e *Engine) ReconcileWatchRootsAfterLostEvents( return e.reconcileWatchRoots(ctx, roots, full, true) } +// ReconcileWatchRootsExcludingAgents keeps an unscoped recovery on every +// physical root while omitting providers already handled by grouped dispatch. +// A shared physical root must not be removed just because one provider owns a +// grouped pass for it. +func (e *Engine) ReconcileWatchRootsExcludingAgents( + ctx context.Context, roots []string, excluded []parser.AgentType, lostEvents bool, +) error { + excludedSet := make(map[parser.AgentType]struct{}, len(excluded)) + for _, agent := range excluded { + excludedSet[agent] = struct{}{} + } + stats, tombstoned, _, err := func() (SyncStats, int, passEpilogueEligibility, error) { + e.syncMu.Lock() + defer e.syncMu.Unlock() + return e.reconcileScopedWatchRootsLocked( + ctx, "", roots, false, lostEvents, excludedSet, + ) + }() + if stats.Synced > 0 || tombstoned > 0 { + e.emit("sessions") + } + return err +} + // ReconciliationRootsForAgent returns every configured root for one provider. // Directory rename events use the complete provider scope because FSEvents may // report only one endpoint of a move between that provider's roots. @@ -3694,6 +3792,7 @@ func (e *Engine) resolveReconciliationPlans( agentFilter parser.AgentType, roots []string, full, fullCoverage bool, + excluded ...map[parser.AgentType]struct{}, ) ([]providerReconciliationPlan, int) { agents := make([]parser.AgentType, 0, len(e.providerFactories)) for agent := range e.providerFactories { @@ -3704,6 +3803,11 @@ func (e *Engine) resolveReconciliationPlans( }) var plans []providerReconciliationPlan for _, agent := range agents { + if len(excluded) > 0 { + if _, skip := excluded[0][agent]; skip { + continue + } + } if e.providerMigrationModes[agent] != parser.ProviderMigrationProviderAuthoritative { continue } @@ -4455,7 +4559,7 @@ func (e *Engine) reconciliationCandidate( if agent == "" { agent = provider.Definition().Type } - identity := reconciliationSourceIdentity(agent, source) + identity := reconciliationSourceIdentity(provider, source) if identity == "" { return reconciliationCandidate{}, false } @@ -4479,11 +4583,7 @@ func (e *Engine) reconciliationCandidate( if agent == parser.AgentCodex && codexLayoutForPath(path) == parser.CodexLayoutDated { preference1 = 1 } - if isOpenCodeFormatAgent(agent) { - if statPath == path { - preference1 = 1 - } - } else if agent != parser.AgentClaude && agent != parser.AgentCodex { + if agent != parser.AgentClaude && agent != parser.AgentCodex { for i, configured := range roots { if samePathOrDescendant(statPath, configured) { preference1 = int64(len(roots) - i) @@ -4517,20 +4617,18 @@ func boolPreference(value bool) int64 { return 0 } -func reconciliationSourceIdentity(agent parser.AgentType, source parser.SourceRef) string { +func reconciliationSourceIdentity(provider parser.Provider, source parser.SourceRef) string { + if resolver, ok := provider.(parser.ReconciliationMemberIdentityResolver); ok { + for _, candidate := range []string{source.Key, source.FingerprintKey, source.DisplayPath} { + if identity := resolver.ReconciliationMemberIdentity(candidate); identity != "" { + return identity + } + } + } + agent := provider.Definition().Type if agent == parser.AgentClaude { return claudeSessionIDFromPath(providerDiscoveredPath(source)) } - if isOpenCodeFormatAgent(agent) { - path := providerDiscoveredPath(source) - if statPath := validatedProviderSourceStatPath(path); statPath != path { - _, sessionID, _ := parser.ParseVirtualSourcePath(path) - return sessionID - } - if strings.EqualFold(filepath.Ext(path), ".json") { - return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - } - } for _, candidate := range []string{source.Key, source.FingerprintKey, source.DisplayPath} { if candidate != "" { return canonicalReconciliationSourceIdentity(candidate) @@ -4539,15 +4637,6 @@ func reconciliationSourceIdentity(agent parser.AgentType, source parser.SourceRe return "" } -func isOpenCodeFormatAgent(agent parser.AgentType) bool { - switch agent { - case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode, parser.AgentIcodemate: - return true - default: - return false - } -} - func reconciliationWatchRoot( path string, watchRoots []parser.WatchRoot, configuredRoots []string, ) string { @@ -4588,7 +4677,7 @@ func (e *Engine) rehydrateReconciliationPage( if err != nil { return nil, fmt.Errorf("rehydrate %s source %s: %w", candidate.Provider, candidate.Path, err) } - if found && reconciliationSourceIdentity(candidate.Provider, source) == candidate.Identity { + if found && reconciliationSourceIdentity(provider, source) == candidate.Identity { files = append(files, parser.DiscoveredFile{ Path: candidate.Path, Project: source.ProjectHint, Agent: candidate.Provider, ForceParse: forceCandidate, @@ -4606,7 +4695,7 @@ func (e *Engine) rehydrateReconciliationPage( } var matched *parser.SourceRef for i := range sources { - if reconciliationSourceIdentity(candidate.Provider, sources[i]) == candidate.Identity && + if reconciliationSourceIdentity(provider, sources[i]) == candidate.Identity && sameReconciliationSourcePath(providerDiscoveredPath(sources[i]), candidate.Path) { matched = &sources[i] break diff --git a/internal/sync/opencode_bounded_coverage.go b/internal/sync/opencode_bounded_coverage.go new file mode 100644 index 000000000..ffb01f2dd --- /dev/null +++ b/internal/sync/opencode_bounded_coverage.go @@ -0,0 +1,579 @@ +package sync + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "go.kenn.io/agentsview/internal/parser" +) + +// BoundedCoverageRoot is provider-owned scope metadata supplied by the poll +// coordinator. The provider capability, not the agent label, admits feed mode. +type BoundedCoverageRoot struct { + Agent parser.AgentType + Root string +} + +// BoundedCoverageBinding is the immutable coordinator key for one provider +// coverage obligation. +type BoundedCoverageBinding struct { + Key string + Agent parser.AgentType + DBPath string + PhysicalDBPath string + Scope string + Generation uint64 +} + +// BoundedCoverageFileIdentity is the physical file fence carried by one +// lease. It is value data so the worker process can enforce the same fence. +type BoundedCoverageFileIdentity struct { + Path string `json:"path"` + Inode int64 `json:"inode"` + Device int64 `json:"device"` +} + +// BoundedCoverageLease is the immutable authority for one bounded lifecycle. +// Coordinator status stays outside this value; consumers receive this lease +// unchanged from admission through source application and repair. +type BoundedCoverageLease struct { + Binding BoundedCoverageBinding `json:"binding"` + Provider parser.AgentType `json:"provider"` + PhysicalDBPath string `json:"physical_db_path"` + ExactProviderScope string `json:"exact_provider_scope"` + Generation uint64 `json:"generation"` + FileIdentity BoundedCoverageFileIdentity `json:"file_identity"` + AdmissionCheckpoint parser.OpenCodeCoverageCheckpoint `json:"admission_checkpoint"` + PendingWork []string `json:"pending_work,omitempty"` + AdmissionRowZero bool `json:"admission_row_zero"` + Reason string `json:"reason"` + fileInfo os.FileInfo `json:"-"` +} + +type boundedCoverageIdentityProvider interface { + BoundedCoverageIdentity(context.Context, string, string) (string, string, error) +} + +// BoundedCoverageScope is the provider-resolved physical scope retained by a +// binding. It prevents retries from reconstructing ownership from all roots. +type BoundedCoverageScope struct { + Agent parser.AgentType + Root string +} + +type BoundedCoverageResolver interface { + BoundedCoverageBindings(context.Context, []BoundedCoverageRoot) ([]BoundedCoverageBinding, error) + BoundedCoverageBindingsForPaths(context.Context, []string) ([]BoundedCoverageBinding, []string, error) + DrainBoundedCoverage(context.Context, BoundedCoverageBinding, parser.OpenCodeCoverageCheckpoint) (parser.OpenCodeFeedResult, []parser.SourceRef, error) + ApplyBoundedCoverageSources(context.Context, []parser.SourceRef) (SyncStats, error) +} + +type BoundedCoverageLeaseResolver interface { + AdmitBoundedCoverageLease(context.Context, BoundedCoverageBinding) (*BoundedCoverageLease, error) + DrainBoundedCoverageLease(context.Context, *BoundedCoverageLease, parser.OpenCodeCoverageCheckpoint) (parser.OpenCodeFeedResult, []parser.SourceRef, error) + TransitionBoundedCoverageRequest(context.Context, *BoundedCoverageLease, []parser.SourceRef, parser.OpenCodeCoverageCheckpoint, bool) (BoundedCoverageTransitionResult, error) + ReconcileBoundedCoverageLease(context.Context, *BoundedCoverageLease, string) error + ReconcileBoundedCoverageSourceLease(context.Context, *BoundedCoverageLease, string) error +} + +type BoundedCoverageTransitionResult struct { + Stats SyncStats + Checkpoint parser.OpenCodeCoverageCheckpoint + Generation uint64 +} + +// BoundedCoverageAdmitter owns the row-zero admission transition. It is kept +// separate from the read/apply resolver so test and generic providers cannot +// accidentally acquire lifecycle authority. +type BoundedCoverageAdmitter interface { + InitializeBoundedCoverage(context.Context, BoundedCoverageBinding) (parser.OpenCodeCoverageCheckpoint, error) +} + +var ( + ErrBoundedCoverageAuditRequired = errors.New("bounded coverage requires authoritative audit") + ErrBoundedCoverageUnresolved = errors.New("bounded coverage source unresolved") +) + +func (e *Engine) BoundedCoverageBindings( + ctx context.Context, roots []BoundedCoverageRoot, +) ([]BoundedCoverageBinding, error) { + bindings := make([]BoundedCoverageBinding, 0) + seen := make(map[string]struct{}) + for _, requested := range roots { + factory := e.providerFactories[requested.Agent] + if factory == nil || factory.Capabilities().Source.BoundedCoverage != parser.CapabilitySupported { + continue + } + providerRoot := filepath.Clean(requested.Root) + for _, candidate := range e.agentDirs[requested.Agent] { + if sameCoveragePath(candidate, providerRoot) || withinOrEqual(providerRoot, candidate) { + providerRoot = candidate + break + } + } + provider := factory.NewProvider(parser.ProviderConfig{Roots: []string{providerRoot}, Machine: e.machine}) + plan, err := provider.WatchPlan(ctx) + if err != nil { + return nil, err + } + for _, watchRoot := range plan.Roots { + for _, include := range watchRoot.IncludeGlobs { + dbPath := coverageDBPath(watchRoot.Path, include) + if watchRoot.Recursive || (!sameCoveragePath(dbPath, requested.Root) && !withinOrEqual(dbPath, requested.Root)) { + continue + } + if _, compatible, err := parser.ProbeOpenCodeJournalCapability(ctx, dbPath); err != nil { + if errors.Is(err, parser.ErrOpenCodeCoverageDatabaseMissing) { + continue + } + return nil, err + } else if !compatible { + continue + } + physicalDBPath, scope, err := boundedCoverageIdentity(ctx, provider, dbPath, watchRoot.Path) + if err != nil { + return nil, err + } + key := boundedCoverageBindingKey(requested.Agent, physicalDBPath, scope) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + bindings = append(bindings, BoundedCoverageBinding{ + Key: key, Agent: requested.Agent, DBPath: physicalDBPath, + PhysicalDBPath: physicalDBPath, Scope: scope, + }) + } + } + } + return bindings, nil +} + +func (e *Engine) BoundedCoverageBindingsForPaths( + ctx context.Context, paths []string, +) ([]BoundedCoverageBinding, []string, error) { + bindings := make([]BoundedCoverageBinding, 0) + remaining := make([]string, 0, len(paths)) + seen := make(map[string]struct{}) + for _, path := range paths { + matched := false + for agent, factory := range e.providerFactories { + if factory == nil || factory.Capabilities().Source.BoundedCoverage != parser.CapabilitySupported { + continue + } + provider := factory.NewProvider(parser.ProviderConfig{Roots: e.agentDirs[agent], Machine: e.machine}) + plan, err := provider.WatchPlan(ctx) + if err != nil { + return nil, nil, err + } + for _, watchRoot := range plan.Roots { + for _, include := range watchRoot.IncludeGlobs { + dbPath := coverageDBPath(watchRoot.Path, include) + if watchRoot.Recursive || !sameCoveragePath(path, dbPath) && + !sameCoveragePath(path, dbPath+"-wal") && + !sameCoveragePath(path, dbPath+"-shm") { + continue + } + relevance, err := parser.ResolveChangedPathRelevance( + ctx, provider, parser.ChangedPathRequest{ + Path: path, WatchRoot: watchRoot.Path, + }, + ) + if err != nil { + return nil, nil, err + } + if relevance == parser.ChangedPathNonData { + continue + } + if _, compatible, err := parser.ProbeOpenCodeJournalCapability(ctx, dbPath); err != nil { + if errors.Is(err, parser.ErrOpenCodeCoverageDatabaseMissing) { + continue + } + return nil, nil, err + } else if !compatible { + continue + } + matched = true + physicalDBPath, scope, err := boundedCoverageIdentity(ctx, provider, dbPath, watchRoot.Path) + if err != nil { + return nil, nil, err + } + key := boundedCoverageBindingKey(agent, physicalDBPath, scope) + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + bindings = append(bindings, BoundedCoverageBinding{ + Key: key, Agent: agent, DBPath: physicalDBPath, + PhysicalDBPath: physicalDBPath, Scope: scope, + }) + } + } + } + } + if !matched { + remaining = append(remaining, path) + } + } + return bindings, remaining, nil +} + +// DrainBoundedCoverage performs provider admission, bounded journal reading, +// and complete ready-set resolution. It has no checkpoint side effect. +func (e *Engine) DrainBoundedCoverage( + ctx context.Context, binding BoundedCoverageBinding, + checkpoint parser.OpenCodeCoverageCheckpoint, +) (parser.OpenCodeFeedResult, []parser.SourceRef, error) { + factory := e.providerFactories[binding.Agent] + if factory == nil || factory.Capabilities().Source.BoundedCoverage != parser.CapabilitySupported { + return parser.OpenCodeFeedResult{Next: checkpoint}, nil, nil + } + dbPath := binding.PhysicalDBPath + if dbPath == "" { + dbPath = binding.DBPath + } + scope := binding.Scope + if scope == "" { + return parser.OpenCodeFeedResult{Next: checkpoint}, nil, + errors.New("bounded coverage binding has no provider-resolved scope") + } + provider := factory.NewProvider(parser.ProviderConfig{ + Roots: []string{scope}, Machine: e.machine, + }) + if _, err := os.Stat(dbPath); err != nil { + return parser.OpenCodeFeedResult{Next: checkpoint}, nil, fmt.Errorf("%w: %s", parser.ErrOpenCodeCoverageDatabaseMissing, dbPath) + } + _, compatible, err := parser.ProbeOpenCodeJournalCapability(ctx, dbPath) + if err != nil { + return parser.OpenCodeFeedResult{Next: checkpoint}, nil, err + } + if !compatible { + return parser.OpenCodeFeedResult{Next: checkpoint}, nil, nil + } + result, err := parser.DrainOpenCodeJournal(ctx, dbPath, checkpoint) + if err != nil { + return result, nil, err + } + if result.AuditRequired || len(result.ReadyIDs) == 0 { + return result, nil, nil + } + sources := make([]parser.SourceRef, 0, len(result.ReadyIDs)) + for _, id := range result.ReadyIDs { + binder, ok := provider.(parser.OpenCodeBoundedSourceBinder) + if !ok { + return result, nil, errors.New("provider does not support exact bounded source binding") + } + source, found, err := binder.FindBoundedSource(ctx, parser.OpenCodeBoundedSourceRequest{ + RawSessionID: id, PhysicalDBPath: binding.PhysicalDBPath, ProviderScope: binding.Scope, + }) + if err != nil { + return result, nil, err + } + if !found { + return result, nil, fmt.Errorf("%w: %s", ErrBoundedCoverageUnresolved, id) + } + sources = append(sources, source) + } + return result, sources, nil +} + +func boundedCoverageIdentity( + ctx context.Context, provider parser.Provider, dbPath, scope string, +) (string, string, error) { + if resolver, ok := provider.(boundedCoverageIdentityProvider); ok { + return resolver.BoundedCoverageIdentity(ctx, dbPath, scope) + } + return "", "", errors.New("provider does not resolve bounded coverage identity") +} + +func (e *Engine) InitializeBoundedCoverage( + ctx context.Context, binding BoundedCoverageBinding, +) (parser.OpenCodeCoverageCheckpoint, error) { + path := binding.PhysicalDBPath + if path == "" { + path = binding.DBPath + } + return parser.InitializeOpenCodeCoverageCheckpoint(ctx, path) +} + +func (e *Engine) ApplyBoundedCoverageSources( + ctx context.Context, sources []parser.SourceRef, +) (SyncStats, error) { + return e.SyncSourceRefsContext(ctx, sources) +} + +func boundedCoverageBindingKey(agent parser.AgentType, physicalDBPath, scope string) string { + return string(agent) + "\x00" + filepath.Clean(physicalDBPath) + "\x00" + filepath.Clean(scope) +} + +func boundedCoverageFileIdentity(path string, info os.FileInfo) BoundedCoverageFileIdentity { + inode, device := getFileIdentity(path, info) + return BoundedCoverageFileIdentity{Path: filepath.Clean(path), Inode: inode, Device: device} +} + +func (e *Engine) AdmitBoundedCoverageLease( + ctx context.Context, binding BoundedCoverageBinding, +) (*BoundedCoverageLease, error) { + path := binding.PhysicalDBPath + if path == "" { + path = binding.DBPath + } + info, err := os.Stat(path) + if err != nil { + return nil, err + } + checkpoint, err := e.InitializeBoundedCoverage(ctx, binding) + if err != nil { + return nil, err + } + return &BoundedCoverageLease{ + Binding: binding, Provider: binding.Agent, PhysicalDBPath: path, + ExactProviderScope: binding.Scope, Generation: binding.Generation, + FileIdentity: boundedCoverageFileIdentity(path, info), + AdmissionCheckpoint: checkpoint, AdmissionRowZero: true, + Reason: "bounded coverage admission", + fileInfo: info, + }, nil +} + +func ValidateBoundedCoverageLeaseIdentity(lease *BoundedCoverageLease) error { + if lease == nil || lease.Provider == "" || lease.Provider != lease.Binding.Agent || + lease.PhysicalDBPath == "" || lease.Binding.PhysicalDBPath == "" || + filepath.Clean(lease.PhysicalDBPath) != filepath.Clean(lease.Binding.PhysicalDBPath) || + lease.ExactProviderScope == "" || lease.Binding.Scope == "" || + filepath.Clean(lease.ExactProviderScope) != filepath.Clean(lease.Binding.Scope) || + lease.Generation == 0 || lease.Binding.Generation == 0 || + lease.Generation != lease.Binding.Generation { + return errors.New("bounded coverage lease identity mismatch") + } + return nil +} + +func (e *Engine) validateBoundedCoverageLease(lease *BoundedCoverageLease) error { + if err := ValidateBoundedCoverageLeaseIdentity(lease); err != nil { + return err + } + return e.validateBoundedCoveragePhysicalLease(lease) +} + +func (e *Engine) validateBoundedCoveragePhysicalLease(lease *BoundedCoverageLease) error { + if lease == nil { + return errors.New("nil bounded coverage lease") + } + info, err := os.Stat(lease.PhysicalDBPath) + if err != nil { + return err + } + current := boundedCoverageFileIdentity(lease.PhysicalDBPath, info) + if lease.fileInfo != nil && !os.SameFile(lease.fileInfo, info) { + return fmt.Errorf("bounded coverage lease physical identity changed: %s", lease.PhysicalDBPath) + } + if current != lease.FileIdentity { + return fmt.Errorf("bounded coverage lease file identity changed: %s", lease.PhysicalDBPath) + } + return nil +} + +func (e *Engine) DrainBoundedCoverageLease( + ctx context.Context, lease *BoundedCoverageLease, checkpoint parser.OpenCodeCoverageCheckpoint, +) (parser.OpenCodeFeedResult, []parser.SourceRef, error) { + if err := e.validateBoundedCoverageLease(lease); err != nil { + return parser.OpenCodeFeedResult{Next: checkpoint}, nil, err + } + result, sources, err := e.DrainBoundedCoverage(ctx, lease.Binding, checkpoint) + if err != nil { + return result, nil, err + } + if err := e.validateBoundedCoverageLease(lease); err != nil { + return result, nil, err + } + return result, sources, nil +} + +// TransitionBoundedCoverageRequest is the sole engine-owned bounded lifecycle +// transition. Replacement and apply serialize on syncMu, so a retired request +// is rejected before source writes and an accepted write returns its commit. +func (e *Engine) TransitionBoundedCoverageRequest( + ctx context.Context, lease *BoundedCoverageLease, sources []parser.SourceRef, + checkpoint parser.OpenCodeCoverageCheckpoint, + replace bool, +) (BoundedCoverageTransitionResult, error) { + if lease == nil { + return BoundedCoverageTransitionResult{}, errors.New("nil bounded coverage lease") + } + key := boundedCoverageBindingKey(lease.Provider, lease.PhysicalDBPath, lease.ExactProviderScope) + var result BoundedCoverageTransitionResult + var stats SyncStats + var err error + func() { + e.syncMu.Lock() + defer e.syncMu.Unlock() + current := e.boundedCoverageGenerations[key] + if replace { + if lease.Generation == 0 || lease.Generation <= current { + err = fmt.Errorf("bounded coverage generation %d is retired", lease.Generation) + return + } + if err = e.validateBoundedCoverageLease(lease); err != nil { + return + } + e.boundedCoverageGenerations[key] = lease.Generation + result = BoundedCoverageTransitionResult{ + Checkpoint: lease.AdmissionCheckpoint, Generation: lease.Generation, + } + return + } + if current != lease.Generation { + if current != 0 { + err = fmt.Errorf("bounded coverage generation %d is retired", lease.Generation) + return + } + e.boundedCoverageGenerations[key] = lease.Generation + } + if err = e.validateBoundedCoverageLease(lease); err != nil { + return + } + if err = e.validateBoundedCoverageSources(lease, sources); err != nil { + return + } + stats, err = e.syncSourceRefsContextLocked(ctx, sources) + if err != nil { + return + } + result = BoundedCoverageTransitionResult{ + Stats: stats, Checkpoint: checkpoint, Generation: lease.Generation, + } + }() + if err != nil { + return BoundedCoverageTransitionResult{}, err + } + if stats.Synced > 0 || stats.sourceMissingTombstoned > 0 { + e.emit("sessions") + } + return result, nil +} + +func (e *Engine) ReconcileBoundedCoverageLease( + ctx context.Context, lease *BoundedCoverageLease, reason string, +) error { + if reason == "" { + return errors.New("bounded coverage audit reason is empty") + } + if lease == nil || lease.Provider != lease.Binding.Agent || + filepath.Clean(lease.PhysicalDBPath) != filepath.Clean(lease.Binding.PhysicalDBPath) || + filepath.Clean(lease.ExactProviderScope) != filepath.Clean(lease.Binding.Scope) || + lease.Generation == 0 || lease.Generation != lease.Binding.Generation { + return errors.New("bounded coverage audit lease identity mismatch") + } + if err := e.validateBoundedCoverageLease(lease); err != nil { + return err + } + return e.ReconcileBoundedCoverageSourceLease(ctx, lease, reason) +} + +// ReconcileBoundedCoverageSourceLease repairs only the admitted physical +// container; generic provider-root reconciliation would widen the request. +func (e *Engine) ReconcileBoundedCoverageSourceLease( + ctx context.Context, lease *BoundedCoverageLease, reason string, +) error { + if reason == "" { + return errors.New("bounded coverage source repair reason is empty") + } + if err := e.validateBoundedCoverageLease(lease); err != nil { + return err + } + if e.providerFactories[lease.Provider] == nil { + return fmt.Errorf("bounded coverage provider %q is unavailable", lease.Provider) + } + if !withinOrEqual(lease.PhysicalDBPath, lease.ExactProviderScope) { + return fmt.Errorf("bounded coverage source scope mismatch: %s", lease.PhysicalDBPath) + } + requestRoot := lease.PhysicalDBPath + if relative, relErr := filepath.Rel( + lease.ExactProviderScope, lease.PhysicalDBPath, + ); relErr == nil && relative != "." { + for _, configuredRoot := range e.agentDirs[lease.Provider] { + resolvedRoot, resolveErr := filepath.EvalSymlinks(configuredRoot) + if resolveErr == nil && filepath.Clean(resolvedRoot) == filepath.Clean(lease.ExactProviderScope) { + requestRoot = filepath.Join(configuredRoot, relative) + break + } + } + } + + key := boundedCoverageBindingKey( + lease.Provider, lease.PhysicalDBPath, lease.ExactProviderScope, + ) + var stats SyncStats + var tombstoned int + var err error + func() { + e.syncMu.Lock() + defer e.syncMu.Unlock() + current := e.boundedCoverageGenerations[key] + if current != 0 && current != lease.Generation { + err = fmt.Errorf("bounded coverage generation %d is retired", lease.Generation) + return + } + if err = e.validateBoundedCoverageLease(lease); err != nil { + return + } + if current == 0 { + e.boundedCoverageGenerations[key] = lease.Generation + } + stats, tombstoned, _, err = e.reconcileScopedWatchRootsLocked( + ctx, lease.Provider, []string{requestRoot}, false, false, + ) + if err == nil { + err = e.validateBoundedCoverageLease(lease) + } + }() + if err != nil { + return err + } + if stats.Synced > 0 || tombstoned > 0 || stats.sourceMissingTombstoned > 0 { + e.emit("sessions") + } + return nil +} + +func (e *Engine) validateBoundedCoverageSources( + lease *BoundedCoverageLease, sources []parser.SourceRef, +) error { + for _, source := range sources { + if source.Provider != lease.Provider { + return fmt.Errorf("bounded coverage source provider mismatch: %s", source.DisplayPath) + } + path := source.DisplayPath + if dbPath, _, virtual := strings.Cut(path, "#"); virtual { + path = dbPath + } + physical, err := filepath.EvalSymlinks(path) + if err != nil || filepath.Clean(physical) != filepath.Clean(lease.PhysicalDBPath) { + return fmt.Errorf("bounded coverage source identity mismatch: %s", source.DisplayPath) + } + if !withinOrEqual(physical, lease.ExactProviderScope) { + return fmt.Errorf("bounded coverage source scope mismatch: %s", source.DisplayPath) + } + } + return nil +} + +func withinOrEqual(path, root string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + return err == nil && (rel == "." || rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func sameCoveragePath(a, b string) bool { return filepath.Clean(a) == filepath.Clean(b) } + +func coverageDBPath(root, include string) string { + path := filepath.Clean(filepath.Join(root, include)) + for _, suffix := range []string{"-wal", "-shm"} { + if before, ok := strings.CutSuffix(path, suffix); ok { + return before + } + } + return path +} diff --git a/internal/sync/opencode_bounded_coverage_respec7_test.go b/internal/sync/opencode_bounded_coverage_respec7_test.go new file mode 100644 index 000000000..54cfe097d --- /dev/null +++ b/internal/sync/opencode_bounded_coverage_respec7_test.go @@ -0,0 +1,176 @@ +package sync + +import ( + "os" + "path/filepath" + "testing" + + "go.kenn.io/agentsview/internal/parser" +) + +func TestBoundedCoverageFileIdentityIgnoresMutableObservations(t *testing.T) { + path := filepath.Join(t.TempDir(), "opencode.db") + if err := os.WriteFile(path, []byte("before"), 0o600); err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + before := boundedCoverageFileIdentity(path, beforeInfo) + if err := os.WriteFile(path, []byte("a larger database observation"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + after := boundedCoverageFileIdentity(path, afterInfo) + if before != after { + t.Fatalf("mutable file observations revoked an unchanged physical lease: before=%+v after=%+v", before, after) + } +} + +func TestBoundedCoverageUsesEngineWriteOwner(t *testing.T) { + engine := &Engine{} + engine.syncMu.Lock() + acquired := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + engine.syncMu.Lock() + close(acquired) + engine.syncMu.Unlock() + }() + select { + case <-acquired: + t.Fatal("bounded source operation bypassed the engine write owner") + default: + } + engine.syncMu.Unlock() + <-acquired + <-done +} + +func TestBoundedCoverageTransitionRetiresBeforeApply(t *testing.T) { + path := filepath.Join(t.TempDir(), "opencode.db") + if err := os.WriteFile(path, []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + engine := &Engine{boundedCoverageGenerations: make(map[string]uint64)} + lease := &BoundedCoverageLease{ + Binding: BoundedCoverageBinding{ + Agent: parser.AgentOpenCode, PhysicalDBPath: path, + Scope: filepath.Dir(path), Generation: 1, + }, + Provider: parser.AgentOpenCode, PhysicalDBPath: path, + ExactProviderScope: filepath.Dir(path), Generation: 1, + FileIdentity: boundedCoverageFileIdentity(path, info), fileInfo: info, + } + if _, err := engine.TransitionBoundedCoverageRequest(t.Context(), lease, nil, parser.OpenCodeCoverageCheckpoint{}, true); err != nil { + t.Fatal(err) + } + retired := *lease + retired.Generation = 2 + retired.Binding.Generation = 2 + if _, err := engine.TransitionBoundedCoverageRequest(t.Context(), &retired, nil, parser.OpenCodeCoverageCheckpoint{}, true); err != nil { + t.Fatal(err) + } + if _, err := engine.TransitionBoundedCoverageRequest(t.Context(), lease, nil, parser.OpenCodeCoverageCheckpoint{}, false); err == nil { + t.Fatal("retired generation was accepted for apply") + } +} + +func TestBoundedCoverageTransitionRejectsMismatchedSourceProvider(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "opencode.db") + if err := os.WriteFile(path, []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + engine := &Engine{boundedCoverageGenerations: make(map[string]uint64)} + lease := &BoundedCoverageLease{ + Binding: BoundedCoverageBinding{ + Agent: parser.AgentOpenCode, PhysicalDBPath: path, Scope: root, Generation: 1, + }, + Provider: parser.AgentOpenCode, PhysicalDBPath: path, + ExactProviderScope: root, Generation: 1, + FileIdentity: boundedCoverageFileIdentity(path, info), fileInfo: info, + } + source := parser.SourceRef{Provider: parser.AgentClaude, DisplayPath: path} + if _, err := engine.TransitionBoundedCoverageRequest( + t.Context(), lease, []parser.SourceRef{source}, parser.OpenCodeCoverageCheckpoint{}, false, + ); err == nil { + t.Fatal("source from another provider crossed the bounded write boundary") + } +} + +func TestBoundedCoverageTransitionRequiresScopedGenerationBinding(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "opencode.db") + if err := os.WriteFile(path, []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + engine := &Engine{boundedCoverageGenerations: make(map[string]uint64)} + lease := &BoundedCoverageLease{ + Binding: BoundedCoverageBinding{ + Agent: parser.AgentOpenCode, PhysicalDBPath: path, Generation: 1, + }, + Provider: parser.AgentOpenCode, PhysicalDBPath: path, Generation: 1, + FileIdentity: boundedCoverageFileIdentity(path, info), fileInfo: info, + } + if _, err := engine.TransitionBoundedCoverageRequest( + t.Context(), lease, nil, parser.OpenCodeCoverageCheckpoint{}, true, + ); err == nil { + t.Fatal("bounded transition accepted a lease without exact scope binding") + } +} + +func TestBoundedCoverageTransitionSeparatesSamePhysicalNestedScopes(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "opencode.db") + if err := os.WriteFile(path, []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + engine := &Engine{boundedCoverageGenerations: make(map[string]uint64)} + newLease := func(scope string) *BoundedCoverageLease { + return &BoundedCoverageLease{ + Binding: BoundedCoverageBinding{ + Agent: parser.AgentOpenCode, PhysicalDBPath: path, + Scope: scope, Generation: 1, + }, + Provider: parser.AgentOpenCode, PhysicalDBPath: path, + ExactProviderScope: scope, Generation: 1, + FileIdentity: boundedCoverageFileIdentity(path, info), fileInfo: info, + } + } + if _, err := engine.TransitionBoundedCoverageRequest( + t.Context(), newLease(root), nil, parser.OpenCodeCoverageCheckpoint{}, true, + ); err != nil { + t.Fatal(err) + } + if _, err := engine.TransitionBoundedCoverageRequest( + t.Context(), newLease(filepath.Join(root, "nested")), nil, + parser.OpenCodeCoverageCheckpoint{}, true, + ); err != nil { + t.Fatalf("same physical database at a different provider scope shared generation state: %v", err) + } +} diff --git a/internal/sync/parsediff_compare_test.go b/internal/sync/parsediff_compare_test.go index 8bcc6c5db..bc178f499 100644 --- a/internal/sync/parsediff_compare_test.go +++ b/internal/sync/parsediff_compare_test.go @@ -554,7 +554,7 @@ func TestCompareSessionFieldsTruncatesLongValues(t *testing.T) { long := strings.Repeat("x", 200) stored := pdBaseSession() prepared := pdBaseSession() - prepared.FirstMessage = new(long) + prepared.FirstMessage = &long diffs := compareSessionFields(&stored, prepared) require.Len(t, diffs, 1) diff --git a/internal/sync/reconciliation_scoping_opencode_test.go b/internal/sync/reconciliation_scoping_opencode_test.go index b120748a0..7e16e6fb4 100644 --- a/internal/sync/reconciliation_scoping_opencode_test.go +++ b/internal/sync/reconciliation_scoping_opencode_test.go @@ -2,6 +2,8 @@ package sync_test import ( "context" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -34,8 +36,14 @@ func seedOpenCodeContainerSessions( func TestReconcileProviderRootsOpenCodeContainerSyncsAndTombstonesMembers( t *testing.T, ) { - env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) - oc := createOpenCodeDB(t, env.opencodeDir) + physicalRoot := t.TempDir() + aliasParent := t.TempDir() + aliasRoot := filepath.Join(aliasParent, "opencode-root") + if err := os.Symlink(physicalRoot, aliasRoot); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + env := setupSingleAgentTestEnvWithDirs(t, parser.AgentOpenCode, []string{aliasRoot}) + oc := createOpenCodeDB(t, physicalRoot) base := int64(1704067200000) seedOpenCodeContainerSessions( t, oc, base, "oc-container-kept", "oc-container-removed", @@ -68,6 +76,56 @@ func TestReconcileProviderRootsOpenCodeContainerSyncsAndTombstonesMembers( "a container-scoped pass reclaims a removed member") } +func TestReconcileBoundedCoverageSourceLeaseTombstonesDeletedMembers( + t *testing.T, +) { + env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) + oc := createOpenCodeDB(t, env.opencodeDir) + base := int64(1704067200000) + seedOpenCodeContainerSessions( + t, oc, base, "oc-lease-kept", "oc-lease-removed", + ) + oc.mustExec(t, "bounded journal event table", ` + CREATE TABLE event ( + id TEXT NOT NULL PRIMARY KEY, aggregate_id TEXT NOT NULL, + seq INTEGER NOT NULL, type TEXT NOT NULL, data BLOB NOT NULL + ); + CREATE TABLE event_sequence (id TEXT NOT NULL PRIMARY KEY, owner_id TEXT); + `) + runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2}) + + bindings, err := env.engine.BoundedCoverageBindings( + t.Context(), []sync.BoundedCoverageRoot{{ + Agent: parser.AgentOpenCode, Root: env.opencodeDir, + }}, + ) + require.NoError(t, err) + require.Len(t, bindings, 1) + bindings[0].Generation = 1 + lease, err := env.engine.AdmitBoundedCoverageLease(t.Context(), bindings[0]) + require.NoError(t, err) + _, err = env.engine.TransitionBoundedCoverageRequest( + t.Context(), lease, nil, lease.AdmissionCheckpoint, true, + ) + require.NoError(t, err) + + oc.deleteParts(t, "oc-lease-removed") + oc.deleteMessages(t, "oc-lease-removed") + oc.mustExec(t, "delete session", + "DELETE FROM session WHERE id = ?", "oc-lease-removed") + require.NoError(t, env.engine.ReconcileBoundedCoverageSourceLease( + t.Context(), lease, "structural journal evidence", + )) + + removed, err := env.db.GetSession(t.Context(), "opencode:oc-lease-removed") + require.NoError(t, err) + assert.Nil(t, removed, + "lease-bound repair must reconcile container membership, including tombstones") + kept, err := env.db.GetSession(t.Context(), "opencode:oc-lease-kept") + require.NoError(t, err) + assert.NotNil(t, kept) +} + // TestReconcileProviderRootsOpenCodeMemberPassCannotTrustPartialMembership // pins the trust-promotion invariant end to end: a pass asked about one // virtual member widens to the whole container, so completing it never diff --git a/internal/sync/watch_backend.go b/internal/sync/watch_backend.go index 8980fc40f..5b2e9c6fa 100644 --- a/internal/sync/watch_backend.go +++ b/internal/sync/watch_backend.go @@ -1,6 +1,9 @@ package sync -import "slices" +import ( + "path/filepath" + "slices" +) // WatchScope identifies one configured provider root whose changes are covered // by a logical watcher root. A physical root may cover multiple configured @@ -21,9 +24,8 @@ type WatchRoot struct { } // RegisterRoots passes the complete desired root plan to the watcher before -// its backend starts. The current fsnotify backend activates existing roots; -// later lifecycle-aware backends can also retain pending missing roots without -// changing the daemon-side plan contract. +// its backend starts. Portable lifecycle-aware backends retain missing +// recursive roots when an existing plan root observes their creation. func (w *Watcher) RegisterRoots( roots []WatchRoot, recursiveBudget int, @@ -55,6 +57,9 @@ func (w *Watcher) RegisterRoots( } results := make([]RecursiveWatchResult, len(roots)) remaining := recursiveBudget + if initializer, ok := w.backend.(watchBudgetInitializer); ok { + initializer.setRuntimeWatchBudget(recursiveBudget) + } for i, root := range roots { agents := make([]string, 0, len(root.Scopes)) for _, scope := range root.Scopes { @@ -80,9 +85,68 @@ func (w *Watcher) RegisterRoots( results[i] = w.backend.AddRecursive(root.Path, remaining) remaining -= results[i].Watched } + if owner, ok := w.backend.(pendingRootOwner); ok { + resolvePendingRootOwnership(owner, roots, results) + } return results } +// pendingRootOwner retains a missing recursive root whose creation is covered +// by an existing watch plan, so the backend can activate it on the create event. +type pendingRootOwner interface { + ownPendingRecursiveRoot(path string) +} + +type watchBudgetInitializer interface { + setRuntimeWatchBudget(int) +} + +func resolvePendingRootOwnership( + owner pendingRootOwner, roots []WatchRoot, results []RecursiveWatchResult, +) { + for i, root := range roots { + if root.Exists || !root.Recursive || + !registeredWatchCoversCreation(root, roots, results) { + continue + } + owner.ownPendingRecursiveRoot(root.Path) + results[i].MissingRootLifecycleOwned = true + } +} + +func registeredWatchCoversCreation( + target WatchRoot, roots []WatchRoot, results []RecursiveWatchResult, +) bool { + targetPath := filepath.Clean(target.Path) + for i, root := range roots { + if !root.Exists { + continue + } + if i >= len(results) || !watchResultCoversCreation(results[i]) { + continue + } + rootPath := filepath.Clean(root.Path) + if !root.Recursive { + if filepath.Dir(targetPath) == rootPath { + return true + } + continue + } + if pathAtOrBelow(rootPath, targetPath) { + return true + } + } + return false +} + +func watchResultCoversCreation(result RecursiveWatchResult) bool { + if result.Err != nil || result.Unwatched > 0 || result.BudgetExhausted || + result.ResourceExhausted { + return false + } + return result.Watched > 0 +} + type backendItemType uint8 const ( diff --git a/internal/sync/watch_backend_fsnotify.go b/internal/sync/watch_backend_fsnotify.go index 64c7add3d..867e1e266 100644 --- a/internal/sync/watch_backend_fsnotify.go +++ b/internal/sync/watch_backend_fsnotify.go @@ -32,7 +32,10 @@ type fsnotifyBackend struct { runtimeBudget int rootScopes map[string][]PollingScope degradedRoots map[string]struct{} + pending map[string]struct{} + lifecycleOwned map[string]struct{} onPollingRequired func(PollingObligation) error + onPollingReleased func(string) error lifecycleMu sync.Mutex lifecycle fsnotifyBackendLifecycle stop chan struct{} @@ -69,6 +72,8 @@ func newFSNotifyBackend(excludes []string) (*fsnotifyBackend, error) { watchBudgetCost: make(map[string]int), rootScopes: make(map[string][]PollingScope), degradedRoots: make(map[string]struct{}), + pending: make(map[string]struct{}), + lifecycleOwned: make(map[string]struct{}), stop: make(chan struct{}), done: make(chan struct{}), }, nil @@ -77,6 +82,20 @@ func newFSNotifyBackend(excludes []string) (*fsnotifyBackend, error) { func (b *fsnotifyBackend) Events() <-chan backendEvent { return b.events } func (b *fsnotifyBackend) Errors() <-chan error { return b.errors } +func (b *fsnotifyBackend) ownPendingRecursiveRoot(path string) { + b.watchMu.Lock() + defer b.watchMu.Unlock() + path = filepath.Clean(path) + b.pending[path] = struct{}{} + b.lifecycleOwned[path] = struct{}{} +} + +func (b *fsnotifyBackend) setRuntimeWatchBudget(budget int) { + b.watchMu.Lock() + defer b.watchMu.Unlock() + b.runtimeBudget = max(budget, 0) +} + func (b *fsnotifyBackend) AddRecursive(root string, budget int) RecursiveWatchResult { b.watchMu.Lock() defer b.watchMu.Unlock() @@ -157,11 +176,12 @@ func (b *fsnotifyBackend) setWatchRootPlan(roots []WatchRoot) { func (b *fsnotifyBackend) bindPollingOwnership( required func(PollingObligation) error, - _ func(string) error, + released func(string) error, ) { b.watchMu.Lock() defer b.watchMu.Unlock() b.onPollingRequired = required + b.onPollingReleased = released } func (b *fsnotifyBackend) AddShallow(root string) error { @@ -366,6 +386,19 @@ func (b *fsnotifyBackend) watchCreatedPath(path string) (backendItemType, bool) } b.watchMu.Lock() + path = filepath.Clean(path) + if _, pending := b.pending[path]; pending { + delete(b.pending, path) + b.addRecursiveRoot(path) + degraded := b.addRuntimeSubtreeLocked(path, []string{path}) + b.watchMu.Unlock() + if len(degraded) > 0 { + b.requireRuntimePolling(degraded) + } else { + b.releaseRuntimePolling(path) + } + return backendItemDirectory, false + } if b.isUnderShallowRoot(path) { b.watchMu.Unlock() return backendItemDirectory, false @@ -451,6 +484,13 @@ func (b *fsnotifyBackend) forgetRemovedSubtree(path string) (bool, []string) { path = filepath.Clean(path) removed := make([]string, 0) lostRoots := make(map[string]struct{}) + lifecycleRemoved := false + for root := range b.lifecycleOwned { + if pathAtOrBelow(path, root) { + b.pending[root] = struct{}{} + lifecycleRemoved = true + } + } for watched := range b.watchOwners { if pathAtOrBelow(path, watched) { removed = append(removed, watched) @@ -462,7 +502,7 @@ func (b *fsnotifyBackend) forgetRemovedSubtree(path string) (bool, []string) { } } if len(removed) == 0 { - return false, nil + return lifecycleRemoved, nil } slices.Sort(removed) for _, watched := range removed { @@ -475,6 +515,13 @@ func (b *fsnotifyBackend) forgetRemovedSubtree(path string) (bool, []string) { } b.reclaimWatchBudgetLocked(watched) } + for root := range lostRoots { + if _, owned := b.lifecycleOwned[root]; !owned { + continue + } + b.pending[root] = struct{}{} + delete(lostRoots, root) + } roots := make([]string, 0, len(lostRoots)) for root := range lostRoots { roots = append(roots, root) @@ -526,6 +573,26 @@ func (b *fsnotifyBackend) requireRuntimePolling(roots []string) { } } +func (b *fsnotifyBackend) releaseRuntimePolling(root string) { + root = filepath.Clean(root) + b.watchMu.Lock() + if _, required := b.degradedRoots[root]; !required { + b.watchMu.Unlock() + return + } + delete(b.degradedRoots, root) + released := b.onPollingReleased + b.watchMu.Unlock() + if released == nil { + return + } + if err := released("fsnotify-runtime:" + root); err != nil { + b.reportError(fmt.Errorf( + "release fsnotify polling for %s: %w", root, err, + )) + } +} + func (b *fsnotifyBackend) reportError(err error) { select { case b.errors <- err: diff --git a/internal/sync/watch_backend_fsnotify_test.go b/internal/sync/watch_backend_fsnotify_test.go index 778312e41..cf91e0ff3 100644 --- a/internal/sync/watch_backend_fsnotify_test.go +++ b/internal/sync/watch_backend_fsnotify_test.go @@ -26,6 +26,200 @@ func testFSNotifyBackend(t *testing.T) *fsnotifyBackend { return backend } +func TestFSNotifyPendingRoot(t *testing.T) { + backend := testFSNotifyBackend(t) + parent := t.TempDir() + missing := filepath.Join(parent, "storage") + polling := make(chan PollingObligation, 1) + watcher, err := newWatcherWithBackendOptions( + 0, 0, func(context.Context, WatchBatch) error { return nil }, + backend, 8, 1_000, + WatcherOptions{OnPollingRequired: func(obligation PollingObligation) error { + polling <- obligation + return nil + }}, + ) + require.NoError(t, err) + results := watcher.RegisterRoots([]WatchRoot{ + {Path: parent, Exists: true, Scopes: []WatchScope{{SyncDir: parent}}}, + {Path: missing, Recursive: true, Scopes: []WatchScope{{SyncDir: missing}}}, + }, 8) + require.Equal(t, []RecursiveWatchResult{{Watched: 1}, {MissingRootLifecycleOwned: true}}, results) + backend.watchMu.Lock() + _, pending := backend.pending[missing] + _, owned := backend.lifecycleOwned[missing] + backend.watchMu.Unlock() + assert.True(t, pending) + assert.True(t, owned) + + require.NoError(t, os.MkdirAll(filepath.Join(missing, "nested"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(missing, "nested", "session.jsonl"), []byte("x"), 0o644)) + event, relevant := backend.translateEvent(fsnotify.Event{Name: missing, Op: fsnotify.Create}) + require.True(t, relevant) + assert.Equal(t, backendItemDirectory, event.ItemType) + assert.Contains(t, backend.watcher.WatchList(), missing) + backend.watchMu.Lock() + _, pending = backend.pending[missing] + backend.watchMu.Unlock() + assert.False(t, pending) + select { + case obligation := <-polling: + t.Fatalf("activated lifecycle root unexpectedly degraded: %+v", obligation) + default: + } + fileEvent, relevant := backend.translateEvent(fsnotify.Event{ + Name: filepath.Join(missing, "nested", "session.jsonl"), + Op: fsnotify.Remove, + }) + require.True(t, relevant) + assert.Equal(t, backendItemUnknown, fileEvent.ItemType) + + _, relevant = backend.translateEvent(fsnotify.Event{Name: missing, Op: fsnotify.Remove}) + require.True(t, relevant) + backend.watchMu.Lock() + _, pending = backend.pending[missing] + backend.watchMu.Unlock() + assert.True(t, pending, "removal must return the lifecycle-owned root to pending") + select { + case obligation := <-polling: + t.Fatalf("pending lifecycle root unexpectedly became a polling obligation: %+v", obligation) + default: + } +} + +func TestFSNotifyPendingRootKeepsPollingWhenAncestorBudgetDegraded(t *testing.T) { + backend := testFSNotifyBackend(t) + parent := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(parent, "existing"), 0o755)) + missing := filepath.Join(parent, "missing") + watcher, err := newWatcherWithBackend( + 0, 0, func(context.Context, WatchBatch) error { return nil }, + backend, 8, 1_000, + ) + require.NoError(t, err) + + results := watcher.RegisterRoots([]WatchRoot{ + {Path: parent, Recursive: true, Exists: true}, + {Path: missing, Recursive: true, Exists: false}, + }, 1) + require.Len(t, results, 2) + assert.True(t, results[0].BudgetExhausted) + assert.False(t, results[1].MissingRootLifecycleOwned, + "an incomplete ancestor watch cannot own a missing child root") + backend.watchMu.Lock() + _, pending := backend.pending[missing] + _, owned := backend.lifecycleOwned[missing] + backend.watchMu.Unlock() + assert.False(t, pending) + assert.False(t, owned) +} + +func TestFSNotifyLifecycleRootReleasesPollingAfterRecreation(t *testing.T) { + backend := testFSNotifyBackend(t) + parent := t.TempDir() + root := filepath.Join(parent, "storage") + required := make(chan PollingObligation, 1) + released := make(chan string, 1) + watcher, err := newWatcherWithBackendOptions( + 0, 0, func(context.Context, WatchBatch) error { return nil }, + backend, 8, 1_000, + WatcherOptions{ + OnPollingRequired: func(obligation PollingObligation) error { + required <- obligation + return nil + }, + OnPollingReleased: func(key string) error { + released <- key + return nil + }, + }, + ) + require.NoError(t, err) + results := watcher.RegisterRoots([]WatchRoot{ + {Path: parent, Exists: true}, + {Path: root, Recursive: true, Exists: false, + Scopes: []WatchScope{{Agent: "opencode", SyncDir: root}}}, + }, 8) + require.True(t, results[1].MissingRootLifecycleOwned) + + backend.setRuntimeWatchBudget(0) + require.NoError(t, os.MkdirAll(filepath.Join(root, "nested"), 0o755)) + _, relevant := backend.translateEvent(fsnotify.Event{Name: root, Op: fsnotify.Create}) + require.True(t, relevant) + require.Equal(t, "fsnotify-runtime:"+root, + requireReceiveWithin(t, required, time.Second).Key) + + require.NoError(t, os.RemoveAll(root)) + _, relevant = backend.translateEvent(fsnotify.Event{Name: root, Op: fsnotify.Remove}) + require.True(t, relevant) + backend.setRuntimeWatchBudget(8) + require.NoError(t, os.MkdirAll(filepath.Join(root, "nested"), 0o755)) + _, relevant = backend.translateEvent(fsnotify.Event{Name: root, Op: fsnotify.Create}) + require.True(t, relevant) + assert.Equal(t, "fsnotify-runtime:"+root, + requireReceiveWithin(t, released, time.Second)) + backend.watchMu.Lock() + _, retained := backend.degradedRoots[root] + backend.watchMu.Unlock() + assert.False(t, retained) +} + +func TestFSNotifyPendingRootResolvesOwnershipAfterAllRootsRegister(t *testing.T) { + backend := testFSNotifyBackend(t) + parent := t.TempDir() + missing := filepath.Join(parent, "missing") + watcher, err := newWatcherWithBackend( + 0, 0, func(context.Context, WatchBatch) error { return nil }, + backend, 8, 1_000, + ) + require.NoError(t, err) + + results := watcher.RegisterRoots([]WatchRoot{ + {Path: missing, Recursive: true, Exists: false}, + {Path: parent, Exists: true}, + }, 1) + require.Len(t, results, 2) + assert.True(t, results[0].MissingRootLifecycleOwned, + "pending ownership must use the completed covering-watch result") +} + +func TestWatcherCreatedSubtreeEnumeratesActivatedRoot(t *testing.T) { + backend := testFSNotifyBackend(t) + parent := t.TempDir() + missing := filepath.Join(parent, "storage") + batches := make(chan WatchBatch, 1) + watcher, err := newWatcherWithBackendOptions( + 0, 0, func(_ context.Context, batch WatchBatch) error { + batches <- batch + return nil + }, backend, 16, 1_000, WatcherOptions{}, + ) + require.NoError(t, err) + watcher.RegisterRoots([]WatchRoot{ + {Path: parent, Exists: true}, + {Path: missing, Recursive: true}, + }, 16) + require.NoError(t, watcher.Start()) + defer watcher.Stop() + require.NoError(t, os.MkdirAll(filepath.Join(missing, "nested"), 0o755)) + file := filepath.Join(missing, "nested", "session.jsonl") + require.NoError(t, os.WriteFile(file, []byte("x"), 0o644)) + + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + for { + select { + case batch := <-batches: + if slices.Contains(batch.Paths, file) { + assert.Contains(t, batch.Paths, file) + return + } + case <-deadline.C: + t.Fatalf("activated subtree did not enumerate %s", file) + } + } +} + func TestFSNotifyBackendOverflowRequestsLostEventRecovery(t *testing.T) { backend := testFSNotifyBackend(t) errorInput := make(chan error, 1) diff --git a/internal/sync/watcher.go b/internal/sync/watcher.go index bab87b3ae..cbbe979f3 100644 --- a/internal/sync/watcher.go +++ b/internal/sync/watcher.go @@ -13,6 +13,8 @@ import ( "sync" "sync/atomic" "time" + + "go.kenn.io/agentsview/internal/parser" ) type RecursiveWatchResult struct { @@ -60,6 +62,7 @@ type WatchBatch struct { Paths []string Renames []WatchRename ReconcileRoots []string + ReconcileGroups []ProviderRootsGroup FullSync bool LostEvents bool lifecycleTokens []backendLifecycleToken @@ -98,8 +101,8 @@ type WatcherOptions struct { // 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. +// ReconcileRoots and ReconcileGroups from WatchRetryBatch; ordinary paths and +// rename metadata are never replayed through this protocol. type WatchRetryError interface { error WatchRetryBatch() WatchBatch @@ -114,9 +117,12 @@ type pendingWatchBatch struct { renames map[WatchRename]struct{} backendRenames map[pendingBackendRename]struct{} roots map[string]struct{} + groups map[parser.AgentType]map[string]struct{} strings map[string]struct{} lifecycle map[backendLifecycleToken]struct{} pathBytes int + groupBytes int + groupEntries int maxEntries int maxPathBytes int fullSync bool @@ -136,6 +142,7 @@ func newPendingWatchBatch(maxEntries, maxPathBytes int) *pendingWatchBatch { renames: make(map[WatchRename]struct{}), backendRenames: make(map[pendingBackendRename]struct{}), roots: make(map[string]struct{}), + groups: make(map[parser.AgentType]map[string]struct{}), strings: make(map[string]struct{}), lifecycle: make(map[backendLifecycleToken]struct{}), maxEntries: maxEntries, @@ -145,7 +152,8 @@ func newPendingWatchBatch(maxEntries, maxPathBytes int) *pendingWatchBatch { func (p *pendingWatchBatch) Empty() bool { return !p.fullSync && len(p.paths) == 0 && len(p.renames) == 0 && - len(p.backendRenames) == 0 && len(p.roots) == 0 && len(p.lifecycle) == 0 + len(p.backendRenames) == 0 && len(p.roots) == 0 && len(p.groups) == 0 && + len(p.lifecycle) == 0 } func (p *pendingWatchBatch) Add(path string) { @@ -208,6 +216,42 @@ func (p *pendingWatchBatch) AddReconcileRoot(root string) { p.roots[root] = struct{}{} } +func (p *pendingWatchBatch) AddReconcileGroup( + agent parser.AgentType, root string, +) { + if agent == "" || root == "" { + return + } + if p.fullSync { + return + } + if roots, ok := p.groups[agent]; ok { + if _, exists := roots[root]; exists { + return + } + } + groupBytes := len(string(agent)) + len(root) + if p.groupEntries+1 > p.maxEntries || p.groupBytes+groupBytes > p.maxPathBytes { + p.discardProviderGroup(agent) + p.overflow() + return + } + if _, ok := p.groups[agent]; !ok { + p.groups[agent] = make(map[string]struct{}) + } + p.groups[agent][root] = struct{}{} + p.groupBytes += groupBytes + p.groupEntries++ +} + +func (p *pendingWatchBatch) discardProviderGroup(agent parser.AgentType) { + for root := range p.groups[agent] { + p.groupBytes -= len(string(agent)) + len(root) + p.groupEntries-- + } + delete(p.groups, agent) +} + func (p *pendingWatchBatch) AddBackendEvent(event backendEvent) bool { if event.Op == backendOpUnknown { return !p.fullSync @@ -305,6 +349,11 @@ func (p *pendingWatchBatch) merge(other *pendingWatchBatch) { for root := range other.roots { p.AddReconcileRoot(root) } + for agent, roots := range other.groups { + for root := range roots { + p.AddReconcileGroup(agent, root) + } + } for token := range other.lifecycle { p.AddLifecycle(token) } @@ -364,12 +413,44 @@ func (p *pendingWatchBatch) makeFullSync(lostEvents bool) { clear(p.renames) clear(p.backendRenames) clear(p.roots) + clear(p.groups) clear(p.strings) p.pathBytes = 0 + p.groupBytes = 0 + p.groupEntries = 0 p.fullSync = true p.lostEvents = p.lostEvents || lostEvents } +// CanonicalizeWatchBatch keeps authoritative recovery from carrying partial +// obligations that could exclude a provider from the generic pass. +func CanonicalizeWatchBatch(batch WatchBatch) WatchBatch { + batch.Paths = append([]string(nil), batch.Paths...) + batch.Renames = append([]WatchRename(nil), batch.Renames...) + batch.ReconcileRoots = append([]string(nil), batch.ReconcileRoots...) + batch.ReconcileGroups = cloneProviderRootsGroups(batch.ReconcileGroups) + if batch.FullSync { + batch.Paths = nil + batch.Renames = nil + batch.ReconcileRoots = nil + batch.ReconcileGroups = nil + } else { + for _, rename := range batch.Renames { + if rename.ItemType == ItemIsFile { + batch.Paths = appendUniqueWatchPath(batch.Paths, rename.Path) + } + } + } + return batch +} + +func appendUniqueWatchPath(paths []string, path string) []string { + if path == "" || slices.Contains(paths, path) { + return paths + } + return append(paths, path) +} + func (p *pendingWatchBatch) Take() (WatchBatch, bool) { return p.TakeWithRootAgents(nil) } @@ -385,9 +466,9 @@ func (p *pendingWatchBatch) TakeWithRootAgents( lostEvents := p.lostEvents p.lostEvents = false tokens := p.takeLifecycleTokens() - return WatchBatch{ - FullSync: true, LostEvents: lostEvents, lifecycleTokens: tokens, - }, true + p.takeProviderGroups() + return WatchBatch{FullSync: true, LostEvents: lostEvents, + lifecycleTokens: tokens}, true } for rename := range p.backendRenames { agents := []string{""} @@ -409,6 +490,7 @@ func (p *pendingWatchBatch) TakeWithRootAgents( lostEvents := p.lostEvents p.lostEvents = false tokens := p.takeLifecycleTokens() + p.takeProviderGroups() return WatchBatch{ FullSync: true, LostEvents: lostEvents, lifecycleTokens: tokens, }, true @@ -442,21 +524,70 @@ func (p *pendingWatchBatch) TakeWithRootAgents( roots = append(roots, root) } slices.Sort(roots) + agents := make([]parser.AgentType, 0, len(p.groups)) + for agent := range p.groups { + agents = append(agents, agent) + } + slices.SortFunc(agents, func(a, b parser.AgentType) int { + return strings.Compare(string(a), string(b)) + }) + groups := make([]ProviderRootsGroup, 0, len(agents)) + for _, agent := range agents { + groupRoots := make([]string, 0, len(p.groups[agent])) + for root := range p.groups[agent] { + groupRoots = append(groupRoots, root) + } + slices.Sort(groupRoots) + groups = append(groups, ProviderRootsGroup{ + Agent: agent, Roots: groupRoots, + }) + } + if len(groups) == 0 { + groups = nil + } clear(p.paths) clear(p.renames) clear(p.backendRenames) clear(p.roots) + clear(p.groups) clear(p.strings) tokens := p.takeLifecycleTokens() p.pathBytes = 0 + p.groupBytes = 0 + p.groupEntries = 0 lostEvents := p.lostEvents p.lostEvents = false return WatchBatch{ Paths: paths, Renames: renames, ReconcileRoots: roots, - LostEvents: lostEvents, lifecycleTokens: tokens, + ReconcileGroups: groups, + LostEvents: lostEvents, lifecycleTokens: tokens, }, true } +func (p *pendingWatchBatch) takeProviderGroups() []ProviderRootsGroup { + if len(p.groups) == 0 { + return nil + } + agents := make([]parser.AgentType, 0, len(p.groups)) + for agent := range p.groups { + agents = append(agents, agent) + } + slices.SortFunc(agents, func(a, b parser.AgentType) int { return strings.Compare(string(a), string(b)) }) + groups := make([]ProviderRootsGroup, 0, len(agents)) + for _, agent := range agents { + roots := make([]string, 0, len(p.groups[agent])) + for root := range p.groups[agent] { + roots = append(roots, root) + } + slices.Sort(roots) + groups = append(groups, ProviderRootsGroup{Agent: agent, Roots: roots}) + } + clear(p.groups) + p.groupBytes = 0 + p.groupEntries = 0 + return groups +} + func (p *pendingWatchBatch) takeLifecycleTokens() []backendLifecycleToken { if len(p.lifecycle) == 0 { return nil @@ -997,7 +1128,7 @@ func (w *Watcher) start(openDispatch bool) error { // unrelated event, manual sync, or audit. func (w *Watcher) QueueRetryBatch(batch WatchBatch) { if !batch.FullSync && len(batch.ReconcileRoots) == 0 && - len(batch.Paths) == 0 { + len(batch.ReconcileGroups) == 0 && len(batch.Paths) == 0 { return } w.eventSink.RetainRetry(batch) @@ -1330,30 +1461,54 @@ func callbackRetryBatch(err error) (WatchBatch, bool) { return WatchBatch{}, false } retry := retryErr.WatchRetryBatch() + retry = CanonicalizeWatchBatch(retry) if retry.FullSync { - return WatchBatch{FullSync: true, LostEvents: retry.LostEvents}, true + return retry, true } if len(retry.Paths) == 0 && len(retry.ReconcileRoots) == 0 { - return WatchBatch{}, false + if len(retry.ReconcileGroups) == 0 { + return WatchBatch{}, false + } } - return WatchBatch{ - Paths: append([]string(nil), retry.Paths...), - ReconcileRoots: append([]string(nil), retry.ReconcileRoots...), - LostEvents: retry.LostEvents, - }, true + return retry, true +} + +func cloneProviderRootsGroups( + groups []ProviderRootsGroup, +) []ProviderRootsGroup { + if len(groups) == 0 { + return nil + } + cloned := make([]ProviderRootsGroup, 0, len(groups)) + for _, group := range groups { + cloned = append(cloned, ProviderRootsGroup{ + Agent: group.Agent, + Roots: append([]string(nil), group.Roots...), + }) + } + return cloned } func retainWatchRetry(pending *pendingWatchBatch, retry WatchBatch) { + retry = CanonicalizeWatchBatch(retry) if retry.FullSync { pending.retainFullSync() } else { for _, path := range retry.Paths { pending.Add(path) } + for _, rename := range retry.Renames { + pending.AddRename(rename) + } for _, root := range retry.ReconcileRoots { pending.AddReconcileRoot(root) } } + for _, group := range retry.ReconcileGroups { + for _, root := range group.Roots { + pending.AddReconcileGroup(group.Agent, root) + } + } pending.lostEvents = pending.lostEvents || retry.LostEvents for _, token := range retry.lifecycleTokens { pending.AddLifecycle(token) diff --git a/internal/sync/watcher_test.go b/internal/sync/watcher_test.go index dcc7c27cb..eb7ec3294 100644 --- a/internal/sync/watcher_test.go +++ b/internal/sync/watcher_test.go @@ -21,6 +21,62 @@ import ( const watcherTestTimeout = 5 * time.Second +func TestPendingWatchBatchFullSyncDropsProviderGroups(t *testing.T) { + pending := newPendingWatchBatch(defaultWatchBatchMaxEntries, defaultWatchBatchMaxPathBytes) + pending.AddReconcileGroup("opencode", `C:\provider\root`) + pending.AddFullSync() + + batch, ok := pending.Take() + require.True(t, ok) + require.True(t, batch.FullSync) + assert.Empty(t, batch.ReconcileGroups) + assert.Empty(t, batch.ReconcileRoots) +} + +func TestPendingWatchBatchFullSyncAfterRenameOverflowDropsProviderGroups(t *testing.T) { + pending := newPendingWatchBatch(1, defaultWatchBatchMaxPathBytes) + pending.AddReconcileGroup("opencode", `C:\provider\root`) + pending.AddRename(WatchRename{Path: `C:\provider\root\nested\rename`, Root: `C:\provider\root`}) + + batch, ok := pending.TakeWithRootAgents(nil) + require.True(t, ok) + require.True(t, batch.FullSync) + assert.Empty(t, batch.ReconcileGroups) +} + +func TestCanonicalizeWatchBatchFullSyncDropsPartialObligations(t *testing.T) { + batch := CanonicalizeWatchBatch(WatchBatch{ + FullSync: true, + Paths: []string{"/sessions/a.jsonl"}, + ReconcileRoots: []string{"/sessions"}, + ReconcileGroups: []ProviderRootsGroup{{Agent: "opencode", Roots: []string{"/sessions/opencode"}}}, + }) + assert.True(t, batch.FullSync) + assert.Empty(t, batch.Paths) + assert.Empty(t, batch.Renames) + assert.Empty(t, batch.ReconcileRoots) + assert.Empty(t, batch.ReconcileGroups) +} + +func TestCanonicalizeWatchBatchPromotesFileRenamesToBoundedPaths(t *testing.T) { + batch := CanonicalizeWatchBatch(WatchBatch{ + Renames: []WatchRename{{Path: "/sessions/changed.jsonl", ItemType: ItemIsFile}}, + }) + assert.Equal(t, []string{"/sessions/changed.jsonl"}, batch.Paths) +} + +func TestPendingWatchBatchGroupBudgetOverflowFallsBackToGenericFullSync(t *testing.T) { + pending := newPendingWatchBatch(1, defaultWatchBatchMaxPathBytes) + pending.AddReconcileGroup("opencode", `C:\provider\first`) + pending.AddReconcileGroup("opencode", `C:\provider\second`) + + batch, ok := pending.Take() + require.True(t, ok) + require.True(t, batch.FullSync) + assert.Empty(t, batch.ReconcileGroups, + "an over-budget provider group must not suppress its generic full recovery") +} + func requireReceiveWithin[T any](t *testing.T, ch <-chan T, timeout time.Duration) T { t.Helper() select { @@ -1194,6 +1250,21 @@ func TestRetriedFullSyncPreservesChangesArrivingDuringCallback(t *testing.T) { assert.Equal(t, []string{"/sessions/new.jsonl"}, changed.Paths) } +func TestRetainWatchRetryPreservesMixedRenameObligations(t *testing.T) { + pending := newPendingWatchBatch(8, 1_000) + retainWatchRetry(pending, WatchBatch{ + Paths: []string{"/sessions/file.jsonl"}, + Renames: []WatchRename{ + {Path: "/sessions/file.jsonl", ItemType: ItemIsFile}, + {Path: "/sessions/child", ItemType: ItemIsUnknown}, + }, + }) + batch, ok := pending.Take() + require.True(t, ok) + assert.Equal(t, []string{"/sessions/file.jsonl"}, batch.Paths) + assert.Len(t, batch.Renames, 2) +} + func TestRetriedLostEventReconciliationPreservesRecoveryMode(t *testing.T) { pending := newPendingWatchBatch(8, 1_000) @@ -1473,6 +1544,15 @@ func TestWatcherUsesCallbackReconciliationScopeForRetry(t *testing.T) { name: "classified ordinary rename retries roots", retry: WatchBatch{ReconcileRoots: []string{"/sessions", "/sessions"}}, }, + { + name: "provider-owned retry preserves group", + retry: WatchBatch{ + ReconcileRoots: []string{"/sessions"}, + ReconcileGroups: []ProviderRootsGroup{{ + Agent: "codex", Roots: []string{"/sessions"}, + }}, + }, + }, { name: "classified authoritative rename retries full sync", retry: WatchBatch{FullSync: true}, @@ -1514,6 +1594,7 @@ func TestWatcherUsesCallbackReconciliationScopeForRetry(t *testing.T) { assert.Empty(t, second.Paths) assert.Empty(t, second.Renames) assert.Equal(t, []string{"/sessions"}, second.ReconcileRoots) + assert.Equal(t, tc.retry.ReconcileGroups, second.ReconcileGroups) } }) }