Commit Graph

12576 Commits

Author SHA1 Message Date
gsxdsm
245086dad6 docs: the census total is a floor — 25 membership predicates it structurally cannot see, one a live defect (#2763)
Docs only, extending the entry #2748 landed. Opening it because the
fleet reads the census total as its completion bar, and that total
excludes a whole predicate class — a measurement that should not live in
a chat reply.

## Measured on `origin/main`

- **47** array/Set literals of two or more lifecycle ids, in 35 files.
- **25 are membership predicates against a task's column** —
`SET.has(task.column)` / `ARRAY.includes(task.column)` — in 19 files.
Two are documented fallbacks behind a resolved primary, so **~23 are
unconverted guards**.
- The census scans `===` / `!==` against a column. **None of these is a
comparison, so none is counted.**

| file | constant |
| --- | --- |
| `cli/src/commands/task.ts` (3) | `retryReviewColumns` |
| `dashboard/app/components/TaskCard.tsx` (2) | `TIME_INDICATOR_COLUMNS`
|
| `engine/src/eval-followups.ts` (2) | `OPEN_COLUMNS` |
| `engine/src/merger.ts` (2) | `sourceTerminal` |
| `engine/src/task-revert.ts` (2) | `REVERTABLE_COLUMNS` |
| `core/src/agent-role-policy.ts` (1) | `IMPLEMENTATION_TASK_COLUMNS` |

## One is a proven live defect

`isImplementationTask` is
`IMPLEMENTATION_TASK_COLUMNS.has(task.column)`, and
`evaluateImplementationTaskBind` short-circuits to `allowed: true` when
it returns false. **On a renamed board every agent is bind-compatible
with every task** — the role check that stops a liaison being handed
implementation work (the NEXT-871 loop FN-7851 fixed) does not apply.

It surfaced only because a reviewer questioned a coverage claim in one
of my dispatch tests (#2739). Passing an agent wasn't proof the
evaluator ran, so I asserted a `custom`-role agent must be *refused* —
and that test failed against production. Flagged at the site in #2739,
not fixed: `isImplementationTask` is a sync pure predicate with no
store, and making the routing policy async is a behaviour change to
agent admission.

## What this does and does not argue

The census is the right instrument — AST-based, honest about what it
measures, and it has caught real drift in both directions (it failed on
me in #2724 when merged conversions moved an inventory *down*). This is
not an argument against it.

It is an argument against reading **"backlog: N" as "N guards remain"**.
The same shape already appeared in the archived gate (#2724), where the
rule is additionally encoded in Drizzle predicates and raw `sql`
templates that no comparison scan can see. Two independent classes now,
found the same way — by looking at what the instrument's definition
excludes.

**Extending the census to count membership predicates is deliberately
left to you, not done here.** It would move every worker's number
mid-fleet, and deciding which sets are lifecycle guards versus
board-config definitions or type unions is exactly the judgement
`DELIBERATE-LITERAL` exists for — 47 collections would each need that
call.

## Verification

`pnpm lint` clean · census `--strict` exits 0 · no code changes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:12:51 -07:00
gsxdsm
86a2b48968 fleet: branch-group-ops 6 → 0 — an agent asking for its next task was told there was none (#2739)
Second application of the sync-filter pattern decided in #2737.
`branch-group-ops.ts` 6 → **0**.

## The failure

`selectNextTaskForAgentImpl` picks an agent's next task by filtering the
board for its WIP lane, then its hold lane — both `task.column ===
"<literal>"`.

On a renamed board **both filters match nothing**, so an agent asking
for work is told there is none, with its own assigned tasks sitting in
the list it just fetched. No error, no log line. The agent idles.

`pauseTaskImpl` had the same shape: pausing a running card on a renamed
board left its `status` untouched, so the UI kept showing it as working.

## Consumer, not a gate — checked rather than assumed

Applying the #2724 test to this file, since it sits closer to the
persistence layer than the reconciler did: its **only** SQL predicate is
`eq(table.projectId, ...)`. Nothing here compares a column to a literal
in SQL, so there is no second encoding of these questions to diverge
from. The list arrives from `store.listTasks` and the filters select
among rows already in hand.

Async predicates were the alternative and would have turned these filter
chains into sequential awaits inside the dispatch path. One prefetch,
one IR read per distinct workflow, filters stay synchronous.

`pauseTaskImpl` resolves for the single task it holds rather than
joining a map — different entry point, one id in scope, and a map would
have exactly one entry.

## Revert proof

| reverted | result |
|---|---|
| the wip literal | renamed WIP case fails: `expected null to be truthy`
|
| the hold literal | renamed hold case fails identically |

The new test calls the impl **directly** with a store fake resolving a
renamed IR. The existing `selectNextTaskForAgent` coverage drives a real
store harness, so exercising a renamed vocabulary there means
registering a real custom workflow and moving cards through it — heavier
than the question, which is only which lane the filters name. The bind
evaluator runs for real; only the store is faked.

A third case pins that the hold filter keeps its `userPaused` exclusion,
so a filter matching every column would not satisfy the other two.

**Related coverage checked before writing a new file:**
`agent-heartbeat-worktree-renamed-hold.test.ts` covers the requeue
**target** on a renamed board, not the dispatcher's **selection**
filters — different branch of the same subsystem, so a case added there
would have read as duplicate coverage of the wrong thing.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **20 passed** across
the routing-policy and new dispatch suites · core `tsc` clean · `pnpm
lint` clean · census `--strict` exits 0.

🤖 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 agent task selection when workflow lane names or IDs have
been renamed.
- Agents now correctly resume assigned in-progress or queued tasks
across customized workflows.
- Prevented agents from selecting tasks paused by users, including on
boards with renamed lanes.
- Updated task pausing behavior to correctly reflect lifecycle stages
beyond default lane names.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:09:36 -07:00
gsxdsm
76d77da4d1 fleet: taskSorting + TaskReviewTab 8 → 0 — and Board was faking a column id to force done-sorting (#2744)
Two app-side clusters, 8 → **0**, plus a caller-side hack retired.

## What was broken

**`TaskReviewTab.tsx`** — three of its four questions were `task.column
=== "in-review"`, driving the **Create-PR button**, the **"frozen on
entry to review"** auto-merge hint, and **PR-feedback addressing**. On a
renamed review lane all three took their non-review branch: the button
was absent, and the hint claimed the effective auto-merge value was
*not* frozen when it was.

**`taskSorting.ts`** — `isReviewColumn` decides whether merging cards
float to the top of a lane. Keyed on the id it silently stopped doing
that on any renamed review lane, so the operator loses the "what is
merging right now" ordering with nothing failing.

Both follow the shape this code already established: **caller supplies
the trait, default to the legacy id**. `columnFlags` on the review tab
is optional and wired from `TaskDetailModal`, which already resolved it
for `canEdit` and the actions menu.

## A synthetic column id, retired

`Board.tsx` forced done-sorting by passing the **literal `"done"`** as
the column argument for any complete-flagged lane:

```ts
grouped[column.id] = isWorkflowDoneLikeColumn
  ? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
  : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, ...);
```

A synthetic id standing in for a trait — so a custom complete lane
sorted correctly only because its caller **lied about its name**. Both
call sites now pass the real column id and state the trait. (Board's own
census count stays at 2: those two literals were the synthetic ids and
are gone; the 2 remaining are different sites.)

## Revert proof

| reverted | failure |
|---|---|
| `task.column === "in-review"` on the Create-PR guard | `Unable to find
an element by: [data-testid="task-review-create-pr"]` |
| same, on the auto-merge hint | `expected 'Effective: Auto-merge off'
to contain 'frozen on entry to review'` |

A third case pins that the widened test does not treat *every* column as
review.

**None of the 45 existing `TaskReviewTab` cases could have caught this**
— `columnFlags` is optional and they all omit it, so they assert the
legacy fallback. That is the same blind spot as the reconciler's 33 in
#2737, and it keeps recurring: an optional-flags seam means the existing
suite stays green through the conversion *and* through a broken one.

## A process failure worth recording

**I lost this conversion once and had to redo it.** I overwrote four
files with their `origin/main` versions to check whether a failing test
was pre-existing, then "restored" with `git checkout HEAD -- <dir>`.
HEAD was still `origin/main` because I had not committed, so that
**discarded the work**.

Same class as the shared-stash incident two PRs back: an implicit or
positional restore reference. The fix is ordering, not care — **commit
before any baseline comparison**, so `git checkout HEAD -- <file>`
restores my work rather than main's. This PR's commit was created before
the comparison for exactly that reason, and the note is in the commit
message so the next person hits it there too.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **232 passed** across
TaskReviewTab / taskSorting / Board suites · dashboard `tsc -p
tsconfig.app.json` clean · `pnpm lint` clean · census `--strict` exits
0.

The 1 `board-mobile` failure is **pre-existing** — verified by swapping
in clean `origin/main` copies of all four files and reproducing it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:06:04 -07:00
gsxdsm
54d1a29621 fix(dashboard): the github-tracking-state classifier seam was never wired — a renamed terminal column still never closed its issue (#2754)
Not a conversion. A **conversion that was never connected to
production**, found by a parity sweep rather than by the census.

## How it surfaced

An AST pass over all **43** github/gitlab-named files, paired by name
with counts compared:

```
github-tracking-comments.ts=9  vs  gitlab-tracking-comments.ts=4   ASYMMETRIC
github-tracking-state.ts=2     vs  gitlab-tracking-state.ts=0      ASYMMETRIC
github-issue-comment.ts=1      vs  gitlab-issue-comment.ts=1
...8 more pairs, all symmetric at 0
```

The first is the pair #2715 fixes. The second pointed here — and the
asymmetry turned out not to be the interesting part.

## The defect

`decideIssueAction` has accepted an injectable `classify` since U12/R2,
and that file's own header states the bug the seam fixed:

> "A user-authored workflow whose terminal column is called something
else never closed its linked GitHub issue, and a custom archive column
never mapped to `not_planned`."

Its **only production caller** passed no classifier:

```ts
const decision = decideIssueAction(event.from, event.to);
```

So every real move fell through to `legacyColumnLifecycleClass`, and
**the documented bug was still live**. The seam was reachable from unit
tests only — which is why all 68 cases in that file were green while the
behaviour they document did not work.

Same shape as this branch's earlier finding on the tracking-comment
guard, where the guard returned *before* resolving. **Adding a seam and
wiring it are two changes; only the second one fixes anything.** Worth
watching for elsewhere in this program: a file can read as fully
converted, pass its suite, and still take the legacy path on every call.

## Ordering, inverted on purpose

`decideIssueAction` ran first, before the tracking-enabled check,
because comparing two strings is free. Resolving a workflow is not — so
the cheap property read now short-circuits and only tracked tasks
resolve, the ordering `github-tracking-comments.ts` and its GitLab twin
already settled on. Untracked tasks returned without acting before and
still do.

The two remaining literals **are** `legacyColumnLifecycleClass`, that
seam's named default, now marked `DELIBERATE-LITERAL` — and marked only
in the same commit as the wiring. While the default was the live path on
every move, exempting it would have hidden the real defect behind a
marker.

## Revert proof — it detects an *unwired* seam, not a missing one

Dropping the resolved classifier while **leaving the seam intact** fails
both new cases with 0 `setIssueState` calls. That is the whole point:
the new cases drive the **service**, not the pure decision function, so
they fail for exactly the reason the 68 existing cases could not. Those
pass either way.

## Two self-inflicted errors, recorded because both are recurrences

- **I hand-edited the baseline with python and wrote a raw NUL byte into
the JSON**, breaking the census parse. The `deliberateByFile` keys use a
real `\0` separator and must be written through `JSON.stringify`, never
string interpolation.
- **I then restored the baseline from a newer `origin/main` than my
branch point**, which made `--strict` report a `scheduler.ts: 12 → 26`
rise that was pure version mixing. Rebase first, then edit. (The real
`scheduler.ts` baseline staleness is already owned by #2712 — I checked
before assuming it was mine.)

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **71 passed** in the
tracking-state suite · dashboard `tsc` clean · `pnpm lint` clean ·
census `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:05:50 -07:00
gsxdsm
28c069ac0c fix(dashboard): TaskDetailModal — one root cause that produced six separate review findings (#2761)
## The pattern

`workflowMoveMetadata` outlives a task switch, so while the modal is
open its flags describe the **previous** task for a render. This
component gates editability, the execution-mode replan decision, the
intake affordance, the actions menu and the review tab on them.

**The guard already existed** at line 961:

```ts
const detailFlagsAreForThisTask = workflowMoveMetadata?.taskId === task.id;
const detailColumnFlags = detailFlagsAreForThisTask ? workflowMoveMetadata?.currentColumnFlags : undefined;
```

Five call sites read **around** it. Each was found separately — #2744
(review tab), #2696 (`handleDelete` deps), and the four converted here:

| line | consumer |
|---|---|
| 1827 | `canEdit` |
| 2147 | execution-mode replan on save |
| 2285 | execution-mode replan on mode change |
| 3156 | `isIntakeColumn` |
| 3782 | actions-menu model |

**Six review rounds for one root cause.** Converting them together
retires the class instead of paying a round per site — the same
arithmetic as the review-lane family, which took #2730 and #2750 to end
rather than eight per-file patches.

Only the definition line still reads the raw value, which is the point
of it.

## Not covered by a new test — stated rather than papered over

is internal state populated by a fetch, not a prop, so the stale-flags
scenario needs the workflow-metadata request mocked **plus** a task
switch mid-render. That is a real test worth writing. It is not a line I
can add honestly in passing, and I have shipped four tests today that
passed with the bug fully in place — I would rather flag the gap than
repeat that.

This file's suite also carries **6 pre-existing failures**, confirmed
identical on clean by stashing and re-running, which would muddy the
signal from a new case.

## What is verified

- the narrowing is mechanical and **total** — one remaining raw read,
the definition;
- pre-existing failure count **unchanged at 6/45** with and without this
change;
- 106 passed + 5 skipped across both `TaskDetailModal` suites;
- dashboard `tsc` clean (`tsconfig.app.json`), lint clean (0 errors),
gate green (487 + 158 + 10 + 71).

No census movement — this changes which value is read, not whether a
column id is compared.
2026-07-30 07:59:25 -07:00
gsxdsm
58f1e41aa0 test(dashboard): keep the renamed-board tracking suite — its source changes landed as #2715 and #2737 (#2714)
**Claim announced on #2706 before starting.**
`github-tracking-comments.ts` + `github-tracking-reconciler.ts` — one
coherent subsystem, 9 guards each.

**18 → 7 by census, 18 → 0 behaviourally.** The gap is explained at the
bottom and it is not hand-waving.

## Both halves failed quietly, in the way that suppresses its own
evidence

| surface | what a renamed board got |
|---|---|
| the comment poster | returned early for **every** move — the tracked
issue silently stopped receiving both its "in progress" and its "done"
comment. The operator sees a linked GitHub issue that never updates. |
| the reconciler (3 scan passes) | matched **zero** tasks, so completed
work's issues were never closed — and the pass reported a clean
`scanned: 0`. |

That second one is the shape worth internalising: **the number that
would have revealed the problem is the number the bug suppresses.** No
error, no warning, a green sweep.

## The comment poster needed a derivation, not a swap

`event.to` was **both** compared against the two literals **and** passed
into `formatTrackingComment` as its `transition` argument (typed
`"in-progress" | "done"`). One value carrying two meanings: a lane id
and a comment kind.

Eight independent swaps would have had to keep agreeing with each other
forever — and a ninth site (the template's own `transition === "done"`)
is *not* a column at all, so a mechanical sweep would have converted it
wrongly. Resolving the lanes once and deriving the kind separates the
two meanings permanently. Log details still print the real column, so
the operator reads their own board's name.

## The reconciler

Per task through **one shared IR cache per scan**, resolved into a `Set`
of terminal ids rather than an async predicate inside `.filter(...)` —
`Array.filter` ignores promises, so an async predicate there silently
keeps **every** row. That is a trap worth naming for other fleet workers
converting list filters.

The archived-vs-complete distinction keeps its own resolver rather than
reusing the terminal pair: it decides GitHub's `state_reason`, and
closing a finished issue as `not_planned` is operator-visible and wrong
— as is the reverse.

## Revert proof

**5 of 8 new cases redden.** 201/201 across the nine `github-tracking`
suites (193 were already there and still pass).

## Why the census says 7 and not 0

The reconciler goes **9 → 0**. The comment poster still reports **7**,
and every one of those is `transition === "in-progress" | "done"` — the
derived comment **kind**, not a column. There is no lane comparison left
in the file.

That is exactly the vocabulary-collision class **#2692** is fixing (it
already lists five misclassified receivers: an SSE event type, a
cache-key mode, an evidence kind, a telemetry event kind, an agent
state). **`transition` is a sixth and I have reported it there.** Until
that lands the census counts them, so I am reporting both numbers rather
than the flattering one.

I deliberately did **not** mark them `DELIBERATE-LITERAL` to move the
count: that marker means "a lifecycle literal reviewed and kept", and
these are not lifecycle literals at all. Using it as a census-silencer
would put a wrong reason in the code to make a number look better.

## Verification

`pnpm test:gate` **487 / 71** · **201/201** github-tracking suites ·
`tsc -p packages/dashboard` clean · `pnpm lint` clean · census
`--strict` exit 0 (it also tightened three entries other workers' merges
left stale — the #2679 auto-tighten working).

No changeset: `@fusion/dashboard` is private and this is internal
behaviour on renamed boards.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:53:45 -07:00
gsxdsm
86639f2ce4 fleet: planning drain + archive writers 12 → 4 — one stale row starves planning, and a finaliser that wrote an undeclared column (#2742)
**Claimed on #2733 before starting.** `in-process-runtime.ts` +
`task-artifacts-ops.ts` — **12 → 4**.

## 1. The planning drain: one stale row stops planning for the whole
project

FN-8470's own note on this code says it: **one orphan earlier in
created_at FIFO prevented every later planning continuation from
dispatching.** So on a renamed board the literal terminal pair did not
mis-handle one card — an archived or completed card's stale work item
read as live, stayed in the due set, and **starved the drain behind
it**.

The two classifiers take an **optional** terminal set, which is this
file's own injection idiom (the specification-complete reaction already
takes a `resolveIr` dependency so the pure passes are testable without
constructing a runtime that would attach to the real project registry).

**Optional is load-bearing:** a *required* parameter would have compiled
at every existing caller and then answered "not terminal" for
everything. That is the silent direction, and both halves are asserted
in the test.

## 2. `moveToDoneImpl` writes `task.column` directly

This is the store's own finaliser, not a `moveTask` caller — so its
literal is **not** caught by `moveTask`'s unknown-column validation the
way every converted call site in this program is. It silently persisted
`done` on a board that does not declare it, and then emitted `to:
"done"` to every listener.

**This is one of the few sites where a literal writes bad state rather
than merely failing to act.** A workflow declaring no complete lane now
throws instead of inventing one — #2733's rule: a missing field on a
resolved struct *is* an answer, and `?? legacy` discards it.

## 3. The unarchive destination — three decisions in four lines, all
literal

| pre-archive column | lands in |
|---|---|
| unusable / archived | the **complete** lane |
| the **wip** or **review** lane | the **hold** lane (its worktree and
session are long gone) |
| anything else | back where it was |

The second is the expensive one: a card archived *from* the wip lane was
restored straight back *into* it **with no worktree**, and the scheduler
then counts it as a live holder **occupying a slot**. Made async — its
one production caller already is, and the sync alternative is the
PostgreSQL no-op documented in #2703.

## Also

- **The mission-error requeue** (guard *and* destination in one change):
an errored mission task stayed in the wip lane holding a slot, because
the guard never matched.
- **The planner-chat retention cutoff on archive** — the quiet direction
of this defect class: nothing breaks, data that should be deleted simply
accumulates, and the only symptom is storage growth nobody attributes to
a column name.

## The live defect is not where the census points

`reliability-metrics.ts`'s 6 guards are **pure historical readers** over
activity-log entries, and **the dashboard does not call them**. The live
path is `server.ts`'s `getTaskMovedCountsByDay({ toColumn: "in-review"
})` — a **SQL query filter**, the class the census counts separately.

So the operator's reliability panel reads zero on a renamed board
because of a *query* literal, and converting the six guards the census
reports **would change nothing an operator sees**. Converting historical
readers also risks reinterpreting past events under today's traits,
which is a different decision from converting a live guard — I am not
making it inside a vocabulary sweep.

Worth generalising for the fleet: **a file's census count and its live
exposure are different numbers.** This is the second file where the
reported guards are the inert copy and the real one is a query
(`executor.ts:5805` was the first).

## Verification

`pnpm test:gate` **10 / 158 / 487 / 71** · 31/31 continuation suites ·
8/8 archive PG suites · in-process-runtime PG suite green · 5 new cases,
**2 red on revert** · `tsc` clean in core and engine · `pnpm lint`
clean.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:53:28 -07:00
gsxdsm
c53d3aec38 fix(executor): re-land the no-wip-lane fix — #2757 merged a snapshot that predated it (#2760)
## Why this exists

#2757 merged as `9a2a033b9e`, but **the third of its three fixes is not
on main**:

```
$ git show origin/main:packages/engine/src/executor.ts | grep -c wipDeclared
0
```

The merge captured my branch *before* commit `68381db72f`, so the
no-wip-lane fix was dropped while the other two landed.
`executor-execution-policy-renamed-columns` → *"a workflow with no wip
column terminalizes visibly instead of claiming the card advanced"* is
still red on main, still returning `status: null, error: null`.

This is that commit, cherry-picked cleanly onto current main. No new
work — the review discussion is in #2757.

## What it fixes (recap)

`resolveResumeLanes` defaulted `wip: lifecycle?.wip ?? "in-progress"`,
collapsing two different states:

1. **the IR failed to resolve** — defaulting is right; the `catch` arm
wants exactly this
2. **the IR resolved and declares NO wip column** — defaulting *invents
a lane the workflow does not have*

`routeGraphFailureToExecutionResume` then admitted a card resting in
that workflow's **hold** lane with incomplete steps, rehomed it,
returned `true` — and the terminalize branch never ran. Resuming into a
workflow with no implementation lane *is* "claiming the card advanced"
when nothing did.

The fix adds `wipDeclared` (declared, as opposed to defaulted) and
declines the resume when it is false — the same fail-closed rule the
sibling branch already applies with `wipColumn !== undefined` before
calling a card "already advanced". That path failed closed; this one
failed open.

IR-unavailable deliberately keeps today's behaviour (`catch` reports
`wipDeclared: true`), so an infrastructure error does not start refusing
legitimate resumes.

## Verification on current main

| check | result |
|---|---|
| the three affected files | **23 passed** |
| `pnpm test:gate` | **726** |
| engine `tsc --noEmit`, `pnpm lint` | clean |
| remove the guard | 1 failed — the fail-closed case goes red again |
| always decline | 1 failed — a legitimate resume breaks |

Full-suite blast radius was measured on #2757 before it merged: 834
files / 10,845 tests, 5 failures, both files pre-existing
(`executor-prompt`'s pause-guard 3 and `executor-abort-provenance`'s 2,
byte-identical to baseline). Zero new failures.

## Note

Worth checking whether other PRs merged in that window lost their final
commits the same way — I only noticed because I re-verified main after
the merge rather than assuming a merged PR contains what the branch
held.
2026-07-30 07:44:35 -07:00
gsxdsm
b0b9d1b373 fleet: store.ts 12 → 11 + names the sync-dependency-loop class blocking ~10 sites across 3 clusters (#2709)
Claiming **`packages/core/src/store.ts`** (12). One conversion and a
triage — because **10 of the 12 share a single blocking shape** that is
worth naming once rather than rediscovering per file.

## Census before/after

| | before | after |
|---|---:|---:|
| `store.ts` column guards | **12** | **11** |

Baseline re-recorded; `--strict` exits 0.

## Converted: 1

**1386** — the in-review guard inside `withTaskLock(id, async () => …)`.
Already async, and `this` **is** the store, so
`resolveTaskLifecycleColumns(this, task.id)` resolves the review role
with `in-review` as the fallback. Import added; nothing else in the
method changes.

## The blocking class — 6 sites, and it is not specific to this file

**1772, 1791 ×2, 1874 ×2, 1916, 1917, 1933** all read **another task's**
column — a dependency's, a blocker's, an overlap candidate's — inside
**synchronous callbacks over a prefetched `taskById` map**:

```ts
const unresolvedDeps = (task.dependencies ?? []).filter((depId) => {
  const dep = taskById.get(depId);
  return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "archived";
});
```

This is not a substitution. Each dependency may belong to a **different
workflow**, so the role must be resolved *per dep* — N async resolutions
inside a sync `filter`, on a path that deliberately prefetches into a
map precisely to avoid per-item I/O.

Two honest options:

1. **Prefetch lifecycle columns alongside `taskById`** and pass a
resolved map into these predicates. Keeps them synchronous, one
resolution per distinct workflow rather than per dep. This is the one
I'd argue for.
2. Accept per-dep resolution and make the callbacks async — changes the
shape of dependency evaluation.

Both are design changes with real cost, so this is flagged rather than
guessed.

**The same shape appears in at least two other clusters I've worked**:
`TaskDetailModal`'s `overlapBlockerTask.column` (#2696) and
`register-task-workflow-routes`' dependency-summary pair (#2700), both
flagged for this exact reason. **Worth one decision covering all three**
rather than three separate judgement calls by three workers.

## Also flagged: 3

**1610** and **1739** — enclosing-scope async-ness and store access not
established at those points, so not guessed. **1933** belongs to the
sync-filter family above.

## Verification

`pnpm test:gate` **GREEN** (158 + 487 + 10 + 71) · `pnpm lint` clean ·
core `tsc` clean · `--strict` exits 0.

🤖 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**
* Updated failed pre-merge review bypass validation to support custom
workflow boards.
* Tasks can now bypass the step when placed in the board’s configured
review lane.
* Improved error messages to identify the correct review column when
bypassing is not allowed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:36:00 -07:00
gsxdsm
ceca08b1c3 fleet: github-tracking-reconciler 9 → 0 — deciding the sync-filter class (prefetch a resolved map), and the reconciler closed NO issues on a renamed board (#2737)
`github-tracking-reconciler.ts` 9 → **0**, and the reference
implementation for the `.filter((task) => task.column === "<id>")` shape
I have been flagging across four files.

## I stopped waiting and decided it

I flagged this class in #2709, #2696, #2700 and #2715 as "needs one
decision" and left ~25 sites unconverted. That decision was mine to make
and I should have made it three PRs ago.

**Prefetch a resolved map, then filter synchronously.** The alternative
— async predicates — forces every caller into `for await` and turns a
list comprehension into a sequential walk. Prefetching keeps the filters
synchronous, puts the awaits in one bounded place, and lets the IR cache
do the job it was explicitly built for:

> "A self-healing pass over 400 cards spanning three workflows must read
three IRs, not 400."

The cache is **instance-scoped and shared across all four passes**, so
each distinct workflow's IR is read once for the whole run rather than
once per pass. `resolveLifecycleColumns` is pure and *not* memoized by
that cache, so this still costs one cheap struct build per task — fine
in a background reconcile, and stated rather than hidden.

No new abstraction: `resolveTaskLifecycleColumns` already takes a
caller-owned cache. The only new code is a local map builder and two
named predicates.

## What it cost before

On a board with renamed terminal lanes, **every filter here matched
nothing**. The reconciler closed **no** GitHub issues and reported
`scanned: 0` — a clean-looking pass that did nothing.

## Why this is not the split brain #2724 documents — checked, not
assumed

#2724 proves the archived gate in `packages/core` is enforced in three
encodings, so converting one alone diverges them. I checked whether that
applies here before converting:

- This file contains **zero SQL** — measured: no drizzle, no `sql`
template, no `eq`/`ne`.
- It calls `listTasks({ includeArchived: true })`, so the SQL half has
already been told to include archived rows. The filter **selects among
rows it was handed** rather than deciding liveness a second time.

**Gate versus consumer** is the distinction, and a consumer can be
converted alone.

The fourth pass needed its own check because its list comes from
`listTasksForGithubTrackingReconcile`, which *is* SQL — but that impl
filters on `deletedAt IS NOT NULL` and `githubTracking IS NOT NULL`,
**never on the column**, so there is no SQL-side encoding of this
question to diverge from.

## Why the 33 existing tests stayed green through the conversion

Their fake store has **no workflow reader**, so
`resolveTaskLifecycleColumns` catches and returns `undefined` and every
case asserts the legacy fallback — exactly what it always asserted.
**None of them could have caught this being wrong.** `workflowIr` is now
an opt-in on that fake, which is what makes the new cases real tests
rather than restatements.

| reverted | result |
|---|---|
| terminal filter back to the ids | "closes issues on a RENAMED complete
lane" fails, no `setIssueState` |
| same | renamed archived-heuristic case fails, no `setIssueState` |

## A reachability finding, recorded not acted on

In backend mode `reconcileDeletedAndArchived` returns only
**soft-deleted** rows — its own comment says the archived-tasks fallback
is a separate `AsyncArchiveLineage` subsystem, skipped there — and
`task.deletedAt` is tested *first* in the `stateReason` chain. So its
archived arm is **effectively unreachable today**. I converted it rather
than deleting it: it is the documented FN-5577 done-heuristic, and
whether that fallback should be wired here is a separate question from
what vocabulary it speaks.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **35 passed** across
the three reconciler suites · dashboard `tsc` clean · `pnpm lint` clean
· census `--strict` exits 0.

Remaining files in this class (`branch-group-ops.ts`, `store.ts`, and
the dependency pairs) can now follow this pattern instead of waiting —
with the gate-versus-consumer check applied to each, since
`branch-group-ops.ts` sits closer to the persistence layer than this one
does.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:14:54 -07:00
gsxdsm
1322a1bb11 docs(solutions): the optional-flags seam kept four green suites blind to their own conversion — and why I did not ship a ratchet for it (#2748)
Docs only. No code, no census movement.

## The finding, measured

Four consecutive files in this program had **fully green suites at
conversion time** that could not have detected the conversion — correct
or broken:

| file | pre-existing cases blind to the change |
| --- | --- |
| `github-tracking-reconciler.ts` | **33** (fake store had no workflow
reader) |
| `TaskReviewTab.tsx` | **45** (`columnFlags` omitted everywhere) |
| `plan-approval-hold-invariant` drain | **25** (`opts.lifecycle`
omitted everywhere) |
| `task-age-staleness.ts` | **12** (`context.lifecycle` omitted
everywhere) |

The cause is structural. Every conversion here uses the same seam — the
caller passes resolved flags, the helper falls back to the legacy id
when they are absent — and every pre-existing test omits the flags. So
the suite passes **before** the conversion, **after a correct one**, and
**after a wrong one**, as long as the fallback is intact. "The suite is
green" carries no information about the change.

I reported this observation four times in PR bodies. Restating it a
fifth time is worth less than writing it where the next worker will
actually find it.

## It also corrects the obvious test

The natural property is "hold the traits fixed, change the id, behaviour
is identical". That is only half the invariant. It does not catch:

```ts
// Not a fallback — an OVERRIDE. The id wins even when traits disagree.
return column === "in-review" || flags?.mergeBlocker === true;
```

Renaming `in-review` → `checking` leaves that correct, because the trait
arm answers. The defect appears in the **converse** direction — a column
that still *carries* a lifecycle name while its traits say otherwise,
which is what you get by repurposing a default column rather than
renaming one. That is the direction that found a live **"Merge & Close"
offered on a mid-implementation card** in #2718.

## Why this is not a ratchet — a negative result, recorded

I tried to automate it, and I am shipping the reason it failed rather
than a guard I do not trust.

The **consumer scan is sound**: AST-based, 31 files, 66 role-helper call
sites. The **coverage half is not**. The renamed ids this program uses —
`building`, `checking`, `converted`, `published`, `backlog` — are
ordinary English words that appear in unrelated test prose, and a test
merely *importing* the module under test does not prove it exercises the
role path. My scan reported `TaskCard.tsx` as covered by
`Column.test.tsx` on a **filename coincidence**.

A guard built on that reports coverage that does not exist, which is
worse than no guard, so it is not shipped.

A sound alternative — pin the consumer set and make each new file
declare its status — was also rejected: a 31-entry status inventory
would conflict with every concurrent fleet PR that adds coverage. That
is the same churn already removed from the census baseline by dropping
its derived aggregates.

The attempt is written down so the next person does not repeat it from
scratch, and the requirement lives as a review criterion until someone
finds a sound signal.

## What it asks for

1. **A flags-supplying case** — if every case omits the new parameter,
the conversion is untested in both directions.
2. **Both directions where both are reachable** — renamed lane, and
repurposed column.
3. **A non-vacuous companion** — assert what the widened predicate must
still *exclude*, or a predicate matching every column satisfies your new
cases. (Both `TaskReviewTab` and the dispatch filters needed this.)
4. **Run the revert and record the failure text.** Twice in this program
a new case passed with the change reverted: once because the branch was
gated behind an unwired handler (`refine` needs `onOpenRefine`), once
because the hook was dispatched by trait and the test IR did not declare
that trait, so it never ran at all.

Cross-linked both ways with the adjacent `store-fake-defects` entry,
with the distinction stated so the two are not confused: **there** a
fake is missing a method so a branch never runs and production looks
wrong; **here** the fake is complete and the test is correct, but a
parameter is absent so production takes its documented fallback.

## Verification

`pnpm lint` clean · census `--strict` exits 0 (unmoved — this PR changes
no code).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:11:22 -07:00
gsxdsm
3066948110 fleet: async-comments-attachments.ts NOT converted — the archived gate lives in three encodings (52 sites), pinned by a guard that fails on each (#2724)
Claimed the **async-comments-attachments** cluster (9) and did not
convert it. This PR is the evidence for why, as a guard rather than a
note — **no production change, census unmoved.**

## What the cluster actually is

Every other file in the backlog converts on its own: resolve the task's
lifecycle columns, compare against the role. `archived` doesn't, and not
by a matter of degree.

**Measured in `packages/core` — one rule, three encodings, 52 sites:**

| encoding | sites | files | what it decides |
|---|---:|---:|---|
| TypeScript `=== "archived"` | **37** | 23 | what code does with a row
it already has |
| Drizzle `ne(tasks.column, 'archived')` | **7** | 6 | which rows a
query returns |
| raw `` sql`…column != 'archived'` `` | **8** | 5 | same — and
invisible to both scans above |

Convert only the TypeScript half and a board whose archived lane is
renamed **splits**: `getLiveTaskColumn` correctly reports the task
archived (it resolved the role) while `readLiveTaskRows` still hands it
back as live. A document write is rejected by its gate while its parent
is listed as live — a state neither gate alone can produce today.

And nothing would catch it. **Every builtin workflow spells that column
`archived`**, so all three encodings agree by accident on every board we
ship.

## Why the SQL sides aren't just converted too

`ne(tasks.column, ...)` needs the resolved id as a **query-build
value**, so the IR must be resolved *before* the query — including
inside the `for update` document/artifact transactions, which today
receive a `db`/`tx` handle and **no store and no workflow reader**. One
raw site is a hand-written `SELECT` string, so its comparison isn't even
a Drizzle expression that could take a bound value without rewriting the
query.

The two real options are: thread a resolver into the persistence layer,
or **declare `archived` a non-renameable system column** and mark all 52
sites deliberate. Both are decisions with blast radius. Neither is a
fleet conversion, so I didn't pick one.

## I was wrong about the shape, and that's the strongest part

I wrote the third case as an assertion that **no** raw `sql` template
compares a column to `'archived'` — I assumed two encodings. **It failed
on the first run with five files.**

Nothing in the repo was counting them: they aren't comparisons
(invisible to the column census) and aren't `eq`/`ne` calls (invisible
to the Drizzle scan). A partial conversion doesn't have to miss one
encoding — it can miss two. That case is now an inventory, with a note
on why asserting absence was the wrong invariant: an absence assertion
has to be deleted by whoever adds the next raw template, and deleting a
red guard is how a class of sites stops being tracked.

## Injection proof — all three run

| injected | result |
|---|---|
| convert one TS comparison in `async-comments-attachments.ts` | fails:
**"TypeScript encoding changed"** |
| remove one Drizzle predicate in `async-lifecycle.ts` | fails:
**"Drizzle encoding changed"** |
| remove one raw template in `reads.ts` | fails: **"Raw-sql encoding
changed"** |

Each failure carries the split-brain explanation and the two real
options, so the next worker hits the reason rather than a bare count
mismatch.

## Two scan bugs I fixed in my own guard

- **It audited documentation.** The raw-template scan reported three
sites in `async-archive-lineage.ts` that were prose in a JSDoc block.
Now comment-stripped through the census's shared `stripComments` rather
than a second implementation.
- **It missed half the cluster.** Requiring a receiver (`x.column ===
"archived"`) misses the list paths that hold the value in a bare local
(`if (column === null || column === "archived") return []`) — **four of
the eight sites in the target file**, measured. The matcher now accepts
a bare identifier named `column`.

Counts here are per file, not per line: line numbers churn on unrelated
edits, and a ratchet that cries wolf gets deleted.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · new guard **2/2** ·
`pnpm lint` clean · core `tsc` clean · census `--strict` exits 0 with
the **baseline untouched** — this PR converts nothing and claims
nothing.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:08:28 -07:00
gsxdsm
7c408ef650 fleet: merge path 10 → 2 — a merged PR never advanced its task on a renamed board (#2733)
**Claimed on #2728 before starting.** The merge path:
`merge-queue-ops-2.ts` + `merger.ts` — **10 → 2**, both survivors
flagged with reasons.

## A merged PR never advanced its task on a renamed board

`applyPrMergedTransition` is what moves a card when GitHub reports a PR
merged. Every guard in it was a default-lineage literal, and they all
failed **in the same direction**:

| guard | renamed board |
|---|---|
| `column === "done"` → skip as already-done | never matched, so a
complete card was re-processed |
| `column !== "in-review"` → bail `wrong-column` | always matched, so a
card **sitting in review** bailed |

Net effect: **a PR merged on GitHub never advances its Fusion task.**
The operator sees a merged PR whose card sits in review forever — which
reads as a broken webhook, so it gets debugged in the wrong place
entirely. That is the most expensive property of this defect class: it
does not just fail, it misdirects.

One snapshot now covers the pre-check, the deliberate **re-read** (a
merge can land between checks), and the **move target**. The target is
asserted in the test alongside the guards, because converting guards
alone would admit the card and then move it to a column the board does
not declare.

## merger.ts

- **The orphan-stash liveness guard** classified every finished task as
unfinished on a renamed board, so orphaned stashes were never cleaned
up. Unioned with the legacy ids: too strict here leaves clutter, too
loose **discards a stash whose task is still running**, so
over-inclusion is the safe direction.
- **The worktree-conflict scan** filters by worktree *path* before
resolving lanes. The naive order — resolve, then filter — is exactly
what made the github-tracking reconciler scan proportional to task
history (#2714 review). Lesson transferred rather than re-learned.
- The deprecated `aiMergeTask` already-finalized guard.

## Two flagged, not converted

**`merge-queue-ops-2`'s sync enqueue guard** runs inside
`store.db.transactionImmediate`. A synchronous lane resolution reads
`getTaskWorkflowSelectionImpl`, which returns `undefined`
**unconditionally in PostgreSQL mode** — so a "conversion" there would
drop the census by one and behave exactly as the literal (the finding
from #2703). Converting it properly means making the path async or
pushing the trait read into SQL: store architecture, not a call site.
Left literal **with that note**, so the next worker does not turn it
into a false green.

`merger.ts`'s last comparison is the same class.

## Pre-existing red, reported not folded

**22 failures in
`packages/dashboard/src/__tests__/routes-github.test.ts`** — spec
revise/rebuild and approve/reject-plan, all asserting moves to
**`triage`, the column U11 deleted**. Verified by reverting my diff and
re-running: identical 22. Same stale-literal-in-a-test class as the two
assertions #2720 fixed, and it is 22 tests pinning a column that does
not exist — worth someone owning deliberately rather than as a rider
here.

## Verification

census **10 → 2** · `pnpm test:gate` **487 / 71** · 23/23 across three
merger suites · 4 new cases, **2 red on revert** · `tsc` clean in core
and engine · `pnpm lint` clean.

## Also examined and deliberately left alone

- **`live-agent-count.ts` (6 guards)** — every literal there is the
*documented degradation path* for a task shape that was not enriched,
and both production callers already enrich (`useExecutorStats`, `fn
project`). Converting them converts nothing; deleting them removes the
fallback that fixtures rely on. The invariant that matters is **caller
enrichment**, which is not a literal at all.
- **`task-merge.ts` (6 guards)** — `getTaskMergeBlocker` is a **pure**
function with no store; its callers inject `resolveTask`. Resolving
lanes needs a matching injected resolver, which is an interface change
across every caller. Also worth a decision first: its dependency check
accepts `in-review` as satisfied while the store's `blockedBy`
computation (#2720) does not — **two definitions of "dependency
satisfied" in one codebase**, and I am not settling that one silently
inside a vocabulary sweep.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:08:16 -07:00
gsxdsm
ab715cbd39 fleet: default-workflow-hooks.ts 7 → 0 — every duration display read ZERO on a renamed board (#2734)
Claiming `default-workflow-hooks.ts` (7 → **0**), verified free against
every open PR's diff first.

## Not a vocabulary tidy — three silent zeroes

This file's header names it for the default workflow, but the store runs
it on the flag-ON path for **every** workflow: the trait registry
resolves each hook by **trait id**, not by workflow.
`reopen-semantics-by-role.test.ts` already documents that exact hazard
for the reopen predicates. The **timing, completion and in-review hooks
had the same defect** and were not part of that conversion.

On a renamed board, with nothing thrown and nothing logged:

- **`applyTimingEffects`** accrues `cumulativeActiveMs` while a card
sits in the WIP lane. With the lane named, the exit test never fires —
so **no active time is ever accrued**, and `productivity-analytics.ts`,
`task-timing.ts` and every duration display read **zero**.
- **`applyCompletionTimingEffects`** never stamps
`executionCompletedAt`, so a finished card looks unfinished to anything
reading that field.
- **`applyInReviewEnterEffects`** returns early, leaving the recovery
counters set.

The file already had the idiom — `ctx.lifecycleColumns`,
`planningColumnsOf`, `liveWorkColumnsOf` with `LEGACY_` fallbacks — so
this adds no abstraction.

One deliberate detail: `applyTimingEffects` resolves the WIP lane **once
into a local** rather than reading it twice. The exit test and the
re-entry test have to agree about which column is WIP, or a rename makes
the accounting count an interval twice, or not at all.

## A test that would have lied to me

I wrote the new cases through `applyDefaultWorkflowMoveEffects` first,
and **all three failed on the DEFAULT lineage too**. The dispatcher
resolves hooks by trait, and neither test IR declares the `timing`
trait, so those hooks never ran at all.

That failure looks exactly like a conversion bug. Going through the
dispatcher would have been testing the trait registry's wiring rather
than this change — so the cases call the converted functions directly,
and the reason is recorded in the test.

## Revert proof — all three, each naming the renamed lineage

| reverted | failure |
|---|---|
| the `in-progress` literals | `renamed lineage accrued no active time:
expected undefined to be 300000` |
| the `done` literal | `renamed lineage did not stamp completion:
expected undefined to be '2026-07-30T00:00:00.000Z'` |
| the `in-review` literal | `renamed lineage kept its recovery counter:
expected 3 to be undefined` |

Every case runs on **both** lineages and the default one passes either
way — which is the point of running it.

## A finding I did not act on

**`evaluateMergeBlockerGuard` appears exactly once in the repo — its own
definition.** And the file header says it is "implemented as the
`evaluateDefaultWorkflowGuards` reader", which does not exist either.
The merge-blocker guard hook is **defined and never consulted**.

I converted it (trailing optional lifecycle param, matching
`DefaultWorkflowMoveContext`) but did not delete it: the header states
this file is a deliberate parallel of `store.ts`'s flag-off path so the
two can be parity-checked, which makes removing it a scope call for
whoever owns that convergence — not something to decide inside a
conversion.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **22 passed** across
`default-workflow-hooks` + `reopen-semantics-by-role` · core `tsc` clean
· `pnpm lint` clean · census `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:05:05 -07:00
gsxdsm
94d88f1d6f fix(census): the work order was sending fleet workers at non-columns (722 -> 714) (#2692)
Found while claiming `TaskDetailModal.tsx` — its census entry included
`session.agentState === "done"`, an **agent state, not a lane**.
Auditing every receiver the classifier counts surfaced four more of the
same shape.

## The misclassified receivers

| site | receiver | what it actually is |
|---|---|---|
| `register-chat-routes.ts` | `event.type === "done"` | an SSE event
type |
| `useTaskDiffStats.ts` | `mode === "done"` | a cache-key mode |
| `async-mission-store.ts` | `evidence.kind === "done"` | an evidence
kind |
| `telemetry-hub.ts` | `event.kind === "done"` | a telemetry event kind
|
| `TaskDetailModal.tsx` | `session.agentState === "done"` | an agent
state |

Each shares a **word** with a column id and nothing else. Converting one
asks the trait registry what lane an SSE event is in, which has no
answer — the same failure class as converting `role === "triage"`, which
this list already exists to prevent.

The difference that makes it worth fixing now: a fleet worker handed
these in a per-file work order **has no reason to doubt them**. The
census is the work order, so a misclassification is an instruction to
break something.

## What I did not exclude

`state` is deliberately kept. `state === "archived"` in `audit-ops.ts` /
`comments-ops.ts` is a task's column reaching those functions under a
shorter name — a genuine guard. I checked rather than assumed, because
excluding a real one silently lowers the bar in the direction nobody
notices.

## Census effect

```
column  722 -> 714
role      5 -> 14
```

Those 8 are **reclassified, not converted** — this PR changes no
production code. The baseline is re-recorded so `--strict` agrees.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0. `pnpm lint` clean.

No changeset: instrument accuracy, no user-facing change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:01:52 -07:00
gsxdsm
15f90706e6 fleet: reliability-metrics.ts 6 → 0 — historical log values, marked not converted (#2756)
Unclaimed file, no overlap with any open fleet PR — deliberately picked
to avoid adding conflicts to the queue.

## Census

| | before | after |
|---|---|---|
| backlog | 539 | **533** |
| reviewed (DELIBERATE-LITERAL) | 31 | 36 |
| this file | 6 | **0** |

`--strict` exit 0, baseline re-recorded in the same commit.

## Why these are marked, not converted

All six ids come from `metadataColumn(entry, "from"|"to")` — the columns
**recorded on a past move event** in the activity log, not a task's
current column.

There is no workflow to resolve them against. The event was written
under whatever the board looked like at the time, and **a column renamed
since leaves every older entry carrying the old id forever.** Converting
them to a trait read would ask *"what role does the column named X play
today?"* about a record written months ago, possibly under a different
workflow — a different question with a different answer.

The failure mode matters: a trait-converted reader on a renamed board
would **zero the series** rather than fix it, silently dropping history
out of `tasksEnteredInReviewPerDay`, `tasksBouncedToInProgressPerDay`,
and `inReviewDurationMetrics`. That is worse than the literal, which at
least keeps matching the data that exists.

**The real fix for renamed boards is at the WRITER** — emit a role
alongside the id when the move event is recorded — not at this reader.
Noted at the site so whoever does that work finds it.

## A rule this generalises to

**Any reader of activity-log or run-audit metadata is a mark, not a
convert.** The census cannot distinguish `task.column === "in-review"`
(a live question, convert it) from `metadataColumn(entry, "to") ===
"in-review"` (a historical record, match it as recorded) — both are just
literals to the AST. Other fleet workers hitting log/audit readers
should expect the same call.

## Placement trap, third occurrence

My first pass marked the `const from`/`const to` declarations and moved
the count by **1 of 6** — the census excuses the construct a marker is
attached to, and the guards live in **sibling `if` statements**. Moved
the markers to the enclosing functions.

This has now caught #2645's author, me on `TaskContextMenu`, and me
again here. **Verify a marker by the count moving, not by the comment
existing** — and until every worker does, a batch reporting "N → 0" can
be off by most of N.

## Verification

Dashboard typecheck clean · reliability suites green (11 passed) ·
`--strict` exit 0 · no behavior change (comments only).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:01:02 -07:00
gsxdsm
9a2a033b9e fix(test): two engine reds from fleet churn — and stop the shellout guard breaking on line drift (#2757)
Both failures are fleet-churn fallout on `main`, not defects in the
conversions. Engine goes **5 failed / 3 files → 3 failed / 1 file**; the
remaining 3 are `executor-prompt`'s pause-guard question, documented in
#2747.

## 1. The shellout guard was coupled to line numbers — third time

Its match key was `file:LINE:primitive:signature`, so **any edit above
an audited call site** broke it while the call itself was untouched.
Recent fleet conversions shifted `executor.ts` and `self-healing.ts`,
and 5 sites drifted at once.

**This is the third time it has gone red this way, and the third hand
re-pin.** A guard that fails on edits it does not care about trains
people to re-pin it without reading it — which is exactly how a real new
shellout slips through in the same commit as a drift fix.

So this removes the coupling rather than updating the numbers again.
Identity is now **file + primitive + signature**, with a **per-key
count**; `line` stays as documentation.

The count preserves what `line` was actually buying: a *second
identical* shellout in the same file is still unmatched, because the
allowlist declares how many of that exact call it audits. What is
deliberately given up is distinguishing "the audited call moved" from
"it stayed put" — which this guard has no reason to care about.

**Measured, all three directions:**

| mutation | result |
|---|---|
| add a NEW, different shellout | **2 failed** / 1 passed |
| **duplicate** an already-audited shellout | **2 failed** / 1 passed —
what `line` used to catch |
| pure line drift above an audited call | **3 passed** — previously the
false failure |

## 2. A test that predicted its own flip

`executor-execution-policy-renamed-columns` asserted `moveTask` was
never called. It now rehomes the card to `inbox`.

That is not a surprise — **the test's own comment called it**:

> *"the resume router's log says 'moved back to todo' and its
already-there check is another `"todo"` literal — one of the 20 sites in
this method left to U5's executor slice. It is why the card stays put
here rather than being rehomed to `inbox`."*

A fleet PR converted that literal, and the router now rehomes to `inbox`
— the declared intake column of `noHoldIr`, the workflow under test.
**The prediction landing is the evidence the conversion is right**, so
the case asserts the rehome instead of the absence of a move, and
additionally pins that the card is never moved to the `todo` this
workflow does not declare.

What the case owns is unchanged: the dispatch-loop gate did not claim
the card (no *"executor recovery preserved"* log), and the run reached a
real classifier rather than falling off the end.

## Verification

`pnpm test:gate` **726**, `pnpm lint` clean, engine `tsc --noEmit`
clean.

## Note on PR count

This is my fourth open PR against the one-per-worker rule, opened
because it clears **red on main** — the stated priority-one exception.
My other three (#2753, #2747, #2743) are rebased onto current main,
green, with zero unresolved threads, waiting only on CI. I am opening
nothing further until they land.
2026-07-30 06:54:44 -07:00
gsxdsm
dc363543a2 fix(cli): fn task retry now CRASHES on a renamed board — #2728 converted the classifier and left the target (#2752)
## This is a live regression on `main`, not a conversion

`#2728` converted the retry **classifier** and left all three re-queue
**targets** on the literal `"todo"`. That pairing is **strictly worse
than the bug it fixed**:

- **Before:** `fn task retry` silently did nothing on a renamed board.
- **After (main today):** it correctly decides to retry, then throws.

```
TransitionRejectionError: Invalid transition: 'checking' → 'todo'. Unknown column for this workflow.
```

`todo` is not a column that board declares. Reproduced against main's
exact code — reverting this fix fails **2 of 4** cases with that error.

I flagged this on #2728 before it landed; posting it as a fix rather
than a comment now that it is merged.

## Why the census did not catch it

The census counts **comparisons**. A move **target** contains no
comparison, so all three sites are invisible to it —
`packages/cli/src/commands/task.ts` reads **0 guards** on main while the
crash is live.

That is the clearest case in this program so far that **the census
measures conversion progress, not correctness**. A classifier and the
target it feeds have to move together, and no automated signal will say
so.

## The fix

The target resolves from the task's own workflow:

```ts
const retryHoldColumn = (await resolveTaskLifecycleColumns(context.store, id))?.hold ?? "todo";
```

Failing soft to `"todo"` when the workflow cannot be resolved, matching
every other fallback in this file. Three call sites, all three
converted.

## Revert proof

| state | result |
|---|---|
| main today (classifier converted, target literal) | **2 failed** / 2
passed — `Invalid transition: 'checking' → 'todo'` |
| with this fix | **4 passed** |

## The fixture is derived, not hand-built

The renamed workflow is `BUILTIN_CODING_WORKFLOW_IR` with **only its
column ids renamed**, so the sole difference between the two runs is
vocabulary. Hand-building a graph tested the fixture's shape as much as
the code — the IR validator rejects an undeclared back-edge, and once
declared as `kind: "rework"` the transition table still did not match
the default board's. The suite also asserts the rename landed
(`checking` present, `in-review` absent), so a surviving literal cannot
pass by accident.

Real store, real persisted workflow, driven through the real
`runTaskRetry` — not the predicate. A unit test of the classifier goes
green on the half-fix; only driving the whole command surfaces the
crash.

## Relationship to #2736

This replaces it. #2736's other contents (active-task count,
near-duplicate filter, archived-lineage label, node-override guards, the
missing-worktree classifier) are now redundant with #2728, so they are
dropped rather than re-litigated. What survives is this fix, its test,
and the **changeset for the published CLI** that #2728 did not include.

I will close #2736 once this is reviewed.

## Verification

- new PG suite **4 passed** · `task-retry.test.ts` **7 passed** across 2
files
- `pnpm test:gate` — **10 / 158 / 487 / 71** · `pnpm lint` clean · CLI
`tsc --noEmit` clean · `check:changesets` passes · `--strict` exits 0

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:51:25 -07:00
gsxdsm
edab088107 fix(test): 23 reds on main from the column U11 deleted — routes-github (22) + move-bypassguards (1) (#2758)
**Red suite on `origin/main`, unclaimed. Reported twice (on #2723 and
#2733) and nobody picked it up, so I am fixing it rather than reporting
a third time.**

`register-task-workflow-routes.move-bypassguards.test.ts` fails on main:
`expected 400 to be 200`.

## Not a route bug — a fixture that outlived its column

The move endpoint validates the target against the **task's own
workflow** (U12/R2), and the default lineage post-#2515 declares `todo |
in-progress | in-review | done | archived`. The fixture asked to move to
**`triage`**, which U11 deleted. So the route correctly answered `400
Invalid column`, the request never reached `moveTask`, and **every
assertion in the case was unreachable**.

Same class as the two assertions #2720 corrected in
`task-dependency-mutation.pg.test.ts`: a test pinning an id the board no
longer has. It is also **why it sat unclaimed** — the failure message
says *"Invalid column"*, which reads as a broken guard rather than a
stale test, so anyone glancing at it would reasonably assume it belonged
to whoever last touched the move route.

Target changed to `in-progress`: keeps the case's actual subject intact
(a caller-supplied `bypassGuards` / `moveSource` must not be forwarded)
and is a forward move from `todo`, so the R16 backward-move PR guard
stays out of the way. **The point was never which column.**

## The paired cases the suite lacked

The cheapest wrong fix here would have been to relax the route's
validation until the old fixture passed again. Two cases now make that
impossible:

- an **undeclared** column (`triage`) is still **rejected**, with a
message naming the board's own columns so an operator can act on it;
- a **declared** column is still **accepted**, so validation cannot
degrade into "reject everything".

Without them, the next person to see *"Invalid column"* in a failure
cannot distinguish a stale fixture from a broken guard — which is
exactly the half-hour this cost me.

## Verification

11/11 route suites green (**69 tests**, was 1 failing) · `pnpm
test:gate` **487 / 71** · `tsc -p packages/dashboard` clean · `pnpm
lint` clean.

Scope is one test file. Opening this despite the one-PR-per-worker rule
because it is the same category as #2753 — a red on main, which the
close-out brief made priority one — and it touches nothing my other six
PRs touch.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:51:10 -07:00
gsxdsm
e18a6cf00c fleet: executor.ts 57 → 15 on top of #2689 — the review/wip lanes, 4 half-conversions, 8-of-19 revert proof (#2703)
**Supersedes #2691, which I am closing.** #2689 landed the terminal-pair
batch on `executor.ts` while my PR was open on the same file — we
collided, that PR won the race, and 30 of my 70 conversions are now
identical to its work. Rather than resolve 30 conflict hunks in a
20k-line lifecycle file (unreviewable, and the wrong artifact to hand
you), I rebuilt from `origin/main`.

**`executor.ts` 57 → 15.** Repo backlog 679 → **650**.

## The four that are defects, not vocabulary

**1. `isReentrantPausedAbortedInFlightNode` resolved lanes at the END,
for its return value, while its four `in-review` eligibility gates were
literals.** On a renamed board those gates all read false — so a review
card skipped the global-pause recheck, the `autoMerge === false`
refusal, the shared-branch-member arbitration **and** the
merge-confirmed refusal — and then the lane-resolved final line answered
*"re-entrant"*. FN-7214's own comment says an auto-merge-off review row
must stay terminal.

**2. The REVERSE half-conversion.**
`routeGraphFailureToExecutionResume`'s destination was already resolved
(U7's `resolveReboundColumnFor`) behind a gate that was still three
literals — so the router refused before reaching its own working move.

| direction | what happens | visible? |
|---|---|---|
| resolved gate → literal destination | card admitted, move rejected by
a board with no such column | **yes** — the move errors |
| literal gate → resolved destination | card refused; the working
recovery never runs | **no** |

Only the second is silent, which is exactly why it survived U7's own
conversion of that destination. **When you convert a destination, check
the gate in front of it in the same commit.**

**3. `routeUnusableWorktreeGraphFailureToRecovery` skipped FN-5147's
auto-merge-off gate** on a renamed board — an automatic recovery moving
a human-review-terminal card backward. #2689 converted the terminal
guard at the top of that method; this is the other half of the same
decision, which is the general risk when two people split one file.

**4. `handleGraphFailure`'s `alreadyFinalizedToReview` /
`suppressFinalizedCompletionAbort`** read `column !== "in-progress"`, so
a completed, already-finalized row looked still-in-wip: FN-6644 /
FN-6647's suppression never fired and the row was re-parked as an
operator-action pause abort — the durability gap those tickets closed.

## Two patterns worth carrying to other files

**An inert guard rarely reports "renamed board" — it reports something
that sounds like a different problem.** `finalizeAlreadyReviewedTask`
returned `"missing"` for a card sitting in review. The completion
handoff logged *"no longer active"* for a card that was actively
executing. The stuck-requeue cleanup logged *"recovered concurrently"*
about a recovery that had not happened. Three different false
explanations, one cause.

**Directions differ inside one family, so convert per method, not per
pattern.** Most wip guards read `!== "in-progress"` and REFUSE on
no-match (renamed board → silently disabled). The rerun watchdog reads
`=== "in-progress"` and SKIPS on match — there the literal never
matched, so a rerun could fire on a card **mid-execution**. A mechanical
sweep of `!== "in-progress"` fixes the refusals and leaves that
admission in place.

Also: the resolver choice inverts within a few lines. *"Is this card in
the ONE column finalize targets?"* needs the **complete** column — the
terminal union carries the legacy ids, so a card in a column merely
*named* `done` reads as already finalized and the finalize is
**skipped**. *"Is this card already finished, so do not move it?"* needs
the **union** — over-inclusion only skips a move, under-inclusion moves
a finished card out of its terminal column. Both are recorded at their
sites.

## Revert proof

19 cases in `executor-graph-failure-lanes-resolved.test.ts`, on a board
sharing **no** column id with the default lineage (on the default board
these guards are correct by coincidence — the literals *are* the board).
**8 fail on revert.** The rest are labelled **in the file** as paired
positives, default-board no-change cases, or — in one instance — a guard
that is genuinely redundant with a later lane check. I would rather
label a case as non-evidence than count it.

Two fixture corrections are recorded at their sites, both my own
assertion failing to touch the behaviour it named: asserting a router's
return value (which was already false for an unrelated reason — fixed by
spying on the recovery call), and `allowsAutoMergeProcessing` keying on
the **global** setting rather than `task.autoMerge` (fixed the fixture,
not the assertion).

## The 15 that remain, each with a reason

- **7 `to`/`from` move-effect parameters** — a move's endpoints, not a
card's resting column. Trait-hook territory.
- **2 enumeration scans** — one is a `listTasks({ column: "in-progress"
})` query whose filter cannot be converted without the query (converting
the filter alone reads as done and changes nothing); the other loops
every task, so per-task resolution is a real cost wanting a shared memo.
- **`12325`, the dependency guard** — *"is this dependency satisfied?"*
is not any single lane role. The same question exists at
`register-task-workflow-routes.ts:3995`; both should be decided once,
together.
- **`14484`** (`fromColumn === "in-review" && toColumn === "in-review"`)
— a same-lane move check that belongs with the move-effect group above.

## Verification

`pnpm test:gate` **158 / 10 / 487 / 71** · **135/135** across the 17
suites covering these paths · `tsc -p packages/engine` clean · `pnpm
lint` clean · census `--strict` exit 0, baseline re-recorded.

No changeset: `@fusion/engine` is private and the behaviour change is
confined to renamed boards.

🤖 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 workflow execution across boards with renamed lifecycle lanes
by resolving lane targets per board instead of using fixed column names.
* Fixed review, WIP, completion, and failure-recovery behaviors to
respect the correct board snapshot (including auto-merge and terminal
work states).
* Improved artifact-recovery protection timing and tightened
execution-resume gating for failure scenarios.
* **Tests**
* Added a new lifecycle invariant test suite covering renamed-lane
recovery, resume, pause/abort, and router-gating behavior.
  * Updated lifecycle column census baseline data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:48:25 -07:00
gsxdsm
61b82a2737 fleet: pure lifecycle predicates 17 → 5 — a monitoring signal that went quiet, and a blocker that waited forever (#2745)
**Claimed on #2742 before starting.** Four pure modules — **17 → 5**,
every survivor flagged with a reason.

All four are **pure functions with no store**, so the fix shape is the
injected-set contract established in #2728, not an in-function resolve.

## Three failures that never error

| predicate | what a renamed board got |
|---|---|
| `getTaskAgeStalenessSignal` | `undefined` for **every** card —
age-staleness reported nothing |
| `isStaleBlockedByBlocker` | "not stale" for a blocker that was
finished, paused in review, or retry-exhausted |
| `areAllDependenciesDone` | "not satisfied" for a dependency that had
landed |

The first is the one to sit with: **a monitoring signal that goes quiet
is indistinguishable from health.** The board looks fine while cards sit
for days, and nobody investigates a metric that isn't alarming. The
signal also chose its *threshold pair* by wip-vs-review, so both halves
were literal.

The second means the blocked card **waited forever**, silently — "not
stale" is the answer that produces no event.

The third is the **third place** "satisfied" is asked. It now gives the
same answer as the store's `blockedBy` computation (#2720) and the merge
blocker: complete or archived, unioned with the legacy ids. Three
surfaces, one rule — which is exactly why I refused to settle it inside
a vocabulary sweep the first two times it came up.

## Optional is load-bearing

Both halves are asserted for every predicate: supplying lanes makes a
renamed board work, **omitting them preserves every existing caller**.

A *required* parameter would have compiled at every call site and then
answered "not active" / "not stale" / "not satisfied" for everything.
That is the silent direction, and **no type checker catches it** — which
is the argument for optional-plus-legacy-default over a clean signature.

The restart-recovery classifiers (with-progress / no-progress /
merge-active) take the same set, and **the combiner threads it to all
three**, so a caller cannot convert the outer question and leave an
inner one literal. `isInReviewMissingWorktreeSessionStartFailure` is
deliberately untouched — #2728 converts it and duplicating that would
conflict.

## The five that remain

- **3 are the ternary trait-fallback branches** (`lanes ? … : legacy`) —
the documented degradation path the census counts by design, not
unconverted guards. I am not marking them `DELIBERATE-LITERAL` to move
the number; that marker means "a lifecycle literal reviewed and kept",
and mislabelling to flatter a count is how the instrument stops meaning
anything.
- **`recoverInterruptedRuns`' filter sits behind a `listTasks({ column:
"in-progress" })` query.** The query is the live filter, so converting
the redundant predicate moves the census and changes nothing an operator
sees. **Third file** where the reported guard is the inert copy and the
real one is a query.
- **`resolveWorkflowBypassGuards` is sync and receives only column
strings** — no task, no store. Converting it means adding lanes to
`MoveTaskOptions` and threading them from the moves path, which another
worker owns. Marked `DELIBERATE-LITERAL` as an explicit hand-off, with
the consequence named: on a renamed board the operator's drag out of the
wip lane was rejected by the transition validator, so **a card could not
be cancelled from the board at all** (AGENTS.md's Move-Task hard-cancel
contract).

## Verification

`pnpm test:gate` **10 / 158 / 487 / 71** · 9 new cases, **5 red on
revert** · 13/13 with the archive PG suite · `tsc` clean in core and
engine · `pnpm lint` clean · census **17 → 5**.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:48:00 -07:00
gsxdsm
e0010f241e fleet: github-tracking-comments.ts 9 → 3 — and the sync-filter class is now in a FOURTH file (~25 sites on one decision) (#2715)
Claiming the **github-tracking pair**. This converts the comments half;
the reconciler half is the flagged class, with the evidence below.

## Census before/after

| | before | after |
|---|---:|---:|
| `github-tracking-comments.ts` | **9** | **3** |

Baseline re-recorded; `--strict` exits 0.

## Converted: 6

The `event.to === "in-progress"` / `=== "done"` sites in
`handleTaskMoved`, to the **wip** and **complete** roles. One
resolution, placed **immediately after the tracking-enabled gate** — so
a move on an **untracked** task pays nothing, which is most moves in
most projects.

## Deliberately not converted: 2 — the ordering is the reason

```ts
if (event.to !== "in-progress" && event.to !== "done") return;   // line 232
```

This runs **before** the tracked-task gate. Converting it moves the
resolution ahead of that gate and makes **every task move in the
project** resolve a workflow just to decide the task has no GitHub
issue. That's a real cost on the hottest event in the system, to convert
a guard whose only job is a cheap filter. Recorded at the site.

## The remaining 1

**Line 165** — `transition === "done"` inside `formatTrackingComment`, a
**pure formatter** with no store and no task. Same shape as
`project-engine.ts:2555`. Threading a resolution into a formatter to
pick a string is the wrong trade.

## `github-tracking-reconciler.ts` (9) — not claimed here, and here is
why

All nine are:

```ts
.filter((task) => task.column === "done" || task.column === "archived")
```

Synchronous filters over task **lists**, where per-task resolution is N
awaits inside a sync predicate.

**This is the fourth file with that exact shape** — after `store.ts`
(#2709), and the dependency pairs in `TaskDetailModal` (#2696) and
`register-task-workflow-routes` (#2700). By my count **roughly 25 sites
across four files now wait on one decision**:

1. **Prefetch lifecycle columns alongside the task list** and pass a
resolved map into these predicates — keeps them synchronous, one
resolution per *distinct workflow* rather than per task. This is the one
I'd argue for.
2. Make the predicates async and accept per-task resolution.

It's a design change, and four workers guessing separately is exactly
how two halves of one rule drift apart. One decision covers all of them.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) ·
`github-tracking-comments` + `github-issue-comment` **81/81** · `pnpm
lint` clean · dashboard `tsc` clean · `--strict` exits 0.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:39:12 -07:00
gsxdsm
b9f3fb0ff2 docs(core): resolveReviewColumns is the BROAD set — and one of its consumers must NOT migrate onto it (#2750)
## A flaw in the helper I merged in #2730

Found by trying to do the migration I had been advocating for three
rounds. One **name** was answering two questions:

| | question | answer |
|---|---|---|
| **broad** | "is this card in a lane where review happens?" | every
`mergeOrchestration` lane + every `mergeBlocker`/`humanReview` lane —
**this function** |
| **narrow** | "is this card in *the* review lane the engine acts on?" |
`resolveLifecycleColumns().review` = `columnsWithFlag(ir,
"mergeOrchestration")[0]` — **one** lane |

The narrow answer is what the executor, the scheduler and
`project-engine` act on. **A caller that admits on the broad set and
then MOVES the card moves cards the engine does not consider in
review.**

## The correction I owe

I have been arguing across #2722, #2723 and #2728 that the inline review
unions should converge on this helper. For the notifier that is right —
over-admission there just means an extra notification.

For `register-task-workflow-routes.ts` it is **wrong**. That resolver is
deliberately narrower (#2723): its re-engagement *moves* the card, so
admitting a second merge lane is a state change the engine will not
agree with. Its local copy is **not drift from this helper — it is the
other question.** Migrating it would reintroduce precisely the
over-admission that PR's review round reasoned away.

I was about to make that change. Reading both implementations side by
side is the only thing that stopped me, and "consolidate the duplicates"
would have looked like an obvious cleanup to the next person too.

## What this PR does

Nothing to behaviour. It writes the distinction down **at the helper**,
where a consumer reaching for "the review columns" will see it, and pins
the difference with a test.

The test needs a board declaring `mergeOrchestration` **twice** — no
default lineage does, which is exactly why the two answers look
identical everywhere else and why the conflation survived review.

**Mutation: narrowing this helper to the first merge lane — the
consolidation someone would reasonably attempt — fails the test.**

## Verification

30 trait tests green · core `tsc` clean · lint clean (0 errors) · gate
green (487 + 158 + 10 + 71). No census movement.

## Not done here

Migrating the notifier and the CLI copies onto this helper. Those
genuinely should converge, but both live in open PRs (#2722,
#2728/#2736) with live review threads; switching them under their
authors mid-flight is worse than letting them adopt it once this
distinction is documented.

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

## Summary by CodeRabbit

* **Documentation**
* Clarified the distinction between broad review-capable lanes and the
workflow’s primary review lane.
* Documented how review lane selection affects workflow state handling.

* **Tests**
* Added coverage confirming that review detection includes all matching
merge lanes.
* Verified lifecycle review selection continues to use only the primary
merge lane.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 06:35:27 -07:00
Phil Larson
8e50967279 fix: restore current main regression invariants (#2755)
## Summary
- align the renamed-review CLI regression with the classifier’s
resolved-boolean contract
- keep GitHub tracking controls expanded across same-task detail and
sparse SSE updates
- strengthen the sticky tracking regression to wait for the sparse
update

## Test plan
- `pnpm --filter @runfusion/fusion exec vitest run
src/__tests__/cli-active-count-lanes.test.ts`
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/restart-recovery-coordinator.test.ts`
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run
app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx`
- `pnpm --filter @fusion/dashboard typecheck`
- `pnpm --filter @runfusion/fusion typecheck`
- `pnpm check:changesets`
2026-07-30 06:24:57 -07:00
gsxdsm
3da8b90ed9 fleet: scheduler.ts 26 → 12 — five quiet wrong answers on a renamed board (finished deps blocked forever, PRs unwatched, missions stalled) (#2729)
## Census

| | before | after |
|---|---|---|
| `packages/engine/src/scheduler.ts` | 26 | **22** |
| repo backlog | 657 | **653** |

Baseline re-recorded in this PR; `--strict` exits 0. Four literals
removed, and I want to be exact about why it is four and not seven: the
converted predicates keep their legacy literals as the documented
**no-metadata fallback**, and the census counts per literal, not per
code-quality improvement. Deleting those fallbacks would change
behaviour in degraded mode (unresolvable workflow → every dependency
reads as unsatisfied → dependents blocked forever), which is the
expensive direction to be wrong in.

## What was actually broken

**1. Dependency satisfaction was keyed on three column ids.**

```ts
return !!dep && (dep.column === "done" || dep.column === "in-review" || dep.column === "archived");
```

On a board whose complete column is `shipped`, a **finished** dependency
matched none of the three. `getUnmetSchedulingDependencies` reported it
unmet, and the dependent was parked `blockedBy` — *permanently*, because
the dependency can never move anywhere that satisfies the literal. Work
stops and nothing rescues it.

Satisfaction is now resolved on the **dependency's own board**, since a
dependency edge may cross workflows — the dependent can sit on the
default board while the dependency lives on a renamed one. Resolution is
passed in by the caller (`resolveDependencySatisfactionColumns`) with a
caller-owned IR cache, so a sweep reads one IR per distinct workflow
rather than one per dependency edge.

**2. The review half of the file-scope lease was never converted.**

The wip half of this same sweep was fixed on 2026-07-30-16:30
(`scheduler-renamed-wip-file-scope-lease.test.ts`). The review half
still read `column === "in-review"`, so on a renamed board no review
card entered `activeScopes`, a merging card's worktree files read as
**free**, and an overlapping candidate dispatched on top of them. One
registry, two halves, disagreeing. It now uses `isReviewColumnRole` over
the *same* resolved flags map the wip half uses, so the two cannot drift
again.

## Finding I am reporting rather than fixing

**The two satisfaction rules in this one function genuinely disagree,
and the live one is the broader.**

| rule | satisfied when |
|---|---|
| legacy (**live**) | complete ∪ archived ∪ **review lane** |
| marker (shadow) | complete ∪ archived |

#2720 settled "satisfied = complete or archived" for
`update-task-deps.ts` — which matches the **marker** rule, not the live
one. So the scheduler currently treats an in-review dependency as
finished and `update-task-deps.ts` does not.

Reconciling them is a product decision, not a vocabulary one, so this PR
preserves **both** rules exactly as shipped. Narrowing the live rule to
match would strand every dependent of an in-review card, which is
precisely the failure mode fix 1 exists to remove — I am not doing that
as a side effect of a rename conversion.

## Revert proof

Each fix reverted **alone**, tests re-run:

| reverted | result |
|---|---|
| dependency satisfaction → literals | **2 failed** / 5 passed |
| review lane → `column === "in-review"` | **2 failed** / 5 passed |
| neither (shipped) | **7 passed** |

Each revert fails exactly its renamed case *and* its "both vocabularies
reach the same outcome" invariant, while the default-vocabulary controls
stay green — so the failures are attributable to a surviving column-id
literal and not to a generally broken path. The suite is differential:
one workflow **shape**, two vocabularies with identical traits, only the
ids differ. No renamed id collides with a legacy literal, so a surviving
`=== "done"` cannot pass by luck.

There is also a paired negative (`an UNFINISHED dependency still blocks,
under both vocabularies`) so the fix cannot degrade into "always
satisfied" — the direction it could overshoot.

## Verification

- `pnpm --filter @fusion/engine exec vitest run <19
scheduler/dependency/hold-release/overlap suites>` — **174 passed**, no
regressions
- new suite — **7 passed**
- `pnpm test:gate` — **158 / 487 / 10 / 71 passed**
- `pnpm lint` clean · `npx tsc -p packages/engine/tsconfig.json
--noEmit` clean · `check:lifecycle-columns --strict` exits 0

## The remaining 22, triaged

Not guessed at — grouped by what they actually ask:

- **6 fallback branches already converted** (L258, L265, L439, L440,
L1712 and the review twin): trait-first with the literal as documented
degraded-mode fallback. These reach 0 by *marking*, not converting.
- **10 `from`/`to` move-transition arms** (L899–L1042): a different
question ("is this transition *into* a review lane?"), and per the
scoping note in `docs/solutions/architecture-patterns/` they should not
ride along with `task.column` conversions.
- **6 `task.column` reads** (L1061, L1135, L1524, L1546, L1554, L2607):
convertible, but two sit in sync methods (`resolveBaseBranch`) needing
the caller-resolves-and-passes shape, so they are a separate unit rather
than a half-conversion here.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:15:05 -07:00
gsxdsm
72d42652e5 fleet: CLI surface 16 → 0 — 'active=0' on a busy board, and a retry gate that disagreed with the dashboard (#2728)
**Claimed on #2714 before starting.**
`packages/cli/src/commands/task.ts` (8) + `dashboard.ts` (8) — **16 →
0**.

## The finding that matters: `active=0` on a busy board

The same four-line aggregation appears **four times** in `dashboard.ts`
— the TUI stats refresh, the serve summary, the status line, the
agent-stats pass. Each compared the default lineage's two ids, so on a
renamed board every one reported `active=0` while the board was plainly
busy.

**This is worse than an inert internal guard.** A recovery path that
silently stops firing is invisible until something breaks. A stats line
that says zero is **read, believed, and acted on** — *"nothing is
running, so I can restart the engine."*

The four copies are now one helper, and that is the other half of the
fix: four independent copies of a lifecycle decision is how they drift,
and these were identical **by accident, not by construction**. One IR
read per *workflow*, asserted by call count — because the returned
number is identical either way, so only counting the work can see it.

## The retry gate exists twice, and #2713 converted one of them

After #2713, `POST /tasks/:id/retry` accepted a renamed board's stalled
review card while `fn task retry` refused it with *"not in a retryable
state"* — **one operator action answering differently depending on the
surface**.

The rule, stated at the site: **converting one copy of a duplicated gate
creates a disagreement that is harder to diagnose than the original
inert guard.** Grep the classifier by name before calling a lane
converted.

## The rest

- **`fn task set-node` / `clear-node`** rewrote the node override of an
*actively executing* card, because the "is in progress" check never
matched. That guard exists because the rewrite races the run.
- **The duplicate-guard candidate filter** kept completed cards in the
comparison set on a renamed board, so a new task was reported as a
duplicate of work that had already landed — the opposite of useful.
- **The duplicate-lineage `(archived)` marker** never printed, so the
operator could not tell a live duplicate from a filed one.

## Two DELIBERATE-LITERALs, with reasons

The board-render glyph compares `col` taken from the legacy `COLUMNS`
enum **that loop iterates** — the literal matches its own receiver by
construction. The real defect is already named in the code above it: a
card in a renamed column **is not rendered at all**, which is the R8/U10
surface change, not this glyph. Converting it would hide that behind a
trait lookup while the loop still cannot see the card.

## Pre-existing, not mine

5 failures in `commands/__tests__/task.test.ts` (GitHub import) **fail
on `origin/main`** — verified by stashing this change and re-running.
Someone owns that; it should not ride in here.

## Verification

census **16 → 0** · `pnpm test:gate` **10 / 71** · `pnpm smoke:boot`
**PASS** · `tsc -p packages/cli` clean · `pnpm lint` clean ·
`task-retry` 3/3 · 4 new cases with **2 red on revert**.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:04:23 -07:00
gsxdsm
a09263ae8c test(dashboard): the mobile board needs RESOLVED lanes — U12/R9 deleted the legacy board (22 → 0) (#2740)
## Root cause

`Board.tsx:874` renders `BoardWorkflowSkeleton` whenever `boardWorkflows
=== null || boardWorkflows.workflows.length === 0`.

All three files depended on the **legacy board**, which U12/R9 deleted —
along with the `flagEnabled` conjunct in that condition. So
`flagEnabled: false` now selects nothing, and the board sits at
`"Loading workflow lanes"` with no lanes, no task cards, and no
Auto-merge toggle.

Every failure surfaced as `Unable to find an accessible element with the
role "checkbox" and name "Auto-merge"` — which points at the query, not
at the board state. The `aria-label` in the error's role dump is what
gave it away.

## Three causes, in sequence — each only visible after fixing the one
before

| # | File | Cause |
|---|---|---|
| 1 | `auto-merge-toggle-blank.mobile` | mocked `workflows: []` →
empty-lane skeleton |
| 2 | `auto-merge-toggle-blank.mobile-integration` | builds its mock
with `createDashboardApiMock`, which **spreads the real module** — so
`fetchBoardWorkflows` was the real network call, never resolving under
jsdom |
| 3 | both | even with a payload, lanes arrive on a **promise** while
these synchronous tests assert immediately after `render()` |

(3) is the one worth remembering: the legacy board rendered
**synchronously**, so no await was ever needed. `renderBoardWithLanes`
flushes a microtask inside `act` — a microtask and not a timer advance,
because this suite runs on fake timers.

## Also re-pinned: a source-scan guard pinning deleted code

`board-mobile` asserted `ref={setBoardRef}` appears **3** times across
"legacy, selected, and aggregate" renders. The legacy `<main
className="board" id="board" ref={setBoardRef}>` render has **0**
occurrences now, so both the count *and* its `toContain` were pinning
removed markup — the second would have failed as soon as the first was
fixed.

Now **2**, with the legacy markup asserted **absent** so it stays gone,
and the test renamed to the two renders that exist. The guard's real
point — that both share one scroll-snap hook — still holds.

## Measured

| Check | Result |
|---|---|
| `components-a` group | 22 failed → **0** (51 files, **1195 passed**) |
| lane payload emptied again | **all 8** cases in the first file fail —
load-bearing |
| `pnpm lint`, dashboard app `tsc` | clean |

## One wrong turn, recorded

My first attempt declared the payload as a module `const` and referenced
it from the `vi.mock` factory. Factories are **hoisted above const
declarations**, and the file reported `"no tests"` rather than a
hoisting error — a failure mode that reads as a collection problem, not
a reference problem. Inlined instead.

## Dashboard status

With #2735 (50 → 1) and this (22 → 0), dashboard goes from 88 failures
across 9 files to **17 across 6**. The remainder are unrelated: CSS
`toHaveClass` / computed-colour assertions, a missing
`wf-add-step-modal` testid, and the one GitHub-tracking affordance
question flagged in #2735.

Per #2732 these lanes are currently **never executed in CI** — the shard
aborts on the first failing package — so this is cleared ahead of them
starting to run.
2026-07-30 06:00:11 -07:00
gsxdsm
e9d7945f71 test(dashboard): TaskDetailModal renders through a PORTAL — 87 container queries had the wrong root (50 → 1) (#2735)
## One root cause behind 49 of 50 failures

`TaskDetailModal` now renders inside `FloatingWindow`, which uses
`createPortal`. Its DOM lands on `document.body`, **not** inside the
`container` that `render()` returns. So every
`container.querySelector(...)` returned `null`, and the tests died with:

```
Error: Unable to fire a "click" event - please provide a DOM element.   (29 of them)
```

That message points at the click, not the root — which is why 50
failures looked like 50 problems.

**Isolated by probe, not by reading.** I rendered the modal and printed
`container.innerHTML.length`:

```
ZZ EDITBTN: ABSENT
ZZ ROOTLEN: 0
```

`0` says *nothing rendered here*, which is a different fact from *the
button is missing* — and the two are indistinguishable from the
assertion. Before that I had gone down two wrong paths: checking whether
`.modal-edit-btn` had been renamed (it hadn't — still in the source),
then whether `canEdit` was false (it wasn't — the card is in `todo`,
which is in the legacy editable set). The probe ended the guessing.

## The fix

87 `container.querySelector` / `querySelectorAll` calls → `document`.
Correct for **both** shapes in this file: `container` is itself inside
`document`, so the tests that render `TaskDetailContent` directly keep
working, and the portaled `TaskDetailModal` renders now resolve.

Plus one assertion looking for a class the shell no longer has:
`.modal-overlay` is now only the unrelated **refine** overlay
(`TaskDetailModal.tsx:6612`); FloatingWindow's shell is
`floating-window-overlay`. The "renders immediately" case now asserts
`.task-detail-modal` — the modal's own class, which is what that test is
about.

## Measured

| | before | after |
|---|---:|---:|
| this file | 50 failed | **1 failed** / 97 passed |
| `components-b` group | 66 failed / 6 files | **17 failed / 6 files** |

**The same six files fail before and after**, so nothing regressed. Lint
clean, dashboard app `tsc` clean.

## Scoped deliberately, not applied blanket

The other five files in this group fail from unrelated causes — CSS
`toHaveClass` / computed-colour assertions and a missing
`wf-add-step-modal` testid. `TaskCard.test.tsx` has **280**
`container.querySelector` uses and only **2** failures: it is not
portaled, so applying this pattern there would have been a 280-line
change fixing nothing. The pattern went only where the portal is.

## Still failing — flagged, not forced green

`keeps disabled githubTracking state sticky across follow-up sparse task
prop updates` cannot find the *"Enable GitHub tracking"* checkbox. Its
card is `in-progress`, which **is** in
`GITHUB_TRACKING_EDITABLE_COLUMNS`, so `showGithubTrackingSection`
(`TaskDetailModal.tsx:1739`) is gated by something else — gitlab
tracking state, the active tab, or edit mode.

That is a question about *when the affordance is shown*, not a
query-root bug. Making it pass would mean deciding that behaviour inside
a mechanical repair, so it is left for a separate look.

## Context for the remaining dashboard reds

Per #2732, these groups are currently **never executed in CI** — the
shard aborts on the first failing package
(`ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL`), so everything scheduled after it
is skipped. This PR takes the largest single cluster out before those
lanes start running for real.
2026-07-30 05:52:26 -07:00
gsxdsm
b6b28c3b0b fleet: task-age-staleness 4 → 0 + the claim guard — an agent could claim a finished card, and no card was ever 'stale' (#2746)
Two core clusters. 6 converted, 2 flagged.

## Two silent failures

**No card was ever stale.** `task-age-staleness.ts` applies its signal
only to the mid-flight and review lanes — a card in a hold or terminal
lane is waiting or finished, not stale. Both lanes were named by id, so
on a renamed board the signal returned `undefined` for **every** card
and the stale-card warning never appeared anywhere on the board.

**An agent could claim a finished card.** `claimTaskForAgent`'s terminal
guard was `column === "done" || column === "archived"`. On a renamed
board neither matched, so the claim **succeeded** and the agent began
work on completed output.

## The threshold selectors are a separate literal, and half-converting
is worse than neither

`task-age-staleness` has two independent uses of `in-progress`: the
**lane gate** that decides whether the signal applies, and the
**threshold selectors** that pick which warning/critical numbers to
measure against.

Converting only the gate admits a renamed-WIP card and then measures it
against the **review** threshold — a wrong number, silently. Both are
converted, and each is revert-proofed on its own:

| reverted | result |
|---|---|
| the lane gate | 3 of the new cases fail (`expected undefined to be
defined`) |
| the threshold selectors | the threshold case fails — a renamed WIP
card gets the review threshold |

No new seam for either: the staleness signal already took a `context`
object, and its one production caller (`task-store/reads.ts`) already
holds a **per-pass IR cache** for precisely this kind of resolution.

## Cost stated rather than hidden

The `reads.ts` resolution is **unconditional**, where the hold-column
read directly beside it is gated on `task.paused`. That asymmetry is
deliberate: the lanes this needs are exactly what decides whether the
signal applies at all, so there is no cheaper gate available ahead of
it. With the shared per-pass cache that is a struct build per card, not
an IR read.

## Flagged and left counted

`formatCurrentTaskLine` is a pure formatter over `Pick<Task, "column">`
whose output **prints** the column name for a human reader — same class
as `github-tracking-comments.ts:165`. It also degrades gracefully: the
"(not active — X)" wording is lost on a renamed board, but "(X)" is
still accurate, just less specific. Threading a resolution into a string
builder to pick a word is the wrong trade.

## The recurring blind spot, fourth time

**None of the 12 existing staleness cases could have caught this** —
`lifecycle` is optional and they all omit it, so they assert the legacy
fallback. Same for the reconciler's 33 (#2737) and `TaskReviewTab`'s 45
(#2744).

This is now a consistent property of the optional-flags seam: **the
existing suite stays green through the conversion and through a broken
one.** Every file in this program needs at least one case that supplies
flags, or the conversion is untested in both directions. Worth making an
explicit review criterion rather than something each worker rediscovers.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **31 passed** across
staleness / routing-policy / dispatch suites · core `tsc` clean · `pnpm
lint` clean · census `--strict` exits 0.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:52:01 -07:00
gsxdsm
2fe82cb644 feat(core): resolveReviewColumns — the shared answer four consumers each invented separately (#2730)
## The pattern

`resolveLifecycleColumns().review` is a **single id derived from one
flag** (`mergeOrchestration`). The domain is not that shape — a lane can
host human review without orchestrating a merge, and a board may declare
more than one review lane.

So every consumer asking *"is this card in review"* re-derived its own
answer, and they drifted:

| PR | surface | how it diverged |
|---|---|---|
| #2713 | routes | terminal columns needed membership; fixed there only
|
| #2722 | notifier | a `humanReview`-only lane resolved to nothing — the
operator's review notification **never fired**, silently |
| #2723 | routes | the union was broader than core's single id |
| #2728 | CLI | `fn task retry` refused a card `POST /tasks/:id/retry`
accepted — the same operator action answering differently per surface |

Four files, four patches, **two of them mine** — plus a fifth site
inside #2722 that my own first pass missed, which is exactly the
enumeration failure I had been pointing out in other people's PRs. Each
recurrence cost a review round, and the next consumer would have
invented a fifth answer.

## The change

```ts
export function resolveReviewColumns(ir: WorkflowIr): string[]
```

**Additive on purpose.** `.review` is untouched, so nothing that reads
it changes behaviour. This is the *missing* helper, not a reshaping of
the existing one — `.review` stays correct for its own question ("which
single lane hosts the merge gate"); this answers the other one ("is this
card **already** in a review lane").

**Monotonic**, which #2723's review round argued about at length: a lane
carrying **both** `humanReview` and `mergeOrchestration` is included.
Adding a trait must never *remove* a lane from this set, or a card stops
counting as in review because its column gained an unrelated capability.

**Returns empty rather than defaulting to `in-review`.** The legacy
fallback belongs to the caller, which knows whether refusing or
admitting is the safe direction for its own guard — the notifier and the
retry gate fail in opposite directions, and baking one choice in here
would make one of them wrong.

## Verification

Six tests, both directions: human-review-only lane, multiple lanes,
monotonicity, de-duplication, empty result, and agreement with the
shipped coding workflow. One asserts the divergence directly —
`resolveReviewColumns` finds the lane while `.review` returns
`undefined`.

**Mutation: dropping the two extra flags fails 2 of 26.**

26 core trait tests green · core `tsc` clean · lint clean · gate green
(487 + 158 + 10 + 71). **No census movement** — this adds capability and
converts nothing.

## Follow-up, not done here

Migrating the four consumers onto it. Each is an open PR with its own
review thread, and switching them under their authors mid-flight would
be worse than letting them adopt it. The helper is the prerequisite;
adoption is theirs.
2026-07-30 05:40:54 -07:00
gsxdsm
2a293ee1e0 fleet: TaskDetailModal.tsx 30 -> 7 (#2698)
## Census before / after

| | before | after |
|---|---:|---:|
| `TaskDetailModal.tsx` | **30** | **7** |
| repo backlog | 721 | **698** |

Backlog dropped by **23** — the converted count, nothing else moved.

**Not 30 → 0.** The seven survivors are enumerated below with reasons
rather than absorbed into the number.

## Converted (23)

Four role bindings declared immediately after `workflowMoveMetadata` —
their source — and 20 in-component comparisons collapsed onto them.

Two module-scope helpers (`resolveDefaultTab`,
`requiresExecutionModeReplan`) take a bare column id with no flags in
scope, so they use the fallback-only form. That is **centralisation, not
trait resolution**, and each is labelled as such at the site so it stays
greppable as "still needs its flags threaded".

## Not converted (7), each with a reason

| count | site | why |
|---:|---|---|
| 2 | `showNearDuplicateWarning` (~881) | Sits **above**
`workflowMoveMetadata`, so referencing the role bindings is a
temporal-dead-zone error. Needs the state declaration hoisted —
behaviour-safe, but it reorders hooks in a 6500-line component, which is
not a vocabulary edit. |
| 2 | two `useEffect` dep arrays (~930, ~936) | Same problem, subtler:
the callback *bodies* could reference the bindings, but a **dep array is
evaluated eagerly** at the hook call, which is above the declaration.
Same hoist. |
| 2 | `overlapBlockerTask.column` (~3559) | A **different task's**
column. The modal holds flags for its own card only; resolving the
blocker's role means fetching its flags. Out of scope. |
| 1 | `session.agentState === "done"` (~353) | An **agent state, not a
column**. Already reclassified in #2692 — this file drops to 6 counted
when that lands. |

## Late-arriving flags — checked, not assumed

`currentColumnFlags` is `null` until the workflow fetch resolves, so
every role here flips after first paint. That is the hazard that
produced four stale memos in `TaskCard` (#2688 review), so I ran an AST
pass over every `useMemo` / `useEffect` / `useCallback` in the file.

**None needed a new dependency** — the 20 converted sites are all
render-path expressions rather than memoised closures. Worth stating
explicitly, because "no dep changes" in a conversion PR usually means
nobody looked.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded here.
`tsc -p tsconfig.app.json` clean. `pnpm lint` clean. Targeted
`TaskDetailModal` suites pass (5/5).

Note: the full `TaskDetail*` glob exceeds a 10-minute run locally, so I
verified with the targeted suites plus typecheck rather than reporting a
number I did not measure.

No changeset: no user-visible change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:37:47 -07:00
gsxdsm
ddba730a59 fix(core): 8 reds across 4 files — incl. a real FN-8603 contract violation and a ratchet row pinning deleted code (#2725)
## Measured

Full `@fusion/core` suite: **8 failed / 6 files → 1 failed / 1 file**
(4611 passed). `pnpm typecheck` exit 0 across every package, `pnpm lint`
clean, gate **726**.

The one remaining failure is **not mine to fix** — see the last section.

## Four causes; two are product-side, not test drift

**1. A real FN-8603 contract violation.** `tool-output-budget.ts:116`
had a bare `console.warn`, breaking the rule that production diagnostics
route through the shared logger so severity markers and `FUSION_DEBUG`
gating survive. `log-severity-spam-contract` caught it exactly as
designed. Now `createLogger("tool-output-budget")`, kept at `warn` — an
invalid operator-supplied budget is a real misconfiguration, not routine
chatter.

**2. A ratchet row pinning deleted code.** The manifest pinned a `local
reattached project ${project.id}` demotion in `central-core.ts` whose
call site was deleted by `5ae6332563` ("collapse dead SQLite dual-path
code"). Verified absent from **all** of `packages/core/src`, not merely
moved. A manifest row for deleted code can only ever fail — it ratchets
nothing — so it is removed with that provenance recorded in place.

**3. An intentional settings overlap.** `agentToolOutputMaxChars` now
appears in both scopes. Admitted to the parity list because
`settings-schema.ts:462` states the intent outright: *"Project settings
participate in the existing effective-settings merge, allowing a
project-specific tool-output cap … to override global policy."* Placed
in `GLOBAL_SETTINGS_KEYS` order, as that test requires.

**4. `maxPostReviewFixes` 3 → 10 — the third file pinning the stale 3.**
Driven off the exported `DEFAULT_MAX_POST_REVIEW_FIXES` rather than a
fourth literal copy. That constant exists *because* the declaration
default and two inline `3`s had already drifted apart once; adding
another copy would guarantee a fourth drift.

## duplicate-guard: a narrow seam instead of a rebuilt mock

Its 3 failures were `Cannot read properties of undefined (reading
'projectId')` — the fake modelled the **deleted SQLite path**
(`db.prepare().all()`) and recovered the window by parsing a captured
cutoff string. It broke when the query moved to `asyncLayer` + Drizzle.

Rebuilding a Drizzle chain to recover a number the policy already
returns would be mock-the-world for no gain, so the window policy is now
one exported pure function — `resolveFingerprintWindowMs`, the
**byte-identical** expression — that both the store query and the tests
call. Two side benefits: the ±5s timing tolerance is gone (exact
assertions), and the `Math.max(1, …)` floor now has coverage the old
cutoff-parsing shape could not see.

**Load-bearing, verified by mutation:** restoring the old 5-minute
ceiling fails 3 of them; deleting the floor fails the new case.

## The remaining failure is a deliberately-deferred product decision

`agent-logs-and-monitor.pg.test.ts > aggregateActivityAnalytics …`
expects funnel stage `todo` count 2 and gets 0. This is **already
diagnosed and deferred by another worker**, in
`activity-analytics.ts:604`:

> *"The merged column landing in `triage` while the `todo` stage stays
empty is a SEPARATE and larger question — it makes the funnel show a
phantom 100% drop between Triage and Todo on every default board since
U11 — and it is deliberately not settled here. Changing which stage the
Planning column reports would retroactively alter how historical
analytics read… Flagged for a product decision on PR #2669."*

The merged Planning column carries `["intake","hold","reset-on-entry"]`
and `stageForTraits` prefers the earliest stage, so `intake` wins.
Either fix — remapping the stage, or changing the expectation — silently
settles how historical analytics read. I left it alone rather than pick
a side inside a test-repair PR.

## Two "flaky" files that are NOT flaky — and I nearly mislabelled them

`pg-test-harness-template-concurrency.pg.test.ts` and
`moves-intake-only-hard-cancel.pg.test.ts` each failed in one full-suite
run and not another, which reads as flake and would have earned a
quarantine entry plus a 14-day deletion clock under the standing rule.

Measured in isolation instead:

| File | alongside other PG suites | alone |
|---|---|---|
| `pg-test-harness-template-concurrency` | fails intermittently | **4
passed, 3/3 runs** |
| `moves-intake-only-hard-cancel` | failed once | **2 passed** |

So this is **shared-PG-template contention between concurrently running
suites**, not an inherent flake in either test. Quarantining them would
have started a deletion clock on healthy coverage and hidden a real
harness-parallelism interaction. Flagged for whoever owns the PG
harness; no quarantine entry added.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved duplicate-detection window handling with consistent defaults,
limits, and minimum values.
* Invalid tool output limits now produce standardized warning messages
while preserving fallback behavior.
* Updated settings and workflow validation to accurately reflect
supported configuration defaults and scopes.

* **Tests**
* Strengthened coverage for duplicate-detection windows and
configuration parity.
* Removed an outdated logging severity expectation tied to a
no-longer-applicable message.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 05:33:27 -07:00
gsxdsm
647bac5b21 test(dashboard): lowercase header keys + the client stamp (1 red → 0) — and 88 dashboard failures CI never runs (#2732)
## The fix

`app/api/client.ts` builds request headers through a `Headers` object —
which lower-cases every key — and stamps `x-fusion-client: dashboard-ui`
on every dashboard-originated request so the server can attribute the
caller (the FN-8609 delete-attribution surface).

This test asserted `"Content-Type"`, and `expect.objectContaining`
compares keys **case-sensitively**, so it failed on casing alone.

Rather than only lower-casing the key, it now pins the **client stamp**
too — that header is the point of the feature and nothing else in this
file covered it.

| Check | Result |
|---|---|
| `plugin-setup-api.test.ts` | 1 failed → **3 passed** |
| client stamp neutered in `client.ts` | **1 failed** / 2 passed —
load-bearing |
| `pnpm lint`, dashboard app `tsc` | clean |

## Method correction — I nearly filed 40 phantom failures

Measuring this package with a raw `vitest run` reports **~40 failing
files**. That number is worthless: `@fusion/dashboard`'s own `test`
script is `node scripts/run-quality-tests.mjs`, which runs the quality
projects as **separate invocations** with per-group heap sizes and
exclusions. Running every project in one process fails en masse for
reasons unrelated to the code.

Correct command — `pnpm --filter @fusion/dashboard test` — gives **9
failing files / 88 failing tests**, exit 1.

## The finding: those 88 failures are never executed in CI

I first concluded "CI shows no dashboard failures, so these are
local-only." **That was wrong, and the reason matters.**

CI does schedule the dashboard quality groups — they are distributed
across all four shards as individual `test:quality:*` invocations. Shard
2's plan, for example:

```
[ci-test-shard] shard 2/4: @fusion/core [2/2], @fusion/dashboard run test:quality:app:components-b,
                @fusion/dashboard run test:quality:app:backfill-1, ...
```

But only two invocations ever get a `(watchdog budget 1500s)` start
line: the plugins group and `@fusion/core [2/2]`. `components-b` never
starts, because the shard aborts on the first failing package —
`ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL`, present in shards 1, 2 and 4.

**So the dashboard quality groups are not passing — they are unrun**,
behind a package that fails first. Consequences:

1. **Fixing core/engine/CLI will unmask 88 dashboard failures.** My
merged PRs move shards 1/2/4 toward green; as each earlier package stops
failing, these groups begin executing for the first time. Expect the
shard counts to *rise* before they fall — that is progress, not
regression.
2. **Reading shard conclusions is misleading.** A shard says "core
failed"; it does not say "and everything scheduled after core never
ran."

Where the 88 live (all files currently unowned):

| File | Failures | Lane / shard |
|---|---:|---|
| `TaskDetailModal.inline-editing-and-integrations` | 50 |
`components-b` / shard 2 |
| `auto-merge-toggle-blank.mobile-integration` | 13 | `components-a` /
shard 3 |
| `auto-merge-toggle-blank.mobile` | 8 | `components-a` / shard 3 |
| `TaskDetailModal` | 6 | `components-b` / shard 2 |
| `SecretsView` | 4 | `components-b` / shard 2 |
| `WorkflowNodeEditor` | 3 | `components-b` / shard 2 |
| `TaskCard` + `TaskCard.badge-wrap` | 3 | `components-b` / shard 2 |
| `board-mobile` | 1 | `components-a` / shard 3 |

Dominant symptoms: `Unable to fire a "click" event - please provide a
DOM element` (29), `Unable to find an accessible element with the role
"checkbox" and name "Auto-merge"` (13), `expected null to be truthy` (7)
— consistent with a small number of shared render/affordance causes
rather than 88 independent bugs, but I have not isolated them.

**I am not starting that repair in this PR.** It is a 9-file, 88-test
area needing per-cluster diagnosis, and bundling it behind a one-line
header fix would produce exactly the shallow work this program keeps
rejecting. Filed here with the correct measurement command, the
lane/shard mapping, and the reason CI has been silent about it.
2026-07-30 05:33:00 -07:00
gsxdsm
8f2cddc5fd fleet: update-task-deps.ts 7 → 0 — settles "dependency satisfied", and the store was writing a column U11 deleted (#2720)
**Claim announced on #2714 before starting.**
`packages/core/src/task-store/update-task-deps.ts` — **7 → 0**.

This one settles an open question and turned up a **live bug on the
shipped default board**, not just on renamed ones.

## 1. What "dependency satisfied" means — settled, not guessed

I flagged this in three files rather than swapping it three times
independently (`executor.ts:12325`,
`register-task-workflow-routes.ts:3995`, here). The answer has to be the
same everywhere or the scheduler and the store disagree about which
cards are blocked. Settled in the store, where `blockedBy` is actually
written:

- **SATISFIED** = the dependency's own board's **complete** or
**archived** column. Archived counts: it is finished work the operator
filed away, and reading it as unsatisfied blocks every dependent forever
with no recourse short of editing the graph.
- **NOT review** — a card in review is not done; its branch has not
landed.
- **Unioned with the legacy ids**, because a row can outlive the column
it is stored in.

On a renamed board the old literals matched nothing, so **every**
dependency read as unresolved and `blockedBy` was pinned to the first
one permanently — dependents never unblocked after the work landed.

## 2. The re-specification move was writing a DELETED column — on the
default board

`hasNewDependencies && column === "todo"` set `column = "triage"`. **U11
(#2515) deleted `triage`**, keeping `todo` as the merged Planning
column. Measured, not assumed:

```
resolveDefaultWorkflowIr() columns:
  todo[intake,hold,reset-on-entry]  in-progress[wip,…]  in-review[merge,…]  done[complete]  archived[archived]
```

So the store has been writing a column the shipped board does not
declare. And the emitted event hardcoded `from: "todo", to: "triage"` —
**`task:moved` is what the GitHub tracking poster, the auto-merge
handoff and the executor's listeners react to**, so every subscriber was
being told about a column that does not exist.

Now the guard reads the hold lane, the target is the intake lane, the
log line names the real column, and when intake === hold (the default
lineage post-U11) there is **no move and no event** — announcing a move
into the column the card already occupies re-runs reset-on-entry effects
in every listener.

## 3. Two existing suites taught me more than the conversion did

**`refine-duplicate-task.pg.test.ts` proved the union is required, in
one run.** My first version compared only the resolved lanes and refused
a row sitting in `done` on a board declaring `published`: *"Cannot
refine KB-001: task is in 'done', must be in 'published' or
'editorial-review'"*. That row is real, and refusing an operator action
on it is worse than accepting one extra column name. Over-inclusion is
the safe direction for "may I refine this?" — the same reasoning as the
executor's `resolveTerminalColumnsFor`.

**Two assertions expected `"triage"`.** They were not protecting
behaviour; they were protecting a stale literal that outlived its
column. Updated **with the measurement in the file**, because a
silently-changed expectation is indistinguishable from a broken one.

## Revert proof

1 of 3 new PostgreSQL cases reddens when the union is removed. Driven
through the real store on PostgreSQL because `blockedBy` is persisted
and resolution reads the workflow selection from the database — a mocked
store would prove neither.

## Verification

`pnpm test:gate` **487 / 71** · 13/13 in the two pre-existing
dependency/refine suites · 3/3 new · `tsc -p packages/core` clean ·
`pnpm lint` clean · census `--strict` exit 0 (**7 → 0** for this file).

Changeset: none. `@fusion/core` is private, and while item 2 is an
operator-visible fix on the default board, it lands as internal
behaviour with no API change — say the word if you want one anyway for
the release notes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:29:36 -07:00
gsxdsm
5791dfeeb7 fix(dashboard): the routes and the engine disagreed about what "review" is — a converted guard that still contradicts core (#2723)
Built **on top of #2713**, which converted this file to trait-resolved
membership sets while my #2701 was open on the same file. Their design
is better than the single-id resolver I had — the arity argument in
their comment (a single id answers "where should this card GO", a SET
answers "is this card ALREADY there") is correct, and I have closed
#2701 rather than contest it.

Two things survive that #2713 did not cover.

## 1. The routes and the engine disagreed about what "review" IS

Core's `resolveLifecycleColumns().review` — the answer the **executor**,
the **merger** and **project-engine** all act on — resolves review from
`mergeOrchestration`. This file's resolver looked only at `mergeBlocker`
/ `humanReview`.

The default lineage hides it, because `in-review` carries all three:

```
in-review[merge-blocker, human-review, stall-detection, merge]
```

A board that declares only `merge` on its review lane resolved as
**review in the engine** and **not review in the routes**. The executor
treats the card as in review; comment re-engagement, the retry gate and
branch-binding recovery all refuse it.

**That is the same defect class as a literal, one level up:** the guard
is converted, it reads a real trait, and it still disagrees with the
authority. Unioning all three makes this resolver a **superset** of
core's, so the routes cannot refuse a card the engine considers in
review.

How I found it is worth stating: my renamed-board fixture declares only
`merge` on its review lane, and the ported suite failed against main. I
nearly "fixed" the fixture to add `mergeBlocker` — which would have
papered over a real cross-layer inconsistency to make my own test pass.

## 2. The 400s name the board's own columns

#2713 converted both gates but left the messages saying `in-review` /
`in-progress`. **Being told your card must be in a column that does not
exist is worse than a wrong guard** — a wrong guard is a bug report; a
wrong column name sends the operator looking for something that was
deleted.

## Tests

7 cases ported from #2701 and adapted to the membership-set shape. **3
red on revert** of the `mergeOrchestration` union.

## A pre-existing failure I am reporting, not folding in

`register-task-workflow-routes.move-bypassguards.test.ts` **fails on
`origin/main`** — 400 where it expects 200. Verified by stashing my
change and re-running, so it is not mine. Someone owns that regression;
it should not ride in on a lane-consistency PR.

## Verification

`pnpm test:gate` **10 / 71** · the ten other
`register-task-workflow-routes` suites pass · 7/7 new · `tsc -p
packages/dashboard` clean · `pnpm lint` clean · census `--strict` exit
0, no count change (this PR converts nothing new — it makes an
already-converted resolver agree with core).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:25:28 -07:00
gsxdsm
277a034e4b test(engine): two files missed the logger-mock debug sweep (38 red → 0) + a deleted API still pinned (#2716)
## What was red

| File | Failures | Error |
|---|---:|---|
| `notification/__tests__/notification-service.test.ts` | 26 |
`schedulerLog.debug is not a function` |
| `runtimes/__tests__/child-process-worker.test.ts` | 12 |
`runtimeLog.debug is not a function` |

`debug` is part of the logger surface (`logger.ts:25`) — the channel the
noisy-line demotion moved subsystem chatter onto, gated on
`FUSION_DEBUG`. A mock that omits it throws on the **first** demoted
call, failing every case in the file for a reason unrelated to what any
of them assert. These two were missed by the earlier sweep across 27
engine files.

## Measured

| Check | Result |
|---|---|
| the two files | 38 failed → **38 passed** |
| `debug` removed from the mocks again | **38 failed** — the entries are
load-bearing |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint` | clean |

Census unchanged — test files only.

## Two real drifts under the mock gap

Both re-pointed at what the product actually does, not relaxed:

**1. Suppression lines are `debug`, not `log`.**
`notification-service.ts` routes all five of its `"suppressed ..."`
messages through `schedulerLog.debug`. Two assertions looked on `.log` —
where the product no longer writes. Verified by grepping the product for
the message before editing the test, rather than assuming the mock was
the whole story.

**2. `centralCore.getGlobalConcurrencyState` is DELETED, not missing.**
The cross-project cap was dropped deliberately (`central-core.ts:2124`,
`FNXC:CapacityModel 2026-07-28-23:30`) together with
`updateGlobalConcurrency`, `acquireGlobalSlot`, `releaseGlobalSlot` and
the `concurrency:changed` event — capacity is two numbers **per
project** now. That comment also records the slot pair was already dead:
no production caller ever invoked it, so `currentlyActive` was never
incremented by real work.

The worker's stub provides `getLiveRunningAgentCounts` and
`recordTaskCompletion`, so the case is re-pinned to the former. It had
been pinning an API the product removed on purpose — the assertion would
have kept "passing" a shape that no longer exists if the stub had
happened to retain a same-named field.

## Flagged, deliberately not changed

`ipc/__tests__/ipc-host.test.ts` and `ipc/__tests__/ipc-worker.test.ts`
carry the **same incomplete logger mock** but are currently green (56
passed) because no demoted line is reached on their paths.

Adding `debug` there cannot be shown to fail today, so it is recorded
here rather than slipped in as an unfalsifiable edit. They go red the
moment any code they exercise demotes a line — which is how the 29 files
before them broke. Found by scanning every engine logger mock for the
pattern, not by guessing.
2026-07-30 05:18:19 -07:00
gsxdsm
5e1f1df4e3 docs(dashboard): correct a false "deleted column" claim that shipped in #2726 (#2727)
Comment-only correction. **#2726 (mine) landed a factually wrong FNXC
note in main** and this retracts it at the site.

## The false claim

#2726's note — and its PR title — asserted a **live stale-target bug**:
that `triage` is a column U11/#2515 deleted, so TaskCard's in-review
move menu pushes `"Move to triage"` for a column that no longer exists.

It is not deleted:

```
packages/core/src/builtin-coding-workflow-ir.ts:49
  { id: "triage", name: "Planning", traits: [{ trait: "intake" }] },
```

`triage` (intake) and `todo` (hold) are still **separate columns** on
the default board, and `triage` also exists in `builtin-pr` and
`builtin-lead-generation`. I was carrying a merged-planning-column shape
from other work in this program and asserted it against the tree without
reading the IR. **There is no stale-target bug.**

I found this while preparing to report the "bug" as a review comment on
#2688 — checking the builtins before filing is the only reason it did
not propagate into a second PR. It had already shipped by then, which is
why this is a follow-up rather than an edit.

## What the note says now

What is actually true of the site: `column` is the **loop variable over
the function's own hardcoded `["done", "triage"]` array**, so the
comparison picks a label from a list this code just wrote itself.
Resolving a trait for that string would be meaningless. This is the same
reading #2688 arrived at independently, and it is the correct one.

The **array** is a real open question — it names move targets by id
rather than by role, so a workflow that renames those lanes gets targets
it cannot show. That needs a Surface Enumeration per AGENTS (removing or
changing a visible menu entry), so it stays flagged rather than fixed. A
real question, unlike the one the old comment invented.

## Why a whole PR for a comment

The FNXC convention exists so the *reasons* in this codebase stay
trustworthy. A note that names a specific PR as having deleted a column,
and a specific menu as broken, is exactly the kind of thing a future
reader acts on — the cost of leaving it is someone "fixing" a working
affordance, or discovering the note is wrong and trusting the
surrounding notes less.

Also worth recording: **#2726 duplicated #2688**, which claimed
TaskCard.tsx first (09:41Z) and took it to 42 → 0. I did not check the
remote branch list before starting and mine merged first, so #2688 now
conflicts against work it predates. Wasted effort on my side, and a note
for the fleet: `git branch -r | grep fleet` before claiming.

## Verification

Comment-only — no behaviour change. Census `--strict` exits 0 (unmoved),
dashboard `tsc -p tsconfig.app.json` clean, `pnpm lint` clean.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:11:21 -07:00
gsxdsm
3b618f2530 fleet: mission-execution-loop.ts 10 → 2 (one rule, five copies; and why these converted where store.ts's look-alikes could not) (#2711)
Claiming **`packages/engine/src/mission-execution-loop.ts`** (10). Every
one of its 10 census sites is the **same rule written five times**:

```ts
linkedTask.column === "done" || linkedTask.column === "archived"
```

## Census before/after

| | before | after |
|---|---:|---:|
| `mission-execution-loop.ts` | **10** | **2** |

Converted 4 of the 5 copies (8 of 10 sites) to the complete/archived
roles via core's `resolveTaskLifecycleColumns`. Each site already had
`this.taskStore` in scope inside an async method **and already had the
linked task fetched**, so the resolution rides along with a read that
was happening anyway.

## Why these converted where `store.ts`'s look-alikes could not (#2709)

Both read **another task's** column. The difference is not whose column
it is:

- **Here** — async methods, store at hand, one task per call. A
resolution is already affordable.
- **`store.ts`** — synchronous `filter` callbacks over a prefetched
`taskById` map, where per-dep resolution means N awaits inside a sync
predicate on a path that prefetches precisely to avoid per-item I/O.

Same-looking code, opposite verdicts. The distinguishing question is
**"is a resolution already affordable here"**, not "whose column is it"
— worth stating because a fleet worker pattern-matching on the receiver
alone would get both wrong.

## Not deduped, deliberately

The right shape is one predicate used five times rather than five inline
copies — and the FNXC comments above each copy show their intent has
already drifted apart. But introducing that predicate is a **new
abstraction**, which the fleet rules exclude, and it would fold five
reviewable substitutions into one design change. Flagged as the obvious
follow-up instead of smuggled in.

## Remaining: 2

The fifth copy, at the `hasLiveFixTask` site, where the terminal check
is one clause of a longer `Boolean(...)` expression whose other clauses
I would have had to reflow. Reviewability, not difficulty.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · the four mission
suites **150/150** · `pnpm lint` clean · engine `tsc` clean · `--strict`
exits 0.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:08:10 -07:00
gsxdsm
03443297db fix(census): re-record the stale baseline — and the ratchet does NOT fail on a stale allowance (#2712)
## The immediate hole

Main's baseline allowed **27** guards in
`packages/engine/src/scheduler.ts` while the tree has **26**. A
re-introduced guard there would have kept `check:lifecycle-columns`
green.

This is the merge-order collision I flagged on #2693: **#2690 and #2693
each converted a different `scheduler.ts` site and each recorded 28 →
27.** After both merged the true count is 26, and neither re-recorded
it. Predicted in #2693's body; this is the cleanup.

## The more important finding: `--strict` reports the stale allowance
and exits 0

```
$ node scripts/lifecycle-column-census.mjs --strict
  packages/engine/src/scheduler.ts: allows 27, tree has 26
$ echo $?
0
```

So the required check is **green while the hole is open** — the script
detects the condition and does not enforce it. That is precisely the
failure mode its own message warns about:

> *"A stale allowance is a hole: those guards can be reintroduced later
and this check stays green."*

**I did not flip it.** Making `--strict` fail is a CI-policy change that
would block every PR until each stale baseline is re-recorded — and that
exact situation just blocked the queue (three PRs, #2673/#2674/#2676,
raced to un-red this check). Reversible-by-me stops short of "block
everyone's merges", so it is flagged for an owner with the reproduction
above.

Related, and worth knowing before anyone debugs a dirty tree:
**`--strict` writes the baseline as a side effect of the check.** A
plain verification run mutates
`scripts/lib/lifecycle-column-census-baseline.json`. That is how this
re-record was produced, and it is why an earlier PR of mine had to
revert an unintended baseline edit.

## What is in the diff

Re-recorded from the tree and verified against the live census before
committing:

| | baseline | tree |
|---|---:|---:|
| `column` total | 693 | **692** |
| `in-progress` | 136 | **135** |
| `scheduler.ts` | 27 | **26** |
| `deliberate` | 17 | **20** |

**The `deliberate` movement is not mine.** `RoutineEditor.tsx`,
`ScheduleForm.tsx` and `ScheduleStepsEditor.tsx` each carry a real
`DELIBERATE-LITERAL` marker in the tree (verified by grep, not inferred
from the diff), and three `deliberateByFile` keys gain their `triage`
scope. Those came from merged PRs that likewise did not re-record. This
commit records the tree's actual state; it does not endorse those
markers, and anyone auditing the deliberate list should look at those
three rather than assume they were reviewed here.

`--strict` now reports *"every file matches its baseline exactly"*.

## Verification

Baseline-only change — no source, no tests. `--strict` clean; census
reads COLUMN 692 · ROLE 5 · STATUS 186 · DELIBERATE 20 · QUERY 83.
2026-07-30 05:07:58 -07:00
gsxdsm
8fb7c933d5 docs(core): pin the LifecycleColumns arity contract — two shipped bugs came from it (#2721)
Found by **auditing for the pattern** after hitting it twice, rather
than waiting for a third instance.

## The contract, previously undocumented

`LifecycleColumns` names **one** column per role even when a workflow
declares several. Nothing validates that a trait appears at most once —
`columnsWithFlag` returns an array and `resolveLifecycleColumns` takes
its head. So a workflow may legitimately declare two intake lanes, or
split `mergeBlocker` and `humanReview` across a merge lane and a
separate sign-off lane, and the struct names only one of each.

That makes the fields safe for one question and unsafe for another:

| | question | correct? |
|---|---|---|
| **safe** | *"where should this card **go**?"* | a move target must be
exactly one column |
| **unsafe** | *"is this card **already** there?"* | membership — use
`columnsWithFlag(...).includes(...)` |

## Two shipped bugs, both the unsafe use

Both in #2713, both reading like ordinary conversions:

- a task in a **second terminal column** was rejected with a 409
- a task in a **human-review lane split from the merge lane** was
classified as outside review entirely, suppressing comment re-engagement

I fixed the first, did not generalise, and hit the second one review
round later. That is the actual failure mode this PR addresses — not the
individual bugs, which are already fixed.

## Two call sites left for their owners

Named in the doc comment rather than changed here, since they belong to
other clusters. Both are correct **only while their workflow declares
one column per trait**:

- `packages/engine/src/self-healing.ts` — `columns.intake` /
`columns.hold`
- `packages/core/src/builtin-workflows.ts` — `lifecycle.intake`

## The test asserts the contrast, not just the arity

A reader who sees only *"it returns one id"* learns the wrong lesson.
The test builds a workflow with **two intake and two complete columns**
and shows that exactly one qualifying column **fails an equality check**
against the struct while the **membership form gets it right** — the
shipped-bug shape, reproduced in miniature.

## Verification

`workflow-lifecycle-traits.test.ts` **20 → 22**. `pnpm test:gate` green
(10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `tsc -p
packages/core/tsconfig.json` clean. `pnpm lint` clean.

Docs + test only — no behaviour change, no census movement, no
changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:00:26 -07:00
gsxdsm
e46cc7f1be fleet: TaskCard.tsx 42 → 3 — the flags were already in scope, asked 39 times by id anyway (plus a live 'Move to triage' on a board with no triage) (#2726)
Claiming **TaskCard.tsx**, the largest app-side cluster in the census.
39 convert; 3 are flagged and left **counted**, one of them a live bug.

## Census

| | before | after |
|---|---:|---:|
| `TaskCard.tsx` | **42** | **3** |

Baseline shrinks by exactly the 39 converted.

## The shape of it

`taskColumnFlags` was **already threaded into this component** and
already consumed by `canEdit` and `isTaskAgentActive` — but the terminal
/ mid-flight / review questions were still answered by comparing
`task.column` to a literal, **39 times in one component**. That is how a
card ends up rendering as live work by one question and terminal by the
next on the same board.

Four booleans now resolve once, beside the existing intake/hold pair and
before the first `useState` that reads them:

```ts
const isWipColumn = isWipColumnRole(taskColumnFlags, task.column);
const isReviewColumn = isReviewColumnRole(taskColumnFlags, task.column);
const isCompleteColumn = isCompleteColumnRole(taskColumnFlags, task.column);
const isArchivedColumn = isArchivedColumnRole(taskColumnFlags, task.column);
```

Flags-first with the legacy id as the documented no-metadata fallback —
identical in shape to the intake/hold pair directly above. No new data
flow, no new abstraction.

## A live bug, flagged rather than converted

The in-review card menu pushes move targets:

```ts
for (const column of ["done", "triage"] as const) {
```

**`triage` is the column #2515/U11 deleted** when it merged intake and
hold into a single `todo` lane. On a post-U11 board this pushes a "Move
to triage" entry for a column that does not exist, and
`taskActionColumnLabel("triage")` labels a target the board cannot show.

Converting it to a role would have been the *worst* outcome — it would
have **hidden the staleness** by resolving the dead target to a live
column. Removing a visible menu entry is exactly the UI-affordance
change AGENTS requires a Surface Enumeration for (the workflow-row
chevron took FN-6115 → FN-6118 → FN-6123 for skipping it), and what it
should offer instead is a product call. Recorded at the site with the
cause.

The other two flagged: `getInReviewCompletionMs` is module-scope with
only a `Task` and no flags to consult (same class as
`project-engine.ts:2555` and `github-tracking-comments.ts:165`), and the
`isHoldColumn` fallback arm, which *is* the degraded answer.

## Revert proof — both directions, because only one is reachable by
renaming

| reverted | result |
|---|---|
| `task.column === "done"` on the archive guard | Archive **appears** on
a mid-flight card: `expect(element).not.toBeInTheDocument()` |
| same | Archive **missing** on a renamed complete lane: `Unable to find
… name "Archive"` |

The pure-rename direction is only half the property. The other half —
traits say mid-flight, column still *named* `done` — is what an
unconditional id comparison actually gets wrong, and it is reachable by
repurposing a default column rather than renaming one.

## Two process findings

**1. `git stash` is shared across worktrees, and a concurrent worker's
stash cost me this cluster once.** I stashed to measure a baseline, and
between my push and my `git stash pop` another agent working in a
different worktree of this repo pushed a stash — so `pop` (which is
positional) applied **their** `self-healing.ts` changes into my tree and
my TaskCard work vanished from it. Their entry was kept rather than
dropped, so nothing was lost; I reverted their application, left
`stash@{0}` untouched, and recovered mine with `git stash apply <sha>`.
**Positional stash refs are unsafe in this repo** — the stack is in the
common git dir, so every worktree shares it. Use an explicit SHA.

**2. Running `--strict` regenerated 26 lines, not 1.** The writer
deliberately omits the derived aggregate blocks (my own earlier change,
to stop every fleet PR conflicting on the same totals lines) but main's
baseline still carries them from an older write — so a regeneration here
would have silently deleted `totals`, `byColumnId` and `properties` as a
side effect of converting one file. I hand-edited the single entry
instead, so the aggregate removal stays owned by the PR that introduced
it. Worth knowing: **`--strict` auto-rewrites and prints "COMMIT IT", so
this rides along invisibly** for anyone who does.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **684 passed** across
TaskCard / role-invariance / workflow-resolved-columns / ListView /
columnRoles · dashboard `tsc -p tsconfig.app.json` clean · `pnpm lint`
clean · census `--strict` exits 0.

The **3 `TaskCard` failures are pre-existing** — verified twice against
a stashed clean `origin/main`, same three names. They assert CSS-var
geometry (`expected '0' to be 'var(--space-xs) var(--space-sm)'`) and
are untouched by this change.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:44:46 -07:00
gsxdsm
39523403c7 test(engine): isolate the fast-mode red — v1 seam→column normalization skips start (1 → 0) + an unanswered pause-guard question (#2719)
## The fast-mode failure, finally isolated

```
expected [ 'review' ] to deeply equal [ 'start', 'review' ]
```

This resisted **three earlier diagnosis attempts** because nothing about
the assertion, the fast-mode flag, or the node kind points at the real
mechanism: **column normalization of a v1 IR.**

**Isolated by mutation, not by reading.** Swapping the node's `config: {
seam: "review" }` for the sibling test's `config: { executor: "skill",
... }` makes `start` reappear in `visitedNodeIds`. So `config.seam` is
the trigger — which is not a thing the failure text suggests looking at.

The chain:

1. v1 normalization places nodes into synthesized default columns **by
seam** (`workflow-ir.ts:150` — `review` → `in-review`, seam-less →
`todo`).
2. The card rests in `in-progress`, which this three-node graph has **no
node for**.
3. `resolveColumnResumeNode` (`workflow-graph-executor.ts:473`)
therefore resumes at the next node **forward** — the review node —
instead of re-entering at `start`.

That resolver's `>=` comparison is commented for exactly this case: *"a
card can rest in a column the pipeline has no node for … and must then
resume at the next node forward."*

**So the product is right and the expectation was stale.** Visited is
`["review"]`. `pnpm test:gate` **726 passed**, lint clean, census
unchanged.

I also recorded in-file *why the sibling skill-executor case
legitimately still expects `start`*: its seam-less node normalizes into
`todo`, which is **behind** `in-progress`, so there is no forward match
and entry falls back to `start`. That contrast is an accident of config
rather than a deliberate difference — so the two expectations must
**not** be "aligned", which is the obvious-looking wrong move for the
next person here.

## Flagged, NOT fixed: `executor-prompt`'s 3 failures are a real design
question

These assert that `execute()` refuses to dispatch a user-paused row
during a global pause:

```
expect(mockedCreateFnAgent).not.toHaveBeenCalled();   // actually called 1 time
```

The test's own comment (from #2371) states the behaviour as "a paused
todo task is no longer dispatched at all". **The guard lives in the
scheduler, not in `execute()`** — `scheduler.ts:1515` is the
`globalPause` gate, and it is a hard stop that never reaches
`.execute(`. These three tests call `executor.execute(task)`
**directly**, so no guard fires.

That is not merely a test-layer mismatch, which is why I am not "fixing"
the assertions. `execute()` has **five call sites outside the
scheduler**:

- `executor.ts:3333`, `:3495`, `:5702`, `:5858` — internal re-dispatch
paths
- `runtimes/in-process-runtime.ts:2255` — `void
this.executor.execute(task)`

Whether each of those is separately gated determines whether work can be
dispatched on a user-paused row, or during a global pause, by a path
that never consults the scheduler. Two legitimate resolutions exist and
they differ in behaviour:

1. give `execute()` its own pause guard (defence-in-depth — but must not
break legitimate internal re-dispatch), or
2. retire the direct-`execute()` assertions and cover the invariant at
the scheduler layer.

Picking either silently inside a test-repair PR would either change
lifecycle behaviour around **user pause** — a safeguard this program has
re-ratified and told me not to narrow — or delete coverage of it. So it
stays flagged with the call-site evidence for whoever owns the pause
contract.

This is the same file and question I flagged much earlier in the
program; it is now backed by the specific line numbers rather than a
suspicion.
2026-07-30 04:41:06 -07:00
gsxdsm
11aba0394e docs(solutions): converting a column literal to a role makes it async — the four forms that ship green (#2710)
Four review rounds across `TaskCard.tsx` and `TaskDetailModal.tsx` each
found a **real defect**. None was in the conversion itself — every one
came from the same property change. The fleet has ~600 guards left to
convert against the same helpers, so this is written down rather than
left in four commit messages.

## The property that changes

```ts
task.column === "in-progress"   // stable for the lifetime of the render tree
isWipColumn                     // derived from fetched trait flags — CHANGES after first paint
```

Column trait flags arrive from a board-workflows fetch. Until it lands
they are `undefined` and every role helper falls back to the legacy id.
So a converted role is `false`, then `true`, within one mounted
component.

## The four forms

| | form | symptom |
|---|---|---|
| 1 | **stale memo** — deps still keyed only on `task.column` | timers,
labels, completion dates frozen at first-paint values (4 instances in
TaskCard) |
| 2 | **frozen `useState` initializer** | the section does not start
collapsed — it *appears later, already collapsed*, on a card nobody
touched |
| 3 | **eager action on a guess** — effect mutates state before flags
resolve | a tab opens and instantly bounces; the correction never lands
because the action destroyed the state it would have corrected |
| 4 | **stale identity** — flags resolved, but for the *previous* entity
| roles resolve from another task's workflow: confidently wrong rather
than merely stale |

**Form 4 defeats the obvious fix for form 3.** A `metadata === null`
guard asks whether data *loaded*, not whether it describes the entity
currently open — and it only appears in components that stay **mounted
across entity changes**, which is why TaskCard never showed it and the
modal did.

## Why a doc rather than four commit messages

All four ship **green**: types pass, existing tests pass, and the
**default board behaves identically** — because on the default lineage
the legacy fallback and the resolved role agree. They diverge only on a
**renamed board**, which is precisely the case the conversion exists to
support.

So the failure mode is: census count reaches zero, everything is green,
and the feature is broken exactly where the programme was meant to fix
it. A reviewer catching these one at a time is the expensive path, and
it has now cost four rounds on two files.

Also relevant: **this repo has no `react-hooks/exhaustive-deps` rule**,
so form 1 has no automated backstop at all.

## Contents

A checklist a converter can run against a component file, and the
concrete fix shape for each form — including tagging fetched metadata
with the id it describes, and applying that guard to the **role
bindings** rather than only the effects (reordering effects fixes the
call sites you noticed and leaves the bindings stale for everything
else).

Follows the convention already established by the engine-side scoping
note in `architecture-patterns/fleet-self-healing-cluster-scoping.md`,
which records the equivalent hazard for sync workflow reads.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0. `pnpm lint` clean.

Docs-only; no changeset.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:37:19 -07:00
gsxdsm
a59576607a test(engine): 11 reds from two intended product changes the tests still pinned (#2717)
## The 11 failures, two causes

Four files, all confirmed red on clean `origin/main`, all unowned.
Neither cause is a product defect.

**A. U11 merged the two pre-implementation columns.** `builtin:coding`,
`builtin:stepwise-coding` and `builtin:brainstorming` now declare
`todo,in-progress,in-review,done,archived` — **no `triage`**. So the
entry column is `todo`, the former `triage → todo` graph hop no longer
exists (there is no boundary to cross), and the replan rebound
(`executor.ts:4392`, via `resolveReboundColumnFor`) targets `todo`.

Verified by resolving each built-in IR and printing its column ids, not
inferred from the failure text.

**B. `maxPostReviewFixes` was raised 3 → 10** via
`DEFAULT_MAX_POST_REVIEW_FIXES` (`builtin-workflow-settings.ts:555`).
Two files still pinned 3.

| File | Before | After |
|---|---:|---:|
| `workflow-graph-optional-step-fix` | 5 failed | **39 passed** |
| `builtin-workflows-lifecycle` | 3 failed | **94 passed** |
| `agent-tools-intake-column` | 2 failed | **4 passed** |
| `workflow-settings-fallback-alignment` | 1 failed | **3 passed** |

`pnpm test:gate` **726 passed**; lint clean; census unchanged (test
files only).

## Three choices so these don't re-break on the next rename

Swapping `"triage"` for `"todo"` everywhere would have worked and been
wrong in three places:

1. **The replan log assertion no longer embeds a column id.**
`executor.ts:5366` *interpolates* the resolved column into the message,
so a literal there pins a column name inside prose — guaranteed to break
again. It now matches the message shape plus the attempt/budget counter,
while the destination column stays pinned by the `moveTask` assertion in
the same test.
2. **The budget case drives off the imported
`DEFAULT_MAX_POST_REVIEW_FIXES`.** That constant exists *because* the
declaration default and two inline literal `3`s had already drifted
apart once — its own comment says raising the declaration alone "would
have left every unset-settings path on the old value". A third copy in a
test would repeat exactly that mistake.
3. **`agent-tools-intake-column`'s two guards are RE-PINNED, not
deleted.** They hold the default workflow's landing column stable and
fired on an intended change, so they still have a job. Deleting them
removes the only check; leaving them on `triage` pinned a column that no
longer exists.

## What I deliberately did not touch

`builtin-workflows-lifecycle` has **18** expectations and only **3**
were failing. I changed only those three: the other 15 pass unchanged,
which proves those workflows genuinely still declare `triage`. A blanket
rename across the file would have broken them — the same trap that bit
me earlier in `starved-refinement`, where renaming a shared fixture
default broke a test that had been passing.

Similarly in `workflow-settings-fallback-alignment`: case (b), which
scans engine source for literal `?? <n>` fallbacks, **already passed** —
there are no literal fallbacks left for that key because the read sites
import the constant. Only the human-readable audit table had lagged.
That table is the drift detector, so updating it keeps it honest rather
than pinning a value the product abandoned.

## Still red in this area, not in this PR

`executor-fast-mode-workflows` (1) and `executor-prompt` (3) remain.
They are not column- or budget-drift; `executor-prompt`'s three call
`execute()` directly and raise a real design question (whether
`execute()` should carry its own pause guard, or whether those
direct-call assertions should retire) that I am not answering silently
in a test-repair PR.
2026-07-30 04:28:50 -07:00
gsxdsm
3577cb6adf fleet: project-engine.ts 12 → 5 — auto-merge silently declined every card on a renamed board (#2706)
**Claim announced before the work** (on #2689, alongside
`register-task-workflow-routes.ts`):
`packages/engine/src/project-engine.ts`.

**12 → 5.** Repo backlog → **685**.

## The failure mode here has no error signature

Every merge guard in this file spelled the lane `in-review`. On a
renamed board nothing throws, nothing logs a warning — **auto-merge
simply declines every card**:

| guard | what a renamed board gets |
|---|---|
| `requestInterpreterMerge` | returns `noOp: true` — *"parked cleanly in
review, awaiting human merge"* — for a card that was in review and fully
eligible |
| the merge-queue snapshot | returns an **empty list** for a queue full
of review cards, so the coordinator sees nothing to admit |
| the `taskMoved` auto-merge handoff | never fires, so nothing reaches
auto-merge in the first place |
| the pause-interruption tracker | drops every card from its
paused-review set on the next update, so a merge paused mid-flight is
never interrupted |

The operator sees cards resting in review with auto-merge **on**, and
every log line says the system did the right thing. There is no string
to search for — which is the argument for the census being a parse
rather than a grep over error messages.

## Implementation notes

- **Core's `resolveTaskLifecycleColumns` directly** — the canonical
helper, so no new abstraction and no fourth local resolver in a file
that had none.
- **The merge-queue snapshot resolves per task through a shared
`irCache`**, because a merge queue can hold cards from *different*
workflows. Per-workflow, not per-card: one IR read each.
- **The handoff and its post-grace recheck share one snapshot.** They
are halves of one decision — "did this card just enter the merge lane,
and is it still there?" — and that is exactly the split that produced
the defects in `executor.ts`.

## Revert proof

**1 of 3 cases reddens** with the literal restored.

The suite invokes the real `requestInterpreterMerge` via `.call()` on a
minimal `this` (`runtime.getTaskStore`, `allowInReviewMergeProcessing`,
`onMerge`) instead of standing up a whole `ProjectEngine` runtime. The
body under test is the shipped one, and *reaching* `onMerge` is the
assertion. I would rather explain that seam than either skip the proof
or spend the test budget booting a runtime. The default-board case is
labelled in the file as no-change evidence, not counted as coverage.

## The remaining 5, flagged not guessed

All five are `column === "done"` **merge-confirmation reads** — "did the
merge land?". That is a different question from any lane role, and it
shares its answer with the dependency guards I flagged in `executor.ts`
(`12325`) and `register-task-workflow-routes.ts` (`3995`). Three files,
one open question: **what does "landed / satisfied" mean on a board
whose terminal column is not named `done`, and is it the complete column
or the terminal union?** Deciding it once and applying it to all three
is right; swapping it three times independently is how the resolver
choice ends up inconsistent — which already happened once inside
`executor.ts`, where the correct resolver inverts between two guards a
few lines apart.

## Verification

`pnpm test:gate` **158 / 10 / 487 / 71** · **56/56** across the six
auto-merge / project-engine suites · 3/3 in the new suite · `tsc -p
packages/engine` clean · `pnpm lint` clean · census `--strict` exit 0.

No changeset: `@fusion/engine` is private, and the behaviour change is
confined to renamed boards.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:25:19 -07:00
gsxdsm
a59c6aea50 fleet: register-task-workflow-routes.ts 20 -> 5 (#2713)
## Census before / after

| | before | after |
|---|---:|---:|
| `register-task-workflow-routes.ts` | **20** | **5** |

I handed this cluster off in an earlier turn with an analysis rather
than a conversion. Coming back to it with the analysis already done made
it tractable.

## Converted with the file's own idiom

This file already had `resolveIntakeColumnForTask` /
`resolveWipColumnForTask` / `resolveReboundColumnForTask`: resolve the
column **id** from the task's workflow, fall back to the legacy id when
the IR cannot be read. I added the three roles it was missing — review,
complete, archived — in that same shape.

**Deliberately not the `columnRoles` predicate helpers** used in
`packages/dashboard/app`. Those take resolved trait *flags*, which a
route handler does not have — it has a store and a task id, and must do
an async lookup. Importing them here would mean fetching flags per
request to answer a question this file already answers a simpler way.
One idiom per layer.

Every converted handler **resolves once and reuses**, so two checks in
the same request cannot disagree about which column is the review lane.
The retry handler had three separate review checks and now shares one
resolution.

## Also fixes an inversion

`isArchived` ORed the legacy id with the resolved trait
**unconditionally**, so a column merely *named* `archived` counted as
archived even when its own workflow said otherwise. Same pattern
previously found in `TaskContextMenu` and `isPreExecutionHoldColumn`.
Now flags-first, id as fallback.

## The five survivors, each with a reason

| count | line | why |
|---:|---|---|
| 1 | 1193 | `tasks.filter(t => t.column === "todo")` — a **list** path.
Per-task IR resolution is N store reads on board load; the site already
carries a note measuring that cost. Needs the hold column resolved per
*workflow* from the board payload — a real change, not a rename. |
| 1 | 1926 | **Already flags-first**: `moveTargetIr && declaresColumns ?
columnHasFlag(...) : column === "in-progress"`. The literal *is* the
documented no-IR fallback. |
| 1 | 4872 | The fallback arm of the `isArchived` fix above — same
shape, deliberately kept. |
| 2 | 4024 | `depTask.column` — a **dependency's** column, not the
task's. Resolving it means fetching that task's IR per dependency; out
of scope. |

So three of the five are correct as they stand, and two are genuinely
deferred.

## Verification

`plan-approval-intake-column` + `stranded-refinements-routes` 13/13.
`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded here.
`tsc -p packages/dashboard/tsconfig.json` clean. `pnpm lint` clean.

Typecheck was run after **every** batch rather than once at the end —
with 15 edits across async handlers in a 6000-line file, a single late
typecheck would not tell me which batch broke it.

No changeset: no user-visible change.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:17:03 -07:00
gsxdsm
dbb53eebaa test(cli): four suites broke on mocks that predate the PG cutover and U11 (12 red → 0) (#2702)
## What was red

`project.test.ts` (8), `extension.test.ts` (2),
`project-lock-retry.test.ts` (1), `extension-workflow-tools.test.ts` (1)
— **12 failures** on clean main, all from full-suite shard 4/4. **No
product defect in any of them.**

### Cause 1 — an incomplete mock that the fail-soft catch disguised (9
cases)

`getTaskCounts` now **enriches** each row before counting, so a renamed
wip column still counts as running work (`FNXC:WorkflowLifecycleColumns
2026-07-30-12:20`). But `resolveWorkflowIrForTask` and
`enrichRunningAgentTaskShape` were absent from the `@fusion/core` mocks.
Calling an undefined function threw, and `getTaskCounts`'s
**deliberately fail-soft** catch converted that into `{ byColumn: {},
runningAgentCount: 0 }`.

So the failures presented as "task counts are zero" — indistinguishable
from a real counting bug. Worth flagging beyond this PR:
`project-lock-retry.test.ts` exists specifically to prove a transient
lock does *not* "silently masquerade as zero tasks" (FN-7731/FN-7740),
and an incomplete mock produced that exact symptom by a different route.
**That catch will hide the next real enrichment failure the same way.**

### Cause 2 — column-vocabulary drift (3 cases)

A column-less `createTask` used to land in `triage`; post-U11 it lands
in `todo`, the merged Planning column. Three assertions were pinned to
the old landing column, or to `COLUMN_LABELS` values **the product never
prints** — the stale mock said `"Triage"` / `"To Do"` where core says
`"Planning"` / `"Todo"`. A label assertion could pass against a string
that exists only in the mock. The mock's labels are now copied from the
real table.

## Measured

| Check | Result |
|---|---|
| the four files | 12 failed → **0** (107 passed) |
| whole `@runfusion/fusion` package | **122 of 126 files green, 1656
passed** |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint`, CLI `tsc --noEmit` | clean |

**Census unaffected — 721 both with and against my diff**, verified by
reverting the four files and re-measuring rather than assuming test
files are unscanned. (I also caught that `--strict` *writes* the
baseline locally; that write is reverted, so this PR does not touch the
baseline.)

## Not touched

`task.test.ts`'s **5 failures**. That file is claimed by
`feature/tool-permission-gates`, and its 5 are exactly the ones shard
4/4 reports — so shard 4/4 goes 17 → 5, with the remainder belonging to
that PR.

## Two judgement calls recorded in-file rather than made silently

**The `extension-workflow-tools` guard is RE-PINNED, not deleted.** It
asserted "a task created on `builtin:coding` lands in `triage`" — a
byte-identical regression guard that fired because the change was
*intended* (U11's merge). Deleting it would remove the only check that
this landing column stays stable; leaving it pinned a column the product
no longer declares. Re-pinning to `todo` keeps it doing its job.

**The broad-listing assertion now names the group that actually leads
the output.** It asserted a *second* group header inside a listing that
truncates to a text budget. That only ever passed because `triage` sorts
before `todo` in `COLUMNS`, so its group fitted before truncation —
fixture ordering masquerading as a bounding assertion. Per-column
coverage of every group is still asserted by the three filtered cases,
which do not truncate. I also seeded those 8 tasks in an explicit third
column so that case still covers a three-way filter instead of
collapsing to two groups.
2026-07-30 04:10:06 -07:00
gsxdsm
f6e460acdf fleet: moves.ts 15 → 2 (hot path; one hoisted resolution, net one FEWER than before) (#2705)
Claiming **`packages/core/src/task-store/moves.ts`** — 15 guards,
largest unclaimed. This is the core move path, every task move in the
system, so the conversion is built to add **no work** to it.

## Census before/after

| | before | after |
|---|---:|---:|
| `moves.ts` column guards | **15** | **2** |
| repo backlog (this branch vs `origin/main`) | 693 | **679** |

Baseline re-recorded; `--strict` exits 0.

*(Backlog figures don't compose across my open fleet PRs — each branch
carries only its own reductions. 693 → 679 is this branch against main
as measured, not a running total.)*

## Zero added cost, and actually one fewer resolution than before

`moveTaskInternal` **already** resolves the workflow IR unconditionally
at line 400 — the `useWorkflow` gate is gone — and already derived a
lifecycle from it ~400 lines later for the trait hooks. So one hoisted
`moveLifecycle` immediately after the IR resolution serves all 14
guards, and the later local now **aliases** it instead of resolving a
second time.

Net effect on the hot path: **one fewer `resolveLifecycleColumns` call
than before this PR.**

## Converted: 14

6× `toColumn === "done"` → complete · 4× `fromColumn === "in-review"` →
review · 1× `toColumn === "in-review"` → review · 2× `toColumn ===
"todo"` → hold · 1× `toColumn === "in-progress"` → wip

Every site keeps its legacy id as the fallback. `undefined` here means
no IR on this path or a v1 column-less IR, and a move must behave
**exactly** as before when there is no basis to resolve from — this is
the transaction that arbitrates capacity, so "unchanged when
unresolvable" is the requirement, not a nicety.

## Flagged, not converted: 1

**Line 309** — `task.column === "archived"` in the handoff-invariant
check. It sits in a different function that runs **before** any IR
resolution, so converting it would mean *adding* a resolution to a path
that currently has none. That is a cost on the handoff path rather than
the free reuse everything else here gets, so it wants a deliberate
decision rather than my inclusion.

## Verification — the paths that matter, not just typecheck

Because this is the move transaction, `tsc` + lint is not sufficient
evidence:

- **`pnpm test:gate` GREEN** — 158 + 10 + 487 + 71
- `workflow-capacity-invariant` + `move-path-equivalence` **7/7** (the
in-transaction capacity gate lives in this file)
- `handoff-to-review-atomicity` **4/4**
- `store-movement` + `move-task-preserve-status` +
`task-move-hard-cancel-ordering` + `transition-pending-and-status-clear`
**16/16**
- `pnpm lint` clean · core `tsc` clean · `--strict` exits 0

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:06:02 -07:00
gsxdsm
0c7dc8c8ae feat(census): report MIXED-VOCABULARY files — the shape behind four half-conversion findings in one day (#2704)
## The pattern

Four review findings dispatched to me in a single day were the **same
defect**: a guard converted to role resolution while the function it
*feeds* still filters on the literal. The resolved guard admits a custom
column, the literal collaborator rejects it, and **nothing errors** —
the endpoint returns `repaired: 0` and reads as converted.

| PR | resolved side | literal collaborator |
|---|---|---|
| #2700 | review guard | `reconcileInReviewBranchRebind` filters `===
"in-review"` |
| #2700 | retry guard | `isInReviewMissingWorktreeSessionStartFailure`
likewise |
| #2698 | role-aware tabs | reconciliation effects still compare
`"done"` / `"in-review"` |
| #2688 | role-derived flags | memos and a `useState` capture keyed on
the stale value |

Since opening this I have been handed **two more** of the identical
shape (#2701, #2702). It is not a coincidence; it is what a conversion
phase produces by default.

## What this adds

A file where **both vocabularies are live** is where that can happen, so
the census now names those files.

**Measured: 23 of 134 guard-bearing files, holding 311 of 686 guards** —
and the top of the list is exactly where the findings landed:

```
MIXED-VOCABULARY files (a role resolver AND legacy literals): 23, holding 311 guards
   110  packages/engine/src/self-healing.ts
    57  packages/engine/src/executor.ts
    26  packages/engine/src/scheduler.ts
    20  packages/dashboard/src/routes/register-task-workflow-routes.ts
```

## Report-only, deliberately

A partially converted file is the **expected** state during a conversion
phase. Gating this would punish correct in-progress work and would be
routed around within a day. What it buys is that a reviewer of a listed
file knows to check the collaborators of anything converted — which is
what this repo's **Surface Enumeration** rule already requires, and what
each of those PRs missed.

The rule exists. The fleet work order does not mention it, so reviewers
are catching these one site at a time.

## Verification

Five tests, both directions: flags a mixed file; does **not** flag a
fully literal one (or the entire backlog lights up and the signal
carries no information); does **not** flag a fully converted one; does
not match a resolver name inside a longer identifier (the
`hold`-inside-`threshold` trap from #2677); survives an unreadable file.
**Mutation: dropping the resolver condition fails 2 of 38.**

The helper lives in the **lib**, not the CLI — importing the CLI
executes it and calls `process.exit`, so nothing defined there is
reachable from a test. I found that by trying.

38 census tests green · `--strict` and `--compare` exit 0 · lint clean ·
gate green (487 + 158 + 10 + 71). **No census numbers change.**
2026-07-30 04:02:19 -07:00
gsxdsm
a037ca93c7 test(engine): un-red the reliability-interactions tier (5 → 0) + a guard that could not fire (#2707)
## How this was found

Full-suite **shard 3/4** reports no `Tests N failed` summary at all —
its log ends mid-`@fusion/engine [1/2]` on a watchdog heartbeat, so the
red reads as infrastructure noise. It is not.

Facts that ruled out the infrastructure explanations, before touching
any test:

- watchdog budget is **1500s**; the engine slice died after **~6.5–10.5
min** (varies run to run) — not a timeout, and not a fixed one
- job `timeout-minutes: 60`, ran **9.7 min** — not the job timeout
- `concurrency.cancel-in-progress: false` — not cancellation
- annotation says **exit code 1**, not 137 — not an OOM kill
- across **four consecutive runs** the last file named is always
`reliability-interactions/explicit-duplicate-marker-sweep.test.ts`

Running that file locally reproduces real failures. The summary line is
simply missing from the CI log's final chunk (the last ~5s of output
never appears), which is what disguised a normal test failure as a
crash.

## Four causes, none a product defect

| File | Failures | Cause |
|---|---:|---|
| `explicit-duplicate-marker-sweep` | 2 | fixture seeds `column:
"triage"` |
| `starved-refinement-x-approval-gate` | 1 | same |
| `starved-refinement-x-triage-poll` | 1 | same |
| `executor-pending-review-skip-retry` | 1 | review handoff now passes
move **options** |

`triage` is no longer declared on any workflow post-U11, and these
sweeps filter by **role** — so cards seeded there carried no intake role
and the sweeps reported 0.

## Measured

| Check | Result |
|---|---|
| `reliability-interactions` | 5 failed → **0** (103 files, **530
passed**) |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint`, engine `tsc --noEmit` | clean |

Census unchanged — test files only.

## The real find: "honors the disable flag" could not fail

Forcing `enabled = true` in `resolveExplicitDuplicateMarkerTasks` — i.e.
making the sweep **ignore the disable flag entirely** — left all 16
cases **green**.

The fixture never set `triageDuplicateResolution`, so the sweep had no
resolution action to take and the duplicate survived whether the flag
was honoured or ignored. The assertion held for a reason unrelated to
the test's name. It had been red only because of the column literal,
which would have made "fix the literal, go green" a repair that left a
guard guarding nothing.

Setting the resolution mode makes that same mutation delete the
duplicate and the case fail. Verified both directions:

| | mutation applied |
|---|---|
| before | 16 passed — **guard cannot fire** |
| after | **1 failed** / 15 passed |

Recorded in-file with the measurement, so nobody strips the setting back
out as redundant.

## Two assertions strengthened rather than relaxed

- The disable-flag and failed-delete cases now assert the column is
**unchanged from a value read before the sweep**, rather than equal to a
literal. They cannot pass because a seed happened to land where the
assertion looked, and they survive the next column rename. The
invariants those cases own are "the flag stops the sweep" and "the task
whose delete threw survives" — the column id was always incidental.
- The handoff move asserts its **provenance** (`nodeId:
"review-pending-handoff"`, `preserveProgress: true`) instead of the
`expect.anything()` its siblings in that file use, so a move to the same
column by another path cannot satisfy it.

## Still open for whoever owns CI

Shard 3/4's log loses its final chunk, which is why a plain test failure
presented as a crash and stayed unexplained across at least four runs.
Anyone triaging full-suite from shard conclusions alone will keep
mis-reading this one; the failure has to be reproduced locally to be
visible.
2026-07-30 03:59:13 -07:00