Skip to content

server: file watcher restarts in a loop on unmodified skill directories, dropping SSE and respawning the CLI #53416

Open

Description

@kumu314

Summary

The file watcher enters a restart loop on skill directories that are not being modified, tearing down and re-subscribing thousands of times per day. The resulting load appears to drop SSE event-stream connections, which makes the desktop app conclude the background service is dead and respawn the ~206 MB CLI process. Frequent respawns then collide on session_message sequence allocation, corrupting conversation ordering.

Environment

  • opencode version: 2.0.23 (bundled CLI opencode v2.0.23, desktop 2.0.23.0)
  • OS: Windows 11, 10.0.26200 build 26200, x64
  • Terminal: Desktop app (OPENCODE_CLIENT=desktop); no TTY terminal variables
  • Shell: C:\WINDOWS\system32\cmd.exe (spawned tools run under pwsh)
  • Install/channel: latest (release channel, packaged desktop build)
  • Active plugins: none configured

Reproduction

  1. Launch the OpenCode desktop app on Windows with at least two skill directories registered, one of which is idle.
  2. Let the service run for an hour or so.
  3. Inspect opencode.log and count watcher started events.

Expected Behavior

A watcher on an unmodified directory should stay started. Watcher restarts should track actual filesystem change volume.

Actual Behavior

An idle directory is repeatedly torn down and re-subscribed.

Measured on one log file:

  • watcher started events total: 3039
  • Directory A (a skills directory on a secondary drive): 3073 restart events
  • Directory B (<USERPROFILE>\.claude\skills): 2574 restart events

Directory B is the decisive data point: its most recent file modification was 2026-08-22, roughly six weeks before the measurement window, yet its watcher was rebuilt 2574 times. Zero file activity, thousands of rebuilds, so the loop is not driven by filesystem changes. Every event is logged as backend=windows.

The churn is continuous, not a startup burst — 121 watcher events were present in the final 400 lines of the log.

Downstream observable effects:

  • Renderer log: [server-request-queue] server thrashing detected appears 17 times
  • CLI process grew to 407 MB working set / 660 MB private memory / 328.5 s CPU
  • System memory was not the constraint (16 GB total, 3.1 GB free), so this is not OOM
  • Both the thrashing warnings and the load clear when the service is freshly restarted

Additional Context

Observed consequence chain

Service respawns are always preceded by an SSE disconnect, with a stable 15–20 s gap (one health-check timeout):

renderer.log  13:19:59.954  [global-sdk] event stream disconnected
main.log      13:20:06.088  v2 CLI background service starting { reason: 'missing' }

renderer.log  23:34:37.879  [global-sdk] event stream disconnected
main.log      23:34:45.497  v2 CLI background service ready          <- found alive, no respawn

renderer.log  01:01:00.844  [global-sdk] event stream disconnected
main.log      01:01:20.423  v2 CLI background service starting { reason: 'missing' }

Note the 23:34 case: the stream also dropped, but the service was judged alive and was not respawned. This suggests the respawn follows a failed liveness probe rather than the disconnect alone.

One respawn took 50 seconds to reach ready.

Impact: session data corruption

About three minutes after one respawn completed, the log shows:

ERROR run=... message="Failed to drain Session"
  cause=EffectDrizzleQueryError: ... UNIQUE constraint failed: session_message.session_id, session_message.seq
ERROR run=... message="Failed to publish Session execution lifecycle"   (same transaction)

14 such pairs appear across two service runs. When the insert loses the sequence-number race, subsequent rows land with seq values that do not agree with their time_created. The runtime reads conversation context ordered by seq, so older messages are presented as the most recent ones. Observed effect: the model answers a question never asked in the current turn, referencing material from roughly 18 hours earlier.

Reported here because the respawn frequency appears to be the driver. The sequence-allocation race itself may warrant its own issue.

The real gate is undocumented, and the desktop app sets a variable that does nothing

The CLI gates the watcher like this (verbatim from the shipped binary):

fs: {
  filewatcher: !y(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
  ...
}

Neither name appears anywhere in the published configuration schema, and opencode --help does not list them.

Separately, the desktop app forcibly injects into the service environment:

Object.assign(process.env, {
  ...t,
  OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: `true`,
  OPENCODE_EXPERIMENTAL_FILEWATCHER: `true`,
  OPENCODE_CLIENT: `desktop`
})

But OPENCODE_EXPERIMENTAL_FILEWATCHER does not appear anywhere in the 2.0.23 CLI binary — zero occurrences, verified with a string search whose detector was confirmed working against other known strings in the same file. The CLI reads OPENCODE_DISABLE_FILEWATCHER, not OPENCODE_EXPERIMENTAL_FILEWATCHER. The desktop app therefore sets a dead variable, which makes the watcher look experimental and configurable when neither is true.

Note also that the watcher is on by default: with neither disable variable set, filewatcher evaluates to true. There is no experimental code path to speak of.

Net effect for users: the only working control is an undocumented environment variable whose name suggests it disables something else, while a similarly-named variable that the desktop app does set is silently ignored.

Ruled out

  • watcher.ignore in opencode.json does not apply to these paths; ignores=0 is logged on every subscribe for both directories. The key exists in the schema but has no effect on globally-registered skill directories.
  • Not disk-related
  • Not a crash (Application event log clean, crashpad reported nothing)
  • Not memory pressure

Workaround

Setting OPENCODE_DISABLE_FILEWATCHER in the user environment and restarting the desktop app stops the watcher churn. This is the only mitigation found; note it disables the watcher outright rather than excluding specific directories, so watcher.ignore would be the better long-term shape once it works for skill paths.

Confidence

Not proven that the watcher loop causes the SSE disconnects. Timing is consistent (churn is heaviest right before disconnects) and the six-week-idle-directory evidence proves the loop exists independent of file activity, but no direct causal measurement was taken. CPU saturation and watcher churn are both candidates.

Activity

  1. github-actions commented on Oct 5, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions