Root cause of "the TUI keeps rendering after I get my terminal back": on
quit, dispose() called logSink.releaseConsole() (re-pointing console.* at
the real terminal) and then tui.stop() left the alt-screen and restored the
user's shell. Every log line from the slow engine/mesh/dev-server teardown
that followed then painted over the recovered prompt.
dispose() now calls a new logSink.silence() instead, which drops all sink
and console.* output from quit through process exit. Shutdown-step
diagnostics (timeShutdownStep + the watchdog stall line) are gated behind
FUSION_DEBUG_SHUTDOWN so a normal quit is pristine; the 3s hard-exit
watchdog still guarantees the process dies.
Adds a silence() regression guard to log-sink.test.ts asserting sink
methods and captured console.* both go silent across surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each graceful-shutdown teardown step (dev servers, hybrid executor,
engine manager, peer exchange, mesh, central-core) now runs through
timeShutdownStep, which records the in-flight step name. A hang leaves
that name set, so the hard-exit watchdog reports the exact culprit on
stderr before force-exiting — no repro needed. Per-step timings print to
stderr under FUSION_DEBUG_SHUTDOWN=1; otherwise only steps slower than
1s are surfaced. Folds the per-step try/catch into the wrapper so a
throwing step logs and continues instead of stranding the process.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pressing q/Ctrl+C in the TUI routes through SIGINT so the dashboard's
graceful shutdown runs (kills dev-server process groups, engines, mesh,
central-core). That shutdown awaits several teardown steps with no
timeout, so a single hung step left process.exit(0) unreachable: the
process never exited and the still-alive dashboard kept writing output
onto the restored shell. The shutdownInProgress guard also swallowed
repeat signals, so mashing q could not escape.
Both shutdown() and devShutdown() now arm an unref'd 3s hard-exit
watchdog on the first signal and force an immediate process.exit(0) on a
second signal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- merger-ai: resolve+persist concrete landedSha when a sub-repo is recognized
already-landed via the Fusion-Task-Id trailer fallback, so finalize no longer
drops it and mis-finalizes a fully-landed workspace task as a no-op
- project-engine: manual-merge land-lease busy errors reject the resolver without
burning mergeRetries; clear stale busy-reenqueue counter on real partial land;
persist retry count before arming the backoff timer (fail closed on write error)
- cli/dashboard + task: use shared isWorkspaceTask predicate instead of inlining
- base-commit-capture: POSIX single-quote shell escaping for integration ref
- git-repository: validate workspace.json repos elements are strings
- merger-ai: drop dead store param from landOneRepo
- tests: assert the 60s backoff cap across cycles; exercise the real runAiMerge
merge door; fix non-git-root assertion; re-export real workspace error classes
in the merger-ai mock (fixes 24 pre-existing instanceof-undefined failures);
remove generic fake-timer smoke test now covered by the live engine assertion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5-persona review of the Phase-C per-repo merge loop. No P0; the no-push invariant
and retry/park accounting verified clean. Fixed:
Land mechanics (merger-ai.ts / active-session-registry.ts):
- persistRepoLandedSha no longer swallows the DB write: a failed landedSha write
after the ref advanced now escalates to WorkspacePartialLandError so the engine
parks/retries instead of silently re-landing (duplicate squash). isRepoLanded
gains a landedSha-independent fallback — it scans the integration ref for this
task's Fusion-Task-Id trailer (a squash commit is NOT a branch descendant, so a
branch-ancestor check is provably wrong), so an actually-landed repo is skipped
on retry.
- The land lease is now taskId-aware across kinds: any foreign-task holder on a
sub-repo path is contention (a merging task can't run over an executing task's
acquire lease), and registerPath throws ActiveSessionPathHeldByForeignTaskError
instead of silently clobbering a different task's entry.
- The per-repo loop is wrapped in try/finally(setStatus(null)) so the busy/partial
throws can't leave the task stuck 'merging'. WorkspacePartialLandError is a real
exported class (not a .name-mutated Error). finalizeWorkspaceTask re-reads fresh
and no longer swallows the mergeDetails write (TOCTOU). isRepoLanded exported for
Phase D.
Dispatch + doors (project-engine.ts / dashboard.ts / task.ts / @fusion/core):
- getTask-null in the partial-land catch fails closed (park) instead of defaulting
retries to 0 and scheduling an indefinite retry storm.
- The merge-confirmed reachability fast-path skips workspace tasks (its
representative commitSha is a sub-repo squash sha, unreachable in the root cwd —
it was demoting fully-merged tasks); they're verified by per-repo landedSha.
- The CLI/dashboard merge doors now return merged:true on full land (were hardcoded
merged:false). WorkspaceRepoLandBusyError re-enqueues with backoff WITHOUT burning
the mergeRetries quota (bounded busy counter) so contention can't park a healthy
task. Backoff capped at 60s. shouldRetryWorkspacePartialLand folded into
shouldRetryAutoMergeConflict. Catch switched to instanceof. New canonical
isWorkspaceTask predicate in @fusion/core.
Gate green: build, typecheck, lint, test:gate (649+58); workspace-merger + oracle
+ project-engine 174.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extracts the per-repo land mechanics out of runAiMerge's inline clean-room
closure into an exported landOneRepo(store, repoRootDir, branch, integrationBranch,
ctx): pre-merge prune (rooted at the sub-repo), the clean-room temp worktree,
mergeAndReview, landSquash, and the CAS concurrent-advance retry that advances ONE
local integration ref — no remote push. runAiMerge is rewired as the single-repo
caller (its task-global finalization unchanged); the merger-ai suite (56 tests)
stays green as the byte-for-byte oracle.
landWorkspaceTask loops a workspace task's acquired sub-repos (sorted keys),
re-resolving each repo's integration branch with the shared override stripped
({...settings, integrationBranch: undefined, baseBranch: undefined}) so each
sub-repo lands on its own origin/HEAD, calls landOneRepo per repo, and aggregates
repo-tagged results — land-as-you-go on each repo's LOCAL ref (D2/D5). It does NOT
finalize/move the task (finalize-once + landed-tracking + idempotent retry are U2).
Door routing (KTD2): the engine dispatch and the user-facing CLI `fn task merge`
+ dashboard merge doors route workspace tasks to landWorkspaceTask so manual merge
works; store.mergeTask, aiMergeTask, and the runAiMerge chokepoint guard keep
throwing WorkspaceTaskMergeError as defense-in-depth.
New two-repo fixture tests: both repos land + no-push assertion, per-repo
override-stripped resolution onto distinct branches, repo-B conflict partial land
(task not moved), defense-in-depth throws. Gate green: typecheck, lint, build,
test:gate (649+58).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies ce-code-review (autofix) feedback — 5 reviewers, P1s corroborated.
F1 [P1, ×4 reviewers] Guard the merge chokepoint, not just the 4 doors. The
per-caller `getTask().catch(()=>null); if(t) assert` pattern failed open on a
transient read, and runAiMerge re-read the task unguarded — so a workspace
task could reach git work against the non-git root. Added a named
WorkspaceTaskMergeError and call assertNotWorkspaceTaskMerge inside runAiMerge
(the sole merge path) and the deprecated aiMergeTask body; door guards remain
as fast-fail defense-in-depth.
F2 [P1] The dispatch catch treated the guard throw as a merge failure and set
mergeRetries=MAX, permanently blocking manual retry. It now recognizes
WorkspaceTaskMergeError and parks without burning retries.
F3 [P2] Deprecation-warning test asserted toBeLessThanOrEqual(1) — vacuously
true on zero emissions. Now resets the per-project flag and asserts the
warning fires exactly once and not again on a second deterministic merge.
F6 [P2] The once-per-process warning flag suppressed the notice for all other
projects in a multi-project host; now keyed per project (Set by cwd).
F5/F7/F8 [P3] @deprecated propagated to the aiMergeTask barrel re-export; CLI
runTaskMerge guard moved inside the formatted try/catch; FNXC placeholder
timestamps corrected; test .at(-1) -> length index.
Documented as residual (deferred to master-plan U8, not bugs in U0's window):
self-healing auto-finalize + store.mergeTask are additional merge-completing
paths not hardened here — workspace tasks are not end-to-end runnable until
master-plan Phase A, and U8 makes self-healing workspace-aware.
Gate green: typecheck (29 projects), lint, build, test:gate (649+58),
affected tests (206+4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make runAiMerge (the FN-5633 clean-room AI merge path, already the default)
the sole merge path; soft-deprecate the legacy aiMergeTask pipeline. Phase 0
of the workspace-mode master plan — a standalone merge-consolidation refactor
that lands first so downstream workspace work targets one merge function.
U1 — collapse the dispatch: project-engine.ts now calls runAiMerge
unconditionally; the two direct callers that bypassed the dispatch
(dashboard.ts onMergeImpl in --no-engine mode, task.ts runTaskMerge /
`fn task merge`) now route to runAiMerge too. Export runAiMerge from
@fusion/engine.
U2 — soft-deprecate: aiMergeTask is @deprecated (body retained for a later
deletion pass; shared helpers runAiMerge imports, e.g.
captureSingleCommitLandedMetadata, left intact). merger.mode "deterministic"
is annotated deprecated and made inert (type + field kept — published
@runfusion/fusion surface); the dispatch logs a one-time deprecation warning
and routes to runAiMerge. Changeset added (minor).
U3 — R7 workspace merge-boundary guard: shared @fusion/core predicate
assertNotWorkspaceTaskMerge(task) rejects tasks with populated
workspaceWorktrees at all four merge entry points (dispatch, store.mergeTask,
onMergeImpl, runTaskMerge) with an error naming master-plan U6. Covers the
window until per-repo merge support lands; U6 removes it.
U4 — deterministic-mode blast-radius audit: no production project, CI config,
or seeded setting pins merger.mode "deterministic"; only four engine tests
used it to drive the dispatch to aiMergeTask as a mockable seam — migrated to
mock runAiMerge instead. Other module-level aiMergeTask mocks were dead under
the default "ai" mode or test aiMergeTask directly (body retained), so they
are unaffected.
Also removes an unused acquireWorkspaceRepoWorktree import inherited from the
foundation branch (executor.ts) that was failing lint; master-plan U1 re-adds
it with its per-repo usage.
Merge gate green: lint, typecheck (29 projects), build, test:gate
(649 + 58), plus the migrated (114) and new predicate (4) tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dashboard's discovered-skills catalog was built only from the
disk-scanning package manager, so plugin-contributed skills (e.g.
compound-engineering ce-*) — which the engine materializes for executor
sessions separately — never appeared in the editor. Built-in workflow
nodes that reference them (builtin:compound-engineering) showed
"— select skill —" / unresolved.
- skills-adapter: merge plugin skill contributions into the discovered
list (deduped by bare name) via an optional getPluginSkills thunk;
add shared bareSkillName normalizer.
- wire getPluginSkills into all three server entry points: serve,
daemon, and dashboard (the UI-serving command — verified via live
end-to-end that omitting it left the editor catalog empty).
- node-summary + WorkflowNodeEditor: resolve namespaced skillNames
(compound-engineering:ce-work) against the catalog's two-segment
names (ce-work/SKILL.md) so nodes display and select the right skill.
Verified: dashboard + CLI typecheck, 136 dashboard tests, and a live
dashboard E2E (discovered skills 0→11; Plan node resolves to "ce-plan"
in both the canvas label and the inspector dropdown).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refresh stored Claude OAuth credentials before reporting dashboard auth status or resolving model auth so users do not need to repeatedly re-login after access-token expiry.
Coalesce concurrent refresh attempts, prevent stale refreshes from overwriting newer logins, and route CLI dashboard/serve/daemon/onboard auth wiring through the shared refresh-capable storage.
Adds PrReconciler — a per-repo, self-owned polling loop (started from the
runtime layer in project-engine.ts, NOT the scheduler) that ETag-probes
GitHub, deep-fetches on change, persists mirror state, clears unverified
on first reconcile, and fires releaseHeldTaskByEvent(github:pr-<event>)
for transitions (changes-requested/approved/conflict/conflict-cleared/
merged/closed). Drops terminal entities; persists an audit event on error.
GitHub ops injected via PrReconcileGithubOps at the 3 CLI sites; engine
never imports the dashboard client. scheduler.ts stays PR-free (R20),
pinned by a regression test. 8 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the three first-class PR workflow node kinds and their handlers via
createPrNodeHandlers(deps), registered in createDefaultNodeHandlers
(fail-closed pr-nodes-unwired when absent). GitHub ops are injected as
callbacks (PrNodeGithubOps) at all three CLI sites (daemon/serve/dashboard)
so the engine never imports the dashboard client (FN-3049). pr-create
routes open/failed as outcomes; pr-merge passes expectedHeadOid and never
writes 'merged' (reconcile corroborates); pr-respond delegates to an
injected respond callback (U5 fills the body). 10 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
U5: action-gating contexts, the heartbeat deferral gate, a two-pass
resumeTaskForAgent, and the reverse-direction agent.taskId guards (via a
new isAgentEffectivelyExecuting callback wired at the in-process runtime)
all consult the column-effective agent; the restart watcher re-resolves
column bindings per tick for bound graph sessions, hot-swapping on
agent-changed and falling back without restart on agent-deleted.
- Update test assertions for bare model IDs
- Add deduplication guard for models with same bare ID
- Add validation for empty model IDs after prefix stripping
- Add test for API key forwarded as env var to spawn
- Add test for deduplication and empty model ID guard
- Extract shared handleOpencodeGoApiKeySaved helper
- Add changeset for the published package
discoverOpencodeGoModels() spawns 'opencode models opencode --refresh' but
never passed the saved API key as OPENCODE_API_KEY. The opencode CLI's
internal OpencodePlugin checks this env var to decide whether to show
paid models; without it, only free (cost.input === 0) models appear.
Now threads the apiKey from auth storage through to the spawned process
environment, so the CLI sees the user's Go subscription and returns the
full model catalog including paid models like Claude, GPT-5.x, Gemini, etc.
Callers in serve.ts, daemon.ts, and dashboard.ts all updated to read
the key from dashboardAuthStorage and pass it to refreshOpencodeGoModels.
syncStartupModels reads from authStorage in StartupSyncOptions.
Push the single group PR's body (member checklist, x/N landed) on each member
landing via an injected SyncGroupPrFn — new updatePr/closePr GitHubClient
helpers (gh CLI + API parity); refreshPrInBackground is task-scoped/wrong
direction and intentionally not reused. Sync failures are non-fatal+retryable;
out-of-band closed/merged PRs reconcile prState instead of erroring. New
POST /branch-groups/:id/abandon closes the PR best-effort and marks the group
abandoned. Also fixes the U5-introduced stub-context regression in the U4
dashboard bridge test (missing options).
Group promotion in PR mode previously flipped prState to 'open' without ever
calling GitHub — prNumber/prUrl were never populated. Add an injected
CreateGroupPrFn (mirrors the processPullRequestMerge seam, no engine→dashboard
import): coordinator creates-or-reuses exactly one PR per group, persists
prNumber/prUrl/prState, and leaves state untouched on GitHub failure so
re-promotion retries. Idempotent via persisted prNumber +
getBranchGroupByBranchName. Wired at all three CLI engine-construction sites
(daemon/dashboard/serve).
Add a synchronous Node-side i18next instance built from the generated
@fusion/i18n CLI catalog map (no async backend, first frame localized), with
locale precedence --lang flag -> GlobalSettings.language -> env (LC_ALL/LANG/..)
-> en. Wrap the Ink DashboardApp render in <I18nextProvider> and thread a
--lang flag through runDashboard.
Upgrade ink 6.8 -> 7.0 (native CJK double-width measurement) and raise the
react/@types/react peer floor to ^19.2.0. A spike test confirms react-i18next
works under Ink's custom reconciler: localized first frame + re-render on
changeLanguage (including CJK), retiring the KTD1 unknown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-start dashboard responsiveness went from ~99s to ~6-11s. CPU profiling
identified two synchronous-spawn hotspots blocking the event loop:
- `GitHubTrackingReconciler` scanned up to 200 done tasks per startup,
each call into `getIssue` invoking `isGhAvailable()` + `isGhAuthenticated()`
via `execFileSync`. `gh auth status` makes a network roundtrip, so 400
sync spawns ≈ 71s of pure event-loop blocking (69% of cold-start CPU).
Memoized both checks with a 60s TTL; `resetGhAvailabilityCache()` is
exported for login/logout flows that need immediate invalidation.
- `PRAGMA integrity_check(100)` walks every page of the SQLite file (~7s
per database, multiple DBs × projects). The deferred check was scheduled
3s after init — right in the responsiveness-critical window. Pushed to
60s so the user is already interacting before it runs; check itself is
unchanged.
Also yields the event loop between major InProcessRuntime init phases and
between self-healing recovery steps (34 per project), defers orphan-task
AI agent resumption by 30s (env-overridable, auto-zero under Vitest), and
ships an opt-in `FUSION_TRACE_EL_LAG=/path/to/file` event-loop lag tracer
that diagnosed all of the above.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple coordinated fixes for the perceived "dashboard takes forever to
load" complaint. Per-page-load HTTP requests drop from ~177 to ~101 and
duplicate per-project InProcessRuntime creation is eliminated.
- engine: shouldUseHybridExecutor no longer auto-enables for local-only
multi-project setups (set FUSION_HYBRID_EXECUTOR=1 to force). The
duplicate-runtime path was running self-healing twice per project and
contending on the same SQLite file. ProjectEngineManager already
handles N local projects with one InProcessRuntime each.
- dashboard cli: parallelized independent store inits, started
CentralCore.init early in background, ran plugin loading concurrently
with extension resolution. Sequenced SQLite store inits to avoid a
TOCTOU race in addColumnIfMissing migrations across TaskStore /
AutomationStore / PluginStore / AgentStore (all open the same
.fusion/fusion.db). Restored try/catch around HybridExecutor.initialize
and engineManager.ensureEngine so a paused or broken cwd project no
longer aborts dashboard startup.
- dashboard client: added in-flight request dedupe wrapped around the
top API offenders. /api/plugins/ui-slots drops from 17x to 1x per load.
dedupe.forceFresh redirects ALL in-flight waiters to receive the fresh
post-mutation response, not just the forcing caller. Generation
counters in useAgents and AgentListModal protect against slow polls
overwriting fresh state.
- dashboard SSE: agent event handler now debounces 250ms with a
trailing-edge guard so multi-agent activity bursts coalesce to at
most 2 refetches per burst.
- dashboard route: PATCH /api/projects/:id with isolationMode change
returns 503 with actionable guidance when HybridExecutor is
unavailable, instead of silently persisting a config the live runtime
won't honor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements project identity tracking and recovery across the Fusion system (FN-5411), enabling persistent identity for projects across storage migrations, daemon reattaches, and CLI session management. Adds a project identity metadata API and central reattach ensure mechanism, wires identity stampin
Fusion-Task-Id: FN-5411
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5411
Exposes the `opencode-go` provider via `startup-model-sync`, wires it into the daemon and serve commands, and adds a refresh-status indicator in the SettingsModal that triggers model reloading whenever the provider key is saved. Includes a changeset, settings documentation, and corresponding tests a
Fusion-Task-Id: FN-5424
Stabilizes mobile keyboard viewport metrics in the `useMobileKeyboard` hook, with tests covering the hook and `ChatView` integration; a minor dashboard command adjustment is included.
Fusion-Task-Id: FN-5155
Awaiting engineManager.startAll() during dashboard startup blocked the
TUI on the slowest project's git/state init. Engine startup now runs
fire-and-forget; the reconciliation loop and the server's on-access
fast path cover any engine the user reaches before it's up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds scheduler heartbeat timer reconciliation with automatic self-healing when timers drift, including tests for tracked-only monitor recovery and documentation in the agents reference.
Fusion-Task-Id: FN-3958