41af5e5dbd6f778498e3eeab30ebcd101cd120ef
12741 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
eb9cd431c1 |
fix(glasses): settings silently discarded the operator's own lane names, so notifications never fired (#2973)
## The operator configures their lanes, and the plugin throws it away
`TaskColumn` was the closed legacy union and `COLUMN_SET` gated every
settings read against it:
```ts
.filter((value): value is TaskColumn => COLUMN_SET.has(value as TaskColumn));
```
**`notifyOnColumns` is a free-text string array in the schema** (`type:
"array", itemType: "string"`) — the UI invites any lane name. So an
operator on a renamed board types `checking`:
1. `getNotifyColumns` filters it out — not in the legacy five
2. `columns.length === 0`, so it substitutes `DEFAULT_NOTIFY_COLUMNS` =
`["in-review"]`
3. their board has no `in-review`
4. `notifier.ts` builds `new Set(getNotifyColumns(...))`, and
`diffSnapshots` tests `notifyOnColumns.has(task.column)`
**No notification ever fires.** The feature is silently off while the
setting reads as configured, and nothing surfaces an error.
`quickCaptureDefaultColumn` had the same shape with an extra irony: it
was rewritten to `todo` *before* `normalizeCaptureColumn` saw it — and
that function already validates against the board's **declared** columns
and falls back to the workflow's own intake lane. The pre-filter
destroyed the operator's answer immediately before the code that could
have honoured it.
## Fix
Validation is now **structural** (non-empty string) rather than
**vocabulary-based**. `TaskColumn` mirrors core's `ColumnId`
(`LegacyTaskColumn | (string & {})`), so legacy ids keep autocomplete
while custom ids are admitted. `COLUMN_SET` survives only as the
quick-capture dropdown's suggestions, not as a gate.
**Deliberately not resolving the board's columns.** That needs a
board-columns endpoint `FusionApiClient` does not have — a new read, not
a rename — and that gap is already recorded in this file by an earlier
audit. This change is orthogonal to it: accepting operator input
requires no resolution at all, which is why it doesn't wait on the
endpoint.
**Trade-off, taken knowingly:** a typo'd lane is now honoured and will
match no card. That is the milder failure. Before, a *correct* custom
lane was discarded categorically, so renamed boards could not use the
feature at all; now the only broken case is one the operator typed wrong
and can see in their own settings.
## Two existing assertions asserted the defect
```ts
expect(getNotifyColumns({ notifyOnColumns: ["nope"] })).toEqual(["in-review"]);
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("todo");
```
I checked provenance before touching them: they date to the plugin's
original commits (FN-3738 / FN-3970), when the five ids genuinely were
the whole vocabulary. They carry no reasoning comment and no later
change defended custom-lane rejection as a contract — so this is a stale
assumption being corrected, not a peer's tested decision being
overwritten. Structural rejection (non-strings, blanks, whitespace-only)
and trimming are still asserted, because that part was always right.
## Revert proof
```
AssertionError: expected [ 'in-review' ] to deeply equal [ 'checking', 'shipped' ]
AssertionError: expected [ 'todo' ] to deeply equal [ 'todo', 'spaced' ]
Tests 2 failed | 5 passed (7)
```
## Verification (measured)
- plugin suite — **196 passed / 19 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-fnxc-future-dates` — green
No changeset: the plugin is `private: true` and is not bundled into the
published CLI.
|
||
|
|
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.
|
||
|
|
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> |
||
|
|
cadf011b25 |
fix(dependency-graph): an allowlist of four legacy lanes rendered a blank graph on a renamed board (#2972)
## A renamed board gets a blank dependency graph
`filterGraphTasks` gated on an allowlist of four legacy lane ids:
```ts
export const INCLUDED_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
export function filterGraphTasks(tasks: Task[]): Task[] {
return tasks.filter((task) => INCLUDED_COLUMNS.has(task.column));
}
```
On a board whose lanes are named anything else — `backlog`, `building`,
`checking` — **no card matches and the graph renders completely empty**.
This is not a mislabelled node or a missing edge: the entire feature is
blank, and it reads as *"this project has no dependencies"* rather than
as a bug. `triage` is in that allowlist too, a lane U11 (#2515) deleted.
## Fix: gate on the finished lanes instead
Inverted to a denylist, so the **default is the safe one**. An
unrecognised lane is active work by assumption and renders; only lanes
that genuinely mean "finished" drop out.
An allowlist fails **closed** — hide everything unknown. A denylist
fails **open** — show it. For a graph, an extra node is a far smaller
error than no graph.
## The residual, named rather than hidden
`EXCLUDED_COLUMNS` is still two literals. `DependencyGraph.tsx` is a
client React component handed plain `Task` rows as a prop, with no async
seam to resolve a workflow IR — so a board that renames its DONE lane
still shows finished cards here. That is deliberately the mild failure
direction: "one extra node", not "no graph". It is documented in the
code rather than papered over with an optional resolved-lanes parameter
no caller could fill.
The invalid-column guard is now **explicit**. Under the allowlist,
`column: undefined` was excluded as a side effect of not being in the
set; under a denylist it would sail through, so it is checked directly
and covered.
## Revert proof
Restoring only `filters.ts`:
```
AssertionError: expected [] to deeply equal [ 'FN-1', 'FN-2', 'FN-3' ]
AssertionError: expected [] to deeply equal [ 'FN-1' ]
Tests 2 failed | 13 passed (15)
```
Worth noting explicitly: **every pre-existing case passes either way.**
They only ever enumerate the six legacy ids, so the allowlist and the
denylist agree on all of them — no existing test could have seen this
blackout. (The new empty-string case also passes both ways; it guards
the new implementation rather than proving the fix, and I am not
claiming it as coverage of the defect.)
## Verification (measured)
- plugin suite — **183 passed / 20 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-fnxc-future-dates` — green
## Checked and deliberately not changed
`GraphTaskNode.tsx` holds `column === "in-progress"` and `column ===
"in-review"`. Both are already audited with an in-code note, and both
degrade mildly rather than blanking anything — `hasExecutionSignal` also
ORs on `ACTIVE_STATUSES`, so a renamed WIP lane still reads as active
via status. Converting them needs column traits this component is not
given, so they stay noted rather than half-converted.
No changeset: `fusion-plugin-dependency-graph` is `private: true`.
|
||
|
|
5d01164994 |
test(glasses): re-green 15 agent-action tests, and revive a guard that asserted nothing (#2969)
## 15 tests have been red on `main`
`startWork`, `requestReview` and `approvePlan` gained a third `moveTask`
argument, `{ moveSource: "user" }`. The assertions in
`agent-actions.test.ts` kept the two-argument form:
```
AssertionError: expected "vi.fn()" to be called with arguments: [ 'FN-1', 'building' ]
Received: [ "FN-1", "building", + { "moveSource": "user" } ]
Tests 15 failed | 41 passed (56)
```
Reproduces on clean `origin/main`. This suite is outside the merge gate,
which is why it went red unnoticed.
## The part that is worse than staleness
Three of these are **negative** assertions, and they did not fail — they
went **dead**. `expect(fn).not.toHaveBeenCalledWith(id, column)` cannot
match a three-argument call, so it passes whether or not the forbidden
move happened. `requestReview`'s "never lands on the legacy `in-review`"
guard has been asserting nothing since the option landed.
Verified rather than reasoned about — a scratch case, run and then
deleted:
```ts
const fn = vi.fn();
fn("FN-1", "in-review", { moveSource: "user" }); // the forbidden move HAPPENED
expect(fn).not.toHaveBeenCalledWith("FN-1", "in-review"); // old form: passes anyway ✓
```
Both cases passed, confirming the old form is vacuous and the
three-argument form throws.
## Applied per action, not uniformly
Only **3 of 5** product `moveTask` calls take the option, and the split
is deliberate:
| action | source | why |
| --- | --- | --- |
| `startWork`, `requestReview`, `approvePlan` | `{ moveSource: "user" }`
| the wearer's tap is a human gesture, matching the dashboard move route
|
| `returnToAgent`, `retryTask` | default (engine) | per the Move-Task
contract a user-source move parks the row `userPaused`, defeating the
return/retry intent |
So `returnToAgent`/`retryTask` assertions are **correct as
two-argument** and are left untouched — including their negatives, which
genuinely assert because the real call is also two-argument.
Attribution was done by scoping each assertion to its enclosing `it(`
block, not to the nearest preceding call: the latter mis-attributes the
`await expect(startWork(...)).resolves` form, which reads as `expect`.
Blocks mixing a user-source and a default-source action were excluded
from rewriting (there were none).
I did **not** "fix" the 3/5 split. It is documented in the product with
its reasoning and it matches the Move-Task contract; changing it would
be a behavior change riding in a test-only commit.
## Verification (measured)
- `agent-actions.test.ts` — **56/56 passed** (was 15 failed / 41 passed)
- full plugin suite — **192 passed / 19 files** (was 180 passed, 15
failed)
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green
Tests only; no product file is touched. An FNXC note now records the
arity contract and which actions take which source, so the next
assertion added here doesn't reintroduce a dead guard.
|
||
|
|
19deb42170 |
gate: ratchet call sites that never receive the lane answer (#2966)
**This is the gap that let three defects reach `main` in one day.** `unwired-lane-parameter.mjs` catches a parameter that reaches **no** caller. It is deliberately satisfied by a mention *anywhere*, so **partial** wiring is invisible to it: | | | | --- | --- | | #2956 | `getInReviewStallReason` wired at **0 of 4** call sites while both siblings were wired | | #2963 | both merge entry points unwired — merging was **impossible** on a renamed board | | #2964 | merge-confirmed finalization unwired — **already-landed work parked `failed`** | Every one was a fix that added an optional parameter without the call-site sweep that has to follow it. The existing guard was green throughout, correctly by its own contract. ## A census, not a guard — and that distinction is the whole design Auditing the sites this finds showed **four of seven were legitimately unwired**: `skipColumnIdentityCheck` callers have already proven lane identity by a stronger means, a sentinel-column caller wants the identity check satisfied by construction, and a dead export has no caller to wire at all. A check that failed on those is ~57% false positives. The sibling guard's own header says why that is worse than a miss — *"it teaches people to disable the check"* — and I agree, so this does not do it. Instead it ratchets like the lifecycle census: **36 known unwired sites across 20 files**, allowed to shrink and not to grow. A new unwired caller raises the count and fails; wiring one lowers it and re-records. The recurrence — adding a caller that forgets the lane answer — is precisely what gets caught, and the legitimate sites cost one baseline line each instead of a permanently red gate. ## Detection is AST-based, deliberately It finds exported functions accepting a lane-named argument — directly *or* as an options-bag member — then finds call sites passing none of them. Not regex: the ad-hoc scan I used during the audit produced false negatives on multi-line calls, which is exactly how a caller gets missed in the first place. Using a heuristic to police a defect caused by a heuristic seemed like a poor trade. ## Verified to fail on the recurrence A ratchet that cannot fail is worse than none, so this was measured rather than assumed. Injecting one new unwired caller into `self-healing.ts`: ``` [check-lane-wiring] call sites not passing a resolved lane argument INCREASED: packages/engine/src/self-healing.ts: 9 unwired now, baseline allows 8 ``` exit 1, naming the file and the delta. ## Placement Runs as a named `check:lane-wiring` step in `pr-checks.yml` beside the lifecycle, SQL, inert-seam and FNXC ratchets — same convention, same failure ergonomics, ~1s. Note the baseline records today's state, which still includes the #2963/#2964 sites because those fixes have not merged yet. When they land the count drops and the baseline is re-recorded downward — the ratchet working as intended rather than a conflict. ## Verification `pnpm test:gate` 161 + 487 + 13 + 71; `tsc` engine clean; lint, lifecycle census `--strict`, FNXC gate, and the new check all clean. |
||
|
|
1c19540f50 |
fix(glasses): the summary card reported five hardcoded lanes, so a renamed board read as empty (#2968)
## The defect
`boardSummaryCardFromCounts` built its text from five hardcoded lane
ids:
```ts
`Triage ${counts.triage} Todo ${counts.todo} Doing ${counts["in-progress"]} Review ${counts["in-review"]} Done ${counts.done}`
```
`boardSummary` seeds exactly those five keys to `0`, then counts by real
`task.column`. On a board whose lanes are named anything else, **every
interpolated value is the seeded zero** — so the summary card, which is
the first card in every deck and the entire body of `GET
/board/summary`, reads:
```
Triage 0 Todo 0 Doing 0 Review 0 Done 0
```
…while the real work sits in lanes it never mentions. The wearer is told
the board is empty.
It is also wrong on the **default** board today: U11 (#2515) deleted the
`triage` lane, so `Triage 0` is permanently dead text — 9 of the 24
characters this display gets per line.
## Why there was no test
The only summary coverage in `cards.test.ts` exercised
`boardSummaryCard`, an **export no production file calls** (its sole
caller is that test). The function the deck actually ships had none.
That gap is why five hardcoded ids survived here.
## The fix, and one deliberate choice
The lanes are derived from `counts` instead of taken as an optional
resolved-lanes parameter.
That is on purpose. This program's recurring defect is precisely the
"optional lane answer + documented literal fallback" shape shipped
without wiring the caller — `unwired-lane-parameter-guard.test.ts`
documents **five live on `main` at once**, and in four of five the
parameter was unreachable because the caller held a larger defect.
`counts` is already keyed by real `task.column` values, so the
vocabulary is in hand with **no resolution, no new plumbing, and nothing
that can be left unwired**.
Zero-count lanes are dropped so the scarce line budget goes to lanes
with work; legacy ids keep their familiar labels and order, unknown ids
sort after them alphabetically so output is deterministic. `counts`
itself is untouched — the route returns it as the API body and consumers
still see every key.
## Revert proof
Restoring only `cards.ts` fails all three new cases, printing the defect
verbatim:
```
AssertionError: expected 'Triage 0 Todo 0 Doing 0 Review 0 Done…' to contain 'backlog 2'
AssertionError: expected 'Triage 0 Todo 1 Doing 0 Review 0 Done…' not to match /Triage/
AssertionError: expected 'Triage 0 Todo 0 Doing 0 Review 0 Done…' to be 'No active work'
Tests 3 failed | 5 passed (8)
```
## Verification (measured)
- `cards.test.ts` — **8/8 passed**
- full plugin suite — **180 passed**, 19 files
- `eslint`, `tsc --noEmit` — clean
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green
No changeset: `@fusion-plugin-examples/even-realities-glasses` is
`private: true` and is not bundled into the published CLI.
## Pre-existing failure, NOT from this change
The plugin suite also has **15 failures in
`src/__tests__/agent-actions.test.ts`**. They reproduce identically on
clean `origin/main` with this branch stashed (`15 failed | 41 passed`),
so they are not caused by this PR and are not fixed by it. Flagging
separately — this suite is outside the merge gate, which is why it has
been red unnoticed.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 --> |
||
|
|
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> |
||
|
|
3461ae7a92 |
docs(gate): record why the SQL-literal gate deliberately does not scan .sql (#2957)
Comment-only. No behavior change. ## Why this is worth a commit #2954 fixed the FNXC-date gate's walk: its extension filter listed the file types stamps were **expected** in rather than the ones they **occur** in, so it was blind to `.sql` and `.css`. That is a tempting pattern to generalize, and `check-sql-column-literals.mjs` is the obvious next candidate — a gate about *SQL* column literals that scans only `.tsx?`. Applying the same fix here would be wrong, and quietly so. ## The two gates are not the same kind of tool The FNXC gate is a plain-text regex scanner, so widening its extension list is trivially correct. This one is **AST-based**: `ts.createSourceFile(..., ScriptKind.TSX)` followed by a walk over string and template nodes. A `.sql` file is not TypeScript. Adding the extension would not widen coverage — it would feed DDL to the TS parser and traverse whatever lenient-mode nodes fell out. The gate would then **report coverage it does not have**, which is strictly worse than not looking, because the silence would read as "SQL is clean." ## Measured before deciding 38 tracked `.sql` files contain exactly one lifecycle-looking literal: ``` 0022_ideation.sql:19 CONSTRAINT ideation_sessions_status_check CHECK (status IN ('open','converged','archived')) ``` That is the **ideation-session** status enum — a different domain that happens to reuse the word — not a `tasks.column` comparison, and not something this gate would flag even if it could parse the file. **Zero real offenders.** So the honest scope is recorded as: raw SQL is **unwatched**, and the trigger that would make it worth watching is a data backfill (`UPDATE tasks SET column = ...`) landing in a migration. If that ever happens it needs a separate raw-text matcher against the exported `COMPARISON`, not an entry in the extension filter. ## Verification - `check-sql-column-literals` → exit 0 - `check-fnxc-future-dates` → exit 0, "478 known, none added" (the new stamp is dated today, local) - `scripts/__tests__/check-sql-column-literals.test.mjs` → **26 pass, 0 fail** - `scripts/__tests__/check-inert-flag-seams.test.mjs` → **12 pass, 0 fail** - `eslint` clean ## Why a comment rather than a doc Per AGENTS.md, decisions of this shape belong next to the code they constrain. The failure mode is specifically someone reading the walk, noticing `.sql` is missing, and "fixing" it — so the note has to be at the filter, where that person is looking, not in `docs/solutions/`. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
ed907e93dc |
chore: tighten the FNXC future-date baseline as the clock advances (#2953)
Retires `packages/core/src/task-store/reads.ts`'s allowance of 2. Its FNXC stamps are no longer in the future, so the headroom they held is removed rather than left sitting where a genuine regression could hide inside it. ## This is the drop path working, not a fix The gate watches a **clock-dependent population**: stamps age out of "future" on their own, with no code change. That is why the drop path auto-lowers the baseline and exits 0 instead of failing. The alternative — failing on drops, the way a code-measured ratchet should — would have turned this repo red on a day nobody touched it, and the noise would have trained everyone to re-baseline without looking. **A ratchet may only fail on drops when its measurement depends solely on code.** This one does not, so it tightens silently and a commit like this one records the new floor. ## What I verified - Second consecutive run exits 0 with no further diff — the tightening is idempotent, not an oscillation between two states. - Diff is a single removed line; the entry is deleted rather than set to `0`, so the file re-enters as a genuine addition if a future-dated stamp lands there again. - `467` known future-dated stamps remain across 237 files, unchanged. ## What this does not do It does not reduce the future-dated stamp count — those 467 are still there and still wrong. They came from agents (me included) stamping FNXC comments a day or two ahead. This only reclaims the allowance for one file that has aged out. **The count will keep falling on its own without anyone fixing anything**, so it should not be read as cleanup progress; the gate's value is blocking *new* future stamps, which it still does. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
255741e9ab |
fix(gate): scan every file type that carries an FNXC stamp (#2954)
The walk's extension filter was `/\.(tsx?|m?js|cjs|md)$/` — the file types stamps were **expected** in, rather than the ones they **occur** in. Wherever the convention spread on its own, the gate could not see it. ## How I found it Chasing four stamps dated `2026-10-19` — three months out, so unlike the rest of the population they would not age out on their own. All four were in `packages/core/dist/`, which the gate correctly skips as generated. The *source* they were compiled from is a `.sql` migration, which the gate skips for a different and much worse reason: it was never scanned at all. ## Why `.sql` is the expensive omission A migration's stamp records **when a schema change landed**. That is the case where a wrong date misleads most — it is the file you read to reconstruct the order schema changes happened in. 69 migration files carry stamps; 10 were future-dated and none were visible. `.css` had drifted furthest by volume: **1023 stamps across 123 files**, almost all from the dashboard CSS split. `.html`, `.ya?ml`, `.json`, `.sh` are included too; they add coverage but contribute no baseline entries. ## The 9 new baseline entries are newly VISIBLE, not new 5 `.css` + 4 `.sql`. Every one predates this change and would have been caught had the gate ever looked. Recording them is a **reclassification**, the same distinction the census draws for its DELIBERATE-LITERAL marker — a baseline that grows here is the gate's coverage improving, not the codebase regressing. Reading the rise as a regression would be exactly backwards. ## Verified by mutation, not by reading - A future-dated stamp appended to `ChatView.css` → gate **exit 1**. - A future-dated stamp appended to `0036_chat_session_tags.sql` → gate **exit 1**. - Both reverted → **exit 0**. Without this, both probes pass silently. ## Two notes on the diff - **Zero removals.** My first attempt rewrote the baseline with sorted keys, which turned unmoved lines into add/remove pairs and made it look like entries were being dropped. Rebuilt in walk order so the diff is additions only. - `reads.ts` is deliberately left at `2` here even though it now measures `0`. That drop belongs to #2953; duplicating it across two open PRs is how this queue got tangled before. The gate auto-tightens it at runtime and still exits 0. ## What this does not fix The **478** future-dated stamps still in the tree. They are agent-written (mine included) and most are one or two days out, so the count falls on its own as the clock advances — it should not be read as cleanup progress. This PR only makes the gate able to *see* the SQL and CSS ones, so no new stamp can land there unnoticed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b8e5d42b7a |
chore(gate): move the FNXC date ratchet beside its three siblings (#2952)
The half that #2948 and #2950 did not cover. Both of those fixed today's redness; **#2949** landed the un-redding first, so both are now conflicting and redundant. This is the placement, which is what made today's failure so expensive. ## Why it hurt `check-fnxc-future-dates` was wired into `pretest` **and** `test:gate`, with no `check:*` script and no `pr-checks.yml` step. So a baseline frozen below the tree it froze did not produce "one CI step is red" — it produced: - `pnpm test:gate` → exit 1, merge gate down for everyone - `pnpm test` → refuses to run before a single test executes ## The precedent All three sibling ratchets are dedicated `pr-checks.yml` steps. `lifecycle-column-census` always has been; `check-sql-column-literals` and `check-inert-flag-seams` moved there in #2941. The census's own header states the reason, and it is the one that matters here: > a permanently-red gate is a bigger hole than a stale allowance, because it gets ignored and then nothing is guarded at all ## The change ``` check:fnxc-future-dates script, beside check:inert-flag-seams "FNXC stamp dates" step in pr-checks.yml, after the other three removed from pretest / pretest:full / test:gate ``` **Enforcement where it matters is unchanged** — `pr-checks.yml` is the blocking gate, so a newly added future-dated stamp still cannot merge. What changes is that a baseline mismatch stops halting work unrelated to it. ## Deliberately not touching The drop behaviour. This gate **already** tightens on a drop rather than failing — the #2888 pattern, already correct here. I checked rather than assuming it needed the same fix its siblings did. ## Verification `pnpm check:fnxc-future-dates` exit 0 · `pnpm test:gate` green (now without this check in it) · lint 0 · step confirmed adjacent to the other three ratchets. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Added automated validation for FNXC stamp dates to lint checks. * Updated test and validation scripts to run the date check through a dedicated command. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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 -->
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
1dc839743e |
census: tell the reader where a DELIBERATE-LITERAL marker has to go (#2909)
A `DELIBERATE-LITERAL` marker in the wrong **position** is indistinguishable from no marker, and the miss is silent until CI. **Measured on #2883:** the marker sat inline in the middle of a conditional expression, so it attached to the wrong AST node and three reviewed literals scored as new debt (`self-healing.ts` 86 → 89). The message the tool printed at the time said *"record why at the site with a `DELIBERATE-LITERAL` marker"* — which I had done. Nothing in the output suggested placement was the problem. Two lines added to the failure message: - Markers are read from a node's **leading** comments, so put one on the declaration and hoist the literal into a named helper if needed. - **`pnpm lint` does not run this census** — CI's Lint job does. That is why the usual "lint passed locally, push" loop cannot catch either mistake, and why the tool itself is the only place a reader sees this in time. ## Verified, not assumed I induced a real failure (a temporary `t.column === "in-review"` guard in `self-healing.ts`) and read the printed output rather than trusting that the string lands in the right branch — the message has two branches and only one is the guard-count-rose path: ``` packages/engine/src/self-healing.ts: 89 -> 90 Resolve a lifecycle column from the task's own workflow (…) correct, record why at the site with a DELIBERATE-LITERAL marker. Put the DELIBERATE-LITERAL marker in the DECLARATION's leading comments, not inline in an expression: markers are read from a node's leading comments, so a mid-expression one attaches to the wrong node and is silently ignored. Hoist the literal into a named helper if you need to. Note that `pnpm lint` does NOT run this census — run it explicitly before pushing. ``` Guidance only — no scanner behaviour changes, so no counts move. ## Verification `pnpm test:gate` 161 + 487 + 13 + 71; `pnpm lint` and census `--strict` clean. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |