Files
fusion/AGENTS.md
Fusion (runfusion.ai) 46cd63796f docs(FN-4976): add reliability backstop coverage entry
Fusion-Task-Id: FN-4976
Fusion-Task-Lineage: b7d6e778-f6b1-4529-b04b-81352a11ddad
2026-05-17 19:21:42 -07:00

36 KiB
Raw Blame History

Project Guidelines

STANDING DIRECTIVE: Buttons Are Frozen (2026-05-13)

Do not file, plan, or implement tasks that adjust button mobile-responsiveness, touch-target sizing, or mobile reflow of header/action button rows anywhere in the dashboard (TaskCard, SettingsModal, ChatView, MissionManager, AgentsView, FAB, etc.). Keep buttons as they are.

This supersedes earlier guidance about mobile touch targets, primary/secondary control sizing on mobile, and .touch-target minimums for buttons. The Frontend UX Design workflow step (WS-006) is disabled and must stay disabled.

If you find yourself opening SettingsModal.css, TaskCard.css, ChatView.css, etc. inside an @media (max-width: 768px) block to touch a .btn, .modal-close, .settings-header-actions, or .card-* button — stop. Confirm with the user in chat before proceeding.

Exception: explicit named user request in chat that overrides this directive.

Finalizing Changes

When a change affects the published @runfusion/fusion package, add a changeset:

cat > .changeset/<short-description>.md << 'EOF'
---
"@runfusion/fusion": patch
---

Short description of the change.
EOF

Bump types: patch (bug fixes / internal), minor (new features / CLI / tools), major (breaking). Commit the changeset alongside the code change.

Do NOT create changesets for internal docs (AGENTS.md, README), CI config, or behavior-preserving refactors. The other workspace packages (@fusion/core, @fusion/dashboard, @fusion/engine) are private — no changesets for them.

Releasing

Always use the repo release script:

pnpm release --yes

scripts/release.mjs is the source of truth: preflight, apply changesets, update lockfile + root changelog, build, commit, publish, push main, push tag. Do not run changeset version, pnpm publish, or manual git tags as a substitute. The script also keeps @runfusion/fusion and runfusion.ai in sync.

Package Structure

  • @fusion/core — domain model, task store (private)
  • @fusion/dashboard — web UI + API server (private)
  • @fusion/engine — triage, executor, reviewer, merger, scheduler (private)
  • @runfusion/fusion — CLI + pi extension (published to npm)

Only @runfusion/fusion is published. The others get inlined into the CLI bundle via tsup noExternal: [/^@fusion\//].

Importing across @fusion/* packages

For the inlining to work, @fusion/* imports must be statically analyzable. The following anti-pattern silently breaks the published CLI (FN-2613, Runfusion/Fusion#9):

// ❌ BROKEN: variable specifier defeats static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);

esbuild leaves the dynamic import in the output, the package isn't installed at runtime, the catch swallows the error, and downstream calls fail with createFnAgent2 is not a function.

Rules:

  1. Default to static imports: import { createFnAgent } from "@fusion/engine".
  2. Exception: @fusion/core cannot statically import engine (circular). Core uses DI via setCreateFnAgent in packages/core/src/ai-engine-loader.ts, called from packages/engine/src/index.ts. Don't add new dynamic import("@fusion/engine") in core — extend the loader.
  3. Never reintroduce the engineModule = "@fusion/engine" trick. Treat any sighting as a bug.
  4. vi.mock("@fusion/engine", …) hoists above static imports — mocking still works.

Storage Model

Hybrid: structured metadata in SQLite (.fusion/fusion.db, WAL mode), large blobs (PROMPT.md, attachments) on disk under .fusion/tasks/{ID}/. See docs/storage.md.

Multi-Project Support

Central registry at ~/.fusion/fusion-central.db; per-project DB at .fusion/fusion.db. See docs/multi-project.md for CentralCore API, isolation modes, and global concurrency.

Testing

Tests are required. Typechecks and manual verification are not substitutes for assertions.

Use the narrowest command that exercises the behavior you changed, then broaden before reporting completion.

pnpm test              # changed-only workspace tests; falls back to full gate in safety contexts
pnpm test:full         # full workspace quality gate
pnpm lint              # lint all packages
pnpm build             # build workspace packages (excludes desktop/mobile)
pnpm verify:workspace  # canonical pre-merge gate: lint -> test:full -> build

pnpm test:full runs each package's default test script with capped worker fanout (FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use VITEST_MAX_WORKERS=<n> only for targeted package-level investigation.

Fresh-worktree dist bootstrap

pnpm test auto-runs scripts/ensure-test-artifacts.mjs to rebuild missing/stale dist artifacts. Dashboard and dependency-graph package lanes auto-bootstrap too. If you hit opaque Failed to resolve import "./cli-spawn.js" (or similar), treat it as bootstrap regression against FN-4605 — don't work around with a manual pnpm build.

Dashboard Test Lanes

pnpm --filter @fusion/dashboard test                # curated app/API quality gate (default)
pnpm --filter @fusion/dashboard test:deep           # exhaustive app + API suite
pnpm --filter @fusion/dashboard test:app            # exhaustive React/jsdom
pnpm --filter @fusion/dashboard test:api            # exhaustive Node API/server
pnpm --filter @fusion/dashboard test:browser-smoke  # local browser CSS/layout smoke
pnpm --filter @fusion/dashboard test:build          # built client output contract

Run test:deep when changing broad dashboard architecture, shared modal/view infrastructure, or route registration. Run test:browser-smoke for layout/responsive/navigation/modal/CSS changes. Run test:build for Vite output, lazy-loading, chunking, or client-dist changes.

When adding a new test file under app/components/__tests__, also add its basename to qualityAppTests in packages/dashboard/vitest.config.ts — otherwise the curated gate silently skips it.

Targeted commands

pnpm --filter @fusion/core test
pnpm --filter @fusion/engine test
pnpm --filter @runfusion/fusion test
pnpm test:scripts
node --test scripts/__tests__/*.test.mjs

For a single Vitest file, use package-local exec vitest:

pnpm --filter @fusion/core exec vitest run src/__tests__/central-db.test.ts --silent=passed-only --reporter=dot

Engine test helper convention

packages/engine/src/__tests__/executor-test-helpers.ts defaults both isUsableTaskWorktree to true and classifyTaskWorktree to { ok: true } via a helper-level worktree-pool mock. To test failure paths, override with vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValueOnce({ ok: false, classification: "unregistered", reason: "..." }) (or isUsableTaskWorktree for legacy call sites). Production liveness assertions in executor.ts are unchanged.

Before Reporting Done

  • Code changes: affected package tests + any directly relevant browser/build lane.
  • Cross-package, shared test infrastructure, or CI changes: pnpm test:full.
  • Production/bundling-sensitive changes: pnpm build.
  • Substantial work: pnpm verify:workspace.
  • If you skip a relevant lane, say why.

Test File Organization

Test for src/foo.tssrc/__tests__/foo.test.ts. Test for app/components/Bar.tsxapp/components/__tests__/Bar.test.tsx. __tests__/ is the standard.

What NOT to write

Tests should cover behavior a user could notice break, not implementation shape. Don't write:

  • CSS-class permutation tests — use one it.each for the boolean matrix, not one it per combination.
  • Field-presence tests when a payload-roundtrip test already exercises the same field.
  • React.memo tautologies — testing React.memo tests React, not us. Test custom comparators directly, one case.
  • Mock-the-world wiring tests — if a test mocks 8+ deps just to render a component, shim children with () => null or delete and rely on an integration test one level up.
  • Structural CSS assertions — "tab uses .class-name not inline style". Consolidate into one aggregate layout-contract test per component.

Prefer it.each over copy-pasted it() blocks. When trimming, keep: first case + opposite case + any precedence/override case.

What TO keep unconditionally

  • Tests linked to an FN-ticket in describe/it names — these guard real regressions.
  • Integration tests exercising real SQLite, real worker pool, or spawned processes.
  • Lean core/engine unit tests with low mock burden.

Port 4040 is Reserved

Port 4040 is the production dashboard port. A user's live session is typically running there. Agents must NEVER:

  • Run kill, kill -9, pkill, or killall against processes on port 4040.
  • Start a test server on port 4040 — always use --port 0 for a random free port.

Architecture invariants

Detailed mechanism logs live in docs/architecture.md and docs/design/. The contracts agents must respect:

  • Orphan fusion/* branches: prune-or-rescue, never force-delete. Subsumed branches pruned; unique-commit branches rescued into triage tasks.
  • Stale active branches: self-healing's reclaim-stale-active-branches stage prunes a fusion/<task-id> branch with zero unique commits when no usable worktree mapping exists, then clears task.branch/task.worktree/task.baseCommitSha. It must defer reclaim (emit branch:stale-active-reclaim-deferred) when the task worktree is in activeSessionRegistry, when executionStartedAt is within STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS (10 minutes), or when the mapped worktree has uncommitted changes.
  • Worktree metadata reconcile ordering (FN-4962): reconcile-task-worktree-metadata must run before reclaim-stale-active-branches; stale task.worktree metadata is rebound to live fusion/<task-id> worktrees when present (task:auto-recover-worktree-metadata-rebound) or cleared (task:auto-recover-worktree-metadata-cleared) when absent.
  • Completion fan-out is synchronous: SelfHealingManager.reconcileCompletedTask() runs on in-review → done. Downstream stale blockedBy links and residual fusion/<task-id> branch/worktree artifacts are reconciled immediately, not on a periodic sweep.
  • In-review stall deadlock: identical stalls (same code + reason) repeated past inReviewStallDeadlockThreshold (default 3) auto-pause with pausedReason: "in-review-stall-deadlock" and status: "failed".
  • Restart recovery: RestartRecoveryCoordinator classifies interrupted in-progress runs. Unusable-worktree session-start failures (missing, incomplete, unregistered git worktree) are recoverable; retries are capped at MAX_WORKTREE_SESSION_RETRIES=3 before escalating.
  • Executor pre-session liveness gate (FN-4935): the gate now skips for fresh acquisitions (acquisition.source === "fresh"), emits structured not_usable_task_worktree:<classification> diagnostics (including canonicalized registered-path snapshots) and a worktree:incomplete-detected audit event with source: "executor-liveness-gate", while preserving the existing taskDoneRetryCount / MAX_TASK_DONE_REQUEUE_RETRIES requeue contract. FN-4651 worktreeSessionRetryCount remains scoped to the in-review/session-start recovery path.
  • Stale self-owned active-session reconcile on conflict cleanup (FN-4973): when executor worktree-conflict cleanup finds only a same-task stale activeSessionRegistry entry and no live in-memory activeWorktrees binding for that task/path, it must unregister the stale entry before removeWorktree (plus one-shot backstop reconcile on same-task ActiveSessionWorktreeRemovalError races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task.
  • Task title/ID drift (FN-4898): active and archived title writes normalize foreign embedded FN-NNN tokens via packages/core/src/task-title-id-drift.ts. Lineage is preserved in sourceParentTaskId / description markers, not title embeds.
  • PR-conflict reclaim wiring (FN-4763): GitHub PR refresh now persists normalized prInfo.mergeable conflict state and, when conflicting, funnels tasks into self-healings existing reclaim machinery (reclaimPrConflictForTask / reclaim-pr-conflicts stage) so branch-conflict handling stays centralized with existing inspectBranchConflict outcomes and unrecoverable pause semantics.
  • Worktrunk-managed lifecycles: when worktrunk.enabled, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
  • Post-finalize verification no-op (FN-4944): when auto-merge receives a delayed VerificationError after a task is already done with mergeDetails.mergeConfirmed === true (already-on-main fast-path), it must log one [verification] ... no action diagnostic and must not bounce the task back to in-progress / merging-fix.

Engine Process Rules

The engine runs the executor, merger, scheduler, IPC host, and dashboard activity loop on a single Node event loop. Blocking that loop stalls every task in-flight.

Never use execSync for User-Configured Commands

Any command from project settings — testCommand, buildCommand, workflow step scripts — must run via promisify(exec) with a timeout:

import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const { stdout, stderr } = await execAsync(command, {
  cwd: worktreePath,
  timeout: 120_000,
  maxBuffer: 10 * 1024 * 1024,
});

execSync is only acceptable for short deterministic git plumbing (git rev-parse, git branch -d, git worktree remove). User-configured command wiring lives under packages/engine/src/sandbox/ (FN-4636 seam); keep internal git plumbing on direct async exec.

Move-Task contract

User-initiated moveTask(in-progress → todo) is a hard cancel: executor listeners must abort active sessions before dispose, stop step/workflow subprocesses, and leave the task parked in todo with userPaused semantics intact. Engine-initiated rebounds (pause, stuck recovery, workflow rerun, self-healing) use default moveSource: "engine" plus the appropriate preserve* flags and must not set userPaused.

Git Conventions

  • Commit messages: feat(FN-XXX):, fix(FN-XXX):, test(FN-XXX):
  • One commit per step (not per file change)
  • Always include the task ID prefix

Merging Branches Into Main

Hard-won rules (FN-2370 silently reverted three commits' worth of work):

  1. Drop duplicate commits before merging. If a branch contains commits that duplicate work already on main, rebase to drop them. Auto-resolvers cannot tell which side is canonical and will silently discard refinements. git log main..branch --format=%s should not overlap with git log <base>..main --format=%s.
  2. Rebase over squash for multi-commit branches. Fusion's direct merger defaults directMergeCommitStrategy="auto": squash for 01 substantive commits, history-preserving rebase/cherry-pick otherwise. Force via project setting or **Direct Merge Commit Strategy:** auto|always-squash|always-rebase in PROMPT.
  3. Empty cherry-picks are no-ops. Cherry-pick merges treat git's empty-pick signatures as "already on main" — empty commits skipped, fully-subsumed branches auto-complete, no empty commit created.
  4. Already-on-main classifier. Verification-fix finalize and self-healing both recover when a task's lineage is already landed (emits task:auto-recover-finalize-already-on-main, task:auto-recover-branch-misbound).
  5. Contamination auto-recovery. When every foreign-attributed commit is upstream by patch-id, the executor drops them and requeues. A second contamination event escalates to paused human adjudication. FN-4499 adds a bootstrap-misbinding safety branch (foreign-only attribution → reanchorBranchToBase + requeue) before the contamination classifier. FN-4887 adds a self-healing foreign-only sweep for in-review/paused tasks, with bounded auto-recovery only when ownCommitCount === 0, nonAttributedCount === 0, and every foreign commit is attributable by subject or Fusion-Task-Id trailer; this emits task:auto-recover-foreign-only-contamination / task:auto-recover-foreign-only-contamination-skipped and leaves ambiguous cases to manual recovery (FN-4860/FN-4875 boundary).
  6. Post-squash audit on auto-resolved conflicts. postMergeAuditMode: warn (default), block (refuse on findings), off. Rebase-strategy overlap-only findings auto-clear when deterministic verification has proven the merged tree. When findings still block, the mergeAuditAutoRecovery pipeline runs (Stages 15: deterministic → programmatic → ai-assisted → bounded retries → park-with-follow-up).
  7. Pre-commit diff-volume gate. Before writing an auto-resolved squash commit, the merger compares each file's staged squash delta against branch net delta vs merge-base. Non-allowlisted files losing too much branch volume block the merge in in-review. Guard against FN-3936-style silent drops.
  8. Smart-prefer-main overlap guard. When mergeConflictStrategy="smart-prefer-main", recent main commits (30-commit lookback) overlapping branch-modified files flip to prefer-branch by default (mergeStrategyOverlapBehavior="flip-to-prefer-branch").

Gitignored-path guard on squash merges

The merger strips gitignored paths from staged squash sets before commit (standard, Attempt 3 fallback, and verification-fix rebuild). Any staged ignored path is unstaged and logged.

Agents must never git add -f .fusion/... or force-add any ignored scratch artifact. Findings, diagnosis, and test-plan notes belong in task documents via fn_task_document_write, not committed files.

File-Scope invariant on squash merges

Every squash commit path enforces a file-scope invariant immediately before commit: the staged set must overlap the task's declared ## File Scope from PROMPT.md. Zero overlap with a non-empty scope throws FileScopeViolationError, resets pre-squash state, and parks the task in in-review.

Per-task opt-out: task.scopeOverride = true (log task.scopeOverrideReason when set). Empty scopes are not enforced.

File Scope entries are validated when PROMPT.md is written — non-path tokens (git refs, URLs, SHAs, bare identifiers) are rejected with InvalidFileScopeError.

Manual audit script

For post-incident inspection: node scripts/audit-squash-merge.mjs <squash-sha>. Review every flagged item yourself — for each duplicate-cherry-pick subject, diff the matching main commit against HEAD and confirm survival. Restore any silent drops on the same branch before reporting merge complete.

Pi Extension (packages/cli/src/extension.ts)

The pi extension ships as part of @runfusion/fusion and provides tools + a /fn command for chat agents.

Update when:

  • CLI commands change (behavior, flags, output)
  • Task store / Agent store API changes
  • New user-facing features chat agents should be able to use

Don't add tools for engine-internal operations (move, step updates, logging, merge) — those are owned by the engine's own agents.

The extension has no skills — tool descriptions give the LLM everything it needs.

fn_web_fetch

Lightweight URL read from agent/chat sessions. HTTP GET, follows redirects, extracts readable text (HTML→text and JSON pretty-print), bounded.

Universal baseline: available by default across executor, step-session, reviewer, merger, triage, and heartbeat (including engineer/custom direct-report paths). Gated under the network_api action-gate category (FN-4603).

  • Defaults: timeoutMs=30000, maxBytes=512000 (500 KB)
  • Blocks private/loopback/link-local hosts (including DNS-resolved) unless explicitly overridden in internal/test contexts
  • Read-only (no JS rendering, no auth flows, no POST/cookie workflows)
  • Use the agent-browser skill when JS rendering or interactive navigation is required

Agent Coordination Tools

Seven coordination tools support spawning, provisioning, discovery, delegation, and direct-report config. Detailed parameter contracts live in tool descriptions and docs/agents.md.

  • spawn_agent — Parent-task-scoped ephemeral child in its own worktree. Limits via maxSpawnedAgentsPerParent (default 5) and maxSpawnedAgentsGlobal (default 20). Auto-terminated with parent. Gated under generic task_agent_mutation (FN-3973 explicitly excludes it from durable agentProvisioning policy).
  • agent_create / agent_delete — Non-ephemeral provisioning of direct reports. Policy-gated via projectSettings.agentProvisioning (approvalMode, trustedRoles, trustedAgentIds, alwaysApproveDelete). Tool responses use details.outcome: created / deleted / pending_approval / denied. Pending requests resolve via POST /api/approvals/:id/decision. Audit events: agent:{create,delete}:{requested,approved,denied}.
  • list_agents — Discovery with role/state/includeEphemeral filters.
  • delegate_task — Create + assign task to a specific agent. Implementation tasks require executor-role target unless override: true. Cannot target ephemeral agents (use spawn_agent).
  • get_agent_config / update_agent_config — Read/write soul, instructions, heartbeat interval/timeout, max concurrent runs, message response mode. Authorization: caller can only act on agents where target.reportsTo === caller.id. Cannot operate on ephemeral agents.

Checkout Leasing

  • 409 Conflict = ownership contention. Response: { error, currentHolder, taskId }. Never auto-retry 409.
  • HeartbeatMonitor.executeHeartbeat() validates checkout before work begins; mismatched checkedOutBy exits with reason: "checkout_conflict". Heartbeat does not auto-checkout — callers obtain the lease.
  • With CentralClaimStore wired, the authoritative owner is the central taskClaims row; per-project lease fields mirror it. MeshLeaseManager.recoverAbandonedLease() releases central first then local. reconcileLeaseRow(taskId) converges divergent state on the next tick (emits task:auto-recover-lease-*). Without a claim store, behavior remains single-node per-project.

Agent Runtime Config

Per-agent overrides via runtimeConfig:

  • Heartbeat: heartbeatIntervalMs, heartbeatTimeoutMs, maxConcurrentRuns. Triggered by timer, task assignment, or on-demand (POST /api/agents/:id/runs).
  • Budgets: per-agent token budget tracking; HeartbeatMonitor.executeHeartbeat() skips when isOverBudget or isOverThreshold (timer triggers). Hard caps pause the agent.
  • Performance ratings: 15 scale with trend analysis, injected into system prompts.

See docs/agents.md for the full contract.

Settings

Two tiers: global (~/.fusion/settings.json) overridden by project (.fusion/config.json). Configure via dashboard Settings modal or fn settings. Full reference: docs/settings-reference.md.

Model selection hierarchy

All three lanes (planning / executor / reviewer) follow the same 5-tier precedence:

  1. Per-task override (planningModelProvider/Id, modelProvider/Id, validatorModelProvider/Id)
  2. Project lane (planningProvider/Id, executionProvider/Id, validatorProvider/Id)
  3. Global lane (planningGlobalProvider/Id, executionGlobalProvider/Id, validatorGlobalProvider/Id)
  4. Project defaultProviderOverride / defaultModelIdOverride
  5. Global defaultProvider / defaultModelId → automatic resolution

Per-task token budget precedence

  1. task.tokenBudgetOverride
  2. Project taskTokenBudget.perSize[task.size]
  3. Project taskTokenBudget.soft/hard
  4. Global taskTokenBudget.perSize[task.size]
  5. Global taskTokenBudget.soft/hard

Hard cap → pause with pausedReason: "token_budget_exceeded". Soft cap → one-shot alert per task.

Model presets

Standardize executor/validator pairs; auto-selectable by task size (Small → Budget, Medium → Normal, Large → Complex). See settings reference.

Missions

  • Autopilot — watches task completion and activates the next slice. States: inactive → watching → activating → completing. See docs/missions.md.
  • Planning context — feature → task triage enriches descriptions with full mission → milestone → slice → feature hierarchy.
  • Planning toolsfn_mission_create, fn_milestone_add/update, fn_slice_add/activate, fn_feature_add/update/link_task. fn_milestone_update and fn_feature_update accept partial patches.

Workflow Steps

Reusable quality gates at configurable lifecycle phases. Pre-merge can block; post-merge is informational. gateMode is gate (failure blocks merge/remediation) or advisory (records advisory_failure, no block, no auto-revive). Defined as prompt (AI review) or script (deterministic command). See docs/workflow-steps.md.

Run Audit

Every engine mutation is recorded across four domains:

  • Database — task:create, task:update, task:move, room:ambiguity:branch (deictic message routing telemetry), etc.
  • Git — worktree:create, commit:create, merge:resolve, etc.
  • Filesystem — file:write, prompt:write, attachment:create, secret:read|create|update|delete|approval-requested|approval-granted|approval-denied|sync-push|sync-pull, secret:env-*, etc.
  • Sandboxsandbox:prepare, sandbox:run, sandbox:failure, sandbox:fallback.

Events are tied to run IDs end-to-end. See docs/architecture.md for the audit API.

Archive Cleanup

Archived tasks can be cleaned up while preserving metadata. Restored tasks keep metadata but lose attachments and agent logs. See docs/task-management.md.

Secrets

AES-256-GCM-encrypted storage in project (secrets) and global (secrets_global) scopes. Per-secret access policy (auto/prompt/deny) resolved as row → global default → "prompt". Master key via the core MasterKeyProvider abstraction. See docs/secrets.md for current capabilities and the pending agent-tool wiring (FN-4867).

Node Dashboard

Mesh-network node management UI. Settings/auth/secrets sync endpoints documented in docs/architecture.md. All remote endpoints require the target node's apiKey; inbound endpoints validate Authorization: Bearer <apiKey>.

Headless Node Mode (fn serve)

Starts API server + AI engine without a frontend. Binds 0.0.0.0 by default. Health endpoint + startup banner in docs/architecture.md.

Terminal UI

The Ink-based TUI is part of fn (no separate @fusion/tui package). Implementation: packages/cli/src/commands/dashboard-tui/.

Engine Diagnostic Logging

Structured logging via createLogger() from packages/engine/src/logger.ts. All lines prefixed with subsystem name. See docs/diagnostics.md for the full key-diagnostic-points catalog. Notable subsystems include [executor], [scheduler], [stuck-detector], [auto-claim-snapshot], [prompt-size], [wake-trigger-diagnostics], [retry-burned], and [room-ambiguity].

AgentSemaphore (packages/engine/src/concurrency.ts) has defensive guards: limit getter returns minimum 1; availableCount returns 0 for invalid limits.

Dashboard UI Styling Guide

The dashboard's CSS is split into a global stylesheet (packages/dashboard/app/styles.css) and per-component files (packages/dashboard/app/components/ComponentName.css). Each ComponentName.tsx imports its stylesheet at the top.

Rule: New CSS for a component goes in app/components/ComponentName.css, NOT styles.css. Only design tokens, primitives (.btn, .card, .modal, .form-input), and cross-component @media overrides belong in the global file.

The index.html shell is templated server-side: the server injects a per-user <link rel="modulepreload"> for the last-used taskView chunk, sourced from Vite's dist/client/.vite/manifest.json and kb:<projectId>:kb-dashboard-task-view in localStorage.

Design tokens

styles.css is the source of truth for tokens (--space-*, --radius-*, --shadow-*, --transition-*, --font-*, --header-height, --mobile-nav-height, --standalone-bottom-gap, --overlay-padding-top) and color variables (--bg, --surface, --card, --text, --text-muted, status colors --triage/--todo/--in-progress/--in-review/--done, semantic --color-success/--color-error/--color-warning/--color-info, status backgrounds --status-*-bg).

Always reference tokens. Never hardcode pixels, hex, or rgba() in component CSS — the only exception is inside :root/theme blocks where tokens are defined. For translucent backgrounds use color-mix(in srgb, var(--color) X%, transparent), not rgba().

Theme system

Dark/light modes via data-theme; 54 color themes via data-color-theme (lazy-loaded from app/public/theme-data.css).

  • Base tokens (--bg, --surface, etc.) — redefine in :root, [data-theme="light"], and every theme block.
  • Semantic tokens (--autopilot-pulse, --event-error-text, --badge-mission-*, --fab-*) — :root + [data-theme="light"] only; no per-color-theme overrides.
  • Status tokens (--triage, --todo, etc.) — redefine per theme block.

status-colors-theme.test.ts iterates all theme blocks to catch regressions.

Component classes

Reuse existing primitives from styles.css:

  • Buttons: .btn, .btn-primary, .btn-danger, .btn-warning, .btn-sm, .btn-icon, .btn-icon--active, .btn-badge. All inherit :focus-visible via --focus-ring-strong and :active via transform: scale(0.97).
  • Modals: .modal-overlay[.open], .modal, .modal-lg, .modal-header, .modal-close, .modal-actions, .modal-actions-left/right. Overlay pads top with --overlay-padding-top.
  • Forms: .form-group, .input, .select, .checkbox-label, .form-error. Inputs in .form-group get focus styles automatically.
  • Cards: .card, .card-header, .card-id, .card-title, .card-meta, .card-status-badge--{triage,todo,in-progress,in-review,done,archived}.
  • Utility: .touch-target (44px min), .visually-hidden.

Don't create parallel button/form variants — add states (:hover, :focus-visible, :active) to the existing primitives.

Mobile responsive

Breakpoints: 768px (primary mobile), 1024px (tablet min-width: 769px and max-width: 1024px), 640px (compact), 480px (xs). Mobile overrides go in @media (max-width: 768px) blocks at the bottom of styles.css after base styles.

Bottom spacing: --mobile-nav-height (44px) + env(safe-area-inset-bottom, 0px) + --standalone-bottom-gap (0/8px PWA). All bottom-positioned mobile elements compose those.

Touch targets: Standing button-freeze directive supersedes per-button touch-target guidance. For non-button elements, primary controls (nav bar, FAB, tab action rows, modal CTAs, list-row tap targets, form controls) must be ≥36px on mobile. Secondary controls inside a card/list-row where the row itself is the tap target stay compact (2428px or small chips).

Safe area: max(var(--space-md), env(safe-area-inset-left, 0px)) for notch-aware horizontal padding.

Lazy-Loaded Heavy Views

These 18 views are lazy-loaded via React.lazy() with <Suspense fallback={null}>. prefetchLazyViews() warms chunks once on mount via requestIdleCallback. Do not make these eager.

  • AgentsView
  • NodesView
  • ChatView
  • MemoryView
  • DevServerView
  • InsightsView
  • DocumentsView
  • SkillsView
  • ResearchView
  • ReliabilityView
  • EvalsView
  • TodoView
  • GoalsView
  • StashRecoveryView
  • SetupWizardModal
  • PluginManager
  • PiExtensionsManager
  • AgentDetailView

When adding or removing entries, update packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts (expected set + count).

CSS testing

Use packages/dashboard/app/test/cssFixture.ts:

import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture";
const allCss = await loadAllAppCss();          // styles.css + all component .css
const baseOnly = await loadAllAppCssBaseOnly(); // strips @media/@supports

Never directly readFileSync('../styles.css') — an ESLint rule (no-restricted-syntax in eslint.config.mjs) bans this and points at cssFixture.ts. vitest.config.ts has test.css: { include: [/.+/] } so component CSS imports inject into jsdom for getComputedStyle assertions.

File browser editor & autosize textarea

  • FileEditor.tsx is CodeMirror 6-only (no <textarea> fallback). Language resolution: packages/dashboard/app/utils/codemirror-language.ts.
  • For chat-style composer fields use packages/dashboard/app/hooks/useAutosizeTextarea.ts. Pattern: height = "auto" then clamp scrollHeight to min/max in useLayoutEffect. Pair with resize: none and overflow-y: auto.

Reuse packages/dashboard/app/utils/filePathLinkify.tsx and FileBrowserContext. Wrap plain text with linkifyFilePaths(...), mixed JSX with linkifyReactChildren(...). Mount under FileBrowserProvider and route clicks through its openFile(path, { workspace?, line?, col? }).

Common pitfalls

  • --surface-hover undefined — reference with a fallback (var(--surface-hover, rgba(0,0,0,0.03))) or define explicitly.
  • BEM specificity — when a container state class and an element modifier target the same node, the container can win. Use :not(.modifier) to scope.
  • CSS @media detection — track brace depth to confirm a rule is mobile-scoped; don't scan backwards for the nearest @media. Many components are global even if visually mobile-only.
  • Mobile board scroll-snap (FN-001)scroll-snap-type: x mandatory on mobile .board causes iOS Safari to compress the viewport when switching from ListView. Use x proximity + overflow-anchor: none.
  • lucide-react icon adds — update vi.mock("lucide-react") test mocks immediately; missing exports cascade.
  • .spin is global — don't redefine the generic spin keyframes in component CSS.

Reliability Mechanism Coverage

Reliability-layer changes are in scope. Interaction regression backstops live in packages/engine/src/__tests__/reliability-interactions/ — any task that adds or changes a reliability layer must add/update interaction tests there covering each plausible pair with existing layers (merge path, workflow/pre-merge, self-healing, scheduler/watchdog/restart recovery, governance gates).

  • FN-4935 backstop: packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts guards fresh-acquisition skip behavior, structured liveness classifications, and executor-gate audit/requeue outcomes.
  • FN-4887 backstop: packages/engine/src/__tests__/reliability-interactions/foreign-only-contamination-recovery.real-git.test.ts covers composition between bootstrap-misbinding, contamination dispatcher retry, misbound-in-review ordering, and FN-4811 active-session safeguards.
  • FN-4976 backstop: packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts guards cleanupConflictingWorktree clearing stale same-task activeSessionRegistry entries before the FN-4811 foreign-owner check, while preserving refusal behavior for foreign owners and live same-task bindings.

The auto-recovery dispatcher at packages/engine/src/auto-recovery.ts (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, mergeAuditAutoRecovery Stages 15, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, branch-conflict-unrecoverable residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.