Commit Graph

11244 Commits

Author SHA1 Message Date
gsxdsm
fb53a96eaa test(engine): the lease-seam alarm fired downward — re-point it, and close two ways it could pass without a fix (#2987)
## What happened

Both of my source-level audits went red on the advance that landed
#2975. They are exact counters, not floors, so this is the alarm working
**downward** — the direction it was written for. #2975 converted the two
self-healing `shouldHoldActiveFileScopeLease` call sites and closed the
2-of-4 seam that
`workflow-file-scope-lease-caller-gap-live-e2e.pg.test.ts` was
measuring.

I judged the conversion real before updating anything. Both sites now
derive their answers from `resolveProjectColumnsForRoles(...)` sets the
sweep had already resolved a few lines above (`self-healing.ts:4754`,
`:5825`) — trait membership, not a literal. So the numbers moved on
purpose.

## The part that is not bookkeeping

Re-pointing an audit to whatever the code now says is how a guard goes
dead. Both assertions could have been satisfied by something that is
**not** a fix, so both were tightened:

| Way it could pass without a fix | Old assertion | Now |
|---|---|---|
| `isWipColumn: true` hardcoded at a self-healing site — the original
defect wearing the converted call shape, answering "yes" for a blocker
resting anywhere | only checked the key was *absent* | requires
resolved-set membership: `/isWipColumn:\s*\w+\.has\(\w+\.column\)/` |
| a site answering one of the two independent role questions and not the
other | `includes(a) \|\| includes(b)` counted it as converted | `&&` |

The scheduler's own two sites *do* pass literal `true`, correctly — they
have already filtered to a role-resolved bucket, so there the answer is
a fact about the loop, not about the card. The form check is scoped to
`self-healing.ts` for that reason.

## Mutation evidence

Not reasoned — measured. Each mutant applied to `self-healing.ts`, suite
re-run, file restored:

| Mutant | Result |
|---|---|
| baseline | 8 passed |
| M1 — hardcode `isWipColumn: true` at one site | **1 failed** |
| M2 — drop `isReviewColumn` at one site (half-converted) | **2 failed**
|
| M3 — revert both sites to pre-#2975 | **2 failed** |

M2 is the one that justifies the `&&`: re-running it with the counter
reverted to `||` leaves the file **green (4/4 passing)**. The old
counter provably could not distinguish a closed seam from a half-closed
one — the exact blind spot this file exists to remove.

## Verification

`test:gate` exit 0 · live-PG E2E surface **171/171** · lifecycle-column
census exit 0 · FNXC date ratchet exit 0 · `pnpm lint` clean. Production
files untouched (`git status` clean on `self-healing.ts` after every
mutant).

## Scope

The other measured seam, `evaluateParkedAgentTaskLink`, is **unchanged
at 2-of-6** — four callers still omit the resolved columns, so the class
is not closed, only one of its two instances is. That remains
characterized, not fixed, in the same file; converting those four is the
capacity worker's file, not mine.

The call-site facts are still asserted against source text rather than
driven through the self-healing sweep, which would need the full
dependency-lease reconcile harness. That limit was stated in the
original file and still is.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Updated end-to-end workflow checks to verify resolved workflow-role
values at active lease call sites.
  * Strengthened assertions for WIP and review column detection.
* Updated audit coverage to reflect conversion of all active-file-scope
lease callers.
  * Preserved tracking for the remaining parked-link integration points.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 23:38:10 -07:00
gsxdsm
a57f6699b3 fix(cli): fn task list never printed cards in renamed columns (#2986)
## `fn task list` never printed cards in renamed columns

```ts
for (const col of COLUMNS) {              // the legacy six ids
  const colTasks = tasks.filter((t) => t.column === col);
```

A task in a workflow-defined column matches **no iteration**, so it is
not printed. This is not a wrong label or a wrong glyph — **the card is
absent**, and the output reads as a shorter, healthy board rather than
as a bug. On a fully renamed board the command prints nothing but the
header. `COLUMNS` also still contains `triage`, which U11 (#2515)
deleted.

## Found where the previous author left it

The `DELIBERATE-LITERAL` note directly above this loop is correct about
its own glyph, and it named the deeper bug rather than hiding it:

> NOT claimed as trait-resolved, and the deeper bug is left alone:
because the loop iterates the legacy enum, a card in a workflow-renamed
column is not rendered AT ALL. That is the R8/U10 surface change […] and
a far bigger fix than this glyph.

It also predicted the coupling: *"If this ever iterates
workflow-resolved columns, that difference becomes live and the right
answer is a trait lookup, not this."* So both move together — once the
loop can yield a custom id, the terminal test **must** stop being an id
comparison. Fixing only the loop would leave a renamed done-lane
rendering as active work.

## Two deliberate choices

**Lanes come from the tasks, not from a resolved IR.** A board can span
several workflows and therefore has no single column list, and a card
must never depend on a resolution succeeding in order to be *visible*.
Legacy ids keep their familiar order and labels; anything else follows
alphabetically, so output is deterministic.

**Terminal lanes are resolved**, via
`resolveProjectColumnsForRoles(TERMINAL_ROLES)` — that is a display
question with a real answer, and this function is async with a store in
hand. Best-effort: a failed resolve falls back to the legacy pair rather
than failing the command, and an unresolved custom lane renders as
*active*. Showing a finished card with the wrong glyph is a far smaller
error than the blank board this replaces.

## Coverage, and its limit stated plainly

The lane-selection decision is extracted to an exported seam and tested
there. It is **not** end-to-end: `runTaskList` resolves a real project
context and ends in `process.exit`, so driving it would need the
mock-the-world shell `docs/testing.md` tells us to avoid when a narrower
seam exists. The call site is held by the compiler instead — the loop's
only source of lanes is that function. I have written this in the test
file rather than leaving it implied, because "5 passed" on a helper
could otherwise read as proof of the command's behaviour.

Reverted — the seam returning `[...COLUMNS]`, which is exactly what the
loop did — **all 5 cases fail**:

```
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'backlog', 'building', 'checking' ]
AssertionError: expected [ 'triage', 'todo', …(4) ] to deeply equal [ 'todo', 'shipped' ]
      Tests  5 failed (5)
```

## Verification (measured)

- **86 passed / 3 files** — new suite plus `bin.test.ts` and
`pr-merge-review-lane.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (26, none
added), `check-fnxc-future-dates` — green
- `pnpm check:changesets` — clean; changeset included (`patch`), since
`packages/cli` is the published `@runfusion/fusion` and this is
user-facing
2026-07-30 23:29:56 -07:00
gsxdsm
d541c3154e fix(dashboard): retire the dead .agent-detail-overlay CSS (#2915) (#2985)
Closes #2915. `.agent-detail-overlay` has had **no renderer** since
FN-8619 moved Agent Detail onto `FloatingWindow`, whose `modal` host
owns the scrim (`.floating-window-overlay--modal`). Four CSS sites plus
one inert mobile `@media` rule.

## Why this sat unfixed, and what unblocked it

I filed this earlier and deliberately did **not** delete the CSS,
because two *passing* guards pinned a selector list naming the class:

```
dashboard-overflow-containment.test.tsx:295
mobile-horizontal-pan-containment.test.ts:90
  ".modal-overlay:not(.confirm-dialog-overlay),\n  .agent-detail-overlay,\n  .agent-dialog-overlay,\n  .workflow-output-modal-overlay"
```

Neither list mentions `.floating-window-overlay--modal`. If that list
were the mechanism keeping modal scrims inside mobile horizontal-pan
containment, then FN-8619 moved every migrated modal's scrim out from
under the guard and the dead entry was **masking a live defect** —
deleting it would have been the wrong move twice over.

I said at the time I couldn't settle it without rendering at a phone
breakpoint. **That was wrong — it is answerable by reading**, and I only
went back because the same mistaken conclusion cost me a day on the
Planning coverage in #2982.

**The containment lockdown is global**, on the mobile `html, body` block
in `styles.css`:

```css
@media (max-width: 768px) {
  html, body {
    overflow-x: hidden;
    overscroll-behavior-x: none;
    touch-action: pan-y;
  }
```

`FloatingWindow.css` says so itself at its mobile breakpoint — *"Mobile
keeps the global `styles.css` pan-y lockdown so the dashboard cannot
drift"*. The per-overlay list only reasserts it for overlays that are
themselves scroll containers. Migrated modals are covered by the global
rule, so **no hole, and no masked defect**.

## Verified the guards still guard something

The risk in editing a pinned selector string is turning a real guard
into a string-equality formality:

| state | result |
|---|---|
| after this change | 15/15 pass |
| global lockdown broken (`touch-action` / `overscroll-behavior-x`
removed from `html, body`) | **2 failed / 13 passed** |

They fail on the mechanism, not the text.

## Scope note

Two of the four `styles.css` sites are **grouped selectors shared with
`.agent-dialog-overlay`, which is still live**
(`NewAgentDialog.tsx:415`). So this is a selector-list edit, not a block
deletion — easy to get wrong in a bulk sweep, which is why it is called
out here and in the FNXC note replacing the deleted rule.

The retired mobile rule set `padding: 0; align-items: stretch` on the
overlay. Not a lost feature: `.floating-window-overlay` is `position:
fixed; inset: 0` with no flex context, so those declarations had nothing
to act on — FloatingWindow positions the panel by geometry.

**Verified:** 120/120 across `agent-modals-mobile`,
`core-modals-mobile`, `AgentDetailView.core`, and both containment
guards; `tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit
0.

Dead-CSS removal with no behaviour change, so no changeset. Main health
while I was here: engine **11475 passed / 0 failed**.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:24:33 -07:00
gsxdsm
94df80bbd6 test(dashboard): restore the Planning project-switch invariant at its new home (last lane red) (#2982)
Clears the **last red test in the dashboard lane** (measured on `main`
at `41af5e5dbd`: `1 failed | 11180 passed`) and restores a leak-class
invariant that has had no assertion since FN-8619.

## What was wrong

FN-8619 moved Planning out of `MainContent` (its branch returns `null`)
into `PlanningKeepAlive`, mounted by `App`.
`MainContent.planning-project-remount.test.tsx` kept asserting there, so
it could only fail — and nothing asserted the invariant at the new
location. **Deleting the project id from `App.tsx:1956`'s key, or
loosening the gate at `:1954`, failed no test.**

That invariant is not cosmetic. Before the project-keyed host, Planning
kept a running plan's stream, selected session and sidebar list from the
**previous** project, and persisted its session under the **new**
project's storage key.

## I withdrew this test once, and that was my mistake

I wrote it earlier, mutated each guard, saw it stay green both times,
concluded it was vacuous, and handed the problem back. **That reasoning
was wrong.**

App defends this invariant **twice, independently**:

1. the `planningEverOpenedProjectId === currentProject.id` gate, which
unmounts the host for a project that never opened Planning; and
2. the project id inside the host's `key`, which forces a remount
instead of reconciling A's live instance under B.

Either alone upholds the contract. So a single-guard mutation *should*
leave a correct test green — that is defence in depth working, not a
hole. The probe that settles it is breaking **both**:

| mutation | result |
|---|---|
| both guards intact | **pass** |
| key only (drop project id) | pass |
| gate only (`!== null`) | pass |
| **both** | **fail** |

I had mutated guards, not the invariant, and mistook redundancy for
vacuity. Worth recording because it is the opposite error from the one I
have been making all day: I have caught four genuinely vacuous guards by
mutation, and that success made "survived a mutation" read as "proves
nothing" when the honest reading was "the system has a second defence."

## The assertion, and why it is `gone OR different node`

An earlier draft asserted the subtree must be **absent** after the
switch. Wrong: `planningViewActive` stays true, so the latch re-arms for
the new project and a fresh host is expected. Both outcomes satisfy the
real contract — *project A's instance is not reused* — which is what
`subtreeForA.isConnected === false` pins.

## Scope

- Planning case moves to `App.test.tsx`, which owns both halves of the
guarantee.
- `MainContent.planning-project-remount.test.tsx` keeps its **Chat** and
**Missions** cases — those still render from MainContent — and gains a
note saying where Planning went, so it is not re-added there.

**Verified:** `App.test.tsx` 143/143,
`MainContent.planning-project-remount` 2/2, `tsc -p tsconfig.app.json` 0
errors, lint clean, FNXC gate exit 0. Test-only; no product code
touched, no changeset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:16:37 -07:00
gsxdsm
16921fc518 fix(engine,core): role resolution was half-done in two shared lifecycle predicates (surfacing family + file-scope leases) (#2975)
The three surfacing sweeps stopped reporting anything for a card resting
in a board's **second** review or hold column.

A lifecycle role is a **trait**, and any number of columns may carry it.
The shared runner resolved it with `resolveLifecycleColumns()[role]` —
**first match** — then gated on it:

```ts
const roleColumn = lifecycle?.[spec.role];        // FIRST column carrying the trait
if (task.column !== resolved.roleColumn) continue; // everything else dropped
```

A workflow that splits human sign-off from the merge lane has two review
columns; one that parks dependency-blocked cards separately has two hold
columns. Cards in the second got **no stale-paused-todo, no
stale-paused-review, no in-review-stalled** diagnostic — silently, with
no error, on all three sweeps at once.

## The second bug hiding inside the fix for the first

Resolving membership but still reading `roleColumns[0]`'s declared
`recovery` applies the **merge lane's** threshold to a card sitting in
the **sign-off** lane. Each card's policy now comes from its own column,
and one of the new cases fails if it doesn't: the first role column
declares a policy that suppresses the signal, the card's own column
declares one that fires.

## Reverted

| | |
|---|---|
| **6 of 12** new cases fail | `fires for a card in the SECOND column
carrying its role` and `reads the recovery policy of the card's OWN role
column` — × 3 sweeps |
| the other 6 pass either way | non-regression halves: still fires for
the FIRST role column, still does **not** fire for a card outside every
role column. Membership must widen the gate, not move it. |

The pre-existing 45 cases were all green throughout — the
single-role-column fixture could not express the case, which is why the
table-driven file that exists to stop these three sweeps drifting apart
never caught it.

## Verification

`pnpm test:gate` 161 + 13 + 487 + 71 · surfacing family 57 · core
stale-paused 20 · lint · census `--strict` · sql-literals · fnxc-dates ·
lane-wiring · changesets — all green.

## Note

`holdColumns` was missing from the lane-wiring vocabulary, so the gate
could not see that argument dropped. Added in the same commit.

While reviewing, I found and measured **two problems in #2974** (comment
posted there): six of its newly-visible sites are `satisfies`-wrapped
false positives, and baselining them means deleting a real
`reviewColumns` argument keeps the count unchanged and the gate green;
and its baseline predates #2970, re-opening the slot that PR closed.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved stale-card detection across all applicable review and hold
columns.
* Cards are now surfaced using the policies configured for their
specific lifecycle column.
* Cards outside matching lifecycle columns are no longer incorrectly
surfaced.
* Preserved existing fallback behavior when no lifecycle columns are
configured.

* **Tests**
  * Added coverage for workflows with split review and hold columns.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Second commit: the same predicate, half-converted
(`shouldHoldActiveFileScopeLease`)

Folded in here rather than stacked — same file, same class, and a
stacked PR on an unmerged base is not mergeable. Reversible; say the
word and I'll split it.

`shouldHoldActiveFileScopeLease` is the **scheduler's** lease predicate,
shared with the self-healing repair paths deliberately so the two cannot
disagree about who holds a file-scope lease. Its two role answers are
optional parameters defaulting to the legacy ids. The scheduler's own
call sites were converted to pass resolved answers; self-healing's two
were not:

```ts
const isWipColumn    = options?.isWipColumn    ?? task.column === "in-progress";
const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review";
```

On a renamed board neither branch matches, so the predicate returns
`false` for every card. The scheduler kept the lease; self-healing saw
none, cleared `overlapBlockedBy`, and **released a dependent to edit
files another agent still holds** — the outcome `groupOverlappingFiles`
exists to prevent.

Membership comes from the wip/review sets each sweep already resolved a
few lines above, so this adds no reads.

**Reverted:** both new cases fail with `overlapBlockedBy` = `null` — the
release itself, not a proxy. The pre-existing legacy-column case in the
same file passes either way, because `in-progress` satisfies the literal
default; that is exactly why it never caught this.

Lane-wiring baseline re-recorded `9 -> 7` in the same commit (the
ratchet refused a stale allowance, as intended).

**Verification:** gate 161 + 13 + 487 + 71 · surfacing 57 · overlap-seam
+ scheduler-lease + query-blindness 79 · core stale-paused 20 · lint ·
census `--strict` · sql-literals · fnxc-dates · changesets — green.
2026-07-30 23:13:34 -07:00
gsxdsm
41af5e5dbd fix(gate): the lane-wiring census could not see its own motivating case (#2956) (#2974)
#2966 shipped a gate that **cannot detect the defect named first in its
own header.**

`findLaneAcceptingFunctions` matched a lane parameter only when
`param.type` was a `TypeLiteralNode` — an inline `{ reviewColumns?: …
}`. But the real code declares these as interfaces:

```ts
export function getInReviewStallReason(
  task: Pick<Task, …>,
  context: InReviewStallContext = {},   // TypeReference — invisible
): InReviewStallSignal | undefined
```

so the function never entered `accepting` and none of its call sites
were examined.

### Measured, both directions

| | before | after |
|---|---|---|
| lane-accepting functions detected | 20 | **30** |
| `getInReviewStallReason` detected | no | **yes** |
| re-introduce #2956 (drop `reviewColumns` from one call site) | `none
added` — **passes** | **fails**: `reads.ts: 7 unwired now, baseline
allows 6` |

The gate now catches the thing it was built for.

### The baseline moves 10 → 24, and that number needs context

`10 unwired call site(s) across 8 files` → `24 across 15`. **No entry
was removed** — every previously-recorded file kept its count and 14
sites became visible for the first time:

```
core/task-store/reads.ts                        0 -> 6
engine/self-healing.ts                          2 -> 4
core/task-store/branch-and-pr-entities.ts       0 -> 1
core/task-store/task-update.ts                  0 -> 1
engine/scheduler.ts                             0 -> 1
dashboard/routes/register-task-workflow-routes  0 -> 1
cli/commands/dashboard-tui/bucket-mapping.ts    0 -> 1
cli/extension.ts                                0 -> 1
```

**These are newly VISIBLE, not newly broken** — they have been unwired
all along. I have **not** audited them, and recording them in the
baseline is not a claim that they are fine; it is the ratchet doing what
its header describes, since the census's own note says roughly half of
the original hits were legitimately unwired (identity proven by a
stronger means, sentinel columns, dead exports). Someone should walk the
14. Two stand out as worth a look first: **`reads.ts` at 6** is the file
#2956 was about, and **`scheduler.ts`** is a dispatch path.

Flagging rather than fixing, because wiring a call site that should not
be wired is its own defect and each needs the judgement call the census
header describes.

### Regression test

`packages/engine/src/__tests__/lane-wiring-census-named-types.test.ts`
pins the detector's shape — named interface, type alias, inline literal,
positional — against fixtures rather than live counts, so it does not
churn when someone legitimately wires a call site. Plus one anti-vacuity
case asserting the named-type arm is still load-bearing on real source
(`getInReviewStallReason` resolves in the live tree), so the fixtures
cannot pass while the tool has quietly stopped applying here.

**Mutation:** removing the `TypeReference` arm fails **4 of 5**.

### Also worth knowing

`findLaneAcceptingFunctions` still only visits
`ts.isFunctionDeclaration` at top level, so `export const fn = (ctx) =>
…` remains invisible. I checked — no exported arrow function currently
takes a lane argument, so nothing is missed today, and I left it rather
than widen the surface in the same change.

Resolved by **name across the corpus** instead of a type-checker
`Program`: these are plain source scans and a checker would cost a full
type-resolution pass for one lookup. Two same-named types merge, which
only ever widens what counts as wired — safe for a ratchet.

**Verified:** 5/5 new tests, `check-lane-wiring` clean at the new
baseline, lint clean, FNXC gate exit 0. Core suite on main is green
(4923 passed / 0 failed) — unrelated, but I had it running.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved lane-wiring analysis to recognize named interfaces and type
aliases.
* Added support for wrapped configuration expressions and positional
parameters when detecting lane information.

* **Tests**
* Added comprehensive coverage for lane-wiring detection, including
named contexts and live-tree validation.

* **Chores**
* Updated baseline counts to reflect newly recognized application areas
and improved self-healing detection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:54:31 -07:00
gsxdsm
0738fb1c8a test(a11y): the aria-label role guard was blind to the role word inside the t() default (#2979)
## What

[#2965](https://github.com/../pull/2965) fixed thirteen dialogs whose
accessible name restated its role, and shipped a source-scanning guard
so the next copy-paste could not sneak back in. Good fix, real ratchet —
its own header even documents catching one blind spot by mutation during
review.

It has a second one. The matcher looks for the role word at the **end**
of the `ariaLabel` value, which is where all thirteen had it. One
position over is invisible:

```tsx
ariaLabel={t("scripts.title", "Scripts dialog")}
```

That renders the accessible name **"Scripts dialog"** — identical
symptom, announced as *"Scripts dialog, dialog"* — but the value does
not END in the role word, because a `")` closes the call after it.

**Measured:** re-introducing this shape into `ScriptsModal` left the
shipped suite green at **13/13**.

## Why this shape matters more than the one already covered

The rendered label *is* the translator's default string. Whoever writes
the next modal naturally puts the word where the title lives, inside
`t()`, rather than appending it outside the call. The suffix form is
what the original thirteen happened to be; this is the form the
fourteenth takes.

## The fix

Every quoted literal that is actually rendered is checked with the same
matcher, not just the whole value.

i18n **keys** are excluded, or the guard fires on
`t("agents.onboarding.dialogLabel", "AI Interview")` — live in the tree
today, and it announces nothing of the sort. Key-shaped means dotted and
whitespace-free, which no real accessible name is. Keeping this narrow
is the whole difficulty: a scan that flags every translated title gets
deleted within a week.

## Measured

| run | result |
|---|---|
| baseline, unmutated `main` | **18/18 green** — no false positive
anywhere in the corpus |
| mutation A — role word inside the `t()` default | **1 failed / 17
passed** (was green before) |
| mutation B — original trailing-suffix shape | **1 failed / 17 passed**
— no regression |

Both defect shapes now fail the guard. Five matcher cases added, three
of them negative; the negatives are what keep the scan from flagging
every translated title.

## Note for the queue

**#2946 is fixed on `main` and can be closed** — I verified all thirteen
callers are clean there, the one grep hit being that i18n key. This PR
is about the guard behind it, not the fix.

Third time in this program an instrument has been blind to a case in its
own motivating class, now across three tools and three authors. The
pattern is not carelessness — each guard was checked against the shapes
its author had in mind. Mutation against a shape you did *not* have in
mind is the only thing that has caught any of them, which is an argument
for making it routine when a ratchet ships rather than when someone gets
suspicious later.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:51:45 -07:00
gsxdsm
be79fe0db6 fix(cli): PR merges silently never ran on a renamed board — the blocker was asked about in-review (#2976)
## PR merges silently never ran on a renamed board

`processPullRequestMergeTask` called its injected blocker with the task
alone:

```ts
if (getTaskMergeBlocker(task)) return "skipped";
```

So `options.reviewColumns` was undefined and the blocker's identity
check fell back to `task.column === "in-review"`. On a board whose merge
lane is named anything else it returns:

```
task is in 'checking', must be in 'in-review'
```

…which is truthy, so this function returns `"skipped"`. **Silently and
permanently** — nothing logs, nothing fails, the PR simply never merges.
`daemon.ts`, `serve.ts` and `dashboard.ts` all drain PR merges through
here, making this a third instance of the #2963/#2964 class ("merge
entry points unwired — merging was impossible on a renamed board").

Found via the baseline #2966 shipped:
`packages/cli/src/commands/task-lifecycle.ts` was a known-unwired call
site in it.

## Narrow resolution, deliberately

`resolveReviewColumns` is the **broad** set, and its own FNXC note warns
that a caller which admits on it *and then moves the card* will act on
cards the engine does not consider in review. This function merges and
moves to the complete lane — a state-changing admission — so it uses
`resolveMergeOrchestrationColumn`, the single lane the engine acts on.
That matches how `moves.ts` wires the same call.

Degradation is unchanged in both directions: `resolveWorkflowIrForTask`
substitutes the default IR rather than throwing, so a default board
resolves `in-review` and behaves identically; a v1-upgraded IR resolves
every role empty and keeps the documented legacy literal (covered by a
test).

## One shape choice worth flagging

The option is always **passed** and conditionally **valued**:

```ts
getTaskMergeBlocker(task, { reviewColumns: mergeLane ? new Set([mergeLane]) : undefined })
```

rather than making the whole argument conditional. These are identical
at runtime — the blocker treats an undefined `reviewColumns` exactly as
it treats absent options — but **only this shape is visible to
`lane-wiring-census.mjs`**, which matches an object-literal argument and
cannot see a ternary. I wrote the ternary first, and the gate still
reported the site as unwired; wiring a gate cannot check is how this
defect survived in the first place.

The gate then confirmed the fix and asked for the baseline in the same
commit:

```
[check-lane-wiring] unwired call sites decreased:
  packages/cli/src/commands/task-lifecycle.ts: 1 -> 0
```

Baseline re-recorded 9 → 8 in this commit, so the allowance cannot be
regrown into.

## Revert proof

**There was no test for this function at all** — that is why it went
unnoticed. Restoring only `task-lifecycle.ts`:

```
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, …(1) ]
AssertionError: expected 'skipped' not to be 'skipped'
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, undefined ]
      Tests  3 failed | 1 passed (4)
```

The one case that passes both ways is "still skips a card that is not in
any merge lane" — it guards against over-admission rather than proving
the fix, and I am not claiming it as coverage of the defect.

## Verification (measured)

- new suite **4/4**; with `pr-automerge-cleanup` **9 passed / 2 files**
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (8, none added), `lifecycle-column-census
--strict`, `check-sql-column-literals`, `check-fnxc-future-dates` —
green

**Changeset added** (`patch`). `packages/cli` is the published
`@runfusion/fusion` and this changes user-facing merge behaviour, so
AGENTS.md requires one. My first pass hedged and left it to a maintainer
— that was wrong, the rule is not discretionary, and it is now in the
branch.
2026-07-30 22:46:19 -07:00
gsxdsm
7fde4bb3ad fix(a11y): my #2965 gave six dialogs two elements with the same accessible name (#2977)
**This fixes a regression I introduced in #2965, found by re-running the
full dashboard lane on `main` rather than trusting the targeted runs I
did at the time.**

`AddNodeModal` and `ConnectNodeModal` are red on main:

```
→ Found multiple elements with the text of: Add Node
→ Found multiple elements with the text of: Connect to Node
```

### Cause

#2965 dropped the redundant `" dialog"` suffix from each
`FloatingWindow`'s `ariaLabel`. That was correct — `role="dialog"`
already conveys it. What I missed is that six of those modals **also**
put an `aria-label` with the *same* text on their own inner `<div>`:

```jsx
<FloatingWindow ariaLabel={t("nodes.addNode", "Add Node")} …>
  <div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}>
```

Before #2965 the two differed (`"Add Node dialog"` vs `"Add Node"`), so
`getByLabelText("Add Node")` matched exactly one element. Now both
match.

### Why the inner one goes, not the dialog's

Those inner labels sit on **role-less `<div>`s**, where assistive
technology ignores `aria-label` entirely — it was never conveying
anything to anyone. Removing it restores a single accessible name per
dialog and needs no test changes.

### Surface enumeration — four of the six were latent

Only two surfaced as failures; the other four have no test querying by
that name, so they would have shipped a duplicate accessible name
silently. Found by scanning every component for an inner `aria-label`
whose expression matches its own `ariaLabel` prop:

| modal | was it red? |
|---|---|
| `AddNodeModal` | red on main |
| `ConnectNodeModal` | red on main |
| `GroupTaskModal` | latent |
| `NodeDetailModal` | latent |
| `ScriptsModal` | latent |
| `WorkflowAddStepModal` | latent |

### Five more, deliberately untouched

`AgentDetailView`, `PlanningModeModal`, `SettingsModal`
(`role="region"`), `ScheduledTasksModal` (`role="listbox"`) and
`NewTaskModal` (`role="dialog"`) also carry their dialog's name on an
inner element — but those elements **have a role**, so the label is
meaningful rather than dead markup. A listbox named "Automations" inside
a dialog named "Automations" is redundant, not broken, and renaming it
is a UX decision rather than a cleanup. Left alone and recorded here.

**Verified:** 93/93 across `AddNodeModal`, `ConnectNodeModal`,
`NodesView`, `GroupTaskModal`, `ScriptsModal` and the #2965 aria guard;
`tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit 0.

Product-code change to a11y markup, so this is user-visible but needs no
operator-facing note — say the word if you want a changeset.

**Measured dashboard-lane state on main before this PR:** `3 failed |
11173 passed`. Two are these; the third is
`MainContent.planning-project-remount`, which belongs to #2420 and is
detailed there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:46:08 -07:00
gsxdsm
2fd798cb36 core: every review card reported a false stall on a renamed board (#2970)
**The failure mode worth distinguishing: the rest of this family went
quiet on a renamed board. This one shouted.**

`getInReviewStallReason` satisfied its **own** lane check from
`context.reviewColumns` — then called `getTaskMergeBlocker` **without**
them. That helper re-ran its column-identity check against the literal
`in-review` and returned, for a perfectly healthy card:

```
task is in 'signoff', must be in 'in-review'
```

…which was surfaced as `{ code: "merge-blocker" }`. **Every in-review
card on a renamed board was flagged as stalled**, each citing a lane the
board does not have. That is how a signal stops being read at all.

## A second symptom, found by the revert rather than by reading

On a **genuinely failed** card, the identity message wins over the real
one. The operator saw the bogus column complaint instead of `task is
marked 'failed': merge verification failed`.

So it did not only invent stalls — it **masked the true reason for real
ones**. I would not have noticed that from the diff; it showed up
because the revert run asserted on the reason text.

## Same shape, last one in the family

The outer question was resolved and the inner one was not — the
half-conversion the helper's own comment records for `moves.ts`, and
#2963/#2964 fixed for the merge entry points. This is the last site the
audit turned up where the lane answer was already in scope and simply
not forwarded.

## Revert results

| | reverted → |
| --- | --- |
| the unforwarded call (what ships today) | **2 of 3 fail** — healthy
card reports a merge-blocker stall; failed card reports the wrong reason
|

**Fixture note worth keeping:** `paused` is deliberately *not* the
genuine-stall case. An earlier guard returns `undefined` for a paused
card before the merge blocker is ever consulted, so that case would pass
whether or not the lanes are forwarded — the vacuous shape this series
has produced eight times.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `@fusion/core` full suite **4878
passed** (457 files); `tsc` core clean; lint, lifecycle census
`--strict`, FNXC gate, changesets all clean.
2026-07-30 22:25:24 -07:00
gsxdsm
df73bbc14a fix(tests): clear the persisted Command Center sub-tab between cases (7 of 8 main reds) (#2971)
`main` is red in the dashboard backfill lane. This fixes **7 of the 8**
failures. All 8 bisect to #2420 (`4f929acc10`): parent `189f237a07`
passes 9/9, that commit fails 5.

### Cause — test-order pollution, not a product bug

#2420 made Command Center restore its sub-tab on remount, because the
view unmounts on navigation by design:

```ts
const [activeTab, setActiveTab] = useState<SubViewId>(
  () => (getCommandCenterState(projectId)?.activeTab as SubViewId | undefined) ?? "overview",
);
```

The panel's test id is derived from that tab
(`data-testid={\`command-center-panel-${activeTab}\`}`), and these files
click through to other tabs — `tokens`, `team`, `github`, `system`,
`mission-control`. Neither `beforeEach` cleared storage, so the
**first** case left `mission-control` persisted and every later case
rendered `command-center-panel-mission-control`:

```
→ Unable to find an element by: [data-testid="command-center-panel-overview"]
```

Nothing in that message points at a previous test, which is what made it
look like a component regression.

**Confirmed as ordering rather than breakage:** each failing case passes
when run alone with `-t`. The "mutation" here is `main` itself — without
the `localStorage.clear()` these files fail 7; with it, 11/11.

### Scope

Two lines plus the note explaining why they exist, so the next person
who adds a tab-switching case knows the persistence is per-project and
sticky. **No product code touched** — the persistence behaviour in #2420
is correct and stays as-is.

**Verified:** 11/11 across both files, `tsc -p tsconfig.app.json` 0
errors, lint clean, FNXC gate exit 0. Test-only, no changeset.

### The 8th failure is NOT fixed here, deliberately

`MainContent.planning-project-remount.test.tsx` fails because #2420
moved Planning out of `MainContent` (its branch now returns `null`) into
`PlanningKeepAlive`, mounted by `App.tsx`. The product side is right —
the host **is** keyed (`App.tsx:1956`):

```jsx
<PlanningKeepAlive key={`${currentProject.id}:${modalManager.planningEntryGeneration}`} … />
```

so project switches still remount and I found **no cross-project leak**.
But that test was `FNXC:ProjectSwitchModalReset` coverage for a
leak-class invariant (Planning carrying a previous project's
stream/session), and **nothing asserts it at the new location**: the
keep-alive test covers navigation reveal, not project switching, and
`App.test.tsx` has no test for the host key. Deleting that `key=` today
would fail no test.

Restoring it needs an App-level test, which is a bigger change than this
fix and worth keeping separate. Detailed on #2420.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:25:13 -07:00
gsxdsm
126cee7e6d engine: finalization parked ALREADY-MERGED work as failed on a renamed board (#2964)
**The worst symptom in this family: the branch landed, and the board
says the task failed.**

`project-engine`'s merge-confirmed finalization spread the task's
**real** column into `getTaskHardMergeBlocker` with no `reviewColumns`,
so the identity check ran against the literal `in-review`. On a renamed
board it returned `task is in 'signoff', must be in 'in-review'`, and
the caller parked the card:

```
status: "failed"
error:  "Merge confirmed but finalization blocked: task is in 'signoff', must be in 'in-review'"
```

For work that had already merged.

## Its sibling had already solved this

`auto-merge-finalization.ts` passes the **review-eligible sentinel**
instead of the card's own column, with the reasoning recorded at that
site: `getTaskHardMergeBlocker` asks *"is this card blocked by anything
other than where it sits?"*, and its callers are recovery paths for
landed work that a graph crash can leave resting in any column.
`project-engine` simply never got the same treatment.

## One name instead of two spellings

Rather than write the sentinel a second time, it is exported once as
`REVIEW_ELIGIBLE_SENTINEL_COLUMN` next to the helper whose contract
gives it meaning, and both recovery paths use it. **Two sites
independently spelling a magic value is how one of them came to be
missing it** — that is the actual root cause here, not the literal
itself.

This also answers the census, which flagged the new literal — correctly.
Its guidance (which I wrote, in #2909) is to hoist a deliberate literal
into a *declaration*, where a `DELIBERATE-LITERAL` marker actually
attaches, instead of leaving it mid-expression where the marker is
silently ignored. The shared constant is exactly that, and it lowers
`auto-merge-finalization`'s literal count too.

## Revert result

| | reverted → |
| --- | --- |
| sentinel replaced by the card's own renamed column | reproduces the
shipped string |

The middle test asserts that string deliberately — it is what landed in
`task.error`, so a regression reports what the operator would actually
have seen. A third case checks the sentinel does **not** suppress
genuine blockers: incomplete steps still block finalization in any lane.

These drive the helper directly; reaching `project-engine`'s
finalization end to end needs a live engine, a merge run and a real
repo, while the defect is entirely in *what the blocker is asked*.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `project-engine` +
`auto-merge-finalization` + the new suite, 207; `tsc` clean on core and
engine; lint, census `--strict`, FNXC gate, changesets all clean.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed merge-confirmed tasks being finalized correctly when boards use
renamed workflow columns.
* Prevented already-merged tasks from being incorrectly marked as failed
due to custom review-column names.
  * Preserved enforcement of genuine incomplete-step blockers.

* **Tests**
* Added coverage for finalization on renamed lanes and legitimate merge
blockers.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:06:24 -07:00
gsxdsm
8e0219d573 fix(merger-ai): gate the no-commits dep-sync skip on the branch diff — the P1 #2501 shipped without (#2958)
## #2501 merged without its P1 fix; this is that fix, alone

#2501 has landed. Its review threads were resolved — I judged and fixed
them — but its head was a fork branch I could not push to, so **the
fixes were never in it**. Confirmed on `main` at `c1c1b964af`:

```
merger-ai.ts:902:      if (ctx.noCommitsExpected === true) {      ← bare flag, no diff gate
merge-dependency-sync.ts: export const LOCKFILE_CANDIDATES → 0 matches
```

Rebasing dropped this PR's five duplicated base commits, so it is now
**one commit**: the review fix and its regression.

## The defect on main

The dep-sync skip trusts `ctx.noCommitsExpected` alone, and **only ever
runs on a branch that has commits** — the `rev-list --count`
short-circuit ~50 lines above returns `outcome: "empty"` at zero ahead,
so control reaches it only when the branch is AHEAD.

Nothing revalidates the flag. Both downstream empty-lane guards carve
no-commits tasks out explicitly — `merger-ai.ts:1372` (#2259
already-landed proof) and `:1994` (FN-8141 executor veto) — and both
guard the *opposite* direction: commit-expected task, empty branch. The
inverse has no check.

So a task marked no-commits whose executor committed a manifest or
lockfile change gets its dependency install **and** its frozen-lockfile
validation skipped, and the change lands unvalidated.

## The fix

The flag says *look*; the branch diff decides. A `main...branch` diff
touching `package.json` or any `LOCKFILE_CANDIDATES` entry falls through
to the normal sync and emits an audit row with `skipOverridden: true`.
An unreadable diff **also** syncs — matching the hard-fail contract
documented directly above that block, rather than treating absence of
evidence as evidence of safety.

`LOCKFILE_CANDIDATES` is exported instead of duplicated, so the skip and
the installer cannot drift on what counts as a dependency change.

**Mutation-verified:** reverting to trust-the-flag fails exactly the new
case and nothing else. The existing *"lands successfully with
noCommitsExpected: true and actual changes"* case is untouched and still
passes — `feature.txt` is not a dependency file, so an ordinary source
change on a no-commits task still skips. The new case differs only in
*which* file the branch touches.

## Also carried over from the #2501 review

**coderabbit's env nit** — `process.env.X = undefined` stores the string
`"undefined"`, leaving a previously-absent var truthy and leaking into
later tests. `restoreEnv` applied at both sites.

**Both entry paths** — deferred with reasons:
`runAiMerge`/`landWorkspaceTask` sit behind real worktrees, sessions and
a merge agent, and the cheap version is a mirrored-implementation test
that cannot fail on a revert (this repo has deleted two of those). The
fix above also means propagation is no longer the only thing between a
stale flag and an unvalidated lockfile.

## A correction to my own work

My first version of the regression committed the lockfile while the
fixture had left the tree on `main`, so the `main...branch` diff could
not see it and the case **passed for the wrong reason**. Corrected, with
the reason recorded in the test.

## Verification

- `merger-ai-no-commits-deps-skip` — **5/5**, mutation-verified
- `merge-dependency-sync-lockfile-heal` — **10/10**
- engine typecheck — clean
- `pnpm lint` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:05:04 -07:00
gsxdsm
04159ff9ed fix(tests): three CSS-shape cases pinned modal DOM the FloatingWindow migration retired (#2967)
Two cases in `agent-modals-mobile` asserted DOM shapes that the FN-8619
`FloatingWindow` migration deliberately retired. Both are **stale
expectations, not product regressions** — established below rather than
assumed, because "delete the failing assertion" is exactly how a real
bug gets buried.

### 1. `AgentDetailView` — the suite asserted both sides of the same
fact

```js
expect(document.querySelector(".agent-detail-overlay")).toBeTruthy();   // here — RED
```
```js
expect(document.querySelector(".agent-detail-overlay")).toBeNull();     // AgentDetailView.core.test.tsx:97 — GREEN
```

No component renders that class (`grep` across `app/**/*.tsx` outside
tests: zero hits). One of these two had to be red, and the one matching
the product is the `toBeNull` sibling. This case now asserts the panel
class it is actually named for — `.agent-detail-modal`, which **is**
live and **is** what the mobile `@media` block in `AgentDetailView.css`
targets — and pins the scrim as still-retired.

I did **not** re-point the overlay half at `.floating-window-overlay`.
That would only re-assert FloatingWindow's own contract (already covered
by `FloatingWindow.test.tsx`) while saying nothing about Agent Detail
being mobile-targetable.

### 2. `AgentGenerationModal` — the class belongs to a different
component

It demanded `.agent-dialog-overlay`. That class is **still live** —
`NewAgentDialog.tsx:415` renders it — which is why the stale expectation
looked plausible and survived. But this modal is a `FloatingWindow` with
`modal` (`AgentGenerationModal.tsx:162`), so its scrim is
`.floating-window-overlay--modal`. Now asserted explicitly, because "a
modal blocks the app beneath it" is a real FN-8619 contract worth
pinning.

### Why the dead CSS is still here

`.agent-detail-overlay` has 4 CSS definitions and one inert mobile
`@media` rule. I deliberately did **not** delete them in this PR:

- Two **passing** tests (`dashboard-overflow-containment.test.tsx:295`,
`mobile-horizontal-pan-containment.test.ts:90`) pin a selector *string*
that lists `.agent-detail-overlay`. Deleting the CSS turns two green
tests red.
- That raises a question I cannot answer without rendering, and I will
not boot an instance to find out: **those containment lists do not
mention `.floating-window-overlay--modal`.** If mobile horizontal-pan
containment is meant to cover modal scrims, the migration may have moved
the scrim out from under its guard. That is a product question for
whoever owns FN-8619, and it is the substance of #2915.

Worth recording: the retired `.agent-detail-overlay { padding: 0;
align-items: stretch }` mobile rule is not a lost feature.
`.floating-window-overlay` is `position: fixed; inset: 0` with no flex
context, so those declarations have nothing to act on — FloatingWindow
positions the panel by geometry instead.

**Verified:** 22/22 in this file, `tsc -p tsconfig.app.json` 0 errors,
lint clean, FNXC gate exit 0. Test-only, no changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:01:07 -07:00
gsxdsm
2502878166 fix(a11y): 13 dialogs announced their role twice ("Settings dialog, dialog") (#2965)
Thirteen modals set an `aria-label` that restates the role they already
carry. `FloatingWindow` renders `role="dialog"` and
`aria-label={ariaLabel}` on the **same element**
(`FloatingWindow.tsx:628` and `:631`), so `"Settings dialog"` is
announced as **"Settings dialog, dialog."**

Fixed at all 13 call sites, plus a guard so the next copy-paste fails
instead of shipping.

### Two independent lines of evidence

I found this by inspection. Then, chasing unexplained dashboard
failures, I hit `NodesView.test.tsx`:

```
Unable to find an accessible element with the role "dialog" and name "Add Node"
  ...
  Name "Add Node dialog":
```

Two tests were already asserting the **correct** name and failing
because the product had drifted to add the suffix. So this is not a
style preference — it is a defect with pre-existing tests that were red.
**Those 2 failures go green here**, and they were among the ones I had
not yet accounted for.

### The guard was vacuous, and mutation is the only reason I know

My first version used one regex with `[^`"']*?` for the label body. It
passed. It was worthless.

Every real call site interpolates a translator call:

```jsx
ariaLabel={`${t("scripts.title", "Scripts")} dialog`}
```

Those inner **double quotes terminate the character class**, so the
pattern matched **none of the thirteen offenders**. It only matched
hand-written samples like ``{`Settings dialog`}`` that happen to contain
no quotes — which is exactly what I had put in the case table. Re-adding
the suffix to `ScriptsModal` in its original form left the suite
**green**.

Extraction is now structural (brace matching), and the case table
carries the real quote-bearing shapes, including the nested-brace
`NodeDetailModal` form and the `+ " dialog"` concatenation variant.

**Mutation now behaves:**

| state | result |
|---|---|
| clean tree | 13/13 pass |
| suffix re-added to `ScriptsModal` (faithful form) | **fails**, naming
the file and the offending value |

I would have shipped a guard that could not fail on the defect it was
written for. It is the same error the guard exists to prevent — a cheap
proxy standing in for the real measurement — so the reasoning is
recorded in the file rather than quietly fixed.

### Verified

- `NodesView` + `FloatingWindow` + the new guard: **110/110**, then
**13/13** for the guard after the rewrite
- `tsc -p tsconfig.app.json`: **0 errors** · lint clean · FNXC gate exit
0
- **No i18n key or default string changed** — all 15 `t()` keys
byte-identical across the diff; only the literal outside the call was
dropped, and the now-pointless `` {`${…}`} `` wrappers were unwrapped

### Not fixed here

`agent-modals-mobile` (2) and `core-modals-mobile` (1) still fail on
this branch — they fail identically on `main`, are CSS-structure
assertions unrelated to aria naming, and belong to the #2915
dead-`.agent-detail-overlay` family. Left alone deliberately rather than
bundled in.

No changeset: user-visible a11y correction with no API or setting
change, and the release-notes audience is operators. Say the word if you
want one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:58:23 -07:00
Drew Donaldson
c1c1b964af fix(dashboard): expose full permission-mapped task toolset in chat sessions (#2376)
## Bug
Chat-session tool surface missing task-mutation tools that exist outside
of chat, even when the agent's permission record grants them.\n\nRepro:
agent-09dcf8b2 (role: custom, CEO) in NextGenEHS has tasks:archive /
tasks:delete / tasks:merge / tasks:retry / tasks:update true in its
permission record with permissionPolicy.presetId = unrestricted and
task_agent_mutation = allow. Calling fn_task_archive / fn_task_delete /
fn_task_merge in chat returns: Tool fn_task_* not found.\n\nRoot cause:
packages/dashboard/src/chat.ts createChatFusionToolset() built a
hardcoded narrow chat-only allowlist while heartbeat registered the
complete lifecycle surface unconditionally.\n\nFix:\n- Add exported
factories in packages/engine/src/agent-tools.ts for missing lifecycle
tools: fn_task_archive, fn_task_unarchive, fn_task_delete,
fn_task_retry, fn_task_pause, fn_task_unpause, fn_task_duplicate,
fn_task_merge, fn_task_update, fn_task_add_dep, fn_task_promote,
fn_trait_list, fn_ask_question, fn_reflect_on_performance,
fn_read_evaluations, fn_update_identity, fn_send_message,
fn_read_messages.\n- Wire those factories into
createChatFusionToolset(). Mission/ideation mutations stay behind
missionMutationGated. Agent-scoped tools still require agentId.\n-
Re-export from packages/engine/src/index.ts.\n- Regression test:
packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts (3/3
passing). Existing chat.test.ts (14/14 passing).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Chat now exposes task lifecycle actions—including archive, retry,
pause, duplicate, merge, and dependency updates—when permitted by the
agent’s action controls.
- Added support for identity updates and evaluation viewing in
agent-linked chats.
- Existing read-only tools remain available, while restricted actions
stay hidden when authorization is unavailable.
- **Tests**
- Added regression coverage for authorized and unauthorized chat tool
surfaces, including preservation of read-only capabilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:51:09 -07:00
flexi767
dd930c8d7d fix(cli): qualify cross-fork PR heads (#2377)
## Summary

- resolve the repository receiving pushes through `git remote get-url
--push origin`
- qualify pull-request head branches with the fork owner when the push
owner differs from upstream
- preserve the existing unqualified head for same-repository workflows

## Root cause

Fusion correctly resolved the PR target from origin's fetch URL, but
assumed the pushed branch lived in that same repository. With an
upstream fetch URL and a fork push URL, GitHub requires
`fork-owner:branch`; the unqualified branch is rejected.

## Validation

- CLI task lifecycle tests: 48 passed
- `@fusion/core` typecheck
- `@runfusion/fusion` typecheck
- strict changeset validation


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Pull requests created from branches pushed to contributor forks now
correctly qualify the PR head with the fork owner when the push remote
differs from the upstream owner.
* Improved PR head handling across both group/shared-branch and per-task
pull request creation paths.
* **Tests**
* Updated and expanded lifecycle tests to cover “origin push to fork”
scenarios using push URL–based repo resolution.
* **Documentation**
  * Added a patch release note for the fork-aware PR head fix.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: v <v@v.speedport.ip>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-07-30 21:50:58 -07:00
gsxdsm
4f929acc10 fix(dashboard): stop over-aggressive component unmounts (keep-alive for planning, terminals, popups) (#2420)
Implements
docs/plans/2026-07-22-001-fix-dashboard-remount-churn-plan.md: every
confirmed source of unnecessary unmount/remount churn in the dashboard,
plus a keep-alive layer for conversation- and terminal-bearing surfaces.

## What changed

**Keying / component identity (U1–U3)**
- Streaming chat segment key no longer embeds `entries.length` — an
expanded thinking block stays expanded while entries stream into it
(R1).
- Dock task list keys `TaskCard` rows by `task.id` (occurrence suffix
only for the duplicate-id anomaly) instead of `id-index` — no remount on
reorder/filter/status change (R2).
- `ProviderStatusBadge` / `GitHubStatusBadge` hoisted out of
ModelOnboardingModal's render body (R3); MCP server rows key by
`server.name` alone (R4).

**Keep-alive layer (U4–U6)**
- New shared `KeepAliveView` wrapper: visible = in-flow flex child;
hidden = out-of-flow `position:absolute; inset:0` with
`visibility:hidden; pointer-events:none` + `aria-hidden` (never
`display:none`, so xterm geometry never collapses).
- Planning Mode renders as a kept-alive sibling of the MainContent
switch after first open (per-project latch mirroring Quick Chat). While
hidden, the session-list SSE, recovery poll, and elapsed ticker suspend
via a new `active` prop; reveal re-subscribes and refreshes the sessions
list once. Payload-carrying entry points (initial-plan handoff, resume)
and project switches remount via a new
`modalManager.planningEntryGeneration` key, preserving pre-keep-alive
fresh-open semantics. `recordResumeEvent` instrumentation records
`remount` on first activation and `route-active` on reveal.
- Task-detail Terminal / Worktree-terminal / Planner-chat tabs stay
mounted-but-hidden after first open (per-task latches; task switch/close
still disposes fully). `SessionTerminal` gains `active`: reveal refits +
forces a font remeasure, and if the WS died while hidden it re-runs the
full attach lifecycle (dead-socket recovery).
- Popped-out task windows hide via FloatingWindow `hidden` instead of
leaving the render array; `TaskDetailContent` gains `active` so hidden
popups close their SSE/EventSource channels while the terminal WS stays
open. `visiblePoppedOutTaskEntries` remains the Escape-shortcut
consumer.

**Planning Mode internal-transition audit (U7)**
- Audit findings: session-list mode and mobile list/detail flips are
CSS-class transitions over one always-mounted detail pane (no
state-discarding unmounts); re-selecting the active session is an
early-return visibility restore; session switching intentionally reloads
from the session row (stream re-attach for generating sessions);
remaining index keys are on stateless lists. No product-code defects
found; regression tests now lock the always-mounted invariant on desktop
+ mobile.

**Cheap-view state (U8)**
- CommandCenter (active sub-tab + date range) and DevServerView
(selected script/task + typed-but-unsent command) persist per project
via `modalPersistence` and restore after their (intentional) unmount
round-trips. Also fixed the candidate auto-fill effect clobbering a
customized non-empty command.

## Symptom Verification
- **Original symptom:** streaming thinking blocks collapsed mid-stream;
terminals reconnected and lost scroll/input on tab flips; Planning Mode
lost in-flight interviews on navigation; popped-out windows vanished
off-view; dock cards remounted on reorder.
- **Exact reproduction:** (1) expand a thinking block during a stream;
(2) run a command in the Terminal tab, flip to Plan and back; (3) start
a planning interview, navigate Board and back; (4) pop out a task with
board/list-only scoping and switch views; (5) change a dock task's
status.
- **Assertion it is gone:** component-identity/instrumentation tests in
TaskChatTab, SessionTerminal, TaskDetailModal
(worktree/planner-chat/tabs), PlanningModeModal keep-alive +
internal-transitions, App keep-alive round-trip, and
App.taskPopupViewGating assert no remount and preserved state for each
repro, across desktop and mobile breakpoints.

## Verification
- File-scoped vitest: 23 files / 1091 tests green (all touched suites
plus FloatingWindow, TerminalModal, TaskPlannerChatTab,
lazy-loaded-views guard, App suites).
- `pnpm verify:fast`: PASS (13 steps — scoped typecheck/build, CLI
build, boot smoke).
- `pnpm check:changesets`: passes; changeset
`fix-dashboard-remount-churn` (`@runfusion/fusion` patch, labeled
format).
- Known pre-existing failures NOT caused by this branch (verified
failing at base a224c1111 in a clean worktree): 7 tests in
`TaskDetailModal.oversight-controls/oversight-mobile/models-progress-workflow`.
- jsdom cannot prove rendered-grid correctness for xterm reveal; per the
plan's risk note, manual browser verification of terminal reveal remains
recommended.

🤖 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**
* Switching views or tabs no longer resets Planning Mode, task details,
terminals, planner chats, or popped-out task windows.
  * Streaming content remains expanded and stable as new entries arrive.
* Hidden views suspend background activity and resume correctly when
shown.
  * Terminal sessions reconnect automatically when needed.

* **Improvements**
  * Command Center and Dev Server selections persist per project.
  * Custom Dev Server commands are preserved while browsing suggestions.
* Improved stability when reordering task lists and updating server
states.

* **Documentation**
* Updated dashboard guidance for hidden, retained task pop-ups and view
transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:47 -07:00
gsxdsm
189f237a07 fix(security): make the approval and permission gates actually enforce (#2457)
## Why

An AI agent autonomously deleted a live task (FN-8609) via
`fn_task_delete`. Investigating that led to two audits, which found the
gate machinery is well-built and its *enforcement* well-tested — but the
*decision boundary* was unguarded and the tools that matter never
reached the gate at all.

The headline defect: all ~76 tools in `packages/cli/src/extension.ts`
are delivered through `hostExtensionPaths` → `DefaultResourceLoader`,
which never passes through `wrapToolsWithActionGate`. `fn_task_delete`
*was* classified as gated in `gating-classifications.ts` — that
classification was dead code. Roughly 15 destructive tools were ungated
even for an operator who had locked their policy all the way down.

Second: `POST /api/approvals/:id/decision` took the approver identity
from the request body, validated for shape only, with no check that the
decider was human or differed from the requester. Combined with a bash
tool whose command string was never inspected (only `params.cwd`), an
agent could read the daemon token from `~/.fusion/settings.json`, find
its own pending request, and approve it.

## Scope

This does **not** tighten defaults. The `unrestricted` preset is
byte-for-byte unchanged — `git diff` on `agent-permission-policy.ts` is
empty — and regression tests assert that an out-of-the-box install
behaves exactly as before. The bug was never "the default is too
permissive"; it was "strict policy doesn't enforce." This makes turning
security up actually work.

The one deliberate exception: the containment that stops an agent
escalating its *own* privileges (reading the daemon token / credentials,
calling the approvals API to self-approve) applies at every preset
including `unrestricted`. That is a privilege-escalation boundary rather
than a permission preference — if it only engaged under strict policy it
would not have prevented the incident that prompted this.

## What changed

8 bisectable commits:

- **Approval lifecycle** — self-approval blocked via server-derived
deciders; same-verdict replay 409s; decide re-reads and re-validates
inside the transaction; expiry TTLs; `markCompleted` ownership check;
session identity registry in core.
- **Engine gates enforce for real** — unclassified tools resolve to a
policy-governed category instead of hardcoded `allow`; missing-policy
fail-open closed; bash containment floor + exact-command approval
binding.
- **Dashboard decision routes** — stop trusting client-supplied actors
(decision, bypass-review, worktrunk → 403 on forged actors).
- **`fn serve` authenticated by default** — auto-mints a token following
the existing `fn dashboard` precedent; `--no-auth` opts out.
- **Sibling entry points closed** — user-sourced hard-cancel moves, ACP
execute-once approvals, plugin task-store gating.
- **pi-extension principal resolution** — the extension resolves the
acting principal and can withhold or policy-gate the previously ungated
destructive tools.
- **Root-cause bonus fix** — `findLatestByDedupeKey` was broken in
PostgreSQL backend mode (already-parsed jsonb fed through a string-only
parser), so approved-grant redemption **never matched in production**,
minting duplicate requests. This explains the live DB state of 17
approved / 0 completed. *(Also cherry-picked to `main` as `a9b30013bb`,
since it is an active production defect on its own.)*
- **Review follow-ups** (`627f1b1fa8`) — operator-configured
provisioning privilege and a configurable grant TTL; see below.

## Review follow-ups

**Provisioning privilege is operator-configured, not role-derived.**
`isCallerPrivileged` had gone from `caller.reportsTo == null` (every
top-level agent privileged — permanent escalation by creating a
manager-less agent) to `caller.role === "ceo"`, which swapped an
implicit rule for a magic string: any agent config can claim that role,
while an operator who genuinely wants a privileged agent had no
supported way to say so. Privilege now derives solely from
`agentProvisioning.trustedAgentIds` / `trustedRoles` and fails closed
when settings are unresolvable.

It is also no longer forwarded to `resolveAgentProvisioningPolicy` as
`isPrivileged`, because that flag short-circuits ahead of
`alwaysApproveDelete` — a trusted caller was bypassing delete approval
entirely. The policy applies the same trusted rules itself, in the right
order. The function now governs only the org-chart escape hatch (acting
outside your own direct reports).

**Grant TTL defaults to 1 hour and is configurable.** Approval →
redemption is not instantaneous: an operator approving from their phone,
an engine restart, a queued lane, or a task waiting on a worktree all
routinely exceeded 15 minutes, after which the grant expired and the
agent silently re-requested. One hour remains far short of the
"redeemable forever" hazard the TTL exists to bound. Override via
`FUSION_APPROVAL_GRANT_TTL_MS` or `configureApprovalRequestTtls()`;
invalid overrides are ignored rather than widening the window to
infinity or collapsing it to zero.

## Behavior changes requiring operator review before rollout

1. `fn serve` requires a bearer token by default (`--no-auth` opts out);
unauthenticated clients get 401.
2. Agents can no longer run withheld destructive tools
(`fn_task_delete`, `fn_task_bypass_review`,
mission/milestone/slice/feature/workflow deletes, `experiment_finalize`,
`skills_install`). Operators keep them via CLI/dashboard. **This is the
incident fix.**
3. Agents get provisioning privilege only when the operator lists them
in `agentProvisioning.trustedAgentIds` / `trustedRoles`; the
provisioning gate is now live in production. Previously-implicit
privilege (top-level position, or a `ceo` role) no longer grants
anything on its own.
4. Decision replay 409s (was 200); pending approvals expire after 24h,
approved grants after 1h (configurable); bash approvals bind per exact
command.
5. Forged/body actors on decision, bypass-review, worktrunk routes →
403; `archive-all-done` requires `{confirm:true}` (external scripts
affected).
6. `fn_secret_get` approvals grant exactly one reveal (previously
granted nothing and looped forever); ACP approvals are execute-once
(previously infinite reuse).
7. Bash containment denies token/credential/approvals-API commands in
all agent sessions at every preset.

## Verification

Independently re-run against the branch, not just self-reported:

- 5 typechecks (core, engine, cli, dashboard `tsconfig.json` +
`tsconfig.app.json`) — clean
- `pnpm lint` — clean
- `pnpm test:gate` — 379 passed
- `pnpm build --force` — green (a plain `pnpm build` skips packages as
unchanged and does **not** compile the branch)
- `pnpm check:changesets` — clean
- ~650 file-scoped tests including new negative-path suites for the
decision boundary, which previously had **zero** test coverage

`packages/engine/src/__tests__/plugin-runner.test.ts` fails 56/80 —
**verified pre-existing**, reproducing identically at base commit
`93a403af67` on `main`. Not in the merge gate.

### A mutation check that failed to fail

Worth recording, because it nearly shipped an untested security fix. The
first mutation check on the provisioning change reintroduced the `ceo`
hardcode and **all 17 tests still passed** — the tests asserted through
the policy path, which can no longer observe `isCallerPrivileged` at
all, precisely because `isPrivileged` is no longer forwarded there.
Org-chart cases that do exercise the function were added; the hardcode
now fails exactly 1 of 19, and restoring is green. A green mutation run
is only meaningful if the test can actually see the code under test.

## Known limitations (stated, not papered over)

- The bash containment floor is string-matching: a cost-raiser, not a
sandbox. Quoting, encoding, `$HOME`, symlinks, or an interpreter
one-liner can evade it. The durable protection is the decision route
refusing agent-originated deciders — the filter is the belt, not the
braces.
- Approval expiry is lazy (evaluated at decide/complete/redeem), not
swept, so an expired pending row stays visible in lists until touched.
- The extension's require-approval path returns a pending message but
cannot suspend a pi session mid-turn; engine-side pause hooks cover
engine lanes only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Security**
* Hardened approval and permission gating with server-side decider
attribution, self-approval blocking, ownership checks, replay/race
protection, and status/TTL enforcement.
* Added fail-closed behavior for sensitive/unclassified tools and
sandbox provisioning approvals.
* Blocked credential/approval access via bash containment; plugin
destructive task operations now require explicit permission.
* **New Features**
* `fn serve` now defaults to bearer-token auth, with `--no-auth` as the
explicit opt-out.
* **Bug Fixes**
* Improved task move-source attribution (`moveSource: "user"`) and
tightened dashboard archive/bypass confirmation and operator attribution
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:37 -07:00
gsxdsm
7712e0ada2 engine: merging was broken outright on a board with a renamed review lane (#2963)
**Not a degraded message — no task on such a board could be merged at
all.**

`getTaskMergeBlocker`'s column-identity check *returns a blocker* when
the task's column is not a review lane. Both merge entry points called
it without `reviewColumns`, so the check ran against the literal
`in-review`:

```
Cannot merge FN-1: task is in 'signoff', must be in 'in-review'
```

`aiMergeTask` (`merger.ts`) and `runAiMerge` (`merger-ai.ts`) turn that
into a thrown error. Every merge on a renamed board fails, with a
message naming a column the board does not have.

## This exact defect was already found once

The helper's own FNXC comment records it, in `moves.ts`:

> *"so on a renamed board that move threw `Cannot move FN-1 to done:
task is in 'signoff', must be in 'in-review'` even though the transition
had just been validated as legal. A half-conversion, where the outer
question is resolved and the inner one is not."*

That fix added the `reviewColumns` option and wired `moves.ts`. **These
two callers were missed** — same shape, one layer out. A fix that adds
an optional parameter is only as good as the call-site sweep that
follows it.

## How it was found

By enumerating the call sites of every lane-taking helper, rather than
trusting the `unwired-lane-parameter` guard. That guard is deliberately
conservative — a mention of the parameter *anywhere* satisfies it — so
**partial** wiring is invisible to it, and `reviewColumns` is mentioned
plentifully elsewhere. This is the method #2956 used on a sibling
defect, applied to every seam I have touched.

## Two sites deliberately unchanged

- **`moves.ts`** passes `skipColumnIdentityCheck: true`. It has already
proven lane identity from resolved IR traits, so supplying lanes *as
well* would be contradictory rather than additive — the helper's comment
is explicit that the two options answer different questions.
- **`isTaskReadyForMerge`** has **zero** production callers. Adding a
parameter there is precisely the unwired-parameter anti-pattern this
program keeps removing.

## Revert result

| | reverted → |
| --- | --- |
| `reviewColumns` at either call | reproduces the shipped string exactly
|

The middle test pins that string deliberately: it is the
operator-visible failure, so if the wiring regresses the test says what
the operator would have seen. A third case checks that supplying lanes
does **not** switch the identity check off — a card in the wip lane is
still blocked, and the message names the resolved lanes rather than a
column the board lacks.

The cases drive `getTaskMergeBlocker` directly: reaching it through the
merge entry points needs a real repo, worktree and merge run, while the
defect is entirely in *which columns the blocker is asked about*. The
wiring itself is covered by tsc and the guard.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `merger` + `merger-ai` +
`self-healing` suites 461; `tsc` engine clean; lint, census `--strict`,
FNXC gate, changesets all clean.
2026-07-30 21:50:32 -07:00
ischindl
8d6acf1314 fix(RUFU-018): add noCommitsExpected dep-sync skip and corepack/pnpm env passthrough (#2501)
Manually land RUFU-018 fix bypassing the AI merge pipeline.

## Summary
- Add `noCommitsExpected` flag to `LandRepoContext`; skip dependency
sync when set
- Forward `COREPACK_HOME`/`PNPM_HOME`/`npm_config_registry` in
`installWorktreeDependencies`
- Add comprehensive tests for both changes

This unblocks all downstream RUFU audit tasks.

## Surface Enumeration
- Providers/bridges: `installWorktreeDependencies` called from
`landOneRepo` (AI merge) and legacy `merger.ts`; `landOneRepo` called
from `runAiMerge` and `landWorkspaceTask`
- Data states: `noCommitsExpected` can be `true`, `false`, or
`undefined` — both callers use `=== true` strict check

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Improved support for tasks that do not produce commits by skipping
unnecessary dependency installation during merges.
- Preserved normal merge and review behavior when dependency
installation is skipped.

- **Bug Fixes**
- Dependency installation now correctly preserves relevant
package-manager and system environment settings.
- Reduced installation failures caused by missing or unavailable
package-manager configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Fusion <noreply@runfusion.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-07-30 21:50:25 -07:00
gsxdsm
01f081e8aa engine: restore the stall-signal lane wiring #2951 dropped (and the test that proved it) (#2961)
**My defect, shipped in #2951 — and the same family as the one #2956
just fixed.** Found by auditing my own seams after that, not by a
failing check.

## What is on `main` right now

`surfaceInReviewStalls` reads the project's review columns (converted in
#2951), then calls `getInReviewStallReason` **without** `reviewColumns`.
The classifier falls back to the literal `in-review`, returns no signal
for a renamed-lane card, and the sweep surfaces nothing.

That is the textbook **missed pair** this program has a ratchet for: a
widened read handing every renamed-board card to a literal classifier.
The resolve work happens and is then discarded. On a renamed board an
operator sees no stall warnings at all.

#2951's conflict resolution dropped two things together:
- the per-card `stallLanes` map and the `reviewColumns` argument
- **the test that proved the wiring**

## Why nothing caught it

**A deleted test cannot fail.** I verified that rebase by comparing the
68 conflict *hunks* — stripping FNXC stamps, confirming 0 of 68 had real
content differences — and then ran the gate. The gate passed precisely
because the proving test had gone with the code it proved.

I verified the conflicts. I did not verify the outcome. Those are
different things, and the difference is invisible when the evidence
disappears alongside the feature.

The `unwired-lane-parameter` guard cannot catch this either, by design:
it is deliberately conservative — a mention of the parameter *anywhere*
satisfies it — so **partial** wiring is outside its reach.
`reviewColumns` is mentioned plenty in `reads.ts`, so the guard is green
while this call site goes unwired.

## How I found it

The check #2956 used on the sibling defect, applied to every lane seam I
have touched: enumerate each function's **call sites** and confirm each
one carries the parameter. That enumeration also flags several other
call sites without `reviewColumns`/lane arguments (`merger.ts`,
`moves.ts`, `auto-merge-finalization.ts`, `merger-ai.ts`,
`project-engine.ts`) — I have **not** touched those here; they need
per-site judgement about whether the lane answer is even available, and
that is a separate change rather than a sweep.

## Revert result

| | reverted → |
| --- | --- |
| `reviewColumns` at the call (i.e. exactly what #2951 shipped) | fails
the restored test |

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; blindness suite 71;
`self-healing.test.ts` 412; `tsc` engine clean; lint, census `--strict`,
FNXC gate, changesets all clean.
2026-07-30 21:45:17 -07:00
gsxdsm
1f0d371228 fix(tests): three more portal query-root failures (pr-tab, worktree-terminal, milestone-slice) (#2959)
Three dashboard test files asserted against `render()`'s `container`,
but the components under test mount through `createPortal` — so
`container` is **empty** and every query returns nothing. Same root
cause as the earlier portal batch; these are the three that were still
held back.

| File | Before | After |
|---|---|---|
| `TaskDetailModal.pr-tab` | failing | pass |
| `TaskDetailModal.worktree-terminal` | failing | pass |
| `MilestoneSliceInterviewModal` | failing | pass |

**Measured: 39/39 passing**, rebased on current main (`3461ae7a92`).
Lint clean, FNXC date gate exit 0.

### Why this stayed hidden

The queries were a **mix** of `container.querySelector(...)` and
`screen.*`. `screen` queries `document`, so they kept working — a
portal-mounted modal makes only the `container` half go blind. The
result is a file that looks half-alive rather than obviously broken, and
the failures present as five different-looking symptoms (`null`,
`undefined`, `+0`, `[]`, `-1`) that don't read as one bug.

Grouping candidate files by **`container.querySelector` call count**
rather than by symptom is what identified these correctly, and — the
part that mattered — correctly *excluded* the neighbouring files that
were failing for unrelated reasons.

### One thing to know if you repeat this

A blanket `container` → `document` replace is wrong: it also rewrites
`renderResult.container.querySelector` into
`renderResult.document.querySelector`, which is not a thing. That broke
two already-passing tests on my first attempt. This uses two separate
passes with a lookbehind so only the bare receiver is rewritten.

### Scope

Test-side only — **no product code changes**, so no changeset. This does
not fix the *cause* (tests are still free to query the wrong root); a
lint rule for that is worth considering separately, but it would need to
distinguish portal-mounting components from ordinary ones, and I did not
want to guess at that boundary inside a test-fix PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Improved modal and task detail accessibility test reliability by
querying rendered elements from the document.
* Updated coverage for keyboard navigation, Pull Request status
indicators, tab ordering, and onboarding provider cards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:42:33 -07:00
gsxdsm
fd795883c5 feat(missions): per-mission taskPrefix override for triaged task ids (#2347)
## Summary
Maintainer re-land of
[#2334](https://github.com/Runfusion/Fusion/pull/2334) (fork
`flexi767:feat/per-mission-task-prefix`) after resolving merge conflicts
with current `main`.

Fork push was unavailable despite `maintainerCanModify`, so this branch
carries the conflict resolution.

### Feature
- Optional per-mission `taskPrefix` for triaged task ids (inherits
project prefix when unset)
- Dashboard MissionManager + routes + store/triage plumbing
- Postgres migration for `project.missions.task_prefix`

### Conflict resolution
- Main claimed migration **0026** (bigint counters) and **0027**
(workflow IR pin)
- Mission task-prefix migration renumbered **0026 → 0028**
- Baseline `0000_initial.sql` includes `task_prefix` on missions
- `legacy.ts` keeps code-org re-exports; `missions.ts` carries
`taskPrefix` on create/update types

## Test plan
- [ ] CI green (lint/typecheck/build/gate)
- [ ] Create mission with custom prefix; triage feature → task ids use
that prefix
- [ ] Clear mission prefix via PATCH null; new tasks inherit project
prefix

Closes / supersedes #2334 once this lands (or re-point the fork PR).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Missions can now set an optional per-mission task ID prefix
(overriding the project default).
* Added task prefix support to mission create/edit UI and dashboard
APIs, including normalized uppercase values and validation.
* **Bug Fixes**
* Improved commit hook generation for custom prefixes and special
characters, with safer shell handling to prevent unsafe interpretation.
* **Chores**
* Added PostgreSQL migration and schema-applier support to persist and
propagate mission task prefixes, including upgrade/backfill coverage.
* **Tests**
* Added backend and UI/API test coverage for task-prefix creation,
clearing, and ID minting behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 21:35:23 -07:00
gsxdsm
e6e70a2562 test(engine): delete two temp-cleanup mechanisms guarding a leak the harness already prevents (#2960)
`scheduler-paused-dispatch-refusal.test.ts` carried **two** tracking
arrays and **two** `afterEach` hooks, both collecting the same
`mkdtempSync` path and removing it twice. One was added per review round
on #2779 — I wrote both, and neither round noticed the other.

The obvious fix is to merge them into one. **I checked whether the leak
was real first, and it isn't.**

### Measured

`packages/core/src/__test-utils__/vitest-setup.ts` **redirects
`os.tmpdir()`** to a per-worker sink and sweeps it by owning pid. So
`tmpdir()` inside a test does not resolve to the real temp root at all.
Probing the paths this file actually creates:

```
/var/folders/.../T/fusion-test-workers-8Tv8um/redir-5845/fusion-paused-dispatch-ZUhUVL
```

| run | fixtures created | left behind |
|---|---|---|
| cleanup as shipped | 4 | 0 |
| **cleanup disabled** | 4 | **0** |

The sink is reclaimed either way. Both mechanisms were appeasing a
review comment about a problem that could not occur.

### Why deleted rather than merged

A cleanup that cannot be observed to clean anything is not a cheap
safety net — it is a claim the file cannot back, and it misreports which
layer owns temp lifetime. Keeping one "just in case" would leave the
next reader believing this file manages its own fixtures. If the
redirect is ever removed, cleanup belongs in the shared setup for
**every** test, not re-added file by file. An FNXC note records the
measurement and says exactly that, so a third round doesn't re-add a
third copy.

### A note on my own measurement

My first check was `ls $TMPDIR/fusion-paused-dispatch-*` before and
after — it reported zero leaked with cleanup **on**, which I nearly took
as "cleanup works." It also reported zero with cleanup **off**. That
contradiction is the only reason I looked further; the glob was
measuring a directory the fixtures never reach. The before/after count
would have "confirmed" a working cleanup just as readily as a redundant
one.

**Verified:** 4/4 pass, `tsc` 0 errors, lint clean, FNXC gate exit 0.
Test-only, no product change, no changeset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:29:39 -07:00
gsxdsm
6c1f074773 fix(core): the in-review stall signal never got the board's review lanes — 0 of 4 call sites (#2956)
## Main is red, and the red is pointing at a real defect

`unwired-lane-parameter-guard` fails on `origin/main` after #2951. This
is not a stale allow-list — the parameter genuinely never reaches the
function.

#2951 added `reviewColumns?: ReadonlySet<string>` to three signal
modules and wired two of them completely. **`getInReviewStallReason` was
wired at none of its four call sites.** Measured by brace-matching each
call's option literal:

```
getInReviewStallReason      L227=NO   L390=NO   L599=NO   L729=NO
getInReviewStalledSignal    all 4 wired
getStalePausedReviewSignal  both wired
```

## The user-visible consequence

`reads.ts` computes two adjacent signals for the same card. On a board
declaring a **separate merge lane beside its human-review lane**,
`inReviewStall` read the *first* review column only, while
`inReviewStalled` — three lines below — read the *set*.

**The same card is "in review" for one signal and not the other.** Two
signals disagreeing is worse than both being legacy, and it is invisible
on every builtin board because there the review set has exactly one
element.

At three of the four sites the resolve sat *below* the call, which is
why the parameter could not be passed. Those are hoisted.

## I have to correct my own earlier report

On #2951 I said *"3 of 4 call sites wired, `reads.ts:227` is the gap."*
**That was wrong.** I had measured with a 12-line proximity grep, which
bled into the adjacent `getInReviewStalledSignal` call and counted its
`reviewColumns:` as the first call's. Brace-matching the literal shows 0
of 4. The defect was four times larger than I reported, and the cause
was exactly the anti-pattern I have spent this session filing against
other people's guards — a proximity window standing in for structure.

## Naming the context types

The guard keys an interface member to its **owner symbol** and only
counts a mention from a file that also names that owner, so passing the
property inline reads as unwired even when every site supplies it.
`satisfies InReviewStalledContext` / `satisfies
StalePausedReviewContext` on the option literals is real type-checking,
not a decorative import — lint rejected the decorative version,
correctly.

## New test, because the existing guard cannot see this

Measured: **deleting the `reviewColumns:` line from a fixed call site
leaves `unwired-lane-parameter-guard` at 9/9 green**, because the file
still names the type. So the wiring I just fixed had no coverage at all.

The new ratchet brace-matches each call site's option literal:

| mutation | result |
|---|---|
| remove lanes from one call site | **1 failed** — *"1 of 4
getInReviewStallReason call sites omit reviewColumns"* |

It also asserts it **found** call sites before checking them — a parse
that matched nothing would be vacuous, which is the failure mode this
guard family keeps producing. (It caught me mid-change too: an earlier
scripted edit left the file syntactically invalid and the source-text
test still passed 3/3. It is a wiring ratchet, not a substitute for
`tsc`.)

## Verification

Core **4861 passed / 0 failed** · guard **9/9** with `KNOWN_UNWIRED`
**unchanged** · `pnpm test:gate` **exit 0** · lint clean · core `tsc`
**0 errors**.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:11:42 -07:00
gsxdsm
8e5e1147d2 core,engine: the last literal lifecycle query — and the three stall signals that disagreed (#2951)
**This is the last one.** `surfaceInReviewStalls` was the final literal
`listTasks({ column })` in production — I verified it by direct scan,
not by census arithmetic: **1 remaining before this, 0 after.**

It tells an operator that a card is stalled in review. On a renamed
board the stall was real and the board simply never said so.

## It came last on purpose

Converting the read alone would have been **worse than leaving it**.
`getInReviewStallReason` gated on the literal `in-review` itself, so a
widened read hands every renamed-board card to a classifier that drops
it — the missed-pair class, wearing the shape of a clean one-line
conversion.

## What was actually there

Three sibling signals decorate the same row, and they **disagreed about
which lane it is in**:

| signal | before |
| --- | --- |
| `getInReviewStalledSignal` | singular `reviewColumn` — resolved, but
**first-per-role** |
| `getStalePausedReviewSignal` | singular `reviewColumn` — same |
| `getInReviewStallReason` | **no seam at all** — literal |

So one row could be judged in-review by one signal and not by another.
And the singular ones are the **arity trap**:
`resolveLifecycleColumns().review` is the *first* column carrying a
review role, so a board with a separate merge lane beside its
human-review lane had a second review column matching none of them.

All three now take `reviewColumns` (membership), resolved **once per
row** through `resolveReviewColumns` — the union of the three review
roles — so they cannot disagree by construction. The singular/literal
paths remain as the no-metadata fallback, so a caller passing nothing is
byte-identical to today. Ten call sites in `reads.ts` wired from that
one answer; the singular resolver is deleted.

## Revert results

Each applied alone and re-run:

| conversion | reverted → |
| --- | --- |
| the resolved read | fails — the card is never listed |
| `reviewColumns` at the call | fails — the classifier drops the renamed
card the widened read just found |

That second row is the whole point: it proves the pair had to move
together, which is the thing I got wrong twice earlier in this series.

## Second commit: a red on `main`, not from this branch

`check-fnxc-future-dates` landed and **`main` fails it** — verified by
running the script on a clean `origin/main` checkout rather than
inferring. Nine files carry stamps dated after today, so every worker's
gate fails on a check none of their changes caused. Several are mine: I
had been stamping tomorrow's date across this whole series, which is
precisely the out-of-order record the check exists to prevent.

Scope held deliberately: a repo-wide sweep touched **266 files** across
docs, scripts and every package. I ran it, backed it out, and limited
this to the nine files the check actually flags — a mechanical rewrite
that size during a queue freeze would conflict with every in-flight
branch, which is worse than the red it fixes.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71 (green **only** with the stamp
commit); `@fusion/core` full suite **4810 passed**; engine self-healing
+ blindness + both ratchets **758 passed**; `tsc` clean on core and
engine; `pnpm lint`, `check:changesets`, `lifecycle-column-census
--strict`, `check-sql-column-literals` and `check-fnxc-future-dates` all
clean.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Review-stall detection now recognizes renamed and multiple review
columns while retaining support for the legacy review column.
  - Paused tasks continue to be excluded from stall detection.
- Self-healing review-stall sweeps now search all configured review
lanes and avoid duplicate task results.

- **Tests**
- Added regression coverage for renamed and legacy review-lane queries.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 20:30:18 -07:00
gsxdsm
f49e487d91 feat(core): untraited-project lane opt-in — and main was red on the FNXC gate (#2949)
Two things, and the second is why the first does not ship alone.

## The opt-in

`resolveProjectColumnsForRoles` gains `untraitedProject:
"declared-columns"`. When **no** workflow in the project expresses
**any** lifecycle trait, every declared column id joins the answer.

This is the three-state rule at **project** scope — the last item on the
deferred list, recorded at three self-healing call sites (#2869, #2876).
A board that renames its lanes and declares no traits contributes
nothing today, so its cards are **absent from every role-keyed query**,
and the correct per-card fallback downstream never runs for them. A
fallback cannot rescue a card the query never returned.

**Not "no workflow declares this role."** A project that expresses
traits and has no review lane has *answered*; widening there would
invent lanes it deliberately lacks. Mutation-verified both directions —
widening unconditionally fails 1 of 12, making the option a no-op fails
1 of 12.

**Opt-in, not default**, because the safe direction differs per caller —
the finding in `project-union-versus-per-task-lanes.md`:

| caller | over-inclusion costs |
|---|---|
| sweep | nothing — the per-card check discards the extra rows |
| aggregator | an inflated number an operator reads (#2864, #2866) |
| action site | a card routed or notified under a vocabulary that is not
its own (#2852, #2891) |

Making it the default moves all three at once, in the one direction two
of them must not. Verified byte-identical without the option, so this
lands with **no caller changes** and each site adopts it on its own
reasoning.

## Main was red, and my own gate caught me first

I dated the new comments `2026-07-31` while today is `2026-07-30` —
**the exact defect `check-fnxc-future-dates` exists to prevent,
committed while writing the feature.** The gate I added yesterday failed
my own commit.

Correcting mine surfaced that the merged sentinel batch, #2947, and
three engine test files carried future-dated stamps too, so **the gate
was failing on `main` for everyone**, not just here.

All corrected to real dates rather than raising the ceiling. The stamps
were simply wrong, and a baseline bump would have recorded the error as
permitted — which is the failure mode that ratchet exists to prevent.

Core and engine `tsc` clean, `pnpm lint` clean, census `--strict` 0,
FNXC gate 0 (469 known, none added), gate green (161/487/13/71).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:17:18 -07:00
gsxdsm
b1bd571682 batch-sql-ratchet: the census / gate-ratchet family — collection branch, fold here (#2941)
## Family branch for consolidation directive item 4

`batch-sql-ratchet` did not exist and ~10 open PRs are waiting for a
collection point, so this establishes it. **Fold your census/ratchet
commit here and close your own PR as superseded.**

```bash
git fetch origin batch-sql-ratchet
git checkout -B batch-sql-ratchet origin/batch-sql-ratchet
git cherry-pick <your-sha>
# verify scoped, not full suite:
pnpm --filter @fusion/core exec vitest run src/__tests__/archived-column-gate-parity.test.ts --silent=passed-only --reporter=dot
git push origin HEAD:batch-sql-ratchet
```

**Candidates I can see open right now** (owners: please fold + close):

| PR | branch |
|---|---|
| #2938 | `fix/comments-ops-sentinel` |
| #2935 | `fix/task-artifacts-sentinels` |
| #2933 | `chore/commit-tightened-census-baseline` |
| #2931 | `fix/async-comments-sentinels` |
| #2928 | `fix/audit-ops-sentinel-marker` |
| #2925 | `live-task-column-lanes` |
| #2923 | `fix/task-id-integrity-sentinel` |
| #2921 | `fix/plugin-store-migration-marker` |
| #2894 | `gate/sql-literals-match-census-placement` |

That is **10 → 1** once folded. I have not cherry-picked anyone else's
commits — folding someone's work without them verifying it is how a
batch lands broken.

---

## What is in it so far (mine, from #2924)

**Clears a live main red:** `archived-column-gate-parity` fails on
`origin/main` today.

```
AssertionError: TypeScript encoding changed.
  async-comments-attachments.ts: 8 → 5
```

#2886 fixed a real bug — archived-document guards failing in *opposite*
directions on a renamed lane — by replacing three `column ===
"archived"` comparisons with `isArchivedLane(column, archivedColumns)`.
The AST scan counts raw comparisons, so the tally dropped.

**What I did not do is record it as three sites converted**, because
measured, it is not:

```
grep -rn "archivedColumns:" packages/core/src packages/engine/src --include="*.ts" | grep -v __tests__
→ (no matches)
```

No caller passes it. The parameter defaults to `LEGACY_ARCHIVED_LANES =
new Set(["archived"])`, so every call resolves to the literal it
replaced — byte-identical behaviour, resolved branch dead.

That matters for this guard's whole argument: its header warns that
converting the TypeScript half while the Drizzle and raw-`sql` halves
still compare the string is a split brain *"no test would catch, because
every builtin workflow spells the column `archived` so the two halves
agree by accident on every board we ship."* **There is no split brain
today precisely because the resolved half is unwired** — it becomes one
the moment a caller threads real lanes in without the SQL sides moving.
Recorded inline so `5` cannot be read as "3 sites done"; flagged on
#2886.

Verified not a split brain: the Drizzle and raw-sql inventories are
unchanged and both pass — worth stating because those assertions run
*after* the TypeScript one, so a plain red says nothing about them.

Scoped edit to `AUDITED_TS_SITES` by line range: these paths appear in
more than one inventory here, and an unscoped replace would quietly edit
the raw-sql side too, making the parity guard agree with itself (the
trap I hit in #2817).

Guard still bites: appending a real `task.column === "archived"` to an
audited file fails it. Core **4852 passed / 0 failed**, lint clean,
test-only.

Closing #2924 as superseded by this.

🤖 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**
* Improved task delegation messages when workflow pickup cannot be
confirmed.
* Delegation results now clearly indicate when a task has not been
verified for pickup.

* **Quality Improvements**
* Added validation checks to catch future-dated markers and inconsistent
SQL-column usage.
* Refined workflow checks to distinguish stale configuration from
incomplete configuration.

* **Documentation**
* Updated lifecycle conversion guidance with more accurate audit
findings and limitations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:59:14 -07:00
gsxdsm
c3df0f641b executor: orphaned tasks were never resumed after a restart on a renamed board (#2947)
`resumeOrphaned` is the only path that recovers tasks after a crash or
restart. On a board with renamed columns it recovered **nothing**.

## A missed pair, not an unconverted read

```ts
const tasks = await this.listWipLaneTasks();          // resolved by role — already converted
const inProgress = tasks.filter(
  (t) => t.column === "in-progress" && …,             // literal — discards everything the read found
);
```

The read was already resolved. The filter directly beneath it
re-asserted the literal on the rows that read returned, so the sweep
found the orphans and threw them all away.

**This is the worse half of the class, and it hid well:**

- the read *looks* converted, so scanning for `listTasks({ column: "…"
})` finds nothing;
- the census scores only the comparison, so the backlog number moves the
**wrong way** as you convert;
- a **structural test already existed** pinning "the read asks for
resolved lanes" — `executor-resume-query-lanes.test.ts` — and it was
green the entire time the sweep was dead. A test asserting the read
exists says nothing about the filter beneath it.

The failure only surfaces after a crash, when an operator is already
investigating the crash and has every reason to blame that instead.

## The ratchet, generalised

#2944 ratcheted this class inside `self-healing.ts` after review found
one instance and a follow-up audit found five more. This generalises it
to every engine source: a function that resolves lanes **and** compares
a column id in the same body is a pair.

Excluded, deliberately:
- the **fallback arm** of a resolved ternary (`lanes ? lanes.has(c) : c
=== "done"`) — the correct shape;
- four files whose literals are deliberate, each with the reason
recorded: `ephemeral-worker-manager` (unresolvable-workflow default),
`triage` (the U11 orphan case), `scheduler` and `replan-target` (sync
listeners on the inert sync IR reader, already pinned by
`sync-workflow-ir-is-always-default.pg.test.ts`);
- `self-healing.ts`, because it has a **dedicated** ratchet that is
strictly more precise. Two ratchets allowlisting the same site is one
fact with two owners, free to drift — the exact failure mode this
program keeps hitting. One file, one ratchet.

It carries a positive control: a wrong source path would make every case
pass by scanning nothing.

**I swept the rest of the engine with it and executor.ts was the only
genuine hit** — everything else is documented-deliberate or blocked on
the inert sync reader.

## Revert results

Each measured by restoring the literal filter and re-running:

| | reverted → |
| --- | --- |
| behavioural case | fails — the renamed card is dropped and the sweep
returns before touching it |
| the ratchet | fails, naming the site: `resumeOrphaned:
executor.ts:5974` |

A non-vacuous companion (card in the review lane → not resumed) rules
out a filter that matches everything: a card in review has no session to
resume, and re-dispatching it would restart finished work.

**Measured:** `executor.ts` column guards 8 → 7; baseline re-recorded
downward.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; executor prompt/soft-delete/resume
suites plus the new ratchet, 357 passed; `tsc` engine clean; `pnpm
lint`, `check:changesets`, census `--strict` and
`check-sql-column-literals` clean, each run explicitly.
2026-07-30 19:59:03 -07:00
gsxdsm
8503a2b12f batch-census-sentinels: six sentinel-marker PRs in one (supersedes #2921 #2928 #2931 #2935 #2938 +1) (#2943)
Fifth family, not in the four you listed — it was about to sit while the
others consolidated. **Six folded; two need arbitration.**

## Folded (cherry-picked clean)

migration marker · async archived check · audited-sentinel missing its
marker · five of six `archived` checks in one file · the two
artifact/comment read-only guards · the last unmarked
`getLiveTaskColumn` sentinel.

One root cause, which is why they belong together: **a literal compared
against a SENTINEL value is not a lifecycle-lane guard** — the census
counts it, and the fix is a marker, not a conversion.

## The baseline conflicted on every cherry-pick

All six re-recorded `lifecycle-column-census-baseline.json`
independently. I resolved by **regenerating once from the folded tree**
rather than merging six hand-edits: the baseline is a derived artifact,
so the measured value is the only correct resolution, and hand-merging
derived JSON is how a wrong ceiling gets locked in.

That is the strongest case for the family model I can give you: six PRs
touching one derived file conflict pairwise regardless of merge order —
15 possible pairs — and auto-rebase would have churned them serially.

## NOT folded — one line for arbitration

**#2925 (`live-task-column-lanes`) conflicts with #2923
(`fix/task-id-integrity-sentinel`) on
`packages/core/src/task-store/task-id-integrity.ts`.** #2923 marks a
sentinel there; #2925 converts lanes. Different intents, same file. I
did not guess which wins — land one, rebase the other, fold both after.

## Verification

`--strict` exit 0 · backlog **158**, reviewed **122** · core typecheck
clean · scoped, not full suite.

## Queue

**52 → 39** after my two folds (this + #2940 portal). The ~24
"self-healing … on a renamed board" family is still the dominant block.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Clarified lifecycle-state terminology and migration markers throughout
task and project management documentation.
* Documented the distinction between archived-task sentinels and
workflow column identifiers.
* Updated lifecycle documentation tracking to reflect the latest
coverage.

* **Bug Fixes**
  * No runtime behavior changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:41:09 -07:00
gsxdsm
8b75a42d22 batch: self-healing sweeps were blind on renamed boards (26 sweeps, folds 23 PRs) (#2944)
**Consolidation of 23 open PRs into one.** Every one shared a single
root cause and mostly touched a single file; 23 CI runs for that was
indefensible.

Folds and supersedes: #2867 #2869 #2876 #2879 #2883 #2891 #2899 #2901
#2902 #2905 #2906 #2914 #2916 #2918 #2919 #2920 #2922 #2927 #2929 #2932
#2934 #2937 #2939.
(#2865, #2882, #2897, #2909, #2912 already merged and are not
re-folded.)

## The root cause

A self-healing sweep selects its work with `listTasks({ column:
"in-review" })`. On a board whose lanes are renamed that returns
**nothing**, so the sweep never runs — no error, no log line, no failed
task. Several sweeps had already had their *predicates* converted to
resolved lanes, which dropped a census count and changed nothing,
because the query above the loop had already returned an empty list.

**26 sweeps converted.** Each one: read the project's columns for the
role, then decide each card against **its own** workflow, with the
legacy ids unioned so a board mid-rename is never skipped.

## What each sweep stops silently failing to do

| | |
| --- | --- |
| stale merger status | one finished card held the **merge queue** for
everything behind it |
| stale `blockedBy` / completed-task release | dependents stayed blocked
on work that had already finished — the board stops moving |
| workspace partial lands | a task left with **some repos merged and
some not** |
| mid-merge retry stamp | the card stalled *and* the operator's manual
Retry was gated by the same stamp |
| in-progress limbo / no-progress failures | dead cards held a work slot
forever |
| partial-progress retry | real work parked failed with its **retry
budget unspent** |
| orphaned-execution signal | visibility only — the one signal pointing
at an orphan went silent |
| zero-commit audit | went **half-blind**: the error arm kept working,
the lane arm did not |

Plus: ghost review cards, transient merge failures, misclassified
failures, branch misbinding, missing-worktree failures,
merged-but-unfinished finalization, done-metadata repair, self-owned
branch conflicts, orphan-only scope violations, post-done wedges, idle
assigned agents, PR-conflict worktree ownership, and orphaned workspace
worktrees.

## Two defects the conversion itself introduced, both caught and fixed

1. **Missed pairs.** Widening a read without converting the guards
beneath it is *worse than not converting*: the sweep starts admitting
renamed-board cards and then mis-decides every one. Review caught a
second guard on a re-read row; the audit that triggered found **five
more**, one of which gates the `reviewProof` triple-proof — a renamed
review card would have been moved backward with the safety check
silently skipped. Column guards 86 → 81.
2. **Duplicate processing.** The literal reads were disjoint by
construction; resolved reads are not, so a column carrying two role
flags put one card in two buckets — duplicate moves, duplicate audit
rows, inflated counts.

Both now have ratchets.
`self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts`
**derives** its sweep list (a sweep counts as converted when its body
calls `resolveProjectColumnsForRoles`), so it cannot go stale, and it
carries two positive controls because a broken regex finds no offenders
and a broken derivation iterates nothing — an empty loop registers no
tests and reads green.

## Deliberately unchanged

- 22 `moveTask` destinations carrying `recoveryRehome: true` —
`moves.ts` exempts these so a card stranded in an undeclared column
stays rescuable.
- One literal in `clearStaleBlockedBy`'s log-dedup closure (allowed by
name in the ratchet, with the reason).
- `surfaceInReviewStalls` — hot list-read path, needs a batched
prefetch; that is a performance design decision, not a conversion.
- `scheduler.ts` and `replan-target.ts` — built on
`resolveTaskWorkflowIrSync`, which returns the default IR for every task
in production. Converting there produces inert code.

## The fold itself is worth one note

All 23 branches appended to the **same test file at the same anchor**,
so every automatic strategy — git 3-way, `merge-file --union`, and three
hand-written resolvers — interleaved them mid-block. Two attempts
committed conflict markers before I caught it. The file is therefore
**reconstructed**: head authored once, body assembled as the union of
each branch's own intact top-level segments keyed by test title, with
the nested `already-merged hard blocker` describe appended whole
(flattening it orphaned its helper). Verified by *parsing after every
step* rather than trusting the merge — which is how each interleaving
was caught.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71. Scoped suites 592 passed
(self-healing, the blindness suite at 68 cases, the ratchet, and the
notification suite). `tsc` engine clean; `pnpm lint`,
`check:changesets`, `lifecycle-column-census --strict` and
`check-sql-column-literals` all clean, each run explicitly.

Each folded conversion was individually revert-proven on its original
branch — the read reverted alone, and the per-card verdict reverted
alone — and those measurements are recorded in the commit messages
carried into this branch.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:40:57 -07:00
gsxdsm
7b68f20501 batch(docs): fold the three workflow-learnings / annotation PRs into one (#2942)
## Family batch — replaces #2926, #2892, #2887

Per the consolidation directive: the u9/e2e **docs family**, folded into
one branch and one CI run. Three PRs, five commits, **five files,
comment and markdown only**.

| folded PR | commits |
|---|---|
| #2892 `docs/union-vs-per-task` | the project union and the per-task
answer are not ranked; date correction |
| #2926 `docs/date-my-measured-claims` | date the measured claims (one
was wrong); date the grep-vs-AST measurement in the SQL gate header |
| #2887 `docs/archived-state-literals` | mark the three archived STATE
literals as deliberate |

Cherry-picked in original order with authorship preserved; all five
applied clean, no conflicts.

## Scope is provably comment-only

```
docs/solutions/workflow-learnings/lifecycle-conversions-that-score-as-wins.md
docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
packages/core/src/task-store/async-maintenance.ts        ← FNXC DELIBERATE-LITERAL annotation
packages/core/src/task-store/workflow-definitions.ts     ← FNXC DELIBERATE-LITERAL annotation
scripts/check-sql-column-literals.mjs                    ← header prose only
```

Every added line in `packages/` and `scripts/` is inside a comment —
checked by filtering the diff for declarations, conditionals and
returns, which returns nothing. The two core files gain
`DELIBERATE-LITERAL` markers explaining that `'archived'` is a **state**
marker there, not a lane: the sweep collects rows Fusion itself archived
or soft-deleted, so widening to the resolved archived set would pull
live cards into a cleanup pass.

## Verification (scoped, per the directive — not the full suite)

- `pnpm lint` — clean
- `check-sql-column-literals` — exit 0 (the file it annotates)
- `check:lifecycle-columns` — exit 0 (the markers it adds are
census-visible)
- `sync-workflow-ir-callsite-allowlist.test.ts` — 3/3

## A correction worth recording

Mid-fold I saw a changeset, `self-healing.ts` and a test file in `git
diff origin/main..HEAD` and nearly reported the batch as impure. They
were **main's own commits** — `origin/main` advanced between branch
creation and the diff, so the comparison was against a stale base.
Rebasing onto current `main` reduced it to the five files above. Worth
flagging for anyone else folding a family today: with `main` moving this
fast, diff the branch **after** rebasing or the file list will lie to
you.

## Closing the originals

#2926, #2892 and #2887 are superseded by this and are being closed. I
hold no PRs of my own in this family — all mine merged — so this fold is
on behalf of the family rather than a rollup of my own work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:12:26 -07:00
gsxdsm
b4ed12e9c8 batch-u7-lane-fixes: three core/engine renamed-board fixes folded (was #2925, #2930, #2936) (#2925)
**Consolidated per the queue freeze.** Three single-fix PRs of mine
folded into this one branch; #2930 and #2936 are closed as superseded.
Net effect on the queue: **3 → 1**.

All three are the same root cause — a lifecycle lane compared against a
legacy id — and all three carry a measured revert proof. Verified scoped
(not full suite) on the folded branch: `tsc --noEmit` clean, `pnpm lint`
clean, SQL-literal gate green, census `--strict` green, and 61 tests
across five suites plus the guard at 9/9.

---

### 1. `getLiveTaskColumn` produced the archived sentinel from a literal
(was #2925)

`getLiveTaskColumn` **manufactures** the string `"archived"` that a
dozen comparisons across five files trust — and it tested `row.column
=== "archived"`. A live row in a renamed archived lane read as **live**,
so the gates hiding an archived card's artifacts and document listings
never closed.

Fixing those twelve comparisons individually would have been wrong twice
over: **they are sentinels, and the defect was in the producer.** One
line, once, and all twelve become correct. `resolveArchivedLanes` moved
to `project-lane-vocabulary.ts` — three private copies of one fact is
how the "write guard says yes, publication guard says no" disagreement
happens at scale.

*Revert proof (real PostgreSQL):* restore the literal → `expected [ {
…(14) } ] to deeply equal []`.

**Caught myself shipping the unwired shape here:** I added the parameter
to seven functions and wired none of their impl callers — the exact
inert-conversion defect this program exists to remove. The failing test
is the only reason I noticed.

### 2. Mission delivery repair refused a completed card (was #2930)

`getTerminalTaskEvidence` tested only `column === "done"`, so a
completed card on a renamed board classified as `nonterminal` and
`reconcileFeatureDoneWithTerminalTask` threw `TASK_NOT_TERMINAL: … not
shipped`. Valid operator work refused — with the message naming the real
column while the check couldn't see it.

The **type** blocked the fix from the far end: `TerminalTaskEvidence`
pinned `column: "done"` / `"archived"`, so the resolver couldn't report
the real column without a compile error. `kind` already carries the
role, so `column` is free to carry the truth.

*Revert proof (real PostgreSQL):* restore the literal →
`TerminalTaskReconciliationError: … not shipped`.

I had deferred this twice on the premise that `AsyncMissionStore` "holds
a layer, not a store". It holds an **optional `taskStore`**, and the
single production construction site supplies it.

### 3. The unwired-lane guard reported two FALSE entries (was #2936)

`unwired-lane-parameter-guard.test.ts` has been **red on main** since
#2875, flagging two `InReviewDurationLanes` properties as unwired when
the impl demonstrably supplies both. Cause: my own owner-scoping rule
requires a mention from a file naming the declaring symbol — correct for
a function, structurally impossible for an interface passed as an
inferred object literal.

Fixed at the caller (name the type) after trying the tool three ways:
relaxing type-owned properties hid **12** genuine entries; resolving
owners to consuming functions hid **6**. Each refinement traded the
false positive for false negatives — the sign a co-occurrence heuristic
has hit its limit. Recording two *wired* parameters in `KNOWN_UNWIRED`
was rejected: that puts non-debt in the debt list, which is how a
ratchet starts lying.

Guard back to **9/9**, baseline unchanged at 17. **This un-reds main.**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:07:09 -07:00
gsxdsm
97c987c40b batch-portal-test-fixes: both portal query-root failures in one PR (supersedes #2911, #2913) (#2940)
Family fold: #2911 + #2913, cherry-picked clean. One root cause
(portalled modals queried the render container instead of
document.body). Scoped verification: 167/167. NOTE: the portal family is
2, not ~8 — the real jam is ~24 self-healing 'renamed board' PRs plus an
unlisted census/sentinel family of ~9.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Updated modal test coverage to correctly validate content rendered
through portals.
* Improved assertions for task counts, timestamps, mobile detail views,
and keyboard-related behavior.
* Added test comments clarifying portal-based rendering and assertion
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:06:57 -07:00
gsxdsm
c6767cb258 self-healing: foreign-only contamination never cleared on a renamed board (fourteenth sweep) (#2891)
`recoverForeignOnlyContaminatedInReviewTasks` classifies a branch
carrying **only foreign commits** and clears the contamination park that
nothing else clears. Two literal reads meant that on a renamed board it
classified nothing, and the task stayed parked indefinitely.

## The two redundant guards were the interesting part

Both filters carried a `task.column === …` check that was **redundant**
while the query pinned the column. Under a resolved read they stop being
redundant and become the per-card verdict — so they convert here rather
than being deleted. Deleting them would have silently widened the sweep,
which is the failure this whole class is about.

## Dedupe matters more here than elsewhere

The concatenated candidate list is deduped (the P1 reviewed on #2879).
It bites harder in this sweep because the two filters have **different
predicates**: a column carrying both a review role and the wip role
could satisfy both and classify one branch twice.

Explicit `has` guard rather than `new Map(entries)` — that constructor
keeps first insertion *order* but the **last** value for a repeated key,
so it reads as first-bucket precedence while doing the opposite.
(Corrected in #2879 and #2883 for the same reason.)

## Revert results

Each applied alone and the file re-run:

| conversion | reverted → |
| --- | --- |
| the resolved reads | fails — the card is never listed, so the
classifier is never called |
| the review verdict | fails — the renamed review lane does not match
and the card is filtered out |

Observable is **candidacy**: `classifyForeignOnlyContamination` runs
once per accepted card and not at all for a rejected one, which is
exactly the read-plus-verdict under test. It is a static named import,
so it is intercepted with a scoped `vi.mock` (spyOn cannot rebind an
already-resolved ESM binding); only that one export is overridden, so
the sweeps in this file that use `inspectBranchConflict` are unaffected.

A non-vacuous companion (same card in the board's hold lane → never
classified) rules out a read that returns everything.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71, plus `self-healing.test.ts` 412;
`tsc` engine clean; `pnpm lint`, `check:changesets`, census `--strict`
clean, each run explicitly.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:26:06 -07:00
gsxdsm
f6e368205e self-healing: a card stuck mid-merge could not be retried on a renamed board (twenty-first sweep) (#2912)
`recoverStaleMergingStatus` clears a `merging`/`merging-pr` stamp left
on a review card with no live merger behind it. The literal read meant
that on a renamed board the stamp was never cleared.

**The operator's escape hatch was closed by the same bug that caused the
stall.** That stamp is consulted by the merger *and* by the dashboard's
manual Retry gate, so the card could neither progress on its own nor be
retried by hand.

## The redundant guard converts

`task.column !== "in-review"` was redundant while the query pinned the
column; under a resolved read it becomes the per-card verdict. Carries
the #2891 shape — **narrow when the card can answer, broad when it
cannot**.

## Fixture note worth keeping

`updatedAt` in the test is deliberately ancient.
`isStaleMergeActiveStatus` requires the stamp to have sat untouched for
`minAgeMs`, so a fresh fixture would be filtered out for a reason that
has nothing to do with lanes — and would then have passed with the fix
reverted. That is the shape of most of the vacuous assertions on this
branch: a *later* filter rejecting the card, masking whether the lane
logic worked at all.

## Revert results

Each applied alone and the file re-run:

| conversion | reverted → |
| --- | --- |
| the resolved read | fails — the card is never listed |
| the per-card verdict | fails — the renamed review lane is filtered out
|

A non-vacuous companion (same stamp on a wip card → untouched) rules out
a read that returns everything; a merge stamp in the wip lane belongs to
`recoverInProgressLimbo` and the executor, not here.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71, plus `self-healing.test.ts` 412;
`tsc` engine clean; `pnpm lint`, `check:changesets`, census `--strict`
and `check-sql-column-literals` clean, each run explicitly.
2026-07-30 18:16:14 -07:00
gsxdsm
1adf886f04 fix(tests): definition-actions queries container; the modal is portalled (12 → 0) (#2893)
## Third file, same root cause

The largest block in the dashboard `app:backfill 2/4` shard — and the
same defect as #2885 and #2890.

**Probed before converting, not pattern-matched:**

```
PROBE container=false document=true
```

`TaskDetailModal` mounts through `createPortal`, so its subtree hangs
off `document.body` rather than the container `render()` returns. All
**25** container-rooted lookups in this file could only ever return
null.

## The regex handles two shapes separately, on purpose

`X.container.querySelector` (render-result-scoped) and a bare
destructured `container.querySelector` are converted in separate passes,
because a blanket replace on the previous file rewrote the former into
`renderResult.document...` — not a thing — and broke two passing tests.
A lookbehind keeps `triageContainer.` / `todoContainer.` out of the bare
pass.

## One case needed more than a query-root swap

`does NOT show Changes tab for triage/todo tasks` renders the triage
modal and the todo modal back to back and told them apart by their
container handles. **Both were empty**, so
`querySelectorAll(".detail-tab")` returned `[]` and the assertion
compared `[]` against twelve tab labels — it could not have failed for
the reason it was written.

Querying `document` alone does **not** fix that one: with two modals
mounted at once, a document-rooted `.detail-tab` lookup returns *both*
tab strips concatenated. Unmounting the first render is what makes each
assertion about one modal again.

Verified by measurement rather than reasoning — the file only reached
66/66 after the unmount, not after the query swap.

## Evidence

| | result |
|---|---|
| the file | **66/66** (was 12 failed) |
| shard `2/4` | **22 → 10** |
| mutation: rename `.detail-spec-edit-trigger` in `TaskDetailModal` |
**1 failed** |

`pnpm lint` clean. Test-only; `TaskDetailModal.tsx` restored clean.

## Running total on the portal defect

| PR | file | cleared |
|---|---|---|
| #2885 | `TaskDetailModal.models-progress-workflow` | 30 |
| #2890 | `settings-mobile` | 17 |
| this | `TaskDetailModal.definition-actions` | 12 |

**59 of the ~111 backfill failures**, all one defect: tests querying
`container` for components that render through a portal. It hid because
`screen.*` queries in the same files always worked (they query the
document), so the failures read as "the component never rendered" rather
than "we asked the wrong root".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:09:58 -07:00
gsxdsm
b3e16b3988 fix(tests): rendering.test queries container; the modal is portalled (28 → 4) (#2895)
## Fourth file, same defect

`TaskDetailModal.rendering.test.tsx` is the whole of the dashboard
`app:backfill 4/4` shard bar one case (28 of 29).

**57** container-rooted lookups converted. `TaskDetailModal` mounts
through `createPortal`, so `container` is empty and every one returned
null — visible in the two failure shapes this file produced:

```
10x   expected null to be truthy
~14x  expected undefined to be '<some text>'      ← container.querySelector(x)?.textContent
```

## Four remain, deliberately

They assert the modal's **wrapper structure**, not its contents:

```ts
expect(document.querySelector(".modal-overlay.open")).toBeTruthy();
```

`FloatingWindow` renders `.floating-window-overlay`
(`FloatingWindow.tsx:627`). The only `.modal-overlay open` left in
`TaskDetailModal` is the unrelated *refine* overlay at `:6801`. So these
pin the **pre-FloatingWindow** wrapper.

Re-pointing them means encoding the *current* modal-shell contract — a
UI structure decision that belongs with whoever owns the FloatingWindow
adoption, not bundled into a query-root fix where it would be easy to
miss. Left failing and flagged rather than guessed at.

It is also a different failure shape from the rest: `expected <div …> to
be null` on a mobile-variant badge, i.e. an assertion that *found*
something, versus 24 that found nothing. Different cause, different fix,
different reviewer.

## Evidence

| | result |
|---|---|
| the file | **120 tests, 4 failed** (was 28) |
| mutation: rename `.detail-id` in `TaskDetailModal` | **5 failed** |

The mutation matters because the change is "query a different root" —
the risk is assertions that now find *something* and stop
discriminating. They still observe the real component.

`pnpm lint` clean. Test-only; `TaskDetailModal.tsx` restored clean.

## The portal defect, totalled

| PR | file | cleared |
|---|---|---|
| #2885 | `TaskDetailModal.models-progress-workflow` | 30 |
| #2890 | `settings-mobile` | 17 |
| #2893 | `TaskDetailModal.definition-actions` | 12 |
| this | `TaskDetailModal.rendering` | 24 |

**83 of the ~111 backfill failures**, one defect: tests querying
`container` for components that render through a portal.

It hid for so long because `screen.*` queries in the same files always
worked — they query the document — so the failures read as *"the
component never rendered"* rather than *"we asked the wrong root"*. And
it was invisible to CI: the quality runner stops after the first failing
lane, and `app:app` failed ahead of every backfill shard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:09:46 -07:00
gsxdsm
85ca9fe461 fix(tests): agent-detail mobile padding — jsdom cannot compute an unparsed shorthand (#2910)
## The last failure in `app:backfill 1/4`

```
AgentDetailView mobile scroll regression (FN-4231)
  > adds mobile row gaps to the overview hero for long health and skills metadata (FN-7958)
AssertionError: expected '0' to be 'var(--space-md)'
```

**Not a style regression — the CSS is unchanged.**

jsdom does not substitute `var()`, and what it does *instead* changed at
the **27 → 29** bump (`4819c2634`): a directly-declared **longhand**
still echoes its raw text, while a **shorthand** fails to parse and
computes to the initial value. Same cause as the TaskCard failures fixed
in #2782.

**The asymmetry is visible three lines above the failure** — `rowGap`
and `columnGap` assert the same kind of token and still pass, because
they are declared as longhands. Only `padding` broke, which is why this
read as a one-property regression rather than a jsdom behaviour change.

## `paddingTop` does not rescue it

That was my first attempt, and it still returns `'0'` — measured, not
assumed. jsdom cannot derive a longhand from a shorthand it failed to
parse, so **computed style cannot answer this at all**.

## The fix

Assert the **declared rule**, which is the pattern this file already
uses for its desktop counterpart:

```ts
expect(loadAllAppCssBaseOnly()).toContain("padding: var(--space-md) calc(...);");
```

One difference that matters: the **full** sheet is needed rather than
the base-only one. This padding is a mobile override inside `@media
(max-width: 480px)` (`AgentDetailView.css:2129-2131`), and
`loadAllAppCssBaseOnly` strips at-rules by design — so the obvious copy
of the neighbouring assertion would have silently matched nothing.

## Evidence

| | result |
|---|---|
| the file | **7/7** (was 1 failed) |
| mutation: mobile card padding `md → xl` | **1 failed** |

The mutation is the important one here: a regex that merely found the
`@media` block would pass regardless. It tracks the actual declaration.

`pnpm lint` clean. Test-only; `AgentDetailView.css` restored clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:09:20 -07:00
gsxdsm
2811a4a2df fix(tests): TaskDetailModal renders through a PORTAL — query the document, not container (#2885)
## What this clears

**30 of the 55 failures** in the dashboard `app:backfill 3/4` shard —
all in one file, all reading `expected null to be truthy`.

## The symptom points the wrong way

That message reads as *"the modal never rendered"*, and that is how this
survived. The file's helpers took the `container` returned by `render()`
and asked it for the modal's elements:

```ts
const header = container.querySelector("[data-testid='agent-log-model-header']");
```

`TaskDetailModal` mounts inside `FloatingWindow`, which uses
**`createPortal`** — so the modal subtree is attached to
`document.body`, **not** beneath the container React handed back. Every
`container.querySelector` in the file returns null no matter what
renders.

## Probed, not inferred

I had already spent one wrong hypothesis on this exact file — the shared
`TaskDetailModal.test-helpers.ts` carries a genuinely stale `{
flagEnabled: false, workflows: [] }` fixture of the kind #2833 fixed for
`App.test.tsx`, so it looked like the 30-failure lever. Adopting
`DEFAULT_BOARD_WORKFLOWS` **changed nothing** (still 30 failed).
Reverted.

So I instrumented instead:

```
P1_after_tab_click  menu=true items=["Live","Feed","Raw","Interventions"]
P2_after_select     viewer=true header=true empty=false
```

The Activity menu opens, `Raw` selects, and the viewer **and** its model
header are both present — via `document`. Only the container-rooted
lookup could not see them.

**Why the file half-worked:** `screen.getByRole(...)` in the same
helpers always succeeded, because `screen` queries the document. That
mix of query roots is what made a query-root bug look like a rendering
fault.

19 `container.querySelector` call sites converted.

## Scope — deliberately narrow

**Only this file.** Eight other `TaskDetailModal` specs use
`container.querySelector` too — 95 of them in `attachments-and-tabs`
alone — and they **all pass today**, because they render
`TaskDetailContent` rather than the portalled modal. Converting green
files would be churn with real risk and no red to justify it.

## Evidence

| | result |
|---|---|
| the file | **48/48** (was 30 failed) |
| shard `3/4` | **55 → 25** failures |
| mutation: rename the `agent-log-model-header` testid in
`AgentLogViewer` | **18 failed** |

The mutation matters here: the fix is "change what we query", so the
risk is assertions that now find *something* and stop being
load-bearing. They still observe the real component.

`pnpm lint` clean. Test-only; `AgentLogViewer.tsx` restored clean.

## Remaining in this shard

`settings-mobile` (17), `NodesView` (2), `MailboxModal` (2),
`agent-modals-mobile` (2), `TaskDetailModal.pr-tab` (1),
`onboarding-flow` (1). Tracked on #2784, which I have been keeping
current with per-lane numbers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:06:13 -07:00
gsxdsm
aacd18e847 docs(lanes): audit the last three files the census points at with no reason attached (#2908)
Second pass of #2873's sweep, over the files that still carry lifecycle
guards and **zero** audit notes. No source change — every literal stays
counted, none gets an exemption marker.

## `project-store-ops.ts` (1) — dead sync path, do **not** convert

The literal would leak a merge-queue entry on a renamed board: a card
leaving review would never be dequeued. Except the function cannot run —
it reaches for `store.db.prepare`, which throws in PostgreSQL backend
mode.

The live path is `dequeueMergeQueueOnColumnExitInTransaction`
(`async-merge-coordination.ts`, called from `moves.ts`), and it is
**already converted** — it takes `moveReviewColumns` and the caller
supplies them. Recorded so the census entry is not mistaken for
unconverted debt, and so it can be deleted alongside the rest of the
sync SQLite residue.

## `task-id-integrity.ts` (2) — one real, one sentinel, and the real one
must not go alone

```ts
return cached?.column === "archived";   // ← board lane: real
if (live === "archived") return true;   // ← getLiveTaskColumn's manufactured value: sentinel
```

Converting the first while `getLiveTaskColumn` still keys on the literal
would leave the two disagreeing about what "archived" means. It waits
for that one, which is the single highest-leverage line in this cluster
— fixing it makes five downstream sentinel checks correct without
touching any of them.

## `auto-merge-finalization.ts` (3) — one real but diagnostic-only, two
non-defects

`task.column === "done"` selects which **reason string** is reported;
both arms return `{ ok: false }`. So a renamed board is refused with the
generic `missing-merge-confirmation` instead of the specific
`done-without-merge-confirmation`. Real, and worth less than the
signature change required to fix it — the resolver two functions up
already computes `isCompleteColumn`, but this function does not receive
it.

The other two are **not** defects and it is worth saying so explicitly:
the `columnId === "done"` near the top is the resolver's documented
degraded fallback (the live arm calls `columnHasFlag`), and the
`step.status` comparison is a **step status**, not a column.

## The pattern across both passes

Of **6 files and 15 guards** audited: **2** were live defects worth
converting, **4** were sentinels or dead paths that would have *broken*
a renamed board if converted, and the rest were diagnostics or misfiled
step statuses.

That ratio is the argument for these notes existing. A file's census
count is an upper bound on convertible sites, not a work estimate — and
in this cluster the naive reading of the number would have made things
worse more often than better.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/core`, `@fusion/engine`) — clean
- census `--strict` — exit 0, counts unchanged (that is the point)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:05:52 -07:00
gsxdsm
27501a53da fix(tests): summary-tab queries container; the modal is portalled (2 → 0) (#2907)
## Fifth file, same defect

`TaskDetailModal.summary-tab.test.tsx` — the last `TaskDetailModal` spec
still failing on the portal/query-root defect (#2885, #2890, #2893,
#2895).

**Probed before converting**, as with each of the others:

```
PROBE container=false document=true
```

`TaskDetailModal` mounts through `createPortal`, so `container` is empty
and its 5 lookups returned nothing. Both failures carried the signature
that shape produces on a text read:

```
expected undefined to be 'Activity'      ← container.querySelector(x)?.textContent
```

## Evidence

| | result |
|---|---|
| the file | **17/17** (was 2 failed) |
| mutation: rename `.detail-tabs` in `TaskDetailModal` | **3 failed** |

`pnpm lint` clean. Test-only; `TaskDetailModal.tsx` restored clean.

## Deliberately not bundled: the other three in this shard

Each is a **different** cause, and lumping them in would hide that:

- **`AgentDetailView.mobile-scroll`** — `expected '0' to be
'var(--space-md)'`. That is the **jsdom-29 `var()` computed-style**
case, the same one fixed for TaskCard in #2782: jsdom does not
substitute custom properties, and what it does *instead* changed at the
27→29 bump. Not a query root.
- **`AgentListModal`** — `expected +0 to be 3`.
- **`SubtaskBreakdownModal`** — an undefined-vs-string assertion
mismatch.

Three separate small fixes, not one sweep. Keeping them apart also keeps
each mutation-check honest about what it proves.

## Portal defect, running total

| PR | file | cleared |
|---|---|---|
| #2885 | `models-progress-workflow` | 30 |
| #2890 | `settings-mobile` | 17 |
| #2893 | `definition-actions` | 12 |
| #2895 | `rendering` | 24 |
| this | `summary-tab` | 2 |

**85** backfill failures from one defect: tests querying `container` for
components that render through a portal.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Improved coverage for task detail modal behavior, including tab
ordering, chat content, merge-card containment, and summary rendering.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:02:36 -07:00
gsxdsm
60bfebdc98 fix(reliability): the duration query hid its lane ids inside a SQL template (#2875)
The Reliability panel's **third and last** blind input — and my own
loose end. #2861 fixed the two counts beside it, so the panel went from
uniformly wrong to **partially** wrong: entries and bounces populated,
duration reporting `no-in-review-entries` forever. Partial blindness is
harder to notice than total, which is why finishing it matters more than
one site suggests.

```sql
metadata->>'to' = 'in-review'
  OR (metadata->>'from' = 'in-review' AND metadata->>'to' = 'done')
```

## The class, not just the site

**This shape is invisible to every check we have.** The lifecycle census
scans `===`/`!==` comparisons; the unwired-lane-parameter guard scans
declarations. Neither sees a lane id inside a `sql` template, so this
class is **not in the backlog total at all** — the number is a floor for
this reason as well as the usual one.

`scripts/check-sql-column-literals.mjs` (#2841, in flight) is the
detector for exactly this: it freezes the surface at 30 sites rather
than converting any, so this one was unowned. That PR and this one are
complementary — it stops the surface growing, this shrinks it by one.

## The fix

Lanes resolve **once per call** via `resolveProjectColumnsForRoles` and
arrive as parameterised equality fragments, one branch per id — no
interpolated list, no string building.

Resolution lives in `getInReviewDurationEventsImpl` because that is
where the store is; `async-audit.ts` takes a bare `db` handle and cannot
resolve anything. Best-effort, defaulting to the legacy pair, so a
caller that cannot resolve keeps exactly today's query.

**The union is correct rather than a widening hack**, for the same
reason as #2861: these are *move records*, and a past move recorded the
column name as it was at the time. A board renamed last month has rows
under both ids, so the honest query covers both — which is precisely
what `resolveProjectColumnsForRoles` returns.

## Tested against real PostgreSQL, deliberately

This is a **SQL predicate** change. A mocked store would assert the
arguments and prove nothing about the query that actually runs — which
is the entire risk when the literal lives inside `sql`. The new case
inserts real `activity_log` rows on a renamed board and reads them back
through the real store method.

The legacy-lane case in the same file stays green, which is the
compatibility half.

**Revert proof, measured:** restore the hardcoded fragments and the new
case fails with

```
expected [] to deeply equal [ 'renamed-entered', 'renamed-done' ]
```

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/core`) — clean
- `activity-log-parity.pg.test.ts` — 5 passed against real PostgreSQL

With this, all three Reliability inputs read the board's own lanes.

🤖 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**
* Reliability duration metrics now work correctly with renamed workflow
lanes.
* Completion tracking recognizes configured completion lanes instead of
relying on fixed defaults.
* Improved handling of transitions between multiple review lanes and
review-to-work-in-progress movements.
* Legacy lane behavior remains supported when configured lane
information is unavailable.

* **Tests**
* Added coverage for renamed lanes, historical lane IDs, and transition
edge cases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:59:34 -07:00
gsxdsm
de677b231a fix(tests): settings-mobile queries container; SettingsModal renders through a portal (#2890)
## What this clears

All **17 failures** in `settings-mobile.test.tsx` — the second-largest
block in the dashboard `app:backfill 3/4` shard after the
TaskDetailModal file (#2885), and the **same root cause**.

## Probed, not assumed

Every failure read `expected null to be truthy`, which looks like the
modal never rendered:

```
PROBE container.settings-layout=false  document.settings-layout=true
      document.modal=true  bodyLen=36637
```

`SettingsModal` mounts through `createPortal`, so its subtree hangs off
`document.body`, not the container `render()` returns. The markup is
there; the container-rooted lookup cannot see it.

## Nine of these assertions could never have failed

They are **absence** checks:

```ts
expect(container.querySelector(".settings-scope-banner")).toBeNull();
expect(container.querySelector("#settings-mobile-section")).toBeNull();
```

`container` is empty for this component no matter what, so these passed
on an **empty root** rather than on absence — they would have kept
passing if the element appeared. Converting them makes them mean what
they say. All nine still pass, so they were correct, just unproven.

## Two rounds of my own errors, both caught by measuring

1. A blanket `container` → `document` replace also rewrote
`renderResult.container.querySelector` into `renderResult.document...` —
**not a thing**. That broke the two *"embedded Settings surface"* star
tests. Because the embedded surface is genuinely not portalled, this
first read as *"embedded needs container"*. It doesn't; the JS was
simply invalid.
2. Fixed by restoring those seven, then converting them to **bare
`document`** once I confirmed each test unmounts its surface before
rendering the next (`modalRender.unmount()` precedes the embedded
render), so a document-rooted query cannot match a stale instance.

**Verified no regressions rather than assuming** — diffed the
failing-test list before and after: 13 fixed / 0 new, then the remaining
4 fixed. Every failure in the final state was already failing at the
start.

## Evidence

| | result |
|---|---|
| the file | **41/41** (was 17 failed) |
| shard `3/4` | **55 → 38** with this change alone |
| mutation: rename `.settings-layout` in `SettingsModal` | **1 failed**
|

`pnpm lint` clean. Test-only; `SettingsModal.tsx` restored clean.

## Together with #2885

#2885 clears the TaskDetailModal file (30). Both are the same
portal/query-root defect in different files, and both were sitting
behind `app:app` in the runner's fail-fast — which is why they went
unnoticed. Shard `3/4` should land at **~8** with both applied.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:59:18 -07:00
gsxdsm
d1ea33ee79 docs(lanes): three measured claims of mine had gone stale — date them or delete them (#2904)
Comment-only. No source change, no behaviour change.

#2903 corrected a note that named a caller which had since been
converted. This applies the same check to my own notes, and all three
measured claims I wrote are now wrong:

| claim | where | actual |
|---|---|---|
| "`self-healing.ts` alone issues **49** such reads" |
`project-lane-vocabulary.ts` + its test | **37** |
| "**51** such destinations exist in production" |
`workflow-lifecycle-traits.ts` | ~34 |
| "**22** deliberately pass `recoveryRehome: true`" | same | ~18 |

All were accurate when measured. The shq fleet has been converting
`self-healing.ts` since, and this program has been converting `moveTask`
destinations all day. The repo-wide read-shaped total is now 37 *in
total*, so "49 in one file" could not have remained true regardless.

## Two different repairs, because the claims differ in one way that
matters

**The self-healing figure has a reproduction.** `node
scripts/lifecycle-column-census.mjs --json` reports `queryByFile` and
`queryRoles`. So the number is kept, marked explicitly as a dated
measurement, and the reader is pointed at the command rather than asked
to trust the figure.

**The `moveTask` counts have none.** Nothing regenerates them — the
census cannot see call arguments, which is the very point the note is
making. So they are **deleted** rather than refreshed, with the grep
that approximates them inlined and labelled approximate.

Refreshing an un-reproducible number just resets the clock on the same
failure. The shape of the finding is what the note is for; the count was
decoration that decays.

## Two process notes worth recording

**Notes that assert facts about other files are a decay class with no
detector.** The census counts literals; the unwired-lane guard counts
declarations; neither reads prose. Two of these corrections in a row
(#2903 and this) came from *reading a note and checking its claim*,
which is not something the toolchain will ever do for us. The durable
form is: cite a command, or state the shape without the number.

**I nearly published a wrong replacement figure.** My first probe
against the census AST returned `0` because I wired `summarize()`
incorrectly — the third time this session a probe has been wrong before
the product was. That is why the corrected note cites `--json` output
rather than another hand count.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/core`) — clean
- `project-lane-vocabulary.test.ts` — 9 passed
- census `--strict` — exit 0, counts unchanged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:52:56 -07:00
gsxdsm
8e505dc4e1 docs(recovery): the reason this parameter is optional stopped being true (#2903)
Comment-only. No source change, no behaviour change.

The note on `isInReviewMissingWorktreeSessionStartFailure` said:

> Optional rather than required because the other caller
(`extension.ts`) still asks BOTH questions with the literal.

**It doesn't.** All three production callers pass the resolved answer:

```
packages/cli/src/extension.ts:1927                              retryReviewColumns.has(task.column)
packages/cli/src/commands/task.ts:1390                          retryReviewColumns.has(task.column)
packages/dashboard/src/routes/register-task-workflow-routes.ts:2885   retryReviewColumns.has(task.column)
```

Left standing, that sentence tells the next reader an unconverted caller
exists — and "we keep the fallback because someone still needs it" is
exactly the justification that keeps an inert-conversion shape alive. It
is the specific failure this program has spent the day removing, in the
form of a comment rather than code.

## The parameter stays optional, for a reason that does not rot

I checked whether to make it **required** — the unwired-lane-parameter
guard's own failure message suggests exactly that ("make the parameter
required so the compiler finds the call sites") — and decided against
it, for measured reasons:

- **25 test call sites** use the optional form, several of them
*precisely* to pin the degraded mode (`cli-active-count-lanes.test.ts`
exercises the no-argument path on both a legacy and a renamed lane).
Requiring the parameter deletes that coverage.
- The enforcement it would buy already exists: `isReviewColumn` is in
the guard's vocabulary, so if any of those three callers stops passing
it, the build fails.

So the note now gives the durable reason instead of the expired one.

## How this surfaced

Not from the census — the count here is unchanged, and an *omitted
argument* is invisible to it anyway. It came from reading an audit note
that named a specific caller and checking the claim. Notes that assert
facts about other files decay silently; this one had.

## Verification

- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/engine`) — clean
- `restart-recovery-coordinator.test.ts` — 12 passed
- `cli-active-count-lanes.test.ts` — 10 passed
- unwired-lane guard — 9/9, no new entries
- SQL-literal gate — green

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:46:47 -07:00
gsxdsm
10f9df1600 fix(overseer): the whole oversight loop was inert on a renamed board (#2898)
`resolveWatchedStage` keyed on the literals `in-progress`/`in-review`,
so on a board that renames either it returned `null` for **every** card.

That is three literals with an outsized blast radius. `observeTask`
returns early on a null stage, so:

- no `OverseerStageObservation` is recorded,
- no `overseer:intervention` entry is emitted,
- and `PlannerRecoveryController`, which consumes those observations,
has nothing to steer, retry or targeted-fix.

**The entire oversight loop was inert and silent about it** — the same
shape as the self-healing sweeps whose queries returned empty arrays.

## I deferred this myself, on a cost argument that was wrong

The audit note I wrote for this site said resolving inside `observeTask`
"buys a workflow read per card per poll". Then I read the caller: the
poll **already awaits `resolveEffectiveSettings` per task**. It is a
per-task async loop regardless, so with an IR cache keyed by workflow
the addition is *(distinct workflows)* resolutions, not *(cards)*.

Pricing the fix before checking the caller cost a deferral. Worth
recording, because "this needs a cost judgement" is the most comfortable
place in this program to leave something.

## The review test is the three-trait union, deliberately

`isReviewColumnRole` checks only `mergeBlocker || humanReview`. A board
whose review lane carries `merge` (**mergeOrchestration**) — the
built-in default's own shape — would classify as *not in review* and be
skipped.

Reaching for the obvious helper would have reintroduced the bug this
change removes, through the helper meant to fix it. There is a case
asserting exactly that.

## Wiring

Both call sites, because either alone leaves a hole:

| site | why it matters |
|---|---|
| the poll (`project-engine.ts`) | per-poll IR cache — a workflow edit
is picked up next tick rather than served stale |
| the manual nudge | otherwise a renamed board answers `no-active-stage`
to an operator pressing the button |

`columnFlags` is in the `unwired-lane-parameter` vocabulary, so the
wiring cannot silently rot — the guard reports it if a future change
drops the argument.

Fail-soft throughout: an unresolvable workflow yields `undefined` and
the callee falls back to the legacy ids, which is exactly today's
behaviour. A v1 IR declares no columns, so it takes the same path.

## Revert proof (measured)

Drop the `columnFlags` branch and **exactly the three renamed-lane cases
fail**:

```
expected null to be "executor"
expected null to be "merger"   (mergeOrchestration lane)
expected null to be "merger"   (humanReview lane)
```

The legacy-id and neither-role cases stay green — the gate must still
gate, and watching every column would be its own defect.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/engine`) — clean
- `planner-overseer.test.ts` +
`planner-recovery-controller-human-control.test.ts` — 64 passed
- unwired-lane guard — 9/9, no new entries

Carries the one-line SQL-baseline re-record (`team-analytics.ts: 6 → 3`)
that #2864 left behind, same as my other open branches — main is red on
it, and identical changes to that line merge without conflict.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:43:43 -07:00
gsxdsm
defe48d30f fix(core): per-workflow metrics read zero on a renamed board (#2866)
Second of the 14 lane-bound SQL sites from #2839, after #2864.
Independent of it — different file, different caller argument.

## The defect

`aggregateWorkflowAnalytics` filtered in SQL on `t."column" = 'done'`
and `IN ('in-progress','in-review')`. On a renamed board those match
nothing, so `tasksCompleted`, `tasksInProgress` and `tasksInReview` come
back **zero for every workflow** while the board is busy. Nothing
errors.

Same shape and same fix as #2864: resolve per **project** via
`resolveProjectColumnsForRoles`, bind an `IN` list, and thread the store
from the single Command Center caller so the parameter has a supplier
immediately rather than becoming an inert seam.

## What the test caught that I had not

**The renamed case still failed with the query fixed.**

The bucketing at lines 296–297 already uses `isWipColumnRole` /
`isReviewColumnRole` — correctly converted — but those read
`query.columnFlagsByName`, which production supplies and my fixture did
not. So:

- the **SQL** decides *which rows come back*;
- the **trait map** decides *which bucket each row lands in*.

Both halves have to be right. Fixing only the query would have shipped a
"conversion" that still reported zero on a renamed board, and the file
would have scored as converted twice over. That is exactly the
partial-conversion shape this program keeps re-finding — caught here
only because the test asserts `tasksInReview` alongside
`tasksCompleted`, since those two paths take **different** resolved sets
(complete vs wip+human-review). Asserting the completed count alone
would have left the second conversion unproven.

## Measured

Reverted, only the renamed case flips:

```
✓ default vocabulary: completed and in-review work are counted
× renamed vocabulary: completed and in-review work are counted
✓ renamed vocabulary: a card in the HOLD lane counts as neither
✓ without a lane store, the legacy ids still answer
  Tests  1 failed | 3 passed (4)
```

The hold-lane negative is there so resolving real lanes cannot degrade
into "every column counts" — trading an undercount for an overcount is
harder to notice than the original bug.

## Scope

The sync SQLite arm in the same file keeps its literals: it throws in
backend mode and has no production caller, the same dead-arm conclusion
reached for `cleanupStaleMergeQueueRowsImpl` on #2839.

## Verification

`pnpm test:gate` green · both Command Center analytics suites 8/8 ·
`tsc` core 0, dashboard 0 · lint 0 · changeset included.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:40:48 -07:00
gsxdsm
c143327d4b fix(core): the archived-document guards failed in OPPOSITE directions on a renamed lane (#2886)
Two of the four convertible sites my own learnings doc **miscounted as
sentinels** — the #2877 review corrected "8 of 9 must not be converted"
to "5 of 9", and these are two of the three that correction freed.

They read `task.column` straight off a row `select`, so they are board
lanes by exactly the test that document gives, and a renamed archived
column is simply not seen. What makes the pair worth fixing together is
that they fail in **opposite directions**:

| guard | on a renamed archived lane | consequence |
|---|---|---|
| `upsertTaskDocument` | fails to **reject** | an archived card's
documents stay **writable** — the read-only contract silently does not
hold |
| `publishArchivedTaskDocumentAddition` | fails to **accept** | a
legitimate archived-document publication is refused as
`parent-not-archived` |

The second is the sharper one: valid operator work refused, and refused
with a message that reads as a data-integrity error rather than a
lifecycle mismatch.

## Shape

Both take an `AsyncDataLayer` and can resolve nothing themselves; their
store-level impls hold the store, so the lane set arrives as a parameter
resolved once per call — the shape #2875 used for the SQL predicate.

**One shared `resolveArchivedLanes` for both paths**, deliberately: if
the write guard and the publication guard could disagree about whether a
card is archived, a card ends up both read-only *and* un-publishable.

## The revert proof caught my own fixture first

My first version set `deletedAt` alongside the renamed column, and **the
revert proof passed with the fix removed**. Both guards are
`column-is-archived || deletedAt != null`, so a soft-deleted fixture
short-circuits the exact comparison under test — the assertion was
holding for an unrelated reason.

Dropping `deletedAt` isolates it, and is also the *real* shape: a live
row in a workflow-declared archived lane is what a renamed board
produces, and what `getLiveTaskColumn` was written to catch.

Revert proof, measured honestly the second time: restore `task.column
=== "archived"` and the renamed-lane case fails — the upsert resolves
instead of rejecting.

## Real PostgreSQL, deliberately

These are row predicates inside a transaction. A mocked store would
assert the arguments and prove nothing about the comparison that runs —
the same reasoning as #2875.

Three cases: the renamed lane rejects, the **legacy** `archived` id
still rejects (most boards never rename anything), and a live card is
still allowed through (a guard that rejects everything is its own bug).

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/core`) — clean
- new `archived-document-lanes.pg.test.ts` + existing
`artifacts-documents-evals.pg.test.ts` — 12 passed against real
PostgreSQL

Note: the SQL-literal baseline is untouched here — #2881 owns
re-recording it after #2864's conversion left main's gate red.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:40:30 -07:00