Move the CLI plugin-install persistence coverage to the shared PostgreSQL test harness.
- Replace SQLite central and local database assertions with PostgreSQL schema queries.
- Assert global installation and project-scoped state persistence without a local plugin store.
- Clean up temporary plugin fixtures after the PostgreSQL test.
Files changed:
packages/cli/src/commands/__tests__/plugin.test.ts | 120 +++++++++++----------
1 file changed, 64 insertions(+), 56 deletions(-)
Fusion-Task-Id: FN-8091
Fusion-Task-Lineage: 99b8d1da-54b7-4cd3-a086-9e4461a8aa06
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Align the responsive TaskDetailModal tab-order assertion with the rendered tab set.
- Include Terminal and Cost between Comments and Artifacts in the responsive tab list.
- Document why the regression expectation includes the always-available tabs.
Files changed:
.../__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx | 3 +++
1 file changed, 3 insertions(+)
Fusion-Task-Id: FN-8039
Fusion-Task-Lineage: 86f347a7-ca5a-4a83-8616-4ca303cc6115
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Keep GitLab import details consistent with the shared padded FloatingWindow layout.\n\n- Move the GitLab preview into the shared heading and scrolling-content structure\n- Place the GitLab import control in the shared bottom action bar for mobile reachability\n- Cover cross-provider detail layout and shared mobile-sheet invariants\n\nFiles changed:\n .../dashboard/app/components/GitHubImportModal.tsx | 20 ++++++++++----\n .../__tests__/GitHubImportModal.test.tsx | 32 +++++++++++++++++++---\n 2 files changed, 42 insertions(+), 10 deletions(-)
Fusion-Task-Id: FN-8032
Fusion-Task-Lineage: 150b992a-d5fa-4001-b818-7ece031a6cb5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Update ChatView header contracts to enter the direct-thread pane before assertions.
- Add a shared mobile direct-thread helper for context-window tests
- Select the active session before mobile and floating header assertions
- Preserve rename coverage for the direct-thread session switcher
Files changed:
.../components/__tests__/ChatView.context-window.test.tsx | 14 ++++++++++++++
.../components/__tests__/ChatView.core-contracts.test.tsx | 9 +++++++++
2 files changed, 23 insertions(+)
Fusion-Task-Id: FN-8053
Fusion-Task-Lineage: a491db31-04b1-4cb5-ac58-c3be297bfe45
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
- One settings-scoped checkbox rule (accent, size, focus ring) so the Advanced-settings
toggle, SettingsToggleRow, ntfy/webhook card headers, and MCP toggle stop falling
back to the browser-default accent.
- Fix the empty/off-screen help bubble on mobile: .notification-provider-header and
.settings-field-label-row are now positioned ancestors for SettingsHelpTip.
- Migrate every remaining inline <small> description across settings sections to the
shared SettingsHelpTip "?" affordance (validation errors and live status stay inline).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Center each wrapped row below the quick-add textarea (workflow/steps,
option chips, icons+Save) so all rows share equal left/right insets on
mobile, instead of options hugging the left edge and Save hugging the
right. Desktop/tablet keep the left-options / right-Save toolbar layout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tighten the mobile primary-group gap to --space-xs and the icon-only
controls' min-width floor to 32px so the Save button no longer wraps to
its own line in the board quick-add composer. Touch-target height is
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## Problem
Migration 0006 made `project_id` the RLS isolation partition on every
`project`-schema table — stamped by a BEFORE INSERT trigger from the
`fusion.project_id` session GUC, with every PK/unique/FK rewritten to
composite `(project_id, …)`. Eleven tables **also** carried a
caller-supplied domain `projectId` on their TS types and wrote that
domain value into the same physical column.
When the domain value differs from the session GUC, the parent row lands
in the domain partition while child rows (`research_run_events`,
`experiment_session_records`, `eval_task_results`, …) land in the
session partition — and the composite FK fails with SQLSTATE 23503.
Appending an event to a project-owned research run could not persist.
## Fix
**Decision (operator): separate domain column; `project_id` stays the
partition.**
- **Migration `0011_owner_project_id.sql`** adds a nullable
`owner_project_id` domain column to the 11 conflated tables
(`research_runs`, `experiment_sessions`, `todo_lists`, `eval_runs`,
`chat_sessions`, `chat_rooms`, `ai_sessions`, `chat_token_usage`,
`project_insights`, `project_insight_runs`, `cli_sessions`), backfills
it from `project_id` (identical in production, so exact; the
`__legacy_unscoped__` sentinel backfills to NULL), and indexes it.
Idempotent, `to_regclass`-guarded per the 0007 pattern.
- **Stores** (`async-research-store`, `async-experiment-session-store`,
`async-todo-store`, `async-chat-store`, `async-ai-session-store`,
`async-eval-store`, `async-insight-store`, `cli-session-store`, …) stop
writing `project_id` entirely — the trigger/GUC owns the partition — and
map their domain `projectId` field to `owner_project_id` for both reads
and filters. TS types unchanged.
- **Applier** registers `OWNER_PROJECT_ID_SPLIT_VERSION = "0011"` and
advances `SCHEMA_BASELINE_VERSION`.
## Verification (re-run independently of the implementing agent)
- Core `tsc --noEmit`: exit 0 · `pnpm lint`: exit 0 · `pnpm
check:changesets`: exit 0 · `pnpm test:gate`: 185/185
- Full postgres suite: **5 failed / 807 passed** vs a **7 / 804**
baseline — the two conflation round-trips
(`satellite-db-injected-stores` ResearchStore + ExperimentSessionStore)
go green, zero new failures. The remaining 5 are pre-existing
unbound-harness `__meta`/identity failures, unrelated to this change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Corrected project-scoped persistence and queries across AI sessions,
chats (rooms + token usage), evaluations/experiments, insights,
research, and todos by separating domain ownership from RLS
partitioning.
* Prevented foreign-key and row-level security violations when storing
or retrieving project-scoped data, including legacy records.
* **Database / New Features**
* Added migration 0011 introducing `owner_project_id` and backfilling
existing rows to preserve ownership while improving isolation.
* **Tests**
* Updated migration-parity coverage to include the new baseline step.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Follow-up on **#2127** (Quality plugin already on `main`). This PR only
lands the remaining Quality deltas that were not merged:
- **Experimental gate fix** — `TaskStore.getSettings()` is async; the
gate now awaits merged settings so enabling
`experimentalFeatures.qualityPlugin` actually works, and status-bearing
errors return structured `{ status, body }` instead of collapsing to
hard failures
- **Done-task QA worktrees** — when a task has no live worktree (typical
after land), preview/task runs create a disposable checkout under
`.fusion/quality-qa/` at the task branch or merge commit so processes
run the **done task’s code**, not project root
- **Hub layout** — shared `ViewHeader` + dashboard spacing/typography so
Quality matches Insights / Compound Engineering / Goals
Scoped to `plugins/fusion-plugin-quality/**` only (rebased onto current
`main`; duplicate plugin-landing commits dropped).
## Test plan
- [ ] Enable **Settings → Experimental → Quality Plugin**, restart if
routes were cold
- [ ] Quality hub: header matches other views; refresh + presets work
- [ ] Done task → QA tab → Start preview uses QA worktree at
branch/merge commit (not project root)
- [ ] Active task with live worktree still uses that worktree
- [ ] Flag off: clear experimental-disabled error (not generic empty
failure)
- [ ] `pnpm --filter @fusion-plugin-examples/quality test` (32 tests)
Mobile operator report: the import screen spent ~4 rows on chrome — a
provider row, a tab row, and a boxed ORIGIN/filter/Load stack — leaving
only **~9 issues visible**. Import also sat *above* the preview it acts
on, and a second Import in the list footer let you import an issue whose
body you had never opened.
## Before → After (measured at 412px)
| | before | after |
|---|---|---|
| control chrome | ~4 stacked bands (93px) | **one wrapping row (70px)**
|
| toolbar | boxed band, origin on its own line | **single 36px row** |
| issues visible | ~9 | **~13** |
| Import | list footer **and** preview header | **detail bottom bar
only** |
## Layout
- **Provider, type tabs, origin, filter and Load share one row.** It
wraps rather than clipping at the narrowest widths.
- **Load is icon-only** — the label survives as `aria-label`/`title`, so
the accessible name is unchanged (the pre-existing role+name query still
finds it, which is what proves nothing was lost for screen readers).
- **The labels filter is a popover.** Its trigger doubles as the readout
— it renders the active labels and takes an `is-active` cue — so
collapsing never hides applied state. Dismisses on outside pointerdown
or Escape; Escape `stopPropagation` keeps the modal from closing along
with it.
- **Origin stays visible as an inline chip** (per your call): it's
context for what you're importing, so it flattens from a stacked
ORIGIN/repo block rather than hiding in the popover.
- Removed the toolbar's `flex: 1 1 100%` mobile stacking — it dated from
when the toolbar was a full-width band and was forcing origin to claim
an entire line.
- Also neutralised `[data-theme="light"] .github-import-toolbar`, which
re-applied the band background later in the cascade at equal specificity
(light mode only).
## Actions — one place to import
Import + Close issue move from the preview header to a **bottom action
bar**: commit actions belong below the content they act on and within
thumb reach, matching the modal's own Cancel bar. The list footer's
duplicate Import is removed (Cancel stays — the modal still needs a
dismiss), so **an issue can no longer be imported sight-unseen**.
## Note for review — interaction with #551a2a3c1
`551a2a3c1` ("align import detail header") landed mid-work and
conflicted. Its panel-padding work is preserved untouched. Its header
rules were kept **as-is** because they remain correct for a lone label,
but its stated rationale — dropping `space-between` so "Close issue and
Import stay grouped as a pair at the end" — is moot now that those
actions live in the bottom bar. The comment is marked superseded rather
than left asserting something untrue.
## Verification
Measured in a real browser at 412px (jsdom has no layout, so the tests
pin structure and behaviour, not geometry): chrome 93→70px, toolbar a
single 36px row, ~13 issues visible, filter popover clamped inside the
viewport and autofocused, detail bar below the content with an unclipped
Import label. Re-verified after the rebase.
**90 tests pass** · gate green (294/122/63) · lint clean · typecheck
clean.
Seven existing tests encoded the old UI (labelled Load, always-open
filter input). They were **updated to the new contract rather than
appeased** — the filter assertions now exercise the whole affordance
(collapsed → open → filters), and FN-7657 persistence is asserted via
the *collapsed trigger*, proving both that state survives remount
**and** that a restored filter stays visible, which is the real risk
when a control collapses.
Two things worth knowing:
- The footer guard was **confirmed non-vacuous**: reintroducing the list
Import fails it.
- One new CSS rule is deliberately a child selector
(`.github-import-controls > *`) rather than naming
`.github-import-tabs`, because `GitHubImportModal.test.tsx` extracts
base rules with a naive first-match regex
(`/\.github-import-tabs\s*\{[^}]*\}/`) that a rule mentioning them
*above* the originals would silently hijack.
🤖 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 a more compact, responsive mobile layout for the GitHub import
screen.
* Moved Import and Close issue actions to the preview’s bottom action
bar.
* Added a collapsible labels filter popover with keyboard and
outside-click dismissal.
* Consolidated mobile controls into a single compact row.
* Simplified the Load action to an accessible icon-only button.
* **Bug Fixes**
* Removed duplicate Import controls from the list footer.
* Improved control sizing, wrapping, and preview action positioning on
narrow screens.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #2158. That PR was squash-merged while this last commit was
still in flight, so these four fixes never landed on main.
### Source Control had no icon in the nav
A section's own `icon` now wins over the generic scope glyph, which
becomes the fallback. `icon` previously only rendered when `scope` was
`undefined`, so **any** scoped section was forced to the globe/folder
pair and could not identify itself — Source Control was
indistinguishable from MCP Servers and Scheduling. Both Source Control
entries now carry a git-branch icon. Nothing is lost: these sections
spell their tier out in the label (`· Global` / `· Project`), and the
aria-label and tooltip still announce it.
### Settings opens on Authentication
Previously "General · Global". Nothing else in the product works until a
provider is connected — the dashboard's own empty state sends operators
to Settings for exactly this. Authentication leads the **AI & Models**
group rather than sitting under Integrations: connecting a provider and
picking the models it gates are one task, in that order. A test asserts
the landing section is never one the Advanced switch hides, so a
default-configured operator cannot land on a section their own nav does
not list.
### The `fn` CLI binary panel moved behind Advanced, at the bottom
It used to render *first* inside "General · Global" — an
install/version/path panel was the first thing an operator saw on
opening Settings. It is machine plumbing touched once, or when an
install breaks, so it belongs with the other specialist surfaces.
### Changing section starts you at the top of it
Sections keep no scroll of their own, so the container's offset carried
over: leaving a long section scrolled halfway and picking a short one
landed mid-content — on mobile, often past everything, on an apparently
blank screen. Guarded by a ref rather than by reading the highlight key,
because a search jump also changes `activeSection` and that key
self-clears ~1.6s later, which would re-run the effect and yank the
operator off the row they just jumped to.
## Verification
- Typecheck clean (`tsconfig.app.json` — the one that covers `app/`).
- Settings suites run against this branch and against plain main:
**identical 67-failure set, 0 new**. Those 67 are pre-existing on main
(51 scheduling-merge + 16 remote-notifications) and are untouched here.
- Browser-verified: lands on Authentication; Source Control renders
`lucide-git-branch`; CLI Binary is last and hidden with Advanced off;
section change resets scroll 1200 → 0 while a search jump still holds
its row.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Evidence
Both Source Control rows now carry a git-branch icon, sized and aligned
like their neighbours; Models keeps globe/folder.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Settings now opens to **Authentication** by default.
* Added a dedicated advanced-only **CLI Binary** settings section.
* Improved navigation icons for Authentication and Source Control
sections.
* **Improvements**
* Settings sections now reliably scroll to the top when switching
sections.
* Search-result navigation preserves the selected result’s position.
* **Tests**
* Updated settings and mobile navigation coverage for the new default
section and labels.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The board composer's Save button rendered its label cut off on mobile
("Sav|").
## Root cause (measured, not guessed)
Reproduced in Chromium at a 412px viewport and measured the live layout:
- The primary action group needs **~275px** inside a **260px** column —
a 15px deficit.
- All five icon controls carry an explicit `min-width: 36px`
touch-target floor, so they **cannot** absorb it.
- Save's own automatic minimum size — which would normally floor a flex
item at its min-content width — is **zeroed by `overflow: hidden`**. Per
the flexbox spec, automatic minimum size applies only when `overflow` is
`visible`.
That `overflow: hidden` exists for a **vertical** reason
(FN-7680/FN-7683 height equalization). So a height fix silently made
Save the only horizontally-shrinkable control in the row: it absorbed
the entire deficit (**40px actual vs 55px needed**) and clipped its own
label.
## Fix
- Save is pinned to its content width (`flex: 0 0 auto; min-width:
max-content`) so it can never be squeezed. `overflow: hidden` stays — it
still owns the vertical clamp.
- The group may `flex-wrap: wrap` with `justify-content: flex-end`, so a
genuine deficit reflows to a second right-aligned line instead of
clipping.
**Not breakpoint-scoped** (FN-5751): the mechanism is width-driven, not
media-driven — mobile only trips it first because its 36px touch targets
are wider than the desktop chips. Where the row already fits, both rules
are inert.
This revises the older "wraps as one unit, never splitting Save from its
neighbors" intent: at widths where the row genuinely cannot fit, Save
wrapping to its own right-aligned line is strictly better than a clipped
label.
## Verification (in-browser, both breakpoints)
| | 412px (mobile) | 1400px (desktop) |
|---|---|---|
| label clipped | **no** (scrollWidth 67 === clientWidth 67) | no |
| Save width | 69px (full) | 69px |
| Save height | 36px — equal to icon siblings | unchanged |
| overflow | none (right edge 292 === container 292) | none |
| layout | wraps to a second right-aligned line | **no-op** — still one
line (group height 28px) |
| icon touch targets | all five still ≥36px | unchanged |
Gate green · lint clean · 288 dashboard composer tests pass.
## On the test
The CSS guard is a **string-match, not a layout proof** — jsdom has no
flex layout and cannot observe clipping, so the real proof is the
browser measurement above. The test exists so the invariant-bearing
declarations can't be silently dropped or re-scoped into a media query.
It was **confirmed non-vacuous**: 3 of its 4 cases fail against the
pre-fix CSS.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Fixed the Quick Entry Save button label being clipped on narrow mobile
screens.
* Improved action layout wrapping while preserving icon touch-target
sizing.
* **Tests**
* Added regression coverage to verify the Save button remains fully
visible across narrow layouts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GitHub/GitLab import detail panel had no inset of its own, so it sat
flush against the window's left edge while FN-8015's resize gutter left a
gap on the right only — "Preview" was clipped and the whole header read as
misaligned on both the desktop window and the mobile sheet. Give the panel a
symmetric inset, letting that existing gutter supply the right side rather
than overriding it (which would push the inner scrollbar back into the
resize hot zone the gutter protects).
Also in the header: `space-between` spread three children apart and flung
"Close issue" into the middle, so the label now takes the free space and the
two actions stay grouped as a pair. Both actions size from one rule instead
of each inheriting its own .btn defaults, at a 40px touch target on mobile
where Import is the sheet's primary action. "Preview" becomes a muted
eyebrow matching the existing ISSUE #NNNN label.
Fix title truncation while here: `.floating-window__title` declared
text-overflow: ellipsis but was display:flex, which made the text an
anonymous flex item that text-overflow cannot act on, so titles hard-cut
mid-word. Every caller passes a plain string, so a block box makes the
existing declaration work as written.
Finally, the detail title bar kept the raw upstream title while the card
below showed the translation — one item displaying two different titles at
once. Both now read importTranslation.display.title, gated on activeTab to
match translateSelection so an item's number is never paired with the other
tab's title. The existing translation test now asserts both surfaces in both
directions; it fails without this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Done-card archive and revert actions now use the existing three-dot menu.
- Remove the duplicate inline Actions dropdown and its state handling.
- Cover done-card archive and revert menu behavior through the unified context menu.
Files changed:
packages/dashboard/app/components/TaskCard.tsx | 74 ++--------------------
packages/dashboard/app/components/__tests__/TaskCard.test.tsx | 71 +++++++++++++--------
2 files changed, 50 insertions(+), 95 deletions(-)
Fusion-Task-Id: FN-8035
Fusion-Task-Lineage: 64f57e12-829c-445b-aab0-df3996af5502
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
The usage-events round-trip failed because the harness ran unbound. Production
binds `fusion.project_id` per connection (connection.ts) and only falls back to
`fusion.project_bypass=on` when no projectId is given, so an unbound harness
wrote blank project_ids that the migration-0006 trigger rewrote to
'__legacy_unscoped__' -- and helpers scoping on `layer.projectId ?? ""` then
looked for a literal '' the database never stores.
Unbound is a shape production forbids: AgentStore.backendProjectId throws on it
("Reject unbound backend heartbeat/run access instead of silently reading or
writing the legacy empty-string partition"). The harness was wrong, not the
product -- an earlier attempt to make the product accommodate the unbound
harness was reverted in b51de02a5.
Binds both the layer and the admin connection: the admin connection seeds
fixtures the layer reads back, so it must sit in the same partition or the
layer cannot see its own setup. Three reads that relied on the unbound default
now pass the project id, matching how production callers thread
`layer.projectId` -- getLiveTaskColumn resolves a missing id to the sentinel
partition, so omitting it looked in the wrong place once rows were bound.
No product code changes. 24/24.
The same binding does NOT fit the satellite suites and they are left alone:
satellite-fusiondir has a test asserting the unbound APIs fail closed (binding
defeats its premise) and another that binds two projects itself, so that
harness needs an opt-out parameter rather than a blanket bind.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks piled up in the Planning column and never moved. hasAdvancedPastPlanning
counted steps.length > 0 as proof a card had advanced past planning, but a
replan card legitimately retains the steps its previous planning pass
materialized. The still-in-planning guard therefore failed for every card
Plan Review sent back, so triage's specifyTask claim silently skipped its
status:"planning" write and re-claimed the same cards every poll — never
planning them, and starving healthy cards out of the maxTriageConcurrent
slots they held.
Steps are no longer advancement evidence while a card sits in a planner lane:
the "triage" column, and the merged "todo" planner lane used by plan-in-place
workflows when the card carries a planning status. Worktrees and
execution/terminal columns remain durable advancement evidence, preserving
FN-7977's protection against a recovery write clobbering a card that raced
ahead into execution.
The primary claim path now warns instead of returning silently; recovery-write
skips stay silent by design. The silence is why this stalled the planner for
hours undiagnosed.
Regression coverage asserts the invariant across both planner surfaces rather
than the reported repro alone: triage cards with and without an explicit
needs-replan status, plan-in-place todo replans, every parked-for-planning
status, and the advancement signals that must still fire.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to FN-8004. Both were found by watching FN-8004's *own*
merge livelock for 40 minutes — it turned out to be blocked by the very
class of bug it was filed to fix.
## 1. AI merge rejections lost their reasons
The reviewer prompt said **both** of these:
> "**End with a single decision line**: `REVIEW_VERDICT:
approve|reject`"
> "**Then list each concrete reason as a bullet.**"
Those are impossible to satisfy at once. Reviewers obeyed "End with" and
wrote their reasoning *above* the verdict — but `extractRejectReasons`
only scanned lines *after* it. So every such rejection collapsed to the
placeholder `reviewer rejected the merge without a stated reason`, and
that placeholder was then handed to the corrective re-merge pass **as
its instruction**. The pass got no actionable feedback and just
re-rolled the merge.
The evidence, from FN-8004's own merge — the pattern repeated across
*both* attempts:
| | Attempt A | Attempt B |
|---|---|---|
| review pass 1 | rejected, no reason (03:46) | rejected, no reason
(03:57) |
| corrective pass | 1/3 | 1/3 |
| review pass 2 | **approved** `a3a3cc6a8` (03:49) | approved |
A reviewer that rejects and then approves identical content isn't
objecting — the reason was being thrown away. Each wasted cycle cost ~7
minutes, stretching the merge past main's ~8-minute churn window so
every attempt lost to a concurrent advance and rebuilt. **The livelock
was caused by the lost-reason bug.**
Fix: the parser recovers reasons from either side of the verdict (inline
→ after → before, nearest-first so the closing argument leads, capped at
8 so a long transcript can't flood the corrective prompt), skipping
severity/verdict/markdown scaffolding. The prompt ordering is now
unambiguous — reasons first, verdict last, nothing after it.
## 2. An orphaned merge-active stamp was un-retryable by hand
The Retry gate refused **every** merge-active status (`Task is not in a
retryable state (current status: landing)`), while self-healing cleared
stale stamps automatically minutes later. So a merger killed mid-flight
— crash, engine restart, operator SIGTERM — blocked the operator's own
escape hatch at exactly the moment they'd reach for it. FN-8004 hit
this: a killed merge left `landing` stamped and Retry 400'd for the full
sweep delay.
`isStaleMergeActiveStatus` now lives in the leaf
`merge-active-status.ts`, shared by `recoverStaleMergingStatus` and the
Retry gate — so **the manual path can never be stricter than the
automatic one**. This is the same one-concept-two-definitions bug as
FN-8004's transient classifier, which is why it's worth fixing
structurally rather than adding another special case.
A live merge stays protected by two independent signals: it holds the
in-process lease **and** refreshes `updatedAt` each phase. Staleness
fails closed on an unparseable timestamp.
One subtlety worth reviewing: the bypass feeds `isInReviewRetry` rather
than only the gate. A bare gate bypass would fall through to the generic
branch and move fully-executed work to `todo`, **re-running finished
work** — a bug this fix could easily have introduced.
## Verification
- Gate green (294 + 122 + 63) · lint clean · engine + dashboard
typecheck clean · `verify:fast` PASS
- 70 merger-suite tests green; all 7 pre-existing verdict-parser tests
still pass (backward compatible — none of them covered the verdict-last
layout, which is exactly why this shipped)
- **The route regression test was confirmed non-vacuous**: neutralizing
the fix fails the two "now retryable" cases while the three
live-merge-protection cases still pass, proving they guard real behavior
rather than the new code.
- Regression tests assert the invariant across every surface per *Fix
the Invariant, Not the Repro*: all five `ACTIVE_MERGE_STATUSES` (a
merger can die in any phase, not just the reported `landing`), both
live-merge signals, boundary conditions, fail-closed paths, and that
pre-existing retry paths are unchanged. Test files carry the required
`## Symptom Verification` and `## Surface Enumeration` sections.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* AI merge rejections now reliably include concrete, correctly ordered
reasons, even when provided before the verdict line.
* Manual retry can recover tasks stuck in stale merge-processing states.
* Retry is still blocked for tasks tied to active merge activity or
recently updated/advancing merges.
* Existing failed-merge retry behavior remains unchanged.
* **Reliability**
* Improved shared handling of “orphaned” merge-active detection across
the engine and dashboard.
* **Tests**
* Added/expanded coverage for merge-active staleness, retry eligibility,
and verdict/reason parsing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>