Skip to content

feat: hot reload caplets serve config#18

Open
ian-pascoe wants to merge 8 commits into
mainfrom
codex/hot-reload-caplets
Open

feat: hot reload caplets serve config#18
ian-pascoe wants to merge 8 commits into
mainfrom
codex/hot-reload-caplets

Conversation

@ian-pascoe
Copy link
Copy Markdown
Contributor

@ian-pascoe ian-pascoe commented May 12, 2026

Summary

  • add an in-process CapletsRuntime for caplets serve that hot reloads config and Caplet files by default
  • reconcile top-level MCP tools through RegisteredTool handles while preserving last known-good config on invalid edits
  • add targeted downstream/OpenAPI/GraphQL cache invalidation plus docs, tests, and a changeset

Verification

  • pnpm verify

Summary by CodeRabbit

  • New Features

    • caplets serve enables default hot-reload: live config validation, auto-apply of valid edits, and Caplet file add/update/remove without restarting.
  • Bug Fixes / Reliability

    • Targeted shutdowns close only affected connections and avoid spurious restarts/status noise.
    • Backend managers can swap runtime data and invalidate cached operations to reflect live changes.
  • Documentation

    • README and design plan updated with hot-reload behavior and serve/dev guidance.
  • Tests

    • New tests validating hot-reload reconciliation, targeted close/invalidate, and runtime reload semantics.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds hot-reload support for caplets serve via a new exported CapletsRuntime that watches config and Caplet files, reloads and reconciles tool registrations without restarting the MCP server, allows runtime registry swaps and targeted backend invalidation, refactors the entrypoint to use the runtime, and adds tests and docs.

Changes

Hot Reload Feature

Layer / File(s) Summary
Planning and docs
docs/plans/2026-05-12-hot-reload.md, .changeset/quiet-rockets-serve.md, README.md
Design plan, changeset release notes, and README updates describing CapletsRuntime architecture, filesystem watching with debounce, reload pipeline, last-known-good behavior, watched paths, and dev guidance.
Backend manager updates
src/downstream.ts, src/graphql.ts, src/openapi.ts
DownstreamManager adds per-connection closing flag, closeServer(serverId), and updateRegistry(); GraphQLManager and OpenApiManager accept mutable registries and add updateRegistry() and invalidate(serverId) to clear endpoint-specific caches.
CapletsRuntime core implementation
src/runtime.ts
Adds exported CapletsRuntime that loads initial config, creates registry and managers, registers tools, watches config/Caplet roots, debounces and runs reloads that build a new registry, invalidates/updates affected backends, swaps registries, reconciles tool registrations (add/update/remove), and resets watchers; exposes connect/close/introspection methods. Runtime tests validate registration, reload reconciliation, and last-known-good preservation on malformed config.
Entrypoint refactor
src/index.ts
Startup now constructs CapletsRuntime (optionally using CAPLETS_CONFIG) and connects it to StdioServerTransport; shutdown closes the runtime.
Tests and helpers
test/*
Adds tests: downstream reconnection test, GraphQL/OpenAPI invalidation tests, and comprehensive CapletsRuntime tests; includes helpers for temp configs, mocked MCP server, and spec/schema generation.

Sequence Diagrams

sequenceDiagram
  participant FileSystem
  participant CapletsRuntime
  participant DownstreamManager
  participant OpenApiManager
  participant GraphQLManager
  participant MCP_Server
  FileSystem->>CapletsRuntime: file change (config or caplet)
  CapletsRuntime->>CapletsRuntime: scheduleReload() (debounced)
  CapletsRuntime->>CapletsRuntime: reload() -> build new registry
  CapletsRuntime->>DownstreamManager: updateRegistry(newRegistry)
  CapletsRuntime->>OpenApiManager: updateRegistry(newRegistry)
  CapletsRuntime->>GraphQLManager: updateRegistry(newRegistry)
  CapletsRuntime->>DownstreamManager: closeServer(serverId) for changed MCP backends
  CapletsRuntime->>OpenApiManager: invalidate(serverId) for changed OpenAPI endpoints
  CapletsRuntime->>GraphQLManager: invalidate(serverId) for changed GraphQL endpoints
  CapletsRuntime->>CapletsRuntime: reconcileTools() add/update/remove MCP tools
  CapletsRuntime->>MCP_Server: registerTool / update / remove calls
  CapletsRuntime->>CapletsRuntime: resetWatchers() recreate fs watchers
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 A runtime reborn to watch and reload,
Caplets dancing without a restart's heavy load,
Config hot-swapped, tools reconciled with care,
Last known-good config always waiting there! 🔄

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically describes the main change: adding hot reload functionality to caplets serve config.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/hot-reload-caplets

Comment @coderabbitai help to get the list of available commands and usage tips.

@ian-pascoe ian-pascoe force-pushed the codex/hot-reload-caplets branch from 04ecb9a to b75bbbf Compare May 12, 2026 22:51
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/quiet-rockets-serve.md:
- Line 5: Add a top-level Markdown heading to the changeset so it satisfies
markdownlint MD041: open the changeset for "quiet-rockets-serve" and insert a
leading H1 (e.g., "# quiet-rockets-serve" or a meaningful title) above the
existing body line "Add default hot reload for `caplets serve`, including live
config and Caplet file reconciliation without restarting the MCP process." to
ensure the file begins with a heading.

In `@docs/plans/2026-05-12-hot-reload.md`:
- Around line 13-69: Update the Markdown task headings so they use level-2
headings (##) instead of level-3 (###) to avoid MD001 warnings: change the
headings for "Task 1: Runtime Controller", "Task 2: Reload Reconciliation",
"Task 3: Filesystem Watching", "Task 4: Documentation", and "Task 5:
Verification" from ### to ## and ensure any related sublists remain correctly
nested under those updated headings.

In `@src/runtime.ts`:
- Around line 250-252: The loop currently skips setting up watches when
existsSync(entry.path) is false (entry.path), which causes missed reloads if the
directory appears later; instead, when entry.path is missing, register a watcher
on its parent directory (e.g., path.dirname(entry.path)) using fs.watch or your
chokidar instance (with ignoreInitial: true) to detect creation/rename events
and then invoke the same watch-setup routine you use for existing paths (so
reuse the code that runs when existsSync is true); ensure you remove the parent
watcher once the target entry.path is created and the real watch is established.
- Around line 234-243: The code only invalidates the cache for the new backend
when a caplet with the same serverId changes backend, leaving the previous
backend's cache stale; update the logic to explicitly invalidate caches for both
the previous and the new backend when a caplet changes (use before?.backend and
after?.backend), and still handle content changes by comparing
serializeCaplet(before) !== serializeCaplet(after); specifically, call
this.openapi.invalidate(serverId) and/or this.graphql.invalidate(serverId) for
whichever backend values are present on before and on after (and keep the
existing branch that invalidates both when !after).
- Around line 255-257: The current fs.watch call (watch(entry.path, {
persistent: true }, () => { this.scheduleReload(); })) does not recurse into
nested directories; replace it with a recursive watcher (preferably using
chokidar) that watches entry.path recursively and invokes this.scheduleReload()
on change events; add the chokidar import, create the watcher with options like
{ persistent: true, ignoreInitial: true } and subscribe to change/add/unlink (or
the 'all') events to call this.scheduleReload(), and ensure you close the
watcher when appropriate to avoid leaks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2487883c-925d-44be-aea3-0c06f4b0c198

📥 Commits

Reviewing files that changed from the base of the PR and between 3f58fdd and 04ecb9a.

📒 Files selected for processing (12)
  • .changeset/quiet-rockets-serve.md
  • README.md
  • docs/plans/2026-05-12-hot-reload.md
  • src/downstream.ts
  • src/graphql.ts
  • src/index.ts
  • src/openapi.ts
  • src/runtime.ts
  • test/downstream.test.ts
  • test/graphql.test.ts
  • test/openapi.test.ts
  • test/runtime.test.ts

Comment thread .changeset/quiet-rockets-serve.md
Comment thread docs/plans/2026-05-12-hot-reload.md Outdated
Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/downstream.ts (1)

224-229: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard transport.onerror during intentional shutdown.

Line 224 can still mark a server unavailable during close()/closeServer(), even though shutdown is intentional. Add the same closing short-circuit used in onclose.

Suggested patch
       transport.onerror = (error: Error) => {
+        if (connection.closing) {
+          return;
+        }
         this.registry.setStatus(
           server.server,
           "unavailable",
           toSafeError(error, "SERVER_UNAVAILABLE"),
         );
       };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/downstream.ts` around lines 224 - 229, The transport.onerror handler
currently marks servers unavailable even during an intentional shutdown; modify
transport.onerror (the same place that uses the onclose closing short-circuit)
to return early when the instance is in the shutdown state (e.g., check
this.closing or the same flag used by onclose/close()/closeServer()) before
calling this.registry.setStatus(server.server, "unavailable", ...); ensure the
guard mirrors the one in onclose so intentional shutdowns don't flip status.
♻️ Duplicate comments (4)
src/runtime.ts (3)

255-257: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use recursive watching (or chokidar) so nested Caplet edits trigger reloads.

fs.watch without recursion can miss deep file changes under Caplet subdirectories.

In Node.js v22, does fs.watch(path, { recursive: false/default }) emit change events for files in nested subdirectories, or only direct children?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime.ts` around lines 255 - 257, The current fs.watch call that
invokes this.scheduleReload() can miss nested Caplet file edits; replace the
non-recursive watcher with a recursive watcher or a robust file-watcher library:
either set fs.watch(entry.path, { persistent: true, recursive: true }) where
supported, or switch to using chokidar.watch(entry.path, { persistent: true,
ignoreInitial: true }) and listen for add/change/unlink events to call
this.scheduleReload(); update the code path around the existing watch(...)
invocation and ensure scheduleReload() is called on those events instead of
relying on the default non-recursive fs.watch behavior.

224-243: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Invalidate both previous and next backend caches when backend type changes on the same serverId.

Current logic invalidates OpenAPI/GraphQL mostly from after?.backend, which can leave stale cache when a Caplet switches backend type (for example, OpenAPI → GraphQL).

Suggested patch
   for (const serverId of changedIds) {
     const before = previousCaplets.get(serverId);
     const after = nextCaplets.get(serverId);
+    const changed = serializeCaplet(before) !== serializeCaplet(after);
+    if (!changed) {
+      continue;
+    }
-    if (
-      before &&
-      before.backend === "mcp" &&
-      serializeCaplet(before) !== serializeCaplet(after)
-    ) {
+    if (before?.backend === "mcp") {
       await this.downstream.closeServer(serverId);
     }
-    if (after?.backend === "openapi" && serializeCaplet(before) !== serializeCaplet(after)) {
+    if (before?.backend === "openapi" || after?.backend === "openapi" || !after) {
       this.openapi.invalidate(serverId);
     }
-    if (after?.backend === "graphql" && serializeCaplet(before) !== serializeCaplet(after)) {
+    if (before?.backend === "graphql" || after?.backend === "graphql" || !after) {
       this.graphql.invalidate(serverId);
     }
-    if (!after) {
-      this.openapi.invalidate(serverId);
-      this.graphql.invalidate(serverId);
-    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime.ts` around lines 224 - 243, The loop over changedIds currently
only invalidates caches based on after?.backend which can leave stale caches
when a caplet's backend changes; update the logic in the for (const serverId of
changedIds) block (using previousCaplets, nextCaplets, serializeCaplet) to
detect backend transitions by comparing before?.backend and after?.backend and
call the appropriate invalidations for both sides: if before?.backend ===
"openapi" call this.openapi.invalidate(serverId), if before?.backend ===
"graphql" call this.graphql.invalidate(serverId), and likewise invalidate the
backend indicated by after?.backend; retain the existing
this.downstream.closeServer(serverId) behavior for MCP and still handle deletion
(!after) by invalidating both openapi and graphql as now.

247-252: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t skip watch setup entirely when the target path is missing at startup.

If a watched directory is created later, hot reload will not attach until process restart.

Suggested patch
   private resetWatchers(): void {
     this.closeWatchers();
+    const attached = new Set<string>();
     for (const entry of watchedPaths(this.paths)) {
-      if (!existsSync(entry.path)) {
+      const watchPath = existsSync(entry.path) ? entry.path : dirname(entry.path);
+      if (!existsSync(watchPath) || attached.has(watchPath)) {
         continue;
       }
       try {
+        attached.add(watchPath);
         this.watchers.push(
-          watch(entry.path, { persistent: true }, () => {
+          watch(watchPath, { persistent: true }, () => {
             this.scheduleReload();
           }),
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime.ts` around lines 247 - 252, The current resetWatchers method
skips any watchedPaths entries when the target path doesn't exist, so new
directories created later never get watched; update resetWatchers (and related
closeWatchers/watchedPaths usage) to still register a placeholder listener for
missing entries: watch the nearest existing parent (or use a
filesystem-watch-for-creation helper) and on creation attach the real watcher
for entry.path, ensuring you track and clean up these placeholder listeners in
closeWatchers and reuse the same attach logic you use for existing paths so
hot-reload will pick up directories created after startup.
docs/plans/2026-05-12-hot-reload.md (1)

13-69: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix heading level increments to satisfy markdownlint MD001.

Task headers should be ## (not ###) under the top-level title.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-05-12-hot-reload.md` around lines 13 - 69, Update the task
section headings so they use level-2 markdown headers (##) instead of level-3
(###); specifically change "### Task 1: Runtime Controller", "### Task 2: Reload
Reconciliation", "### Task 3: Filesystem Watching", "### Task 4: Documentation",
and "### Task 5: Verification" to "## Task 1: Runtime Controller", "## Task 2:
Reload Reconciliation", "## Task 3: Filesystem Watching", "## Task 4:
Documentation", and "## Task 5: Verification" respectively so the heading
hierarchy satisfies markdownlint MD001 while leaving the top-level title
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime.ts`:
- Around line 112-121: close() can race with reloadOnce(): if close() runs while
reloadOnce() is executing, reloadOnce() may recreate watchers after shutdown;
fix by introducing a short lifecycle guard: set this.closed = true immediately
in close(), add a reload-in-progress flag (e.g., this.reloading) or Promise so
close() can await any in-flight reloadOnce() before calling
closeWatchers()/server/downstream.close(), and update reloadOnce() to check
this.closed (and/or this.reloading) at key points before reattaching watchers or
scheduling timers (using reloadTimer) and abort early if closed; reference the
methods/fields reloadOnce(), close(), closeWatchers(), reloadTimer, this.closed,
and server/downstream to locate where to add the guard and awaiting logic.

---

Outside diff comments:
In `@src/downstream.ts`:
- Around line 224-229: The transport.onerror handler currently marks servers
unavailable even during an intentional shutdown; modify transport.onerror (the
same place that uses the onclose closing short-circuit) to return early when the
instance is in the shutdown state (e.g., check this.closing or the same flag
used by onclose/close()/closeServer()) before calling
this.registry.setStatus(server.server, "unavailable", ...); ensure the guard
mirrors the one in onclose so intentional shutdowns don't flip status.

---

Duplicate comments:
In `@docs/plans/2026-05-12-hot-reload.md`:
- Around line 13-69: Update the task section headings so they use level-2
markdown headers (##) instead of level-3 (###); specifically change "### Task 1:
Runtime Controller", "### Task 2: Reload Reconciliation", "### Task 3:
Filesystem Watching", "### Task 4: Documentation", and "### Task 5:
Verification" to "## Task 1: Runtime Controller", "## Task 2: Reload
Reconciliation", "## Task 3: Filesystem Watching", "## Task 4: Documentation",
and "## Task 5: Verification" respectively so the heading hierarchy satisfies
markdownlint MD001 while leaving the top-level title unchanged.

In `@src/runtime.ts`:
- Around line 255-257: The current fs.watch call that invokes
this.scheduleReload() can miss nested Caplet file edits; replace the
non-recursive watcher with a recursive watcher or a robust file-watcher library:
either set fs.watch(entry.path, { persistent: true, recursive: true }) where
supported, or switch to using chokidar.watch(entry.path, { persistent: true,
ignoreInitial: true }) and listen for add/change/unlink events to call
this.scheduleReload(); update the code path around the existing watch(...)
invocation and ensure scheduleReload() is called on those events instead of
relying on the default non-recursive fs.watch behavior.
- Around line 224-243: The loop over changedIds currently only invalidates
caches based on after?.backend which can leave stale caches when a caplet's
backend changes; update the logic in the for (const serverId of changedIds)
block (using previousCaplets, nextCaplets, serializeCaplet) to detect backend
transitions by comparing before?.backend and after?.backend and call the
appropriate invalidations for both sides: if before?.backend === "openapi" call
this.openapi.invalidate(serverId), if before?.backend === "graphql" call
this.graphql.invalidate(serverId), and likewise invalidate the backend indicated
by after?.backend; retain the existing this.downstream.closeServer(serverId)
behavior for MCP and still handle deletion (!after) by invalidating both openapi
and graphql as now.
- Around line 247-252: The current resetWatchers method skips any watchedPaths
entries when the target path doesn't exist, so new directories created later
never get watched; update resetWatchers (and related closeWatchers/watchedPaths
usage) to still register a placeholder listener for missing entries: watch the
nearest existing parent (or use a filesystem-watch-for-creation helper) and on
creation attach the real watcher for entry.path, ensuring you track and clean up
these placeholder listeners in closeWatchers and reuse the same attach logic you
use for existing paths so hot-reload will pick up directories created after
startup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f45ef35d-efa3-42a5-8366-4156a7e346bf

📥 Commits

Reviewing files that changed from the base of the PR and between 04ecb9a and b75bbbf.

📒 Files selected for processing (12)
  • .changeset/quiet-rockets-serve.md
  • README.md
  • docs/plans/2026-05-12-hot-reload.md
  • src/downstream.ts
  • src/graphql.ts
  • src/index.ts
  • src/openapi.ts
  • src/runtime.ts
  • test/downstream.test.ts
  • test/graphql.test.ts
  • test/openapi.test.ts
  • test/runtime.test.ts

Comment thread src/runtime.ts
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

This PR introduces CapletsRuntime, an in-process controller that gives caplets serve hot-reload capabilities: it watches config and Caplet files with fs.watch, debounces changes, reconciles MCP tool registrations through RegisteredTool handles, and preserves the last known-good config on invalid edits. Backend managers gain updateRegistry and targeted invalidate/closeServer methods so only the affected downstream connection is torn down on a change.

  • src/runtime.ts implements the full lifecycle — startup reconciliation, debounced watcher management, serialized reload loop with pending-reload tracking, and clean shutdown.
  • src/downstream.ts moves ManagedConnection creation before client.connect() so the closing flag is accessible in onclose/onerror handlers, suppressing spurious restart noise after intentional closes.
  • src/index.ts is reduced to a thin CLI entrypoint that delegates to CapletsRuntime.

Confidence Score: 3/5

The hot-reload watcher logic has a path-deduplication bug that silently prevents recursive directory watching for the default user caplets location.

The watchedPaths deduplication bug silently prevents recursive directory watching for the default user caplets location, undermining the core hot-reload feature for users with subdirectory-organized caplets. The downstream closeServer-during-connect race flagged in a prior review cycle also remains unresolved.

src/runtime.ts — specifically the watchedPaths function and its interaction with uniqueWatchedPaths

Important Files Changed

Filename Overview
src/runtime.ts New CapletsRuntime class: reconciles MCP tools, manages watcher lifecycle, and debounces reloads. Contains a deduplication bug in watchedPaths that silently drops the caplets watch reason for the user and project caplets directory, preventing recursive directory watching and watcher-refresh on rename events.
src/downstream.ts Moves connection object creation before client.connect() so onclose/onerror handlers can reference it; adds closing flag to suppress restart logic on intentional close; adds closeServer() and updateRegistry() for hot-reload support.
src/graphql.ts Adds updateRegistry() and invalidate() methods for hot-reload support; mutable registry field change is safe and well-encapsulated.
src/openapi.ts Adds updateRegistry() and invalidate() methods for hot-reload support; mirrors the GraphQL changes consistently.
src/index.ts Simplified entrypoint delegates server lifecycle to CapletsRuntime; clean refactor with no logic changes.
test/runtime.test.ts New tests cover initial registration, add/update/remove reconciliation, last-known-good fallback, and mid-flight pending reload behavior; mock server correctly tracks registered tools.

Comments Outside Diff (1)

  1. src/runtime.ts, line 713-724 (link)

    P1 User caplets directory always deduped to "config" reason

    resolveCapletsRoot(configPath) is defined as dirname(configPath) (see src/config/paths.ts line 18), so the third watchedPaths entry always resolves to the same path as the first entry. uniqueWatchedPaths keeps the first occurrence (reason: "config") and silently drops the { ..., reason: "caplets" } entry.

    Concretely: with the default config at ~/.caplets/config.json, resolveCapletsRoot returns ~/.caplets/, which is identical to dirname(configPath). The "caplets" entry is deduped away, so watchEntry never reaches the reason === "caplets" branch, watchDirectoryTree is never invoked for the user caplets directory, and rename events in ~/.caplets/ never trigger scheduleWatcherRefresh. Any caplet files stored in subdirectories under ~/.caplets/ will not be watched. The same collision occurs for the project caplets directory when TRUST_PROJECT_CAPLETS_ENV is enabled.

    A straightforward fix is to prefer "caplets" over "config" when the same path appears multiple times — either by inserting the caplets entries before their config counterparts so that uniqueWatchedPaths keeps the higher-priority reason, or by making uniqueWatchedPaths merge reasons (keeping "caplets" when duplicated).

    Fix in Codex

Fix All in Codex

Reviews (5): Last reviewed commit: "fix: tighten hot reload review edge case..." | Re-trigger Greptile

Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/downstream.ts (1)

209-227: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard stale transport callbacks from mutating newer connections

At Line 210, onclose always deletes this.connections for the server before checking whether this callback belongs to the active connection. If a reconnect happens quickly, an old transport callback can remove or mark unavailable the new connection.

Suggested fix
       transport.onclose = () => {
-        this.connections.delete(server.server);
+        const current = this.connections.get(server.server);
+        if (current === connection) {
+          this.connections.delete(server.server);
+        }
         if (connection.closing) {
           return;
         }
+        if (current !== connection) {
+          return;
+        }
         this.restartState.set(server.server, {
           restartUsed: true,
           backoffUntil: Date.now() + 1_000,
@@
       transport.onerror = (error: Error) => {
         if (connection.closing) {
           return;
         }
+        if (this.connections.get(server.server) !== connection) {
+          return;
+        }
         this.registry.setStatus(
           server.server,
           "unavailable",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/downstream.ts` around lines 209 - 227, The transport.onclose handler
mutates shared state (this.connections, restartState, registry) even if a newer
connection has replaced the one captured as `connection`; guard the callback by
verifying the active connection matches the captured one before making changes
(e.g., check that this.connections.get(server.server) === connection), and only
then perform deletion, restartState.set, and registry.setStatus; apply the same
guard pattern for transport.onerror to prevent stale callbacks from mutating
newer connections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime.ts`:
- Around line 107-111: reloadOnce() can reject and make this.reloading an
rejecting promise, causing unhandled rejections from void this.reload() and
breaking shutdown/cleanup; change assignments so this.reloading is a
non-rejecting promise by piping a .catch(err => { /* log/record err */; return
<failure-sentinel>; }) before .finally, e.g. set this.reloading =
this.reloadOnce().catch(err => { processLogger.error("reloadOnce failed", err);
return false; }).finally(() => { this.reloading = undefined; }); and update all
places that await this.reloading (and the call site reload()) to expect the
sentinel/boolean instead of throwing, ensuring cleanup/shutdown paths always
run.
- Around line 301-313: watchDirectoryTree can leak watch handles if a call to
watch(directory, ...) throws; wrap the per-directory watch creation in a
try/catch so that on any exception you iterate existing watchers (the array
returned by watchDirectoryTree) and call their close() method to release
resources before rethrowing the error; keep the existing behavior of invoking
scheduleReload() and scheduleWatcherRefresh() in the watcher callback and ensure
the function still returns the created FSWatcher[] on success.

---

Outside diff comments:
In `@src/downstream.ts`:
- Around line 209-227: The transport.onclose handler mutates shared state
(this.connections, restartState, registry) even if a newer connection has
replaced the one captured as `connection`; guard the callback by verifying the
active connection matches the captured one before making changes (e.g., check
that this.connections.get(server.server) === connection), and only then perform
deletion, restartState.set, and registry.setStatus; apply the same guard pattern
for transport.onerror to prevent stale callbacks from mutating newer
connections.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9f434f4d-a2bf-404c-9291-03b6bd253ab7

📥 Commits

Reviewing files that changed from the base of the PR and between b75bbbf and 23205b2.

📒 Files selected for processing (4)
  • .changeset/quiet-rockets-serve.md
  • docs/plans/2026-05-12-hot-reload.md
  • src/downstream.ts
  • src/runtime.ts

Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts
Comment thread src/runtime.ts Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 1 file(s) based on 2 unresolved review comments.

Files modified:

  • src/runtime.ts

Commit: 8ecbc4bb249a6f41a1ddef6f5bfafc8187fc494b

The changes have been pushed to the codex/hot-reload-caplets branch.

Time taken: 3m 14s

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime.ts (1)

1-429: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run the formatter on this file before merge.

CI is currently failing oxfmt --check . on src/runtime.ts, so this change set won’t verify cleanly until the file is reformatted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime.ts` around lines 1 - 429, The file fails the repository formatter
check; run the project formatter (oxfmt) on the changed file containing the
CapletsRuntime class and its helper functions (e.g., watchedPaths,
resetWatchers, discoverDirectories) and commit the results so the formatting of
src/runtime.ts matches CI expectations; specifically run the same command used
in CI (oxfmt --check . locally to see issues, then oxfmt . or your project's
format script) and re-run tests to ensure oxfmt passes before pushing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime.ts`:
- Around line 296-300: Currently the file watcher calls scheduleWatcherRefresh()
for every event inside the watch callback, causing redundant full watcher
rebuilds on ordinary change events; update the watch callbacks (the watch(...)
handlers that call scheduleReload() and scheduleWatcherRefresh()) to inspect the
event type (e.g., "change" vs "rename"/file-creation) and only call
scheduleWatcherRefresh() when topology changes occur (rename or target
creation), leaving scheduleReload() for all events; note that reloadOnce()
already calls resetWatchers(), so avoid invoking scheduleWatcherRefresh() on
plain "change" events to prevent duplicate full teardown/rebuild cycles (apply
the same change to the other similar watch callback around
scheduleReload()/scheduleWatcherRefresh()).
- Around line 103-105: The current branch that does "if (this.reloading) { await
this.reloading; return !this.closed; }" drops any edits that arrive while a
reload is in-flight; change this so events received during an active reload set
a pending flag (e.g. this.pendingReload = true) instead of being consumed, then
after awaiting this.reloading check that flag and trigger an additional reload
(call reloadOnce() or loop to drain pendingReload) before returning; update the
code paths that set/clear this.reloading and this.pendingReload so a single
extra change causes one more reload and the flag is cleared when handled.

---

Outside diff comments:
In `@src/runtime.ts`:
- Around line 1-429: The file fails the repository formatter check; run the
project formatter (oxfmt) on the changed file containing the CapletsRuntime
class and its helper functions (e.g., watchedPaths, resetWatchers,
discoverDirectories) and commit the results so the formatting of src/runtime.ts
matches CI expectations; specifically run the same command used in CI (oxfmt
--check . locally to see issues, then oxfmt . or your project's format script)
and re-run tests to ensure oxfmt passes before pushing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 94f4e50b-01a8-4154-a876-6d50abe3cfec

📥 Commits

Reviewing files that changed from the base of the PR and between 23205b2 and 8ecbc4b.

📒 Files selected for processing (1)
  • src/runtime.ts

Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime.ts`:
- Around line 267-277: The resetWatchers loop currently calls watchPathTree for
every watchedPaths entry which causes full recursive watching of parent
directories and nearest-existing ancestors; change it so only Caplet root
entries use recursive watching via this.watchPathTree(watchPath, entry.path) and
all other entries (config parent directories and fallback
nearest-existing-parent sentinels) register non-recursive watchers instead
(e.g., a single-path watcher method or this.watchPathTree with a recursive=false
flag). Update the same pattern where watchPathTree is used around the other
spots noted (the blocks around lines 292-295 and 349-356) so recursive traversal
is limited to actual Caplet roots and parents/missing-targets get non-recursive
watches. Ensure you reference watchedPaths(...) entries to decide which path is
a Caplet root vs a parent/sentinel and preserve the existing
this.watchers.push(...) logic for both cases.
- Around line 99-117: The reload() method currently discards the boolean result
from the this.reloading promise and returns !this.closed or true
unconditionally; change it to propagate the actual outcome from reloadOnce by
awaiting and returning the boolean resolved by this.reloading. Specifically:
ensure the branch that waits for an in-flight reload (when this.reloading is
truthy) does "const result = await this.reloading; return result &&
!this.closed" (or simply return the awaited boolean if reloadOnce already
accounts for closed), and in the initial-start path await this.reloading and
return its resolved boolean instead of returning true; keep the existing catch
that resolves false so failures propagate as false. Ensure references to
reload(), reloadOnce(), and this.reloading are updated accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c2e6a423-8aa1-472d-9885-81f732a76743

📥 Commits

Reviewing files that changed from the base of the PR and between 8ecbc4b and 65aecb0.

📒 Files selected for processing (1)
  • src/runtime.ts

Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts
@ian-pascoe ian-pascoe force-pushed the codex/hot-reload-caplets branch from e53e262 to 319e038 Compare May 13, 2026 11:48
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