## Summary
Approving or rejecting a plan now releases the task instead of leaving
it permanently blocked as `awaiting-approval`.
The workflow routes use the task store's durable clear semantics, and
approval records the current plan fingerprint while safely clearing a
stale fingerprint when the plan cannot be read. Real PostgreSQL route
coverage proves both actions clear the scheduler hold and persist the
expected fingerprint state.
Fixes#3322.
## Validation
- `pnpm --filter @fusion/dashboard exec vitest run
src/__tests__/plan-approval-status.pg.test.ts --silent=passed-only
--reporter=dot`
- `pnpm --filter @fusion/dashboard exec vitest run
src/__tests__/routes-github.test.ts --silent=passed-only --reporter=dot
-t 'POST /tasks/:id/(approve-plan|reject-plan)'`
- `pnpm --filter @fusion/core exec vitest run
src/__tests__/task-update-awaiting-approval-reason.test.ts
--silent=passed-only --reporter=dot`
- Core and dashboard typechecks
- Scoped ESLint and strict changeset validation
---
[](https://github.com/EveryInc/compound-engineering-plugin)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Manually approved plans now resume correctly in their workflow.
* Rejected plans are fully cleared, preventing stale approval
information from affecting future decisions.
* Approval records accurately reflect the latest plan, including when
plan details are unavailable.
* Failed cleanup keeps rejected plans from being released prematurely.
* **Tests**
* Added coverage for approval and rejection persistence, workflow state,
and cleanup failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Advance the durable creation epoch for each explicit Planning Mode action while preserving idempotency for transport retries. Recover soft-deleted task links with a fresh claim key and cover live, deleted, and retry paths.
## Summary
Restores the non-blocking full suite on `main` after a multi-package
failure cluster (latest red run:
[30777132347](https://github.com/Runfusion/Fusion/actions/runs/30777132347)).
### Product fixes
- **MissionManager**: move `handleDiscardInterviewSession` /
`handleConfirmDelete` **above** `if (!isActive) return null` so hiding
the inline Missions tab does not drop hooks (`Rendered fewer hooks than
expected`).
- **task-update dependency re-spec**: emit `task:moved` only when the
column actually changes, with resolved endpoints (not hardcoded
`todo`→`triage`); fixes `laneCache.set is not a function` harness
failures and deleted-column announcements.
### Bookkeeping / suite alignment
- **Schema applier tests**: baseline `0042`, project table count `104`
(+4 FN-8685 consumer tables), historical `0000` fixture includes
`mission_validator_runs` for 0042 upgrades, version lists include
0041/0042.
- **Ledgers**: archived-gate TS inventory (lifecycle-ops), legacy
collection ledger (`LEGACY_PLANNER_WAKE_COLUMNS`), worktree capacity
audited bounds + two admission readers (scheduler + triage).
- **Desktop**: pin `actions/setup-java@v5.6.0` after Dependabot bump.
- **Dashboard tests**: FN-8701 ToolCallDetails nested `<pre>`;
CreateRoomModal re-pins agent mock after empty-once.
## Test plan
- [x] `@fusion/core` task-update + ledger tests
- [x] `@fusion/core` schema-applier subset (table count,
0000/0001/0002/0003 upgrades, concurrent)
- [x] `@fusion/dashboard` AgentLogViewer / CreateRoomModal /
MissionManager overview tests
- [x] `@fusion/desktop` release-workflow tests
- [ ] CI full-suite green on this PR
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Bug Fixes**
- Dependency re-specification now moves held tasks to the resolved
workflow intake lane when available.
- Task move events are emitted only when a task’s column changes.
- Improved mission draft deletion handling for locked, missing, and
failed operations.
- Prevented mission management issues when inline Missions are shown or
hidden.
- Updated database migration handling for lifecycle-related tables.
- **Tests**
- Expanded coverage for workflow lanes, database upgrades, worktree
limits, and release workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
FN-8704 failed at the graph parse node because PROMPT.md was only
"DUPLICATE: FN-8676". Filesystem validation treated non-empty as planned
and admitted the card into WIP, which then looped on parse failure.
Treat a sole DUPLICATE redirect as unplanned: block dispatch and hold
release, badge as awaiting planning, and if parse still sees that shape
rebound to needs-replan with feedback instead of parking failed.
Failed parks left in the WIP column still counted as running agents and
file-scope lease holders, so they consumed maxWorktrees/maxConcurrent and
could serialize unrelated todos. Match review-lane semantics: status
failed is never a live top-level holder.
Expand task update lane coverage across every direct and failure-isolated producer.
- Inventory direct and safe task:updated producer routes.
- Exercise warm and cold lane metadata through public producer operations.
- Cover lifecycle, workflow integrity, and completion update paths.
Files changed:
.../task-updated-lanes-emit-surfaces.test.ts | 308 +++++++++++++++++----
1 file changed, 256 insertions(+), 52 deletions(-)
Fusion-Task-Id: FN-8658
Fusion-Task-Lineage: 7d93593c-6f5a-4299-96f3-f4ee8f260e41
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
The field was defined in persistence and serialization, the executor's Plan
Review replan-cap park wrote it, the triage manual gate null-cleared it, and
the dashboard special-cases it (isReviewBudgetExhaustedApproval badge + detail
explanation) — but updateTask's field-by-field merge never applied the key, so
every writer silently dropped it. FN-8647's 15-cycle non-converging Plan Review
loop therefore parked with a generic 'needs approval' and no hint it was a cap
escalation.
Merge contract, pinned by tests with a measured revert proof (3/4 fail
pre-fix): set persists, explicit null clears, a status write that leaves
awaiting-approval without addressing the reason auto-clears it so an approved
or replanned card cannot carry a stale escalation reason into its next park,
and unrelated updates leave it untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
**`check-fnxc-future-dates` exits 1 on `origin/main`.** This is the last
stamp causing it.
```
packages/core/src/task-store/lifecycle-ops.ts: 1 future-dated FNXC stamp, baseline allows 0
FNXC:Diagnostics 2026-08-01-00:50 (today is 2026-07-31)
```
Corrected to `2026-07-31-00:50`. One character.
`check-fnxc-future-dates` now exits 0; `tsc --noEmit` clean.
## Why this was left behind
Four PRs converged on this red main — #3262, #3263, #3265, and my own
#3266 (closed as superseded). Between them they covered the census rise
and the boundary-work stamps. **None touched `lifecycle-ops.ts`**, so
the gate stayed red after the others landed.
That is the predictable failure of parallel work on one symptom:
everyone fixes the part they saw first, and the residue survives because
each author checked "is main green now?" against their own branch rather
than against main.
## I claimed before working this time
```
node scripts/check-file-claimed.mjs packages/core/src/task-store/lifecycle-ops.ts
→ UNCLAIMED
```
Then pushed the branch before editing. I did the opposite on #3266 —
built it, then discovered #3265 already covered it — which was the sixth
duplication of the phase and my third. The tool answers in one command;
the discipline is running it *first*.
## Verification
- `check-fnxc-future-dates` — **exit 0** (was exit 1 on main)
- `census --strict` — exit 0 (already green; #3265's marker landed)
- `tsc --noEmit` (core) — 0 errors
- one-character diff, no behaviour change
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What
Pins the **last two uncovered lane reads** in `packages/core`.
Test-only. This closes the per-site core audit.
| site | what it decides |
|---|---|
| `async-mission-store.ts:1179` | is an ARCHIVED card valid terminal
evidence for mission repair? |
| `task-id-integrity.ts:502` | does an archived child still count as a
LIVE lineage child? |
## Measured
```
mission-store: 39 passed clean; 1 failed | 38 passed blinded
lineage: 3 passed clean; 1 failed | 2 passed blinded
lint clean; fnxc-future-dates: none added; census unchanged
```
Both blinds confirmed applied with `git diff --stat` before each run.
## The third adjacent-pair split
`:1179` is the **archived** half of a pair whose **complete** half
(`:1178`, *one line above*) was already covered by a test in the same
file, written for exactly this concern. Terminal evidence is "done OR
supported archived state," so an archived card is equally valid repair
evidence — but on a board whose archive lane is `vaulted` the archived
half could not see it, and reconciliation threw `TASK_NOT_TERMINAL` for
a card that was genuinely filed away. Same refusal the covered case
fixed, reached through the other door.
That is now the third confirmed instance in core (after `team-analytics`
in #3227 and the scheduler pair earlier). **Being adjacent to a covered
resolver is not coverage**, and it is the most reliable place to look.
## What breaks without the lineage read
An archived child is filed away, not live, so it must not hold the
delete gate shut. Renamed, it still counted as live and
`TaskHasLineageChildrenError` blocked the parent's delete **forever** —
the operator archived the child *precisely* to clear the way, and the
gate could not see that they had.
## A fixture detail I got wrong first
My first mission fixture created a live card in a `vaulted` column and
failed with `deleted or archived without a valid retained tombstone and
archive snapshot` — nothing to do with the lane read.
The `archived` verdict requires **all three** of `deletedAt !== null`,
an archive-snapshot row, and `isArchived(column)`. A live card merely
sitting in an archive-trait column is `invalid-deleted`, not `archived`.
The test now archives for real and *then* renames the recorded lane,
which isolates the third condition — the only one under test. Recorded
in the file so the next person does not re-derive it.
## Paired positives
Both files pin the complement: a WORKING child still counts as live.
Recognising the renamed archive lane must not degrade into "no child is
ever live" — that would silently **disable** the lineage gate and let a
parent be deleted out from under real descendants, which is worse than
the bug being fixed.
## Core audit complete
**14 sites blinded individually: 9 already covered, 5 uncovered, all 5
now pinned** (#3233, #3234, this PR).
Every `resolveProjectColumnsForRoles` call site in `packages/engine` and
`packages/core` has now been blinded. Remaining unaudited: `dashboard`
(2 files) and `cli` (1) — I claim nothing about those.
Found by a live browser E2E of the coding workflow in test mode: every
scripted full-task run failed at `steps#0:step-execute` with `Step 4 out
of range (task has 4 steps)`, rebounding through recovery forever.
**Root cause:** `fn_task_update.step` has been **0-based since FN-6607**
(executor.ts FNXC:StepNumbering — the old `step - 1` conversion made
Step 0 impossible to mark). `mock-provider.ts` still sent `index + 1`,
so test mode marked steps 1..N instead of 0..N-1: Step 0 (Preflight)
never completed and step N threw out-of-range. Test mode's full-task
path has been broken since June.
**Also fixes the test that pinned the bug:** `mock-provider.test.ts`
expected `{ step: 1 }` for a fixture whose first unfinished step is
index 0 — the expectation encoded the 1-based off-by-one.
Verified: 12/12 mock-provider tests; the live E2E instance completes the
task after this patch (see follow-up screenshot in the session).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## What
Pins **both** create-time duplicate guards in
`branch-and-pr-entities.ts`. Test-only.
| site | method | excludes |
|---|---|---|
| `:445` | `findRecentTasksByContentFingerprint` | ARCHIVED (unless
`includeArchived`) |
| `:484` | `findRecentTasksBySourceParentTaskId` | COMPLETE and ARCHIVED
|
Blinding either back to its literals left the entire 16-file
lane-detector set green. **No test in `packages/core` reaches either
method.**
## Measured
```
converted: Tests 8 passed (8)
blinded :445 Tests 1 failed | 7 passed (8) <- only the fingerprint case
blinded :484 Tests 2 failed | 6 passed (8) <- only the sibling cases
lint clean; fnxc-future-dates: none added; census unchanged
```
**Each blind fails exactly its own cases.** That matters: it proves the
two resolvers are pinned *independently*, rather than one broad test
appearing to cover both. Blinding `:445` leaves every sibling case green
and vice versa — so neither is riding on the other's coverage.
## They fail in opposite directions
This is why both belong in one file:
- **Fingerprint guard** — a renamed board leaves archived cards in the
candidate set, so filing a new task is **refused as a duplicate** of one
the operator already archived. The create is blocked and the thing
blocking it is invisible.
- **Sibling guard** — a renamed board leaves finished siblings in the
"recent live siblings" set, so completed work keeps counting as active.
One over-includes into a *refusal*, the other over-includes into
*phantom activity*. Neither raises an error.
## Positives pinned too
A LIVE fingerprint match is still a duplicate candidate;
`includeArchived: true` opts the renamed archived lane back in; a
WORKING sibling is still live. Excluding the finished lanes must not
degrade into excluding everything, or the guards stop guarding — the
failure mode a lane-widening change invites.
## A fixture detail that would have made this vacuous
Both queries cut off at `Date.now() - windowMs`, with `windowMs` capped
at 24h. The sibling harness I copied from seeds a **fixed past
timestamp**, which falls outside that window — every case would then
pass on an empty result, including the ones that are supposed to fail
under blinding. Fixtures are seeded at current time instead, and the
reason is recorded in the file so nobody "tidies" it back to a frozen
date.
## Progress
3 of the 5 uncovered core sites are now pinned (`store.ts:1135` in
#3233, these two here). Remaining and unclaimed:
`async-mission-store.ts:1179` and `task-id-integrity.ts:502`.
## What
Pins the **open-undo query's finished-lane exclusion** in
`packages/core/src/store.ts`. Test-only.
`findOpenRevertTaskForSource` answers *"is there an OPEN undo task for
this source?"* — the question behind the dashboard's Undo affordance. It
answers by **excluding the finished lanes**, so a prior undo that
already landed does not keep rendering as open.
Blinding that exclusion back to `ne(column,"archived"),
ne(column,"done")` left the entire 16-file lane-detector set green. **No
test in `packages/core` reaches this method at all.** The dashboard-side
twin (`taskRevert.ts`, #3129) is tested; the store-side query behind it
was not.
## Measured
| | default (control) | renamed complete | renamed archived | working
lane |
|---|---|---|---|---|
| converted | pass | pass | pass | pass |
| blinded to `["done","archived"]` | pass | **FAIL** | **FAIL** | pass |
```
converted: Test Files 1 passed (1) / Tests 4 passed (4)
blinded: Test Files 1 failed (1) / Tests 2 failed | 2 passed (4)
lint clean; fnxc-future-dates: none added; census unchanged
```
Blind confirmed applied with `git diff --stat` before the run.
## What breaks without it
On a board whose complete lane is `shipped`, neither literal matches, so
a **done** undo task is never excluded and the query keeps returning it.
The card shows an undo already in flight *forever*, and the real
affordance is unreachable. Nothing errors — the button is just
permanently wrong, which is why it went unnoticed.
## Includes the paired positive
An undo still in a **working** lane IS reported as open. Excluding the
finished lanes must not degrade into excluding everything, or the
affordance breaks in the other direction and no undo is ever reported in
flight. Both new failing cases are renamed-lane cases; both survivors
are cases that should survive.
## Where this came from
Per-site blinding of all 14 remaining `resolveProjectColumnsForRoles`
call sites in `core`, run against a 16-file detector set. **9 covered, 5
uncovered:**
| site | verdict |
|---|---|
| `store.ts:1135` | **uncovered** → pinned here |
| `async-mission-store.ts:1179` (archived) | **uncovered** — its
neighbour `:1178` (complete) is covered |
| `branch-and-pr-entities.ts:445` | **uncovered** |
| `branch-and-pr-entities.ts:484` | **uncovered** |
| `task-id-integrity.ts:502` | **uncovered** |
| `reads.ts` ×3, analytics ×3, `eval-automation`, `task-artifacts-ops`,
`async-mission-store:1178` | covered |
The first run of that probe was **invalid and I nearly published it**:
it reported all 14 sites "COVERED" with *zero failing tests*. zsh does
not word-split unquoted parameter expansions, so `vitest run $DET`
passed 16 paths as one argument and vitest exited 1 with "No test files
found" — which my script read as a failing test. The re-run treats that
string as `INVALID` rather than a result. Third time this session a
wrong reading came from test *selection* rather than from blinding.
## Flagged, not guessed
The four remaining uncovered sites are named above rather than quietly
left; `async-mission-store` shows the same adjacent-pair split as
`team-analytics` in #3227, which is now the third confirmed instance of
that shape.
## What
**Fixes a red main.**
`workflow-reconciliation-production-shape.pg.test.ts` has been failing
with `expected 'todo' to be 'triage'`. Test-only.
Found while establishing a clean baseline for an unrelated coverage
audit — my tree was clean at `origin/main` (`76c73238a0`), so this is
not something I introduced. It is in the non-blocking suite, which is
why it has stayed red.
## It is not a regression — the test was the stale half
The delete path was deliberately fixed to re-home occupants using
`resolveEntryColumnId(resolveDefaultWorkflowIr())` instead of
`BUILTIN_CODING_WORKFLOW_IR`. This assertion was not updated with it.
The two IRs are **not the same board**:
| IR | entry column |
|---|---|
| `BUILTIN_CODING_WORKFLOW_IR` (`builtin:legacy-coding`) | `triage` |
| `resolveDefaultWorkflowIr()` (the catalog default) | `todo` |
Re-homing into `triage` put cards in a column the default board never
declares. It slipped past `moveTask`'s undeclared-target guard **only
because `triage` is a legacy id** and the recovery-rehome path exempts
those — so the guard that exists to stop exactly this could not see it.
So `todo` is the correct behaviour and the literal `"triage"` was what
needed fixing.
## Why it asserts a resolver rather than `"todo"`
Swapping one hardcoded id for another would be the identical trap one
rename later — the same class of defect this whole program exists to
remove. The expectation now derives from **the same two functions the
product path calls**, so it cannot drift out of sync with them again.
I also added the complement: the card must genuinely have **left** the
vanished column, not merely match whatever a resolver returns. Without
it, a resolver that started returning `custom-hold` would pass.
## Proven not appeasement
Reverting the product line to the legacy IR — the original defect —
fails this test:
```
AssertionError: expected 'triage' to be 'todo'
Test Files 1 failed (1) / Tests 1 failed | 6 passed (7)
```
That is the check that matters for a test edit that turns a red green.
It fails on the defect it describes.
## Measured
```
before: Tests 1 failed | 6 passed (7)
after: Tests 7 passed (7)
16-file detector set: 181 passed (16 files) [was 1 failed | 180 passed]
lint clean
```
## Note on the reading
I got this wrong twice before getting it right, and the record is worth
having. My first read was "the test is stale, `triage` was merged away."
My second was "the builtin IR still declares `triage`, so the
*behaviour* is the defect" — which the IR file superficially supports.
Only the third reading, of the FNXC note at the fix site, showed the
file I was reading is the **legacy** IR and not the default one. Two of
those three readings would have produced a confidently wrong PR; the
deciding evidence was the comment the fixing author left at the call
site, which is a good argument for writing them.
## What
Pins `aggregateTeamAnalytics`' **in-flight lane read** (`activeLanes`) —
the unpinned half of an adjacent resolver pair. Test-only, no product
change.
`completeLanes` and `activeLanes` are declared **two lines apart**.
Every existing case in this file asserts only `totals.tasksCompleted`,
so the in-flight query `activeLanes` feeds was never observed.
Measured on main:
| blinded resolver | result |
|---|---|
| `completeLanes` → `["done"]` | **FAILS** the file (1 failed / 3
passed) — pinned |
| `activeLanes` → `["in-progress","in-review"]` | **entirely GREEN** (4
passed) — unpinned |
One resolver held, its neighbour not, in a file named
`team-analytics-renamed-lanes`. This is the half-covered-pair shape the
program keeps finding; being *next to* a covered resolver is not
coverage.
## How I found it
Rather than blind core's 17 files one at a time, I made
`resolveProjectColumnsForRoles` itself return legacy-only — its own
documented degrade path — which blinds **all 116 call sites in one
edit**. The full core suite then reported **24 failures across 16
files** out of 4,987 tests, which maps the covered areas in a single
run: the analytics renamed-lane pg tests, the archived-lane family,
eval-automation, mission-store, and the resolver's own tests.
That global probe finds *areas* that are covered, not *resolvers* — so
the pairs still needed individual blinding, which is what surfaced this
one. `workflow-analytics.ts` has the identical two-resolver shape and
**both halves are covered**; the gap is specific to `team-analytics.ts`.
## Two things have to be right, and both are now asserted
1. **The SQL must ASK for the board's real wip lane** — `activeLanes`.
2. **`buildTeamAnalytics` must RECOGNISE the row it gets back.** It
classifies via `isWipColumnRole(query.columnFlagsByName?.get(name),
name)`, which **without flags falls back to `name === "in-progress"`**
and drops a renamed lane it already fetched.
So supplying `columnFlagsByName` is part of the caller contract, not
test scaffolding: **widening the query alone would still report zero.**
A test that only widened the first half would pass while the feature
stayed broken.
## Measured
```
converted: Test Files 1 passed (1) / Tests 7 passed (7)
blinded activeLanes: Test Files 1 failed (1) / Tests 2 failed | 5 passed (7)
lint clean; fnxc-future-dates: none added; census unchanged
```
Blind confirmed applied with `git diff --stat` before each run.
## What breaks without it
A per-agent `tasksInProgress: 0` sitting beside a nonzero completed
count and real token spend — an agent that looks idle while it is
working. Same wrong-but-plausible shape this file's own header
describes: nothing errors, and a plausible-looking number is the least
likely defect for anyone to file.
## Flagged, not guessed
- `packages/core` is not my package; this is additive tests only. I
raised the same note on #3225.
- The global probe shows core has **substantial** renamed-lane coverage
— it is not the uniformly-unpinned surface I implied when I first
reported 17 unaudited files. Correcting that here rather than leaving
the stronger claim standing.
- Still unblinded individually: the resolver pairs in
`async-mission-store.ts` (1178/1179) and the archived reads in
`task-store/reads.ts` (396/615/793). Their *files* fail under the global
blind, so something covers each area — but that is not per-resolver
evidence, and I am not claiming it is.