Files
fusion/.fusion/memory.md
Fusion 01aeb449d7 fix(FN-2048): prevent stale SSE reconnects after channel teardown
- Add a closed flag to sse-bus channel state so teardown permanently disables reconnect scheduling
- Guard useTasks onReconnect callbacks against stale effect instances when toggling views
- Add regression tests for sse-bus and useTasks to ensure closed/unmounted channels do not reconnect
- Document the leak root cause and closed-flag pattern in .fusion/memory.md
2026-04-18 02:53:49 -07:00

100 KiB
Raw Blame History

Project Memory

Architecture

  • FN-1737/FN-1868 Ephemeral Agent Auto-Deletion: Runtime-created agents (task-workers created by InProcessRuntime and spawned child agents created by TaskExecutor) are auto-deleted from AgentStore after reaching a terminal state. A 5-second delay allows the UI to observe the "terminated" state before deletion. The isEphemeralAgent() helper in @fusion/core provides canonical detection heuristics:

    • agent.metadata?.agentKind === "task-worker" — task-worker agents
    • agent.metadata?.type === "spawned" — spawned child agents
    • agent.metadata?.taskWorker === true — legacy marker
    • agent.metadata?.managedBy === "task-executor" — executor-managed agents
    • Legacy fallback: executor role with name starting with "executor-" and no reportsTo
  • AgentStore.listAgents() and getOrgTree() exclude ephemeral agents by default via the includeEphemeral filter parameter. The dashboard API routes (GET /api/agents, GET /api/agents/org-tree) use this parameter.

  • Frontend uses isEphemeralAgent from @fusion/core to filter ephemeral agents in displayAgents and displayOrgTree memos.

  • FN-1776 Agent SSE Event Forwarding: Agent lifecycle events are now forwarded through the SSE pipeline:

    • createSSE() accepts an optional AgentStore parameter for forwarding agent:created, agent:updated, agent:deleted, and agent:stateChanged events
    • getOrgTree() accepts { includeEphemeral?: boolean } filter matching listAgents() pattern
    • GET /api/agents/org-tree supports includeEphemeral query parameter
    • useAgents hook excludes ephemeral agents from activeAgents by default
    • Server SSE endpoint resolves AgentStore from engine via getAgentStore() for project-scoped streams
  • FN-1976 Message SSE + Route Store Cohesion:

    • MessageStore is an EventEmitter; SSE listeners must attach to the SAME MessageStore instance used by message-writing routes.
    • Reusing the same SQLite database is not enough for realtime updates — two MessageStore instances on one DB do not share in-memory events.
    • In dashboard routes, prefer engine.getMessageStore() (or options.engine?.getMessageStore() for default scope) before creating a fallback new MessageStore(db).
  • FN-1736 Multi-Project Scoping Audit: Comprehensive audit of project-scoping across the Fusion stack found:

    • SSE/WebSocket endpoints (/api/tasks/:id/logs/stream, /api/events, /api/ws) already use resolveProjectScopedStore() or getProjectContext() correctly
    • Badge WebSocket (setupBadgeWebSocket) properly scopes per-project with listeners on scoped stores
    • Terminal WebSocket properly scopes per-project via projectId query param
    • All frontend EventSource connections (useMultiAgentLogs, AgentDetailView, MissionManager, etc.) pass projectId correctly
    • CLI commands (mission.ts, plugin.ts, backup.ts, etc.) use resolveProject()/getStore() properly
    • Fixed: Chat session creation (POST /api/chat/sessions) was NOT passing projectId to createSession() — now uses getProjectContext(req) and passes projectId ?? null
    • AI sessions are intentionally global (shared across projects) — AiSessionStore uses single DB
    • Session locks (useSessionLock) are intentionally global — they prevent concurrent AI session access across projects
  • TaskExecutor terminates active agent sessions (single and step) when tasks are moved away from in-progress via the task:moved event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.

  • Centralized Context-Window Auto-Compaction (FN-1877): The promptWithFallback() function in packages/engine/src/pi.ts automatically catches context-window overflow errors, runs compactSessionContext(), and retries once. This centralizes recovery for ALL agent types (executor, step-session, merger, triage, heartbeat, reviewer, mission-execution-loop). Callers that previously had duplicate compact-and-resume logic (executor, step-session-executor, merger) have been simplified to use promptWithFallback's auto-compaction as first-level recovery, with their own reduced-prompt fallbacks as second-level recovery. This eliminates code duplication and ensures consistent recovery behavior.

  • Workflow Step Revision Loop (FN-1499): Workflow steps can request implementation revisions via "REQUEST REVISION" output. The flow:

    1. Workflow step agent outputs "REQUEST REVISION\n\n[feedback]" to signal that code changes are needed
    2. executeWorkflowStep() detects this pattern and returns WorkflowStepOutcome with revisionRequested: true
    3. runWorkflowSteps() propagates the structured outcome with WorkflowStepResult.revisionRequested
    4. handleWorkflowRevisionRequest() is called, which:
      • Injects "Workflow Revision Instructions" section into PROMPT.md (replacing any prior revision block)
      • Resets all steps to pending for fresh execution
      • Clears session file to get a fresh agent session
      • Schedules fresh execution via setTimeout after current guard unwinds
    5. Task stays in in-progress and the scheduler re-dispatches the task for a fresh executor pass
    6. Guard-unwind requirement: The revision rerun MUST be scheduled after the current execute() guard clears (this.executing.delete()). Failure to observe this causes a race where the scheduler re-dispatches while the old execution guard is still set, silently no-oping the new dispatch and stranding the task in in-progress with no active session.
  • Review Handoff (FN-1259): Agents can hand off tasks to users for human review via steering comments containing handoff phrases ("send it back to me", "hand off to user", etc.). When reviewHandoffPolicy is "comment-triggered", the executor detects handoff intent in agent-authored steering comments and executes the handoff: sets status: "awaiting-user-review", assigneeUserId: "requesting-user", moves task to in-review, and disposes the agent session. The merger skips tasks with "awaiting-user-review" status (via BLOCKING_TASK_STATUSES in task-merge.ts). Users can accept review (clear status) or return to agent (move to todo). The assigneeUserId field stores the user ID who should review the task.

  • Agent preset templates in NewAgentDialog.tsx are a UI-only concept (AgentPreset interface), separate from the engine's AgentPromptTemplate type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.

  • soul and instructionsText are already supported in AgentCreateInput and AgentUpdateInput — no API changes needed when adding these to presets.

  • CronRunner uses dependency injection for AI prompt execution: an AiPromptExecutor function is injected via options. This keeps it decoupled from createKbAgent and testable without real agent sessions.

  • createAiPromptExecutor(cwd) is an async factory function that creates a new agent session per call, uses onText for text accumulation, and disposes sessions in a finally block.

  • The factory uses lazy import("./pi.js") to avoid pulling the pi SDK into the module graph when AI execution isn't needed.

  • HeartbeatMonitor.executeHeartbeat() uses the Paperclip wake→check→work→exit model. The lazy import("./pi.js") pattern keeps pi SDK out of the module graph when only monitoring (not execution) is needed.

  • Agent tool factories (createTaskCreateTool, createTaskLogTool) live in agent-tools.ts and are shared between TaskExecutor and HeartbeatMonitor to avoid duplication.

  • Heartbeat Control-Plane Lane (FN-1487): Heartbeat runs from the Agents panel run on a separate control-plane lane that is independent of task execution concurrency limits. HeartbeatMonitor and HeartbeatTriggerScheduler are created WITHOUT the task-lane semaphore in both runDashboard() and runServe(). The semaphore boundary is documented in comments with "UTILITY PATH: This component does NOT receive the task-lane semaphore." This ensures agent responsiveness is preserved even when task pipelines are saturated.

  • Task-worker agent contract (FN-1661): Runtime-created executor task workers (for example executor-FN-1234) must be explicitly marked with metadata.agentKind = "task-worker" and runtimeConfig.enabled = false, then transition idle -> active -> running after assignment wiring completes. HeartbeatTriggerScheduler.watchAssignments() must skip assignment wakeups when runtimeConfig.enabled === false; otherwise task workers inherit user-agent heartbeat semantics and show false "Unresponsive" health in the dashboard.

  • Dashboard SSE clients (planning/subtask/mission interview) now use a shared keep-alive pattern: start a 25s setInterval in stream onOpen that POSTs /api/ai-sessions/:id/ping, and always stop it on stream close, complete, and fatal errors.

  • Subtask Session ProjectId Propagation (FN-1479): Subtask breakdown sessions must persist projectId throughout their lifecycle to enable project-scoped resume. Key patterns:

    • POST /api/subtasks/start-streaming forwards projectId from the route handler to createSubtaskSession()
    • projectId is stored on the session state object AND persisted to SQLite via persistSubtaskSession() on every state update (not just initial insert)
    • Session rehydration via buildSubtaskSessionFromRow() preserves projectId from the database row
    • Retry/complete/error transitions maintain projectId through all lifecycle stages
    • Background/Resume semantics: "Send to Background" is non-destructive (closes UI/stream but preserves server session); "Close/Cancel" is explicit abandonment (session deleted). Backgrounding during startup (while "Preparing..." is shown) follows the same pattern—close local stream/UI cleanly without deleting the server session.
  • Peer Gossip Protocol (FN-1224): Nodes exchange peer information via POST /api/mesh/sync endpoint. PeerExchangeService runs periodic sync cycles (default 60s interval) with all online remote nodes. CentralCore.mergePeers() handles peer data merging — new peers are registered via registerGossipPeer(), stale peers are updated with fresher data, and the local node is never overwritten. The service uses single-flight pattern to prevent overlapping syncs and refreshes local metrics before each sync.

    • Settings Sync (FN-1822): PeerExchangeService supports optional settings synchronization via settingsSyncEnabled option. When enabled, nodes exchange SettingsSyncPayload (containing global settings, project settings, and provider auth) during peer sync. Uses checksum-based version comparison and throttling (default 5-minute window) to prevent redundant transfers. The mesh/sync endpoint handles incoming settings by comparing checksums and applying remote settings when different. Settings sync errors are non-fatal — peer exchange continues even if settings sync fails.
  • Node Plugin Sync (FN-1246/FN-1518): Nodes track version information for plugin synchronization. Central schema v4 adds versionInfo and pluginVersions columns to the nodes table. getAppVersion() utility reads from nearest package.json. CentralCore methods: updateNodeVersionInfo(), getNodeVersionInfo(), syncPlugins(), checkVersionCompatibility(). Events: node:version:updated, node:plugins:synced.

  • Node Plugin Sync Dashboard Routes (FN-1518): Dashboard REST API endpoints for node version info and plugin sync:

    • GET /api/nodes/:id/version — Returns NodeVersionInfo when present, null when not yet stored. Returns 404 if node doesn't exist.
    • POST /api/nodes/:id/sync-plugins — Compares plugins between local and remote nodes. Returns 400 if target is local (sync is remote-only), 400 if no local node registered, 404 if target missing. Calls syncPlugins(localNodeId, remoteNodeId) with argument order: (localNodeId, remoteNodeId).
    • GET /api/nodes/:id/compatibility — Checks version compatibility between local and target nodes. Returns 400 if local node missing, 400 if either version info missing, 404 if target missing. Calls checkVersionCompatibility(localVersion, remoteVersion) with version strings (not node IDs).
  • Plugin Management API Routes (FN-1411): Plugin CRUD endpoints implemented in createApiRoutes with getScopedStore(req) pattern for multi-project support:

    • Mode discriminator pattern for POST /plugins: Deterministic behavior via required mode field with "register" (explicit manifest) or "install" (load from path) values. Missing mode, unknown mode, or ambiguous shapes return 400.
    • Error mapping matrix: Input/validation → 400, not found (ENOENT) → 404, lifecycle conflicts (EEXISTS) → 409, unexpected → 500.
    • Project scoping: All routes support projectId in query param or request body. Uses getScopedStore(req) which calls getOrCreateProjectStore(projectId).
    • Scoping test strategy: Mock projectStoreResolver.getOrCreateProjectStore to return a scoped store with scoped plugin store. Tests verify the scoped store is used and data comes from the scoped plugin store.
    • Plugin store access: Routes use scopedStore.getPluginStore() to get the plugin store from the scoped task store, enabling per-project plugin isolation.

FN-1354: Auto-Summarize Titles Bug Fix

  • The summarize field in TaskCreateInput must be forwarded by the frontend API (createTask in api.ts) to enable the auto-summarization flow
  • summarizeTitle() in ai-summarize.ts uses session.state.messages to extract AI responses, with fallback to direct prompt() return value
  • Debug logging via process.env.FUSION_DEBUG_AI helps diagnose AI session issues
  • When testing console.warn calls that expect multiple substrings in a single concatenated string, use expect(mock.calls[0][0]).toMatch(/substring1/) pattern instead of expect.stringContaining() on multiple arguments

FN-1544: Viewport-Gated Card Metadata Loading

When optimizing dashboard performance for large task sets:

  • Viewport gating pattern: Use IntersectionObserver with rootMargin: "200px" to prefetch data just before cards become visible
  • Lazy enable option: Add { enabled?: boolean } parameter to hooks (default true for backward compatibility)
  • Disabled state: Return stable empty state without triggering fetches when enabled: false
  • In-memory caching: Use TTL-based caching (e.g., 30 seconds) to avoid repeated fetches during rerenders
  • Cache key format: "taskId:projectId" for separate caching per task/project context
  • Cache hit behavior: Return immediately without loading flicker — don't set loading state on cache hit
  • Export test helpers: Export __test_clearCache() functions for test isolation
  • Lightweight comparisons: Replace JSON.stringify in memo comparators with field-by-field comparisons (e.g., areAttachmentsEqual, areCommentsEqual)

Example from useTaskDiffStats:

// Cache keyed by taskId:projectId
const diffStatsCache = new Map<string, { stats: DiffStats; expiresAt: number }>();

// Hook returns immediately on cache hit
if (cached) {
  setStats(cached);
  setLoading(false);
  return;
}

FN-1734: Polling Hook Loading Contract

When implementing polling hooks that fetch data periodically (e.g., health metrics, status updates):

  • Loading contract: loading should be true ONLY for initial data fetch, NOT during background polling
  • Background polling pattern: Use a ref (initialLoadCompleteRef) to track if initial load is done; only set loading: true when !initialLoadCompleteRef.current
  • Component behavior: Components consuming these hooks should show skeleton only when there's genuinely no data (not just when loading is true during refresh)
  • Why it matters: Setting loading to true on every poll causes skeleton flicker and scroll position resets, degrading UX

Reference: useProjectHealth in packages/dashboard/app/hooks/useProjectHealth.ts demonstrates this pattern.

Conventions

  • For cross-package imports from @fusion/engine, prefer root exports (for example import("@fusion/engine")) over undocumented deep subpaths like @fusion/engine/pi or @fusion/engine/pi.js; add any needed helpers to packages/engine/src/index.ts to keep runtime and test resolution consistent.

  • LocalStorage completion state pattern (FN-1862): When implementing localStorage-based completion tracking that is distinct from dismissal, use a timestamp field (completedAt) to differentiate states:

    • markOnboardingCompleted() — Sets completedAt timestamp, preserves state for timestamp queries
    • isOnboardingCompleted() — Returns true only when completedAt is set
    • clearOnboardingState() — Full removal (for explicit reset, not completion)
    • Keep dismissed state separate from completed state: dismissal preserves step state without completedAt, completion sets completedAt
    • Auto-open suppression should check both server-side flags AND localStorage completion state for resilience
  • When mocking function types with Vitest for the build (tsc), use vi.fn().mockResolvedValue(x) as unknown as T instead of vi.fn<Parameters<T>, ReturnType<T>>(). The generic syntax works at runtime but fails during tsc build.

  • expect.any(Number) does not work in Vitest matchers — use expect(mockFn.mock.calls.length).toBeGreaterThanOrEqual(1) or similar instead.

  • When mocking AgentStore for heartbeat execution tests, track saveRun calls in a local Map<string, AgentHeartbeatRun> and have getRunDetail read from it — this way completeRun's saved state is reflected in the returned run.

  • When HeartbeatMonitorOptions has optional fields (taskStore?, rootDir?), capture them in local const variables after the early-return validation check to avoid Object is possibly 'undefined' TypeScript errors in the closure.

  • For package-scoped single-file test runs, prefer pnpm --filter <pkg> exec vitest run <file> over pnpm --filter <pkg> test -- <file> when the package test script already hardcodes positional args.

  • Mission interview POST /api/missions/interview/create-mission now auto-generates contract assertions at milestone, slice, and feature levels; route/e2e tests should expect extra addContractAssertion calls while linkFeatureToAssertion remains feature-only.

  • In dashboard task-creation forms, avoid special-casing built-in workflow template IDs in UI state; render from fetched workflowSteps IDs and let store-side template materialization resolve template IDs (browser-verificationWS-XXX).

  • When a package mixes Electron main-process .ts files with renderer .tsx files, use moduleResolution: "bundler" plus lib: ["DOM", "DOM.Iterable"] in that package tsconfig; Node16 resolution will otherwise force .js extensions and break renderer imports during tsc.

  • For React component tests in the desktop package, include .test.tsx in Vitest discovery and call cleanup() in afterEach to avoid cross-test DOM leakage that causes duplicate-element query failures.

  • When extracting App-level async handlers into hooks, keep error/toast behavior inside the hook and wire passthrough handlers in App.tsx (const handler = hookAction) to avoid duplicate rollback/toast logic.

  • For deep-link modal behavior (?task=), preserve one-time open semantics with internal refs in the hook so closing the modal can safely strip only the task query param while preserving other params (like project).

  • When deprecating fields from BoardConfig but tests/internal flows still poke private config methods, keep temporary compatibility fields non-enumerable in readConfig() so writeConfig() can omit them from config.json while legacy tests can still mutate them.

  • For dashboard route tests that mock @fusion/core, keep the mock export list in sync with the real route imports (for example parseCompanyArchive); missing one export silently changes route behavior and causes hard-to-diagnose failures.

  • Browser directory pickers (webkitdirectory) cannot provide a server filesystem path; for dashboard import flows, parse selected AGENTS.md files client-side and send { agents } payloads instead of trying to submit a directory source path.

  • For conditionally rendered mobile inputs in dashboard components, prefer React autoFocus on the input over effect+setTimeout focus logic keyed to open-state booleans; mount timing is more reliable and simpler.

  • Checkout leasing is explicit: use checkoutTask/releaseTask (or /api/tasks/:id/checkout + /release) for ownership, treat 409 conflicts as non-retryable contention, and let HeartbeatMonitor.executeHeartbeat() only validate checkedOutBy (never auto-acquire leases).

  • The null-as-delete pattern for settings: In TaskStore.updateSettings(), null values in the settings patch are treated as "delete this key from settings" (since JSON.stringify drops undefined keys). This allows the frontend to explicitly clear a setting by sending null. The key is deleted from both config.settings and projectPatch before merging, so cleared settings fall back to DEFAULT_SETTINGS.

  • TaskStore.logEntry(), addComment(), addSteeringComment(), pauseTask() accept an optional RunMutationContext parameter for audit trail correlation. Always pass it when the caller is an engine module (executor, heartbeat monitor) to maintain the audit trail. The executor constructs a synthetic runContext with runId: "exec-{taskId}-{timestamp}-{random}" since it doesn't use AgentHeartbeatRun.

  • Run-Audit Instrumentation (FN-1404): The engine instruments mutation calls with audit events via createRunAuditor() from run-audit.ts. Each active run (heartbeat, executor, merger) creates an EngineRunContext with runId, agentId, taskId, and phase. The auditor no-ops cleanly when no run context exists (backward compatible with manual/non-run paths). Use generateSyntheticRunId() for executor/merger synthetic IDs. Audit events are emitted for git mutations (worktree/branch/create/remove/reset), database mutations (task:update/move/comment/assign/checkout), and filesystem mutations (file:capture-modified).

  • In-Merge Verification Fix (FN-1858): When deterministic verification fails during merge, the merger now attempts to fix the issue by spawning an AI agent on the main branch (cwd: rootDir). This is different from the executor which runs in worktrees. The fix agent uses tools: "coding" for read/write access. Always dispose sessions in finally blocks, use withRateLimitRetry() for resilience, and cap retry attempts (max 3) to prevent runaway costs.

  • Write-through cache pattern (FN-1336): When adding caching to a store, use write-through invalidation (update cache in setter, return cached value in getter). For GlobalSettingsStore, the cache survives for the lifetime of the process since it's a singleton per server instance. Add invalidateCache() for testing and edge cases where external processes modify the file.

  • API wrapper tests for validation: When testing functions that validate parameters synchronously before calling fetch:

    • Use expect(() => fn()).toThrow() for synchronous throws (not rejects.toThrow())
    • The api() function in app/api.ts only passes headers: { "Content-Type": "application/json" } when no opts.method is specified — GET requests don't include method: "GET" in the fetch options
    • URL-encoded parameter values (like %20) are valid values — they're decoded at the URL level, not parameter level
    • Mock setups for successful responses should return 200 status to avoid triggering error paths
  • ESLint flat-config ordering (FN-1756): In eslint.config.mjs, global ignores must come FIRST before any recommended configs. This ensures ignored paths are filtered before base config evaluation. The correct order is:

    1. Global ignores — files never linted (must be first)
    2. Base recommendations — eslint/recommended + typescript-eslint/recommended
    3. Context-specific overrides — test-support, production, Node, SW, etc.
    • When running eslint . from a git worktree, include test-only support files (app/test/**, vitest.setup.ts) in global ignores or test-support config blocks
    • Later per-file ignores do not stop the base recommended configs from linting those files if they're not ignored globally first
  • API mock export parity (FN-1756): When dashboard components or routes import new API symbols or @fusion/core exports, the corresponding test mocks must be updated. This is a common source of test failures after refactoring:

    • Component tests (app/components/__tests__/*.test.tsx): Update vi.mock("../../api") export list to include new API functions
    • Route tests (src/*.test.ts, src/__tests__/*.test.ts): Update vi.mock("@fusion/core") and vi.mock("@fusion/engine") export lists to include new exports
    • Missing mock exports cause cascading runtime failures with "No 'X' export is defined" errors
    • Example: When parseCompanyArchive is added to @fusion/core exports, add it to the mock export list in routes.test.ts
  • Dashboard Express routers should normalize req.params.* through a string validator/helper before passing values into typed store methods; under strict tsconfig, route params can surface as string | string[] and break package typecheck if used directly.

Color Theme System

  • There are 54 unique color themes in packages/dashboard/app/public/theme-data.css (default, ocean, forest, sunset, zen, berry, high-contrast, industrial, monochrome, slate, ash, graphite, silver, solarized, factory, ayu, one-dark, nord, dracula, gruvbox, tokyo-night, catppuccin-mocha, github-dark, everforest, rose-pine, kanagawa, night-owl, palenight, monokai-pro, slime, brutalist, neon-city, parchment, terminal, glass, horizon, vitesse, outrun, snazzy, porple, espresso, mars, poimandres, ember, rust, copper, foundry, carbon, sandstone, lagoon, frost, lavender, neon-bloom, sepia). Each has a dark variant [data-color-theme="<name>"] and a light variant [data-color-theme="<name>"][data-theme="light"]. Theme blocks were extracted to a separate file in FN-1409 to enable lazy loading — theme-data.css is only loaded when a non-default color theme is active.
  • When adding CSS custom properties that should be theme-aware (like --accent, --status-*-bg), add them to all 54 theme blocks plus :root and [data-theme="light"] base blocks. The test in status-colors-theme.test.ts iterates all blocks programmatically to prevent regressions.
  • Semantic tokens (tokens describing purpose, not appearance) that maintain consistent meaning across all color themes (e.g., "autopilot active" is always green-tinted, "event error" is always red-tinted) only need dark/light adaptation via the base [data-theme="light"] block. They do NOT need per-color-theme overrides because the semantic meaning is consistent. Examples from FN-1357: --autopilot-pulse, --event-*-text, --event-*-bg, --terminal-bg, --star-idle, --star-active, --badge-mission-*, --fab-*.
  • Runtime-safe theme loading (FN-1526): The theme-data.css stylesheet URL is derived from document.baseURI rather than hardcoded paths. This ensures correct resolution in both HTTP/HTTPS contexts (uses /theme-data.css) and Electron file:// contexts (derives path relative to HTML file directory). The same getThemeDataUrl() helper is used by both the pre-hydration inline script in index.html and the runtime useTheme.ts hook. Bug fix (FN-1535): The initial implementation had a path joining bug where new URL("theme-data.css", baseUrl) was used incorrectly, producing malformed paths like .../apptheme-data.css instead of .../app/theme-data.css. The fix uses url.resolve() or explicit path joining with proper slash handling to ensure the URL always contains the correct slash separator between directory and filename. Refinement (FN-1534): Fixed two additional issues: (1) URL resolution now correctly handles both trailing-slash directories (/path/) and filename paths (/path/index.html) by checking base.endsWith('/') and using appropriate slice/replace logic; (2) loadThemeDataStylesheet() now updates existing link href when stale instead of returning early, ensuring theme changes apply correctly even after page loads with different base URLs.

Plugin System (FN-1111 / FN-1400)

The plugin system is built on three layers:

  1. PluginStore (packages/core/src/plugin-store.ts) — SQLite-backed CRUD operations for plugin installations, stored in the plugins table (schema v24)
  2. PluginLoader (packages/core/src/plugin-loader.ts) — Dynamic import, lifecycle management, dependency resolution (topological sort), hook invocation
  3. PluginRunner (packages/engine/src/plugin-runner.ts) — Engine/runtime lifecycle integration, hook fanout, and tool adaptation

PluginRunner Integration (FN-1401)

The PluginRunner bridges the plugin core system with the Fusion engine runtime:

Lifecycle Integration:

  • PluginRunner.init() loads enabled plugins and subscribes to store/loader events for hot-load/unload synchronization
  • PluginRunner.shutdown() unsubscribes all listeners and stops all plugins cleanly
  • Runtime integration: InProcessRuntime.start() initializes PluginStore/PluginLoader/PluginRunner after TaskStore, stop() calls pluginRunner.shutdown()

Hook Timeout & Isolation:

  • Plugin hooks have a default 5-second timeout (configurable via hookTimeoutMs)
  • Each hook invocation wraps in try/catch with timeout rejection — failures are logged but never propagate
  • Task lifecycle hooks: onTaskCreated on task:created, onTaskMoved/onTaskCompleted on task:moved (completion only when to === "done")
  • Agent lifecycle hooks: onAgentRunStart/onAgentRunEnd invoked in executor session start/end paths

Tool Adaptation:

  • Plugin tools are converted from PluginToolDefinition[] to ToolDefinition[] (pi-coding-agent format)
  • Tool names prefixed with plugin_ to avoid collision with built-in tools
  • Tools are cached and invalidated on plugin state changes
  • Tool collision guard: built-in tools (task_*, review, etc.) cannot be overridden by plugin tools

Store Event Synchronization:

  • PluginRunner subscribes to: plugin:enabled, plugin:disabled, plugin:unregistered, plugin:stateChanged, plugin:updated
  • Loader event subscribes to: plugin:loaded, plugin:unloaded, plugin:reloaded
  • All events invalidate tool/route caches for immediate hot-reload of new plugin capabilities

Step-Session Plugin Tool Integration:

  • PluginRunner injected into TaskExecutorOptions as optional dependency
  • StepSessionExecutor receives plugin tools via TaskExecutorOptions.pluginRunner
  • Each step-session agent creation merges plugin tools with step session custom tools

Key types (in packages/core/src/plugin-types.ts):

  • PluginManifest — Plugin metadata (id, name, version, dependencies, settingsSchema)
  • FusionPlugin — Loaded plugin instance with hooks, tools, routes
  • PluginContext — Runtime API surface (taskStore, settings, logger, emitEvent)
  • PluginInstallation — Persisted plugin record in SQLite

Hook types: onLoad, onUnload, onTaskCreated, onTaskMoved, onTaskCompleted, onError

Database schema (plugins table, v24):

  • Stores plugin metadata, path, enabled flag, state, settings, error
  • Settings stored as JSON, validated against settingsSchema on update

PluginLoader patterns:

  • Uses topological sort for dependency resolution (throws on circular deps)
  • Error isolation: plugin crashes set state: "error but don't crash loader
  • Hook invocation is non-blocking: one plugin's failure doesn't prevent others from receiving hooks
  • createContext() is async (gets settings from store)

Integration points for FN-1113:

  • Hooks invoked by scheduler on task lifecycle events
  • Tools registered via getPluginTools() → merged with built-in agent tools
  • Routes registered via getPluginRoutes() → mounted under /api/plugins/:pluginId/

Plugin Lifecycle SSE Event Propagation (FN-1412)

Dashboard SSE (/api/events) streams plugin lifecycle events as normalized plugin:lifecycle SSE events.

Payload contract (PluginLifecyclePayload):

  • pluginId — Plugin identifier
  • transition — Normalized transition type: installing, enabled, disabled, error, uninstalled, settings-updated
  • sourceEvent — Underlying store event that triggered this transition
  • timestamp — ISO-8601 timestamp
  • projectId — Included for project-scoped streams (omitted for default streams)
  • enabled — Whether the plugin is currently enabled
  • state — Current plugin state (installed, started, stopped, error)
  • version — Plugin version
  • settings — Plugin settings snapshot
  • error — Error message (only when state is "error")

Transition mapping:

Source Event Transition
plugin:registered installing
plugin:enabled enabled
plugin:disabled disabled
plugin:stateChanged (state === "error") error
plugin:unregistered uninstalled
plugin:updated settings-updated

Project-scoped wiring:

  • /api/events (no projectId) → uses store.getPluginStore() for default store
  • /api/events?projectId=X → uses scopedStore.getPluginStore() from getOrCreateProjectStore(projectId)
  • Both streams share the same EventEmitter via the project-store resolver pattern
  • Listener cleanup happens on req.on("close") and write-failure paths

Implementation files:

  • packages/dashboard/src/sse.tscreateSSE() with plugin lifecycle relay
  • packages/dashboard/src/server.ts/api/events route wiring with scoped plugin sources
  • packages/dashboard/src/__tests__/sse.test.ts — Plugin lifecycle SSE tests
  • packages/dashboard/src/server.events.test.ts — Server wiring tests

Pitfalls

  • When adding props to a React component interface that were previously declared but not destructured in the function body, remember to add them to the destructuring list too. TypeScript won't warn about unused interface fields, so onOpenScripts in MobileNavBarProps compiled fine but caused ReferenceError: onOpenScripts is not defined at runtime.

  • Express wildcard route ordering (FN-1492/FN-1909): When defining Express routes, ALWAYS define more specific routes BEFORE generic parameterized or wildcard routes. Express matches routes in order, so:

    • {*filepath} patterns: POST /files/{*filepath} shadows POST /files/{*filepath}/delete if defined first
    • /:id patterns (FN-1909): GET /:id shadows GET /runs and GET /runs/:id if defined before them
    • The fix is to define operation routes (/runs, /run, /runs/:id, /copy, /move, /delete, etc.) BEFORE the generic write/catch-all route
    • See packages/dashboard/src/routes.ts and packages/dashboard/src/insights-routes.ts for the correct ordering patterns
  • Webhook HMAC testing: The REQUEST test utility in test-request.ts doesn't handle stream-based middleware like express.raw() well. For webhook routes requiring HMAC verification (e.g., GitHub webhooks, routine webhooks), test the verifyWebhookSignature function directly using await import() rather than trying to set up raw body middleware through Express. See the routine webhook tests in routes.test.ts for the pattern.

  • vi.fn<Parameters<SomeType>, ReturnType<SomeType>>() works in Vitest runtime but causes TypeScript build errors (TS2558: Expected 0-1 type arguments, but got 2). Always use the cast pattern instead.

  • When adding new exports to @fusion/engine, update the mock in packages/cli/src/commands/__tests__/dashboard.test.ts AND packages/cli/src/commands/__tests__/serve.test.ts to include the new export, otherwise the test may fail with mysterious errors. Both test files need to be kept in sync.

  • When adding new CLI command exports (like node.ts, mesh.ts), update BOTH src/bin.test.ts AND src/__tests__/bin.test.ts mocks to include the new exports, otherwise all tests importing from bin.ts will fail with "No 'X' export is defined" errors.

  • Test describe blocks in Vitest can't access helper functions defined in sibling describe blocks. Place shared helpers in the parent scope or within the same describe block.

  • When extracting shared code from executor.ts (e.g., tool factories), move the parameter schemas (taskCreateParams, taskLogParams) to the shared module too — keep them canonical in one place to avoid duplication.

  • When changing API function signatures (e.g., startAgentRun), add new params at the END to preserve backward compatibility. Existing callers passing positional args will break if you insert a new param before existing ones.

  • For UI tests that assert calls to git/dashboard API helpers with optional trailing params (for example projectId or force), assert the leading semantic arguments via mock.calls.at(-1)?.slice(0, n) instead of exact toHaveBeenCalledWith(...) on the full argument list. Some call paths omit trailing undefined values while others pass them explicitly.

  • HeartbeatMonitor.executeHeartbeat() calls startRun() internally — do NOT call both startRun() and executeHeartbeat() for the same run, or you'll get duplicate runs. Use startRun() alone for record-only, or executeHeartbeat() for full execution.

  • When RunsTab loads data via API calls instead of props, tests must mock the API functions (fetchAgentRuns, fetchAgentRunDetail) in addition to existing mocks, and set up defaults in beforeEach.

  • In UI static analysis tests, avoid regex that spans multiple lines for code patterns (e.g., setInterval.*5000). Use separate toContain() assertions instead since the code is multi-line.

  • In large inline mock objects, duplicate property keys are only warned by esbuild and the last declaration silently wins, which can hide the real mock implementation during route tests.

  • For hardcoded workflow-step shortcuts in dashboard forms (like "browser-verification"), checked/toggle logic must reconcile both the literal template ID and resolved WS-XXX IDs by matching workflowStep.templateId.

  • Testing modal dropdown menus (FN-1489): When testing dropdown menus in modal footers:

    • Wrap fireEvent.click() calls in act() when the dropdown state updates: await act(async () => { fireEvent.click(btn); })
    • Use screen.getByRole("menuitem", { name: "..." }) instead of screen.getByText("...") for menu items
    • When checking menu item counts, check BEFORE closing the dropdown (e.g., check Retry count while Actions dropdown is still open)
    • For conditionally-rendered dropdowns (e.g., only show Actions dropdown for non-triage tasks), test both cases explicitly
  • When using import.meta.env in packages/dashboard/app/*, ensure packages/dashboard/tsconfig.app.json includes "vite/client" in compilerOptions.types, or the dashboard typecheck test will fail with Property 'env' does not exist on type 'ImportMeta'.

  • In dashboard app tests under app/__tests__, the built client output directory resolves to ../../dist/client (not ../../../dist/client).

  • Fresh worktrees may miss linked Capacitor plugin packages until dependencies are installed; if dashboard tests/typecheck fail with unresolved @capacitor/* imports, run pnpm install at repo root first.

  • When dashboard components add new lucide-react icons or new API functions, update the component test mocks (vi.mock("lucide-react") and vi.mock("../../api")) immediately; missing mock exports cause cascading runtime failures (No "X" export is defined) across otherwise unrelated tests.

  • In fresh worktrees, workspace dependency links can be stale enough that dashboard/core tests fail resolving yaml from @fusion/core; run pnpm install at repo root before chasing false test failures.

  • pnpm test at repo root runs dashboard's clean-checkout typecheck test; App-level TS issues (like duplicate imports or bad hook call signatures) may pass targeted Vitest runs but still fail the full suite.

  • In executor worktrees, task attachment files referenced in PROMPT may exist only under the main repo path (/Users/.../Projects/kb/.fusion/tasks/...); if relative .fusion/tasks/... paths are missing, read the absolute attachment path directly.

  • SQLite ORDER BY timestamp DESC alone can be nondeterministic when multiple rows share the same millisecond timestamp; add a stable tiebreaker (for example rowid DESC) when selecting a "latest" event.

  • In TaskCard.tsx, isInteractiveTarget must check target instanceof Element (not HTMLElement) so SVG elements from lucide-react icons are correctly detected as interactive when inside buttons.

  • If workspace tests fail resolving @fusion/core package exports from packages/core/dist/index.js (for example No matching export ... in CLI/TUI/package-level tests after adding a new core export), run pnpm --filter @fusion/core build before rerunning the suite so ignored dist/ exports are refreshed.

  • If CLI tests/build-exe tests fail with Could not resolve "@fusion/dashboard" (or Vite reports missing @fusion/dashboard entry), build the dashboard package first (pnpm --filter @fusion/dashboard build) so packages/dashboard/dist/index.js exists for workspace consumers.

  • If dashboard/TUI tests fail resolving @fusion/engine entry exports (for example Failed to resolve entry for package "@fusion/engine"), build engine artifacts first (pnpm --filter @fusion/engine build) so packages/engine/dist/index.js is available for workspace imports.

  • QuickEntryBox control test IDs are reused in ListView integration tests; when control layout changes (for example nested menu → inline buttons), update both QuickEntryBox.test.tsx and ListView.test.tsx together to avoid cascading failures.

  • When InlineCreateCard layout changes, also check Column.test.tsx and board-mobile.test.tsx for references to moved/removed test IDs like inline-create-description-actions.

  • When adding portal-based dropdown menus to QuickEntryBox, tests may fail in isolation but pass when run together (test isolation issues). This is because tests share DOM state across describe blocks. Always verify new dropdown tests pass both in isolation (--testNamePattern) and when run together.

  • mission-store.test.ts has a flaky test (getMissionHealth computes mission metrics and latest error context) that fails intermittently when timestamps collide in the same millisecond — this is pre-existing and not related to dashboard changes.

  • SettingsModal sidebar reordering: When reordering sections in SETTINGS_SECTIONS, update all tests that assume a specific section is the default. Tests using screen.getByText("SectionName") may fail with "multiple elements found" when the section heading also appears in the content area alongside the sidebar item. Use screen.getAllByText("SectionName")[0] or navigate to the section explicitly before accessing its fields.

  • Test isolation with temp directories: Tests that create filesystem state (like agent files under .fusion/agents/) should use per-test temp directories via mkdtempSync(join(os.tmpdir(), 'fn-test-')) and clean up in afterEach with rmSync(dir, { recursive: true, force: true }). Shared temp paths cause state leakage between tests, leading to noisy/flaky behavior. See in-process-runtime.test.ts for the pattern.

  • xterm.js WebGL on mobile (FN-1739): The @xterm/addon-webgl addon causes garbled/overlapping Unicode text on mobile browsers (especially iOS Safari/WebKit) due to rendering artifacts. Always wrap WebGL addon loading in a !isMobileDevice() check, falling back to canvas rendering for mobile. Use the project's monospace font stack (ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace) for better Unicode coverage on all platforms.

  • When adding light-theme overrides for CSS components that already use var(--*) tokens, most selectors inherit correctly from the light-theme root variable redefinitions. Only add explicit [data-theme="light"] overrides where fine-tuning is needed (e.g., slightly different opacity values, subtle box-shadows for contrast).

  • --surface-hover is used but never defined as a CSS custom property in the root or light theme blocks — it resolves to invalid/empty. Components using var(--surface-hover) (like .github-import-tab:hover) get no background. Either define it in the theme roots or use fallbacks like var(--surface-hover, rgba(0,0,0,0.03)).

  • .form-error and similar error-state selectors should use color-mix(in srgb, var(--color-error) 10%, transparent) instead of hardcoded rgba(248, 81, 73, 0.1) for theme adaptability.

  • When styling input[type="radio"] elements in .imported items, the selector must match .issue-item.imported input[type="radio"] (classes on the same element, not nested), because the HTML structure is <div class="issue-item imported"><input type="radio">.

FN-1529: Search Query Propagation in Multi-Path Scenarios

When the dashboard supports multiple data paths (local vs remote node mode), ensure UI state like searchQuery is propagated to ALL data hooks that fetch the displayed data:

  • Local mode uses useTasks({ searchQuery }) which forwards to fetchTasks with q param
  • Remote mode uses useRemoteNodeData({ searchQuery }) which forwards to fetchRemoteNodeTasks with q param
  • The searchQuery state must be defined BEFORE calling both hooks, and both must receive the same query value
  • Missing propagation causes the "silent regression" where local search works but remote search fails without errors
  • Add regression tests that mock the API layer and verify query propagation for both paths

FN-1657: Project-Context Reset in useTasks

When switching projects in useTasks, stale task bleed-through can occur if tasks from the previous project remain visible during the fetch gap or if SSE events from the previous project context are processed. The fix uses three mechanisms:

1. Immediate task clearing on project change:

if (previousProjectIdRef.current !== projectId) {
  previousProjectIdRef.current = projectId;
  projectContextVersionRef.current++;
  setTasks([]); // Clear immediately to prevent stale data visibility
}

2. SSE context version guard:

const contextVersionAtStart = projectContextVersionRef.current;
// In each SSE handler:
if (projectContextVersionRef.current !== contextVersionAtStart) {
  return; // Reject stale events from previous project context
}

3. Fetch projectId tracking:

const requestProjectId = projectId; // Capture at request time
// At resolution:
if (projectId !== requestProjectId) {
  return; // Reject responses from wrong project
}

Key patterns:

  • Use refs to track context state that survives re-renders
  • Increment context version on project change (not search query change)
  • SSE handlers capture version at effect start and compare at event time
  • Fetch handlers capture projectId at call time and compare at resolution time
  • Clear tasks immediately on project change, not after fetch completes

Extension to realtime hooks (FN-1764): This same pattern has been applied to:

  • useAgentLogs — Clears entries and rejects stale fetch/SSE on project/task switch
  • useMultiAgentLogs — Clears all state and rejects stale events on project switch
  • useLiveTranscript — Clears entries and rejects stale events on project/task switch
  • AgentDetailView — Adds context version tracking for logs tab SSE rejection

Each hook uses a contextVersionRef incremented on context change, with stale rejection guards in SSE handlers and fetch callbacks.

FN-1522: Task State Reconciliation Pattern

Tasks can get into contradictory states (e.g., column: "done" with status: "blocked" in summary/log). This happens when agents mark tasks done without verifying actual completion. Reconciliation steps:

  • Audit actual deliverables (code files, exports, database schema) before assuming task is complete
  • When reconciliation is needed, update BOTH task.json AND SQLite (fusion.db) to maintain consistency
  • For SQLite updates, use sqlite3 directly or use TaskStore methods that write to both
  • Replace stale dependency references (e.g., FN-1267 → FN-1519) when the replacement task exists
  • Add a single reconciliation log entry explaining the state reset, don't duplicate existing diagnostic entries
  • Reset ALL completion-related fields: column, status, currentStep, steps, mergeDetails, branch, baseCommitSha, worktree, stuckKillCount

CSS Testing Patterns

  • Several test files assert specific CSS values in styles.css mobile media query blocks (e.g., board-mobile.test.tsx, core-modals-mobile.test.tsx, mission-planning-modals-mobile.test.ts, mobile-nav-bar-css.test.ts). When changing mobile CSS values (like min-height), update both the CSS and the corresponding test assertions + regex patterns.
  • Mobile-specific selectors like .mobile-nav-tab and .mobile-more-item may exist as base styles (not inside media queries) but are still mobile-only components. The .touch-target utility class at the top of styles.css is intentionally 44px and should not be changed when reducing mobile button sizes.
  • When checking if a CSS value is inside a @media block, don't just search backwards for the nearest @media — track brace depth to confirm the line is actually between the block's opening { and closing }. Many component styles are defined globally (not in media queries) even though they visually only appear on mobile.
  • Regex tests using [\s\S]* (greedy match across lines) to check CSS rules inside @media blocks are unreliable — they can match across block boundaries. Use non-greedy [^}]* scoped to a single rule block instead.
  • Touch target sizing in styles.css mobile media queries uses 36px (reduced from the original 44px). The .touch-target opt-in utility class remains at 44px. Comments mentioning "44px" in the mobile sections have been updated to reflect the actual values.
  • CSS specificity with BEM modifiers (FN-1631): When a component has both container state (.quick-entry-box--expanded) and element modifier (.quick-entry-input--expanded) classes, the container selector may have higher specificity than the modifier selector. For example, .quick-entry-box--expanded .quick-entry-input (0,2,1) beats .quick-entry-input--expanded (0,1,0). To fix, use :not(.quick-entry-input--expanded) to ensure container selectors only affect non-modified elements: .quick-entry-box--expanded .quick-entry-input:not(.quick-entry-input--expanded). This allows the modifier class's rules to take precedence when the modifier is active.

FN-1464: Mobile Bottom-Spacing Contract

The mobile bottom-spacing is controlled by a single CSS variable --mobile-nav-height (defined at :root) to ensure consistent spacing across all bottom-positioned elements:

  • .mobile-nav-bar: Uses min-height: var(--mobile-nav-height) (currently 44px)
  • .executor-status-bar mobile: Uses bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom)) to position above the nav bar
  • .project-content--with-mobile-nav: Uses padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom)) to reserve nav space
  • .project-content--with-footer.project-content--with-mobile-nav: Uses padding-bottom: calc(32px + var(--mobile-nav-height) + env(safe-area-inset-bottom)) to reserve footer + nav space

When adjusting mobile bottom spacing, change --mobile-nav-height in one place and all related elements will update. Tab touch targets (.mobile-nav-tab) remain at 36px minimum regardless of nav height changes.

FN-1626: PWA Home Bar Bottom Spacing

For installed PWA mode (@media (display-mode: standalone)), an additional --standalone-bottom-gap token provides extra breathing room for the iOS home indicator:

  • :root default: --standalone-bottom-gap: 0px (non-PWA fallback)
  • Standalone mode: --standalone-bottom-gap: 8px (extra 8px for home bar)
  • Additive spacing pattern: All bottom-positioned elements use + var(--standalone-bottom-gap) in their calc expressions

This pattern ensures PWA mode gets extra bottom room without breaking non-PWA behavior:

/* :root */
--standalone-bottom-gap: 0px;

@media (display-mode: standalone) {
  --standalone-bottom-gap: 8px;
}

/* Usage example */
#root {
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}

.executor-status-bar {
  bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}

The token-based approach allows all bottom-layout consumers to be updated together by adding + var(--standalone-bottom-gap) to their calc expressions.

FN-1458: Mobile Header Search Safe-Area-Inset Fix

  • When fixing mobile header search positioning issues (search box clipping off-screen), add safe-area-inset handling to both .header and .header-floating-search in the mobile media query
  • Use padding-left: max(var(--space-md), env(safe-area-inset-left, 0px)) pattern to ensure content respects device notches
  • CSS regression tests should verify: (1) .mobile-search-expanded has width: 100%, (2) no fixed negative offsets (left: -NNpx, right: -NNpx) exist in mobile search rules, (3) .header-floating-search has safe-area-inset handling

UX Audit Findings (FN-1379)

  • Header overload: The Header component (Header.tsx) has 15+ icon buttons with no labels, making discovery difficult. Consider grouping secondary actions into overflow menus.
  • Modal inconsistency: Different modals handle close behavior differently (X button vs. Esc vs. click-outside). Standardize via a shared ModalHeader component.
  • Loading states: Some components use skeletons, some use spinners, some have no loading indicator. Use skeleton screens for content-heavy areas, spinners for quick operations.
  • Toast system: The ToastContainer is minimal — consider adding type-specific icons, action buttons, and stacking management.
  • Accessibility gaps: Many icon buttons lack aria-label. All interactive elements need proper labeling for screen reader users.
  • Empty states: Views like Board, List, Agents, and Missions lack helpful empty state guidance with actionable CTAs.

TUI Package Testing

  • The @fusion/tui package uses ink's render function for testing, not @testing-library/react. Use setTimeout(resolve, ms) to wait for async operations in tests.

  • When mocking useFusion in TUI tests, use vi.mock("../fusion-context.js", ...) to intercept the import.

  • For EventEmitter mocking in TUI tests, create mock objects with Object.create(EventEmitter.prototype) and add methods like listTasks or getActivityLog.

  • Ink's render function captures errors but doesn't throw them — use expect(() => instance.unmount()).not.toThrow() pattern for error-handling tests.

  • When testing components that use useInput (Ink's keyboard input hook), mock it with vi.mock("ink", async (importOriginal) => { const actual = await importOriginal<typeof import("ink")>(); return { ...actual, useInput: vi.fn() }; }) to avoid "Raw mode is not supported" errors in test environments without TTY.

  • ScreenRouter component captures activeScreen state by passing it to children and capturing in a local variable for test assertions.

  • When adding database schema migrations, increment SCHEMA_VERSION and add migration blocks with applyMigration(N, () => { ... }). Also update hardcoded schema version assertions in db.test.ts and other test files (e.g., task-documents.test.ts) to expect the new version. Missing updates cause test failures like expected 22 to be 21.

Agent Skills

Engine Skill Selection (FN-1795)

The createKbAgent function in packages/engine/src/pi.ts supports a skills?: string[] convenience parameter for skill filtering:

  • Convenience parameter: AgentOptions.skills accepts an array of skill names and auto-derives a SkillSelectionContext
  • Precedence: Explicit skillSelection takes precedence over skills when both are provided
  • Logging: When using the convenience path, a log message is emitted: [pi] Using skills from convenience parameter: [skill1, skill2]
  • Engine integration: All 5 engine paths (executor, triage, reviewer, merger, heartbeat) use buildSessionSkillContext to derive skill selection from agent metadata, which then flows through to createKbAgent via the skillSelection option
  • Skill resolver: resolveSessionSkills and createSkillsOverrideFromSelection handle the actual skill filtering based on project settings

create-fusion-plugin Skill (FN-1134)

The create-fusion-plugin skill teaches agents how to create Fusion plugins. Located at .pi/agent/skills/create-fusion-plugin/.

Purpose: Enables agents to build, extend, and debug Fusion plugins with custom tools, routes, hooks, and settings.

Routing:

  • "Create plugin", "build plugin", "new plugin" → workflows/create-plugin.md
  • "Add tool", "add route", "add hook", "add settings" → workflows/add-capability.md
  • "Plugin not working", "debug plugin", "plugin error" → workflows/debug-plugin.md

Files:

  • references/plugin-api.md — Complete API reference (types, interfaces, helpers)
  • references/plugin-patterns.md — Common patterns and idioms
  • workflows/create-plugin.md — Scaffold and build new plugins
  • workflows/add-capability.md — Extend existing plugins
  • workflows/debug-plugin.md — Diagnose and fix plugin issues
  • templates/minimal-plugin.ts — Bare minimum working plugin
  • templates/plugin-with-tools.ts — Plugin with AI-agent-callable tools
  • templates/plugin-with-routes.ts — Plugin with HTTP API routes

Key references for plugin authors:

  • Import from @fusion/plugin-sdk (not @fusion/core)
  • Use definePlugin() for type-safe plugin definitions
  • Hooks: 5-second timeout, error isolation, all optional
  • Tools: JSON Schema parameters, return PluginToolResult
  • Routes: GET/POST/PUT/DELETE, mounted at /api/plugins/{pluginId}/{path}
  • vitest.config.ts: use pool: "threads" (NOT vmThreads)

FN-1400 Plugin Core Foundation

Key implementation details from the plugin core foundation task:

Database Schema (v24):

  • plugins table stores plugin metadata, path, enabled flag, state, settings (JSON), settingsSchema (JSON), error message
  • Migration adds table via applyMigration(24, ...) in db.ts
  • Schema version assertions in db.test.ts and __tests__/task-documents.test.ts must be updated to expect v24

PluginStore patterns:

  • Lazy initialization pattern: _db starts null, initialized on first access via get db()
  • EventEmitter for state change notifications: plugin:registered, plugin:unregistered, plugin:enabled, plugin:disabled, plugin:updated, plugin:stateChanged
  • Settings validation against settingsSchema before persisting
  • Deterministic state transitions enforced in updatePluginState()

PluginLoader patterns:

  • Uses topological sort (resolveLoadOrder()) for deterministic load order based on dependencies
  • Circular dependency detection throws Error during sort
  • Error isolation: plugin failures set state: "error" in store but don't crash loader
  • Hook invocation via safeCallHook() with try/catch per plugin
  • getPluginTools() and getPluginRoutes() aggregate from successfully loaded plugins only

TaskStore integration:

  • getPluginStore() lazy getter following the same pattern as getMissionStore()
  • Import PluginStore at top of store.ts: import { PluginStore } from "./plugin-store.js";
  • Private field: private pluginStore: PluginStore | null = null;

Public exports (from @fusion/core):

  • Types: PluginManifest, PluginSettingSchema, PluginOnLoad, PluginOnUnload, PluginOnTaskCreated, PluginOnTaskMoved, PluginOnTaskCompleted, PluginOnError, PluginToolDefinition, PluginToolResult, PluginRouteDefinition, PluginContext, PluginLogger, FusionPlugin, PluginState, PluginInstallation
  • Functions: validatePluginManifest()
  • Classes: PluginStore, PluginLoader
  • Interfaces: PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput, PluginLoaderOptions

Dashboard/Serve plugin wiring (FN-1468):

  • PluginStore initialized with store.getFusionDir() as rootDir
  • PluginLoader initialized with { pluginStore, taskStore: store }
  • Both passed to createServer() via pluginStore, pluginLoader, and pluginRunner (pluginLoader instance)
  • Enables /api/plugins REST endpoints in both dashboard and headless node modes

Plugin Hot-Load/Unload (FN-1133)

  • Plugins can be loaded and unloaded at runtime without restarting the engine or dashboard.
  • PluginLoader.reloadPlugin(id) — stops old instance, invalidates module cache, re-imports, calls onLoad. On failure: restores old instance (rollback). If rollback also fails: removes plugin, sets state to "error". onUnload has 5s timeout.
  • PluginRunner subscribes to PluginStore events (plugin:enabled → loadPlugin, plugin:disabled → stopPlugin) for automatic hot-load/unload.
  • Plugin tools fetched per-agent-session in executor — hot-loaded plugins available immediately for new task executions.
  • Dashboard has POST /plugins/:id/reload endpoint and reload button in PluginManager.
  • PluginLoader emits plugin:loaded, plugin:unloaded, plugin:reloaded events.
  • Tool/route caches use stale-flag pattern — invalidated on plugin state changes, rebuilt on next getPluginTools()/getPluginRoutes() call.
  • Stopping a plugin with dependents logs warning but does NOT cascade-stop dependents.
  • Module cache busting uses ?reload=timestamp query parameter for fresh ESM imports.

Background Memory Summarization (FN-1399)

The background memory summarization feature uses a three-layer architecture:

  1. CronRunner post-run hook: onScheduleRunProcessed callback receives (schedule, result) after execution and recording. This keeps post-processing isolated from core execution.

  2. Schedule-specific filtering: The callback in dashboard/serve checks schedule.name === INSIGHT_EXTRACTION_SCHEDULE_NAME to filter for the memory insight schedule only.

  3. Core processing: processAndAuditInsightExtraction() parses AI output, merges insights, writes audit report, and handles errors gracefully.

Key files:

  • packages/core/src/memory-insights.ts — Core helpers for processing, merging, and audit generation
  • packages/engine/src/cron-runner.ts — Post-run callback option (onScheduleRunProcessed)
  • packages/cli/src/commands/dashboard.ts — Startup sync + settings change handler
  • packages/cli/src/commands/serve.ts — Same wiring for headless node mode

Startup ordering: syncInsightExtractionAutomation() must run BEFORE cronRunner.start() to avoid stale config races. The cron runner's immediate tick could execute outdated schedules before sync runs.

Test patterns:

  • For CLI test suites with hoisted mocks (vi.hoisted()), helper functions like triggerSignal must be defined inside each describe block since they're not accessible from sibling blocks.
  • When testing settings-change handlers in CLI commands, emit events on the mock store instance stored in taskStores[0] to trigger the handler.

Pluggable Memory Backend Integration (FN-1769)

The dashboard memory routes integrate with the pluggable memory backend system:

Backend-mediated routes:

  • GET /api/memory uses readMemory(rootDir, settings) from @fusion/core
  • PUT /api/memory uses writeMemory(rootDir, content, settings) from @fusion/core
  • Error mapping: MemoryBackendError codes → HTTP status codes:
    • READ_ONLY/UNSUPPORTED/CONFLICT → 409 Conflict
    • BACKEND_UNAVAILABLE → 503 Service Unavailable
    • QUOTA_EXCEEDED → 413 Payload Too Large
    • Other errors → 500 Internal Server Error

Settings integration:

  • PUT /api/settings validates memoryBackendType (must be string or null)
  • Unknown backend IDs are accepted and persisted verbatim
  • Fallback-to-file is runtime resolution behavior only

Available backends:

  • file — Default backend, stores in .fusion/memory.md
  • readonly — Read-only backend, returns empty on write attempts
  • qmd — QMD (Quantized Memory Distillation) backend with QMD CLI integration
  • Custom backends — Registered at runtime via registerMemoryBackend()

Key exports from @fusion/core:

  • readMemory(rootDir, settings) — Backend-aware memory read
  • writeMemory(rootDir, content, settings) — Backend-aware memory write
  • MemoryBackendError — Error class with code, message, and backend fields
  • resolveMemoryBackend(settings) — Resolve backend from settings
  • listMemoryBackendTypes() — List registered backend types

Memory Compaction and Auto-Summarize (FN-1892):

  • compactMemoryWithAi(content, rootDir, provider?, modelId?) — AI-powered memory compaction
  • createAutoSummarizeAutomation(settings) — Creates cron-based automation for scheduled compaction
  • syncAutoSummarizeAutomation(automationStore, settings) — Syncs automation schedule with project settings
  • POST /api/memory/compact — API endpoint to trigger manual memory compaction
  • New settings: memoryAutoSummarizeEnabled, memoryAutoSummarizeThresholdChars, memoryAutoSummarizeSchedule
  • Automation uses "coding" tools mode (needs write access to update memory file)

FN-1719: Lint/Type/Test Baseline Restoration

ESLint flat config best practices:

  • Global ignores must come FIRST in the config (per ESLint flat config rules)
  • Use separate config blocks for: production TS, test files, node scripts, service workers, demo files
  • Test files should have no-explicit-any and no-unused-vars set to "off"
  • Production TS files should have no-explicit-any set to "warn" (not error)
  • Node scripts need globals: process, console, setTimeout, setInterval, require, module, __dirname, __filename, Buffer
  • Service worker files need globals: self, caches, fetch, URL, Request, Response, Headers, Cache, CacheStorage
  • Avoid using react-hooks/exhaustive-deps eslint-disable comments unless the plugin is installed
  • When linting errors remain from eslint-disable comments for non-existent rules, remove the comments

Pre-existing test issues:

  • Some tests have flaky timeouts or expose pre-existing bugs - use it.skip() with a TODO comment noting the issue
  • The stream flush test in api.test.ts exposes a bug where pending SSE events aren't flushed when the stream ends without a trailing newline

Verification commands:

  • pnpm lint - lint check (0 errors target)
  • pnpm typecheck - typecheck all packages
  • pnpm test - full test suite
  • pnpm build - build all packages

Plugin Examples & Authoring (FN-1114)

Three example plugins demonstrate different plugin capabilities:

Example plugins location: plugins/examples/

  • fusion-plugin-notification/ — Sends webhook notifications (Slack, Discord, generic) on task lifecycle events. Demonstrates: onLoad, onTaskCompleted, onTaskMoved, onError hooks, settings schema, event filtering.
  • fusion-plugin-auto-label/ — Automatic task categorization using keyword matching. Demonstrates: onTaskCreated hook, plugin tools, event emission.
  • fusion-plugin-ci-status/ — Polls CI status for branches with custom REST API. Demonstrates: plugin routes, setInterval polling, onLoad/onUnload lifecycle.

Plugin scaffold command: fn plugin create <name> generates a new plugin project with:

  • package.json, tsconfig.json, vitest.config.ts
  • src/index.ts with minimal definePlugin() call
  • src/__tests__/index.test.ts with basic test
  • README.md template

Plugin authoring guide: docs/PLUGIN_AUTHORING.md covers:

  • Getting started, manifest reference, settings schema
  • All hooks with exact TypeScript signatures
  • Tools and routes registration patterns
  • Plugin context API reference
  • Testing patterns and publishing guide

Plugin UI Slots (FN-1914/FN-1916)

Plugins can register UI components that render at named mount points in the Fusion dashboard.

Type Definition

PluginUiSlotDefinition (in packages/core/src/plugin-types.ts):

  • slotId: string — Unique slot identifier matching a dashboard mount point
  • label: string — Human-readable label
  • icon?: string — Optional lucide-react icon name
  • componentPath: string — Path to component module, relative to plugin root

Available Slot IDs

Slot ID Location Status
task-detail-tab Task detail modal — tab in task detail view Available (tested in FN-1914)
header-action Dashboard header — action button in toolbar Available (tested in FN-1914)
settings-section Settings modal — custom settings section Available (tested in FN-1914)
task-card-badge Task card on the board — badge on task cards Planned (FN-1926)
board-column-footer Board column — footer below last card Planned (FN-1926)

Architecture

  • PluginLoader.getPluginUiSlots() — Aggregates uiSlots from all loaded plugins, returns Array<{ pluginId, slot }>
  • PluginRunner.getPluginUiSlots() — Cached access with invalidation on plugin state changes
  • GET /api/plugins/ui-slots — API endpoint returning aggregated UI slot definitions
  • PluginSlot component (packages/dashboard/app/components/PluginSlot.tsx) — Generic React component that takes slotId prop and renders matching plugin slots
  • usePluginUiSlots hook (packages/dashboard/app/hooks/usePluginUiSlots.ts) — Fetches and caches UI slots with 60s TTL, provides getSlotsForId(slotId) lookup

Current Implementation Status

  • Types, loader, runner, and API endpoint are implemented (FN-1914)
  • Frontend data layer (hook + component) implemented (FN-1925)
  • Dashboard view integration in progress (FN-1926)
  • Dynamic component loading is a future iteration — currently renders placeholder divs with data-plugin-slot attributes

TUI Package (FN-1471)

The @fusion/tui package provides Ink-based React components for terminal UI.

Global Shortcuts Implementation:

  • useGlobalShortcuts hook centralizes all keyboard shortcuts at the app root level
  • FocusGuardRef module-level ref tracks text input focus state for focus guarding
  • Shortcuts blocked when focused: q, ?, h, 1-5 (only when FocusGuardRef.isFocused === true)
  • Ctrl+C always works (emergency exit)
  • HelpOverlay component displays shortcuts and handles Escape/q to close

Focus Guard Pattern:

  • Ink's useFocusManager doesn't provide global "is anything focused" detection
  • Use module-level ref (FocusGuardRef) for cross-component focus state
  • Text inputs should set FocusGuardRef.isFocused = true on focus and false on blur

Testing TUI Components:

  • Use Ink's render() function from ink/testing for tests
  • Mock useInput with vi.mock("ink", ...) to avoid "Raw mode is not supported" errors
  • Use setTimeout(resolve, ms) for async state updates in tests
  • Track captured handlers via module-level variables for test assertions

Kimi/Moonshot API Usage (FN-1578)

  • Primary endpoint: /v1/coding_plan/usage (underscore) — Codexbar-validated working endpoint.
  • Fallback endpoint: /v1/coding-plan/usage (hyphen) — Legacy endpoint for older accounts/API versions.
  • Fallback trigger: ANY 404 response triggers fallback (regardless of body content).
  • Auth errors (401/403): Short-circuit immediately — no fallback for authentication failures.
  • Known 404 error shapes:
    • {"code":5,"error":"url.not_found","message":"没找到对象",...} — endpoint not available (no coding plan active).
    • {"error":"url_not_found"} — alternative format.
  • User-facing error: When last endpoint returns url.not_found, show friendly message: "Usage endpoint unavailable — Kimi coding plan may not be active on this account".
  • Auth: Uses Authorization: Bearer <api_key> header with kimi-coding key from ~/.pi/agent/auth.json.
  • Response parsing: Supports data.windows[] array and flat data.used/total/remaining shapes.

FN-1516: Periodic Auto-Merge Sweep

  • The canAutoMergeTask() function must be defined locally inside runDashboard() to work correctly with Vitest mocks. Module-level exports capture the real getTaskMergeBlocker at import time, before mocks are applied.
  • When importing shared utilities from dashboard.ts in serve.ts, ensure both files define compatible predicates (same mergeRetries limit check).
  • Periodic sweep tests using vi.useFakeTimers() must be isolated in their own test file or properly reset timers to avoid affecting subsequent tests.

FN-1408: Node Provider and Remote Node Status

  • When adding node context (NodeProvider, useNodeContext) to the App shell, update mocks in App.test.tsx for useNodes, useRemoteNodeData, useRemoteNodeEvents, and the NodeContext module.
  • Clear fusion-dashboard-current-node from localStorage in test beforeEach to avoid cross-test leakage.
  • Use mockReturnValue (not mockReturnValueOnce) for repeated mocks in tests with dynamic imports.
  • Add await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); }) in tests that wait for App initial load to complete before interacting with Header components.

FN-1462: Context-Limit Error Recovery

  • When fixing context-limit error detection, add test cases for the specific error message format before making the fix
  • The isContextLimitError() function uses regex patterns to match error messages - patterns must be tested independently
  • When fixing executor recovery paths that fall through to failure, ensure the fix adds an explicit return after successful recovery to prevent execution from continuing to the failure path
  • Vitest runs source files directly (.ts) rather than compiled dist files - rebuild with tsc before running tests if changes aren't picked up

FN-1643: Unified Context-Limit Recovery Across Executor Paths

Both single-session and step-session executors now share consistent context-limit recovery:

Single-session executor (executor.ts catch block):

  • Normalize error messages with const errorMessage = typeof err === "string" ? err : err?.message ?? String(err)
  • Recovery flow: compact-and-resume → reduced-prompt retry (bounded to 1 attempt per error)
  • Recovery state tracked in loopRecoveryState Map with attempts counter
  • Explicit return after successful recovery prevents fallthrough to failure path

Step-session executor (step-session-executor.ts executeStep loop):

  • Recovery attempts tracked separately from retries counter via recoveryAttempts variable
  • Recovery bounded to MAX_STEP_RETRIES attempts to prevent infinite loops
  • New buildReducedStepPrompt() generates simpler step prompts for recovery
  • New imports: compactSessionContext, isContextLimitError, checkSessionError

Testing context-limit recovery:

  • Use vi.mocked(promptWithFallback) to control success/failure per call
  • Track callCount to make first call throw and subsequent calls succeed
  • Use vi.useFakeTimers() for retry delay handling in step-session tests
  • vi.clearAllMocks() in beforeEach to reset mocks between tests

Key pitfall: The appendAgentLog method requires 5 parameters (including type as AgentLogType). Use "text" for info messages and "tool_error" for error messages.

FN-1525: Merger Fresh-Session and Compaction Recovery

  • The merger (runAiAgentForCommit) enforces a fresh session per merge attempt via createKbAgent - no stale conversation state
  • Context-limit errors trigger compact-and-retry: isContextLimitError detects overflow, compactSessionContext compresses history, then retry
  • Non-context errors propagate immediately without compaction - no false-positive recovery attempts
  • Error handling uses err: unknown type with err instanceof Error ? err.message : String(err) pattern for type safety
  • Log messages distinguish fresh-session start ("starting fresh merge agent session") from compaction recovery ("Context limit reached", "Compacted at X tokens")

FN-1588: Truncated-Prompt Retry Pattern

When context-limit errors occur on fresh sessions (compaction returns null), the merger retries with a truncated prompt:

  • Prompt truncation constants: MERGE_COMMIT_LOG_MAX_CHARS = 5000, MERGE_DIFF_STAT_MAX_CHARS = 3000
  • Helper function: truncateWithEllipsis(text, maxChars) returns truncated text with "\n... (truncated)" suffix
  • Truncated retry prompt: Uses "(see git log)" for commit log, "" for diff stat, and simplifiedContext: true
  • Guard: truncatedRetryAttempted flag prevents infinite loops when truncated prompt also fails
  • Error propagation: If truncated retry fails with context-limit error, original error is thrown
  • Recovery flow: Fresh session → compaction attempt (fails) → truncated retry → success or propagate

FN-1532: SQLite Index Optimization

When adding indexes to SQLite schema migrations:

  • Always use CREATE INDEX IF NOT EXISTS to make migrations idempotent
  • For indexes on tables that may not exist in legacy databases, wrap in if (this.hasTable("tableName")) before creating
  • Profile query plans using EXPLAIN QUERY PLAN to identify full scans and temp B-tree sorts
  • Composite indexes can cover both filtering and ordering: CREATE INDEX ON table(col1, col2 DESC)
  • Update SCHEMA_VERSION constant AND all hardcoded version assertions in tests (e.g., expect(db.getSchemaVersion()).toBe(N))
  • The creates all expected indexes test in db.test.ts must list all indexes including new ones
  • Memory pitfall: Test files like run-audit.test.ts and __tests__/task-documents.test.ts also assert schema version

FN-1414: Run-Audit Integration Testing

Key learnings from adding integration test coverage for run-audit:

Test file locations:

  • @fusion/core: packages/core/src/run-audit.integration.test.ts (multi-domain correlation, event shape, ordering)
  • @fusion/engine: packages/engine/src/run-audit.integration.test.ts (engine-to-core correlation, emitter behavior)

Run commands:

  • Core: pnpm --filter @fusion/core exec vitest run src/run-audit.integration.test.ts
  • Engine: pnpm --filter @fusion/engine exec vitest run src/run-audit.integration.test.ts

Ordering guarantee:

  • Core uses ORDER BY timestamp DESC, rowid DESC for deterministic tie-breaking
  • When splitting synthetic run IDs (e.g., "exec-FN-001-123-abc"), use lastIndexOf("-") to handle task IDs with dashes

Metadata normalization:

  • Engine emitters always include phase in metadata
  • source is conditionally included only when provided
  • Database domain infers taskId from target when target looks like a task ID (FN-*, KB-*)

Backward compatibility:

  • createRunAuditor(store, null) returns no-op auditor
  • Store without recordRunAuditEvent method returns no-op auditor
  • No throw on null/undefined context or missing methods

FN-1537: CI Workflow Stabilization

Node.js version compatibility:

  • All GitHub Actions workflows must use node-version: "24" in actions/checkout and pnpm/action-setup
  • Use actions/setup-node@v5 with node-version: "24" instead of v4
  • Node.js 20 actions are deprecated and will stop working June 2, 2026

Changesets configuration:

  • Internal packages with private: true must be listed in .changeset/config.json ignore array
  • Published packages: @gsxdsm/fusion
  • Private packages (must be ignored): @fusion/core, @fusion/dashboard, @fusion/engine, @fusion/tui, @fusion/plugin-sdk, @fusion-plugin-examples/*
  • Without proper ignore entries, changesets tries to publish private packages and fails npm provenance verification

Test version assertions:

  • Tests asserting package versions must read dynamically from package.json using JSON.parse(readFileSync(pkgPath, "utf-8"))
  • Hardcoded version strings in tests (e.g., expect(version).toBe("0.1.0")) break after version bumps
  • Use getAppVersion() for runtime version checks in tests

FN-1563: Decoupling CLI Command Dependencies

Architectural boundary:

  • serve.ts (headless node) must NOT import from ./dashboard.js
  • Shared task lifecycle helpers live in ./task-lifecycle.js (no UI/dashboard dependency)
  • Shared interactive utilities (port prompting) live in ./port-prompt.js
  • Both runDashboard() and runServe() import from these neutral modules

Module structure:

  • task-lifecycle.ts: PR merge helpers (getMergeStrategy, getTaskBranchName, cleanupMergedTaskArtifacts, processPullRequestMergeTask)
  • port-prompt.ts: Interactive port selection (promptForPort)
  • dashboard.ts: UI-specific logic, re-exports neutral helpers for backward compatibility with tests

Test imports:

  • When moving functions to new modules, update test imports accordingly
  • The serve test mocks ./task-lifecycle.js and ./port-prompt.js (not dashboard.js)
  • The dashboard test imports helpers from ./task-lifecycle.js and runDashboard from ./dashboard.js

FN-1269: Routine Engine Integration

The Routine Engine Integration adds scheduled, webhook-triggered, and manual routine execution via the heartbeat system:

Key components:

  • RoutineRunner (packages/engine/src/routine-runner.ts) — Executes routines via heartbeat with concurrency policy enforcement (allow/skip/replace/queue)
  • RoutineScheduler (packages/engine/src/routine-scheduler.ts) — Polls for due routines and triggers execution via RoutineRunner
  • API endpoints: POST /api/routines/:id/trigger (manual), POST /api/routines/:id/webhook (webhook with HMAC-SHA256 verification)

Concurrency policies:

  • allow — Run immediately regardless of existing executions
  • skip — Return failed result without calling heartbeat if already running
  • replace — Cancel existing execution, then run new one
  • queue — Wait for existing execution to complete, then run

Catch-up policy:

  • skip — Update lastTriggeredAt without additional executions
  • catchUp — Execute missed intervals up to 10 max (prevents runaway catch-up)

HMAC signature verification pattern for routine webhooks:

import { createHmac, timingSafeEqual } from "node:crypto";
const signature = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
const isValid = timingSafeEqual(Buffer.from(signature), Buffer.from(req.headers["x-webhook-signature"]));

InProcessRuntime lifecycle integration:

  • RoutineScheduler initialized after HeartbeatMonitor/TriggerScheduler
  • Graceful degradation if RoutineStore not available (FN-1519 types incomplete)
  • getRoutineScheduler() and getRoutineRunner() getters for testing access

Dashboard Startup Perf — listTasks Hot Paths

The dashboard CLI (pnpm dev dashboard) was extremely slow on boards with ~1200 tasks. Three independent code paths were each pulling the entire tasks table (with the full log/comments/steps JSON, ~67 MB) at startup and on every maintenance/sweep cycle.

Bug 1 — TaskStore.watch() (packages/core/src/store.ts): The 1-second poll loop in checkForChanges() filters on updatedAt > lastPollTime, but lastPollTime was left null after watch() populated the cache. The first poll cycle therefore ran an unfiltered SELECT * and emitted a task:updated SSE event for every cached task — ~60 MB of SSE traffic plus 1199 React setState calls one second after dashboard startup. Fix: set this.lastPollTime = new Date().toISOString() at the end of watch() so the first poll only sees tasks that changed after the cache snapshot.

Bug 2 — Auto-merge sweeps (packages/cli/src/commands/dashboard.ts): The startup sweep, the two unpause handlers, and the periodic scheduleMergeRetry() (every 15s by default) all called store.listTasks() and then JS-filtered for in-review tasks. On a 1200-row board with mostly done/archived tasks that's a constant 67 MB allocation just to find 05 candidates. Fix: added column?: Column option to listTasks so callers can scope the SQL WHERE directly, and changed those four call sites to listTasks({ column: "in-review" }).

Bug 3 — Engine maintenance (packages/engine/src/self-healing.ts): SelfHealingManager.archiveStaleDoneTasks() runs every 15 min from runMaintenance(). It only needs id, column, and columnMovedAt to decide which done tasks are >48h old, but it called the full listTasks(). Fix: pass { slim: true } — the slim row still includes those fields and excludes the heavy log/comments/steps payload.

General contract going forward: listTasks() is heavy by default. Hot paths must pass { slim: true }, { column: ... }, or { includeArchived: false }. The board endpoint (GET /api/tasks in packages/dashboard/src/routes.ts) already uses slim+includeArchived; the archived column is loaded lazily on expand via a sticky includeArchived flag in useTasks.ts.

Backlog cleanup: archiveStaleDoneTasks walks tasks one at a time via store.archiveTask(id), which is fine for the steady-state 520 tasks per cycle but would take minutes on the 866-task backlog after the auto-archive feature first lands. For one-off backlog cleanup, a direct SQL UPDATE tasks SET column='archived', columnMovedAt=now, updatedAt=now WHERE column='done' AND columnMovedAt < cutoff is safe and fast — subsequent watch() polls will pick up the changes and emit task:moved events. (Beware emitting hundreds of events in one cycle if a dashboard is connected.)

FN-1426: Vite Alias for @fusion/core

The dashboard's vite.config.ts has an alias that maps @fusion/core to ../core/src/types.ts directly. When adding new exports from @fusion/core (like PROMPT_KEY_CATALOG), you must either:

  1. Re-export the new export from types.ts to make it available via the alias, OR
  2. Change the alias to point to ../core/src/index.ts

The alias approach was intentional (to avoid circular dependencies), so option 1 is preferred. Add the re-export at the end of types.ts:

export { PROMPT_KEY_CATALOG } from "./prompt-overrides.js";

Then rebuild core: pnpm --filter @fusion/core build before running dashboard tests or build.

FN-1413: Plugin Settings Section with SSE Live Updates

The Plugin Settings section in the dashboard Settings modal provides real-time plugin management with SSE-driven live updates.

Component Structure

PluginManager.tsx (packages/dashboard/app/components/):

  • Manages plugin lifecycle (install, enable/disable, uninstall, settings)
  • Subscribes to /api/events SSE stream for real-time updates
  • Handles project-scoped filtering for multi-project mode

SSE Live Update Pattern

// EventSource subscription with heartbeat watchdog
const SSE_HEARTBEAT_TIMEOUT_MS = 45_000;

useEffect(() => {
  let closedByCleanup = false;
  let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
  
  const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
  const es = new EventSource(`/api/events${query}`);

  const resetHeartbeat = () => {
    if (heartbeatTimer) clearTimeout(heartbeatTimer);
    heartbeatTimer = setTimeout(() => {
      if (!closedByCleanup) {
        es.close();
        // Fallback: refetch all plugins
        void loadPlugins();
      }
    }, SSE_HEARTBEAT_TIMEOUT_MS);
  };

  es.addEventListener("plugin:lifecycle", (e: MessageEvent) => {
    resetHeartbeat();
    const payload: PluginLifecyclePayload = JSON.parse(e.data);
    // Filter by projectId if scoped
    if (projectId && payload.projectId && payload.projectId !== projectId) {
      return;
    }
    // Reconcile local state based on transition type
    // ...
  });

  return () => {
    closedByCleanup = true;
    es.removeEventListener("plugin:lifecycle", handlePluginLifecycle);
    es.close();
  };
}, [projectId, loadPlugins]);

Event Payload Types

interface PluginLifecyclePayload {
  pluginId: string;
  transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated";
  sourceEvent: string;
  timestamp: string;
  projectId?: string;
  enabled: boolean;
  state: PluginState;
  version: string;
  settings: Record<string, unknown>;
  error?: string;
}

Transition Handling

Transition Action
enabled Update plugin state to enabled
disabled Update plugin state to disabled
settings-updated Update plugin settings
uninstalled Remove plugin from list
error Update plugin state to error
installing Refetch plugin list

Project-Scoped Filtering

  • SSE URL includes projectId query param when provided
  • PluginManager filters events by payload.projectId !== projectId
  • Prevents cross-project state pollution in multi-project mode

Test Patterns

When testing SSE event handling:

  • Mock EventSource globally in beforeEach
  • Store handler reference in a module-level variable for triggering
  • Use act() when triggering events to ensure React updates complete
  • Test projectId filtering by sending events with mismatched projectId
beforeEach(() => {
  const eventSourceInstance = {
    handlers: {},
    addEventListener: vi.fn((event, handler) => {
      eventSourceInstance.handlers[event] = handler;
    }),
    close: vi.fn(),
  };
  vi.stubGlobal("EventSource", vi.fn(() => eventSourceInstance));
});

it("handles plugin enabled SSE event", async () => {
  // Trigger the event
  const handler = eventSourceInstance.handlers["plugin:lifecycle"];
  act(() => {
    handler({ data: JSON.stringify({ pluginId: "test", transition: "enabled", ... }) });
  });
  
  // Assert state change
  expect(screen.getByRole("checkbox")).toBeChecked();
});

API Wrapper Tests

When adding plugin API wrappers:

  • Mock vi.mock("../../api") with inline object (not external variable)
  • Test projectId propagation via withProjectId() pattern
  • Use mockResolvedValueOnce() for deterministic test sequences
  • Verify URL construction with query string parameters

FN-1607: Process Shutdown Investigation & Diagnostic Instrumentation

Added comprehensive diagnostic instrumentation to identify long-running process stability issues.

Diagnostic Logging

Periodic Diagnostics (every 30 minutes):

[dashboard] diagnostics: uptime=30m rss=XXXmb heap=XXXmb/XXXmb external=XXXmb arrayBuffers=XXXmb handles=XX requests=XX db=ok listeners=task:created:N, task:moved:N, ...

Key diagnostics to monitor:

  • rss / heap — memory usage trends (growing heap over time suggests a leak)
  • handles — active handle count (growing handles suggests resource accumulation)
  • db — database health (db=failed indicates connection issues)
  • listeners — SSE/EventEmitter listener counts (growing counts suggest listener leaks)

Shutdown diagnostics:

[dashboard] active handles at shutdown: TCPServer:1, Timer:5, ... 
[dashboard] shutdown requested reason=SIGINT uptime=2h15m30s tasks=42 active=2 columns=triage:20,todo:15,in-progress:2,done:3,in-review:2

Files Modified

  • packages/cli/src/commands/dashboard.ts — process diagnostics, beforeExit handler, handle type logging
  • packages/cli/src/commands/serve.ts — same diagnostics for headless node mode
  • packages/dashboard/src/sse.ts — SSE high water mark tracking, res.on("close") safety net
  • packages/core/src/store.tshealthCheck() method for database diagnostics

Verified Timer Cleanup

All major timers/intervals properly cleared on shutdown:

  • TaskStore.watch() pollInterval → cleared in stopWatching()
  • Scheduler.pollInterval → cleared in stop()
  • HeartbeatMonitor.pollInterval → cleared in stop()
  • HeartbeatTriggerScheduler.timers → cleared in stop()
  • StuckTaskDetector.interval → cleared in stop()
  • SelfHealingManager.maintenanceInterval, unpauseTimer → cleared in stop()
  • MissionAutopilot.pollTimer, healthCheckTimer → cleared in stop()
  • CronRunner.pollInterval → cleared in stop()
  • PrMonitor.intervals → cleared in stopMonitoring()/stopAll()
  • scheduleMergeRetry() → guarded by disposed/shuttingDown flags

SSE Connection Safety

  • res.on("close", cleanup) added as safety net alongside _req.on("close")
  • High water mark tracking logs when new connection highs are reached
  • Heartbeat 30s interval properly cleared on cleanup

Recommendations for Monitoring

  1. Watch for rss growth over 24h — linear growth suggests memory leak
  2. Watch for handles growth — growing handles suggest resource accumulation
  3. Watch for listener count growth — especially task:created, task:updated (SSE subscriptions)
  4. Watch for beforeExit code=0 without preceding shutdown log (unexpected exit)
  5. db=failed indicates database connectivity issues

Skill Selection Resolver (FN-1510)

The skill selection resolver (packages/engine/src/skill-resolver.ts) computes deterministic session skill sets from project settings and optional caller overrides.

Key patterns:

  • resolveSessionSkills(context) reads .fusion/settings.json (primary) or .pi/settings.json (fallback) for skill patterns
  • Skill patterns use + prefix (include) or - prefix (exclude); unprefixed patterns are treated as +
  • requestedSkillNames acts as intersection filter on top of pattern-based selection (case-insensitive name matching)
  • filterActive: false means no filtering (all discovered skills pass through); filterActive: true means filtering is active
  • createSkillsOverrideFromSelection() returns the skillsOverride callback for DefaultResourceLoader
  • skillsOverride is only set when skillSelection is provided in AgentOptions; omitting it preserves existing behavior
  • SkillSelectionResult includes excludedSkillPaths to track skills explicitly disabled by - patterns
  • Filtering distinguishes three cases:
    1. Allowed skills: skills matching allowedSkillPaths pass through
    2. Disabled skills: skills matching excludedSkillPaths are filtered out and produce warnings
    3. Missing skills: configured paths not matching any discovered skill produce warnings
  • The createSkillsOverrideFromSelection callback filters skills and produces diagnostic messages via console.error with [pi] [skills] prefix

Settings format:

{
  "skills": ["+skills/foo/SKILL.md", "-skills/bar/SKILL.md"],
  "packages": [
    { "source": "@myorg/ai-kit", "skills": ["+skills/custom/SKILL.md"] }
  ]
}

Test patterns:

  • Use in-memory mock filesystem (Map<string, string>) for unit tests
  • mockFiles.set(path, content) and mockFiles.get(path) for read/write
  • mockFiles.clear() in beforeEach to reset state between tests
  • vi.resetModules() when using vi.doMock inside tests

Cross-Node Architecture (FN-1833)

Proxy Architecture

The cross-node system uses a proxy-based model where the local dashboard server forwards API requests to remote nodes:

  • Frontend proxy infrastructure exists: proxyApi() in api.ts (line 2176), withNodeId() (line 2162), useRemoteNodeData(), useRemoteNodeEvents()
  • Backend proxy routes are missing: routes.ts has NO /api/proxy/:nodeId/* handlers — this is the critical gap blocking remote node viewing
  • URL rewriting pattern: proxyApi("/tasks", { nodeId }) rewrites to /api/proxy/{nodeId}/tasks
  • SSE proxy: useRemoteNodeEvents() opens EventSource("/api/proxy/{nodeId}/events") with 45s heartbeat timeout and 3s reconnect

Project-Node Assignment Model

  • RegisteredProject.nodeId — optional field pointing to a node in the registry
  • Local node: handles projects with matching nodeId AND unassigned projects
  • Remote node: handles only projects explicitly assigned to it
  • Routing logic: isProjectRoutedToNode() in nodeProjectAssignment.ts
  • Critical gap: CentralCore.registerProject() does NOT accept nodeId — must use separate assignProjectToNode() call

Background Services Not Wired

  • PeerExchangeService exists in packages/engine/src/peer-exchange-service.ts but is NOT instantiated in serve.ts or dashboard.ts
  • CentralCore.startDiscovery() exists but is NOT called in CLI commands
  • Peer exchange and mDNS discovery need to be wired in InProcessRuntime.start() and runServe()/runDashboard()

Dependency Chain for Cross-Node

  1. FN-1802 — Generic proxy route (/api/proxy/:nodeId/*) — unblocks all remote viewing
  2. FN-1806 Implemented (FN-1833): 5 proxy routes in routes.ts with proxyToRemoteNode() helper. JSON routes: /health, /projects, /tasks, /project-health. SSE route: /events (30s timeout, client disconnect cleanup). Auth via Bearer header when apiKey set. Filters hop-by-hop headers.
  3. FN-1803 — Node-aware project registration and directory browsing
  4. FN-1804 — Frontend node selector for project creation
  5. FN-1805 — Wire peer exchange and discovery in runtimes
  6. FN-1736 — Comprehensive project scoping review (SSE/WebSocket filtering)

FN-1806 implementation notes:

  • Routes must be placed BEFORE return router; in createApiRoutes — TypeScript's scope analysis requires helper functions to be defined before they're used at the same scope level
  • Use async function declarations (not arrow functions) for the route handlers to ensure proper TypeScript scope resolution
  • For mock fetch responses in tests: always use ReadableStream for the body — never return null for body, or the streaming pipe won't work
  • proxyToRemoteNode helper uses new URL(req.url, 'http://localhost') to reliably extract query params for forwarding

Mission Validation Board Tasks (FN-1982)

When a mission feature's implementation task completes, MissionExecutionLoop.processTaskOutcome() runs validation against contract assertions. As of FN-1982, each validation run creates a visible board task:

  • Task creation: taskStore.createTask() with column: "in-progress", missionId, sliceId, and status: "mission-validation"
  • Task lifecycle:
    • in-progressdone (validation passes)
    • in-progressin-review (validation fails, blocked, or errors)
  • Status marker: status: "mission-validation" prevents scheduler/stuck-detector from dispatching the task
  • Validator run linkage: MissionValidatorRun.taskId stores the board task ID for cross-referencing
  • VALID_TRANSITIONS: "in-progress""done" was added specifically for validation tasks (note in types.ts)

The error status from parseValidationResult() (empty response, invalid JSON, invalid status) is now handled in processTaskOutcome() alongside pass/fail/blocked.

FN-2048: SSE Connection Leak — closed Flag Pattern in sse-bus

Rapid view transitions (e.g., Board↔Missions) caused zombie EventSource connections to accumulate, exhausting the browser's HTTP/1.1 connection pool (6 per origin) and blocking subsequent fetchMissions calls. Two fixes prevent this:

1. closed flag in sse-bus Channel

The closeChannel function sets channel.closed = true before closing the EventSource. This prevents forceReconnect() (triggered by error events) from scheduling a reconnect timer after the channel has already been torn down. Additionally, the reconnect timer callback itself checks channel.closed before calling openChannel.

interface Channel {
  // ...
  closed: boolean;
}

function closeChannel(channel: Channel): void {
  channel.closed = true;  // Set BEFORE es.close() to block synchronous reconnect
  if (channel.es) channel.es.close();
  // ...
}

function forceReconnect(channel: Channel): void {
  // ...
  if (channel.closed) return;  // Guard: do not schedule reconnect on teardown
  // ...
  channel.reconnectTimer = setTimeout(() => {
    if (channel.closed) return;  // Guard: do not reopen after teardown
    // ...
  }, RECONNECT_DELAY_MS);
}

Key insight: The closed check at the top of forceReconnect prevents the timer from being set when closeChannel runs synchronously after an error event. Without this, the reconnect timer could be set after the channel was already marked for teardown, creating a zombie connection.

2. active flag in useTasks SSE effect

The SSE effect in useTasks.ts uses an active boolean to prevent stale onReconnect callbacks from firing after the effect has cleaned up (e.g., when sseEnabled flips to false):

useEffect(() => {
  let active = true;
  // ...
  return subscribeSse(url, {
    onReconnect: () => {
      if (!active) return;  // Block stale callbacks after effect cleanup
      // ...
    },
  });
  return () => { active = false; };  // Runs after subscribeSse's cleanup
}, [projectId, sseEnabled]);

Important: The cleanup function that sets active = false must be returned after the subscribeSse() return value — not as a second return (which would be unreachable). The unsubscribe from subscribeSse is called first, then active is set to false.

Files affected

  • packages/dashboard/app/sse-bus.tsclosed flag on Channel
  • packages/dashboard/app/hooks/useTasks.tsactive flag in SSE effect
  • Tests: packages/dashboard/app/__tests__/sse-bus.test.ts, packages/dashboard/app/hooks/__tests__/useTasks.test.ts