5f6f39e115f3b6933ca20d2fb355ef536e60f7fc
328 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cfcbba6f81 |
fix(census): 4 RED ratchet tests on main, and the report said nothing at zero (#3218)
Two problems, both caused by the backlog actually shrinking. ## 1. Four failing tests on main **Pre-existing, not introduced here** — running this file on clean `origin/main` gives `49 passed / 4 failed` with identical messages. I checked that before touching anything, because the failures surfaced while I was editing the same file. The ratchet cases build their fixture like this: ```ts Object.entries(baseline.byFile).find(([, c]) => c > 1) // needs a file with MORE THAN ONE guard ``` After the tail reclassification no such entry exists. `find` returns undefined → `byFile[undefined] = NaN` → the baseline is corrupt → every case fails with `expected … to contain 'TIGHTENED'`, a message that points squarely at the CLI when the **fixture** is at fault. That misdirection is why this sat red. The ratchet doesn't care *which* file it tightens, only that an allowance exceeds the measured count. So `inflate` now takes any entry, and synthesises one against a real scanned file when the backlog is empty. `deflate` is the harder half: a RISE needs an allowance **below** the real count, and once every measured count is 0 the only value below is negative. The empty case uses `-1`. That is not a realistic baseline value and the comment says so — it is the sole way to exercise the `measured > allowed` comparison against a tree with nothing left to count, which is the tree this suite now runs on. Same class as the unbounded-slice rot in #3207: **census self-tests coupled to the size of a shrinking backlog.** That is now twice, so it is a pattern rather than an accident. ## 2. The report went silent at the finish line The verdict was two inline branches and neither fired at zero — `CONVERSION QUEUE EMPTY` required `totals.column > 0`. So the one state the entire fleet phase was working toward printed **nothing**, which reads as a broken scan rather than the protected end state. Extracted to a pure `describeBacklogState({ columnGuards, unexaminedGuards })` returning lines, so the caller stays a dumb printer: ``` BACKLOG ZERO: no lifecycle-column guard remains. This is the protected end state, not an empty scan — `--strict` fails on any RISE, so a new guard cannot land silently. Use the role helpers (resolveLifecycleColumns / columnHasRole). ``` Pure **specifically** so the zero state is testable before the tree reaches zero. While it was inline, only the *current* backlog state was observable — and a message nobody can test before they need it is the one that is wrong when they do. ## Evidence | check | result | |---|---| | census test file | **53 passed** (was 49 passed / 4 failed) | | behaviour on today's tree | **unchanged** — identical `CONVERSION QUEUE EMPTY` block | | empty-baseline probe | exits 1, `column-guard count ROSE` | | forced zero verdict | prints `BACKLOG ZERO … not an empty scan` | | `--strict` / `check-fnxc-future-dates` / eslint | 0 / 0 / clean | | `pnpm test:gate` | exit 0 (744 tests) | Four new tests pin all three states, including that the unexamined branch must **not** claim the queue is empty while real work is outstanding. ## Census No guard converted — this is tooling and test repair. Backlog unchanged at 1, which #3215 takes to 0. |
||
|
|
0bdc9bf4fb |
fix(dashboard): archived tasks stayed in the research picker on a renamed board (#3215)
## The defect The enrich-mode task picker filtered with `task.column !== "archived"`. On a board whose archive lane is renamed, that matched nothing — so filed-away tasks stayed in the picker and an operator could attach research findings to work they had deliberately archived. ## Census before / after | | before | after | |---|---|---| | COLUMN guards (backlog) | 10 | **9** | | `ResearchTaskActionModal.tsx` | 1 | **0 — converted** | Baseline re-recorded in the same commit; `--strict` green. ## This site was declined twice, and I wrote the second wrong estimate #3213 left it counted, correctly, on the note that was here — which was mine. Both prior cost estimates were wrong, so this corrects my own work: 1. **"Needs a data-fetch change"** — reasoned about `columnFlagsByTaskId`, a per-**task** map built from board-resident rows. Right that such a map can't help (archived rows are exactly what a board map omits), but this guard asks a per-**column** question, so it never needed one. 2. **"Needs prop threading, MainContent → ResearchView → here"** — right that the answer is column-keyed, wrong about where it lives. `ListView` builds `columnFlagsById` *inline*, which made it look like the owner. The data is `useBoardWorkflows`, a hook already called from `App`, `Board`, and `HeaderWorkflowSwitcherSlot`. **Measured cost: one file.** The modal already takes `projectId`, and `ResearchView` renders it only when a finding is open (`open` hardcoded beside `if (!finding) return null`) — so the hook cannot fetch for a closed modal, which was the one real objection to calling it here. Union across workflows keyed by column id, first declaration wins — the same convention `ListView` uses, so the two cannot disagree about a shared id. `isArchivedColumnRole` fail-softs to the legacy id when a column has no flags, so an unresolved workflow behaves exactly as the literal did. ## Tests — the invariant, not the repro Per the surface-enumeration rule, four cases: renamed archive lane, legacy id, unresolved workflow (fail-soft), and a second workflow's archive lane through the cross-workflow union. A repro-only test would pass on the legacy board and prove nothing about the case the guard exists for. **Anti-vacuity control:** | | renamed lane | union | legacy id | fail-soft | |---|---|---|---|---| | pre-fix literal | **FAIL** | **FAIL** | pass | pass | | converted | pass | pass | pass | pass | The legacy and fail-soft cases hold in both directions **on purpose** — they pin that this conversion did not change the pre-resolution answer. Flagging that so 4/4 isn't read as four independent proofs. ## Measured | check | result | |---|---| | `census --strict` / `check-fnxc-future-dates` | exit 0 / exit 0 | | `eslint` | clean | | `tsc -p tsconfig.app.json` (the config that actually covers `app/`) | exit 0 | | new tests | 4/4 | | `pnpm test:gate` | exit 0 (744 tests) | ## Note on process My first attempt at the control silently did nothing — the revert script threw a `SyntaxError`, so the "pre-fix" run was the fixed code and reported 4/4. Caught it because the error printed. The table above is from the re-run. |
||
|
|
c66b434b7b |
fix(self-healing): a renamed hold lane re-logged the same overlap blocker on every sweep (#3216)
## The defect `clearStaleBlockedBy` keeps a per-task memo of which overlap blocker it already logged, so a sweep running every few seconds doesn't repeat the same line forever. The memo was retained only while the card sat in a column matching the literal `todo` — so on a renamed board it was dropped on **every** sweep and `still blocked by file scope overlap with <id>` was re-logged each time. ## Census before / after | | before | after | |---|---|---| | COLUMN guards (backlog) | 9 | **8** | | `packages/engine/src/self-healing.ts` | 1 | **0 — converted** | Baseline re-recorded in the same commit; `--strict` green. (Counts follow #3215, which took 10 → 9.) ## The stated blocker was not real The note here declined the conversion because the lane prefetch is keyed on `candidates`, *"which this closure helps build"*. Measured — it does not: ``` 6033| for (const task of blockedTasks) candidates.set(task.id, task); 6034| for (const task of queuedDependencyTasks) candidates.set(task.id, task); 6036| for (const [taskId, lastLoggedBlockerId] of this.preservedQueuedOverlapLogged) { <- only CLEARS memos ``` `candidates` is fully populated two statements earlier, and this loop only clears memo entries. So the prefetch was hoistable; it now sits above the loop. That is a pure move of a read-only computation with no conditional between the two positions. Reaching the lane clause already proves the id is a candidate — `!candidates.has(taskId)` is the first arm of the same `||` chain, so short-circuit means the lane question is only asked for ids the prefetch covered (`referencedIds.add(task.id)` runs for every candidate). `lanesOf` still falls back to the legacy set, so an unresolvable workflow answers exactly as the literal did. This is the second inherited "too expensive" estimate to fail on inspection this session (see #3215). Both were written in good faith and both were checkable in a few minutes. ## One thing typecheck caught that review would not have `memoTask?.column !== "todo"` was **also** the undefined check, and tsc narrowed the later clauses on it. Replacing it without that arm compiled clean to the eye but broke narrowing — `TS18048: 'memoTask' is possibly 'undefined'` on the next line. `|| !memoTask` is now explicit rather than implied. ## Evidence The test drives the sweep **twice**, because a single pass cannot observe a dedup memo at all. | | pre-fix literal | converted | |---|---|---| | `still blocked by file scope overlap` log lines | **2 — FAILS** | **1 — passes** | Failure message against the pre-fix code: `expected [ [ 'FN-DEPENDENT', …(1) ], …(1) ] to have a length of 1 but got 2`. Worth correcting the record: the note called the cost *"a duplicate log line, not a wrong lifecycle decision"*. The lifecycle half is right — but it is a duplicate on **every sweep**, so it is recurring log spam, not a one-off. That is a bigger cost than the note implies, though still not a correctness bug. | check | result | |---|---| | `census --strict` / `check-fnxc-future-dates` | exit 0 / exit 0 | | `eslint` / engine `tsc --noEmit` | clean / exit 0 | | self-healing + overlap suites | 15 / 21 / 6 passed | | `pnpm test:gate` | exit 0 (744 tests) | Reused the existing `RENAMED_BOARD_IR` harness in that file rather than building a new one. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved cleanup of stale workflow blockers, including renamed workflow lanes. - Prevented duplicate overlap warnings during repeated cleanup. - More reliably preserves valid queued overlaps while ignoring missing or inactive tasks. - **Tests** - Added regression coverage for repeated stale-blocker cleanup and duplicate warning prevention. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
aa1655ccd9 |
fleet: reclassify the census tail — 10 → 2 guards, all reasoning already in the code (#3213)
## Census before / after
```
before after
COLUMN guards (backlog) 10 2
DELIBERATE-LITERAL 138 148
```
Baseline re-recorded in the same commit; `--strict` green.
## This converts nothing — the tail was never backlog
All ten remaining guards already carried an explicit in-code decision.
**None carried the `DELIBERATE-LITERAL` marker the census reads**, so
each re-appeared to every fleet pass as if unexamined. That is the whole
defect this fixes.
| site | the reasoning already at the site |
| --- | --- |
| `audit-ops.ts`, `moves.ts` | the degraded fallback arm of an
**already-converted** site; the live arm uses the resolved lane set |
| `scheduler.ts` ×2 | *"LEFT COUNTED"* — an await behind the
`tracked.has` re-entrance guard lets two updates double-start a monitor;
the sibling is the measured-expensive `task:updated` emit path (26 sites
against 7) |
| `notification-service.ts` | this method and its only caller are
**sync**, reached from a listener the store invokes as `(task: Task):
void`; resolving makes the chain async and reorders notification
classification against every other `task:updated` handler |
| `lifecycle-ops.ts` | *"Recorded rather than converted"* — dead code |
| `task-id-integrity.ts` | sync, no store-scoped read; converting alone
would disagree with `getLiveTaskColumn` |
| `triage.ts` | *"LEFT COUNTED until then"* — wants a non-sync-resolved
lane answer |
## Marker placement is load-bearing, and I got it wrong twice
The census reads a node's **leading** comments. A marker in a nearby
block comment attaches to the wrong node and is **silently ignored** —
it reads as reviewed while the count still lists the site.
- `task-id-integrity.ts` — my first marker went into the block comment
above the `const`; the literal is in the `return`. Count stayed at 1
until I moved it.
- `ResearchTaskActionModal.tsx` — marker added, **measured that it did
not register**, reverted.
Every edit was verified by re-running the census, not assumed. That is
the only reason the count actually moved.
## Two sites deliberately left counted
- **`ResearchTaskActionModal.tsx`** — the literal sits mid-expression
inside a `.then()` chain, so no marker can attach. The census's own
guidance is to hoist it into a named helper; the site's note asks for
that to be someone's deliberate change rather than a drive-by, so it
stays counted and honest.
- **`self-healing.ts`** — the memo closure I converted and reverted in
#3049. Its note: a renamed board costs a duplicate log line, not a wrong
lifecycle decision.
## Correction I owe on the measurement itself
For many turns I reported "zero unclaimed guards". That came from a bug
in **my own** query — `byFile` is an array of `[file, count]` pairs and
I had switched to `Object.entries()`, which yields `[index, pair]`, so
`n > 0` was always false and the filter returned zero regardless of
state. It agreed with reality while open PRs held every file, which is
why it went unnoticed; it was still wrong, and a constant zero against a
falling backlog should have prompted me to check it sooner.
## Verification (measured)
- engine `self-healing` + `scheduler` suites — **1003 passed / 56
files**
- core `task-id` / `moves` suites — green
- `tsc --noEmit` clean in core, engine and dashboard; `eslint` clean
- `pnpm test:gate` — green
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-fnxc-future-dates` — green
No changeset: `@fusion/core`, `@fusion/engine` and `@fusion/dashboard`
are private, and no runtime behaviour changes.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Clarified internal annotations for archived, in-progress, and
in-review workflow states.
* Documented fallback behavior and timing safeguards across lifecycle,
scheduling, notification, and triage flows.
* **Chores**
* Updated internal lifecycle tracking baselines to reflect current
annotations and state coverage.
* **Bug Fixes**
* No user-visible behavior changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
||
|
|
8d393422ac |
chore(fnxc): tighten the future-dates baseline — merge-queue-ops-2 4 -> 3 (#3211)
One-line baseline tightening, produced by the gate's own auto-tighten path. `check-fnxc-future-dates` deliberately auto-tightens rather than failing on a drop, because its population moves with the calendar and a drop has **no author** — the counterpart asymmetry to `check-inert-sync-lane-conversions`, where a drop *does* have an author and must fail. Any gate run regenerates this; `main`'s committed baseline had simply not caught up. **Why this isn't churn:** left loose, the baseline permits 4 future stamps in a file that now has 3. That slack silently absorbs one genuine future-dated stamp — precisely the failure this gate exists to catch, and one the fleet hit four times in a single day (`scheduler.ts`, a scheduler PG test, `task-update.ts` twice by different authors), each a real time on the wrong day that passed locally and reddened `main` for everyone else. Verified: both `check-fnxc-future-dates` and `check-inert-sync-lane-conversions` green on the tightened baseline. No changeset: tooling baseline, no published-package surface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
215f09d88f |
fix(census): the bare command could not say the conversion queue is EMPTY — and a test fix for main (#3207)
## Why this exists
The fleet instruction is *"claim the largest unclaimed census file
cluster (`node scripts/lifecycle-column-census.mjs`)"*. That command
cannot answer it. The availability verdict lived **only** behind
`--claims`, which shells to `gh`:
```
line 342: if (claims && !json) {
```
So a worker following the instruction literally sees per-file counts,
reads a nonzero backlog as a work queue, and picks a file whose guard is
already documented as deferred. Counts alone cannot separate *work left*
from *debt left*.
**Measured cost:** the queue reached **zero unexamined guards** while
dispatch continued. I re-audited the last three candidates —
`merge-queue-ops-2`, `lifecycle-ops`, `notification-service` — and all
three were already documented. Only one was reclassifiable, and by
**deletion** rather than conversion (#3205).
## What the bare command prints now
```
COLUMN guards (the backlog): 11
CONVERSION QUEUE EMPTY: all 11 remaining column guard(s) carry a documented deferral note.
There is no unexamined guard to claim. A nonzero backlog above is DEBT, not a work queue.
Re-read the note at a site before converting it; run --claims to also check open-PR ownership.
```
Or, when work does exist: `N unexamined guard(s) remain (no deferral
note) — run --triage to list them by file.`
**Local signals only**, so it is honest offline. It reports what it can
prove — no *unexamined* guard remains — and explicitly does **not**
claim the files are unclaimed, because only `--claims` sees open PRs. No
count, no exit code, `--strict`/`--json` untouched.
## Three commits, deliberately separated
1. **`refactor`** — move `FLAG_MARKERS` + the 40-line window into the
lib as `hasDeferralNote()`, verbatim. It was a private const plus an
inline `.slice()` in the CLI, so the rule deciding where the fleet is
sent had **no test in either direction**. Proven identical on the real
tree: `11 documented / 0 unexamined` before and after.
2. **`feat`** — the verdict + 6 tests.
3. **`fix`** — an unrelated pre-existing failure (below).
## The test fix — this one is turning main red
`attributes a remaining file to the open PR that touches it` asserted
over `out.slice(out.indexOf("UNCLAIMED:"))`, which runs to **end of
output** and so also covers the `SYNC-RESOLVED` section printed
afterward. That section legitimately lists `scheduler.ts`.
Latent until `topRemainingFile()` returned `scheduler.ts` — which
happened as the backlog shrank, **a state every conversion moves
toward**. Confirmed pre-existing: clean `origin/main` runs `42 passed /
1 failed` with the identical message.
## Evidence
| check | result |
|---|---|
| `hasDeferralNote` tests | both directions, boundary exact at 40 above
/ not below, 5 real phrasings |
| verdict control (by hand) | one tracked undocumented guard → **11 →
12**, verdict flips to `1 unexamined`; removed → restored |
| test-fix anti-vacuity | claim split broken → **FAILS**; restored →
passes |
| census file | **49 passed** (was 42 passed / 1 failed) |
| `census --strict` / `check:fnxc-future-dates` | exit 0 / exit 0 |
| `pnpm test:gate` | **exit 0** (732 tests) |
The verdict control was **invalid on the first attempt** — my probe file
was untracked and `git ls-files` never scanned it, so the verdict did
not flip and nothing was proven. Recording that because a control that
silently proves nothing is the exact failure this PR is about.
## Census before / after
No guard converted here; this is tooling. Backlog unchanged at 11, all
deferred.
|
||
|
|
230be28576 |
fix(core): the merge-queue enqueue guard was not debt — the code it guarded had no callers (#3205)
## The deferral note was right about the mechanism and wrong about the
remedy
`merge-queue-ops-2.ts` sat in the census as deferred debt behind this
note:
> Converting it properly means either making this path async or pushing
the trait read into SQL, both of which are store-architecture changes
rather than call-site conversions.
That is correct as far as it goes — the guard runs inside
`store.db.transactionImmediate`, so the only synchronous resolver
available (`resolveTaskWorkflowIrSync`) returns the DEFAULT workflow
under PostgreSQL and a "conversion" would be inert.
But it assumed the code needed converting. Measured across the tree:
```
=== every call site of .enqueueMergeQueueSyncInternal( ===
packages/core/src/store.ts:1775: public enqueueMergeQueueSyncInternal(...) <- the declaration itself
```
**Zero callers.** Every other occurrence of the name is a comment. The
live path is `enqueueMergeQueueAsync` (`task-artifacts-ops.ts:117`), and
that file already documented the deletion:
> Merge-queue enqueue is PostgreSQL-only via enqueueMergeQueueAsync …
The SQLite `enqueueMergeQueueSyncInternal` arm is deleted.
The arm was deleted; its declaration was not. The guard was unreachable
on the shipped backend.
## Change
- Deleted `enqueueMergeQueueSyncInternalImpl` (-85 lines) and its
`store.enqueueMergeQueueSyncInternal` entry point.
- Dropped the six imports that became unused
(`MergeQueueTaskNotFoundError`, `MergeQueueInvalidColumnError`,
`MergeQueueEntry`, `MergeQueueEnqueueOptions`, `normalizeTaskPriority`,
`MergeQueueRow`).
- Refreshed the three comments naming the removed symbol, so none points
at a deleted identifier. The
`handoffMergeQueueFailureInjectorForTesting` hook those comments sit on
is a **different** member and is untouched — it only mentioned the sync
arm as context.
## Census before / after
| | before | after |
|---|---|---|
| `packages/core/src/task-store/merge-queue-ops-2.ts` | 1 | **0 (entry
removed)** |
Baseline tightened by exactly one entry. **The 0 here is a deletion, not
a conversion** — recorded in the file's own FNXC note so the next worker
does not read it as a converted seam. This is the failure mode the
census warns about ("a count of 0 is the WORST case, not the best"), so
it is stated at the site rather than left to inference.
## Measured
| check | result |
|---|---|
| `census --strict` | exit 0 |
| `@fusion/core tsc --noEmit` | exit 0 |
| `eslint` (4 changed files) | clean |
| core merge-queue tests | **110 passed / 6 files**, incl.
`postgres/merge-queue-renamed-review-column.pg.test.ts` |
| `pnpm test:gate` | exit 0 (**732 tests**) |
No changeset: `@fusion/core` is private and this removes unreachable
code with no user-visible behavior.
## Flagged, not guessed
The other four deferral-note files remain deferred. I only reclassified
this one because its call-site count is a fact I could measure, not a
judgement. Whether `lifecycle-ops.ts:667` is likewise dead (it sits in
the legacy-SQLite polling-replica path) is a separate question I have
not measured, so I have not touched it.
|
||
|
|
a319e35a67 |
fix(dashboard): the card's completion timestamp reads the resolved complete lane (census 13 → 12) (#3146)
`TaskCard.tsx` 1 → 0. **Census 13 → 12**, baseline re-recorded in-commit. ## The defect `getInReviewCompletionMs` gated on `task.column === "done"`, so on a board whose completion lane is renamed, a finished card rendered its execution time **without the completion half** — the `Completed <when>` part of the indicator's `title` / `aria-label` never appeared. Nobody reported it because the card does not look broken. It looks like a card whose completion time was never recorded. ## The recorded blocker had expired, and I trusted it twice The note on that helper read: > Module-scope, takes only a `Task`, and has no flags to consult. Converting it means either threading resolved flags through a pure duration helper or resolving a workflow inside it. True when written (2026-07-30). False within a day, and the evidence is in the same file: - `taskColumnFlags` is a **prop of this component**, destructured and already consumed by `isWipColumnRole` / `isReviewColumnRole`. - The **sibling duration helpers were threaded for exactly this purpose** — `getTotalAgentActiveMs` carries the note *"THREADED SO THE CONVERSION IS NOT INERT"*. - This helper has **one caller**, inside the component, where the flags are in scope. The threading the note called prohibitive was already done; only this helper was left behind. I read that note twice this week and took it at face value both times — and what finally prompted the check was main landing `taskRevert 2 → 0 — **the recorded blocker named the wrong variable**` (#3129), someone else finding the same class of decay in a note I had also accepted. This program's own learnings say a deferral's stated blocker is a claim that ages like any measurement. I had applied every other entry in that document this week except that one. ## A dependency-array bug the conversion would have introduced The memo now reads `taskColumnFlags`, so it joins the dependency array. Flags arrive **asynchronously** — the board resolves workflows after first paint — so a card rendered before they load and re-rendered after would otherwise keep the pre-flag answer, since none of the memo's other inputs changed. This repo has **no `react-hooks/exhaustive-deps` rule**, so nothing would have flagged the omission. ## Two wrong probes before a correct one, both caught by controls and mutation Recording these because the fix was right from the start and my instruments were not: 1. **`textContent` matched nothing.** The completion time lands in `title`/`aria-label`, never in visible text. The **control failed too** — the signature of a broken probe rather than a broken fix. 2. **`innerHTML` on the whole card matched always.** The lifecycle-dates footer renders its own `Completed <date>` line, and *that* path already resolves the complete lane correctly. The probe was reading a different, already-converted feature. **Mutation exposed it: reverting the fix left all six green.** The final assertion queries `.card-time-indicator` and reads its `title`, which is the only form that can tell the two apart. ## Verification | | result | |---|---| | suite | **6 passed** | | mutation (restore `=== "done"`) | **1 failed \| 5 passed** — the renamed case only, control still green | | dashboard `tsc -p tsconfig.app.json` | **0 errors** | | census `--strict` | exit 0, baseline re-recorded in-commit | Flags stay optional with the legacy id as fallback (`isCompleteColumnRole`), so any caller without resolved flags behaves exactly as before. ## Note on `check-fnxc-future-dates` It fails on this branch, but **not because of it** — `scheduler.ts` and one PG test carry future stamps on `main` itself. #3139 fixes that. None of my files appear in the report. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a6d67844b8 |
fix(triage): the "unconvertible" site was convertible — the blocker was two test harnesses (#3191)
#3141 measured this site as unconvertible, and I twice reported the cause as a production constraint. It was not. This is the instrumented answer to the probe I recommended there and then ran myself. ## The isolation | configuration | result | |---|---| | flag only, no conversion | **8 passed** → the orphan arm is *not* the cause | | flag + conversion | **5 failed** → the conversion is | | same, with a realistic mock store | **8 passed** → the mock was the cause | `triage-stuck-requeue-preserve-draft.test.ts` defined neither `getTaskWorkflowSelection` nor its async twin — exactly like `triage.test.ts` did before #3189. Both made `resolveWorkflowIrForTaskWithProvenance` **throw** and take its catch branch: the *"could not ask"* shape, which a production store never presents. So the 5 failures I deferred as a possible semantics change were the same harness gap in a second file — confirmed, not argued. ## What changes **`selectionAbsent`** marks the determinate case: the store *answered* "no selection", so the workflow is the default and its IR is in hand. Added as a **separate field, not a third `source` value** — `source === "default"` is compared in **31 places** in `self-healing.ts` meaning "be conservative", and a new enum value would silently stop matching every one of them while still compiling and still passing on a default board. **`recoverApprovedTask`** now accepts a legacy `triage` row *explicitly* (its workflow does not declare that column) instead of depending on `resolvePlannerLanes` **failing** and falling back to legacy ids. Correctness resting on a resolver's failure mode is what this removes. ## Measured | | result | |---|---| | broad suite (triage / self-healing / recovery / planning) | **77 files, 1302 tests passed** | | the three directly affected suites, post-rebase | **245 passed** | | the flag is load-bearing | conversion **without** it: **18 failed \| 221 passed** | | `census --strict`, `check-fnxc-future-dates` | exit 0 | **The inert-sync-lane count is unchanged at 7 for `triage.ts`.** This site was never among the counted guards, so this is **not** a ratchet reduction — stating that rather than letting a conversion imply one. It removes a real inert dependency the ratchet cannot see, which is the blind-spot class this phase has been mapping. ## Why this took four attempts I described this blocker at four levels: merged intake/hold, orphan-arm scoping, identity verification (filed as **#3187**, closed as wrong), and finally the harness. **The two I instrumented held; the two I reasoned to did not.** The fix here is the probe I wrote down for someone else — which is where it should have started. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24c565540e |
gate: a sync lane handed to a wrapper is still inert — 13 scheduler guards were invisible (#3181)
## The fourth shape: a sync lane handed to a wrapper #3169 taught `unwrapForSyncCall` to walk await, parenthesized, conditional and binary expressions. It still stops at the **call boundary**, so a source call sitting in an *argument* position stays invisible: ```ts const parked = mergeParkedColumns(resolveTaskParkedColumnsSync(store, id), lanes); ``` That prefers the event payload and falls back to the sync answer whenever `lanes` is absent. The callee is `mergeParkedColumns`, not a source — so the walker never looked inside, and **the entire `scheduler.ts` file read as clean**. ``` main today: 9 (triage 7, executor 2, scheduler 0) this PR: 22 (scheduler 13, triage 7, executor 2) ``` Thirteen guards. And `check:inert-sync-lanes` has run in `test:gate` since #3136, so CI is currently enforcing a ratchet that reports a file it cannot see into as fully converted. The green is official, which makes it worse than the version nobody ran. ## Is the fallback still reachable? Yes, which is why these are not retired. #3135 attached lanes at every *live* emitter, but absence remains reachable three ways: the two `lifecycle-ops.ts` emitters on the SQLite-only polling path, any future emitter added without lanes, and the three forwarders (`project-manager.ts`, `remote-node-runtime.ts`, `child-process-runtime.ts`) that reconstruct the event object field-by-field rather than forwarding it. A rarely-exercised fallback is still a fallback. Counting it as clean is how the ledger stops meaning anything. ## The change One line inside your walker, plus its note: ```js if (ts.isCallExpression(n)) { for (const a of n.arguments) walk(a); } ``` Every shape #3169 added is preserved. Still a name match, not dataflow — the limits section still applies. ## Mutation evidence — all three shapes, one tree | Mutant | Result | |---|---| | baseline (22) | exit 0 | | **argument position** (this PR) | **exit 1**, 13 → 14 | | conditional (#3169's) | exit 1, 13 → 14 | | inline (#3062's) | exit 1, 13 → 14 | `scheduler.ts` restored clean after each run. ## Baseline 9 → 22 **Detection, not regression.** No production file changes in this PR. 22 is the exact union I measured before #3169 merged (13 + 7 + 2) and posted on both PRs at the time — it landing unchanged is the confirmation that the two fixes were additive rather than overlapping. ## Census before / after ``` before: COLUMN guards (the backlog): 12 after: COLUMN guards (the backlog): 12 ``` Unchanged — this converts nothing. It restores 13 guards to a ledger that had silently dropped them. ## Supersedes #3122 #3122 carried this fix as a standalone rewrite of `syncLaneLocals` and conflicted with #3169 the moment it landed. This is the six-line version I offered there; #3122 is closed. ## Verification `test:gate` exit 0 · `check:inert-sync-lanes` exit 0 at the re-recorded baseline · plus `fnxc-future-dates`, `lifecycle-columns`, `quarantine-ledger`, `inert-flag-seams`, `lane-wiring`, `sql-column-literals` — all exit 0. Gate script + baseline only. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved detection of synchronous operations nested within wrapper arguments. * Updated synchronization checks to report all currently identified findings, including additional scheduler-related cases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
fa62c951cf |
fix(gate): the inert ratchet went quiet exactly when the code improved (conditional initializer) (#3169)
Found by dogfooding my own change: I wrote `executor.ts` in the payload-first/sync-fallback shape while adopting #3140's better fallback, **predicted in a comment that the guards would stay counted**, and the gate reported **zero**. The prediction was wrong in the direction that matters — the gate under-reports. ## The gap `syncLaneLocals` registered a local only when its initializer **was** a call expression: ```ts const sync = payload ? undefined : localSync(store, id); return column === sync?.hold; // inert, and counted as nothing ``` Conditionals and `??`/`||` chains are now unwrapped, so a sync call in any branch registers the local. Still a **name** match, not dataflow — the file's LIMITS section still applies. ## Why this shape matters more than the inline one already guarded **The missed shape is the one authors are steered toward.** Falling back to the sync resolver is *better* than falling back to legacy literals — it is best-effort under legacy SQLite, whereas a literal can never be right on a renamed board. So writing the guard well is what made it invisible. A ratchet that goes quiet exactly when the code improves is worse than none: it rewards the worse degraded path with a tidier number. ## Known remaining gap, stated in the test rather than implied Only **one hop** is followed. The two-hop form is still uncounted: ```ts const sync = payload ? undefined : localSync(store, id); const lanes = { hold: payload?.hold ?? sync?.hold ?? "todo" }; if (from !== lanes.hold) … // still invisible ``` `executor.ts` is written that way today, which is why it reads 0 while the sync call is still present. Closing it needs propagation through object-literal construction — a larger change than this one, and I would rather ship the one-hop fix with the gap documented than imply full coverage. ## Verification | | result | |---|---| | gate on `main` | **exit 0**, output unchanged (11 = triage 7 + executor 4) | | test suite | **5 pass** | | new case against the **unfixed** gate | **fails** — `the conditional-initializer shape must be counted` | The regression case drives a real file through the scanned tree rather than calling a helper, because the bug was in which nodes the scan **visits**. A helper-level assertion would have been written against the same wrong mental model that produced the gap — which is how the inline-spelling hole in this same file survived its first draft. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved detection of sync-lane conversions in conditional expressions, fallback logic, awaited and parenthesized values, and object-literal relays. * Corrected matching for identifiers containing special characters. * Updated validation results to include two additional findings that were previously missed. * **Tests** * Added integration coverage for conditional initializers, chained object-literal conversions, and special-character identifiers. * Ensured temporary test files are cleaned up automatically. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2cb5cab595 |
chore(core): mark the mission-store dead-sync-path literal DELIBERATE (census 13→12) (#3179)
Comment-only. `tsc` 0 errors, `census --strict` and `check-fnxc-future-dates` exit 0. ## Claimed with the new tool First use of `scripts/check-file-claimed.mjs` (#3175) to pick work instead of guessing: ``` CLAIMED packages/engine/src/scheduler.ts #3177, #3142 CLAIMED packages/core/src/task-store/audit-ops.ts #3165 UNCLAIMED packages/core/src/mission-store.ts UNCLAIMED packages/core/src/task-store/task-id-integrity.ts ``` Two of the four files I would have reached for were already taken — by PRs whose branch names give no hint they touch those paths. That is the collision this phase paid for five times, answered in one command. I took `mission-store.ts`; `task-id-integrity.ts` is still free. ## Census 13 → 12 Reclassification, not conversion — the line is unchanged. ## Verified the blocker rather than deferring to it The site carries an audited note: the sync `MissionStore` reaches `this.db.prepare`, and `getMissionStoreImpl` returns the `AsyncDataLayer`-backed `AsyncMissionStore` under PostgreSQL, so the class is unreachable in the shipped backend. I checked that independently instead of accepting it — `async-mission-store.ts:168` states the same routing from the other side. **That check exists because of #3129**, where a note I had accepted as settled ("blocked on a per-neighbour flag map that does not exist") turned out to name the wrong variable, and the file was convertible all along. I had publicly argued it should stay counted. So the rule I am applying: a documented blocker gets marked only after its named obstacle is confirmed from a second source. Here it held; on `taskRevert.ts` it did not. ## Related, and still open `merge-queue-ops-2.ts` carries a note of the same shape that does **not** survive this check — it names two ways to convert (make the path async, push the trait read down) and misses the one that worked twice this phase: thread the resolved lanes in from a caller that already awaited them, as #3112 and #3118 did for `executor.ts`. Its sibling `taskStillInReview(projectId, reviewColumns)` already takes lanes from its caller. Worth a real look rather than a marker. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2a57820dd2 |
chore(gate): normalize the last future-dated stamp in task-update.ts (tightens the allowance 1 → 0) (#3168)
**Main is red on the FNXC gate again** — third occurrence of this class today, third different file. ``` packages/core/src/task-store/task-update.ts: 2 future-dated FNXC stamp(s), baseline allows 1 ``` Stamps dated **2026-08-01** while UTC is **2026-07-31-14:24**. Every open PR inherits the failure; #3164 merged carrying it. ## The fix Date only, to today. Clock times preserved exactly — they were real times on the wrong day — and no comment text touched, so the record reads identically, just in order: ``` -FNXC:StateMachine 2026-08-01-10:20 (PR #2793's finding — the INNER half, merged with #2821): +FNXC:StateMachine 2026-07-31-10:20 (PR #2793's finding — the INNER half, merged with #2821): ``` Baseline **tightened** as a side effect (`1 → 0`): one future stamp was grandfathered, normalizing the file cleared it too, and the gate refuses a stale allowance on the way down. Re-recorded in the same commit. ## The recurrence is the point, not this fix Three separate files have tripped this in one day — `scheduler.ts`, the scheduler PG test, and now `task-update.ts` — plus the midnight-rollover variant this morning that reddened everyone's baseline. **Stamps are written from a local clock and validated against UTC.** A worker behind UTC writes what is genuinely "today" for them and produces a future stamp the moment UTC has already rolled. Nothing in the local loop catches it: `pnpm lint` passes locally because the local date agrees. The durable fix is to generate the stamp from `date -u` rather than a wall clock — one line in whatever produces these, and the class disappears. I have patched the symptom three times today; someone should take the cause. I have not done it myself because the stamps are authored by hand across every worker's flow, so the change belongs wherever that convention is documented, not in a file I happen to be touching. ## Verification `check-fnxc-future-dates` green (TZ=UTC CI=true) · `pnpm test:gate` 13 + 161 + 487 + 71 · lint · core typecheck clean · diff is date substitutions only. |
||
|
|
d7324c1a20 |
fix(gate): main is RED on check-inert-sync-lanes — my #3137 dropped the count without re-recording (#3172)
`check-inert-sync-lane-conversions` is inside `test:gate`, so **the merge gate itself is red on `main` right now**. ``` inert-sync-lane: total fell 11 -> 7. Good news — but re-record the baseline in the SAME commit, or the allowance stays high and the gate silently accepts that many NEW inert conversions exit 1 ``` ## Cause: mine #3137 converted `executor.ts`'s planner-evacuation guards (4 → 0 under the current scan) and **did not re-record the baseline in the same commit**. The gate fails an unrecorded drop by design — a stale-high allowance is four free slots for new inert conversions — and that requirement is stated both in its failure text and in its own test suite (*"an unrecorded DROP fails, so the allowance cannot stay stale-high"*). I knew the rule and still shipped without it; the drop only became visible once the PR merged. This PR is the baseline only: **11 → 7**, `executor.ts` 4 → 0, `triage.ts` unchanged at 7. ## Ordering note for my two open gate PRs #3169 and #3170 make the scan follow a sync lane through a **conditional initializer** and through an **object literal**. `executor.ts` is written in exactly that shape after #3137, so with those fixes the count **rises 7 → 9** — a legitimate rise from better detection, not a regression. That re-record belongs in #3170's own commit, which is where the gate asks for it, and I will put it there rather than pre-baking it here. So the expected sequence is: **7 now**, **9 when #3170 lands**. ## Process notes - I checked for an existing fix PR before writing this one. - I also caught this only because I re-measured the exit code **without a pipe**. `node gate.mjs | head` then `echo $?` reads `head`'s status and reported 0 — the exact harness trap recorded in this program's learnings doc, which I walked into while checking whether main was healthy. ## Verification - gate **exit 1 on `origin/main`**, **exit 0** here - diff is the baseline file only Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
920d68e10f |
fix(dashboard): expose column roles to browser bundle (#3151)
## Summary - export the browser-safe `@fusion/core/column-roles` subpath - keep Vite/Vitest aliases ahead of broad `@fusion/core` aliases - restore production dashboard builds after task undo classification adopted shared column-role helpers ## Test plan - `node scripts/check-no-node-only-core-imports-in-dashboard.mjs` - `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest run app/utils/__tests__/taskRevert.test.ts --pool=threads --maxWorkers=1` - `pnpm --filter @fusion/core typecheck` - `pnpm --filter @fusion/dashboard typecheck` - `CI=true pnpm check:changesets` - `pnpm build` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed dashboard build compatibility for browser-based environments. * Improved reliability when importing column role functionality across supported application components. * **Refactor** * Made column role utilities available through a dedicated browser-safe entry point. * **Chores** * Updated development and test configurations to consistently resolve the new entry point. * Documented the browser-safe module classification and recorded the release patch. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
a8dae03fdb |
fleet(dashboard): taskRevert 2 → 0 — the recorded blocker named the wrong variable (#3129)
The largest remaining census cluster. Deferred twice, with a blocker that turns out to be false **in the same component where its counter-example already lives**. ## What the earlier notes got right `detailColumnFlags` describes the **modal's own task**, and the column classified here belongs to a **neighbour**. Supplying it would answer *"is this neighbour finished?"* with a different row's traits — wrong on data, not merely stale on vocabulary. That reasoning stands and I kept it. An earlier pass also converted this, left the parameter unsupplied, and **reverted it** — correctly. An unsupplied optional parameter is strictly worse than the literal: the guard is gone, the census counts a conversion, and the behaviour is the legacy fallback forever. That rule is why the wiring ships in this same commit. ## What the conclusion got wrong > "A correct conversion needs per-**neighbour** flags — which the modal does not have and should not fetch mid-render." `columnFlagsByTaskId` is a per-task map. It is **already a prop** of `TaskDetailModal` (declared :367, destructured :727), and the call site at :992 sits **below** that destructure. And `TaskDetailModal` already uses it exactly this way, for the near-duplicate canonical: ```ts columnFlagsByTaskId?.get(nearDuplicateCanonical.id) ``` …under a note observing that *its* blocker had been *"asserted from the shape of the problem rather than tested against what was in scope."* Same assertion, one function over. So the supplier the earlier note went looking for exists, is per-neighbour, and needs no fetch. ## What it fixes This lookup skips **finished** candidates so a done/archived prior undo attempt never renders as an active "Undo task" link. On a board that renames those lanes it matched neither — a finished undo task kept rendering as open, which is precisely the stale affordance the function's own header says it exists to prevent. ## Census | | before | after | |---|---|---| | `taskRevert.ts` | 2 | **0** | | repo backlog | 17 | **15** | ## Measured - 4 new cases; `taskRevert.test.ts` **11/11 pass**. - **MUTATION**: restoring the literal pair fails the renamed case. - **The negative is load-bearing.** The map is fail-soft, so a candidate it does not cover must still be treated as **open**, not skipped. A conversion that skipped unknown candidates would *hide live undo links* — failing in the direction nobody reports. - A **control** pins that an unwired caller (no flags at all) still skips the legacy ids, so the optional parameter cannot regress default boards. - `TaskDetailModal` suites — **31 files / 664 tests pass**. - `tsc --noEmit -p tsconfig.app.json` clean; census `--strict`, `check-lane-wiring`, `check-fnxc-future-dates` clean. ## Pattern worth noting This is the fourth deferral this session whose stated blocker had dissolved or misidentified itself, and the second where the counter-example was already in the same file. The common shape: a note records *why* something is blocked, is accurate when written, and is never re-checked — so the block outlives its cause. Re-reading them cost minutes each and returned two real conversions. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ada62a7c4a |
census: --claims shows which remaining files an open PR already holds (two duplicate claims today) (#3124)
The census says **where** the work is but not **who has it**, and duplicate claims are now the dominant coordination cost of this phase. This adds an opt-in `--claims` report mapping each remaining file to the open PRs already touching it. ## The problem is measured, not suspected - **`self-healing.ts` took three overlapping conversions** from different lanes while one branch was open (#3049, #3075, #3078). Each forced a full rebuild of #3094, and every conflict was the same shape: *same guard, two spellings, different variable names*. That PR's body asks, in as many words, for one lane to own the file. - **`executor.ts` took two independent conversions today** — #3112 and #3118 — same four literals, same payload-lanes fix, two branches. Two workers each read the census, saw the top cluster, and started. Neither could see the other; I only caught it because both appeared in one `gh pr list`. The census is what sends everyone to the same file, so the claim signal belongs here rather than in a side channel nobody reads. `--triage` (#3097) already measured the underlying fact — 53 of 88 guards sat inside an open PR — one step short of being actionable. ## Measured on current main (29 guards) ``` CLAIMED by an open PR: 6 files holding 15 guards 6 packages/engine/src/self-healing.ts ← #3121 #3116 4 packages/engine/src/executor.ts ← #3118 #3112 2 packages/engine/src/auto-merge-finalization.ts ← #3107 1 packages/core/src/task-store/task-artifacts-ops.ts ← #3120 #3119 #3091 … UNCLAIMED: 12 files holding 14 guards — start here 2 packages/dashboard/app/utils/taskRevert.ts 2 packages/engine/src/scheduler.ts … ``` It independently reproduces **both** collisions I found by hand today, which is the strongest evidence I can offer that it works: `executor.ts ← #3118 #3112` and `self-healing.ts ← #3121 #3116`. It also answers the standing fleet instruction empirically. "Claim the largest unclaimed cluster" currently resolves to **12 files holding 14 guards, none larger than 2** — and one of those two (`scheduler.ts`) is in the SYNC-RESOLVED list, where conversion is inert. That is a materially different picture from the headline `29`. ## Design decisions **Report-only and fail-soft**, on the same terms as `--triage`: opt-in, printed beside the totals, changes no count and no exit code. It shells to `gh`, so it is unavailable offline, in CI without a token, and in sandboxes — all of which print a notice and continue. A gate must not depend on network state; this is a work-selection aid, not a gate. **The fail-soft path is loud on purpose**, and it is the case I care most about. A claim report that silently degrades to "nothing is claimed" is *worse than no report*, because it actively sends the reader into work another lane holds — the exact failure the flag exists to prevent. So when `gh` cannot answer it prints `POSSIBLY CLAIMED` and suppresses the start-here list entirely rather than rendering it empty. **Heuristic, and says so.** A PR touching a file is not proof it converts *that file's* guards — it may edit an unrelated function. It over-reports rather than misses, which is the safe direction: a false claim costs one comment asking, a missed one costs a rebuilt branch. **One bulk `gh pr list` call**, not a request per PR — the per-PR shape was too slow to become habitual, and a report nobody runs is not a fix. ## Verification - `lifecycle-column-census.test.ts` — **42 passed** (was 40) - Differential: disabling the flag gives **2 failed | 40 passed**. Both new tests fail on the defect they were written for. - `--strict` and `check-fnxc-future-dates` — exit 0 - Tests stub `gh` on PATH, so no network call and no dependency on the live PR list. The fixture reads the census's **own current top file** rather than a hardcoded path, so it cannot rot as the backlog shrinks (same self-maintaining discipline as #3106). ## What this does not do It does not reserve anything — there is no lock, and two workers who both run it can still collide if they start simultaneously. It reports what is already visible in the PR list, which is enough to catch the every-case-so-far pattern of *starting work on a file someone has held for hours*. A real reservation would need shared mutable state, and I would not add that without an owner asking for it. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6483f9ce2b |
fix(scheduler): resolve task:updated / task:deleted lanes asynchronously (scheduler inert 5 → 0) (#3128)
The last inert guards in `scheduler.ts`. Independent of my other branches. ## Inert-guard ratchet | Scope | Before | After | |---|---:|---:| | `scheduler.ts` | 5 | **0** | | total | 12 | **7** (triage.ts 8 → other worker; executor.ts 4 → #3112) | ## The live bug These read `resolveTaskParkedColumnsSync`, which answers with the **default** workflow in production. On a renamed board the scheduler **never woke** on unpause or planning-finish, and a **deleted blocker never unblocked its dependents** — the card sat behind a task that no longer existed. ## The criterion, restated because I got it wrong before **What blocks a guard is whether its answer is consumed synchronously — not whether the enclosing listener is declared sync.** I assumed the latter earlier in this program and reverted for it. All three fail that test: two only gate `schedule()`, which is itself `async`, fire-and-forget and re-entrance-guarded; the third already sits below an `await getSettings()`. The edge-trigger bookkeeping (`planningTaskIds.delete`) **stays synchronous** on purpose — deferring *that* would let a second update re-enter the branch. ## The union is load-bearing, not defensive Post-U11 the default lineage has no `triage` column, so a **resolved** answer returns `intake: "todo"` where the inert path fell back to `"triage"`. Converting without unioning the legacy ids silently **narrowed** the wake set and stopped waking cards in a legacy-named lane — caught by *"schedules when planning clears in triage"*. **A resolved conversion must be a superset of what it replaces, or it is a behaviour change wearing a vocabulary change's clothes.** That's the reusable lesson here. ## Tests - Drained with the repo's existing **`flushAsyncHandlers`** helper — written for exactly this fire-and-forget shape — rather than loosening any assertion. - **The characterization test flipped, as designed.** `workflow-scheduler-parked-columns-live-e2e.pg.test.ts` asserted *"a dependent in a RENAMED hold column is NEVER unblocked"*, with its author noting: *"expected to flip to null the moment the resolver is fixed — and that flip is the whole point of writing it down."* It flipped. Inverted to a REGRESSION case so the assertion holds the fix rather than the defect; it now matches its own CONTROL arm, which still guards against a vacuous pass. ## Verification - 21 scheduler suites — **361 green**, including the live PostgreSQL e2e - **`pnpm test:gate` green**; eslint and `tsc` clean - Changeset added; `check:changesets` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ad5172afd5 |
fix(engine): main is red on check:inert-sync-lanes — #3114's triage conversion is inert, revert the arm (#3126)
## `main` is red on `check:inert-sync-lanes` right now ``` inert-sync-lane: NEW inert conversions — a lane guard now reads a sync resolver that always answers with the DEFAULT board. packages/engine/src/triage.ts: 7 -> 8 ``` Verified on a clean `origin/main` checkout, not on my branch. #3114 converted this guard's third arm to `disposeLanes.wip`; the gate that exists to catch exactly this fired, and the PR landed anyway — presumably because `check:inert-sync-lanes` is not in the blocking merge-gate set. ## The change did not change behaviour `disposeLanes` comes from `resolvePlannerLanes`, which resolves through `resolveTaskWorkflowIrSync` — inert under PostgreSQL for two independent reasons (#3103). So `disposeLanes.wip` evaluates to `in-progress`: **the same value as the literal it replaced.** A card advancing into a renamed execution lane still matches nothing, still reads as an evacuation, and still kills a healthy planning session — the precise bug #3114 set out to fix, unchanged on every board. So the arm goes back to the literal. The gate's own failure text rules out the alternative: > Do NOT re-record the baseline to clear this — that is the same false green one layer up. ## #3114's analysis is kept — only the code reverts Its behavioural description is **correct** and is the clearest statement of this bug anywhere in the file. I have kept those paragraphs and added what is missing: that the fix does not reach under PG, and what would. Whoever supplies a lane answer that is not sync-resolved should make this line read `disposeLanes.wip` and delete the note. The specification is sitting right there for them. ## It also reconciles two contradictory notes, one of them mine My #3108 flag said converting the third arm this way adds an inert comparison and removes a census entry that is telling the truth. #3114 then converted it and added a note saying it fixes the bug. **Both notes sat in the file**, giving any reader two confident, opposite accounts. They are now one account with the evidence attached. ## Read this file's census count carefully #3114 took it to **0** while the inert count went to **8**. The census's own `--triage` output warns about exactly this shape: > for a sync-resolved file, a count of 0 is the WORST case, not the best — the file reads as fully converted Reverting restores it to 1, which is the honest signal. ## Census | | before | after | |---|---|---| | `triage.ts` | 0 | **1** | | repo backlog | 26 | **27** | **The number going up is the point.** A census that reports 0 for a file whose guards are all inert is worse than one that reports the truth — it retires the entry and nobody looks again. ## Measured - `check-inert-sync-lane-conversions`: **exits 1 on `main`, 0 here** (8 → 7). - `src/__tests__/triage*` — **25 files / 374 tests pass**. - `tsc --noEmit -p packages/engine` clean; census `--strict`, `check-fnxc-future-dates` clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
218086bea2 |
fleet(engine): self-healing 6 → 1 — the board-stall counter, the last guard that needed a sync answer (#3121)
The last fan-out guard, and the one I explicitly said needed a synchronous answer. #3109 made that answer available without an await, so the flag comes off. ## Why this one was last The other two guards in this listener gated work the listener **already `void`s**, so they moved onto the async resolver in #3094. This one increments in-memory state **in the handler's own tick**, so it genuinely needed a synchronous answer. The sync IR path was never that answer: `resolveTaskWorkflowIrSync` cannot resolve a **custom** workflow at all — two independent blockers, #3103 — which is why I wrote that conversion, measured it, and withdrew it. #3109's emitter-carried `lanes` removes the dilemma rather than trading one horn for the other: reading them needs **no await**, so the increment stays in the same tick *and* the guard becomes correct. ## What it fixes On a renamed board this counter read **zero**. The board-stall watchdog was blind to a board whose cards were moving out of implementation the whole time — the signal it exists to raise was never raised. ## Census | | before | after | |---|---|---| | `self-healing.ts` | 6 | **1** | | repo backlog | 29 | **24** | The remaining 1 is the log-dedup closure — a pre-existing flag whose degraded answer costs a duplicate log line, not a lifecycle decision. ## Measured - 3 new cases; `self-healing-completion-fanout.test.ts` **13/13 pass**. - **MUTATION**: restoring the literal pair fails the renamed case. - **The paired negative is the load-bearing one.** The guard means *"left implementation for somewhere that is not implementation"*, so a move **between two non-wip lanes** must not count. Without that case, a conversion that counted every move would pass the positive and inflate the watchdog's denominator — breaking it in the opposite direction, which is harder to notice than a zero. - A **fail-soft** case pins that an emit carrying no `lanes` still counts on the legacy ids. - **Asserted through the counter itself**, not a downstream alert. The increment *is* what this guard decides; routing the assertion through the watchdog would let an unrelated threshold change mask a regression here. - `src/__tests__/self-healing*` + `task-agent*` — **42 files / 848 tests pass**. - `tsc --noEmit -p packages/engine` clean; census `--strict`, `check-lane-wiring`, `check-inert-sync-lane-conversions`, `check-fnxc-future-dates` clean. ## On the withdrawal this reverses #3094 withdrew a sync-IR conversion of this listener and recorded why, precisely. That record is what made this cheap: I could tell in one read that #3109 addressed the *specific* obstacle rather than a general "async is hard". A flag that names its blocker exactly is a flag that can be retired the day the blocker goes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6050d6eb83 |
chore(engine): mark the auto-merge-finalization reviewed literals DELIBERATE (census 47→45) (#3107)
Fleet phase. `packages/engine/src/auto-merge-finalization.ts` was the last census file with no branch, worktree, or open PR against it. Claim published by pushing the branch before starting. ## Census before / after | | total | this file | |---|---|---| | before | **47** | 2 | | after | **45** | 0 | `--strict` exits 0, baseline re-recorded. **Reclassification, not conversion** — both lines are unchanged. ## Both sites were already reasoned, in a note that calls them non-defects - **Line 30** is the resolver's **degraded fallback arm**, inside `catch`. The live arm two lines up calls `columnHasFlag(ir, columnId, "complete")`. The literal is reached only when IR resolution throws, where the legacy id is the only answer left — removing it would make a failed resolve return nothing. - **Line 99** picks an **error string**. The note above it works through threading `isCompleteColumn` in and concludes the signature widening costs more than the sharper diagnostic buys. I did not revisit either judgement. The gap was mechanical: prose the census cannot read, so both stayed in `byFile` as apparent debt for the next pass to re-derive. ## This is the fourth, and it closes the set With #3056, #3060, and #3063, **every census file that was unclaimed during this phase has now been examined, and not one needed a conversion.** Each site was a three-state fallback arm, or a site a prior pass had already reviewed and kept. The corollary is the finding I would most want carried forward: the remaining count is not a work queue. A worker told to "claim the largest cluster" reads the number, finds most of it already reasoned, and reaches for whatever moves it — which is how three PRs converted guards to a synchronous resolver that is inert under PostgreSQL. One exception worth preserving: **`taskRevert.ts` should stay counted.** I claimed, inspected, and released it without marking. Converting it would classify a *neighbour* row using the modal task's flags — wrong on data, not merely stale on vocabulary — and its note correctly calls the entry **accurate debt** blocked on a per-neighbour flag map. Fallback arms and dead paths → mark. Placeholders awaiting a capability → leave counted. ## Verification - `census --strict` exit 0; `tsc --noEmit` (engine) **0 errors** - No dedicated test file for this module (`vitest` reports none), so no suite to run — comment-only diff, no behaviour change Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
82329819f7 |
fleet: resolve the pre-archive unarchive target (census 69 → 68) (#3091)
## Census
| | column guards |
|---|---|
| before | **69** |
| after | **68** |
## How this was found
By finishing a triage I'd left incomplete. Of the 19 single-guard files,
I had actually examined six and flagged the rest partly on assumption —
so I went back and read them.
Five of the remaining ones turned out to be `archived` comparisons
**pinned by `archived-column-gate-parity.test.ts`** (`audit-ops`,
`task-id-integrity`, `mission-store`, `async-comments-attachments`, plus
`merge-queue-ops-2` for review). Converting any of those moves one of
three encodings that must move together.
**This one isn't pinned**, and that difference is the whole PR.
## What changed
```ts
if (!declaresPreArchiveColumn || preArchiveColumn === archivedColumn
|| preArchiveColumn === "archived")
```
Belt-and-braces: the condition already accepted the resolved lane **or**
the legacy id, stated twice. A set says it once, so the two halves can't
drift apart — the real risk with a duplicated condition, rather than the
census count.
## Why this isn't the split brain
The parity guard pins comparisons of a **task's column** — one of three
encodings of *"an archived task is not live."* This compares a **stored
`preArchiveColumn` value** against the board's archive lane: a different
question, about where to send a card on unarchive.
Verified rather than argued — that suite runs **green** here, and it
went **red** the last time I touched a pinned site (#3076, where I named
an arm and immediately reverted). It's a live check, not an assumption.
## Measured
| check | result |
|---|---|
| archive / artifact / unarchive suites | 7 files, **27 tests green** |
| `archived-column-gate-parity` | **2 passed** |
| five gates + strict census | green |
| core `tsc` | clean |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
89b21e2906 |
fleet: triage's planning-evacuation check uses the resolved wip lane (census 45 → 44) (#3114)
## Census
| | column guards |
|---|---|
| before | **45** |
| after | **44** |
## What changed
```ts
if (task.column === disposeLanes.hold || task.column === disposeLanes.intake
|| task.column === "in-progress") return;
```
Two role questions and one id question on the same line.
`resolvePlannerLanes` is **already called immediately above**, and its
result carries `wip` — so this needs no new resolution and no new await.
The literal just stops being the odd one out among its neighbours.
## What it cost on a renamed board
This handler aborts a planning session when a card leaves the planner
lanes. `in-progress` is excluded because *a card advancing into
execution is not an evacuation* — that's stated in the note directly
above it.
Against the literal, that exclusion **never matched** on a board whose
execution lane is renamed. So a legitimate advance into execution read
as an evacuation and **killed a healthy planning session** — precisely
the case the comment says must not abort.
`wip` is optional by design (PR #2628: a missing role stays `undefined`
so callers refuse rather than invent a column). Undefined here means the
board declares no execution lane, so there's no advance-into-execution
to exclude and the comparison is correctly false.
## Not addressed, and pre-existing
This line resolves through `resolvePlannerLanes` — the **sync** twin,
which returns the default workflow's lanes under PostgreSQL. That
affects all three lanes on the line equally and predates this change:
the handler is `(task: Task) => {}` with no await available, so fixing
it needs the same emitter-side change as #3082.
Making the third lane consistent with the other two doesn't deepen that,
and it leaves **one** shape to fix there rather than two.
## Measured
| check | result |
|---|---|
| triage / evacuation / planner-lane suites | **453 tests green** |
| four gates + strict census | green |
| engine `tsc` | clean |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7d75633daa |
gate: follow sync-lane sources across module boundaries (11 inert guards were invisible) (#3079)
## Eleven inert guards were invisible to the check that exists to find them #3062 shipped this check collecting sync-lane sources **per file**, and recorded the module-boundary gap as a known limit. That limit has stopped being theoretical. `resolvePlannerLanes` is defined in `replan-target.ts` and consumed in `triage.ts` and `executor.ts`. `triage.ts:723` already reads: ```ts if (task.column === disposeLanes.hold || task.column === disposeLanes.intake || task.column === "in-progress") return; ``` A sync-resolved pair sitting directly beside a literal. That is precisely the site a fleet worker reaches for next — the "conversion" is a one-word edit, it drops the census by one, and it changes nothing, because `resolvePlannerLanes` resolves through `store.resolveTaskWorkflowIrSync` and always describes the default board. **The shipped check could not have seen it.** ## What changed Sources are collected in a **first pass over the whole tree**; consumption is counted in a **second pass** against that repo-wide set. | file | guards now visible | |---|---| | `packages/engine/src/scheduler.ts` | 18 | | `packages/engine/src/triage.ts` | **7 — previously invisible** | | `packages/engine/src/executor.ts` | **4 — previously invisible** | **Baseline 20 → 29. The rise is detection, not regression.** Those eleven guards were already inert; nothing in the tree got worse and no production file is touched by this PR. Flagging that explicitly because the check's own failure text says *"do NOT re-record the baseline to clear this"* — that rule is about a code change, and this is a detector widening. The distinction matters and I would rather state it than have it inferred. ## Mutation evidence, on the motivating site Converting `triage.ts`'s literal to `disposeLanes.wip` — the exact inert edit this widening exists to prevent: | | result | |---|---| | check as shipped on main | **exit 0** — missed entirely | | this PR | **exit 1**, `triage.ts: 7 → 8` | `scheduler.ts` restored clean after every run. ## Third widening — and the honest read on that This is the third hole found in this check, each discovered the same way: 1. **#3062**: matched only the local-variable spelling; the inline `resolveX(...).review` walked past. 2. **#3068**: matched only comparisons; `parked.terminal.has(to)` walked past — and worse, made the count *fall*, inviting a re-record that would have retired live sites. 3. **here**: matched only same-file sources; a cross-module helper walked past. Every time, the check was correct about the shape in front of it and blind to a trivially different spelling of the same defect. I said in #3068 that the durable fix is to key on **the source** rather than enumerate consuming syntax; this closes the module-boundary half of that. The remaining half is value-level dataflow (a sync-resolved id assigned through an intermediate, passed as an argument, or returned), which needs a type-aware pass and is a larger change than a gate script should carry casually. I am not claiming this version is complete. It is measurably better than the shipped one on a site that exists in the tree today. ## Census before / after ``` before: COLUMN guards (the backlog): 84 after: COLUMN guards (the backlog): 84 ``` Unchanged — this converts nothing. It makes eleven already-inert guards countable so the next conversion of them fails loudly instead of reading as progress. ## Verification `test:gate` exit 0 · `pnpm check:inert-sync-lanes` exit 0 · lifecycle-column census exit 0 · `pnpm lint` clean. Gate script + baseline only. Still open and unrelated: **#3073** re-greens the archived-gate parity ratchet, which is red on `main` right now and sits outside the merge gate. |
||
|
|
ec2921b958 |
fleet(engine): self-healing 23 → 6 — async-reachable guards, plus 3 of 4 fan-out guards the sync path could not serve (#3094)
**Replaces #3093, which I am closing.** Third rebuild of this work. ## A coordination note first, because it is costing more than the code `self-healing.ts` has had **three** overlapping conversions land from other lanes while my branch was open — #3049, #3075, #3078. Every time, replaying my commits produced conflicts that were all the same shape: *same guard, two spellings, different variable names*. Each rebuild is a full cycle spent on merge mechanics rather than on lanes. I have rebuilt against `main`'s own census each time rather than argue about whose spelling wins, and this PR contains only what `main` (23) does not have. But if this file is going to keep receiving concurrent fleet passes, one lane should own it — otherwise the next PR pays the same tax again. ## Converted | site | note | |---|---| | `isPhantomExecutorBinding` | caller resolved `lanesOfReclaim(task.id).wip` **three lines above the call**, then passed a task whose column the predicate compared against `in-progress` | | `isWorkspaceOwnerLive` | required `completeColumns` | | `recoverPausedAbortFailures` **body** | #3075 converted this sweep's *router* and left three body guards comparing ids | | `reconcilePreExecutionWorktrees` | a four-id literal in a sweep that **removes worktrees** | | `recoverStarvedRefinementTriageTasks` | 2 peer counts that read zero, so escalation never fired | | `evaluateParkedAgentTaskLink` | the omitted `parkedColumns` argument | All through the **async** `resolveProjectColumnsForRoles`, whose only store read is `listWorkflowDefinitions()` — answerable under PostgreSQL. That is what separates these from the inert kind. **The half-converted sweep is the important one.** A router that resolves correctly feeding a body that compares ids is worse than converting neither: the route now fires on a renamed board and the body then acts on the wrong lane. The `moveTask` **target** is the sharp end — an undeclared target is rejected *except* under `recoveryRehome` with a legacy id (`moves.ts:570`, the #1411 escape hatch), so a converted route feeding the literal `"todo"` rehomes the card into a column its workflow does not declare, which is the state other reconcilers exist to repair. **A real dropped-behaviour bug**: `evaluateParkedAgentTaskLink` was called without `parkedColumns`, falling back to `LEGACY_PARKED_COLUMNS`. A live durable agent linked to a card resting in a renamed hold lane read as not-parked, so the safeguard preserving its task link never applied. ## Withdrawn: the `task:moved` fan-out I wrote the sync-IR conversion, measured it, removed it. `getTaskWorkflowSelectionImpl` returns `undefined` **unconditionally** under PostgreSQL, so `resolveTaskWorkflowIrSync` always answers with the default builtin IR and `columnsWithFlag` on it yields exactly the legacy ids — inert on every board. Worse than the literal, because **the literal is counted**. My own test passed only because its store mock supplied a renamed IR: it pinned the helper's shape, not production behaviour. The refutation is recorded in place, and `check-inert-sync-lane-conversions` exits 0 on this branch. ## One question, one answer An earlier pass of this work mapped the notification-attach guard onto a wider `activeWork` set, and `self-healing-paused-abort-recovery > "rehomes an in-progress pause-abort park back to todo"` caught it — an in-progress park attached a transition notification it should not have. The fix is not a narrower set. Both guards ask **one** question — *"is the card already at the requeue target?"* — which the literal happened to spell twice as `=== "todo"`. The target now resolves once, before the write, and both read it. Deriving one question two ways is exactly how a converted guard and an unconverted target drift apart. ## Census | | before | after | |---|---|---| | `self-healing.ts` | 23 | **11** | | repo backlog | 53 | **41** | ## Measured - `src/__tests__/self-healing*` + `task-agent*` — **42 files / 836 tests pass** - `tsc --noEmit -p packages/engine` clean; **`check-inert-sync-lane-conversions` exits 0**; census `--strict`, `check-lane-wiring`, `check-fnxc-future-dates` clean ## The remaining 11, flagged not guessed - **4** — the fan-out, withdrawn above; blocked on a sync-capable selection reader. - **1** — the log-dedup closure: pre-existing flag; it sits before the lane prefetch it needs, and the degraded answer costs a duplicate log line, not a lifecycle decision. - **1** — the synthetic `{ column: "todo" }` for a *missing* task: deliberate, and now correct rather than unconverted, because `parkedColumns` is legacy-seeded. - The rest are status/deliberate classifications the census counts but that are not lane guards. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved workflow automation for boards using renamed or customized workflow lanes. * Fixed task completion fan-out, branch rebinding, recovery, and stalled-task detection across custom lifecycle columns. * Prevented completion actions from triggering when tasks move back to the work-in-progress lane. * Improved cleanup and pause recovery behavior for customized workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
39e6891c93 |
chore(core): mark the dead sync-path lane literal DELIBERATE-LITERAL (census 104→102) (#3060)
Fleet phase. Claimed `packages/core/src/task-store/project-store-ops.ts` — the largest census file with no branch, worktree, or open PR against it. Claim published by pushing the branch **before** starting work. ## Census before / after | | total | this file | deliberate | |---|---|---|---| | before | **104** | 2 | 130 | | after | **102** | 0 | 130 | `--strict` exits 0, baseline re-recorded in the same commit. **Reclassification, not conversion** — the line is unchanged. ## The site was already audited today, in prose the tool cannot read ``` FNXC:WorkflowLifecycleColumns 2026-07-31-02:45 (audited — DEAD SYNC PATH, do not convert): … It is the SQLite-mode twin. The live path is `dequeueMergeQueueOnColumnExitInTransaction` … and it is ALREADY converted … This body reaches for `store.db.prepare`, which throws in PostgreSQL backend mode … ``` The reasoning is sound and I did not second-guess it: the live path is converted, this twin cannot execute in production, and converting it would mean threading a lane set into a function whose first statement throws. The problem is purely mechanical — **the note is prose, and the census reads markers.** So the site stayed in `byFile` looking like unconverted debt, and each fleet pass pays to re-derive the same conclusion. Adding `DELIBERATE-LITERAL` moves it to `deliberateByFile`, where a reviewed-and-kept literal belongs. ## This is the second one, which makes it a pattern Same shape as #3056 (`async-mission-store-queries.ts`, fallback arms). Across the files I have checked this phase — `agent-store`, `github-tracking-state`, `planner-overseer`, `auto-merge-finalization`, `async-mission-store-queries`, and this one — **every site was either a fallback arm or an already-documented deliberate leave**, and `agent-store.ts:236` carries its own "FLAGGED AND LEFT COUNTED" note from today. So the count is not a work queue, and the gap is not judgement — previous passes reached the right answer. They recorded it where only a human reader would find it. Two lines of marker per site closes that, and the number then means "conversions owed", which is how every worker reads it when picking a cluster. ## Verification - `census --strict` exit 0; `tsc --noEmit` **0 errors** - `check:fnxc-future-dates`, `check:lane-wiring`, `check:sql-column-literals`, `check:inert-flag-seams` — all exit 0 - No behaviour change: only a comment added Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dca67a79a3 |
fix(census): a DELIBERATE-LITERAL marker on a ternary arm was invisible (#3099)
## The gap The marker works above a statement or a function. It does **not** work in the position people actually use it — on the fallback arm itself, right beside the literal it excuses: ```ts flags ? flags.hold === true /* DELIBERATE-LITERAL — the no-metadata fallback. */ : column === "in-progress"; ``` That comment sits **before the `:` token**, so it's the colon's leading trivia rather than the arm expression's — `getLeadingCommentRanges` at the arm's full start never sees it. The ancestor walk doesn't rescue it either: the next ancestor is the `ConditionalExpression`, whose own leading comments are somewhere else entirely. ## Measured, not hypothesised `in-review-stall.ts` carried a marker in exactly this position **and stayed on the backlog**. The only way I could clear it was to restructure the code into a named set (#3064). That's the tool dictating shape rather than reading intent — and a marker that silently does nothing trains people to stop marking. Given this fleet phase has had several workers reach for `DELIBERATE-LITERAL` (#3056 used it successfully at statement level), the failure mode is one worker's marker working and another's not, for reasons neither can see. ## Scope and controls Scoped to the span between the previous arm (or the condition) and this one, so it can't pick up a comment belonging to anything else. Verified both directions with a probe: | case | before | after | |---|---|---| | marker above the statement | `deliberate` | `deliberate` | | marker on the ternary arm | **`column`** | `deliberate` | | unrelated marker on a neighbouring statement | `column` | `column` (unchanged) | **The real tree's count is unchanged at 51** — nothing is silently reclassified, because the one site that had an arm marker was already converted away. This is forward-looking. ## Not fixed here — pre-existing red on `main` `lifecycle-column-census.test.ts` has two failures (`expected 22 to be 26`, `expected +0 to be 1`) whose fixture arithmetic drifts with real tree counts as fleet conversions land. **Verified identical on `origin/main`** before and after this change, so it isn't mine — but someone should decide whether that fixture ought to be derived rather than pinned, since every fleet merge moves it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a7b2a757fa |
fix(engine): serialise wedge handling per task, then convert the lane guards it was blocking (5 → 1) (#3087)
The largest unclaimed census cluster, and the one two earlier fleet passes explicitly declined. ## The standing blocker, taken on Both passes converted these four ids and reverted, each time after the same test went red: ``` task-wedge-notification.test.ts > sends one actionable push and mailbox message per active terminal episode expected 2 calls, got 1 ``` Their diagnosis was right and I have kept it: this branch **resolves** a wedge episode, `handleTaskUpdated` starts it fire-and-forget from a synchronous `(task) => void` listener, and **any** await introduced before the resolve lets a re-wedge arriving close behind reach `claim` while the previous episode is still active — `claimed: false`, second operator notification silently dropped. Column resolution needs an await, so the conversion could not be made safe from inside the branch. Both notes named the fix and left it for "whoever owns the wedge episode contract": *serialise wedge handling per task*. This PR does that, then takes the conversion. ## 1. Serialisation `enqueueWedgeHandling` chains handling per task id, so resolve-then-claim keeps its order however many awaits either branch acquires. Details that matter: - **Keyed by task, not global** — different tasks stay concurrent, so this is not a throughput regression on a busy board. - **The map entry is dropped when its chain drains**, and only if no later link was appended while it ran, so it does not grow with the task table. - **Links never reject.** `maybeNotifyTaskWedge` already owns its error handling; a rejected link would poison every later notification for that task. ## 2. The conversion it was blocking The four ids are an enumeration of *"every lane except review"* — the lanes whose occupancy proves a wedged card's lifecycle has visibly resumed. On a renamed board none of them matched, so a recovered card's episode never resolved. Two consequences, and the second is worse than the first: 1. the operator keeps an open "needs operator action" alert for work that has moved on; 2. an active episode **suppresses re-claim**, so the *next* genuine wedge on that task is never delivered. Membership over the four roles, legacy-seeded, so an unconverted board resolves exactly the four ids it used to compare. ## Measured **The acceptance test the earlier notes named is the gate on both halves.** With the conversion and *without* the serialisation, "sends one actionable push and mailbox message per active terminal episode" fails exactly as they reported. With the serialisation, green. I reproduced their finding rather than taking it on trust — it is the evidence that the serialisation is load-bearing and not incidental refactoring. | | result | |---|---| | `task-wedge-notification.test.ts` | **15/15** (2 new) | | notification suites | **11 files / 234 tests pass** | | `tsc --noEmit -p packages/engine` | clean | | census `--strict`, `check-lane-wiring`, `check-inert-sync-lane-conversions`, `check-fnxc-future-dates` | clean | **MUTATION**: restoring the four literals fails the renamed-recovery case and leaves its paired negative green. **A vacuity I caught and fixed, worth stating plainly.** My first version of the renamed case recovered the card with `status: "queued"`. `hasProgressed` is an OR whose other arm is *"status is a non-failed string"* — so that arm answered true and the column comparison never ran. The mutation did not fail it. The case now clears `status` and `error` together, which makes column membership the only thing that can resolve the episode, and the paired negative uses the identical shape so only the lane differs. ## Census | | before | after | |---|---|---| | `notification-service.ts` | 5 | **1** | | repo backlog | 71 | **67** | ## The remaining 1, flagged not guessed `isManualMergeHold` (`task.column !== "in-review"`) is sync, and so is its only caller `classifyWorkflowTransitionNotification`, reached from the same `handleTaskUpdated` listener. Converting it means making that whole chain async — a change to notification *classification ordering* against every other `task:updated` handler, which is a different contract from the episode one this PR owns. The serialisation added here does not cover it: it wraps wedge handling, not transition classification. Threading a pre-resolved `LifecycleColumns` in as a parameter is the likely fix, and it wants the same gate-placement judgement applied deliberately rather than swept in behind this. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6949f22ef8 |
fleet: resolve the same-column handoff review target (census 84 → 83) (#3076)
## Census | | column guards | |---|---| | before | **84** | | after | **83** | `moves.ts`: 2 → 1. One of its two sites converts; the other **must not**, and that difference is the useful part of this PR. ## Converted — the move target at the same-column handoff ```ts if (internal.fromHandoff && toColumn === "in-review") ``` Against the literal this **never fired on a renamed board**, so a same-column handoff into a renamed review lane silently took the *other* branch — the sync-SQLite path, which throws under PostgreSQL. It now asks `moveReviewColumns`: the broad membership set (`mergeOrchestration ∪ mergeBlocker ∪ humanReview`) already resolved **three lines above** for the merge-queue pair. Same value, so this branch cannot disagree with the enqueue/dequeue calls that receive it. ## Not converted — the archived fallback arm I named it, and `archived-column-gate-parity.test.ts` went red on **`TypeScript encoding changed`**. That guard's argument holds: the archived gate is enforced in three encodings, the SQL halves still compare the raw string, and moving the TypeScript half alone is the split brain it exists to prevent. Restored inline **with a note recording the measurement**, so the next person doesn't retry it and rediscover the same red. This is the second time that guard has stopped me this session. It's doing exactly what it was built for. ## On the pre-existing red That suite is red on `origin/main` for an unrelated raw-SQL drift (#3072 fixes it — the drift is from my own merged #3042/#3046). I verified this branch produces the **identical** failure and no other, so it doesn't compound it. ## Measured | check | result | |---|---| | moves / handoff / merge-queue suites | green | | four gates + strict census | green | | core `tsc` | clean | | parity suite | same single raw-SQL failure as `origin/main`, nothing added | Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e242ea294 |
fix(engine): backlog pressure called every dependency unfinished on a renamed board (#3081)
## The third lane question This reporter had **three** lane questions. Two were resolved when the file's query-blindness was fixed — hold and wip, both through `resolveProjectColumnsForRoles`. The third sat one method down and was never touched: ```ts if (dependency.column !== "done") return false; ``` One board, two lane answers. ## What it cost On a renamed board every dependency reads unfinished, so `isRunnableCandidate` rejects every card that has one. The backlog-pressure alert then names **only dependency-free cards** as the runnable ones. The failure mode is the quiet kind: the report still renders, the counts are right, and the candidate list looks plausible. The operator is told the queue is blocked on nothing in particular. No default-board test can see it — which is exactly why the earlier conversion of this same file, which fixed its reads, left this behind. ## Fix `finishedColumns` (complete ∪ archived) resolved once by the async caller alongside hold and wip, then passed into the sync predicate. - **Required parameter, not optional-with-a-literal-default.** An optional parameter leaves `done` in the file as a silent fallback and the next caller gets pre-conversion behaviour by writing nothing. - **Archived is included** because a dependency that has been archived is finished too — and this reporter already reads with `includeArchived: true` precisely so archived blockers resolve. - **Async resolution.** `resolveProjectColumnsForRoles`' only store read is `listWorkflowDefinitions()`, a project-wide async read that works under PostgreSQL. That is the line between a real conversion and the inert sync-IR kind (#3058), and the new test supplies its board through that same reader so it exercises the production path. ## Census | | before | after | |---|---|---| | `backlog-pressure-reporter.ts` | 1 | **0** | ## Measured - One new case; file **11/11 pass**. - **MUTATION**: restoring `dependency.column !== "done"` fails it. - The case asserts **both directions in one test** — a dependency resting in the board's own complete lane makes its card runnable, *and* a dependency still in the hold lane still blocks it. Asserting only the first would pass against a predicate that had simply stopped checking dependencies. - The file already had a `RENAMED_IR` scoped to its second describe; mine is a distinct `RENAMED_DEPENDENCY_IR` with different lane names. I hit the shadowing first and the test failed as `under-threshold` — worth noting because a same-named fixture that silently resolves to the *other* board is precisely how a renamed-lane test goes vacuous. - `tsc --noEmit -p packages/engine` clean; census `--strict`, `check-lane-wiring`, `check-inert-sync-lane-conversions`, `check-fnxc-future-dates` clean. ## Flagged, not guessed Adjacent census entries I looked at and deliberately left: - **`executor.ts` (4)** — all inside a sync `task:moved` listener. Converting via `resolveTaskWorkflowIrSync` would be inert for #3058's reason, and making the listener async reorders it against every other subscriber. Correctly out of scope, as #3048 judged. - **`triage.ts:724`** — half-converted in the same shape: `disposeLanes.hold`/`.intake` come from a sync resolver, so the resolved arms are themselves inert and "finishing" the guard would add a third inert comparison. - **`auto-merge-finalization.ts` (2)** — one is the resolver's documented degraded fallback (the live arm calls `columnHasFlag`), the other is already recorded as a deferred signature-widening whose cost exceeds the error string it sharpens. - **`in-review-stall.ts:196`** — an explicitly marked DELIBERATE-LITERAL no-metadata fallback. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c531d984c |
fix(engine): self-healing lane cluster round 2 — 38 → 26 (two sweeps could disturb live work) (#3078)
The largest census cluster became **unclaimed again** when #3055 closed conflicting. I had closed my own #3050 an hour earlier expecting #3055 to land, so this re-applies the conversions #3047 and #3049 did not cover. **Re-applied from current main rather than rebasing the closed branch.** The conversions are small; the conflict archaeology is what went wrong last time — nine conflicts against #3049, several on variable names identical to mine, and my mechanical fixup corrupted the file badly enough that I aborted. Starting from main cost less than resolving that and carries no risk of resurrecting a stale line. ## Census | | before | after | |---|---|---| | `packages/engine/src/self-healing.ts` | **38** | **26** | | repo-wide column guards | 84 | **72** | ## Three sweeps, existing role helpers only | sweep | roles | what it did on a renamed board | |---|---|---| | worktree metadata | terminal + wip + review | rebound finished cards every pass, **and the FN-5256 liveness guard went silent** | | orphaned pending step results | wip | **could rewrite `pending` results under a live executor run** | | agent-link drift | wip + review + terminal | evaluated agents whose task was plainly still executing | Two of these disturb **live** work, which is why they were worth redoing now rather than leaving for the next fleet round: - The worktree-metadata sweep clears `worktree`/`branch` metadata. Its liveness guard is the thing standing between that and a running shell (FN-5256). Keyed on ids, it matched nothing on a renamed board. The scope-override safety condition beside it now reads the **same resolved sets**, so the two cannot disagree about which lanes are live — previously they were two independent literal lists. - The orphaned-step-results sweep's own header says it must never touch an executor-owned row. The id-keyed skip made it do exactly that. Resolved once per sweep, outside the paging loop, so a large board still pays one resolve. ## Flagged, not guessed — the 26 that remain Unchanged from my earlier audit and re-verified on this base: - **Sync predicates** (`isWorkspaceOwnerLive`, the pause-abort classifier, the phantom-binding check, the `task:moved` listener guards). No store handle; converting means a signature change or making a synchronous event listener async, which reorders handlers against a synchronous emitter. - **Already-converted fallbacks** — `own.length > 0 ? own.includes(...) : task.column === "in-review"`. The resolved answer wins; the literal is the documented no-metadata path. - **The notification-route `fresh.column === "todo"` sites** — measured previously: any `await` before the wedge resolve drops an operator notification. Needs the wedge-episode contract, not a column pass. ## Verification self-healing suites **204 passed** · agent-link-drift + query-filter-blindness **83 passed** · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · census `--strict` · lane-wiring — green. |
||
|
|
98aac40ca8 |
fleet: reads.ts 2 → 0 lifecycle-column guards (#3057)
> **Rebased.** Main landed another worker's conversion of the review gate while this was open — the overlap was a whole rewritten function, so I reset to main and rebuilt only my remaining delta on top of their work rather than resolving hunks. Their conversion is kept as-is. ## Census | | column guards | |---|---| | before | **104** | | after | **102** | `reads.ts`: **2 → 0**. ## Two changes **1. `includeColdStorage`** asks whether the *caller* is filtering to the archive lane. Against the literal, a caller filtering to a renamed archive lane took the false branch — cold storage was skipped and the filtered view returned only whatever archived rows still sat in `project.tasks`, **a short list presented as the whole archive**. Still literal on main; converted here. **2. Both fallbacks become named sets** instead of inline arms — including the one on the just-landed review gate. ## The second point is the one worth the fleet's attention This is bookkeeping correctness, not style. The census counts an inline comparison **whether or not it sits in a fallback branch**, because its `traitFallback` hint is advisory and never changes `kind`. So a correctly-converted guard with an inline legacy arm **stays on the backlog permanently**, and the number stops distinguishing real debt from documented degraded answers. Concretely: converting with an inline fallback is correct work that scores **zero**. My own first pass at this file did exactly that. There are roughly **12 such sites** across the tree — `github-tracking-state`, `planner-overseer`, `async-mission-store-queries`, `register-task-workflow-routes`, `restart-recovery-coordinator` — and I have that cluster converted and ready to open next. ## Measured | check | result | |---|---| | reads / get-task / stall suites | 5 files, **87 tests green** | | renamed-archive PG suite | green | | strict census | green; `tsc` clean | | unconverted boards | byte-identical — the named sets hold the previous ids | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Review and archive checks now work correctly with resolved workflow columns while retaining legacy compatibility. * Fresh agent activity is detected in resolved review lanes. * Lists filtered by a resolved archive lane now include archived items stored in cold storage. * **Chores** * Updated internal lifecycle tracking baselines. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c12a51627 |
fix(core): the merge result reported a column the finaliser did not write (merge-queue-ops 3 → 0) (#3071)
Largest unclaimed census cluster in `packages/core` — three `done`
literals in `mergeTaskImpl`. Two of them produced **wrong state**, not
merely a guard that stopped firing.
## 1. The result overrode the writer
`moveToDoneImpl` resolves the board's completion lane and writes it onto
the task object:
```ts
task.column = completeColumn; // task-artifacts-ops.ts
```
Both merge call sites then did:
```ts
result.task = { ...task, column: "done" };
```
putting the literal back over what the writer had just set. Every
`task:merged` listener — GitHub tracking, the auto-merge handoff — was
told the card landed in `done` while the persisted row said `shipped`.
The row was right and the event was wrong, which is the worse direction:
the listeners act on the event, not the row.
Fixed by reading back what the writer set (`{ ...task }`). Deliberately
**not** a second resolution — that would only be a second chance to
disagree with the finaliser.
## 2. The guard disagreed with the writer
The already-complete short-circuit asked `task.column === "done"`, while
the finaliser it guards short-circuits on the resolved `task.column ===
completeColumn`. On a renamed board those two answers differ, so a card
already resting in the board's completion lane fell through and the
merge ran again against a branch that was already landed and deleted.
Converted with the **same resolution and the same shape** — a single
first-match column, not membership — because the whole point is that
these two answers cannot differ. A workflow declaring no complete lane
resolves to `undefined`, which matches no column; the finaliser refuses
such a board explicitly one function later.
## Census
| | before | after |
|---|---|---|
| `merge-queue-ops.ts` | 3 | **0** |
## Measured
- Two new cases added to `merge-blocker-renamed-review-lane.test.ts`
(same renamed-board fixture, same PG harness) — file **5/5 pass**.
- **MUTATION**: restoring either literal fails **both** new cases and
leaves the three pre-existing ones green.
- Reached with **no git fixture**: with no branch present, `git
rev-parse --verify` fails and the function takes its own documented
*"branch not found — moving to done without merge"* path — which is
exactly the path that calls `moveToDone` and then builds the result. No
repo setup, no flake surface.
- `packages/core` targeted run: **38 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-lane-wiring` ("none added"), `check-fnxc-future-dates` clean.
## Not done here (flagged, not guessed)
The other `done`/`archived` literals still in the core census are each
blocked for a *different* documented reason, so sweeping them into this
PR would have meant guessing:
- `agent-store.ts:236` — a pure formatter over `Pick<Task,"column">`
that prints the column for a human; degrades gracefully and has no store
to resolve from.
- `async-mission-store-queries.ts` — already converted with
caller-threaded lane sets.
- `taskRevert.ts:119` — classifies a **neighbour** task; the only flags
in scope describe the modal's own task, so wiring them would answer the
question for the wrong row. Needs per-neighbour flags.
- `moves.ts:310` — a **refusal**, where a legacy-seeded superset is the
documented hazard rather than the safe direction. Wants its own change
with its own test.
- `mission-store.ts:2332` — a sync SQLite path with no async seam.
Each is real debt; none is a mechanical conversion.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8eef8852a0 |
fleet: 4 long-tail fallback arms become named sets (census 101 → 97) (#3064)
## Census | | column guards | |---|---| | before | **101** | | after | **97** | The single-guard long tail is **19 files**. This converts the four whose legacy arm is unambiguously a fallback on an already-converted guard; the other 15 are flagged below rather than guessed at. ## Two shapes **`in-review-stall.ts`, `stalled-review-detector.ts`** — the resolved answer with an inline legacy arm: ```ts reviewColumns ? reviewColumns.has(col) : col === "in-review" → (reviewColumns ?? LEGACY_REVIEW_LANES).has(col) ``` **`merger.ts`, `in-process-runtime.ts`** — belt-and-braces: ```ts col !== (lifecycle?.complete ?? "done") && col !== "done" ``` That accepted the resolved lane **or** the legacy id, stated twice. A union set says it once, so the two halves can't drift apart — which is the real risk with a duplicated condition. ## A finding for anyone else marking fallbacks `in-review-stall.ts` **already carried a `DELIBERATE-LITERAL` marker** on that arm and was counted anyway. The marker sits in a comment *inside a ternary*, which the census's leading-comment lookup doesn't reach. So: **naming the set works, marking it does not.** Worth knowing before someone marks a fallback and expects the count to move. ## No behaviour change `new Set(["in-review"]).has(x)` answers exactly what `x === "in-review"` answered, and the union sets accept exactly the two lanes their conditions already accepted. ## Flagged, not converted The remaining 15 single-guard sites need individual judgement, not a mechanical pass: - **plain unconverted guards with no resolution in scope** — `audit-ops`, `lifecycle-ops`, `merge-queue-ops`, `task-id-integrity`, `backlog-pressure-reporter`, `ephemeral-worker-manager`, `ResearchTaskActionModal` - **sites where the literal IS the answer** — `eval-signal-collector` maps a column to an archive-vs-done *label*; `TaskCard` reads a completion timestamp - **already resolved on their line** — `triage.ts`, `restart-recovery-coordinator.ts`, both covered by open PRs ## Measured | check | result | |---|---| | core stall suites | 4 files, **85 tests green** | | engine merger/runtime suites | **1044 tests green** | | five gates + strict census | green | | `tsc` (core, engine) | clean | Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c220455e3a |
fleet: 10 inline fallback arms become named sets (census 102 → 92) (#3061)
## Census | | column guards | |---|---| | before | **102** | | after | **92** | Five files drop to **0** guards each. Baseline re-recorded in the same commit. ## A cluster the census could not distinguish from real debt **Every site here is already converted.** Each reads resolved lanes when it has them and falls back to a legacy id when it doesn't: ```ts reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review" ``` The census counts an inline comparison **whether or not it sits in a fallback branch** — its `traitFallback` hint is advisory and never changes `kind`. So ten correctly-converted guards sat on the backlog permanently, and the number stopped distinguishing *work still to do* from *documented degraded answers*. Naming the fallback set fixes the bookkeeping without touching behaviour: `new Set(["in-review"]).has(x)` answers exactly what `x === "in-review"` answered. ## Files | file | sites | what they gate | |---|---|---| | `restart-recovery-coordinator.ts` | 4 | three shared review gates + one `??` default | | `github-tracking-state.ts` | 2 | complete / archived lane predicates | | `planner-overseer.ts` | 2 | wip / review classification | | `async-mission-store-queries.ts` | 2 | terminal complete / archived | | `register-task-workflow-routes.ts` | 2 | wip promotion target, archived respecify guard | **No behaviour change is claimed and none is intended** — that's the point. These were already right; only the accounting was wrong. ## Worth the fleet's attention Converting a guard while leaving an inline fallback is **correct work that scores zero** on the census. My own first pass at `reads.ts` did exactly that — behaviourally correct, census unmoved. Anyone converting this way is doing real work the number won't credit, and the backlog will look stuck. ## Measured | check | result | |---|---| | engine suites | **173 tests green** | | core mission suites | **70 tests green** | | dashboard route suites | **211 tests green** | | five gates + strict census | green | | `tsc` (core, engine, dashboard) | clean | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Standardized fallback handling for workflow stages, including in-progress, review, completed, and archived states. * Preserved existing behavior when explicit workflow column settings are available or unavailable. * Improved consistency across task tracking, planning, and recovery workflows. * **Chores** * Updated lifecycle tracking baselines to reflect current source-file coverage. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
18fab9b8e5 |
fleet: name the WIP half of an existing fallback (census 88 → 87) (#3070)
## Census | | column guards | |---|---| | before | **88** | | after | **87** | ## What `ephemeral-worker-manager.ts` answers its unresolvable-workflow default two ways, two lines apart: ```ts if (TERMINAL_TASK_COLUMNS.has(task.column)) return true; // named set — not counted return task.column !== "in-progress"; // inline — counted ``` Both are the **same documented fallback** — the block carries one `DELIBERATE-LITERAL` marker covering both — but only the inline one was on the backlog, because the census reads comparisons regardless of which branch they sit in while a set is a definition. Naming it makes the pair consistent and stops the site reading as unconverted debt. ## Correction to my own flag in #3064 I listed `ephemeral-worker-manager`, `backlog-pressure-reporter`, `merge-queue-ops` and `lifecycle-ops` as *"plain unconverted guards with no resolution in scope."* **That was wrong for all four.** Each already imports the resolvers — 7, 4, 3 and 2 references respectively. I wrote the flag without checking, which is the same mistake as an untested deferral rationale, just inside a PR body instead of an issue. Re-examined, the other three are genuinely harder rather than unresolved — and these are the real reasons: - **`backlog-pressure-reporter:197`** classifies a **dependency**, a different row from the one the caller resolved. Per-dependency resolution is needed or it repeats the wrong-row shape that `taskRevert` is blocked on. - **`lifecycle-ops:655`** guards an emit whose **target** is also a literal (`to: "archived"`). Converting the guard alone leaves the pair inconsistent — the move-target half is invisible to this census. - **`merge-queue-ops:352`** is an early return on an already-complete task inside a merge path that resolves lanes elsewhere; the placement needs its own judgement about which resolution it should share. They stay flagged, now with the real reason rather than an unchecked one. ## Measured | check | result | |---|---| | ephemeral-worker suites | green | | four gates + strict census | green | | engine `tsc` | clean | Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
141f54e51d |
chore(core): mark the agent-store status formatter DELIBERATE-LITERAL (census 101→99) (#3063)
Fleet phase. `packages/core/src/agent-store.ts` was the **last** census file with no branch, worktree, or open PR against it. Claim published by pushing the branch before starting. ## Census before / after | | total | this file | |---|---|---| | before | **101** | 2 | | after | **99** | 0 | `--strict` exits 0, baseline re-recorded. **Reclassification, not conversion** — the line is unchanged. ## Already decided, in prose the census cannot read The site was flagged earlier today by another pass, as `FLAGGED AND LEFT COUNTED`: a pure formatter over `Pick<Task, "column">` with no store and no task id, whose output is a human-readable status line. On a renamed board it falls through to `(<column>)` — still accurate, just less specific. Converting it would mean threading a lane resolution into a string builder. That reasoning is right and I did not revisit it. The only gap was mechanical: a prose note is invisible to the tool, so the site kept reading as backlog. ## This completes the sweep of unclaimed files Third and last of these. Together with #3056 (fallback arms) and #3060 (dead sync path), **every census file that was unclaimed this phase has now been examined, and not one of them needed a conversion.** Each was either a three-state fallback arm — where the legacy id is the answer when resolution fails, and removing it would break the caller — or a site a previous pass had already reviewed and deliberately kept. That is the finding worth carrying forward. The remaining **99** is not a work queue: a meaningful share is correct code the tool cannot distinguish from owed work, and every fleet pass pays to re-derive it. Since all workers rank by the same `byFile` output, we also converge on the same top file — which is how `self-healing.ts` drew three parallel conversions, two of which are now unmergeable. Two cheap changes would fix both symptoms: 1. **Mark reviewed-and-kept sites** so the count means *conversions owed*. Two lines each. 2. **Push the branch at claim time** so `git ls-remote` is authoritative before work starts. Costs nothing; I did it for all three of these. ## Verification - `census --strict` exit 0; `tsc --noEmit` **0 errors** - `check:fnxc-future-dates`, `check:lane-wiring` — exit 0 - Comment-only diff; no behaviour change Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
581e6fba43 |
chore(core): mark the async-mission fallback arms DELIBERATE-LITERAL (census 108→106) (#3056)
Fleet phase. Claimed `packages/core/src/async-mission-store-queries.ts` — **the only census file with no branch, no worktree, and no open PR against it.** Claim published by pushing the branch before doing any work. ## Census before / after | | total | this file | deliberate | |---|---|---|---| | before | **108** | 2 | 128 | | after | **106** | 0 | **130** | `--strict` exits 0, baseline re-recorded in the same commit. **This is a reclassification, not a conversion.** The same two lines are still there. A reader comparing 108 → 106 against my #3047's 126 → 121 should know only the latter changed behaviour. ## Why marking is the right answer here Both sites are the **fallback arm** of the three-state rule: ```ts terminalColumns?.complete ? terminalColumns.complete.has(column) : column === "done"; ``` `terminalColumns` undefined means the caller could not resolve lanes. The legacy id is then the only answer that keeps the query working at all — converting it would delete the fallback and make an unresolvable caller return nothing. The census counts the literal, but **the literal is the design**. The file's own comment shows a previous worker already reached this conclusion. Nothing recorded it in a form the tool reads, so it stayed in `byFile` as apparent backlog for the next pass to re-derive. ## The finding this makes concrete I checked five unclaimed files this phase (`agent-store`, `github-tracking-state`, `planner-overseer`, `auto-merge-finalization`, this one). **Every site in them was either a fallback arm or an already-documented deliberate leave** — `agent-store.ts:236` carries a comment from today's fleet phase explaining why it stays. So the remaining count is not a work queue. A meaningful share is correct code the tool cannot distinguish from owed work, and each fleet pass pays to re-derive that. Marking them is cheap, mechanical, and makes the number mean "conversions owed" — which is what every worker reads it as when picking a cluster. I marked only the file I claimed. The others belong to whoever holds them. ## Verification - `census --strict` exit 0; `tsc --noEmit` **0 errors** - `check:fnxc-future-dates`, `check:lane-wiring`, `check:sql-column-literals`, `check:inert-flag-seams` — all exit 0 - No behaviour change: the two expressions are byte-identical, only comments added ## Note on the marker's granularity The first marker covered only `isComplete` — the census attaches markers by *preceding comment*, so the sibling `isArchived` needed its own. Caught by re-running the census (2 → 1, not 2 → 0) rather than by reading. Worth knowing before marking a group of related literals. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bdedb6cf1a |
gate: fail the build on a NEW inert sync-lane conversion (#3062)
## Claim, and why it turned into a gate
I claimed the largest unclaimed unflagged cluster, `executor.ts` (4
guards at 3557/3581/3632/3642). All four are conditions of a
**synchronous** `store.on("task:moved", …)` listener — the same class as
`scheduler.ts`. Converting them needs either an `await` in a sync
prologue or the sync resolver, and the sync resolver is inert.
`executor.ts` already says so, at line 10459, dated 2026-07-30:
> **THE SYNCHRONOUS RESOLVER IS A NO-OP IN PRODUCTION.** … every
sync-resolved conversion resolves the DEFAULT workflow and answers with
the legacy ids no matter what board the task is on. That makes a sync
conversion cosmetic: the census counts it as converted, `--strict` goes
down by one, and the guard behaves exactly as the literal did. **Worse
than leaving the literal, because the number says the site is done.**
The next day, #3051 did exactly that to ten `scheduler.ts` arms. Census
fell by ten; nothing changed on any board (refuted live in #3058).
So the finding was already written down, in the file a converter would
be reading, in capitals — and the fleet phase produced the defect
anyway. **A comment cannot fail a build.** Converting `executor.ts`'s
four the only available way would have made me the third instance. I
flagged them and built the guard instead.
## What the check does
Per file: finds functions reaching `resolveTaskWorkflowIrSync`, the
locals assigned from them, and the `===`/`!==` guards consuming those
roles. Baselined per file; **fails on a rise.**
Not zero, deliberately. The existing sync guards are real and documented
— the scheduler's listeners genuinely cannot `await` today and their
authors said so. Demanding zero forces a revert or a day-one exemption
marker. What must not happen is *more* literals quietly becoming
inert-resolved.
Complements `check-inert-flag-seams.mjs`, which catches the opposite
shape (a lane parameter **no** caller supplies). This catches a
parameter that **is** supplied, from a source that always answers the
same thing — which passes that check cleanly.
## Why the shape is invisible
The obvious reading is wrong, and it is what makes this survive review.
The helper does **not** receive `undefined` and fall through to `??
"in-review"`. It receives a **real IR that resolves real traits** — the
default board's — so it answers with full confidence and the `??` arms
beside it are dead code.
```
tsc passes the value is a string, correctly typed
tests pass on the default board the constant answer IS the right answer
the census DROPS it counts comparisons against literals, and the literal really is gone
```
## Mutation evidence — including one against this check itself
| Mutant | Result |
|---|---|
| baseline | exit 0, 20 guards in `scheduler.ts` |
| convert one more literal to a sync-resolved lane (the #3051 move) |
**exit 1, 20 → 21** |
| convert the same literal to an **async**-resolved lane | exit 0 —
correctly silent |
The first draft **failed its own mutation test**: it matched only the
local-variable spelling (`const parked = resolveX(...)` then
`parked.review`), which is what #3051 used, and the inline spelling
`resolveX(store, id).review` walked straight past it while being exactly
as inert. A ratchet one rewrite evades is worse than none, because the
green result reads as proof. Both spellings now count.
## Limits, stated so nobody over-trusts it
Sources are matched **within a file by function name**, so a helper
imported from another module is not followed — this finds the dominant
local-helper shape and will miss a cross-module one
(`resolvePlannerLanes`, consumed in `executor.ts`/`triage.ts`, is
currently outside its reach). It proves a guard consumes a sync-resolved
answer, not that the answer is wrong for every caller. Tests are
excluded. Treat a report as a pointer to investigate.
## Census before / after
```
before: COLUMN guards (the backlog): 104
after: COLUMN guards (the backlog): 104
```
Unchanged by design — this converts nothing. It stops the count from
moving for the wrong reason.
Worth recording alongside it, measured across the current backlog: **21
of 104 already carry an explicit flag note**, **51 are
`self-healing.ts`** (concurrently claimed by **#3055, #3050 and #3049**
— three PRs, one file, still worth de-conflicting), and **28 are
genuinely unclaimed and unflagged**, the largest being these
`executor.ts` four. The cluster-sized work is close to exhausted; what
is left is scattered and mostly blocked, which is the pressure that
produced #3051.
## Verification
`test:gate` exit 0 · `pnpm lint` clean · lifecycle-column census exit 0
· `pnpm check:inert-sync-lanes` exit 0. No production file touched.
|
||
|
|
af470f7c05 |
convert(engine): self-healing lane cluster 56 -> 38 guards (repo 126 -> 108) (#3049)
## Census before / after
```
before after
self-healing.ts column guards 56 38
repo-wide COLUMN guards (backlog) 126 108
```
`self-healing.ts` was the largest single cluster by a wide margin — 56
guards against 12 in the next file. Baseline re-recorded in the same
commit; `--strict` green.
## Converted: 15 guards across 11 sweeps
Existing helpers only — `resolveProjectColumnsForRoles` with
`TERMINAL_ROLES` / `REVIEW_ROLES` / `countsTowardWip` / `hold` /
`archived`, the same shape this file already uses. No new helper, no new
resolution pattern.
What each was silently doing on a renamed board:
| sweep | behaviour before |
| --- | --- |
| `archiveStaleDoneTasks` | **both** guards inert, so every card counted
as an active dependent and the sweep archived **nothing at all** |
| `reconcileDependencyBlockingLeases` | no holder matched, so a stale
file-scope lease blocking an unmet dependency was never cleared |
| `reconcileCompletedBlockedTasks` | work whose blocker had cleared
stayed parked instead of advancing |
| `reconcileInReviewUnmetDependencies` | a card sat in review with unmet
dependencies and no rebound |
| `reclaimStaleActiveBranches` | archived cards were eligible for branch
reclaim |
| `reconcileInReviewBranchRebind` | the rebind list was empty |
| `autoReboundPausedScopeDecayDetailed` | no card was ever seen as
executing |
| `detectStalledCards` | finished cards counted as stall candidates |
| `recoverApprovedStrandedAiMergeCommit`,
`recoverDriftedAgentTaskLinks`, `cleanupStaleTempMergeWorktrees` | same
shape |
**Reused rather than duplicated:** `recoverWedgedActiveMerge` already
resolves `wedgedReviewColumns` via `resolveReviewColumnsFor` three lines
above the site I was converting, so the site now uses it instead of a
second resolution of the same question.
## One site I converted and then reverted
`clearStaleBlockedBy`'s memo closure carries an FNXC note stating the
literal is **deliberate**: the closure only decides whether to re-log an
already-logged blocker, so a renamed board costs a duplicate log line —
not a wrong lifecycle decision — and restructuring a sweep's control
flow to convert a logging decision is the wrong trade.
I read that note *after* editing the line. Restored.
Worth flagging separately: **it has the reasoning but no
`DELIBERATE-LITERAL` marker**, so the census keeps counting it and it
re-appears in the backlog as if unexamined. That is a marker gap, not a
conversion gap — the next person will make the same mistake I did.
## Not converted — flagged, not guessed
Ten of the twenty-four remaining sites are in **sync predicates with no
resolution seam**:
- `classifyPausedAbortWorkflowRecovery` (3)
- the `start()` task-moved listener (5) — compares event `from`/`to`
columns inside a sync callback
- `isWorkspaceOwnerLive` (1)
- `isPhantomExecutorBinding` (1)
Converting these means threading a flags parameter down from every
caller — precisely the unwired-optional-parameter shape this program
keeps finding inert (five were live on `main` at once per
`unwired-lane-parameter-guard`). They need a decision about *where the
resolution lives*, not a guess from me.
The other **14** are in async sweeps with a seam available and are
ordinary follow-on work in this same file.
## Verification (measured)
- self-healing suites — **816 passed / 41 files**
- `tsc --noEmit`, `eslint` — clean
- `pnpm test:gate` — green
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green
No changeset: `@fusion/engine` is private.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Self-healing workflows now continue functioning when workflow columns
are renamed.
* Improved recovery for stalled, blocked, paused, or disconnected
workflow states while preserving existing filters and actions.
* Temporary merge worktrees and drifted agent links are cleaned up more
reliably.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
||
|
|
06717ac3fa |
refactor(engine): resolve replan-target's advancement test by role (fleet, 4 sites) (#3052)
## Census
| | column guards |
|---|---|
| before | **126** |
| after | **122** |
`replan-target.ts`: **4 → 0**, and it drops out of the top-files list.
Baseline re-recorded in the same PR, as the ratchet requires.
## What changed
`hasAdvancedPastPlanning` asked "has this card moved past planning" as
four literal comparisons — `in-progress`, `in-review`, `done`,
`archived`. It now asks the same question in roles, from lanes the
**caller** resolves.
## Caller-resolved is the whole point
The module's sync twin `resolvePlannerLanes` reads
`store.resolveTaskWorkflowIrSync`, which returns the **default workflow
IR for every task under PostgreSQL**. Converting through it would have
improved the census while answering about a board the card isn't on —
the second failure shape in the learnings doc, already proven at this
exact seam by
`workflow-planner-lanes-sync-vs-async-live-e2e.pg.test.ts`.
The only production caller is `async`, so it uses
`resolvePlannerLanesForTaskAsync`.
**The caller's own inert resolution is fixed too**, not just the four
arms: `releasedToTodo` compared against `resolvePlannerLanes(...).hold`
— the sync twin — so it read `todo` on every board regardless of
vocabulary. One async resolution now supplies the planner column, the
merged-planning column and the forward lanes.
## Flagged, not guessed
The archive lane is a **separate argument** rather than a fifth
`PlannerLanes` role. Adding the field surfaced a genuine divergence
between the sync and async twins — `_workflow-vocabulary-fixture` models
no archive lane, so they disagree there — and that fixture backs **37
test files**. That divergence deserves its own change with its own
evidence; forcing it through a conversion PR would have meant editing a
37-file fixture to make my own change pass.
## Two larger clusters I did NOT claim, with reasons
I went by census size first and verified before writing:
- **`self-healing.ts` (56 guards, 44% of the backlog)** — already
claimed. Three branches hold it, one checked out in another worktree
(`convert/self-healing-lane-cluster-u7`). I'd drafted four sibling role
helpers before checking; reverted rather than collide.
- **`scheduler.ts` (12 guards)** — blocked by design and already
documented at line 907 by a prior fleet worker. The `task:moved` handler
is `async` but its **prologue is not**: no `await` between entry and the
terminal-blocker branch ~55 lines down, so hoisting a resolution turns
the prologue into a microtask and reorders this listener against every
other synchronous subscriber ("verified, not assumed"). Lazy resolution
doesn't help — the *condition* needs the lanes. Unblocking needs the
emitter to carry resolved lanes on the payload, which is a design change
rather than a conversion.
`restart-recovery-coordinator.ts`'s 4 sites are the trait-fallback arms
the census already counts as converted — converting those would delete
the legacy fallback, not add resolution.
## Measured
| check | result |
|---|---|
| replan + planner-lane suites | 11 files, **102 tests green** |
| triage suites | **374 tests green** |
| five gates + strict census | green; `tsc` clean |
| unconverted callers | byte-identical — absent lanes fall back to
`LEGACY_PLANNER_LANES`, absent `archivedColumn` keeps the legacy id |
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c9516dbd09 |
fix(engine): resolve archiveStaleDoneTasks lane guards by role (fleet: self-healing 56→51) (#3047)
Fleet phase. Claimed **`packages/engine/src/self-healing.ts`** — the largest cluster at **56 of 126** total sites. Verified unclaimed first: no open PR touches the file and no active worktree held a branch on it. ## Census before / after | | total | self-healing.ts | |---|---|---| | before | **126** | **56** | | after | **121** | **51** | `census --strict` exits 0; baseline re-recorded in this commit so the retired allowances cannot be regrown into. ## What converted, and why each role `archiveStaleDoneTasks` asked "has this card finished?" by comparing column ids, so on a renamed board it treated every finished card as live and archived nothing — the sweep was inert on exactly the boards this program exists to support. - **active-dependents scan** and **temp-worktree age gate** → `TERMINAL_ROLES` (complete ∪ archived): both ask "is this card done with, in any sense?" - **staleness filter** → `complete` **alone**: this sweep *archives* finished cards, so an already-archived card is not a candidate. Using the terminal pair here would have made the sweep consider its own output. **Union, not per-task, deliberately.** Over-inclusion is free at these sites because the per-card check still discards, and the union needs no per-task workflow selection — the failure mode `resolveWorkflowIrForTask` has, where a card with no recorded selection silently resolves to the built-in board. Recorded in `docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md`. ## The half-converted state is the interesting part Converting only the first two guards made `archiveStaleDoneTasks` **register as a converted sweep** — the existing ratchet suite grew from **36 to 38 tests** — and it then failed for still carrying `t.column !== "done"`. That is the failure mode worth naming: a partial conversion is worse than none, because the function now *looks* converted (it calls the resolver, it reads as role-aware) while one guard still pins it to the legacy vocabulary. Finishing the function turned it green. I would not have caught it from the diff. ## Verification - `self-healing` suites — **807 pass** (41 files) - `tsc --noEmit` — **0 errors** - `census --strict`, `check:lane-wiring`, `check:fnxc-future-dates`, `check:inert-flag-seams`, `check:sql-column-literals` — all exit 0 ## Flagged, not guessed — the remaining 51 Deliberately left, each for a stated reason rather than an omission: 1. **Move-transition matrices** (~1489–1504): `from`/`to` pairs encoding a legal-transition graph (`in-progress → todo|in-review|done|archived`). These are the *shape* of the lifecycle, not a lane lookup; converting them needs a transition-role model that does not exist yet. Guessing here would encode a wrong graph. 2. **`getLiveTaskColumn` comparisons** (~1398, 5313–5342): compared against a normalizing accessor that manufactures `"archived"` for soft-deleted rows. Those are protocol values, not column ids — converting them changes what the sentinel means. 3. **Sites without store access** in scope (several module-level predicates): need the resolved set threaded in as a parameter, which is a seam change per call site, not a substitution. 4. **`todo` requeue targets** (1927, 6181, 6303, 12060–12064): these pick a destination, so they want the single `intake`/`hold` answer from `resolveLifecycleColumns`, not a set — different arity, and several are inside sweeps whose rebound semantics I would be changing rather than preserving. Each is a real conversion; none is a one-line substitution, and doing them blind is how a guard count drops while behaviour gets worse. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4878bda197 |
fix(core): the mission bootstrap duplicate was archived into a lane the board does not declare (#3046)
## Invisible to both censuses
`archiveDefinedFeatureBootstrapDuplicate` writes `tasks.column`
**directly** rather than through `moveTask`:
```ts
.set({ column: "archived", updatedAt: … })
```
- the **lifecycle census** reads comparisons — an assignment isn't one
- the **move-target census** reads `moveTask` call arguments — this
never calls it
So on a board whose archive lane is renamed, the duplicate landed in a
column that workflow doesn't declare: a card in a lane the board can't
render, from a path that runs during ordinary feature bootstrap.
## Reuses the helper this class already has
`archivedLanesFor(taskId)` was added for the guards further up the same
file. It returns the legacy id when the task has no resolvable workflow,
so an **unconverted board is byte-identical**. No new resolution
machinery — the two `<> 'archived'` guards become `notInArray(column,
[...lanes])` and the write targets the resolved lane.
A board declaring several archive lanes is arbitrated by taking the
first, the same choice `resolveLifecycleColumns` makes. Multiple archive
lanes aren't a shape the builtin lineages produce.
## Measured
| check | result |
|---|---|
| mission-store PG suite | **36 → 38**, all green |
| new pair | differential — `filed` collides with no legacy id, and the
default-lineage control still lands in `archived` |
| mutation (hardcode the target back) | fails the renamed case |
| SQL literal gate · `tsc` | green |
## How this was found
Measuring the literal-column-**write** population for #2839: 51 raw
sites, of which 20 are the four builtin workflow IRs declaring their own
columns (correct by definition) and several more are archive-*entry
record* fields rather than board columns. This is the one I verified is
a real board write on a live path.
Worth noting the measurement itself was wrong twice first — my glob was
`packages/*/src/**/*.ts`, which requires a subdirectory and silently
skipped every top-level file in `src/` (including this one), and my
script printed only the first 14 findings so the grouping was over a
truncated list. Same scope-blindness class as #3000 and #3002, this time
in a throwaway scanner.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8b82e77fbf |
chore(core): delete liveParentFilter — no caller, and it carried a legacy lane literal (#3042)
Found while enumerating archive-exclusion sites for #3041. ## Unambiguously dead `liveParentFilter` has exactly **one** reference in the repo: its own definition. - not exported from `index.ts` or `index.gate.ts` - no test imports it - no production code calls it It nonetheless contained `column != 'archived'`, so it was one of the 22 sites the SQL column-literal gate tracks. ## Why delete rather than convert Converting it would mean adding lane resolution to code nothing runs — risk with no behaviour. That's the same argument #3041 makes for *not* converting the other two dead sites; deleting is the version of it that also removes the literal. ## The gate it documents is not being deleted Its docblock describes the document/artifact visibility gate (VAL-CROSS-015). That gate is real and still enforced — by the inline conditions inside `listLiveTaskDocuments` and `listLiveArtifacts`, which is presumably why this helper was never wired up in the first place. Only the unused composition goes. ## Measured | check | result | |---|---| | SQL literal population | **22 → 21**; the gate ratcheted its own baseline down and asked for the commit, included here | | `taskstore-remaining.test.ts` (archive-lineage suite) | **27 tests green** | | six gates + `tsc` | green | ## Not deleted, deliberately `listLiveTaskDocuments` and `listLiveArtifacts` are referenced **only** by that test file. That's a weaker signal than zero references — someone may have written them ahead of a consumer. Their literals stay counted, which is the honest state for code whose intent I can't read from the repo. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
35729699b8 |
fix(dashboard): Lane and ListView sorted every column with the LEGACY role defaults (wrong card order on renamed boards) (#3016)
`sortTasksForDisplayColumn` takes four role answers and defaults each to the legacy id. Its own header names the callers that never supplied them: > *"defaults to the legacy id so the callers that do not resolve flags (Lane, ListView) keep today's behaviour exactly."* On a renamed board, today's behaviour is the **wrong order**, silently: | lane | what is lost | |---|---| | hold | priority-then-FIFO queue order — an urgent card is no longer visibly next | | complete | completion-date ordering | | review | the merging card no longer floats to the top | Nothing throws, nothing logs. The cards are simply in the wrong order — which is exactly why this survived every existing test in these files: their fixtures use the built-in ids, where the defaults happen to be right. `Board.tsx` already resolves these from `column.flags`. Mirrored here rather than answered a second way, including its `complete && !archived` done-like rule. ## Reverted All **3** new `Lane` cases fail. Each picks inputs where the role order and the generic fallback **disagree**: - hold — equal priority, so role order is created-at and the fallback is task-id - complete — `columnMovedAt` DESC vs task-id ascending - review — a `merging` card, which the fallback ignores entirely **My first draft asserted urgent-first and passed with the fix reverted.** The generic sort also puts urgent first, so the assertion discriminated nothing. Recording that because it is the second time this shape has caught me: an assertion that is *true* is not the same as an assertion that is *load-bearing*. ## Coverage I do not have `ListView`'s identical wiring has **no component test**. Its harness stubs `fetchBoardWorkflows` with a never-resolving promise, and `listColumns` derives from the resolved workflow — so a renamed board is not drivable there without reworking that stub, which several other tests in the file depend on. The call site is covered structurally by the lane-wiring ratchet (baseline 19 → 17) and by the helper's own unit tests, but that is a structural guarantee, not a behavioural one. I would rather say so than imply the two callers are equally proven. ## Verification Lane + ListView + taskSorting + Board **357 passed** · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · lifecycle census `--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed task sorting in lanes and list views after workflow columns are renamed. * Preserved correct ordering for completed, on-hold, archived, merge-blocked, and review tasks. * Ensured task ordering reflects each column’s configured role rather than its previous identifier. * Maintained consistent ordering across board and list views. * **Tests** * Added coverage for renamed workflow columns and their expected task ordering. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
e9b24b69e8 |
fix(dashboard): the duplicate banner judged the canonical by legacy lane ids (#3032)
Found by applying the check I proposed on #3028: **re-test each `ALLOWED_OMISSIONS` entry — does the omission still fail once the excuse is removed?** It found a stale excuse on its first run. ## The entry's blocker was never tested ``` "…TaskDetailModal.tsx::isNearDuplicateCanonicalInactive" reason: "…Correct supply needs a fetch — a data change. See the note at the site." ``` The reasoning gets the hard part right: passing `detailColumnFlags` would answer about the **modal's** task, not the canonical, and would type-check while reading as a conversion. Rejecting that is correct. Then it concludes the seam needs a fetch — without checking what is in scope. - `columnFlagsByTaskId` is **already a prop of this component** (declared `:367`, destructured `:727`, used for the fan-out map at `:3718`), keyed by task id. - The canonical is `tasks.find((c) => c.id === nearDuplicateOf)` — drawn from the same loaded set the map covers. If the banner can render at all, the canonical is in `tasks`. So `columnFlagsByTaskId?.get(canonical.id)` is the canonical's own flags, no fetch. **`Column.tsx:307` already does exactly this**, with a comment making the same point about not reusing the row's flags — a sibling call site of the same function, solved. ## What was broken The banner's "this duplicates X" warning stayed up when the canonical had landed in a **renamed** complete lane, because `isNearDuplicateCanonicalInactive` fell back to the legacy ids and never saw it as finished. Same user-visible symptom #2997 fixed for the card chip; this is the modal. ## Verification | state | result | |---|---| | clean | seams gate exit 0; 123/123 across `TaskDetailModal.rendering` + `Column.neardup-flags-arrival` | | revert the supply | **gate exit 1** — `isNearDuplicateCanonicalInactive() — supplied by 10/11 call sites; omitted at TaskDetailModal.tsx:1 (of 2)` | That mutation is the point: with the allow-list entry present, this exact omission passed silently. It is now defended by the gate rather than excused by it. `tsc -p tsconfig.app.json` 0 errors in the file, lint clean, FNXC gate exit 0. ## The general point This is the second allow-list entry in two PRs whose stated blocker was wrong — #3028 removed the other one (*"needs a published-API change"*; the SDK is `private: true` and every consumer was in-repo). An `ALLOWED_OMISSIONS` entry is a deferral **carrying a gate's authority**. It reads as settled, it lives inside the checker, and it turns "nobody tested this" into "someone tested it and concluded no". A stale baseline *number* invites a recount; a stale *paragraph* invites agreement. Both entries this gate carried were stale, and the note at this call site had even been revised once — the revision corrected which flags were wrong to pass, and left the untested "needs a fetch" conclusion standing. Worth a periodic sweep of the remaining entries as they accumulate; with these two gone the list is empty, so the cheapest time to institutionalise it is now. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a460a9bbc0 |
fix(plugins,dashboard): the dependency graph drew every card with the LEGACY lane vocabulary (#3029)
## The third producer of unflagged cards — the one a host-side fix could not reach #3025 fixed the two producers that go through `renderTaskCard`. `GraphTaskNode` is a third: it imports `TaskCard` **directly** through the plugin's interop shim, so that fix bypassed it and every role helper inside a graph card kept reading the legacy ids. The same component also called the stuck predicate without its flags: ```ts const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); // no columnFlags ``` so `isWipColumnRole` fell back to the literal and **no card in the graph could ever be stuck on a renamed board**. Because `isStuck` gates `isActive`, a wedged card rendered with the **active** styling — the graph reported *"running"* about a task that had not moved in hours, while the main board showed the same card as stuck. That asymmetry between two views of one task is the defect, and it is what the new test pins. ## One cause, so one fix Both symptoms came from the same gap: `PluginDashboardViewContext` exposed `tasks` and nothing about the board's vocabulary. It now carries `columnFlagsByTaskId` — the same per-task map `renderTaskCard` already uses, **two lines away in the same object literal**. ## I filed this twice as blocked on a public-API change. It was not. ``` packages/dashboard @fusion/dashboard private: true packages/plugin-sdk @fusion/plugin-sdk private: true plugins/fusion-plugin-dependency-graph @fusion-plugin-examples/dependency-graph private: true ``` No published surface anywhere in the path — three in-repo private packages and a hand-written `.d.ts`. **#3026 landed the general form of that mistake while I was still making it**: a deferral's stated blocker is a claim, and mine decayed unchecked until I finally measured it. ## Two type decisions worth reviewing - **`Partial<TraitFlags>`** in the plugin-facing type, not the dashboard's `ExecutorColumnFlags` — that module's own header restricts it to `@fusion/core` and `react` imports so external plugin builds can consume it. Same runtime object either way. - **`MainContentProps.columnFlagsByTaskId` widened** from `{complete, archived, intake, hold}` to the flags the map really carries. It is built from `workflow.columns.find(...).flags`, so the four-flag declaration was a narrower view than the value — and `countsTowardWip`, which every wip predicate needs, was invisible through it. That narrow type is why threading this looked impossible at first. Absent still means legacy, matching how the host treats remote rows and off-board columns: the degraded answer is the documented literal, never *"this board has no wip lane"*. ## Revert proof Dropping the 4th argument: ``` AssertionError: expected 'graph-task-node graph-task-node--acti…' not to contain 'graph-task-node--active' Tests 1 failed | 26 passed (27) ``` The paired case (a fresh legacy `in-progress` card still reads active) passes both ways by design — it guards against over-detection, so I am not counting it as coverage. The gate agrees independently: `plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx: 1 -> 0`, baseline re-recorded 16 → 15 in the same commit. ## Verification (measured) - plugin suite — **185 passed / 20 files** - dashboard `dashboard/` + `plugins/` suites — **48 passed / 6 files** - `tsc --noEmit` clean in both packages; `pnpm lint` clean - `lifecycle-column-census --strict`, `check-lane-wiring` (15, none added), `check-sql-column-literals`, `check-inert-flag-seams`, `check-fnxc-future-dates` — green No changeset: all three packages are `private: true`. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5897d87e95 |
fix(gate): the lane census judged a call against a signature it never had (the false positive #3013's merge introduced) (#3021)
#3013's merge of same-named declarations fixed a false **negative** and introduced a false **positive**. `ModelSelectorTab` declares its own two-parameter `resolveEffectiveExecutor(task, settings)` — a pass-through with nothing lane-related — while an unrelated exported function of the same name in `effective-model-resolution.ts` takes `columnFlags`. Both local calls were reported unwired against a signature they have never had. Only **exported** declarations enter the accepting map, so the rule is exact: if the calling file declares the name itself and the map entry came from a different file, the call resolves to the local declaration and is not a lane call here. ## The version I did not ship My first attempt re-ran the detector over the single calling file and used that result. It scored *better* on this tree — **19 → 16** instead of 19 → 17, also clearing `bucket-mapping.ts` — and I threw it away. A single-file pass cannot resolve an **imported** options interface. A locally-declared function with an imported context type would quietly stop being lane-accepting, and every call to it would stop being checked. That is a false negative, which is the one failure a ratchet must not have; the better-looking number came from the gate seeing less. The global pass still does all type resolution here — only the *choice* of declaration is local. ## Measured | | | |---|---| | new tests | 3 | | against the old census | **1 of 3 fails** — the positive | | baseline | **19 → 17**, exactly the two `ModelSelectorTab` sites | Both negatives pass either way and they are the ones that matter: a file declaring its **own** exported lane function is not shadowed by itself, and a file declaring nothing is judged normally. Shadowing must not become a way to disappear a genuine unwired call. ## Still flagged, honestly `bucket-mapping.ts:75` stays in the baseline. `bucketForTask(task: TaskItem)` is only lane-accepting because `TaskItem` *declares* `columnFlags` — the lane data rides on the domain object, so passing `task` forwards it inherently. That is a different limitation (options-bag vs domain-entity parameters) and I have not tried to fix it here; it accounts for 2 of the remaining 17 along with `otherBucketSecondaryLabel`. ## Verification `node --test scripts/__tests__/check-lane-wiring.test.mjs` **19 passed** · `pnpm test:gate` 13 + 161 + 487 + 71 · lint · lifecycle census `--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green. |
||
|
|
6f936f2de7 |
fix(cli): the node-override guard never fired on a renamed board, so mid-flight changes were allowed (#3019)
## The node-override guard never fired on a renamed board
`fn_task_update` called the guard with no options:
```ts
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
```
so `wipColumns` fell back to its documented default of
`{"in-progress"}`. On a board whose WIP lane is named anything else,
`wipColumns.has(task.column)` is false, the mid-flight check passes, and
**an operator can change the node override on a running task** —
precisely what that guard exists to refuse, in its own words:
> "Is this task executing right now?" — keyed on the literal, a renamed
board let an operator change the node override MID-FLIGHT on a running
task, which is exactly what this guard exists to refuse.
That note is attached to the `wipColumns` option added for this purpose.
The CLI simply never passed it.
## Two assumptions in the guard's own docs that did not hold
```
Both callers supply them. An omitted set keeps the legacy id, which is what a caller
without cheap IR access (a CLI tool, a route with only a task row) still gets.
```
1. **"Both callers"** — this is a *third* one, and it was in
`check-lane-wiring`'s known-unwired baseline the whole time.
2. **"a CLI tool … without cheap IR access"** — this handler is async
and has already awaited `store.getTask`, so one more resolve costs
exactly what `resolveTaskLifecycleColumns` already costs elsewhere **in
this same file** (the linked-lineage label at ~1239). The assumption was
reasonable in general and wrong here.
Passed present-but-conditionally-valued rather than as a conditional
argument: an omitted set still keeps the documented legacy default, and
only that shape is visible to `lane-wiring-census`, which matches an
object-literal argument and cannot see a ternary.
## Coverage — stated rather than implied
**There is no new unit test.** The regression guard is the ratchet
itself, and it is a real revert-proof: with the wiring removed,
```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:
packages/cli/src/extension.ts: 1 unwired now, baseline allows 0
```
Verified by actually reverting it, not by assuming. Baseline re-recorded
19 → 18 in the same commit, so the allowance cannot be regrown into.
A behavioural test would need a custom workflow definition persisted
*and* selected inside the integration harness to get a card resting in a
renamed WIP lane. That is worth doing and I would take it as follow-up
harness work — but it is not part of this fix, and I would rather name
the gap than let "85 passed" imply coverage I did not write.
## Verification (measured)
- **85 passed** across `extension.test.ts`,
`extension-experiment-finalize.test.ts`,
`task-list-board-columns.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (18, none added), `lifecycle-column-census
--strict`, `check-inert-flag-seams`, `check-fnxc-future-dates`,
`check:changesets` — green
Changeset included (`patch`): `packages/cli` is the published
`@runfusion/fusion` and this changes guard behaviour operators rely on.
|
||
|
|
3a016b1f17 |
fix(scripts): four FNXC stamps carried hour 26, and main has been red on them (#3010)
## `main` is currently red on `check-fnxc-future-dates` Four stamps read `2026-07-30-26:10` — an hour that cannot exist. They're exactly what #2995 taught this gate to catch. That PR landed the hour validation (`00-23`) *after* #2999 had already merged these four, so the gate started reporting a defect that was already sitting there rather than one introduced afterwards. **The guard is working**; nothing was checking before it. ``` scripts/lib/backend-db.mjs:41 scripts/reconcile-task-state-consistency.mjs:8, :51 scripts/__tests__/reconcile-task-state-consistency.test.mjs:109 ``` Corrected by **literal normalisation** — 26:10 on the 30th *is* 02:10 on the 31st — rather than flattening them to an arbitrary in-range hour. AGENTS.md specifies `yyyy-MM-dd-hh:mm`, and the stamp exists to give a readable why-does-this-exist trail, so the ordering is the part worth preserving. ## The baseline tightening rides along, and it's a date rollover Stamps written yesterday as `2026-07-31` were future *then* and were baselined as such. Today they're past, so **176 files ratchet to zero**. Nobody did anything. The gate rewrites the baseline as a side effect and exits 0, so leaving it uncommitted dirties the tree on every subsequent run **for everyone** — which is why it belongs in this commit rather than a later one. Re-recording on a decrease is the rule this gate and its siblings already state. Worth knowing about the design, since I wrote it: this churn recurs whenever a day boundary passes with future-dated stamps in the baseline, and it shrinks only as people stop writing them — which is the behaviour the gate exists to produce. **93 files still carry a non-zero allowance**, so the drain isn't finished. If it stays noisy once those clear, the gate's fail-on-tighten contract is the thing to revisit, not the stamps. ## Measured | check | result | |---|---| | gate | red before, **exit 0 after**, stable across two consecutive runs | | baseline | −176/+25 entries, all date-rollover | | inert-seam · sql-literal · lane-wiring · census | all green | | reconciler's own suite | green | ## One correction to a claim I made earlier this session While investigating I reported the gate as hanging for 600s. It wasn't — the harness killed the process (exit 144) and the empty output made it look like a stall. The gate completes in seconds. Noting it because I nearly filed a performance bug against a healthy script. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |