- 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
100 KiB
Project Memory
Architecture
-
FN-1737/FN-1868 Ephemeral Agent Auto-Deletion: Runtime-created agents (task-workers created byInProcessRuntimeand spawned child agents created byTaskExecutor) are auto-deleted fromAgentStoreafter reaching a terminal state. A 5-second delay allows the UI to observe the "terminated" state before deletion. TheisEphemeralAgent()helper in@fusion/coreprovides canonical detection heuristics:agent.metadata?.agentKind === "task-worker"— task-worker agentsagent.metadata?.type === "spawned"— spawned child agentsagent.metadata?.taskWorker === true— legacy markeragent.metadata?.managedBy === "task-executor"— executor-managed agents- Legacy fallback: executor role with name starting with "executor-" and no
reportsTo
-
AgentStore.listAgents()andgetOrgTree()exclude ephemeral agents by default via theincludeEphemeralfilter parameter. The dashboard API routes (GET /api/agents,GET /api/agents/org-tree) use this parameter. -
Frontend uses
isEphemeralAgentfrom@fusion/coreto filter ephemeral agents indisplayAgentsanddisplayOrgTreememos. -
FN-1776 Agent SSE Event Forwarding: Agent lifecycle events are now forwarded through the SSE pipeline:createSSE()accepts an optionalAgentStoreparameter for forwardingagent:created,agent:updated,agent:deleted, andagent:stateChangedeventsgetOrgTree()accepts{ includeEphemeral?: boolean }filter matchinglistAgents()patternGET /api/agents/org-treesupportsincludeEphemeralquery parameteruseAgentshook excludes ephemeral agents fromactiveAgentsby default- Server SSE endpoint resolves
AgentStorefrom engine viagetAgentStore()for project-scoped streams
-
FN-1976 Message SSE + Route Store Cohesion:MessageStoreis anEventEmitter; SSE listeners must attach to the SAMEMessageStoreinstance used by message-writing routes.- Reusing the same SQLite database is not enough for realtime updates — two
MessageStoreinstances on one DB do not share in-memory events. - In dashboard routes, prefer
engine.getMessageStore()(oroptions.engine?.getMessageStore()for default scope) before creating a fallbacknew 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 useresolveProjectScopedStore()orgetProjectContext()correctly - Badge WebSocket (
setupBadgeWebSocket) properly scopes per-project with listeners on scoped stores - Terminal WebSocket properly scopes per-project via
projectIdquery param - All frontend EventSource connections (
useMultiAgentLogs,AgentDetailView,MissionManager, etc.) passprojectIdcorrectly - CLI commands (
mission.ts,plugin.ts,backup.ts, etc.) useresolveProject()/getStore()properly - Fixed: Chat session creation (
POST /api/chat/sessions) was NOT passingprojectIdtocreateSession()— now usesgetProjectContext(req)and passesprojectId ?? null - AI sessions are intentionally global (shared across projects) —
AiSessionStoreuses single DB - Session locks (
useSessionLock) are intentionally global — they prevent concurrent AI session access across projects
- SSE/WebSocket endpoints (
-
TaskExecutorterminates active agent sessions (single and step) when tasks are moved away fromin-progressvia thetask:movedevent 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 inpackages/engine/src/pi.tsautomatically catches context-window overflow errors, runscompactSessionContext(), 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 usepromptWithFallback'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:
- Workflow step agent outputs "REQUEST REVISION\n\n[feedback]" to signal that code changes are needed
executeWorkflowStep()detects this pattern and returnsWorkflowStepOutcomewithrevisionRequested: truerunWorkflowSteps()propagates the structured outcome withWorkflowStepResult.revisionRequestedhandleWorkflowRevisionRequest()is called, which:- Injects "Workflow Revision Instructions" section into
PROMPT.md(replacing any prior revision block) - Resets all steps to
pendingfor fresh execution - Clears session file to get a fresh agent session
- Schedules fresh execution via
setTimeoutafter current guard unwinds
- Injects "Workflow Revision Instructions" section into
- Task stays in
in-progressand the scheduler re-dispatches the task for a fresh executor pass - 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 inin-progresswith 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
reviewHandoffPolicyis"comment-triggered", the executor detects handoff intent in agent-authored steering comments and executes the handoff: setsstatus: "awaiting-user-review",assigneeUserId: "requesting-user", moves task toin-review, and disposes the agent session. The merger skips tasks with"awaiting-user-review"status (viaBLOCKING_TASK_STATUSESintask-merge.ts). Users can accept review (clear status) or return to agent (move to todo). TheassigneeUserIdfield stores the user ID who should review the task. -
Agent preset templates in
NewAgentDialog.tsxare a UI-only concept (AgentPresetinterface), separate from the engine'sAgentPromptTemplatetype. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types. -
soulandinstructionsTextare already supported inAgentCreateInputandAgentUpdateInput— no API changes needed when adding these to presets. -
CronRunneruses dependency injection for AI prompt execution: anAiPromptExecutorfunction is injected via options. This keeps it decoupled fromcreateKbAgentand testable without real agent sessions. -
createAiPromptExecutor(cwd)is an async factory function that creates a new agent session per call, usesonTextfor text accumulation, and disposes sessions in afinallyblock. -
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 lazyimport("./pi.js")pattern keeps pi SDK out of the module graph when only monitoring (not execution) is needed. -
Agent tool factories (
createTaskCreateTool,createTaskLogTool) live inagent-tools.tsand are shared betweenTaskExecutorandHeartbeatMonitorto 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.
HeartbeatMonitorandHeartbeatTriggerSchedulerare created WITHOUT the task-lane semaphore in bothrunDashboard()andrunServe(). 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 withmetadata.agentKind = "task-worker"andruntimeConfig.enabled = false, then transitionidle -> active -> runningafter assignment wiring completes.HeartbeatTriggerScheduler.watchAssignments()must skip assignment wakeups whenruntimeConfig.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
setIntervalin streamonOpenthatPOSTs/api/ai-sessions/:id/ping, and always stop it on streamclose,complete, and fatal errors. -
Subtask Session ProjectId Propagation (FN-1479): Subtask breakdown sessions must persist
projectIdthroughout their lifecycle to enable project-scoped resume. Key patterns:POST /api/subtasks/start-streamingforwardsprojectIdfrom the route handler tocreateSubtaskSession()projectIdis stored on the session state object AND persisted to SQLite viapersistSubtaskSession()on every state update (not just initial insert)- Session rehydration via
buildSubtaskSessionFromRow()preservesprojectIdfrom the database row - Retry/complete/error transitions maintain
projectIdthrough 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/syncendpoint.PeerExchangeServiceruns periodic sync cycles (default 60s interval) with all online remote nodes.CentralCore.mergePeers()handles peer data merging — new peers are registered viaregisterGossipPeer(), 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):
PeerExchangeServicesupports optional settings synchronization viasettingsSyncEnabledoption. When enabled, nodes exchangeSettingsSyncPayload(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.
- Settings Sync (FN-1822):
-
Node Plugin Sync (FN-1246/FN-1518): Nodes track version information for plugin synchronization. Central schema v4 adds
versionInfoandpluginVersionscolumns to thenodestable.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— ReturnsNodeVersionInfowhen present,nullwhen 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. CallssyncPlugins(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. CallscheckVersionCompatibility(localVersion, remoteVersion)with version strings (not node IDs).
-
Plugin Management API Routes (FN-1411): Plugin CRUD endpoints implemented in
createApiRouteswithgetScopedStore(req)pattern for multi-project support:- Mode discriminator pattern for POST /plugins: Deterministic behavior via required
modefield 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
projectIdin query param or request body. UsesgetScopedStore(req)which callsgetOrCreateProjectStore(projectId). - Scoping test strategy: Mock
projectStoreResolver.getOrCreateProjectStoreto 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.
- Mode discriminator pattern for POST /plugins: Deterministic behavior via required
FN-1354: Auto-Summarize Titles Bug Fix
- The
summarizefield inTaskCreateInputmust be forwarded by the frontend API (createTaskinapi.ts) to enable the auto-summarization flow summarizeTitle()inai-summarize.tsusessession.state.messagesto extract AI responses, with fallback to directprompt()return value- Debug logging via
process.env.FUSION_DEBUG_AIhelps diagnose AI session issues - When testing
console.warncalls that expect multiple substrings in a single concatenated string, useexpect(mock.calls[0][0]).toMatch(/substring1/)pattern instead ofexpect.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 (defaulttruefor 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.stringifyin 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:
loadingshould betrueONLY for initial data fetch, NOT during background polling - Background polling pattern: Use a ref (
initialLoadCompleteRef) to track if initial load is done; only setloading: truewhen!initialLoadCompleteRef.current - Component behavior: Components consuming these hooks should show skeleton only when there's genuinely no data (not just when
loadingis true during refresh) - Why it matters: Setting
loadingto 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 exampleimport("@fusion/engine")) over undocumented deep subpaths like@fusion/engine/pior@fusion/engine/pi.js; add any needed helpers topackages/engine/src/index.tsto 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()— SetscompletedAttimestamp, preserves state for timestamp queriesisOnboardingCompleted()— Returns true only whencompletedAtis setclearOnboardingState()— Full removal (for explicit reset, not completion)- Keep dismissed state separate from completed state: dismissal preserves step state without
completedAt, completion setscompletedAt - 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 Tinstead ofvi.fn<Parameters<T>, ReturnType<T>>(). The generic syntax works at runtime but fails duringtscbuild. -
expect.any(Number)does not work in Vitest matchers — useexpect(mockFn.mock.calls.length).toBeGreaterThanOrEqual(1)or similar instead. -
When mocking
AgentStorefor heartbeat execution tests, tracksaveRuncalls in a localMap<string, AgentHeartbeatRun>and havegetRunDetailread from it — this waycompleteRun's saved state is reflected in the returned run. -
When
HeartbeatMonitorOptionshas optional fields (taskStore?,rootDir?), capture them in localconstvariables after the early-return validation check to avoidObject is possibly 'undefined'TypeScript errors in the closure. -
For package-scoped single-file test runs, prefer
pnpm --filter <pkg> exec vitest run <file>overpnpm --filter <pkg> test -- <file>when the package test script already hardcodes positional args. -
Mission interview
POST /api/missions/interview/create-missionnow auto-generates contract assertions at milestone, slice, and feature levels; route/e2e tests should expect extraaddContractAssertioncalls whilelinkFeatureToAssertionremains feature-only. -
In dashboard task-creation forms, avoid special-casing built-in workflow template IDs in UI state; render from fetched
workflowStepsIDs and let store-side template materialization resolve template IDs (browser-verification→WS-XXX). -
When a package mixes Electron main-process
.tsfiles with renderer.tsxfiles, usemoduleResolution: "bundler"pluslib: ["DOM", "DOM.Iterable"]in that package tsconfig; Node16 resolution will otherwise force.jsextensions and break renderer imports duringtsc. -
For React component tests in the desktop package, include
.test.tsxin Vitest discovery and callcleanup()inafterEachto 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 thetaskquery param while preserving other params (likeproject). -
When deprecating fields from
BoardConfigbut tests/internal flows still poke private config methods, keep temporary compatibility fields non-enumerable inreadConfig()sowriteConfig()can omit them fromconfig.jsonwhile 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 exampleparseCompanyArchive); 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 selectedAGENTS.mdfiles client-side and send{ agents }payloads instead of trying to submit a directorysourcepath. -
For conditionally rendered mobile inputs in dashboard components, prefer React
autoFocuson the input over effect+setTimeoutfocus 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 letHeartbeatMonitor.executeHeartbeat()only validatecheckedOutBy(never auto-acquire leases). -
The null-as-delete pattern for settings: In
TaskStore.updateSettings(),nullvalues in the settings patch are treated as "delete this key from settings" (sinceJSON.stringifydropsundefinedkeys). This allows the frontend to explicitly clear a setting by sendingnull. The key is deleted from bothconfig.settingsandprojectPatchbefore merging, so cleared settings fall back toDEFAULT_SETTINGS. -
TaskStore.logEntry(),addComment(),addSteeringComment(),pauseTask()accept an optionalRunMutationContextparameter 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 syntheticrunContextwithrunId: "exec-{taskId}-{timestamp}-{random}"since it doesn't useAgentHeartbeatRun. -
Run-Audit Instrumentation (FN-1404): The engine instruments mutation calls with audit events via
createRunAuditor()fromrun-audit.ts. Each active run (heartbeat, executor, merger) creates anEngineRunContextwithrunId,agentId,taskId, andphase. The auditor no-ops cleanly when no run context exists (backward compatible with manual/non-run paths). UsegenerateSyntheticRunId()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 usestools: "coding"for read/write access. Always dispose sessions infinallyblocks, usewithRateLimitRetry()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. AddinvalidateCache()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 (notrejects.toThrow()) - The
api()function inapp/api.tsonly passesheaders: { "Content-Type": "application/json" }when noopts.methodis specified — GET requests don't includemethod: "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
- Use
-
ESLint flat-config ordering (FN-1756): In
eslint.config.mjs, globalignoresmust come FIRST before any recommended configs. This ensures ignored paths are filtered before base config evaluation. The correct order is:- Global
ignores— files never linted (must be first) - Base recommendations — eslint/recommended + typescript-eslint/recommended
- 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
ignoresdo not stop the base recommended configs from linting those files if they're not ignored globally first
- Global
-
API mock export parity (FN-1756): When dashboard components or routes import new API symbols or
@fusion/coreexports, the corresponding test mocks must be updated. This is a common source of test failures after refactoring:- Component tests (
app/components/__tests__/*.test.tsx): Updatevi.mock("../../api")export list to include new API functions - Route tests (
src/*.test.ts,src/__tests__/*.test.ts): Updatevi.mock("@fusion/core")andvi.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
parseCompanyArchiveis added to@fusion/coreexports, add it to the mock export list inroutes.test.ts
- Component tests (
-
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 asstring | 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:rootand[data-theme="light"]base blocks. The test instatus-colors-theme.test.tsiterates 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.cssstylesheet URL is derived fromdocument.baseURIrather than hardcoded paths. This ensures correct resolution in both HTTP/HTTPS contexts (uses/theme-data.css) and Electronfile://contexts (derives path relative to HTML file directory). The samegetThemeDataUrl()helper is used by both the pre-hydration inline script inindex.htmland the runtimeuseTheme.tshook. Bug fix (FN-1535): The initial implementation had a path joining bug wherenew URL("theme-data.css", baseUrl)was used incorrectly, producing malformed paths like.../apptheme-data.cssinstead of.../app/theme-data.css. The fix usesurl.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 checkingbase.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:
- PluginStore (
packages/core/src/plugin-store.ts) — SQLite-backed CRUD operations for plugin installations, stored in thepluginstable (schema v24) - PluginLoader (
packages/core/src/plugin-loader.ts) — Dynamic import, lifecycle management, dependency resolution (topological sort), hook invocation - 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 synchronizationPluginRunner.shutdown()unsubscribes all listeners and stops all plugins cleanly- Runtime integration:
InProcessRuntime.start()initializes PluginStore/PluginLoader/PluginRunner after TaskStore,stop()callspluginRunner.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:
onTaskCreatedon task:created,onTaskMoved/onTaskCompletedon task:moved (completion only whento === "done") - Agent lifecycle hooks:
onAgentRunStart/onAgentRunEndinvoked in executor session start/end paths
Tool Adaptation:
- Plugin tools are converted from
PluginToolDefinition[]toToolDefinition[](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
TaskExecutorOptionsas optional dependency StepSessionExecutorreceives plugin tools viaTaskExecutorOptions.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, routesPluginContext— 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
settingsSchemaon update
PluginLoader patterns:
- Uses topological sort for dependency resolution (throws on circular deps)
- Error isolation: plugin crashes set
state: "errorbut 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 identifiertransition— Normalized transition type:installing,enabled,disabled,error,uninstalled,settings-updatedsourceEvent— Underlying store event that triggered this transitiontimestamp— ISO-8601 timestampprojectId— Included for project-scoped streams (omitted for default streams)enabled— Whether the plugin is currently enabledstate— Current plugin state (installed,started,stopped,error)version— Plugin versionsettings— Plugin settings snapshoterror— 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) → usesstore.getPluginStore()for default store/api/events?projectId=X→ usesscopedStore.getPluginStore()fromgetOrCreateProjectStore(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.ts—createSSE()with plugin lifecycle relaypackages/dashboard/src/server.ts—/api/eventsroute wiring with scoped plugin sourcespackages/dashboard/src/__tests__/sse.test.ts— Plugin lifecycle SSE testspackages/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
onOpenScriptsinMobileNavBarPropscompiled fine but causedReferenceError: onOpenScripts is not definedat 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}shadowsPOST /files/{*filepath}/deleteif defined first/:idpatterns (FN-1909):GET /:idshadowsGET /runsandGET /runs/:idif 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.tsandpackages/dashboard/src/insights-routes.tsfor the correct ordering patterns
-
Webhook HMAC testing: The
REQUESTtest utility intest-request.tsdoesn't handle stream-based middleware likeexpress.raw()well. For webhook routes requiring HMAC verification (e.g., GitHub webhooks, routine webhooks), test theverifyWebhookSignaturefunction directly usingawait import()rather than trying to set up raw body middleware through Express. See the routine webhook tests inroutes.test.tsfor 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 inpackages/cli/src/commands/__tests__/dashboard.test.tsANDpackages/cli/src/commands/__tests__/serve.test.tsto 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.tsANDsrc/__tests__/bin.test.tsmocks to include the new exports, otherwise all tests importing from bin.ts will fail with "No 'X' export is defined" errors. -
Test
describeblocks 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
projectIdorforce), assert the leading semantic arguments viamock.calls.at(-1)?.slice(0, n)instead of exacttoHaveBeenCalledWith(...)on the full argument list. Some call paths omit trailingundefinedvalues while others pass them explicitly. -
HeartbeatMonitor.executeHeartbeat()callsstartRun()internally — do NOT call bothstartRun()andexecuteHeartbeat()for the same run, or you'll get duplicate runs. UsestartRun()alone for record-only, orexecuteHeartbeat()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 inbeforeEach. -
In UI static analysis tests, avoid regex that spans multiple lines for code patterns (e.g.,
setInterval.*5000). Use separatetoContain()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 resolvedWS-XXXIDs by matchingworkflowStep.templateId. -
Testing modal dropdown menus (FN-1489): When testing dropdown menus in modal footers:
- Wrap
fireEvent.click()calls inact()when the dropdown state updates:await act(async () => { fireEvent.click(btn); }) - Use
screen.getByRole("menuitem", { name: "..." })instead ofscreen.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
- Wrap
-
When using
import.meta.envinpackages/dashboard/app/*, ensurepackages/dashboard/tsconfig.app.jsonincludes"vite/client"incompilerOptions.types, or the dashboard typecheck test will fail withProperty '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, runpnpm installat repo root first. -
When dashboard components add new
lucide-reacticons or new API functions, update the component test mocks (vi.mock("lucide-react")andvi.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
yamlfrom@fusion/core; runpnpm installat repo root before chasing false test failures. -
pnpm testat 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 DESCalone can be nondeterministic when multiple rows share the same millisecond timestamp; add a stable tiebreaker (for examplerowid DESC) when selecting a "latest" event. -
In
TaskCard.tsx,isInteractiveTargetmust checktarget instanceof Element(notHTMLElement) so SVG elements from lucide-react icons are correctly detected as interactive when inside buttons. -
If workspace tests fail resolving
@fusion/corepackage exports frompackages/core/dist/index.js(for exampleNo matching export ...in CLI/TUI/package-level tests after adding a new core export), runpnpm --filter @fusion/core buildbefore rerunning the suite so ignoreddist/exports are refreshed. -
If CLI tests/build-exe tests fail with
Could not resolve "@fusion/dashboard"(or Vite reports missing@fusion/dashboardentry), build the dashboard package first (pnpm --filter @fusion/dashboard build) sopackages/dashboard/dist/index.jsexists for workspace consumers. -
If dashboard/TUI tests fail resolving
@fusion/engineentry exports (for exampleFailed to resolve entry for package "@fusion/engine"), build engine artifacts first (pnpm --filter @fusion/engine build) sopackages/engine/dist/index.jsis available for workspace imports. -
QuickEntryBox control test IDs are reused in
ListViewintegration tests; when control layout changes (for example nested menu → inline buttons), update bothQuickEntryBox.test.tsxandListView.test.tsxtogether to avoid cascading failures. -
When
InlineCreateCardlayout changes, also checkColumn.test.tsxandboard-mobile.test.tsxfor references to moved/removed test IDs likeinline-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.tshas 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 usingscreen.getByText("SectionName")may fail with "multiple elements found" when the section heading also appears in the content area alongside the sidebar item. Usescreen.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 viamkdtempSync(join(os.tmpdir(), 'fn-test-'))and clean up inafterEachwithrmSync(dir, { recursive: true, force: true }). Shared temp paths cause state leakage between tests, leading to noisy/flaky behavior. Seein-process-runtime.test.tsfor the pattern. -
xterm.js WebGL on mobile (FN-1739): The
@xterm/addon-webgladdon 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-hoveris used but never defined as a CSS custom property in the root or light theme blocks — it resolves to invalid/empty. Components usingvar(--surface-hover)(like.github-import-tab:hover) get no background. Either define it in the theme roots or use fallbacks likevar(--surface-hover, rgba(0,0,0,0.03)). -
.form-errorand similar error-state selectors should usecolor-mix(in srgb, var(--color-error) 10%, transparent)instead of hardcodedrgba(248, 81, 73, 0.1)for theme adaptability. -
When styling
input[type="radio"]elements in.importeditems, 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 tofetchTaskswithqparam - Remote mode uses
useRemoteNodeData({ searchQuery })which forwards tofetchRemoteNodeTaskswithqparam - The
searchQuerystate 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 switchuseMultiAgentLogs— Clears all state and rejects stale events on project switchuseLiveTranscript— Clears entries and rejects stale events on project/task switchAgentDetailView— 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.jsonAND SQLite (fusion.db) to maintain consistency - For SQLite updates, use
sqlite3directly 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.cssmobile 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 (likemin-height), update both the CSS and the corresponding test assertions + regex patterns. - Mobile-specific selectors like
.mobile-nav-taband.mobile-more-itemmay exist as base styles (not inside media queries) but are still mobile-only components. The.touch-targetutility class at the top ofstyles.cssis intentionally 44px and should not be changed when reducing mobile button sizes. - When checking if a CSS value is inside a
@mediablock, 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@mediablocks are unreliable — they can match across block boundaries. Use non-greedy[^}]*scoped to a single rule block instead. - Touch target sizing in
styles.cssmobile media queries uses 36px (reduced from the original 44px). The.touch-targetopt-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: Usesmin-height: var(--mobile-nav-height)(currently 44px).executor-status-barmobile: Usesbottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom))to position above the nav bar.project-content--with-mobile-nav: Usespadding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom))to reserve nav space.project-content--with-footer.project-content--with-mobile-nav: Usespadding-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:
:rootdefault:--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
.headerand.header-floating-searchin 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-expandedhaswidth: 100%, (2) no fixed negative offsets (left: -NNpx,right: -NNpx) exist in mobile search rules, (3).header-floating-searchhas 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/tuipackage usesink'srenderfunction for testing, not@testing-library/react. UsesetTimeout(resolve, ms)to wait for async operations in tests. -
When mocking
useFusionin TUI tests, usevi.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 likelistTasksorgetActivityLog. -
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 withvi.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
activeScreenstate by passing it to children and capturing in a local variable for test assertions. -
When adding database schema migrations, increment
SCHEMA_VERSIONand add migration blocks withapplyMigration(N, () => { ... }). Also update hardcoded schema version assertions indb.test.tsand other test files (e.g.,task-documents.test.ts) to expect the new version. Missing updates cause test failures likeexpected 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.skillsaccepts an array of skill names and auto-derives aSkillSelectionContext - Precedence: Explicit
skillSelectiontakes precedence overskillswhen 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
buildSessionSkillContextto derive skill selection from agent metadata, which then flows through tocreateKbAgentvia theskillSelectionoption - Skill resolver:
resolveSessionSkillsandcreateSkillsOverrideFromSelectionhandle 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 idiomsworkflows/create-plugin.md— Scaffold and build new pluginsworkflows/add-capability.md— Extend existing pluginsworkflows/debug-plugin.md— Diagnose and fix plugin issuestemplates/minimal-plugin.ts— Bare minimum working plugintemplates/plugin-with-tools.ts— Plugin with AI-agent-callable toolstemplates/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"(NOTvmThreads)
FN-1400 Plugin Core Foundation
Key implementation details from the plugin core foundation task:
Database Schema (v24):
pluginstable stores plugin metadata, path, enabled flag, state, settings (JSON), settingsSchema (JSON), error message- Migration adds table via
applyMigration(24, ...)indb.ts - Schema version assertions in
db.test.tsand__tests__/task-documents.test.tsmust be updated to expect v24
PluginStore patterns:
- Lazy initialization pattern:
_dbstarts null, initialized on first access viaget db() - EventEmitter for state change notifications:
plugin:registered,plugin:unregistered,plugin:enabled,plugin:disabled,plugin:updated,plugin:stateChanged - Settings validation against
settingsSchemabefore 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()andgetPluginRoutes()aggregate from successfully loaded plugins only
TaskStore integration:
getPluginStore()lazy getter following the same pattern asgetMissionStore()- 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()viapluginStore,pluginLoader, andpluginRunner(pluginLoader instance) - Enables
/api/pluginsREST 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.PluginRunnersubscribes 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/reloadendpoint and reload button in PluginManager. - PluginLoader emits
plugin:loaded,plugin:unloaded,plugin:reloadedevents. - 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=timestampquery parameter for fresh ESM imports.
Background Memory Summarization (FN-1399)
The background memory summarization feature uses a three-layer architecture:
-
CronRunner post-run hook:
onScheduleRunProcessedcallback receives(schedule, result)after execution and recording. This keeps post-processing isolated from core execution. -
Schedule-specific filtering: The callback in dashboard/serve checks
schedule.name === INSIGHT_EXTRACTION_SCHEDULE_NAMEto filter for the memory insight schedule only. -
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 generationpackages/engine/src/cron-runner.ts— Post-run callback option (onScheduleRunProcessed)packages/cli/src/commands/dashboard.ts— Startup sync + settings change handlerpackages/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 liketriggerSignalmust be defined inside eachdescribeblock 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/memoryusesreadMemory(rootDir, settings)from@fusion/corePUT /api/memoryuseswriteMemory(rootDir, content, settings)from@fusion/core- Error mapping:
MemoryBackendErrorcodes → HTTP status codes:READ_ONLY/UNSUPPORTED/CONFLICT→ 409 ConflictBACKEND_UNAVAILABLE→ 503 Service UnavailableQUOTA_EXCEEDED→ 413 Payload Too Large- Other errors → 500 Internal Server Error
Settings integration:
PUT /api/settingsvalidatesmemoryBackendType(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.mdreadonly— Read-only backend, returns empty on write attemptsqmd— 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 readwriteMemory(rootDir, content, settings)— Backend-aware memory writeMemoryBackendError— Error class with code, message, and backend fieldsresolveMemoryBackend(settings)— Resolve backend from settingslistMemoryBackendTypes()— List registered backend types
Memory Compaction and Auto-Summarize (FN-1892):
compactMemoryWithAi(content, rootDir, provider?, modelId?)— AI-powered memory compactioncreateAutoSummarizeAutomation(settings)— Creates cron-based automation for scheduled compactionsyncAutoSummarizeAutomation(automationStore, settings)— Syncs automation schedule with project settingsPOST /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
ignoresmust 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-anyandno-unused-varsset to "off" - Production TS files should have
no-explicit-anyset 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-depseslint-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.tsexposes 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 packagespnpm test- full test suitepnpm 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,onErrorhooks, settings schema, event filtering.fusion-plugin-auto-label/— Automatic task categorization using keyword matching. Demonstrates:onTaskCreatedhook, plugin tools, event emission.fusion-plugin-ci-status/— Polls CI status for branches with custom REST API. Demonstrates: plugin routes,setIntervalpolling,onLoad/onUnloadlifecycle.
Plugin scaffold command: fn plugin create <name> generates a new plugin project with:
package.json,tsconfig.json,vitest.config.tssrc/index.tswith minimaldefinePlugin()callsrc/__tests__/index.test.tswith basic testREADME.mdtemplate
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 pointlabel: string— Human-readable labelicon?: string— Optional lucide-react icon namecomponentPath: 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
uiSlotsfrom all loaded plugins, returnsArray<{ 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 takesslotIdprop and renders matching plugin slots - usePluginUiSlots hook (
packages/dashboard/app/hooks/usePluginUiSlots.ts) — Fetches and caches UI slots with 60s TTL, providesgetSlotsForId(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-slotattributes
TUI Package (FN-1471)
The @fusion/tui package provides Ink-based React components for terminal UI.
Global Shortcuts Implementation:
useGlobalShortcutshook centralizes all keyboard shortcuts at the app root levelFocusGuardRefmodule-level ref tracks text input focus state for focus guarding- Shortcuts blocked when focused:
q,?,h,1-5(only whenFocusGuardRef.isFocused === true) Ctrl+Calways works (emergency exit)HelpOverlaycomponent displays shortcuts and handlesEscape/qto close
Focus Guard Pattern:
- Ink's
useFocusManagerdoesn't provide global "is anything focused" detection - Use module-level ref (
FocusGuardRef) for cross-component focus state - Text inputs should set
FocusGuardRef.isFocused = trueon focus andfalseon blur
Testing TUI Components:
- Use Ink's
render()function fromink/testingfor tests - Mock
useInputwithvi.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 withkimi-codingkey from~/.pi/agent/auth.json. - Response parsing: Supports
data.windows[]array and flatdata.used/total/remainingshapes.
FN-1516: Periodic Auto-Merge Sweep
- The
canAutoMergeTask()function must be defined locally insiderunDashboard()to work correctly with Vitest mocks. Module-level exports capture the realgetTaskMergeBlockerat import time, before mocks are applied. - When importing shared utilities from dashboard.ts in serve.ts, ensure both files define compatible predicates (same
mergeRetrieslimit 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 inApp.test.tsxforuseNodes,useRemoteNodeData,useRemoteNodeEvents, and theNodeContextmodule. - Clear
fusion-dashboard-current-nodefrom localStorage in testbeforeEachto avoid cross-test leakage. - Use
mockReturnValue(notmockReturnValueOnce) 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
returnafter successful recovery to prevent execution from continuing to the failure path - Vitest runs source files directly (
.ts) rather than compiled dist files - rebuild withtscbefore 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
loopRecoveryStateMap withattemptscounter - Explicit
returnafter successful recovery prevents fallthrough to failure path
Step-session executor (step-session-executor.ts executeStep loop):
- Recovery attempts tracked separately from
retriescounter viarecoveryAttemptsvariable - Recovery bounded to
MAX_STEP_RETRIESattempts 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
callCountto make first call throw and subsequent calls succeed - Use
vi.useFakeTimers()for retry delay handling in step-session tests vi.clearAllMocks()inbeforeEachto 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 viacreateKbAgent- no stale conversation state - Context-limit errors trigger compact-and-retry:
isContextLimitErrordetects overflow,compactSessionContextcompresses history, then retry - Non-context errors propagate immediately without compaction - no false-positive recovery attempts
- Error handling uses
err: unknowntype witherr 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, andsimplifiedContext: true - Guard:
truncatedRetryAttemptedflag 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 EXISTSto 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 PLANto 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_VERSIONconstant AND all hardcoded version assertions in tests (e.g.,expect(db.getSchemaVersion()).toBe(N)) - The
creates all expected indexestest indb.test.tsmust list all indexes including new ones - Memory pitfall: Test files like
run-audit.test.tsand__tests__/task-documents.test.tsalso 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 DESCfor deterministic tie-breaking - When splitting synthetic run IDs (e.g.,
"exec-FN-001-123-abc"), uselastIndexOf("-")to handle task IDs with dashes
Metadata normalization:
- Engine emitters always include
phasein metadata sourceis conditionally included only when provided- Database domain infers
taskIdfrom target when target looks like a task ID (FN-*,KB-*)
Backward compatibility:
createRunAuditor(store, null)returns no-op auditor- Store without
recordRunAuditEventmethod 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"inactions/checkoutandpnpm/action-setup - Use
actions/setup-node@v5withnode-version: "24"instead ofv4 - Node.js 20 actions are deprecated and will stop working June 2, 2026
Changesets configuration:
- Internal packages with
private: truemust be listed in.changeset/config.jsonignorearray - 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.jsonusingJSON.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()andrunServe()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.jsand./port-prompt.js(not dashboard.js) - The dashboard test imports helpers from
./task-lifecycle.jsandrunDashboardfrom./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 executionsskip— Return failed result without calling heartbeat if already runningreplace— Cancel existing execution, then run new onequeue— Wait for existing execution to complete, then run
Catch-up policy:
skip— UpdatelastTriggeredAtwithout additional executionscatchUp— 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()andgetRoutineRunner()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 0–5 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 5–20 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:
- Re-export the new export from types.ts to make it available via the alias, OR
- 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/eventsSSE 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
projectIdquery 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 loggingpackages/cli/src/commands/serve.ts— same diagnostics for headless node modepackages/dashboard/src/sse.ts— SSE high water mark tracking, res.on("close") safety netpackages/core/src/store.ts—healthCheck()method for database diagnostics
Verified Timer Cleanup
All major timers/intervals properly cleared on shutdown:
TaskStore.watch()pollInterval → cleared instopWatching()Scheduler.pollInterval→ cleared instop()HeartbeatMonitor.pollInterval→ cleared instop()HeartbeatTriggerScheduler.timers→ cleared instop()StuckTaskDetector.interval→ cleared instop()SelfHealingManager.maintenanceInterval,unpauseTimer→ cleared instop()MissionAutopilot.pollTimer,healthCheckTimer→ cleared instop()CronRunner.pollInterval→ cleared instop()PrMonitor.intervals→ cleared instopMonitoring()/stopAll()scheduleMergeRetry()→ guarded bydisposed/shuttingDownflags
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
- Watch for
rssgrowth over 24h — linear growth suggests memory leak - Watch for
handlesgrowth — growing handles suggest resource accumulation - Watch for listener count growth — especially
task:created,task:updated(SSE subscriptions) - Watch for
beforeExit code=0without preceding shutdown log (unexpected exit) db=failedindicates 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+ requestedSkillNamesacts as intersection filter on top of pattern-based selection (case-insensitive name matching)filterActive: falsemeans no filtering (all discovered skills pass through);filterActive: truemeans filtering is activecreateSkillsOverrideFromSelection()returns theskillsOverridecallback forDefaultResourceLoaderskillsOverrideis only set whenskillSelectionis provided inAgentOptions; omitting it preserves existing behaviorSkillSelectionResultincludesexcludedSkillPathsto track skills explicitly disabled by-patterns- Filtering distinguishes three cases:
- Allowed skills: skills matching
allowedSkillPathspass through - Disabled skills: skills matching
excludedSkillPathsare filtered out and produce warnings - Missing skills: configured paths not matching any discovered skill produce warnings
- Allowed skills: skills matching
- The
createSkillsOverrideFromSelectioncallback filters skills and produces diagnostic messages viaconsole.errorwith[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)andmockFiles.get(path)for read/writemockFiles.clear()inbeforeEachto reset state between testsvi.resetModules()when usingvi.doMockinside 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()inapi.ts(line 2176),withNodeId()(line 2162),useRemoteNodeData(),useRemoteNodeEvents() - Backend proxy routes are missing:
routes.tshas 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()opensEventSource("/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()innodeProjectAssignment.ts - Critical gap:
CentralCore.registerProject()does NOT acceptnodeId— must use separateassignProjectToNode()call
Background Services Not Wired
PeerExchangeServiceexists inpackages/engine/src/peer-exchange-service.tsbut is NOT instantiated inserve.tsordashboard.tsCentralCore.startDiscovery()exists but is NOT called in CLI commands- Peer exchange and mDNS discovery need to be wired in
InProcessRuntime.start()andrunServe()/runDashboard()
Dependency Chain for Cross-Node
- FN-1802 — Generic proxy route (
/api/proxy/:nodeId/*) — unblocks all remote viewing - FN-1806 — ✅ Implemented (FN-1833): 5 proxy routes in
routes.tswithproxyToRemoteNode()helper. JSON routes:/health,/projects,/tasks,/project-health. SSE route:/events(30s timeout, client disconnect cleanup). Auth viaBearerheader whenapiKeyset. Filters hop-by-hop headers. - FN-1803 — Node-aware project registration and directory browsing
- FN-1804 — Frontend node selector for project creation
- FN-1805 — Wire peer exchange and discovery in runtimes
- FN-1736 — Comprehensive project scoping review (SSE/WebSocket filtering)
FN-1806 implementation notes:
- Routes must be placed BEFORE
return router;increateApiRoutes— TypeScript's scope analysis requires helper functions to be defined before they're used at the same scope level - Use
async functiondeclarations (not arrow functions) for the route handlers to ensure proper TypeScript scope resolution - For mock fetch responses in tests: always use
ReadableStreamfor the body — never returnnullfor body, or the streaming pipe won't work proxyToRemoteNodehelper usesnew 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()withcolumn: "in-progress",missionId,sliceId, andstatus: "mission-validation" - Task lifecycle:
in-progress→done(validation passes)in-progress→in-review(validation fails, blocked, or errors)
- Status marker:
status: "mission-validation"prevents scheduler/stuck-detector from dispatching the task - Validator run linkage:
MissionValidatorRun.taskIdstores the board task ID for cross-referencing - VALID_TRANSITIONS:
"in-progress"→"done"was added specifically for validation tasks (note intypes.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.ts—closedflag on Channelpackages/dashboard/app/hooks/useTasks.ts—activeflag in SSE effect- Tests:
packages/dashboard/app/__tests__/sse-bus.test.ts,packages/dashboard/app/hooks/__tests__/useTasks.test.ts