Skip to content

fix(cli): reuse project stores for skill discovery#2102

Merged
gsxdsm merged 4 commits into
Runfusion:mainfrom
plarson:fix/postgres-skills-discovery
Jul 15, 2026
Merged

fix(cli): reuse project stores for skill discovery#2102
gsxdsm merged 4 commits into
Runfusion:mainfrom
plarson:fix/postgres-skills-discovery

Conversation

@plarson

@plarson plarson commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • reuse the dashboard command's backend-aware per-project TaskStore cache during project-scoped plugin skill discovery
  • obtain plugin state through TaskStore.getPluginStore() instead of constructing bare SQLite-default PluginStore / TaskStore instances
  • keep cached project stores alive for the dashboard process while still stopping request-scoped plugin loaders
  • add a regression covering the real Skills adapter callback and refresh the dashboard test fixture with getAsyncLayer()

Root cause

GET /api/skills/discovered resolved the project correctly, then getProjectScopedPluginSkills() constructed new stores without an AsyncDataLayer. After VAL-REMOVAL-005, that enters the physically removed synchronous SQLite runtime and returns HTTP 500 even when PostgreSQL health, projects, tasks, and both project engines are healthy.

The existing route tests mocked the Skills adapter callback, so they did not exercise this CLI wiring.

Verification

  • targeted dashboard regression: 1 passed, 91 skipped
  • pnpm lint
  • pnpm --filter @runfusion/fusion typecheck
  • pnpm --filter @runfusion/fusion build
  • pnpm check:changesets --strict
  • git diff --check

Live Atlas validation against the migrated embedded PostgreSQL runtime:

  • /api/skills/discovered?projectId=proj_84f4645c2da64288: HTTP 200, 36 skills
  • /api/skills/discovered?projectId=proj_7538a9dd46c24c5f: HTTP 200, 36 skills
  • local dashboard and Tailscale dashboard: HTTP 200
  • controlled SIGTERM: launchd restarted the dashboard and both Skills routes remained healthy

Summary by CodeRabbit

  • Bug Fixes
    • Fixed dashboard project-scoped plugin-skill discovery in PostgreSQL mode with safer store reuse/teardown and request-scoped plugin-loader lifecycle.
    • Improved dashboard cleanup to avoid duplicate concurrent store closes and ensured proper shutdown behavior per root type.
    • Made fusion_runtime role creation race-safe during concurrent PostgreSQL migrations.
  • New Features
    • Added persistRuntimeState option to control whether plugin runtime state changes are persisted.
  • Tests
    • Expanded dashboard and core hot-reload tests to verify scoped, non-persistent runtime behavior.

Copilot AI review requested due to automatic review settings July 14, 2026 23:08
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

Dashboard project-scoped plugin-skill discovery now reuses backend-aware stores, suppresses persistent runtime-state updates for temporary loaders, manages project-store shutdown, and makes PostgreSQL runtime-role creation race-safe.

Changes

Dashboard plugin-skill discovery

Layer / File(s) Summary
Backend-aware project store lifecycle
packages/cli/src/commands/dashboard.ts
Project stores retain backend shutdown callbacks, are reused for scoped discovery, and are closed during dashboard disposal and shutdown paths.
Scoped plugin discovery and validation
packages/cli/src/commands/dashboard.ts, packages/cli/src/commands/__tests__/dashboard.test.ts
Discovery uses temporary non-persistent loaders for other roots, while tests validate scoped stores, loader options, cleanup, and disposal.
Plugin runtime-state persistence
packages/core/src/plugin-loader.ts, packages/core/src/__tests__/plugin-hot-reload.test.ts
Plugin loader state transitions can be skipped for request-scoped loaders, with coverage for unloading without changing stored installation state.

PostgreSQL role setup

Layer / File(s) Summary
Race-safe runtime-role creation
packages/core/src/postgres/migrations/0006_project_ownership.sql, .changeset/calm-skills-postgres.md
Runtime-role creation handles concurrent conflicts, and a patch changeset records the dashboard, runtime-state, and PostgreSQL fixes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: reusing project stores for skill discovery in the CLI dashboard flow.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes project-scoped plugin skill discovery for the CLI server surfaces. The main changes are:

  • Reuse backend-aware project stores for dashboard skill discovery.
  • Close cached dashboard project stores during shutdown.
  • Make request-scoped discovery loaders skip durable runtime-state writes.
  • Cover dashboard, serve, and daemon skill discovery with regression tests.
  • Make PostgreSQL runtime-role creation tolerate concurrent migrations.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
packages/cli/src/commands/dashboard.ts Reuses resolved project stores for skill discovery, adds cached-store shutdown, and keeps discovery loader state writes non-persistent.
packages/cli/src/commands/serve.ts Configures request-scoped skill discovery to avoid durable plugin runtime-state writes.
packages/cli/src/commands/daemon.ts Configures request-scoped skill discovery to avoid durable plugin runtime-state writes.
packages/core/src/plugin-loader.ts Adds a persistRuntimeState option and routes plugin lifecycle state updates through it.
packages/core/src/postgres/migrations/0006_project_ownership.sql Handles concurrent fusion_runtime role creation during PostgreSQL migrations.

Reviews (7): Last reviewed commit: "fix(cli): keep skill discovery loaders r..." | Re-trigger Greptile

Comment thread packages/cli/src/commands/dashboard.ts Outdated
Comment thread packages/cli/src/commands/dashboard.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

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 fixes dashboard skill discovery in PostgreSQL backend mode by ensuring project-scoped plugin skill discovery reuses backend-aware, per-project TaskStore instances (and their AsyncDataLayer) instead of constructing standalone SQLite-backed stores that now fail after VAL-REMOVAL-005.

Changes:

  • Rework project-scoped plugin skill discovery to obtain plugin state via TaskStore.getPluginStore() and reuse the dashboard’s per-project store cache.
  • Keep project stores cached for the dashboard process lifetime while still stopping request-scoped plugin loaders after metadata collection.
  • Add a regression test that exercises the real Skills adapter callback wiring, plus update the dashboard test fixture (getAsyncLayer() and plugin loader mocks).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
packages/cli/src/commands/dashboard.ts Reuses backend-aware per-project TaskStore/PluginStore for plugin skill discovery to avoid falling into removed SQLite runtime paths under PG.
packages/cli/src/commands/tests/dashboard.test.ts Adds a regression test that calls the real getPluginSkills adapter callback and verifies no SQLite PluginStore is constructed.
.changeset/calm-skills-postgres.md Adds a patch changeset describing the PostgreSQL-mode dashboard skill discovery fix.

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

Comment thread packages/cli/src/commands/dashboard.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

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)
packages/cli/src/commands/dashboard.ts (1)

993-1034: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Cache stampede, resource leaks, and in-flight lifecycle races.

The project-store cache suffers from a critical concurrency flaw where resolving the store without caching the Promise leads to severe resource leaks and database schema race conditions:

  1. Embedded PG Leak: getProjectStore caches the resolved TaskStore. Concurrent requests for an uncached path will bypass the cache, boot multiple embedded PostgreSQL clusters, and permanently leak them when one overwrites the other in projectStores and projectStoreShutdowns.
  2. Teardown Race: closeProjectStores takes a synchronous snapshot of projectStoreShutdowns while createTaskStoreForBackend promises might still be in-flight. When the promise resolves, its shutdown callback is lost, leading to await projectStore.close() which fails to stop the embedded cluster.
  3. TOCTOU Schema Race: await stateStore.init() in getProjectScopedPluginSkills will execute concurrently on the same cached store instance if multiple requests arrive, triggering the SQLite TOCTOU schema-migration race explicitly documented on lines 940-946.

These can be fully resolved by enforcing a promise-based caching boundary that encompasses both the store boot and the satellite store initializations.

  • packages/cli/src/commands/dashboard.ts#L993-L1034: Cache the Promise of the boot result, ensure satellite stores are initialized within the same synchronized block, and await the inflight promises during teardown.
  • packages/cli/src/commands/dashboard.ts#L1857-L1860: Remove the unprotected await stateStore.init() call since the promise-based getProjectStore now safely handles initialization.
🔒️ Proposed robust promise-based caching boundary

1. Apply this fix to the caching logic (lines 993-1034):

-  const projectStores = new Map<string, TaskStore>();
-  const projectStoreShutdowns = new Map<string, () => Promise<void>>();
+  type CachedProjectStore = { taskStore: TaskStore; shutdown?: () => Promise<void> };
+  const projectStores = new Map<string, Promise<CachedProjectStore>>();
   let projectStoresClosePromise: Promise<void> | undefined;
 
   async function getProjectStore(projectPath: string): Promise<TaskStore> {
-    const cached = projectStores.get(projectPath);
-    if (cached) return cached;
-    let projectStore: TaskStore;
-    if (projectPath === cwd) {
-      if (!store) throw new Error("cwd TaskStore not yet initialized");
-      projectStore = store;
-    } else {
-      const boot = await createTaskStoreForBackend({ rootDir: projectPath });
-      if (boot) {
-        projectStore = boot.taskStore;
-        projectStoreShutdowns.set(projectPath, boot.shutdown);
-      } else {
-        projectStore = new TaskStore(projectPath);
-        await projectStore.init();
-      }
-    }
-    projectStores.set(projectPath, projectStore);
-    return projectStore;
+    if (shutdownInProgress) throw new Error("Dashboard is shutting down");
+    let promise = projectStores.get(projectPath);
+    if (!promise) {
+      promise = (async () => {
+        if (projectPath === cwd) {
+          if (!store) throw new Error("cwd TaskStore not yet initialized");
+          return { taskStore: store };
+        }
+        let projectStore: TaskStore;
+        let shutdown: (() => Promise<void>) | undefined;
+        const boot = await createTaskStoreForBackend({ rootDir: projectPath });
+        if (boot) {
+          projectStore = boot.taskStore;
+          shutdown = boot.shutdown;
+        } else {
+          projectStore = new TaskStore(projectPath);
+          await projectStore.init();
+        }
+        await projectStore.getPluginStore().init();
+        return { taskStore: projectStore, shutdown };
+      })();
+      projectStores.set(projectPath, promise);
+    }
+    const cached = await promise;
+    return cached.taskStore;
   }
 
   async function closeProjectStores(): Promise<void> {
     projectStoresClosePromise ??= (async () => {
-      const stores = Array.from(projectStores.entries()).filter(([, projectStore]) => projectStore !== store);
+      const promises = Array.from(projectStores.values());
       projectStores.clear();
-      const shutdowns = new Map(projectStoreShutdowns);
-      projectStoreShutdowns.clear();
-      await Promise.allSettled(stores.map(async ([projectPath, projectStore]) => {
-        const shutdown = shutdowns.get(projectPath);
-        if (shutdown) {
-          await shutdown();
-        } else {
-          await projectStore.close();
-        }
+      await Promise.allSettled(promises.map(async (promise) => {
+        try {
+          const { taskStore, shutdown } = await promise;
+          if (taskStore === store) return;
+          if (shutdown) {
+            await shutdown();
+          } else {
+            await taskStore.close();
+          }
+        } catch {
+          // Best-effort teardown: ignore initialization errors during shutdown
+        }
       }));
     })();
     await projectStoresClosePromise;
   }

2. Apply this fix to the plugin discovery logic (lines 1857-1860):

     const scopedTaskStore = await getProjectStore(normalizedRootDir);
     const stateStore = scopedTaskStore.getPluginStore();
-    await stateStore.init();
     const enabledPlugins = await stateStore.listPlugins({ enabled: true });
🤖 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 `@packages/cli/src/commands/dashboard.ts` around lines 993 - 1034, Replace the
resolved-store cache in getProjectStore with promise-based caching that
synchronizes boot and all satellite-store initialization before publishing the
result; track and await in-flight promises during closeProjectStores so shutdown
callbacks cannot be lost and each store is closed exactly once. At
packages/cli/src/commands/dashboard.ts lines 1857-1860, remove the direct await
stateStore.init() in getProjectScopedPluginSkills because initialization is now
handled by getProjectStore’s synchronized promise boundary.
🤖 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 `@packages/core/src/postgres/migrations/0006_project_ownership.sql`:
- Around line 36-42: Replace the database-local pg_advisory_xact_lock in the
migration with a cluster-wide bootstrap mechanism for creating fusion_runtime,
or make the CREATE ROLE operation idempotent and retryable on duplicate-role
failures. Ensure concurrent project database initialization cannot fail due to
races when creating the shared PostgreSQL role.

---

Outside diff comments:
In `@packages/cli/src/commands/dashboard.ts`:
- Around line 993-1034: Replace the resolved-store cache in getProjectStore with
promise-based caching that synchronizes boot and all satellite-store
initialization before publishing the result; track and await in-flight promises
during closeProjectStores so shutdown callbacks cannot be lost and each store is
closed exactly once. At packages/cli/src/commands/dashboard.ts lines 1857-1860,
remove the direct await stateStore.init() in getProjectScopedPluginSkills
because initialization is now handled by getProjectStore’s synchronized promise
boundary.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e564aa24-0c31-474a-bc33-419bf10458e2

📥 Commits

Reviewing files that changed from the base of the PR and between 063f6d3 and 1f6a922.

📒 Files selected for processing (6)
  • .changeset/calm-skills-postgres.md
  • packages/cli/src/commands/__tests__/dashboard.test.ts
  • packages/cli/src/commands/dashboard.ts
  • packages/core/src/__tests__/plugin-hot-reload.test.ts
  • packages/core/src/plugin-loader.ts
  • packages/core/src/postgres/migrations/0006_project_ownership.sql
🚧 Files skipped from review as they are similar to previous changes (2)
  • .changeset/calm-skills-postgres.md
  • packages/cli/src/commands/tests/dashboard.test.ts

Comment thread packages/core/src/postgres/migrations/0006_project_ownership.sql Outdated
@plarson plarson force-pushed the fix/postgres-skills-discovery branch from 5e63447 to 8423208 Compare July 15, 2026 00:12

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
packages/core/src/__tests__/plugin-hot-reload.test.ts (1)

314-320: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use getPlugin instead of type casting to access the installation state.

Using (mockPluginStore as any) bypasses TypeScript's type checking to access internal properties. You can retrieve the updated state cleanly by using the public getPlugin method provided by the mock store.

♻️ Proposed refactor
       await pluginLoader.loadPlugin("hot-reload-test");
-      expect((mockPluginStore as any)._installation.state).toBe("installed");
+      expect((await mockPluginStore.getPlugin("hot-reload-test")).state).toBe("installed");
 
       await pluginLoader.stopAllPlugins();
 
       expect(pluginLoader.isPluginLoaded("hot-reload-test")).toBe(false);
-      expect((mockPluginStore as any)._installation.state).toBe("installed");
+      expect((await mockPluginStore.getPlugin("hot-reload-test")).state).toBe("installed");
🤖 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 `@packages/core/src/__tests__/plugin-hot-reload.test.ts` around lines 314 -
320, Update the installation-state assertions in the hot-reload test to retrieve
the plugin through the mock store’s public getPlugin method instead of casting
mockPluginStore to any and accessing _installation directly. Preserve the
existing "installed" expectations before and after stopAllPlugins.
🤖 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.

Nitpick comments:
In `@packages/core/src/__tests__/plugin-hot-reload.test.ts`:
- Around line 314-320: Update the installation-state assertions in the
hot-reload test to retrieve the plugin through the mock store’s public getPlugin
method instead of casting mockPluginStore to any and accessing _installation
directly. Preserve the existing "installed" expectations before and after
stopAllPlugins.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fe2e4139-1ccc-404a-9b1c-3adb646cc481

📥 Commits

Reviewing files that changed from the base of the PR and between 5e63447 and 8423208.

📒 Files selected for processing (6)
  • .changeset/calm-skills-postgres.md
  • packages/cli/src/commands/__tests__/dashboard.test.ts
  • packages/cli/src/commands/dashboard.ts
  • packages/core/src/__tests__/plugin-hot-reload.test.ts
  • packages/core/src/plugin-loader.ts
  • packages/core/src/postgres/migrations/0006_project_ownership.sql
🚧 Files skipped from review as they are similar to previous changes (4)
  • .changeset/calm-skills-postgres.md
  • packages/cli/src/commands/tests/dashboard.test.ts
  • packages/core/src/plugin-loader.ts
  • packages/cli/src/commands/dashboard.ts

@plarson plarson force-pushed the fix/postgres-skills-discovery branch from 8423208 to 8558de2 Compare July 15, 2026 00:23
@plarson plarson force-pushed the fix/postgres-skills-discovery branch from 8558de2 to cf2a9d3 Compare July 15, 2026 00:35
Comment thread packages/cli/src/commands/dashboard.ts
@gsxdsm gsxdsm merged commit be55d0a into Runfusion:main Jul 15, 2026
7 checks passed
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