Skip to content

Add config hot reload and targeted invalidation#9

Closed
shuv1337 wants to merge 4 commits intodevfrom
feat-config-hot-reload
Closed

Add config hot reload and targeted invalidation#9
shuv1337 wants to merge 4 commits intodevfrom
feat-config-hot-reload

Conversation

@shuv1337
Copy link
Copy Markdown
Collaborator

@shuv1337 shuv1337 commented Nov 12, 2025

Summary

PATCH /config, which is not currently used by the OpenCode TUI and uses the legacy config.json file format, currently force a full restart for every edit. This branch introduces a feature-flagged hot reload pipeline with safe persistence so we only invalidate the subsystems touched by each diff.

Changes

Features

  • Build locking/backup/diff/persistence modules that atomically merge JSONC config updates, emit config.updated events, and provide diffs for downstream targets so edits can be applied without restarts.
  • Wire ConfigInvalidation handlers plus the new State.register API into the server/event bus so a diff only reinitializes the provider/MCP/LSP components that actually changed.
  • Document the workflow in IMPLEMENTATION_SUMMARY.md and expand specs/config-spec.md plus hot reload tests that describe the feature flags and behaviors.

Fixes

  • Replace the JSON.parse writer with jsonc-parser so comment-rich configs no longer corrupt on incremental edits, and extend write tests to keep the fallback path unused.
  • Add regression coverage around theme-only diffs so targeted invalidation prevents the MCP/LSP/Plugin churn previously observed in logs.

Refactoring

  • Move agent and command state into string-keyed registrations, allowing us to call State.invalidate("agent") during targeted refreshes instead of disposing the entire instance.
  • Update the config server entry points to publish config.updated events, respect project vs global scope, and to rely on the new persistence primitives instead of ad hoc file IO.

Breaking Changes

  • Config.update now requires an object input { scope, update, directory } and returns { before, after, diff, filepath }; any callers must update to the new signature.
  • PATCH /config supports a scope query parameter and now responds with the merged config plus diff metadata, so API consumers expecting the previous shape must adjust.

Testing

  • bun test packages/opencode/test/config/write.test.ts packages/opencode/test/config/hot-reload.test.ts
  • bun test packages/opencode/test/config/config.test.ts

Configuration

  • Set OPENCODE_CONFIG_HOT_RELOAD=true to enable targeted invalidation; leave unset to retain full dispose behavior.
  • Optional safety/diagnostic toggles: OPENCODE_FULL_DISPOSE_ON_CONFIG_UPDATE=true to force legacy restarts and OPENCODE_CONFIG_INVALIDATION_LOG_DIFF=true for verbose diff logging.

Migration

  • Update any custom automation or SDK callers that invoke Config.update or PATCH /config to pass the new parameters and to consume the new response structure.
  • Validate that deployment scripts set desired feature flags before rolling out config hot reload.

@shuv1337 shuv1337 requested a review from Copilot November 12, 2025 22:42
@shuv1337
Copy link
Copy Markdown
Collaborator Author

@codex review this PR

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements config hot reload functionality with targeted invalidation for OpenCode, allowing configuration changes to be applied without full server restarts. The implementation introduces a feature-flagged pipeline that computes config diffs and selectively invalidates only the affected subsystems (provider, MCP, LSP, plugin, etc.).

Key changes:

  • New state management infrastructure with string-based registration and selective invalidation via State.register() and State.invalidate()
  • Safe config persistence with file locking, backup/restore, and JSONC comment preservation using jsonc-parser
  • Event-driven invalidation system that publishes config.updated events and applies targeted refreshes based on diff analysis

Reviewed Changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
specs/config-spec.md Documents PATCH /config endpoint behavior, feature flags, and client workflows
packages/opencode/test/config/write.test.ts Tests JSONC comment preservation and incremental edit validation
packages/opencode/test/config/hot-reload.test.ts Comprehensive hot reload tests covering project/global scopes, event publishing, and targeted invalidation
packages/opencode/test/config/config.test.ts Updated config tests for new API signature and plugin resolution
packages/opencode/src/config/lock.ts File locking mechanism with timeout-based acquisition
packages/opencode/src/config/backup.ts Backup/restore utilities for atomic config updates
packages/opencode/src/config/write.ts JSONC writer using jsonc-parser for comment-preserving incremental updates
packages/opencode/src/config/persist.ts Main persistence logic with locking, diffing, validation, and cache invalidation
packages/opencode/src/config/diff.ts Config diff computation for detecting changed sections
packages/opencode/src/config/error.ts Typed error definitions for config operations
packages/opencode/src/config/invalidation.ts Targeted invalidation handlers that map diff sections to subsystem refreshes
packages/opencode/src/config/hot-reload.ts Feature flag helper for checking hot reload enablement
packages/opencode/src/config/global-file.ts Global config file path resolution
packages/opencode/src/config/config.ts Updated Config.update() to new signature, added event definitions, and plugin path resolution
packages/opencode/src/server/server.ts Enhanced PATCH /config with scope parameter, event publishing, and toast enrichment
packages/opencode/src/project/state.ts New State.register() and State.invalidate() APIs for named state management
packages/opencode/src/project/instance.ts Added Instance.invalidate() and Instance.forEach() helpers
packages/opencode/src/project/bootstrap.ts Wired ConfigInvalidation.setup() into initialization
packages/opencode/src/tool/registry.ts Converted to State.register() for targeted invalidation
packages/opencode/src/provider/provider.ts Converted to State.register() for targeted invalidation
packages/opencode/src/plugin/index.ts Converted to State.register() with cleanup dispose handler
packages/opencode/src/permission/index.ts Converted to State.register() for targeted invalidation
packages/opencode/src/mcp/index.ts Converted to State.register() for targeted invalidation
packages/opencode/src/lsp/index.ts Converted to State.register() for targeted invalidation
packages/opencode/src/format/index.ts Converted to State.register() for targeted invalidation
packages/opencode/src/file/watcher.ts Converted to State.register() for targeted invalidation
packages/opencode/src/command/index.ts Converted to State.register() for targeted invalidation
packages/opencode/src/agent/agent.ts Converted to State.register() for targeted invalidation
IMPLEMENTATION_SUMMARY.md Comprehensive implementation documentation with architecture decisions and migration guide

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/opencode/src/config/lock.ts Outdated
Comment on lines +33 to +42
let releaseFn: () => void
const lockPromise = new Promise<void>((resolve) => {
releaseFn = resolve
})

fileLocks.set(normalized, lockPromise)

return () => {
fileLocks.delete(normalized)
releaseFn!()
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The releaseFn! usage on line 42 uses a non-null assertion operator, but releaseFn is not guaranteed to be defined if this function is called before the Promise callback executes. While the current logic ensures this won't happen, it would be safer to initialize releaseFn or use a pattern that avoids the assertion:

let releaseFn: (() => void) | undefined
const lockPromise = new Promise<void>((resolve) => {
  releaseFn = resolve
})

fileLocks.set(normalized, lockPromise)

return () => {
  fileLocks.delete(normalized)
  if (releaseFn) releaseFn()
}

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/config/config.ts Outdated
Comment on lines 181 to 187
if (result.autoshare === true && !result.share) {
result.share = "auto"
}

// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

Duplicate code: Lines 181-187 are identical to lines 185-187. The second check for autoshare === true is redundant and should be removed.

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/config/lock.ts Outdated
Comment on lines +19 to +23
if (waited > 5000 && waited < 5100) {
log.warn("lock acquisition taking longer than expected", {
filepath: normalized,
waited,
})
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

[nitpick] The magic numbers 5000 and 5100 on lines 19-23 for the warning threshold should be extracted as named constants for better readability:

const LOCK_WARNING_THRESHOLD_MS = 5000
const LOCK_WARNING_WINDOW_MS = 100

if (waited > LOCK_WARNING_THRESHOLD_MS && waited < LOCK_WARNING_THRESHOLD_MS + LOCK_WARNING_WINDOW_MS) {

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/server/server.ts Outdated
Comment on lines +83 to +88

function rememberConfigUpdate(directory: string, scope: "project" | "global", sections: string[]) {
LastConfigUpdate.set(directory, { scope, sections, at: Date.now() })
// best-effort cleanup of stale entries
for (const [key, value] of LastConfigUpdate) {
if (Date.now() - value.at > 60_000) LastConfigUpdate.delete(key)
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

[nitpick] The magic number 60000 (60 seconds) on line 88 should be extracted as a named constant:

const CONFIG_UPDATE_MEMORY_TTL_MS = 60_000

if (Date.now() - value.at > CONFIG_UPDATE_MEMORY_TTL_MS) LastConfigUpdate.delete(key)
Suggested change
function rememberConfigUpdate(directory: string, scope: "project" | "global", sections: string[]) {
LastConfigUpdate.set(directory, { scope, sections, at: Date.now() })
// best-effort cleanup of stale entries
for (const [key, value] of LastConfigUpdate) {
if (Date.now() - value.at > 60_000) LastConfigUpdate.delete(key)
const CONFIG_UPDATE_MEMORY_TTL_MS = 60_000
function rememberConfigUpdate(directory: string, scope: "project" | "global", sections: string[]) {
LastConfigUpdate.set(directory, { scope, sections, at: Date.now() })
// best-effort cleanup of stale entries
for (const [key, value] of LastConfigUpdate) {
if (Date.now() - value.at > CONFIG_UPDATE_MEMORY_TTL_MS) LastConfigUpdate.delete(key)

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/server/server.ts Outdated
if (match) {
const scope = (match[1] as string).toLowerCase() as "global" | "project"
const now = Date.now()
const isFresh = (ts: number) => now - ts < 10_000
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

[nitpick] The magic number 10000 (10 seconds) on line 1708 should be extracted as a named constant for better maintainability:

const TOAST_FRESHNESS_WINDOW_MS = 10_000

const isFresh = (ts: number) => now - ts < TOAST_FRESHNESS_WINDOW_MS
Suggested change
const isFresh = (ts: number) => now - ts < 10_000
const TOAST_FRESHNESS_WINDOW_MS = 10_000
const isFresh = (ts: number) => now - ts < TOAST_FRESHNESS_WINDOW_MS

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/config/lock.ts Outdated
Comment on lines +7 to +13
interface LockOptions {
timeout?: number
}

export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => void> {
const normalized = path.normalize(filepath)
const timeout = options?.timeout ?? 30000
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

[nitpick] The timeout value on line 13 is a magic number (30000ms). Consider extracting it to a named constant for better maintainability:

const DEFAULT_LOCK_TIMEOUT_MS = 30000
const timeout = options?.timeout ?? DEFAULT_LOCK_TIMEOUT_MS
Suggested change
interface LockOptions {
timeout?: number
}
export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => void> {
const normalized = path.normalize(filepath)
const timeout = options?.timeout ?? 30000
const DEFAULT_LOCK_TIMEOUT_MS = 30000
interface LockOptions {
timeout?: number
}
export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => void> {
const normalized = path.normalize(filepath)
const timeout = options?.timeout ?? DEFAULT_LOCK_TIMEOUT_MS

Copilot uses AI. Check for mistakes.
tasks.push(task)
}
entries.delete(key)
recordsByKey.delete(key)
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The cleanup operation in line 142 has a bug. It deletes the wrong key - it should delete key but instead deletes entries.delete(key). The correct line should be:

recordsByKey.delete(key)

Currently, entries.delete(key) will return a boolean (true/false), and that boolean is then used as the key to delete from recordsByKey, which is incorrect.

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/config/lock.ts Outdated
throw new Error(`Lock timeout: could not acquire lock for ${normalized} after ${waited}ms`)
}

await fileLocks.get(normalized)
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

The lock acquisition loop uses a busy-wait pattern without any delay between checks, which could cause high CPU usage. Consider adding a small delay (e.g., await new Promise(resolve => setTimeout(resolve, 10))) between iterations to reduce CPU consumption while waiting for the lock.

Suggested change
await fileLocks.get(normalized)
await fileLocks.get(normalized)
await new Promise(resolve => setTimeout(resolve, 10))

Copilot uses AI. Check for mistakes.
Comment thread packages/opencode/src/config/persist.ts Outdated
@@ -0,0 +1,178 @@
import path from "path"
import os from "os"
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

Unused import os.

Suggested change
import os from "os"

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +13
const log = Log.create({ service: "config.write" })

Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

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

Unused variable log.

Suggested change
const log = Log.create({ service: "config.write" })

Copilot uses AI. Check for mistakes.
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.

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 183 to +209
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
return c.json(config)
const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project"
const directory = Instance.directory

const result = await Config.update({
scope,
update: config,
directory,
})

const publishDiff = result.diffForPublish
const hotReloadEnabled = isConfigHotReloadEnabled()
const sections = Object.keys(publishDiff).filter((k) => (publishDiff as any)[k] === true)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 Badge Config patches ignore updates when hot reload flag is off

Previously the PATCH /config endpoint disposed the instance after writing so changes took effect immediately. The new handler only publishes invalidation events when OPENCODE_CONFIG_HOT_RELOAD is true; otherwise it just calls Config.update and returns the response. Config.update now skips any State.invalidate calls when hot reload is disabled, so with the flag unset (the documented default) the running process keeps using the old configuration until it is manually restarted. This silently regresses the existing behavior and makes config edits appear successful while they are ignored. Consider invoking the legacy full dispose path when hot reload is disabled.

Useful? React with 👍 / 👎.

@shuv1337 shuv1337 closed this Nov 13, 2025
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.

3 participants