S05 now covers builtin:coding-ideas-v2. 5/5 consecutive full lanes.
Two harness defects, both of which made a CORRECT engine refusal look like a flake.
1. Task ids collided. The serial lived on the harness instance and reset with it, so
every test's first task was `FN-182-S05-1`. The engine's process-wide state —
`executingTaskLock`, `activeSessionRegistry.pathsForTask`, worktree registrations —
is keyed by task id, so a straggler from the previous test answered for the NEXT
test's identically-named task and handed it a worktree under the PREVIOUS fixture.
The serial now lives on the module.
2. Teardown forgot in-flight work instead of waiting for it. `ProjectEngine.stop()`
clears timers but does not await an execution already inside `execute()`, and the
harness then called `activeSessionRegistry.clear()` — which hides a live session
rather than ending it. `dispose()` now drains `executingTaskLock` and the registry
for its own task ids, bounded, and THROWS on expiry: a straggler that outlives the
budget is a real defect, and a silent continue would restore the leak.
Throughout this, the product was right. The executor detected the foreign worktree,
refused it (`outside_worktrees_dir`), retried, exhausted its budget and failed
visibly. That refusal is the desired behaviour and was never the bug — the harness
was manufacturing the condition.
Budget re-baselined 150s -> 175s for attributable growth: a 7th file (the remediation
drive) and S05 on V2, one of the longest scenarios. Five runs at 140.1-148.4s left
under 2s of headroom against the old ceiling, which is a flake waiting to happen. The
standing rule is unchanged and now has three precedents: growth must be nameable, or
it is a regression to fix rather than a budget to raise.
pnpm lint 0 errors, test:gate, verify:fast, engine-pipeline-smoke 90/90, and five
consecutive full runs: 146.0s, 142.6s, 148.5s, 144.8s, 146.3s of the 175s budget.
Two review-seal defects, both found by running builtin:coding-ideas-v2 through the
whole scenario matrix rather than the nominal path alone.
1. A DETERMINISTIC verification gate was sealed as write-capable. It needs a
worktree because it runs the project's test/build commands there, but it only
reads the tree — `verification-gate.ts` has no mutation path.
`workflowNodeRequiresWorktree` conflates "needs a worktree" with "writes", and
its inline-fix branch matches on the node NAME (`/review|verification/i`), so a
gate named "Verification" was refused after any approval.
2. A gate that already `passed` or was `skipped` was refused on replay. A
post-approval requeue — a merge conflict, a transient merge failure — walks the
graph back through gates whose output is already inside the approved tree.
Refusing them converts a retryable merge into a terminal wedge; re-running them
would rewrite the tree the review approved. "Already produced, already
reviewed" now resolves as satisfied. A gate with no result still hits the
refusal, which is the case the seal exists for.
Measured by pipeline-smoke S13, where a conflicting merge left the card cycling on
documentation-delivery with `workspace-review-seal-required` instead of retrying.
Coverage: builtin:coding-ideas-v2 now runs 16 of the 19 declared scenarios plus
the multi-repository workspace drive, up from 1. The duration budget is
re-baselined 90s -> 150s, and the workload growth is itemised in docs/testing.md:
17 added scenario executions and a second project shape, 124.95s measured against
76.9s for the smaller matrix. Three consecutive full runs: 116.9s, 115.6s, 119.8s.
NOT covered, deliberately and stated rather than hidden: S07 (unactionable review
rejection), S13 (scripted merge-conflict resolution) and S17 (restart resilience)
still run on the original workflows only. S07 and S13 do not converge on V2, and
S17 produced one intermittent post-merge failure in four full-lane runs — a flake
is not something to ship or to paper over, so those three stay uncovered until
they are understood.
Opt-in `pnpm smoke:pipeline` lane replaying 19 declared scenarios across
builtin:coding-ideas and the builtin:coding non-regression floor, driving the
real engine: disposable local Git repositories, throwaway PostgreSQL store,
production graph dispatch, ProjectEngine merge admission, real worktree
acquisition, and deterministic mock-provider scripts under testMode.
Each scenario declares one closed terminal state (merged-done, inert-intake,
parked, manual-hold, no-op-merge); an undeclared terminal fails the run, and
five wedge detectors (W1-W5) reject contradictory parks, finalization loops,
severed sessions, unreachable waits, and quiescence without progress.
Differential proof: on the pre-FN-180 tree (95ea06b48) exactly S05, S06, S09,
S10 and S16 fail across both workflows with behavioral assertions, and pass
after FN-180 — the FN-175/FN-177 incident classes are reproduced mechanically.
The declared duration budget is re-baselined 70s -> 90s at landing. The harness
did not degrade: the identical branch measured 61.8-64.1s against the
pre-integration main and 73.2-80.2s against the same main after 65 upstream
commits, with growth in transform, import and test phases the lane does not own.
docs/testing.md records the measurements, the cause, and the file-consolidation
lever to reach for before the budget is touched again.
Excluded from engine-default and engine-core; the merge gate is unchanged and
CI runs the lane non-blocking after merge.
Fusion-Task-Id: FN-182
## Summary
- replace a zero-width space in the comment-assertion gate documentation
- restore the clean-main ESLint gate without changing scanner behavior
## Test plan
- `node scripts/check-no-comment-assertions-in-tests.mjs`
- `pnpm exec eslint scripts/check-no-comment-assertions-in-tests.mjs`
- `pnpm check:changesets`
- `pnpm lint`
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Clarified the explanation for a narrowly scoped test-checking
exception.
* No runtime behavior or user-facing functionality changed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Tests must assert behavior, not source text. A test that pins an FNXC block, a
date stamp, or comment prose guards documentation — and AGENTS.md tells authors
to keep those comments current, so the two rules fight and the test loses in the
worst way.
Measured today: grok-runtime-bootstrap.test.ts asserted runTaskMerge's body
contained "FNXC:GrokCliRouting 2026-07-15-10:17". FN-9167 legitimately rewrote
that function and dropped the block while leaving behavior intact; the test went
red, and the fix applied earlier in this sweep was to RE-ADD THE COMMENT to
packages/cli/src/commands/task.ts. A comment returned to shipped source not
because it documented anything true, but to appease a test. Four more such
assertions sat in dashboard CSS tests, each beside a real assertion, each adding
nothing.
- Drops the two prose pins from grok-runtime-bootstrap; its real structural
guard (`not.toContain("mergePluginRunner")`) stays. The product comment stays
too — it is accurate documentation, it was simply never a test's business.
- Adds scripts/check-no-comment-assertions-in-tests.mjs, wired into pretest,
pretest:full, and test:gate:static. It flags the unambiguous case; an earlier
draft that also matched `/*` produced 24 false positives and zero true ones,
because a regex cannot separate comment prose from a path glob.
- Adds the standing rule to AGENTS.md, with an explicit boundary: prose,
comments, and date stamps are never a test subject, while code-construct and
call-site-allowlist guards (no-blocking-shellout, vi-mock resolution, durable
write and emit-surface inventories, legacy tombstones) are a different
category and stay.
Also carries a product fix that the agent-generation tests surfaced: the
system prompt exists in two copies, and `resolvePrompt` returns core's catalog
default, so FN-021 adding the xhigh/max thinking levels to the dashboard copy
alone left every real generation run advertising levels that stop at "high".
Core's copy is synced and both literals now say they must move together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Core 32 failures -> 0 (5,981 passing); CLI 46 -> 0 (2,021 passing). Three
agents per package, root-cause fixes only.
One product defect, same class as FN-9059 and found the same way — by a test
that leaked state between runs:
`projectTableNames` was missing SEVENTEEN tables the schema declares
(current_plan_evidence, spec_locks, spec_drift_reports, symbol_locks,
configuration_revisions, chat_tags, chat_session_tags, mission_lineage_stops,
task_verification_requests, unplanned_execution_blocks,
workflow_agent_capacity_leases and the six task_lifecycle_* tables). That list
drives BOTH the PG test-harness per-test reset and production health
compaction, so those tables were never truncated between tests (a plan-evidence
version counter carried forward, making whole-file runs disagree with isolated
ones) and never VACUUM/ANALYZEd in production. Registered, with
project-table-registry.test.ts as a ratchet — verified it fails on an
unregistered new table naming the offender.
Everything else was drift behind deliberate changes: branch-write provenance,
FN-073 dependency validation, the FN-9191 pre-merge merge gate, U11's triage/
planning lane merge, refinement workflow coming from the ORIGIN selection,
async-converted provider registration, a barrel mock missing exports a guard
added, and several source-pinned inventories broken by module moves. Tests for
removed features were deleted with their removing commit cited.
Also fixes a vitest config gap where @fusion/core/mcp-builtin-servers resolved
only to dist/, which was breaking test COLLECTION in unrelated CLI files and had
been misread as transient cross-agent noise.
Quarantines mission-store.pg's concurrent-claim race (second sighting): it holds
a transaction open, sleeps 250ms and asserts the rival has not settled, which
fails under parallel load. An A/B against the registry change above looked
causal on one run and did not reproduce on three — that coincidence is the flake
itself, and rescue needs a real lock-wait probe rather than a longer sleep.
Core's config now inlines its exclude array, because check-quarantine-ledger.mjs
cannot resolve a variable reference and silently reported the ledger unpaired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full engine suite at a97aa84a20: 3 failures out of 12,414. All three diagnosed:
- self-healing-pending-wedge-notification's marker-selection case fails ONLY in
a full-suite run (expects 1 elapsed marker, sees 2) and passes deterministically
alone. This is its SECOND sighting, so per AGENTS.md it is an on-sight
quarantine with no further discretion: ledger entry + matching vitest exclude,
same commit, 2026-09-06 deletion deadline.
- spec-drift-reconciler's exponential-backoff case shows the same shape on a
FIRST sighting, so it is recorded in the observed register instead of evicting
that file's other passing coverage. Both are timer-driven reconciler tests that
only fail alongside other suites, pointing at cross-file fake-timer state.
- merge-orphan-durable-write-inventory drift was pure lineHint movement (19
changed, zero newly unclassified entries) after product edits shifted lines.
Regenerated.
Also fixes check-quarantine-ledger.mjs, which could not see the exclude I added:
its comment stripper treated the `/**` inside glob literals like "node_modules/**"
and "src/**/*.slow.test.ts" as a block-comment opener and deleted through to the
next "*/", swallowing whole array literals and every entry after them. It now
scans string-aware, so the lockstep check actually holds. Nothing was appeased:
no timeout widened, no retry added, no assertion relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#3514 removed the last unwired task-merge review-lane call site but left
the ratchet allowance at 1, so every PR against main failed
check:lane-wiring on a drop.
Mark isAtOrPastReviewLane as DELIBERATE-LITERAL so the lifecycle-column
census ratchet stops treating FN-158's physical pre-cutover lane IDs as
new unexamined guards. Refresh the pinned baseline to match.
The correct `docker run` is long and every piece of it fails confusingly when
omitted: the OAuth callback ports are fixed by the providers' registered redirect
URIs and unreachable without PI_OAUTH_CALLBACK_HOST=0.0.0.0, /home/node must be a
volume or the Tailscale login and embedded Postgres are lost on recreate, and
--tailscale is an entrypoint flag that must precede the CLI arguments.
- Every knob is an environment variable, so per-container config lives in an
--env-file outside the repo rather than a committed token.
- FUSION_STATE_VOLUME supports setups that mount /home/node/.fusion as its own
volume nested inside /home/node; omitting it is not destructive but silently
points the dashboard at a different database, so the board comes up empty.
- An existing container is never replaced without --recreate, and volumes are
never removed, so a recreate keeps the database, settings, and tailnet login.
- --dry-run prints the command without running it, including when the container
already exists, which is exactly when previewing a recreate is most useful.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Starting the daemon unconditionally gave every container a process, a listening
socket, and an identity in someone's tailnet that it never asked for. Most
containers never use remote access, so the daemon is now opt-in.
- The entrypoint consumes a leading `--tailscale` argument (or FUSION_TAILSCALE=1,
with `--no-tailscale` to override it back off) and strips it from the argument
list, so everything after it stays a normal Fusion CLI invocation.
- Arguments are rotated through shift/append rather than string concatenation, so
values containing spaces survive as single argv entries.
- Replaces the FUSION_DISABLE_TAILSCALED opt-out, which is redundant now that the
default is off.
- Document the flag, the userspace-networking mode (no NET_ADMIN/tun caps), the
one-time `tailscale up`, and the /home/node mount that persists that login.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tailscale remote access failed in the container with a bare "process exited 1":
the image ships the `tailscale` CLI but nothing ever ran `tailscaled`, so the
`tailscale funnel <port>` spawn died instantly on "failed to connect to local
tailscaled".
- Add scripts/docker-entrypoint.sh, which best-effort starts tailscaled in
userspace-networking mode (needs neither NET_ADMIN nor /dev/net/tun, so the
documented `docker run` is unchanged) and then execs the CLI with CMD verbatim.
Opt out with FUSION_DISABLE_TAILSCALED=1.
- Symlink /var/lib/tailscale into /home/node/.tailscale so the documented
`-v <vol>:/home/node` mount persists the node login across container recreates,
and pre-create the daemon's socket/log paths node-owned before the USER switch.
- Preflight daemon reachability and backend state with `tailscale status --json`
in evaluateRemoteLifecycle instead of only `which tailscale`, so unreachable,
logged-out, and stopped backends all report an actionable
runtime_prerequisite_missing reason rather than an unexplained exit 1.
Regression coverage asserts the invariant across all three unusable-backend
surfaces, not just the reported container repro.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## Summary
- update workspace worktree test stores for callback-based entry
mutations
- preserve validation, existing-entry, and singular-routing behavior in
the fakes
- canonicalize the macOS worktree path fixture before comparing
persisted state
- remove the stale inert-seam exception left after the review-column
callback became fully supplied
## Test plan
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/node-worktree-isolation.test.ts
src/__tests__/workspace-root-worktree-routing.test.ts
src/__tests__/worktree-acquisition.test.ts --silent=passed-only
--reporter=dot`
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/worktree-acquisition-workspace.test.ts
--silent=passed-only --reporter=dot`
- Directly changed engine workspace suite with PostgreSQL-dependent
blocks skipped: 338 passed, 4 skipped
- `pnpm --filter @fusion/engine typecheck`
- `node scripts/check-inert-flag-seams.mjs`
- `node --test scripts/__tests__/check-inert-flag-seams.test.mjs`
- `pnpm lint`
- `pnpm check:changesets`
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved recovery of orphaned worktrees by consistently using
canonical paths.
* Strengthened worktree assignment and updates to preserve existing
entries and prevent stale task state.
* Improved synchronization and validation during concurrent workspace
updates.
* Refined self-healing recovery so branch metadata changes only when
necessary.
* Improved handling of stalled tasks, retry exhaustion, and clearing
obsolete worktree or branch information.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
Fixes the `session.subscribe is not a function` crash that breaks every
engine workflow step (Plan Review, Code Review) executed by an ACP agent
(Hermes ACP, Prime, Grok).
Introduced by the ACP custom-tools bridge (#3476) in the sense that it
made the gap reachable: ACP sessions stream through the bridging client
handler onto `callbacks` and never exposed the pi-style `subscribe()`
that the engine's AgentSession contract promises. Two production call
sites call it unconditionally:
- `packages/engine/src/executor/execute-workflow-step.ts` (workflow
steps — Plan Review / Code Review)
- `packages/engine/src/pi.ts` fallback wiring (`wireFallbackHooks`,
`promptableSession.subscribe`)
(`reviewer.ts` guards with `typeof session.subscribe === "function"`;
the other paths do not.)
Real-world symptom (verified on 0.77.0-beta.6 with a Hermes ACP agent):
every Plan Review attempt fails before producing a verdict:
```
[pre-merge] Workflow step failed: Plan Review
outcome: Plan Review failed before producing a verdict: session.subscribe is not a function
Plan Review provider retry budget exhausted
```
## Fix
Fix at the seam rather than guarding every call site forever:
- `AcpRuntimeAdapter.createSession` wraps the raw callbacks so each
forwarded text/thinking/tool event is **also** replayed to subscribers
as the pi-shaped event consumers parse (`message_update` +
`assistantMessageEvent.{text_delta,thinking_delta}`,
`tool_execution_start/end`)
- exposes `session.subscribe(handler)` returning an unsubscribe
function; dispose clears subscribers
- merges engine `taskEnv` into the subprocess env behind the existing
allow-list trust boundary (KTD6b preserved — only allow-listed keys
forward, task values win)
- original callback delivery unchanged; subscriber exceptions are
isolated so a faulty consumer can't break the streaming bridge
## Testing
- New regression tests in `runtime-adapter.test.ts` against the real
echo-agent fixture (`ACP_FIXTURE_RICH_PROMPT=1`):
- subscribe replays text/thinking/tool events as pi-shaped events
- unsubscribe stops delivery
- dual delivery: original `onText` callback AND subscriber both fire
- Full plugin suite: 255 passed (21 files)
- `tsc --noEmit` clean for the plugin
- changeset for `@runfusion/fusion` (patch, bugfix)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added session event subscriptions with unsubscribe support.
* Streamed text, thinking, and tool updates are replayed in a consistent
event format.
* Added support for task-specific environment values when launching
subprocesses.
* **Bug Fixes**
* Ensured streamed events reach both existing callbacks and subscribers.
* Isolated subscriber errors so they do not interrupt other handlers.
* Session cleanup now stops further subscription notifications.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The repo keeps binary assets as Git LFS objects, but the runner stage
installed plain git. Without git-lfs, checkout writes ~130-byte pointer files
in place of the real content and reports a clean tree, so an agent reads a
text stub where an image should be and any `git lfs` subcommand fails. That
is silent corruption of a working checkout, not a visibly missing tool, which
is why it goes in beside git rather than with the optional tooling.
Confirmed in the running container: screenshots/fn-061-medieval-desktop.png
was a `version https://git-lfs.github.com/spec/v1` stub — 129 tracked files
in that state — and became a valid 753KB PNG (signature and IEND intact)
after installing git-lfs and running `git lfs pull`.
The Dockerfile manifest guard now asserts the package so it cannot be dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working on Fusion from inside a machine that already runs one, a plain
`pnpm dev` shares that instance's LIVE database. Everything durable hangs off
$HOME/.fusion — settings, credentials, central DB, the embedded Postgres data
dir — and a process pointed at a data dir whose postmaster is already running
attaches to it instead of starting its own.
--isolated spawns the dev child with HOME pointed at a sandbox, so it gets its
own settings, credentials and Postgres cluster on its own port. It also moves
the child's cwd, which is the half that is easy to miss: `fn dashboard`
derives its project from the working directory and has no project flag, so
isolating HOME alone leaves both instances on `<repo>/.fusion` — including
`.fusion/tasks/<id>/`, which the orphaned-task-dir sweep re-imports, so a
fresh dev database would adopt the real instance's tasks.
The sandbox defaults to ~/.fusion-dev/<checkout-name>/{home,project}: outside
the work tree so it neither shows up in git status nor dies on a clean
checkout, and keyed by checkout so two clones cannot collide. The project dir
is git init-ed on first use because Fusion projects are git work trees.
Changing cwd is safe because PRELOAD/LOADER/ENTRY are already absolute.
Verified in a container beside a running Fusion: the isolated instance
reported zero projects while the real one reported two, on separate Postgres
clusters (ports 42617 and 38311).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reorganize the suite-only flake register around its current decision state.
- Separate active observations, gate evictions, and archived closed records while preserving entry anchors.
- Mark entry 1 as an escalated second sighting and restore FN-9126/FN-9131, FN-9128, and FN-9127 ownership.
- Add regression checks for active counts, exact states and owners, and testing-guide anchors.
Files changed:
.../suite-only-flakes-observed-register.md | 189 +++++++++++++--------
scripts/__tests__/observed-flake-register.test.mjs | 65 +++++++
2 files changed, 181 insertions(+), 73 deletions(-)
Fusion-Task-Id: FN-9145
Fusion-Task-Lineage: 39b3292a-db48-4be4-878c-7d2b026abae3
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
## Problem
PR #3472 resolved a `.changeset/pre.json` rebase conflict against a copy
predating the v0.76.0 stable:
| | at `v0.77.0-beta.1` | after #3472 |
|---|---|---|
| `initialVersions["@runfusion/fusion"]` | `0.76.0` | `0.75.1` |
| consumed ledger | 67 entries | 158 (the pre-0.76.0 cycle's) |
Nothing failed at PR time. Days later `pnpm release` saw the cycle
anchored below the shipped `v0.76.0`, fired its stale-cycle re-anchor
(`pre exit` → rewrite all 36 `package.json` → `pre enter`), and proposed
**`0.77.0-beta.0`** — below the already-published `0.77.0-beta.1`. The
re-anchor guard exists to stop a beta numbering under a stable; fed a
stale anchor it caused exactly that.
`pre.json` is generated by changesets, hand-edited by nobody, and
conflicts in nearly every long-lived branch — so a wrong resolution is
invisible until release day. This moves the failure to the PR that
causes it.
## The check
`scripts/check-pre-json-anchor.mjs`, three invariants:
- **`anchor-below-stable`** — `initialVersions` must not sit below the
newest `v*` stable tag. This is the exact predicate
`evaluateBetaCycleAnchor` keys on in `release.mjs`, so green here means
the release will *not* re-anchor.
- **`ledger-regression`** — the consumed ledger must stay a **superset**
of the last `chore(release):` commit's. Deliberately not a count test:
#3472's ledger *grew* 67 → 158 while dropping all 67 real entries, so a
size comparison would have passed it.
- **`dangling-ledger-entry`** — every consumed entry keeps its
`.changeset/*.md`, which pre-mode needs to aggregate notes into the
eventual stable release.
Skips cleanly outside pre-mode (the stable track deletes `pre.json`).
## Wiring
Added to the **Lint** job and `pretest`. Job names are unchanged, so no
branch-protection update is needed. The Lint checkout takes
`fetch-depth: 200` + `fetch-tags` rather than a full 486MB clone —
releases land every few days, so that always reaches a baseline; out of
range the ledger rule reports `SKIPPED` rather than passing vacuously,
and the two local rules still run.
## Verification
- Reproducing #3472's exact `pre.json` in the tree → **exit 1** on all
three rules, with the 67 dropped entries named.
- Clean `main` → exit 0.
- 11 unit tests (`scripts/__tests__/check-pre-json-anchor.test.mjs`),
including an explicit assertion that the ledger *grew* in the regression
case.
- `eslint` clean; workflow YAML parses; job names still `Lint,
Typecheck, Build, Gate`.
No changeset: CI config only, no `@runfusion/fusion` behavior change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added automated validation for beta release-cycle metadata.
* Checks anchor versions, consumed changeset records, and corresponding
changeset files.
* Provides clear success or error messages and skips checks when release
history is unavailable or not applicable.
* **Chores**
* Pull request checks now run the beta-cycle validation automatically.
* Added comprehensive coverage for valid, invalid, and skipped
validation scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`pnpm dev --tunnel` printed its banner to stdout, and a TTY run then handed
the screen to DashboardTUI, which repainted over it. The public URL — the
entire output of the flag — was unreadable.
The wrapper forwards the URL to the dev child over the IPC channel it already
uses for the listening report, and the dashboard renders a Tunnel row in the
system panel beside URL and Token.
Capture is order-independent. cloudflared can publish before or after the TUI
is constructed, and an IPC message that arrives with no listener attached is
lost, so the URL is stored at run scope on arrival and applied by whichever
half completes second.
Watch-mode restarts reuse the tunnel (a fresh quick tunnel would hand out a
new hostname on every reload), but the new child knows nothing about it, so
the wrapper re-announces on each listening report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auditing for repeats of the `pnpm dev --tunnel` bugs turned up the same
mistakes elsewhere.
Remote tunnels assumed 4040. ProjectEngine's Cloudflare quick tunnel
hardcoded http://localhost:4040, so a dashboard on an explicit --port, a PORT
override, or runDashboard's EADDRINUSE rebind published a PUBLIC tunnel to
whatever else held 4040. The dashboard now records its bound port
(setLocalDashboardPort, from both runDashboard and headless serve) and the
tunnel reads it, keeping 4040 only as the pre-report default.
register-discovery-routes already derived its port from req.socket.localPort
and is untouched.
Stopping the dev wrapper orphaned everything it started. It installed no
signal handlers, so teardown only ran from the child's close handler:
signalling the wrapper left the dev server AND its cloudflared alive —
observed twice, four surviving processes each time, including a public
trycloudflare URL still serving a dev server believed to be down. Ctrl-C hid
it by signalling the whole process group.
SessionTerminal appended scrollback instead of clearing first, though the
server sends it as a separate frame precisely so the client can clear. Latent
today because every reattach builds a fresh xterm; a duplicated-history bug
the moment an in-place reconnect appears.
And BackupManager's centralDbPath is gone: written, never read, and a
leftover of the removed SQLite backup — the same class of stale artifact that
onboarding was using as evidence about a Postgres install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When the dev child had not reported a bound port within 60s, the wrapper fell
back to the configured port and published a tunnel to it. In the case the
port fix exists for — a container whose own Fusion owns 4040 — that hands out
a dev-looking URL serving a completely different instance, with only a
passing "which may not be it" warning to show for it.
Observed with a dev server stopped on the interactive `Run central db now?
(Y/n)` prompt: it never listens, so it never reports, so the tunnel published
the container's Fusion instead.
A missing tunnel is a visible problem that explains itself; a tunnel to the
wrong app is a silent one. The wrapper now waits, unbounded, printing a
notice once a minute that names the interactive-prompt case. An explicit
--tunnel=PORT still publishes immediately, since it names a target the dev
child knows nothing about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The banner re-derived the token from ~/.fusion/settings.json. That is simply
the wrong source: on a real run the file contained no daemonToken while the
dashboard's own banner, two lines above, printed a working one — so the
tunnel claimed no token existed next to a token that plainly did.
The dashboard already holds the resolved token at the point where it reports
its bound port, so it now reports both over the same IPC message and the
wrapper prefers that over anything it could derive. The env/settings lookup
survives only for targets that report nothing, such as an explicit
--tunnel=PORT aimed at a server the dev child knows nothing about. The token
crosses the existing parent/child channel only; it is never logged or sent
onward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tunnel target was resolved from PORT/4040 before anything bound, but an
occupied port makes the dashboard silently rebind to an ephemeral one
(server.listen(0) on EADDRINUSE). With a normal Fusion already running on
4040, `pnpm dev --tunnel` therefore published THAT instance under a
dev-looking URL while the dev server sat unreachable on a random port.
The dashboard now reports the port it actually bound to the dev supervisor
over IPC, and the wrapper tunnels that:
- IPC is enabled whenever --tunnel is set, not only in watch mode; a plain
tunnel run previously had no channel at all.
- The tunnel waits for the report (60s cap, then falls back to the configured
port with a warning), so it also cannot come up against a port nothing is
serving yet.
- A mismatch is logged instead of silent.
- A reported port is treated as the dashboard whatever its number, so the
banner keeps printing the bearer token; without that, the ephemeral case
would classify the dev dashboard as a foreign port and drop it.
- An explicit --tunnel=PORT names a target the dev child knows nothing about,
so it never waits and is still compared against the configured port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pnpm dev --tunnel` published a bare URL under a "public, unauthenticated"
header. That label was wrong for the flag's own default target: --tunnel with
no port aims at the dashboard, which is bearer-token gated, so the recipient
of a shared link got a 401 with no token to supply.
resolveDevTunnelAuth() now classifies the target and the banner says what is
actually true of it:
token dashboard with auth on — prints the token and a ?token= link,
resolved from FUSION_DASHBOARD_TOKEN, FUSION_DAEMON_TOKEN,
then ~/.fusion/settings.json
token-pending first run, token not minted yet — defers to the dashboard's
own startup banner
no-auth --no-auth is on; the dashboard really is open
foreign a non-dashboard port; Fusion has no auth to lend it, the only
genuinely ungated case
Auth resolves at banner time rather than flag-parse time so a token the dev
child mints on a first authenticated run is already readable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator case: someone works inside a remote Fusion (a container, a shared box),
starts a dev server there, and needs to view it from their own browser. The dev
server binds inside that machine, so without a tunnel the only options are port
publishing or a VPN — both needing cooperation from whoever owns the host.
pnpm dev --tunnel # tunnels the dashboard port (PORT, default 4040)
pnpm dev --tunnel=5173 # tunnels a Vite dev server instead
pnpm dev --tunnel dashboard # tunnel the default port AND run the dashboard
FUSION_DEV_TUNNEL=1 pnpm dev
Cloudflare QUICK tunnels are usable here precisely because a dev server is HTTP:
no account, no domain, no card. The TCP endpoints that SSH would have needed
require a card (ngrok) or a domain plus Zero Trust (Cloudflare) — that asymmetry
is why this exists for HTTP only, and it is recorded in the module header so the
next person does not retry the SSH variant.
Design decisions:
- Tunnel failure is NON-FATAL. A missing cloudflared or a tunnel that never
publishes a URL logs and is skipped; losing a preview URL must never cost the
operator their dev loop.
- Watch-mode restarts reuse the existing tunnel. A fresh quick tunnel hands out a
different hostname each time, which would invalidate an already-shared link.
- `--tunnel` consumes a following token only when it is numeric, so
`--tunnel dashboard` forwards `dashboard` to the dev command rather than
tunnelling port NaN. That is the bug this flag shape invites, so it is tested.
Verified end to end in a container: a dev server bound to 127.0.0.1 inside it was
fetched from the public internet through the tunnel (200, correct body). Also
confirmed that tunnelling the DASHBOARD port does not weaken auth — unauthenticated
requests through the tunnel return 401 for /api/tasks, /api/settings and
/api/artifacts, with only /api/health open by design.
Adding two fields to parseDevWrapperArgs' return broke two existing strict toEqual
assertions; those were updated rather than loosened to toMatchObject. 27 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## Summary
- removes the deleted taskStuck helper from dashboard package exports
and local build/test aliases
- removes the dependency-graph plugin TypeScript path for the deleted
dashboard module
- adds a script regression so the removed module cannot be reintroduced
as a stale alias
## Test Plan
- pnpm test:scripts --
scripts/__tests__/dashboard-stuck-task-removal.test.mjs
- pnpm check:changesets
- pnpm exec eslint
scripts/__tests__/dashboard-stuck-task-removal.test.mjs
packages/dashboard/vite.config.ts packages/dashboard/vitest.config.ts
- pnpm --filter @fusion/dashboard typecheck
- pnpm --filter @fusion-plugin-examples/dependency-graph build
- pnpm --filter @fusion/dashboard build
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Removed stale stuck-task references from dashboard package and build
configurations.
* Prevented unavailable task-stuck utilities from being exposed or
resolved.
* **Tests**
* Added validation to ensure removed task-stuck references do not
reappear in dashboard or plugin configuration.
* **Documentation**
* Recorded the cleanup in the project’s release notes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Operator asked for cloudflared, tailscale, rg, git and gh available by default
in the container. git/ca-certificates/ripgrep already landed; this adds the
remaining three.
Each comes from its vendor's own signed apt repository rather than a
curl-to-shell installer, so signature checking and upgrades follow the normal
apt path:
gh https://cli.github.com/packages
tailscale https://pkgs.tailscale.com/stable/debian
cloudflared https://pkg.cloudflare.com/cloudflared
Why each belongs in the image: gh backs Fusion's gh-cli GitHub auth mode (the
auth route instructs operators to run `gh auth login`, impossible without the
binary), cloudflared backs the dashboard's remote-access feature whose in-app
installer cannot bootstrap itself reliably in a slim container, and tailscale is
the private-network option for the same box.
Installing tailscale does NOT make tailscaled runnable by itself: the daemon
also needs --cap-add NET_ADMIN --device /dev/net/tun at docker run. Shipping the
binary is the image's part; granting kernel capabilities stays an explicit
operator decision.
Commands were validated live in a running container before being written here;
the guard test asserts both the repo wiring and the package names so half a
change cannot silently ship.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The coding agents Fusion drives reach for `rg` as their primary search tool. It
was absent from the image, so inside a container they silently fall back to
slower or partial search while working fine on a developer machine that has it
installed. Operator asked for it by default.
Installed alongside git and ca-certificates in the runner stage, and covered by
the same runner-stage guard so it cannot quietly drop out again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator hit "Git clone failed: ... server certificate verification failed.
CAfile: none CRLfile: none" the moment they tried to add a project in the
container.
The runner stage installed git but not ca-certificates, and the slim base ships
zero CA certificates (/etc/ssl/certs was empty). git verifies TLS against the
SYSTEM trust store, so every HTTPS remote failed and project setup — the first
thing anyone does after logging in — was impossible in Docker.
It hid because Node carries its OWN bundled CA store: the dashboard, model API
calls, and the OAuth token exchanges against platform.claude.com and OpenAI all
worked fine, so the image looked healthy right up until the first clone. Nothing
else in the image exercises the system trust store, so a guard is added rather
than trusting someone to notice next time.
Verified in the running container: installing ca-certificates took it from 0 to
301 certs and `git clone https://github.com/Runfusion/Fusion.git` then succeeded
as the node user.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured wall times: gate 14.0s, boot smoke 20.4s (-6.3s), changed-only
pnpm test 17.5s; quarantine ledger 0. Shard-timing snapshot rebuilt
from today's CI shard artifacts (run 31929730933) plus a locally
measured full dashboard suite so dashboard lane weighting keeps its
per-file data. The trim shows: the former top-6 core PG offenders are
gone from the slowest-20 (sqlite-migrator 2m29s serial -> 20.4s;
SettingsModal.general off the table entirely). Caveat: the dashboard
rows come from the pre-trim analysis measurement, so entries like
SettingsModal.scheduling-merge (30.2s, now ~13s) are pessimistic until
the next dashboard re-measure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## Summary
- replaces newly added future-dated FNXC metadata with the actual UTC
change time
- tightens the FNXC future-date baseline to zero known exceptions
## Test plan
- `pnpm check:fnxc-future-dates`
- `pnpm check:lifecycle-columns`
- `pnpm check:changesets`
- `pnpm --filter @runfusion/fusion exec vitest run
src/__tests__/staged-plugin-core-imports.test.ts --silent=passed-only
--reporter=dot`
- `pnpm --filter @fusion/engine typecheck`
- `pnpm lint`
Confirm the W33 spike as cold-start variance and preserve a repeatable diagnosis protocol.
- Record five sequential phase-timed samples and the 20.5-second median threshold.
- Link the controlled remeasurement protocol from the testing guide.
- Explain the timing snapshot handoff and why incomplete CI artifacts were not published.
- Preserve the no-appeasement requirement beside boot-smoke phase timing.
Files changed:
.../boot-smoke-w33-walltime-anomaly.md | 100 +++++++++++++++++++++
docs/testing.md | 2 +-
scripts/boot-smoke.mjs | 7 ++
3 files changed, 108 insertions(+), 1 deletion(-)
Fusion-Task-Id: FN-9105
Fusion-Task-Lineage: 6956b037-db0f-4560-bb04-136080a975b5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Full-suite repair, engine source-scan cluster. The package code
organization waves moved ~30 engine modules into subdirectories
(plugins/, execution/, scheduling/, healing/, worktree/, executor/
peels); the log-severity manifest, prompt carve-out, emit-surface,
failure-lane, and worktree-invariant scanners now read the moved
locations, verified per file via git log --follow. Two scans caught
real drift rather than moves: the lifecycle census had 12 unexamined
column guards (resolved with DELIBERATE-LITERAL markers for the mailbox
archived tab, the FN-9059 lease-owner terminality check, and the FN-9056
legacy done fallback — baseline re-recorded with zero absorbed debt),
and planning-claim gained a genuine second writer in self-healing's
FN-8998 transport-failure recovery, admitted to the allowlist with its
CAS-guarded justification. 9 files / 119 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>