Skip to content

Hot reload config and trust rules on file change#121

Merged
siddseethepalli merged 1 commit into
mainfrom
hot-reload-config-trust
Feb 8, 2026
Merged

Hot reload config and trust rules on file change#121
siddseethepalli merged 1 commit into
mainfrom
hot-reload-config-trust

Conversation

@siddseethepalli
Copy link
Copy Markdown
Contributor

@siddseethepalli siddseethepalli commented Feb 8, 2026

Summary

  • Watch ~/.vellum/config.json and ~/.vellum/trust.json for changes while the daemon is running
  • Config changes: invalidate config cache, re-initialize providers, evict idle sessions / mark busy ones stale (same pattern as /model command)
  • Trust rule changes: clear the trust rule cache so permission checks immediately use the latest rules
  • Uses fs.watch() with 200ms debounce to handle editors that write files in multiple steps
  • Watchers are started on server.start() and cleaned up on server.stop()
  • Added invalidateConfigCache() export to config/loader.ts

Test plan

  • bun tsc --noEmit passes
  • All 212 tests pass (bun test)
  • Manual verification: editing config.json while daemon runs triggers reload log message

🤖 Generated with Claude Code


Open with Devin

Watch ~/.vellum/config.json and ~/.vellum/trust.json for changes while
the daemon is running. When either file is modified:

- config.json: invalidate the config cache, re-initialize providers,
  and evict/mark-stale all sessions so they pick up the new settings
- trust.json: clear the trust rule cache so permission checks use the
  latest rules immediately

Uses fs.watch() with 200ms debounce (editors often write in multiple
steps). Watchers are started on daemon start and cleaned up on stop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@siddseethepalli siddseethepalli merged commit c838df2 into main Feb 8, 2026
@siddseethepalli siddseethepalli deleted the hot-reload-config-trust branch February 8, 2026 22:13
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment on lines +89 to +90
const watchFile = (filePath: string, label: string, onChange: () => void) => {
if (!existsSync(filePath)) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 File watcher silently skipped when config/trust file doesn't exist at daemon startup

The watchFile helper at line 90 returns early with if (!existsSync(filePath)) return;, meaning no watcher is set up if the file doesn't exist when the daemon starts. If a user starts the daemon before creating ~/.vellum/config.json or ~/.vellum/trust.json, and later creates or modifies those files, the changes will never be detected.

Root Cause and Impact

The config.json file is only created when the user explicitly runs a config-set command (via saveRawConfig at assistant/src/config/loader.ts:131-136), and trust.json is only created when a trust rule is first added (assistant/src/permissions/trust-store.ts:43-53). Both files may legitimately not exist at daemon startup.

Since fs.watch() requires the file to exist, the fix should either watch the parent directory for file creation events, or use fs.watchFile() (polling-based) which works with non-existent paths.

Impact: The hot-reload feature — the entire purpose of this PR — silently does nothing for users who haven't previously created these config files, which is likely the common case for new users.

Prompt for agents
In assistant/src/daemon/server.ts, the watchFile helper on line 89-105 skips setting up a watcher when the file doesn't exist. Instead of watching individual files with fs.watch(), consider watching the parent directory (dataDir) for changes. When a 'rename' event fires for the target filename, trigger the onChange callback. This handles both file creation and editors that use atomic rename-based saves. Alternatively, use fs.watchFile() (polling-based) which works even when the file doesn't exist yet, though it uses more resources. The directory-watching approach would look something like: watch(dataDir, (eventType, filename) => { if (filename === basename(filePath)) { ... debounce and call onChange ... } }).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +131 to +136

private stopFileWatchers(): void {
for (const watcher of this.watchers) {
watcher.close();
}
this.watchers = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pending debounce timer fires after server stop, causing use-after-teardown

When stopFileWatchers() is called during stop(), it closes the FSWatcher instances but does not cancel any pending setTimeout debounce callbacks. If a file change event fired shortly before stop(), the 200ms debounced callback will still execute, accessing this.sessions and calling invalidateConfigCache() / initializeProviders() after the server has been torn down.

Root Cause and Impact

At assistant/src/daemon/server.ts:92-99, the debounceTimer is a local variable captured in the watch callback closure. The stopFileWatchers method at lines 132-136 only calls watcher.close() and clears the this.watchers array — it has no reference to the debounce timers to clear them.

When the debounced callback fires after stop:

  • invalidateConfigCache() clears the config cache unnecessarily
  • getConfig() and initializeProviders() re-initialize providers on a stopped server
  • The for (const [id, session] of this.sessions) loop mutates session state after teardown

Impact: Could cause unexpected errors or log noise during shutdown. In the worst case, it could interfere with a subsequent server restart if the process is reused.

Prompt for agents
In assistant/src/daemon/server.ts, the stopFileWatchers method at lines 131-136 needs to also clear any pending debounce timers. One approach: store the debounce timer IDs alongside the watchers (e.g., change this.watchers to store objects like { watcher: FSWatcher, clearDebounce: () => void }). In the watchFile helper, expose a way to clear the debounceTimer. In stopFileWatchers, call clearDebounce() for each entry before closing the watcher. Alternatively, add a boolean flag like this.stopped = true and check it at the start of each onChange callback.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +107 to +124

watchFile(configPath, 'config.json', () => {
invalidateConfigCache();
try {
const config = getConfig();
initializeProviders(config);
} catch (err) {
log.error({ err }, 'Failed to reload config');
return;
}
// Evict idle sessions; mark busy ones as stale
for (const [id, session] of this.sessions) {
if (!session.isProcessing()) {
this.sessions.delete(id);
} else {
session.markStale();
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Self-triggered config reload: handleModelSet writes config.json which triggers the file watcher

When handleModelSet at line 354 calls saveRawConfig(raw), it writes to config.json. This triggers the file watcher's onChange callback (after 200ms debounce), which redundantly invalidates the config cache, re-initializes providers, and evicts/marks sessions stale a second time.

Root Cause and Impact

The file watcher at lines 108-124 fires for any change to config.json, including changes made by the daemon itself via handleModelSet (line 349-378). The handleModelSet method already performs the full reload sequence (invalidate cache → getConfig → initializeProviders → evict sessions). The watcher then does it all again 200ms later.

The second eviction pass is particularly problematic: any new sessions created in the 200ms window between handleModelSet completing and the debounced watcher firing will be incorrectly evicted or marked stale, even though they were already created with the latest config.

Impact: Sessions created immediately after a /model command may be unnecessarily evicted or marked stale by the delayed watcher callback, forcing an extra reload on the next user message.

Prompt for agents
In assistant/src/daemon/server.ts, add a mechanism to suppress the file watcher callback when the daemon itself writes to config.json. One approach: add a private field like `private suppressConfigReload = false;` to DaemonServer. Set it to true before calling saveRawConfig() in handleModelSet, and reset it after. In the config watcher onChange callback (lines 108-124), check this flag and skip the reload if it's set. Alternatively, use a timestamp-based approach: record when the daemon last wrote the file, and in the watcher callback, skip if the change happened within a small window of the last self-write.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cad49fcb7f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const trustPath = join(dataDir, 'trust.json');

const watchFile = (filePath: string, label: string, onChange: () => void) => {
if (!existsSync(filePath)) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Start watching config/trust even before files exist

The new watchFile helper exits immediately when the target file is missing (if (!existsSync(filePath)) return;), which means fresh daemon runs never subscribe to config.json changes because loadConfig() can run entirely from defaults without creating that file. In that common case, a later vellum config set ... (or manual file creation) will not trigger cache invalidation/provider reinit, so the daemon keeps stale settings until restart; the same blind spot applies to first-time trust.json creation after cache warmup.

Useful? React with 👍 / 👎.

if (!existsSync(filePath)) return;
try {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const watcher = watch(filePath, () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reattach file watches after atomic rename writes

The watcher is bound directly to the file path with watch(filePath, ...) but does not handle rename by recreating the watcher. trust-store persists updates via renameSync(tmpPath, path), so after the first atomic replace, many platforms stop reporting future changes for that watch handle; subsequent trust edits then no longer clear the in-memory cache and permission decisions can remain stale until process restart.

Useful? React with 👍 / 👎.

siddseethepalli added a commit that referenced this pull request Feb 8, 2026
…ppression

Addresses all review feedback on #121:

1. Watch the data directory instead of individual files, so watchers work
   even when config.json/trust.json don't exist at daemon startup (P1).
   Also fixes atomic rename writes (trust-store uses renameSync) that
   would break per-file watchers on many platforms.

2. Cancel pending debounce timers in stopFileWatchers() to prevent
   use-after-teardown callbacks firing after server shutdown.

3. Suppress redundant config reload when handleModelSet writes
   config.json — the method already does the full reload sequence,
   and a delayed watcher callback would incorrectly evict sessions
   created in the 200ms debounce window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
siddseethepalli added a commit that referenced this pull request Feb 8, 2026
…ppression (#124)

Addresses all review feedback on #121:

1. Watch the data directory instead of individual files, so watchers work
   even when config.json/trust.json don't exist at daemon startup (P1).
   Also fixes atomic rename writes (trust-store uses renameSync) that
   would break per-file watchers on many platforms.

2. Cancel pending debounce timers in stopFileWatchers() to prevent
   use-after-teardown callbacks firing after server shutdown.

3. Suppress redundant config reload when handleModelSet writes
   config.json — the method already does the full reload sequence,
   and a delayed watcher callback would incorrectly evict sessions
   created in the 200ms debounce window.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant