Skip to content

fix(opencode): split the watch plan into database and storage coverage units - #1318

Open
rodboev wants to merge 6 commits into
kenn-io:mainfrom
rodboev:pr/1208-opencode-coverage-units
Open

fix(opencode): split the watch plan into database and storage coverage units#1318
rodboev wants to merge 6 commits into
kenn-io:mainfrom
rodboev:pr/1208-opencode-coverage-units

Conversation

@rodboev

@rodboev rodboev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The OpenCode provider declares one recursive watch root per configured directory, so registration walks the whole tree and draws on the process-wide recursive budget. On a large installation the budget runs out, the root gets no native coverage, and every poll becomes an authoritative reconciliation over the archive. The database and its write-ahead log are direct children of the configured root and never needed recursion.

This splits the plan into two coverage units per root: a non-recursive unit on the configured root covering only the database and its WAL, exempt from the recursive budget, and a recursive unit on the storage subtree, emitted only when the root resolves to file-backed storage. The units fail independently, so losing storage coverage no longer takes SQLite observation with it. A changed path is claimed by exactly one unit while the storage directory's existence is stable, and Kilo, MiMoCode, and ICodeMate inherit the same shape through the shared provider spec. The watch plan is cached at startup, so a storage tree appearing at runtime gets recursive coverage at the next startup, with the daily audit as backstop. A unit that loses coverage is still polled by its configured root at the same archive scale as before; carrying unit identity through the polling obligation belongs to the follow-up.

Measured on windows/amd64 with fsnotify v1.10.1: the non-recursive watch observes the database, its WAL, and storage-directory creation, and does not observe grandchildren. The watcher-exhaustion trace and the production measurements came from the issue report. It is one of three preparatory PRs for #1208, with #1307 and the reconciliation-scoping change (#1319), staged separately so roborev reviews each in isolation; the bounded change feed and coalesced audit that close the issue follow once they land. This branch overlaps #1307 on the polling obligation surface and rebases onto it after #1307 merges.

Refs #1208

@roborev-ci

roborev-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (bebdb83)

The watcher changes need one medium-severity fix for OpenCode storage directories created after startup.

Medium

  • internal/parser/opencode_provider.go:584 — The recursive storage watch is created only if storage/ exists when the initial watcher plan is built. If OpenCode creates storage/ later, only the directory creation is observed; descendants remain unwatched, while unitScopeAllows assigns nested paths to the unregistered storage unit. As a result, new storage-backed sessions receive no live updates until restart or reconciliation.
    • Fix: retain a pending storage watch that activates when storage/ appears, or dynamically register the recursive unit upon creation. Base scope classification on the registered plan and add a regression test that creates storage/session/...json after watcher registration.

Reviewers: 2 done | Synthesis: codex, 10s | Total: 5m26s

@rodboev

rodboev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

The storage tree narrowing is discussed in the PR body: the watch plan is cached at startup, so the shallow unit observes storage/ creation but classifies the bare directory to no source, and files added at runtime wait for restart or the daily audit.

The reason an always-emitted storage unit doesn't work is documented on watchUnits itself:

// 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 <root>/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,
// because an always-emitted <root>/storage unit would become a permanently
// missing probe on pure-SQLite roots, and the unwatched-root poller defers
// every candidate overlapping a blocked root, silencing the configured
// directory's only remaining coverage.

A unit emitted before the directory exists is a permanently missing probe on pure-SQLite roots. The poller defers every candidate overlapping a blocked probe, so it would silence the fallback polling that matters when watcher coverage degrades.

A pending watch that owns the ancestor lifecycle would avoid the probe. The Darwin backend has that shape, but the portable backend and the engine's cached changed-path plan don't, so dynamic activation belongs to the bounded change-feed follow-up on #1208.

The unitScopeAllows point is inert until then: with no recursive watch registered, no nested event is ever delivered, so classification never sees those paths.

@roborev-ci

roborev-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (d34f845)

Medium

  • internal/parser/opencode_provider.go:587 — The recursive storage watch is emitted only if storage/ exists at startup. If it is created later under a mode-none or SQLite root, only the directory-creation event is observed; no recursive watch is registered and the cached watch plan remains stale. Subsequent session files may be missed until restart, manual sync, or the daily audit. On creation, register the recursive unit, invalidate the cached provider watch plan, and reconcile the subtree. Add an integration test covering startup without storage/, followed by creation of a complete session tree.

Reviewers: 2 done | Synthesis: codex, 9s | Total: 4m57s

@rodboev

rodboev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Same finding as the last run; the commit in between only clarified the watchUnits comment that explains why. That rationale still holds: always emitting the unit leaves a permanently missing probe on pure-SQLite roots, which blocks the fallback poll, and activating it on creation needs ancestor lifecycle ownership in the portable backend plus invalidation of the engine's cached plan, so it goes with the change-feed follow-up on #1208.

@roborev-ci

roborev-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (a4c21fb)

The watcher-scope changes need fixes for storage lifecycle handling and macOS-compatible test coverage.

Medium

  • internal/parser/opencode_provider.go:587 — If storage/ is created after startup, the cached watch plan retains only the shallow container watch. The creation event neither installs a recursive storage watch nor invalidates cached roots, so subsequent session/message/part changes are not observed in real time. Direct changed-path dispatches are also rejected because unitScopeAllows treats the newly existing storage unit as the owner.

    • Fix: Atomically register the storage unit and invalidate the cached changed-path plan, or represent the missing storage unit without allowing its probe to block SQLite fallback polling.
  • cmd/agentsview/opencode_coverage_units_test.go:128 — The test assumes every recursive watcher honors the directory budget, but the supported macOS FSEvents backend intentionally ignores it and successfully creates a recursive watch, causing the macOS workflow to fail.

    • Fix: Inject a portable/fsnotify backend for this test, or limit the assertion to backends where recursive watches consume the shared directory budget.

Reviewers: 2 done | Synthesis: codex, 12s | Total: 5m2s

@roborev-ci

roborev-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (ea14de5)

Review identified one medium-severity watcher lifecycle regression.

Medium

  • internal/parser/opencode_provider.go:587 — The recursive storage/ watch is created only if that directory exists during initial planning. If an OpenCode root initially lacks storage/—for example, it is empty or SQLite-only—and the directory appears later, only the shallow root watch remains. It detects the directory creation but does not cover subsequent nested session, message, or part changes because the recursive watch is never registered and the cached watch plan is not invalidated. Dynamically register the recursive storage watch and invalidate cached watch roots when storage/ appears, or retain it as a pending lifecycle-owned root without allowing its missing probe to block SQLite fallback polling.

Reviewers: 2 done | Synthesis: codex, 9s | Total: 3m28s

wesm pushed a commit that referenced this pull request Jul 31, 2026
…#1307)

When one provider loses native watch coverage, the unwatched-root poller
flattened every obligation into a single root list and called the unscoped
reconcile entry point, which expands each root across every configured agent.
One provider's coverage gap therefore dragged every overlapping provider
through a full authoritative discovery every two minutes, and an authoritative
pass for the wrong provider can tombstone sessions under a lifecycle-owned
missing root. Two smaller bugs compounded it: obligations for different
reasons over one physical root collapsed into one map entry (releasing one
silently released the other), and the poll interval was measured between
ticker deadlines, so a pass that ran longer than the interval was followed
immediately by the next one.

What this changes:

- `PollingObligation` now carries `(agent, root)` scopes instead of bare
  roots. Provider identity travels through registration, the watcher
  backends, and the polling handoff instead of being reconstructed
  downstream (`internal/sync/watcher.go`, `cmd/agentsview/main.go`).
- The coordinator groups available scopes by agent and reconciles each
  provider only over its own roots. Obligation keys are namespaced by reason
  and provider (`degraded:<agent>:<path>`, `nowatcher:<agent>:<path>`,
  `persistent:<dir>`), so overlapping obligations stay independently
  releasable (`cmd/agentsview/unwatched_poll.go`).
- Persistent-polling obligations carry scopes only for the providers that
  requested persistent polling for a directory, not every agent sharing it.
  `collectWatchRoots` records that provenance and `watchPollingObligations`
  consumes it (`cmd/agentsview/main.go`).
- A poll pass issues one grouped engine call,
  `Engine.ReconcileProviderRootsGrouped`, which runs each provider's bounded
  pass but shares a single epilogue — global subagent linking and skip-cache
  persistence — across the batch. Without this, that archive-sized work ran
  once per provider per tick. The sync lock is held across the groups and the
  epilogue so a concurrent pass cannot persist newer skip state that the
  shared epilogue would then overwrite; a canceled batch stops between groups
  and skips the epilogue rather than blocking shutdown on it; epilogue
  eligibility is recorded at each pass's own gate sites (after page writes
  commit, before tombstoning), so a tombstoning or cleanup failure cannot
  leave synced sessions unlinked; and session events are emitted once per
  batch outside the lock (`internal/sync/engine.go`).
- The pre-discovery SQLite container capture is scoped to the pass: a group
  outside the OpenCode container family probes no containers, and an
  in-family group probes only the containers overlapping its roots, so probe
  work per poll is bounded by the batch instead of group count or total
  configuration (`internal/sync/opencode_container_gate.go`).
- The worker waits a full interval measured from the previous pass's
  completion, not from the previous ticker deadline.

Deferral is conservative where scopes mix: an obligation without a provider
keeps the unscoped path, so a blocked provider-less root defers every provider
on it and vice versa. Probe gates, remote roots, watcher recovery, and the
daily audit are unchanged; the audit remains the backstop for anything a
scoped poll declines to prove.

Each provider's pass still performs a full discovery over its scope; making
the pass itself incremental needs provider-declared coverage units and is
deferred to #1318, which builds on the polling-scope API introduced here. The
15-minute scheduled sync (`runScheduledSyncPass`) still reconciles providers
individually and could adopt the grouped call in a follow-up.

Refs #1208.


Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
@rodboev
rodboev force-pushed the pr/1208-opencode-coverage-units branch from ea14de5 to 902bc7d Compare July 31, 2026 23:19
@roborev-ci

roborev-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (902bc7d)

The watcher changes have one medium-severity reliability issue; no security issues were found.

Medium

  • internal/parser/opencode_provider.go:587 — The storage watch is emitted only when storage/ exists during initial planning. If a mode-none or SQLite root creates storage/ later, the shallow watch sees only the directory creation; it does not discover existing contents or install recursive coverage. Subsequent JSON sessions may therefore be missed until manual reconciliation or restart.
    • Suggested fix: When storage/ is created, register the recursive unit, invalidate cached provider watch roots, and reconcile the subtree. Alternatively, retain polling or recursive lifecycle coverage until storage activation. Add a test that creates storage/ and nested sessions after watcher registration.

Reviewers: 2 done | Synthesis: codex, 10s | Total: 6m41s

@rodboev

rodboev commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

This rebased cleanly on top of #1307 after that one merged.

wesm pushed a commit that referenced this pull request Aug 1, 2026
…1319)

Part of the prep work for #1208, along with #1307 and #1318. The branch was squashed and includes #1291's changes; if #1291 merges on its own, this needs a rebase.

## The bug

Asking reconciliation to check one directory could delete sessions in other directories. The requested path got widened to the whole configured root, and that widened claim was treated as proof when deciding what to tombstone.

## The fix

Each provider now says exactly what a request covers:

- Discovery may walk a wider directory, but the pass can only touch what was actually asked for.
- Deleting anything requires proof the pass really looked there.
- A pass that covered one whole container (a SQLite file, a Hermes archive) can clean up its members — after checking the session didn't just move somewhere else.
- Blank or unrelated paths now do nothing instead of matching everything.

Hermes, OpenCode, Zed, Shelley, Trae, Aider, VS Copilot, and Omnigent each get topology rules for their own layouts (shared databases, archives, profile folders).

## Also in here (from #1291)

Watcher performance for shared SQLite databases: one session's write used to re-parse the whole database. Now each session has its own change signal, and a file event does work proportional to what changed, not to the archive size.

## Where to look

- `internal/parser/provider.go` — the scope plan
- `internal/sync/engine.go` — how the engine uses it
- `internal/parser/hermes_provider.go`, `opencode_provider.go`, `multi_session_container.go` — per-provider rules


Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
@rodboev
rodboev force-pushed the pr/1208-opencode-coverage-units branch from 902bc7d to be31f47 Compare August 1, 2026 14:09
@roborev-ci

roborev-ci Bot commented Aug 1, 2026

Copy link
Copy Markdown

roborev: Combined Review (be31f47)

One medium-severity watcher lifecycle issue needs to be addressed.

Medium

  • internal/parser/opencode_provider.go:695 — If storage/ does not exist when the watch plan is collected, only the shallow container watch is registered. Creating storage/ later does not add recursive coverage, so descendant JSON updates may be missed because these providers also lack periodic reconciliation. Additionally, unitScopeAllows checks the live filesystem and may emit explicitly supplied paths for a storage unit absent from the engine’s cached plan.

    Suggested fix: Register a pending lifecycle-aware storage unit, or dynamically install it and invalidate the cached plan when storage/ appears. Base scope classification on the emitted plan, and add a regression test covering the transition from absent to created storage.


Reviewers: 2 done | Synthesis: codex, 9s | Total: 6m9s

@rodboev

rodboev commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and starting on the integration slice mentioned in #1306 (comment).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant