Skip to content

config: global config directory watch fails when ~/.config/opencode is a symlink (inotify ENOTDIR), so edits are never hot-reloaded #54933

Open

Description

@oysteinlunde

Summary

When the global config directory ($XDG_CONFIG_HOME/opencode, e.g. ~/.config/opencode) is a symlink to a real directory, the service fails to subscribe its directory watcher with inotify_add_watch ... failed: Not a directory. As a result, edits to the global opencode.json are never hot-reloaded; only opencode reload or a service restart picks them up. A real (non-symlinked) directory works.

Environment

  • opencode version: 2.0.27
  • OS: Linux 6.18.40.1-microsoft-standard-WSL2 x86_64 (WSL2, ext4)
  • Terminal: TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/bash
  • Install/channel: latest
  • Active plugins: not relevant; the isolated reproduction below uses an empty config with no plugins

Reproduction

Isolated, using temporary XDG directories:

R=/tmp/symrepro; mkdir -p $R/real/opencode $R/xdg $R/data $R/state $R/cache $R/proj
echo '{"agent":{"probe":{"description":"v1","prompt":"x"}}}' > $R/real/opencode/opencode.json
ln -s $R/real/opencode $R/xdg/opencode     # trailing slash or not makes no difference

cd $R/proj
XDG_CONFIG_HOME=$R/xdg XDG_DATA_HOME=$R/data XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache \
  opencode serve --hostname 127.0.0.1 --port 47123 --print-logs
  1. In another shell, request GET /api/agent?directory=$R/proj (basic auth opencode:<printed password>). The probe agent has description v1.
  2. Edit the config: sed -i 's/v1/v2/' $R/xdg/opencode/opencode.json and wait several seconds.
  3. Request GET /api/agent again.

Expected Behavior

The config directory watcher starts (following the symlink), and step 3 returns description v2, just as it does when $R/xdg/opencode is a real directory.

Actual Behavior

The directory watcher fails when it starts, and the edit is never picked up (step 3 still returns v1):

level=INFO  message="watcher subscribe" path=/tmp/symrepro/xdg/opencode type=directory ignores=3 role=server
level=ERROR message="failed to subscribe" directory=/tmp/symrepro/xdg/opencode cause="Error: inotify_add_watch on '/tmp/symrepro/xdg/opencode' failed: Not a directory
    at Config.reconcileWatches (/$bunfs/root/chunk-xnspstqv.js:118:33644)
    at ServerProcess.start (/$bunfs/root/chunk-c1a0vz4v.js:2:7540)"
$XDG_CONFIG_HOME/opencode Directory watcher Value after edit
symlink → real dir failed to subscribe … Not a directory v1 (stale)
symlink with trailing slash in target same failure not separately tested
real directory watcher started … backend=inotify v2

File watchers on paths inside the symlinked directory (AGENTS.md, skills, using backend=node) start normally. Only the type=directory inotify watch on the symlink itself fails. This suggests the inotify backend subscribes with IN_DONT_FOLLOW/IN_ONLYDIR on the unresolved path. Resolving the directory with realpath before subscribing (and mapping events back) would probably fix it. This is an inference; I have not verified it in the source.

Additional Context

Activity

  1. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    Thanks for the clear reproduction. I can confirm this on 2.0.27 on Linux, using the same setup with temporary XDG directories and $XDG_CONFIG_HOME/opencode as a symlink to a real directory:

    • The type=directory watcher for the config directory fails with inotify_add_watch on '…/xdg/opencode' failed: Not a directory.
    • After editing opencode.json through the symlink, GET /api/agent still returns the old v1 description.
    • With a real directory in the same place, the watcher starts with backend=inotify and the edit reloads to v2.

    It still happens on the latest dev (1438073), run from source. The script below reproduces it (./repro.sh symlink versus ./repro.sh real).

    Open PR #54093 resolves symlinked directories before subscribing the watcher and maps the event paths back, which looks like it targets this exact failure. Related but separate: #53920 (a symlinked opencode.json file in a real directory), #52240 and #53763 (plugins under symlinks).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #54933: global config dir watch fails when $XDG_CONFIG_HOME/opencode is a symlink.
    # Usage: repro.sh [symlink|real]   (OPENCODE_CMD overrides the opencode command)
    set -u
    MODE=${1:-symlink}
    OC=${OPENCODE_CMD:-opencode}
    PORT=${PORT:-47123}
    R=$(mktemp -d /tmp/opencode/symrepro.XXXX)
    mkdir -p $R/real/opencode $R/xdg $R/data $R/state $R/cache $R/proj
    echo '{"agent":{"probe":{"description":"v1","prompt":"x"}}}' > $R/real/opencode/opencode.json
    if [ "$MODE" = symlink ]; then
      ln -s $R/real/opencode $R/xdg/opencode
    else
      mv $R/real/opencode $R/xdg/opencode
    fi
    cd $R/proj
    XDG_CONFIG_HOME=$R/xdg XDG_DATA_HOME=$R/data XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache \
      $OC serve --hostname 127.0.0.1 --port $PORT --print-logs > $R/log 2>&1 &
    PID=$!
    for i in $(seq 1 60); do
      PW=$(grep -oE 'password[^A-Za-z0-9]*[A-Za-z0-9_-]{8,}' $R/log | head -1 | grep -oE '[A-Za-z0-9_-]{8,}$')
      curl -s -o /dev/null -u "opencode:$PW" "http://127.0.0.1:$PORT/api/agent?directory=$R/proj" && break
      sleep 1
    done
    get() { curl -s -u "opencode:$PW" "http://127.0.0.1:$PORT/api/agent?directory=$R/proj" | grep -oE '"description":"v[0-9]"' | head -1; }
    for i in $(seq 1 20); do B=$(get); [ -n "$B" ] && break; sleep 1; done
    echo "mode=$MODE before edit: $B"
    sleep 3
    sed -i 's/v1/v2/' $R/xdg/opencode/opencode.json
    sleep 8
    echo "mode=$MODE after edit:  $(get)"
    echo "--- watcher log lines ---"
    grep -E 'watcher subscribe|watcher started|failed to subscribe|Not a directory' $R/log | grep -i 'xdg/opencode' | head
    kill $PID 2>/dev/null; wait $PID 2>/dev/null

    Run it: bash repro.sh

  2. Veld101 commented on Oct 17, 2026

    @Veld101

    Correcting my earlier comment on this issue.

    I wrote that "nothing needs mapping back" and that a fix in ConfigWatch.plan alone was enough. That was wrong, and my PR built on that assumption (#56294) has been closed as a duplicate of #54093.

    Resolving the watched directory fixes only half of the problem. It makes the subscription start, but the events then arrive under the resolved path while Config entries keep the spelling they were discovered with (sources.global is the unresolved ~/.config/opencode). Every domain subscriber filters with FSUtil.contains(path.join(entry.path, ...), update.path), so the two spellings never match:

    entry.path : /tmp/…/link/agents           ← what Config keeps
    event.path : /tmp/…/real/agents/review.md ← what a resolved watch emits
    FSUtil.contains(entry, event) = false
    

    That is agent.ts:137, command.ts:152 and source.ts:195 in packages/core/src/config/plugin/. A plan-only fix therefore turns "subscription refused" into "events silently dropped" — the same broken hot-reload with a quieter failure mode.

    The real fix resolves the directory before subscribing and rebases emitted event paths back onto the subscribed spelling, which is what #54093 does:

    Watcher resolves resolved = FSUtil.resolve(directory) for @parcel/watcher and rebases emitted event paths under resolved back onto directory

    What still holds from my earlier comment: the failure is an outright refused subscription, not a silent one, and it reproduces on a real inotify backend:

    [symlink] subscribe FAILED: inotify_add_watch on '/tmp/ino-xxx/link' failed: Not a directory
    [real]    event: [["update", "real/a.json"]]
    

    So #54093 is the fix to track here, not the plan-level change I proposed.

  3. donvargax commented on Oct 23, 2026

    @donvargax

    Still happening on 2.1.1 (Linux). It isn't limited to $XDG_CONFIG_HOME/opencode either: pointing OPENCODE_CONFIG_DIR at a symlinked directory hits the same failure.

    OPENCODE_CONFIG_DIR points to Directory watcher probe description after edit
    symlink → real dir failed to subscribe … inotify_add_watch … failed: Not a directory v1 (stale)
    the real dir watcher started … type=directory backend=inotify v2

    Reproduction, using temporary directories only:

    R=$(mktemp -d); mkdir -p $R/real $R/xdg $R/data $R/state $R/cache $R/proj
    echo '{"agent":{"probe":{"description":"v1","prompt":"x"}}}' > $R/real/opencode.json
    ln -s $R/real $R/config-link
    
    cd $R/proj
    OPENCODE_SERVER_PASSWORD=repro OPENCODE_CONFIG_DIR=$R/config-link \
    XDG_CONFIG_HOME=$R/xdg XDG_DATA_HOME=$R/data XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache \
      opencode serve --hostname 127.0.0.1 --port 47123 --print-logs > $R/log 2>&1 &
    
    get() { curl -s -u opencode:repro "http://127.0.0.1:47123/api/agent?location%5Bdirectory%5D=$R/proj" | grep -oE '"description":"v[0-9]"'; }
    sleep 10; get                                  # "description":"v1"
    sed -i 's/v1/v2/' $R/real/opencode.json; sleep 8
    get                                            # still "description":"v1"
    grep 'failed to subscribe' $R/log

    Setting OPENCODE_CONFIG_DIR=$R/real instead makes the edit show up as v2.

    The config itself is read through the symlink without trouble. Only the watch refuses to follow it, so following the link here wouldn't expose anything that isn't already being read. Resolving the path before subscribing, as #54093 does in the shared watcher, looks like it would cover this entry point too.

    Workaround until then: set OPENCODE_CONFIG_DIR (or XDG_CONFIG_HOME) to the resolved real path.

  4. haineb commented on Nov 5, 2026

    @haineb

    Repro on v2.2.5.

    Setup

    • ~/.config/opencode is a symlink to a real directory (dotfiles layout): ~/.config/opencode -> <workspace>/opencode-v2/
    • One opencode.jsonc reachable through both the global and the project config path

    Behavior

    Every service start and every opencode reload fails to subscribe the global config directory, so config edits are never hot-reloaded:

    level=ERROR message="failed to subscribe" directory=.../.config/opencode
      cause="Error: inotify_add_watch on '.../.config/opencode' failed: Not a directory
          at Config.reconcileWatches (...)
          at Config.reload (...)
          at ServerProcess.start (...)"
    
    • 62 occurrences in the current ~/.local/share/opencode/log/opencode.log; most recent 2026-11-04T17:06:02Z
    • Consistent: every start and every reload
    • A real (non-symlinked) config directory watches fine, so this is specific to the symlinked-root case
    • Per-file watches in that same symlinked root do succeed (watcher subscribe ... type=file for each plugin file), so the failure is specific to the directory-level inotify watch on the symlinked root
    • Impact: every opencode.jsonc edit requires a manual opencode reload (or service restart) to take effect; the in-file watcher path is silently dead
    • Workaround in use: opencode reload after each edit

    Environment

    • opencode: v2.2.5 (release channel)
    • OS: WSL2 — Linux 6.18.54.1-microsoft-standard-WSL2 x86_64
    • Shell: /bin/bash, TERM=xterm-256color

    Note

    #56294 was closed and is CI-green but still unmerged - so the bug persists in the latest release. This may be a good place to look to resolve.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions