diff --git a/cmd/agentsview/opencode_coverage_units_test.go b/cmd/agentsview/opencode_coverage_units_test.go new file mode 100644 index 000000000..fd3e928b2 --- /dev/null +++ b/cmd/agentsview/opencode_coverage_units_test.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "runtime" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/parser" + agentsync "go.kenn.io/agentsview/internal/sync" +) + +// newOpenCodeSQLiteWatchDir creates a configured OpenCode directory whose +// only source is a SQLite database, the shape of the reported +// budget-starvation regression. +func newOpenCodeSQLiteWatchDir(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "opencode.db"), []byte("sqlite")) + return root +} + +func registerCollectedWatchRoots( + t *testing.T, roots []watchRoot, budget int, +) []agentsync.RecursiveWatchResult { + t.Helper() + watcher, err := agentsync.NewWatcherWithCallback( + time.Millisecond, 0, + func(context.Context, agentsync.WatchBatch) error { return nil }, + nil, agentsync.WatcherOptions{}, + ) + require.NoError(t, err) + t.Cleanup(watcher.Stop) + registered := make([]agentsync.WatchRoot, 0, len(roots)) + for _, root := range roots { + registered = append(registered, root.registeredRoot()) + } + return watcher.RegisterRoots(registered, budget) +} + +// TestOpenCodeSQLiteRootSurvivesExhaustedRecursiveBudget is the reproduction +// for the reported failure: with the recursive watch budget exhausted, a +// pure-SQLite OpenCode root used to fail registration entirely and fall to +// the unwatched-root poller, whose every pass is an authoritative archive +// reconciliation. The container unit registers shallowly without consulting +// the budget, so the poller must never receive the OpenCode root. +func TestOpenCodeSQLiteRootSurvivesExhaustedRecursiveBudget(t *testing.T) { + openCodeDir := newOpenCodeSQLiteWatchDir(t) + sentinel := requireExistingPollRoot(t, t.TempDir(), "sentinel") + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {openCodeDir}, + }} + + roots, unwatchedDirs, symlinkGatedDirs, persistentDirAgents := + collectWatchRoots(cfg) + results := registerCollectedWatchRoots(t, roots, 0) + unwatchedDirs = accountRegisteredWatchRoots(unwatchedDirs, roots, results) + obligations := watchPollingObligations( + roots, results, unwatchedDirs, persistentDirAgents, + ) + obligations = append( + obligations, symlinkPollingObligations(symlinkGatedDirs)..., + ) + + syncer := &recordingUnwatchedPollSyncer{wake: make(chan struct{}, 2)} + coordinator := newUnwatchedPollCoordinatorWithTicks( + t.Context(), syncer, make(chan time.Time), func() {}, + func(run func()) { run() }, nil, time.Now, + func(d time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Time{} + return ch + }, + ) + t.Cleanup(coordinator.Stop) + for _, obligation := range obligations { + require.NoError(t, + coordinator.AddObligation(syncObligationToPoller(obligation))) + } + // The sentinel keeps one pollable root installed so the poll pass is + // observable even when the OpenCode root correctly stays out of it. + require.NoError(t, coordinator.AddObligation(pollingObligation{ + Key: "sentinel", Scopes: []pollingScope{{Root: sentinel}}, + })) + + coordinator.requestPoll() + requirePollWithin(t, syncer.wake, time.Second) + calls := syncer.snapshot() + require.NotEmpty(t, calls) + for _, call := range calls { + assert.NotContains(t, call, filepath.Clean(openCodeDir), + "a budget-starved SQLite root must not reach the archive poller") + } + assert.Contains(t, slices.Concat(calls...), sentinel) +} + +// TestOpenCodeContainerUnitIsBudgetExempt pins the registration mechanism in +// a mixed provider plan: with the recursive budget at zero, recursive roots +// degrade while the OpenCode container root still registers natively. +func TestOpenCodeContainerUnitIsBudgetExempt(t *testing.T) { + openCodeDir := newOpenCodeSQLiteWatchDir(t) + claudeDir := t.TempDir() + writeTestFile(t, + filepath.Join(claudeDir, "session.jsonl"), []byte("{}\n")) + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {openCodeDir}, + parser.AgentClaude: {claudeDir}, + }} + + roots, _, _, _ := collectWatchRoots(cfg) + results := registerCollectedWatchRoots(t, roots, 0) + + openCodeIdx := slices.IndexFunc(roots, func(root watchRoot) bool { + return root.path == filepath.Clean(openCodeDir) + }) + require.GreaterOrEqual(t, openCodeIdx, 0, + "opencode container root must be in the collected plan") + assert.False(t, roots[openCodeIdx].recursive) + require.NoError(t, results[openCodeIdx].Err) + assert.Equal(t, 1, results[openCodeIdx].Watched, + "the shallow container unit must register despite budget 0") + assert.False(t, results[openCodeIdx].BudgetExhausted) + + claudeIdx := slices.IndexFunc(roots, func(root watchRoot) bool { + return root.path == filepath.Clean(claudeDir) + }) + require.GreaterOrEqual(t, claudeIdx, 0) + require.True(t, roots[claudeIdx].recursive) + if recursiveWatchesMeterBudget() { + assert.True(t, results[claudeIdx].BudgetExhausted, + "recursive roots still compete for the exhausted budget") + } +} + +// recursiveWatchesMeterBudget reports whether this platform's watch backend +// counts recursive roots against the shared directory budget. The macOS +// FSEvents backend owns the whole root plan and opens one stream per logical +// root, so it never consults the budget and never reports exhaustion. +func recursiveWatchesMeterBudget() bool { + return runtime.GOOS != "darwin" +} + +// TestOpenCodeSQLiteRootPollableUnderWatcherUnavailableFallback proves the +// conditional storage-unit emission does not over-block: when the watcher +// cannot be constructed at all, a pure-SQLite root's configured directory +// must remain pollable rather than being deferred behind a probe for a +// storage subtree that will never exist. +func TestOpenCodeSQLiteRootPollableUnderWatcherUnavailableFallback( + t *testing.T, +) { + openCodeDir := newOpenCodeSQLiteWatchDir(t) + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {openCodeDir}, + }} + + roots, unwatchedDirs, _, persistentDirAgents := collectWatchRoots(cfg) + obligations := watchPollingObligations( + roots, nil, unwatchedDirs, persistentDirAgents, + ) + + gates := make([]pollingObligation, 0, len(obligations)) + for _, obligation := range obligations { + gates = append(gates, syncObligationToPoller(obligation)) + } + assert.Contains(t, + availableUnwatchedPollRootsFlat(gates), filepath.Clean(openCodeDir), + "a pure-SQLite root must stay pollable when no watcher exists") +} + +// TestOpenCodeSymlinkedStorageSubtreeKeepsExistingGate pins the symlink +// boundary: a storage subtree reached through a directory symlink keeps the +// existing recursive-symlink polling gate while the container unit still +// joins the watch plan natively. +func TestOpenCodeSymlinkedStorageSubtreeKeepsExistingGate(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "storage-target") + require.NoError(t, os.MkdirAll(filepath.Join(target, "session"), 0o755)) + storageRoot := filepath.Join(root, "storage") + requireSymlinkOrSkip(t, target, storageRoot) + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root}, + }} + + roots, unwatchedDirs, symlinkGatedDirs, _ := collectWatchRoots(cfg) + + container, ok := findCollectedWatchRoot(roots, root) + require.True(t, ok, "container unit must register natively") + assert.False(t, container.recursive) + assert.True(t, container.exists) + _, storageInPlan := findCollectedWatchRoot(roots, storageRoot) + assert.False(t, storageInPlan, + "a symlinked recursive root never joins the watcher plan") + assert.Equal(t, map[string][]watchScope{ + filepath.Clean(storageRoot): { + {agent: parser.AgentOpenCode, syncDir: filepath.Clean(root)}, + }, + }, symlinkGatedDirs, + "the symlinked storage subtree keeps the polling.symlinkRoots gate") + assert.Contains(t, unwatchedDirs, filepath.Clean(root)) +} + +// TestOpenCodeModeNoneRootProducesZeroObligationsAtZeroBudget pins the +// cheapest row of the mode matrix: an existing root with neither database +// nor storage layout costs exactly one shallow watch and never creates a +// polling obligation, even with no recursive budget at all. +func TestOpenCodeModeNoneRootProducesZeroObligationsAtZeroBudget(t *testing.T) { + root := t.TempDir() + cfg := config.Config{AgentDirs: map[parser.AgentType][]string{ + parser.AgentOpenCode: {root}, + }} + + roots, unwatchedDirs, symlinkGatedDirs, persistentDirAgents := + collectWatchRoots(cfg) + require.Len(t, roots, 1) + assert.False(t, roots[0].recursive) + results := registerCollectedWatchRoots(t, roots, 0) + require.NoError(t, results[0].Err) + assert.Equal(t, 1, results[0].Watched) + + unwatchedDirs = accountRegisteredWatchRoots(unwatchedDirs, roots, results) + obligations := watchPollingObligations( + roots, results, unwatchedDirs, persistentDirAgents, + ) + obligations = append( + obligations, symlinkPollingObligations(symlinkGatedDirs)..., + ) + assert.Empty(t, obligations, + "a mode-none root must produce zero polling obligations") +} + +// TestShallowWatchObservesDirectChildrenOnly is the live filesystem proof +// behind the container unit: one non-recursive watch on the configured root +// observes the database, its WAL, and storage/ directory creation as direct +// children, and does not observe grandchildren. It drives the real watcher +// backend against real writes, so it exercises this platform's fsnotify +// semantics rather than assuming them. +func TestShallowWatchObservesDirectChildrenOnly(t *testing.T) { + root := t.TempDir() + batches := make(chan agentsync.WatchBatch, 16) + watcher, err := agentsync.NewWatcherWithCallback( + 10*time.Millisecond, 0, + func(_ context.Context, batch agentsync.WatchBatch) error { + batches <- batch + return nil + }, + nil, agentsync.WatcherOptions{}, + ) + require.NoError(t, err) + t.Cleanup(watcher.Stop) + + results := watcher.RegisterRoots([]agentsync.WatchRoot{{ + Path: root, Recursive: false, Exists: true, + }}, 0) + require.Len(t, results, 1) + require.NoError(t, results[0].Err) + require.NoError(t, watcher.StartCollecting()) + watcher.OpenDispatch() + + dbPath := filepath.Join(root, "opencode.db") + walPath := filepath.Join(root, "opencode.db-wal") + storageDir := filepath.Join(root, "storage") + grandchild := filepath.Join(storageDir, "deep.json") + writeTestFile(t, dbPath, []byte("sqlite")) + writeTestFile(t, walPath, []byte("wal")) + require.NoError(t, os.Mkdir(storageDir, 0o755)) + writeTestFile(t, grandchild, []byte("{}")) + + observed := make(map[string]bool) + directChildren := []string{dbPath, walPath, storageDir} + deadline := time.After(5 * time.Second) + for { + allDirect := true + for _, child := range directChildren { + if !observed[filepath.Clean(child)] { + allDirect = false + } + } + if allDirect { + break + } + select { + case batch := <-batches: + for _, path := range batch.Paths { + observed[filepath.Clean(path)] = true + } + case <-deadline: + require.FailNow(t, + "direct-child events did not arrive", "observed %v", observed) + } + } + // Grace window: any pending grandchild event would have been batched + // alongside or shortly after the direct-child events above. + grace := time.After(300 * time.Millisecond) + for { + select { + case batch := <-batches: + for _, path := range batch.Paths { + observed[filepath.Clean(path)] = true + } + continue + case <-grace: + } + break + } + assert.False(t, observed[filepath.Clean(grandchild)], + "a shallow watch must not observe grandchildren") +} diff --git a/docs/configuration.md b/docs/configuration.md index e8c751d02..1f78a8c84 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -425,11 +425,14 @@ directory shape, never pasted tokens, OAuth files, or other secrets. layouts. If a `storage/session/` directory exists under the OpenCode root, sessions are parsed from the per-file JSON layout (`storage/session`, `storage/message`, `storage/part`); otherwise the legacy `opencode.db` SQLite -file is used. Detection is automatic and requires no configuration. In storage -mode, the file watcher scopes itself to the `storage/` subtree rather than the -entire OpenCode directory, so unrelated OpenCode state like binaries, logs, and -caches no longer trigger sync events. In SQLite mode, it watches the -`opencode.db` parent. +file is used. Detection is automatic and requires no configuration. The file +watcher always places a shallow (non-recursive) watch on the OpenCode +directory itself, which observes `opencode.db` and its WAL as direct children +without descending into unrelated OpenCode state like binaries, logs, and +caches. When the file-backed storage layout is present, a separate recursive +watch covers the `storage/` subtree, so database and storage coverage are +independent: exhausting the recursive watch limit degrades only the storage +subtree, never the SQLite database watch. Kilo and MiMoCode use the same OpenCode-format storage reader. Kilo reads from `storage/session`, while MiMoCode reads from `storage/session_diff` when diff --git a/internal/parser/opencode_coverage_units_test.go b/internal/parser/opencode_coverage_units_test.go new file mode 100644 index 000000000..ec1db8fc8 --- /dev/null +++ b/internal/parser/opencode_coverage_units_test.go @@ -0,0 +1,308 @@ +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, + IncludeGlobs: []string{"*.json"}, + DebounceKey: string(agent) + ":storage:" + root, + } +} + +// TestOpenCodeFamilyWatchPlanCoverageUnits pins the two-unit emission shape +// for every OpenCode-family agent across resolved mode, database presence, +// and root existence: the shallow container unit is unconditional, and the +// recursive storage unit exists exactly when storage/ exists as a directory, +// so a pure-SQLite root never gains an absent-probe obligation while a +// storage tree whose session subdirectory has not been created yet still +// gets recursive coverage. +func TestOpenCodeFamilyWatchPlanCoverageUnits(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, + )} + if tc.withStorage { + 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..50833333b 100644 --- a/internal/parser/opencode_provider.go +++ b/internal/parser/opencode_provider.go @@ -333,12 +333,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 +660,49 @@ 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 whenever /storage exists as a directory, which is +// weaker than the root resolving to file-backed storage: the session +// subdirectory under it is created lazily, so keying on the resolved mode +// leaves an existing-but-empty storage tree with only grandchild coverage the +// shallow unit cannot see. Emission still requires the directory to exist: +// an always-emitted /storage unit is a permanently missing probe on +// pure-SQLite roots, and the unwatched-root poller defers every candidate +// overlapping a blocked root, so the fallback poll that degraded or +// unavailable watcher coverage depends on never runs. Activating the unit +// when the directory appears avoids the probe, but that needs ancestor +// lifecycle ownership in the portable backend and invalidation of the +// engine's cached changed-path plan, so it belongs to the follow-up. +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, + }} + if s.emitsStorageUnit(root) { + units = append(units, WatchRoot{ + Path: filepath.Join(root, "storage"), + Recursive: 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 +736,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 +770,62 @@ 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 + } + if !s.emitsStorageUnit(root) { + return true + } + _, insideStorage := relUnder(filepath.Join(root, "storage"), path) + return !insideStorage + } + return true +} + +// emitsStorageUnit reports whether root emits a recursive storage unit. Watch +// emission and changed-path scoping must agree on this predicate: a root that +// emits the unit must yield storage-subtree paths to it, and a root that does +// not must keep claiming them itself. +func (s openCodeFormatSourceSet) emitsStorageUnit(root string) bool { + info, err := os.Stat(filepath.Join(root, "storage")) + if err != nil { + return false + } + return info.IsDir() +} + +// 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) { diff --git a/internal/parser/opencode_provider_test.go b/internal/parser/opencode_provider_test.go index a5b2ede33..f97283c21 100644 --- a/internal/parser/opencode_provider_test.go +++ b/internal/parser/opencode_provider_test.go @@ -389,9 +389,14 @@ 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.Equal(t, []string{"*.json"}, plan.Roots[1].IncludeGlobs) discovered, err := provider.Discover(context.Background()) require.NoError(t, err) @@ -494,11 +499,13 @@ 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, 1, + "a pure-SQLite root must not emit a storage 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) discovered, err := provider.Discover(context.Background())