Commit Graph

1105 Commits

Author SHA1 Message Date
gsxdsm
7d9d097acf fix(cli): the TUI board fell back to the LEGACY workflow — it rendered a triage lane the default no longer has (#3178)
Found by following an unexplained number rather than by a sweep: while
re-verifying #3141 the resolver reported `intake: "todo"` where
`BUILTIN_CODING_WORKFLOW_IR` resolves `intake: "triage"`. That
divergence is correct and intentional inside core — and wrong here.

## The defect

`dashboard.ts` resolved a task's columns as `def?.ir ??
BUILTIN_CODING_WORKFLOW_IR`, and its card-chip fields the same way. That
constant is the **legacy** monolithic IR (`builtin:legacy-coding`); the
catalog's actual default is `resolveDefaultWorkflowIr()`. Post-U11 they
differ **by a whole column**:

```
default  todo, in-progress, in-review, done, archived          (planning merged into todo)
legacy   triage, todo, in-progress, in-review, done, archived
```

So a task with **no workflow selection row** was rendered against a
six-column board including `triage` — a lane the real default no longer
declares.

## The same drift is already documented as fixed elsewhere

`builtin-workflows.ts` records it:

> `prepareWorkflowMovePolicyPreflightImpl` resolved the default through
the catalog while `resolveTaskWorkflowIrForMove` used the raw constant,
so a task with NO selection row produced two different workflow
signatures and every flag-ON move threw *"workflow move policy preflight
is stale"*. Both sides (and the sync resolver) now call this helper so
the default cannot drift again.

This surface was missed, and it is the **last non-test consumer of the
legacy constant outside core**.

## Test scope, stated because it is narrow

Driving the TUI end-to-end needs a rendered terminal and a live store.
That harness does not exist here, and building one to assert a fallback
would be testing the harness. So the test pins the two facts that make
the bug possible and the fix meaningful:

1. **the two IRs genuinely disagree, about `triage` specifically** — if
a future change re-merges them, this reports it rather than leaving the
fix silently pointless;
2. **the source no longer reaches for the legacy constant.**

(2) is a source assertion, weaker than driving the code. It is used for
the same reason as the `FloatingWindow` aria-label scan: the defect is a
**value at a call site**, there is no single render that reaches both
sites, and a per-site render test would pin the one someone bothered to
write. Both assertions are anti-vacuity guarded — the IR comparison
fails if either side stops resolving to a v2 column set.

## Verification

| | result |
|---|---|
| cli `tsc` | **0 errors** |
| new test | **2 passed** |
| mutation — restore `?? BUILTIN_CODING_WORKFLOW_IR` | **1 failed / 2**
|
| census `--strict`, `check-fnxc-future-dates` | exit 0 (this class is
invisible to the census — an argument, not a comparison) |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:13:57 -07:00
gsxdsm
25b3c06d2d fix(plugins): compound-engineering pipelines stalled forever on a renamed board (#3022)
Closes #3020 — which I filed **instead of** fixing, on a rationale that
turned out to be wrong.

I said the plugin had no scaffolding for faking `CePipelineStore` +
`taskStore` together. It does: `_harness.ts` already builds a real
`PluginContext` over a live PostgreSQL layer. The gap was **two missing
readers on its task-store stub**, not missing infrastructure. I checked
the harness only after filing.

## The defect

`TERMINAL_COLUMNS` is `{in-review, done}`, and the reconciler advances a
pipeline only when **every** current-stage board task is in that set. On
a board whose review and completion lanes are renamed that's false for
every task, permanently:

- the pipeline never advances a stage
- it never creates its outbound task
- it sits `running` indefinitely

Nothing errors, so it reads as work that hasn't finished. Unlike the
display defects in this family (#3014, #3017), the CE flow actually
**stops**.

## Shape

The decision is extracted to an exported `isStageTerminalColumn` because
it *is* the whole decision. Left private it could only be reached
through a pipeline-state + links + board-tasks fixture, and the half
that needed proving is that a renamed board resolves to its own lanes
through this store.

It uses `resolveReviewColumns` rather than re-deriving the union — that
helper is the documented review **set** (`mergeOrchestration ∪
mergeBlocker ∪ humanReview`), so a board splitting those across a merge
lane and a human lane is covered without this site drifting from it.

## Two things my first attempt got wrong

**The fixture spelled traits in camelCase** — `{ trait: "humanReview"
}`. Trait **ids** are kebab-case (`human-review`, `merge-blocker`,
`wip`); the camelCase names are the resolved **flags**. Those columns
therefore resolved to *no roles at all*, silently, because an unknown
trait isn't an error. `complete` is spelled identically in both
vocabularies, which is exactly what made the first run look like
*"complete works, review is broken"* rather than *"the fixture is
wrong"* — I nearly went debugging the production union.

**The harness extension is additive** and inert until a test seeds it,
so all 24 existing plugin suites see the previous shape.

## Measured

| check | result |
|---|---|
| new suite | **4/4** |
| reverting to the literal-only gate | fails **exactly 2** — the
renamed-terminal case, and a board declaring a NON-terminal column named
`done` — while the legacy control and the WIP/intake negative still pass
|
| plugin suite | **24 files, 184 tests green** |
| `tsc` + all five gates | clean |

That second row is the one that matters: the `done`-without-`complete`
board is the only shape where a real resolution and a legacy fallback
disagree, so it's what separates the fix from a lucky agreement.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:20:04 -07:00
gsxdsm
f8155cafd7 fix(cli): the node-override guard saw only the FIRST wip lane (#3023)
Follow-up to #3019, which merged with an incomplete fix. I found this
while sitting down to write the test that PR was missing.

## The guard still never fired, one lane over

#3019 wired `fn_task_update`'s guard like this:

```ts
const nodeOverrideLifecycle = await resolveTaskLifecycleColumns(store, task.id);
wipColumns: nodeOverrideLifecycle?.wip ? new Set([nodeOverrideLifecycle.wip]) : undefined,
```

`resolveTaskLifecycleColumns` → `resolveLifecycleColumns`, whose
per-role accessor is **first match**
(`workflow-lifecycle-traits.ts:353`):

```ts
const first = (flag) => resolved.find((c) => c.flags[flag] === true)?.id;
```

The guard's contract is **every** column carrying the trait — its own
resolver uses `columnsWithFlag(ir, "countsTowardWip")`. So on a board
with a build lane beside a verify lane, a task sitting in the **second**
wip lane still slipped the mid-flight check, and an operator could still
repoint the node of a running task. That is the defect #3019 set out to
close.

Interchangeable on any single-wip-lane board, which is exactly why it
read as correct — the same arity trap #2975 removed from the surfacing
family.

## The fix

Use `resolveNodeOverrideLanes`, the guard's own resolver, which
`task-update.ts` and `branch-and-pr-entities.ts` already call. All three
callers now resolve identically and the V1/unresolvable fallback lives
in one place. Needed a one-line re-export from `@fusion/core`.

**Mutation:** forcing the resolver to first-match (`.slice(0, 1)`) fails
the new case, 1 of 32.

The new test names **two** wip lanes, because that is the only shape
that separates the two resolutions — a single-wip-lane test passes
against both, which is why #3019's gap was invisible and why I would
have written a useless test if I had not read the implementation first.

## A gate constraint worth recording

My first version passed the resolved object straight through:

```ts
validateNodeOverrideChange(task, normalizedNodeId ?? null, overrideLanes)
```

Identical at runtime, and it turned the lane-wiring gate **red**:
`check-lane-wiring` matches an object-literal argument and cannot see
through a variable, so the correct call reads as UNWIRED. #3019's header
records hitting the same constraint — and it is what pushed that PR
toward resolving the lanes inline, which is where the first-match bug
entered.

So the gate's shape requirement steered a correct instinct into a subtly
wrong implementation. The fix here spells both keys explicitly,
satisfying the gate without the bespoke resolution. Worth someone
deciding whether the census should follow a variable to its initializer
— but that is a change to a shared ratchet, and I have noted it at the
call site rather than making it.

**Verified:** 32/32 core guard suite, `tsc` 0 errors for both packages,
lane-wiring gate exit 0, FNXC gate exit 0, lint clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:08:54 -07:00
gsxdsm
5659ccace9 test(cli): pin the node-override error contract on a renamed board, which is what #3019 actually changed (#3024)
## What #3019 actually changed, pinned — and a correction to my own
claim

I described #3019 as closing a hole where an operator could re-route a
running task on a renamed board. **That was wrong.**
`TaskStore.updateTask` runs the same guard with its own resolved lanes
(`resolveNodeOverrideLanes`) and throws, so the change was refused
either way.

This test is how I found out: I wrote it to cover #3019's wiring and it
passed against a tree with that wiring removed. A test that passes with
the change reverted is not a test, so I went looking for what was really
refusing — and it was the store.

## But the two paths *are* distinguishable, which my correction then got
wrong in the other direction

In correcting myself on #3019 I said the paths were externally
indistinguishable and no test could separate them. Also wrong. Measured
both ways:

| | `details.error` |
| --- | --- |
| pre-check fires (wired) | `"task-in-progress"` — machine-readable
reason code |
| pre-check misses (unwired) | `"Cannot change node override for KB-001
while it is in progress…"` — the store's thrown prose |

So on a **legacy** board a caller could branch on `task-in-progress`; on
a **renamed** board it silently got a sentence instead. That is a real
API inconsistency, visible only to whoever was parsing it — the kind of
thing nobody notices until it breaks.

That is what these cases pin, and it is the honest description of
#3019's value: an error-contract fix, not a security fix.

## Revert proof

With #3019's wiring removed:

```
Expected: "task-in-progress"
Received: "Cannot change node override for KB-001 while it is in progress. …"
      Tests  1 failed | 1 passed (2)
```

Verified by actually reverting, not by reading the source — which is the
discipline that caught both of my wrong claims above.

The paired case ("still allows the override once the card leaves that
wip lane") passes both ways by design; it guards against over-refusal,
so I am not counting it as coverage of the contract.

## Also closes the gap I named in #3019

That PR shipped with `check-lane-wiring` as its only regression proof,
and I said a behavioural test was owed. The two are complementary and
fail for different reasons: **the ratchet** fails if the argument stops
being passed; **this** fails if it is passed and the contract still
degrades.

## Verification (measured)

- **2 passed / 0 failed**
- `tsc --noEmit` clean; `eslint` clean (one pre-existing warning, no
errors)
- `check-fnxc-future-dates`, `lifecycle-column-census --strict`,
`check-lane-wiring` — green

Tests only; no product file touched. No changeset.

## Note on the harness, for whoever writes the next one of these

Seeding a card into a renamed lane has two traps, both inherited from
`merge-blocker-renamed-review-lane.test.ts` and both recorded in this
file's header: the real API is `createWorkflowDefinition` +
`selectTaskWorkflow` (the plausible `saveWorkflowDefinition?.()` does
not exist and the optional call swallows it silently), and moving a card
takes `moveTask`, not `updateTask({ column })`. Both are guarded here by
asserting the card really is in `building` before the subject runs.
2026-07-31 01:06:09 -07:00
gsxdsm
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.
2026-07-31 00:52:50 -07:00
gsxdsm
72f5f8e51a fix(gate): the FNXC stamp gate never validated the hour, so 25:30 passed (#2995)
`check-fnxc-future-dates.mjs` validates the **date** portion of a stamp
and never looks at the clock time:

```js
const STAMP = /FNXC:[A-Za-z0-9_-]+\s+(\d{4}-\d{2}-\d{2})/g;
…
for (const match of source.matchAll(STAMP)) if (match[1] > today) hits += 1;
```

The capture stops before the hour, so a stamp may carry **any** `hh:mm`
and pass. Found while pre-flighting #2992, whose new comments read
`2026-07-30-25:30`.

## It is not one typo

Four stamps **already on `main`** carry a clock time that cannot exist:

```
packages/cli/src/__tests__/task-list-board-columns.test.ts:2     -24:40
packages/cli/src/commands/task.ts:29                             -24:40
packages/cli/src/commands/task.ts:636                            -24:40
scripts/check-lane-wiring.mjs:18                                 -24:00
```

Three separate authors, so this is the gate's blind spot rather than one
person's slip — and #2992 adds two more, which is how I noticed.

AGENTS.md specifies `yyyy-MM-dd-hh:mm`. The stamp's whole purpose is to
make the FNXC record a readable chronology of *why* code exists; a
timestamp that cannot exist quietly costs it that, and nothing was going
to catch it.

## The fix

Hours `00-23`, minutes `00-59`, counted per file **alongside** the
future-dated population rather than as a separate gate — same defect
class (a stamp that does not describe a real moment), and one ratchet is
cheaper to keep honest than two.

**Mutations, both directions:**

| stamp | result |
|---|---|
| `2026-07-30-25:00` | **flagged** |
| `2026-07-30-23:75` | **flagged** |
| clean tree | `475 known future-dated stamp(s), none added`, exit 0 |

## On the four existing stamps

Normalized by clamping the impossible hour to `23`, minutes preserved,
so relative ordering within each file survives. **That is a
normalization with a stated rule, not a claim about the true minute** —
`-24:40` most plausibly meant "just past midnight", but writing
`2026-07-31-00:40` would be future-dated against today's local calendar
and fail the very gate this PR extends. Clamping keeps every stamp real,
ordered, and non-future; the exact minute was already unrecoverable.

**Verified:** FNXC gate exit 0, lane-wiring gate exit 0,
`task-list-board-columns` 5/5, lint clean.

Comment-only changes to the CLI files (stamp text inside FNXC blocks),
so no behaviour change and no changeset.

Noted separately on #2992 so its two new stamps get corrected there
rather than landing and immediately failing this gate.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:03:02 -07:00
gsxdsm
a57f6699b3 fix(cli): fn task list never printed cards in renamed columns (#2986)
## `fn task list` never printed cards in renamed columns

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

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

## Found where the previous author left it

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

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

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

## Two deliberate choices

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

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

## Coverage, and its limit stated plainly

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

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

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

## Verification (measured)

- **86 passed / 3 files** — new suite plus `bin.test.ts` and
`pr-merge-review-lane.test.ts`
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (26, none
added), `check-fnxc-future-dates` — green
- `pnpm check:changesets` — clean; changeset included (`patch`), since
`packages/cli` is the published `@runfusion/fusion` and this is
user-facing
2026-07-30 23:29:56 -07:00
gsxdsm
be79fe0db6 fix(cli): PR merges silently never ran on a renamed board — the blocker was asked about in-review (#2976)
## PR merges silently never ran on a renamed board

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

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

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

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

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

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

## Narrow resolution, deliberately

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

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

## One shape choice worth flagging

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

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

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

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

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

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

## Revert proof

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

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

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

## Verification (measured)

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

**Changeset added** (`patch`). `packages/cli` is the published
`@runfusion/fusion` and this changes user-facing merge behaviour, so
AGENTS.md requires one. My first pass hedged and left it to a maintainer
— that was wrong, the rule is not discretionary, and it is now in the
branch.
2026-07-30 22:46:19 -07:00
flexi767
dd930c8d7d fix(cli): qualify cross-fork PR heads (#2377)
## Summary

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

## Root cause

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

## Validation

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


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

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

---------

Co-authored-by: v <v@v.speedport.ip>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-07-30 21:50:58 -07:00
gsxdsm
189f237a07 fix(security): make the approval and permission gates actually enforce (#2457)
## Why

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

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

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

## Scope

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

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

## What changed

8 bisectable commits:

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

## Review follow-ups

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

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

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

## Behavior changes requiring operator review before rollout

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

## Verification

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

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

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

### A mutation check that failed to fail

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

## Known limitations (stated, not papered over)

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

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

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

## Summary by CodeRabbit

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

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

---------

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

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

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

Closing #2924 as superseded by this.

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

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

* **Bug Fixes**
* Improved task delegation messages when workflow pickup cannot be
confirmed.
* Delegation results now clearly indicate when a task has not been
verified for pickup.

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:59:14 -07:00
gsxdsm
b85e5f90e1 fix(create): two task-CREATE destinations named a lane the board does not have (#2843)
Both files sat at **census-zero** and both wrote real cards into columns
no workflow declares. The census scores `===` comparisons, so a lane
literal passed as a **call argument** is invisible to it — one of the
four census-blind classes. These are the only two explicit-`column`
creates in production:

```
packages/dashboard/src/routes/register-gitlab.ts:108   column: "triage"
packages/cli/src/extension.ts:5243                     column: "todo"
```

## The two defects

**`register-gitlab.ts` — `column: "triage"`, a column U11 DELETED.**
This one is broken on *every* board, not only renamed ones: the default
lineage is now `todo | in-progress | in-review | done | archived`.
`createTask` already resolves the intake column of the workflow it
selects (`resolvedEntryColumn`), and an explicit `column` **overrides**
that resolution — which is why the stale literal survived U11. Nothing
rejects the write and nothing logs it: the route answers `201` with a
task id and the imported card is simply not on the board. Same shape as
the `task-update.ts` triage defect fixed earlier in this program.

Fix: omit `column` and let `createTask` resolve intake.

**`extension.ts` `fn_delegate_task` — `column: "todo"`.** On a workflow
whose ready lane is named anything else, the delegated card goes to an
undeclared column: written, reported to the caller as delegated, never
visible to the agent it was delegated to.

Fix: resolve the selected workflow's `hold` lane. **Deliberately not**
"omit the column like the GitLab route" — the tool's own contract is
*"the task goes to the ready-to-work lane and the target agent picks it
up on its next heartbeat"*, so inheriting intake resolution would park a
delegated card in a manual-intake lane waiting for a human. That would
be a behaviour change; `hold` is the role that names the lane the
literal meant.

## New helper: `resolveWorkflowColumnForRole(store, role, workflowId?)`

The **write**-shaped counterpart to `resolveProjectColumnsForRoles`. The
read helper unions in the legacy ids because an extra id in a query set
is inert; here the same trick is a silent wrong write (post-U12 an
undeclared column is a `TransitionRejectionError` on move, a phantom
lane on create), so it returns one column from one workflow, or
`undefined`.

**A contract I got wrong twice, now pinned by a test.** `undefined`
means *"this workflow declares no such column"* and nothing else.
`resolveWorkflowIrById` never throws and never returns nothing — an
unregistered builtin id, a missing definition row and a failing read all
resolve to the default coding IR (branded via `markFellBack`). So an
unreadable workflow yields the **built-in** hold lane, not `undefined`,
and both call sites' `?? "todo"` fallbacks are narrower than they look.
Two of my first test cases asserted the opposite and failed; the
behaviour is the resolver's, and the write it produces is identical to
the caller's own legacy fallback either way.

## Revert proofs (measured, not asserted)

| revert | failure |
|---|---|
| `column: holdColumn` -> `column: "todo"` | `extension.test.ts`:
`expected 'todo' to be 'queued'` |
| omitted column -> `column: "triage"` | `routes-gitlab.test.ts`:
`expected 'triage' to be undefined` |

The two neighbouring `fn_delegate_task` cases stay green under the first
revert, because the built-in board and the test's `linearWorkflowIr`
both call the lane `todo` — which is exactly why this literal survived
every previous pass.

The GitLab case asserts **absence** of the key rather than a resolved
id: the store there is a fake whose `createTask` echoes its input, so
asserting a resolved value would be testing the fake. Absence is the
property that hands the decision to the real `createTask`.

## Census

| file | before | after |
|---|---|---|
| `packages/dashboard/src/routes/register-gitlab.ts` | 1 | 0 |
| `packages/cli/src/extension.ts` | 1 | 0 |

Baseline tightened. It also picks up
`packages/core/src/task-store/moves.ts` 2 -> 0, which was **already true
on main** — not from this diff.

## Noted, deliberately not changed

- `validateAssignableAgentId`'s synthetic probe a few lines above still
uses `{ id: "<new>", column: "todo" }`. It feeds `isImplementationTask`,
whose `IMPLEMENTATION_TASK_COLUMNS` set an earlier worker documented as
deliberately-not-converted (converting it makes the routing policy async
— an agent-admission behaviour change). On a renamed board the probe is
now *stricter* than the real destination, which is the safe direction
and matches the pre-existing behaviour.
- The third site from this bucket, `workflow-node-handlers.ts:455`
(`transitionTask({ column: "in-review" })` on the `review-handoff`
seam), is a **hard** failure rather than a silent one — `transitionTask`
routes through `moveTask`, which post-U12 throws
`TransitionRejectionError` for an undeclared destination, so the
workflow walk dies at the handoff on any renamed review lane. It is
engine (`batch-engine`) and fixing it properly touches `executor.ts`,
which #2820 is also editing. Left for that batch rather than opened as a
conflicting edit.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit -p tsconfig.json` for `@fusion/core`,
`@runfusion/fusion`, `@fusion/dashboard` — clean
- `node scripts/lifecycle-column-census.mjs --strict` — exit 0
- targeted: `project-lane-vocabulary.test.ts` 14/14,
`routes-gitlab.test.ts` 8/8, `extension.test.ts -t fn_delegate_task` 9/9

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* GitLab-imported cards now appear in the workflow’s configured intake
lane.
* Delegated tasks now move to the workflow’s configured hold lane,
including workflows with custom lane names or separate intake and hold
lanes.
* Delegation reports the task’s final lane and provides an error when it
cannot be moved successfully.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:20:38 -07:00
gsxdsm
6fc98fd6c7 the third census-invisible class: 51 hardcoded moveTask destinations, measured — and duplicates never archived on a renamed board (#2808)
A third census-invisible class, measured — plus the two worst instances
fixed.

## The shape

```ts
if (task.column !== "in-review") { … return; }     // the census counts THIS
await this.store.moveTask(taskId, "in-progress");  // and cannot see THIS
```

The census is an AST scan for **comparisons**. A `moveTask` destination
is a **call argument**, so no backlog entry ever points at one.
Converting the guard alone is *worse than converting neither*: the
handler starts admitting work on a renamed board and then tries to move
the card into a lane that board may not declare.

This bit twice in one week — #2797 (`branch-worktree` requeued into a
lane that may not exist) and #2807 (a GitHub "changes requested" review
dropped, then a move to a hardcoded `in-progress`). Both times it was
found only because the guard *next to it* happened to be under
conversion. So I went looking.

## Measured

Across `core`/`engine`/`dashboard`/`cli`/`plugins`, excluding
`__tests__`/`*.test.*` and comment lines:

| | count |
| --- | ---: |
| hardcoded `moveTask` destinations in production | **51** |
| …passing `recoveryRehome: true` — **deliberate**, not defects | 22 |
| …plain, rejected on a board that does not declare the target | **29**
|

**The 22 must not be "fixed".** `moves.ts` exempts them on purpose
(#1411): a card stranded in an undeclared column has to stay rescuable
to a legacy safe-landing column, or it can never be recovered at all. A
sweep that converts them deletes the rescue path. That distinction is
the reason this is 29 and not 51, and it is why I measured before
writing.

## Why this got sharper recently

The `workflowHasColumn(workflowIr, toColumn)` rejection used to sit
inside a block gated on `isWorkflowColumnsCompatibilityFlagEnabled` — a
settings key **nothing in production writes** — so it never executed and
the legacy `VALID_TRANSITIONS` table decided instead. U12 hoisted it out
of that dead branch and it is now live, proven on a real store by
`live-move-path-undeclared-target.test.ts`:

```
moveTask(card in "todo" -> "triage")  now REJECTS: /Unknown column for this workflow/
```

That changed the failure mode of all 29 from *"silently lands the card
in an undeclared column"* to *"throws"*.

**29 is not a crash count.** Whether a throw surfaces or disappears
depends on whether the caller catches, which is per-site and I did
**not** measure it — the doc says so explicitly rather than letting the
number imply severity it hasn't earned.

## Fixed here: 9 of the 29

`duplicate-intake` and `duplicate-guard` both archive a duplicate. On a
renamed archive lane the move is rejected, so **the duplicate is never
archived and keeps sitting on the operator's board as live work** — and
in `duplicate-guard` the row has already been stamped
`deterministicDuplicateOf`, so it is *marked* a duplicate while
occupying an active lane. Half-applied, which is the same trap as
#2797's branch clear.

Both now resolve the `archived`-trait column from the task's own
workflow through one shared helper, unioned with the legacy id.

**`cli/commands/task-lifecycle`** — `finalizePullRequestMerge` and
`finalizeNoOpMergeTask` both move the card to a hardcoded `"done"`, and
both run `updateTask({ status: null, mergeRetries: 0 })` *first*. On a
rejection the merge has already landed and the bookkeeping is already
cleared while the card never reaches its complete lane: the operator
sees a merged branch, a card still sitting in review, and a reset retry
counter. Same half-applied shape as #2797's branch clear. Both now route
through one resolver so they cannot drift.

**`contamination` / `foreign-only-contamination` (×2) /
`restart-recovery-coordinator`** — four recovery requeues to a hardcoded
`"todo"`, none of them a `recoveryRehome` escape. On a board without
that column the move is rejected and **the recovery never completes** —
the card stays contaminated or stranded, which is precisely the state
these paths exist to clear.

**Consolidation.** `resolveReboundTargetForTask` and
`resolveArchiveTargetForTask` now live beside
`resolveTaskLifecycleColumns` in `workflow-lifecycle-traits`, already
the store-dependent resolution seam. My first pass put the archive
helper inside `duplicate-intake` and had `duplicate-guard` import it
from there — wrong home, and it would have grown a copy per caller as
more sites converted. Seven call sites now share two definitions.

**Plain (non-`recoveryRehome`) destinations: 29 → 21.**

**Coverage on the CLI pair is scoped, and I'd rather say so than imply
more:** the test covers the *resolver*, not the two call sites. Both
enclosing functions are private and reachable only through
`processPullRequest`, which needs a live GitHub surface — exporting them
purely to test wiring is a worse trade than stating what is covered.
Three cases: renamed lane resolves, no-workflow falls back to the legacy
id (which also pins that a default board is byte-identical), and a
throwing lookup falls back.

## Revert result (measured)

| conversion | reverted → |
| --- | --- |
| duplicate archive destination | new case fails — `moveTask` called
with `"archived"` on a board whose archive lane is `boxed` |
| CLI complete-lane resolver | replacing the body with a bare `return
"done"` fails the renamed case |
| both move-target resolvers | replacing either body with a bare return
of its legacy id fails 5 cases across the resolver suite and
`duplicate-guard` |

Each resolver has a **non-vacuous companion** asserting it does *not*
return the legacy id on a renamed board — without it, a resolver
returning any string would pass. The fallback cases are load-bearing
rather than padding: `resolveWorkflowIrForTask` degrades to the built-in
IR rather than throwing, and the built-in rebound/archive lanes *are*
`todo`/`archived`, so those cases also pin that a default board is
byte-identical.

The pre-existing case asserting the legacy `"archived"` passes both
ways, which is exactly why it could not detect this and why the new one
supplies a workflow.

## Ownership note

`packages/core` was `batch-core`'s territory and `packages/cli` was
`batch-cli-plugins`'. Both batches have landed, and this is
newly-discovered work in the class documented here rather than leftover
conversion backlog. Four sites, two shared helpers — happy for either
half to move if those owners would rather carry it.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `duplicate-guard` + `duplicate-intake` — 40 passed
- `tsc` on core and engine — clean
- `pnpm lint`, `check:changesets`, census `--strict` — all clean (run
explicitly; a clean `pnpm lint` alone is not evidence the CI Lint check
passes)


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

## Summary by CodeRabbit

- **Bug Fixes**
- Duplicate tasks are now archived to each workflow’s configured archive
lane.
- Completed tasks are moved to the workflow-specific completion lane,
with a safe fallback for older workflows.
- Recovery and requeue actions now use each workflow’s configured
rebound lane instead of assuming a fixed destination.

- **Documentation**
- Added guidance on avoiding failures caused by hardcoded workflow
destinations and incomplete lifecycle conversions.

- **Tests**
- Added coverage for renamed workflow lanes, fallback behavior,
duplicate archiving, and recovery destinations.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:25:00 -07:00
gsxdsm
29186a96da fix(tests): new CLI red from #2775 — the test pinned a decision its own PR superseded (#2801)
## New red on main

#2775 landed and put one failure on `main`, in a test that PR itself
added:

```
pr-create-review-lane-resolved.test.ts
  > refuses WITHOUT naming a phantom lane when the workflow declares no review lane
AssertionError: expected undefined to be defined
```

## Two review rounds pushed `pr.ts` in opposite directions; the test is
from the losing one

| round | decision |
|---|---|
| **1** (greptile P2) | a resolved workflow with no review-trait column
is an **answer** — do not invent `'in-review'`, say *"no review lane"*.
**This test was written against that.** |
| **2** (greptile) | refusing on an empty set rejects **every v1
workflow**, because `synthesizeDefaultColumns` upgrades a v1 graph by
emitting every column with `traits: []` — so a v1 board whose
`in-review` column plainly exists resolves to an empty review set. |

**Round 2 shipped** (`pr.ts:206-207`) and is right: an empty set is
indistinguishable from a v1 upgrade, so it means *unexpressed* rather
than *absent* and takes the same legacy fallback as an unreadable
workflow. Both rounds are extensively documented in `pr.ts` — the code
is deliberate and I have not touched it.

The consequence is simply that **there is no "no review lane" message in
the shipped code at all**, so `errors.find((e) => e.includes("no review
lane"))` returned `undefined`. The test could never have passed against
what merged.

## The fix

Re-pointed at the contract that actually shipped: the filtered board
takes the legacy `'in-review'` fallback, and the refusal must **not**
name the renamed lanes (`signoff`, `waiting-on-a-human`) that this board
no longer declares — which preserves the anti-phantom-lane intent the
test was named for.

## Flagged, not guessed

The round-1 behaviour is **not recoverable** without a way to
distinguish *"v2 board that declares no review lane"* from *"v1 board
whose traits were synthesised empty"*. The IR does not currently carry
that signal, so emitting a distinct message would re-break every pre-v2
project — the exact regression round 2 caught. Recorded in the test
rather than invented.

## Evidence

Mutations, both caught:

| mutation | result |
|---|---|
| fallback names lanes the board lacks | **1 failed** |
| the review-lane gate removed entirely | **2 failed** |

Full CLI package **1684 passed / 106 skipped (126 files)** — was 1
failed. Gate **732 green** · lint clean. Test-only; `pr.ts` restored
clean after the mutations.

## How this was found

Pre-flighting the open batch PRs against current `main` rather than
their branch heads, after batch-engine's previous landing put 32
failures on main that were only caught post-merge. #2785 and #2783 both
came back clean (commented on each); re-running `main` itself after the
newest landings surfaced this one.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:25:46 -07:00
gsxdsm
4184fde08d batch-cli-plugins: 7 guards — 3 were a foreign enum, and fn pr create refused every card on a renamed board (#2775)
`batch-cli-plugins` — the u7 worker's mega-batch: `packages/cli` +
`plugins` + anything left.

## The batch is 7 guards, and 3 of them are not guards at all

The census's per-file list gives this batch seven sites. Reading them,
**three are a foreign vocabulary the census matches on the string
alone**:

| file | site | verdict |
|---|---|---|
| `plugins/fusion-plugin-reports/store/report-store.ts` | `next ===
"archived"` ×2 | **not a column** — `next` is a `ReportStatus` |
| `plugins/fusion-plugin-reports/store/report-types.ts` | `to ===
"failed" \|\| to === "archived"` | **not a column** — same enum, its own
terminal states |

The reports plugin has its own status lineage (`draft → generating →
review_* → approved → published`, plus `failed`/`archived`) that shares
two spellings with the lifecycle vocabulary. A report is not on a board
and has no workflow, so resolving an IR there would answer a question
nobody asked. All three are marked `DELIBERATE-LITERAL` with the reason
at the site.

**This cuts the other way from #2763.** That PR establishes the census
total as a *floor* (25 membership predicates it structurally cannot
see). This is the opposite error in the same number: a foreign enum
inflating it. The total is neither a ceiling nor a floor — it is an
estimate with error in both directions, and the per-file list is worth
reading before trusting a file's count.

## Converted (census before → after, per file)

| file | before | after |
|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | **0** |
| `plugins/…/even-realities-glasses/notifications/diff.ts` | 1 | **0** |
| `plugins/…/reports/store/report-store.ts` | 2 | **0** (deliberate) |
| `plugins/…/reports/store/report-types.ts` | 1 | **0** (deliberate) |

### `fn pr create` refused every card on a renamed board

The live defect in this batch. The gate was `task.column !==
"in-review"`, and its error told the operator to move the task to a
column their board does not have:

```
Error: Task must be in 'in-review' column to create a PR (current: signoff)
```

There is no way to satisfy that short of renaming the workflow back. Now
resolved through core's `resolveReviewColumns`, and the message names
the lanes that actually exist.

**The SET, not `lifecycle.review`.** A board may declare more than one
review lane, and a card parked in a `humanReview`-only lane is still a
card you can open a PR from. A single-id answer keeps refusing those —
the same narrowing #2728's review caught in the CLI retry gate, which is
why the test pins both lanes.

## Skipped, with the reason

**`plugins/fusion-plugin-even-cards` (2 guards) — blocked on packaging,
not on analysis.** The defect is real: `boardToDeck` filters with
`column !== "archived" && column !== "done"`, so on a renamed board
every finished card stays in the deck, fills `maxCards`, and pushes the
active cards off the display. The wearer sees a board that never
finishes anything.

I implemented the fix and **reverted it**: this plugin is not in
`pnpm-workspace.yaml` and depends only on `@fusion/plugin-sdk` — it has
no `@fusion/core` dependency, so the route cannot reach
`resolveTaskLifecycleColumns`. Adding one is a packaging change, which
this program's rules put out of scope. Shipping only the injected
parameter without a caller was the alternative, and that is precisely
the decorative conversion #2759 documents: the census would drop by 2
and the deck would keep the bug.

Flagged for whoever owns the plugin's dependency surface. The glasses
plugin next door *does* depend on `@fusion/core`, so this is a
one-plugin problem, not a plugin-wide one.

## Honest note on the glasses conversion

`diff.ts`'s completion branch is **currently unreachable** — the only
production caller (`notifier.ts`) passes `alsoNotifyOnDone: false`. So
that conversion changes nothing at runtime today. It is converted rather
than marked deliberate because the literal is not deliberate: it is
wrong, and would ship the bug the day someone turns the flag on. Stated
here rather than left for a reviewer to discover.

## Verification

- new CLI suite **4 passed**; `pr-command` + `pr-automerge-cleanup` +
`bin-pr-router` **35 passed**
- glasses plugin **181 passed (19 files)** · reports plugin **110 passed
(23 files)**
- `pnpm test:gate` — **158 / 10 / 487 / 71** · `pnpm lint` clean ·
`--strict` exits 0

**Revert proof, measured.** Restoring `if (task.column !== "in-review")`
fails 3 of the 4 new cases (`process.exit:1` on both renamed lanes, and
the refusal message reverts to naming `in-review`). The
unresolvable-workflow case keeps passing — it is the legacy path — so
the negative cases alone do not pin the fix and all four are required.

## Handoff to `batch-engine`

`packages/engine/src/project-engine.ts` **5 → 0** is finished, green,
and pushed as `handoff/project-engine-lanes-for-batch-engine`
(`34dbb35209`) for the capacity worker to cherry-pick — it is
engine-owned, not mine to land.

It fixes two live defects: a card that **had merged** reported as a
failed merge to `fn task merge` and the dashboard button (`merged:
finalTask?.column === "done"`), and the three post-finalize `column ===
"done" && mergeConfirmed` fast-path checks, which on a renamed board
sent an already-landed card down the bounce path — re-queued,
retry-counted, and in the capped branch parked `failed` with its merge
sitting on main. Plus `hasAutoHealableVerificationBufferFailure`, which
returned false for every card on a renamed board, so a buffer-overflow
verification failure was never auto-healed.

8 new tests, revert-proven (restoring the literal fails 4 of 8), gate
green.

---

## Completion pass (u7) — the batch is now closed

Two workers converged on this branch. I rebased onto the first-landed
commit rather than force-pushing over it, took its wording wherever the
conclusion was identical, and added what was missing.

### What this pass added

1. **`even-cards` (2 sites)** — the only in-scope file the first pass
left open. Marked DELIBERATE-LITERAL: the package depends on
`@fusion/plugin-sdk` only, and the SDK does not re-export the lifecycle
role helpers, so there is no IR, no store, and no trait flags to resolve
*from*. Fixing it properly means the SDK exposing role flags on the task
shape it hands plugins — a structural change, out of scope, and recorded
at the site as the correct home. Live consequence is cosmetic: a
finished card on a renamed board shows as active in the glasses deck.

2. **A red test in the `fn pr create` conversion.** The incoming version
rendered `Task must be in 'in-review' to create a PR`, dropping the word
`column`. `task.test.ts:3422` pins `must be in 'in-review' column`, so
that hunk failed `runTaskPrCreate > exits with error when task not in
in-review column`. Restoring the word makes the single-lane message
**byte-identical** to the pre-conversion one, which is what a vocabulary
conversion should be — the guard's own test now passes unmodified.
Marked at the site so it is not "simplified" back.

3. **Duplicate imports** — the two independent conversions each added
`resolveWorkflowIrForTask`/`resolveReviewColumns`, which does not
compile. Deduped in its own commit.

### Census

Measured with `--json` on `origin/main` and on this branch.

| file | before | after | action |
|---|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | 0 | converted |
| `plugins/fusion-plugin-reports/src/store/report-types.ts` | 1 | 0 |
marked |
| `plugins/fusion-plugin-reports/src/store/report-store.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-cards/src/cards/board-cards.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-realities-glasses/.../diff.ts` | 1 | 0 |
marked |

Backlog **415 → 408** (−7, exactly the in-scope count). Deliberate **40
→ 46** (+6 marked); 6 + 1 converted = 7. `--strict` exits 0. **Nothing
remains in `cli` + `plugins` + everything-else — there is no follow-up
batch behind this one.**

### One note on the `even-realities-glasses` site

Worth recording beyond "cannot resolve": its only production caller
(`notifier.ts:80`) passes `alsoNotifyOnDone: false`, so that arm is
**unreachable today**. Converting it could not have changed observed
behaviour either way.

### Verification (measured, on the merged branch)

- `pnpm --filter @runfusion/fusion exec tsc --noEmit` → exit 0
- `pnpm lint` → 0 errors
- CLI `task.test.ts` → 144 passed, including the `runTaskPrCreate` guard
test
- `@fusion-plugin-examples/reports` → 110 passed;
`even-realities-glasses` → 181 passed

**Pre-existing failures, not from this change:** the 5
`runTaskImportFromGitHub` / `runTaskImportGitHubInteractive` tests fail
identically on `origin/main` — verified by stashing this diff and
re-running (5 failed / 144 passed both ways).

---

## Census audit (unowned follow-on)

After closing the batch scope I audited whether the **392**
column-backlog number is inflated by foreign vocabularies — the class
this batch found in the reports plugin, where `"archived"` is a
`ReportStatus` rather than a board lane. If that class were widespread,
every remaining batch would be chasing sites that must not be converted.

**It is not. The number is real.** A receiver-level pass over all 392
column-category sites found exactly **3** false positives, all in
`plugins/fusion-plugin-reports` (`next`, a `ReportStatus`), all now
marked in this PR.

What was checked and cleared:

- **Property-reached foreign enums** (`step.status`, `feature.status`,
`mission.status`) — already correctly bucketed into the separate
`status` category (185), not the column backlog. Verified against
`merge-queue-ops.ts`: 11 lifecycle-spelled literals in the file, census
counts **1**, and that 1 is the genuine `.column` guard.
- **Bare step-status variables** (`status`, `currentStatus`,
`liveStatus` compared to `"done"`/`"skipped"`) — likewise excluded.
- **Every other receiver in the backlog** — `to`, `from`, `column`,
`fromColumn`, `toColumn`, `latestColumn`, `state`, `preArchiveColumn`.
All resolve to genuine task columns. `executor.ts`'s 15 sites were
spot-checked line by line: all 15 are real.

The gap the classifier genuinely cannot close is a foreign enum held in
a **bare variable** — the receiver name carries no type information, so
`next === "archived"` is indistinguishable from a lifecycle guard by AST
alone. That is why the reports sites need a marker rather than a
classifier fix, and it is now documented in
`lifecycle-column-census-ast.mjs`'s header alongside the measured scope,
so the remaining batches do not re-run this hunt.

Census tests: **43 passed**. The change is comment-only.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:38:39 -07:00
gsxdsm
59b5e61fa2 fix(tests): the last CLI reds — import assertions still required the column U11 removed (#2788)
## What was red

All 5 failures in a full `@runfusion/fusion` run on `origin/main` (`5
failed / 1673 passed`), in `src/commands/__tests__/task.test.ts`:

```
- "column": "triage",
```

## The product change is intentional and documented

**#2603 (U11)** removed the hardcoded `column: "triage"` from the
GitHub/GitLab import writes so `createTaskImpl` resolves the
**workflow's** intake column instead. Passing `column` would override
that resolution and, post-U11, name a lane the default workflow no
longer declares.

`task.ts` still carries the note at three sites:

> `createTaskImpl` resolves the WORKFLOW'S intake column, and
`input.column` would override it. Hard-coding `"triage"` created the
card in a column the default [workflow does not declare].

Six `toHaveBeenCalledWith` assertions still required the removed
literal, so a correct product change surfaced as five CLI failures.

## Scoped deliberately

Only the **six assertion-side** occurrences are removed. The other
**16** `column: "triage"` literals in this file are mock *return* values
and `makeTask` fixtures, and they stay — what a created task comes
*back* as is a different question from what the import *asks for*, and
blanking them would weaken unrelated cases.

## Evidence

- Full CLI package: **1678 passed / 106 skipped, 125 files green** (was
5 failed).
- **Mutation:** reintroduce `column: "triage"` into the import write →
**2 failed**. The assertions still pin the invariant rather than having
been loosened into always-true — the thing worth checking when a fix is
"delete an expectation".
- Gate **732 green** · `pnpm lint` clean. Test-only (mutation reverted;
`git diff` clean).

## Ownership

`packages/cli` belongs to the **batch-cli-plugins** owner (u7) under the
mega-batch split. This is fix-forward on a red rather than a conversion,
confined to one test file, and touches no production code.

With #2779 and #2786 this leaves engine, core and CLI at **0 failures**
on main. The remaining known reds are the 123 dashboard failures
documented in #2784.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:20:01 -07:00
gsxdsm
2e4905fa0e refactor: one definition of "which columns are review" — three copies deleted onto core's resolver (#2751)
**#2730 added `resolveReviewColumns` to core. This deletes the three
copies that predated it.**

Measured on `origin/main` before this change — three in-tree
definitions, **none of which agreed**:

| site | definition |
|---|---|
| `core/workflow-lifecycle-traits.ts` (#2730, authoritative) |
mergeOrchestration ∪ mergeBlocker ∪ humanReview — **all** columns |
| `dashboard/routes/register-task-workflow-routes.ts` | mergeBlocker ∪
humanReview ∪ **first** mergeOrchestration |
| `cli/src/extension.ts` | mergeBlocker ∪ humanReview ∪ **first**
mergeOrchestration |
| `cli/src/commands/task.ts` | all three, full union |

**Both `.slice(0, 1)` variants are mine**, from #2723's review round: I
narrowed to core's then-single `.review` because the reviewer was right
that a superset let the dashboard act on a lane the engine did not own.
#2730 answered that question authoritatively in the other direction, so
the narrowing is obsolete.

Worse, and the part that makes this urgent rather than tidy: **the two
CLI copies had already drifted apart inside #2728.** `fn_task_retry`
refused a card in a second merge lane that `fn task retry` accepted —
two surfaces, one operator action, two answers, from two copies of one
definition written days apart by me.

All three now call core. The dashboard keeps its thin store→IR wrapper
(its callers hold a store and a task id, not an IR) but the **body** is
core's.

## One assertion inverted, deliberately

My #2723 case asserted that a **second** `mergeOrchestration` column is
**refused**. Core says every merge lane is review, so the behaviour
legitimately changed and the assertion flips with it.

**Kept rather than deleted**, because the invariant under test — *the
routes agree with core* — is unchanged. Deleting the case would have
hidden that its answer moved; inverting it records which decision moved
and why. A test whose expectation quietly disappears is
indistinguishable from a test that was wrong.

## A footgun found while rebasing

The shipped signature is
`isInReviewMissingWorktreeSessionStartFailure(task, isReviewColumn?:
boolean)` — the merged version takes the **answer**, not the lanes. My
branch had passed a `ReadonlySet`, and because the parameter is `boolean
| undefined` with a `??` default, **a truthy object makes it answer
`true` for every column**.

TypeScript stops typed callers; my test only reached it through an `as
never` cast, which is how I found it. All three production call sites
correctly pass `retryReviewColumns.has(task.column)` — now asserted
structurally so a fourth surface cannot omit it.

The boolean is arguably the better shape, and I'd keep it: there is
nothing left for the callee to re-derive, so it cannot disagree with the
caller's own membership test.

## The ratchet

No surface may reintroduce a local review union (`columnsWithFlag(…,
"mergeBlocker" | "humanReview")`). Those three copies appeared because
each was added **in good faith, in a different review round, by someone
reading only their own call site** — which no amount of care prevents
and a ratchet does.

## Verification

census **553** · `pnpm test:gate` **487 / 10 / 71** · `tsc` clean in cli
and dashboard · `pnpm lint` clean · 10/10 in each touched suite.

**Pre-existing, not mine:**
`register-task-workflow-routes.move-bypassguards.test.ts` fails on
`origin/main` (400 vs 200) — already reported on #2723.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:28:32 -07:00
gsxdsm
dc363543a2 fix(cli): fn task retry now CRASHES on a renamed board — #2728 converted the classifier and left the target (#2752)
## This is a live regression on `main`, not a conversion

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

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

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

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

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

## Why the census did not catch it

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

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

## The fix

The target resolves from the task's own workflow:

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

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

## Revert proof

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

## The fixture is derived, not hand-built

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

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

## Relationship to #2736

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

I will close #2736 once this is reviewed.

## Verification

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:51:25 -07:00
Phil Larson
8e50967279 fix: restore current main regression invariants (#2755)
## Summary
- align the renamed-review CLI regression with the classifier’s
resolved-boolean contract
- keep GitHub tracking controls expanded across same-task detail and
sparse SSE updates
- strengthen the sticky tracking regression to wait for the sparse
update

## Test plan
- `pnpm --filter @runfusion/fusion exec vitest run
src/__tests__/cli-active-count-lanes.test.ts`
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/restart-recovery-coordinator.test.ts`
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run
app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx`
- `pnpm --filter @fusion/dashboard typecheck`
- `pnpm --filter @runfusion/fusion typecheck`
- `pnpm check:changesets`
2026-07-30 06:24:57 -07:00
gsxdsm
72d42652e5 fleet: CLI surface 16 → 0 — 'active=0' on a busy board, and a retry gate that disagreed with the dashboard (#2728)
**Claimed on #2714 before starting.**
`packages/cli/src/commands/task.ts` (8) + `dashboard.ts` (8) — **16 →
0**.

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

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

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

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

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

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

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

## The rest

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

## Two DELIBERATE-LITERALs, with reasons

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

## Pre-existing, not mine

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

## Verification

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

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

---------

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

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

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

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

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

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

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

## Measured

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

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

## Not touched

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

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

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

**The broad-listing assertion now names the group that actually leads
the output.** It asserted a *second* group header inside a listing that
truncates to a text budget. That only ever passed because `triage` sorts
before `todo` in `COLUMNS`, so its group fitted before truncation —
fixture ordering masquerading as a bounding assertion. Per-column
coverage of every group is still asserted by the three filtered cases,
which do not truncate. I also seeded those 8 tasks in an explicit third
column so that case still covers a three-way filter instead of
collapsing to two groups.
2026-07-30 04:10:06 -07:00
gsxdsm
50ebf3c543 TAKING cli/project.ts (fn project reported 0 running agents) + two test fixes — dashboard conversions WITHDRAWN in favour of #2626 and #2636 (#2631)
Three app-cluster conversions plus the evidence that they behave on a
renamed AND a merged board.

## Per-file guard counts

| file | before | after | note |
|---|---|---|---|
| `packages/cli/src/commands/project.ts` | 0 | 0 | not a comparison site
— see below |
| `packages/dashboard/app/components/TaskContextMenu.tsx` | 2 | 2 |
**count does not move — deliberate, see below** |
| `packages/dashboard/app/components/Column.tsx` | 2 | 2 | **count does
not move — deliberate, see below** |

**Read this before scoring the PR against the bar.** You said a claim
that does not move your number is not done, so I am telling you up front
that *this PR does not move it*, and why.

Both dashboard conversions are **fallback-preserving**:

```ts
const isIntakeColumn = columnFlags ? columnFlags.intake === true : column === "triage";
```

The literal survives as the no-flags branch, so the grep still counts
it. That is the shape the sibling code already uses
(`isPreExecutionHoldColumn`, same file, converted earlier in the
program), and dropping the fallback would make an unresolved-column
render *lose* the affordance a second way. What changes is the
**behaviour when flags exist** — which is what the mutation results
below measure.

If you want these to zero out the count, the fallback has to go, and
that is a separate decision about whether an unresolved column should
fail open or closed. Say the word and I will do it as a follow-up; I did
not make that call unilaterally because it is not reversible from a
rendering standpoint.

`cli/project.ts` was never a comparison site at all — it fed **raw
rows** to `isRunningAgentTaskShape`, so the helper's own internal legacy
fallback kicked in and `fn project` reported **0 running agents** on any
renamed board. Fixed by resolving the IR per task before counting.
Nothing to subtract.

## Two of the three had a test that looked like coverage and was not

- **`Column.tsx`** — the quick-create gate is `workflowMode ||
isIntakeColumn`. Every pre-existing intake case in `Column.test.tsx`
*also* passes `workflowMode`, so the `||` short-circuited and **none of
them ever reached the trait lookup**. Added cases that omit
`workflowMode`, the only path where the conversion changes the answer.
- **`TaskContextMenu.tsx`** — the intake suppression was asserted only
for the legacy `triage` id, the one board shape where a broken
conversion still returns the right answer.

Mutation-verified rather than asserted:

| mutation | result |
|---|---|
| `isIntakeColumn` → `column === "triage"` | **2 of 88 fail** (exactly
the renamed and merged cases) |
| menu suppression → `task.column !== "triage"` | **1 of 12 fail** |

## A pre-existing red I fixed on the way past

`uses VALID_TRANSITIONS and in-review back-to-progress labels` was
**already failing on origin/main**. #2521 correctly moved the "Back to
X" label onto the host's `columnLabel` function; this file's stub is
`(column) => column`, so the hardcoded `"Back to In Progress"`
expectation was left over from the pre-#2521 hardcode and nothing had
updated it.

Matching the raw id would have made it pass while proving nothing, so
instead that one case gets a display-like label function — the assertion
now fails both if the "Back to" prefix regresses **and** if the label
stops routing through `columnLabel`. Strengthened, not relaxed. Counts
against completion criterion #2.

## Verification

- `Column.test.tsx` + `TaskContextMenu.test.tsx`: **100 passed**
- `tsc -p tsconfig.app.json` (the root config does not cover `app/`) and
the CLI typecheck: clean
- `pnpm test:gate`: green

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:52:30 -07:00
gsxdsm
eb874f3da3 convert(cli/commands/task.ts): triage guard 1 → 0 (+ a live main regression in the lifecycle E2E release path) (#2627)
**Batched push, gate open.** One conversion; the two E2E commits are
held back and the reason is below — it is the more important half of
this PR body.

## Conversion

| File | triage column comparisons before | after |
|---|---|---|
| `packages/cli/src/commands/task.ts` | **1** | **0** |

`pnpm test:gate` green, `pnpm lint` clean.

All four non-terminal columns rendered the **same** glyph, so the four
id comparisons were only ever asking "is this column terminal?". Naming
`triage` made it a lifecycle-vocabulary site for no behavioural reason —
the merged Planning column dropped that id, so the comparison silently
stopped matching while the output stayed correct **by accident** (the
fallthrough gave it the same glyph).

Behaviour-identical **only** because the loop iterates the legacy
`COLUMNS` constant (`types/board.ts:27` — exactly the six ids), so `col`
can never be a custom id. Stated because the forms **diverge** outside
that set: the old chain fell through to the terminal glyph for an
unrecognised id, the new form returns the non-terminal one. If this ever
iterates workflow-resolved columns that difference becomes live, and the
right answer is a trait lookup, not this.

**Deeper bug deliberately untouched, for U12:** because the loop
iterates the legacy enum, a card in a workflow-renamed column **is not
rendered at all**. That is R8's surface change, far bigger than this
glyph.

*(Note: this file is not on the 45-guard list, so it will not move your
count. Flagging so the numbers reconcile.)*

---

## ⚠️ Live regression on origin/main — the lifecycle E2E release path

While rebasing to push, the flagship lifecycle E2E went red. **I
verified it on `origin/main` alone, with none of my commits: 2 failed /
18 passed.**

```
scenario 1 — DEFAULT vocabulary  → AssertionError: expected [] to include 'FN-E2E-1'
                                   (r.sweep.released is EMPTY)
scenario 2 — RENAMED vocabulary  → audit trail differential broken:
                                   renamed produced [{end},{review}], default produced []
```

Both are pre-existing tests I have never touched. **The capacity release
sweep is releasing nothing.**

**Likely cause, from reading rather than bisecting** — so treat it as a
lead, not a verdict: `moves.ts:1059` now resolves a capacity pool id and
enforces `enforcePooledColumnCapacity` **inside the move transaction**
(#2488 "bind the in-transaction capacity gate", made user-visible by
#2499 "make the capacity gate actually bind for real projects"). The E2E
drives with `settings = { experimentalFeatures: { workflowGraphExecutor:
true } }` — **no `maxConcurrent`** — while the fixture's wip column
declares `{ trait: "wip", config: { limitSetting: "maxConcurrent",
countPending: true } }`. If the resolved limit is finite and the pooled
count meets it, the hold→wip move is rejected on capacity and the sweep
correctly reports nothing released.

If that is right, it is a **test-harness/production interaction, not a
product break** — but it means the program's primary end-to-end evidence
for the capacity boundary is currently inert on main, which matters for
completion criterion 3. It needs the capacity worker's eyes, since
#2488/#2499 are theirs and I would be guessing at the intended
pool/limit contract.

## Why my two E2E commits are held

They add scenario 3 (merged intake+hold board) and scenario 6 (REVISE →
rework), both of which **depend on the same release leg**. On current
main they fail for main's reason, taking the file from 2 failures to 4.
Pushing them would add red to the count you are tracking and obscure
whose regression it is.

Both are complete, mutation-attributed, and green against the commit I
wrote them on:

| Scenario | Proves | Mutation that fails it |
|---|---|---|
| 3 — merged intake+hold | capacity release works from a dual-role
column | `isHeldTask` treating intake/hold as exclusive → exactly its 2
tests |
| 6 — REVISE → rework | `InReview → InProgress` on renamed *and* merged
boards | disabling rework re-entry → exactly its 2 tests |

They go out in the next batch the moment the release path is green.

## Also not shipped, twice attempted, deleted both times

Safeguard 2 (`autoMerge:false` terminal-until-human) still has **no**
graph-level E2E. Attempt 1 passed and then survived mutating
`merge-gate` to ignore `task.autoMerge` — the card was parking on the
review column's `merge-blocker` trait, not the gate. Attempt 2 removed
that trait to isolate the gate, and then the *control* case parked too,
so the flag still was not the discriminator. A fixture that can isolate
it needs a merge path mirroring the builtin (`merge-gate → merge node →
end`) rather than a direct edge to `end` — a real redesign, not a
speculative edit. The enforcement that actually holds today is
`allowInReviewMergeProcessing` in `project-engine` (unit-mutation
verified, NEW=9; gated via #2526).

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:04:59 -07:00
gsxdsm
d5f1ce7abd U11 [writes]: stop CREATING cards into a column the workflow no longer declares (9 -> 0, engine+cli) (#2603)
**Taking: `engine/triage.ts`, `engine/pr-comment-handler.ts`,
`engine/eval-followups.ts`, `cli/commands/task.ts`, `cli/extension.ts`**
(write class — no collision with the comparison backlog).

## A class the census does not count

The 48-guard work list tracks `=== "triage"` **comparisons**. These are
`column: "triage"` **writes** — and post-#2515 every one creates a card
directly into the state STALL 3 was about, except **manufactured
continuously** rather than left behind by the upgrade.

## Why they bite

`createTaskImpl` resolves the column as:

```ts
column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage"
```

`input.column` **wins**, so an explicit `column: "triage"` overrides the
workflow's resolved intake column entirely.
`store-create-intake-column.test.ts` already pins that a create with
**no** column lands in the default workflow's intake (now `todo`) —
these callers opted out of it.

The sharpest is `triage.ts`'s `fn_task_create` agent tool: it passed
`workflowId: params.workflow_id` **and** `column: "triage"` in the same
call. The caller chose a workflow and the column ignored it — a Coding
(Ideas) create landed in `triage` instead of `ideas`.

## Counts

**Comparison guards: unchanged by this PR.** This is the write class;
conflating the two would misreport convergence toward the zero bar.

| file | `column: "triage"` writes before | after |
|---|---:|---:|
| `packages/engine/src/triage.ts` | 1 | **0** |
| `packages/engine/src/pr-comment-handler.ts` | 1 | **0** |
| `packages/engine/src/eval-followups.ts` | 1 | **0** |
| `packages/cli/src/commands/task.ts` | 3 | **0** |
| `packages/cli/src/extension.ts` | 3 | **0** |
| **total** | **9** | **0** |

## A test that pinned the defect

`pr-comment-handler.test.ts` asserted `column: "triage"` in the
createTask call — so it would have **failed the fix and passed the
bug**. Rewritten to assert the invariant (the caller passes no column,
so the workflow's intake wins) plus an explicit `Object.hasOwn(arg,
"column") === false`, which is what actually catches a reintroduction.

## Interaction with #2591

My merged #2591 rescues these cards once created — they sit on a legacy
planner id their workflow doesn't declare and are still in planning
stage. So this isn't a *visible* stall today; the rescue absorbs it.
**That's the reason to fix it rather than leave it:** a self-healing
path silently absorbing a steady stream of malformed creates is exactly
how the underlying defect stays invisible.

## Deliberately not touched

- `{ id: "start", kind: "start", column: "triage" }` in the builtin
coding / PR / lead-generation IRs — workflow-internal **node
declarations** for workflows that still legitimately declare a `triage`
column, not lifecycle writes.
- Left for their owners: `core/task-store/project-store-ops.ts:210`,
`core/task-store/update-task-deps.ts:111` (main worker),
`dashboard/src/routes/register-gitlab.ts:108` (u12). Same defect, same
one-line shape.

## Verification

- 304 engine/CLI tests green across the affected suites
- merge gate green (482 + 132 + 10), engine + CLI tsc clean, lint clean

No changeset: `@fusion/engine` and `@fusion/core` are private; the CLI
change is a bug fix with no user-facing API change — happy to add one if
you'd rather it appear in release notes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 20:58:52 -07:00
Phil Larson
9a8fc409ff fix: persist manual task pauses (#2536)
## Summary

- persist an explicit `userPaused` latch when operators pause tasks
through CLI, MCP, dashboard task routes, or mission stop
- keep automatic/internal pauses distinct (`userPaused` remains false
unless explicitly requested)
- clear the latch on unpause
- route the flag through in-memory and PostgreSQL task stores
- add contract coverage across core, CLI, MCP, dashboard task routes,
and mission stop

## Why

A manually paused task could lose the reason for its pause across
dashboard/runtime restart. Startup recovery then treated it like an
internally interrupted task and reclaimed it, restarting automation
against the operator’s intent. Manual pauses must survive restart and
remain non-runnable until explicitly unpaused.

## Verification

- core pause durability tests: 2 passed
- CLI task/extension tests: 150 passed; PostgreSQL integration lane
remains active in CI
- dashboard route tests: 261 passed
- `@fusion/core`, `@runfusion/fusion`, and `@fusion/dashboard`
typechecks passed
- full workspace build passed with pnpm 10.33.0
- changeset validation and `git diff --check` passed
- live aggregate runtime verification also confirmed
`paused=true,userPaused=true` survived a normal dashboard restart with
zero active tasks


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

- **Bug Fixes**
- Manual task pauses now persist across application restarts and
recovery.
- Pauses initiated via the CLI, dashboard, MCP tools, and mission stop
controls are recorded as explicit user actions.
  - Automatically paused tasks remain eligible for recovery.
  - Unpausing clears the durable manual-pause state.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 00:04:28 -07:00
gsxdsm
3badc244a7 U12 part 2: bind the three U5 reconciliation guards — USER-VISIBLE (and one path that couldn't run under PostgreSQL at all) (#2512)
## U12 part 2 — the three U5 reconciliation guards now actually fire

USER-VISIBLE. Taken on standing authority; here is exactly what changed
for operators.

All three read the RAW `experimentalFeatures.workflowColumns` key via
`store.workflowColumnsFlagOn()`. Nothing in production writes it, so all
three have been inert since the workflow-columns cutover.

| Guard | Before (every real project) | After |
|---|---|---|
| Workflow edit removing an **occupied** column | Save succeeded; cards
left in a column the workflow no longer declares | Save fails with
`OccupiedColumnsError` unless `rehomeTo` is supplied |
| Workflow **delete** | Occupant capture returned `[]`; cards sat in the
deleted workflow's columns until the next engine start | Cards move to
the default workflow's entry column as part of the delete |
| Workflow **switch** | Never reconciled; the `reconciliation` field in
the declared return type was never populated | Card in an undeclared
column moves to the resolved target; a declared column is preserved |

Both consumers already handle the new outcomes and needed no change:
`register-workflow-routes.ts` maps `OccupiedColumnsError` to a
structured 409 carrying per-column occupant counts, and
`fn_workflow_update` returns a retryable structured result. The
dashboard editor's `rehomeTo` retry flow becomes reachable for the first
time. I only updated two stale "flag-ON" comments there — that code was
correct all along and simply never fired.

### What an operator actually sees (USER-VISIBLE — read this bit)

Four changes to what the board and the API do. Nothing here is silent.

1. **Editing a workflow to remove a column that has cards in it now
FAILS.** Previously the save succeeded and the cards were left in a
column their workflow no longer declared. The dashboard shows the
existing 409 with per-column occupant counts and prompts for a re-home
target; retrying with `rehomeTo` moves the cards and saves. Removing an
EMPTY column is unaffected.
2. **Deleting a workflow moves its cards immediately** to the default
workflow's entry column, instead of leaving them until the next engine
start.
3. **Switching a task's workflow moves the card** when the new workflow
does not declare its current column. A card whose column IS declared
stays exactly where it is. The API response now carries the
`reconciliation` summary it always promised.
4. **A switch whose re-home would be REJECTED is now refused before
anything is written.** If the destination column is at its WIP limit,
the switch fails with a structured 409 (`workflow-switch-rehome-failed`)
naming the task, both columns and the reason — and **nothing changes**:
the task keeps its current workflow AND its current column. Retry after
making room. Previously this combination committed the selection and
then silently reported a move that never happened, leaving selection and
column disagreeing.

**Can a torn card still happen? Yes, in one narrow case, and here is how
you recover.** If the destination fills in the window between the
pre-flight and the move, the selection is already committed and the card
ends up in a column its new workflow does not declare. That case is not
silent: it writes a `task:workflow-switch-torn` run-audit row, and the
error carries `selectionCommitted: true` with both columns. Recovery:
make room in the destination and move the card there, or switch the task
back — and if neither happens, the R7 startup sweep
`reconcileUndeclaredTaskColumns` re-homes it on the next engine start.
The card is never lost; it is visible in a lane the board may not draw
until one of those runs.

The one thing to watch after merge: (1) converts a previously-silent
success into a visible failure, so an operator mid-edit on a busy
workflow will start seeing a 409 they never saw before. That is the
point — the alternative was stranding their cards — but it is the change
most likely to generate a "this used to work" report.

### The thing that made this more than a gate removal

Un-gating the switch guard surfaced that
`selectTaskWorkflowAndReconcileImpl` read the task through
`store.readTaskFromDb` — the **synchronous SQLite** reader, which throws
under PostgreSQL:

```
TaskStore.db: SQLite Database is not available in backend mode
```

The flag returned before that line, so the gate was hiding a path that
**could not execute at all in the production backend**, not merely a
disabled feature. Ported to the async `readTaskRow`. Found by the new
tests, not by reading the code.

### Review round 2 (both findings real, both fixed)

**Torn write with no alarm — fixed by ORDERING, not by a louder
message.** My first attempt only made the error loud, which left the
torn state intact. The real fix is that the deterministic rejection
cause (destination at its WIP limit) is now checked BEFORE
`selectTaskWorkflow` commits, by resolving the target IR straight from
`workflowId` instead of through the task's selection. Nothing commits on
that path.

For the residual race the failure is loud AND recorded: `rehomeOccupant`
now returns `{ moved, error? }` (additive; sweep callers ignore it), the
switch writes a `task:workflow-switch-torn` run-audit row, and throws
`WorkflowSwitchRehomeFailedError` with `committed: true`. Consumers
translate it: the dashboard route returns a structured 409 with
`selectionCommitted`, and `fn_task_set_workflow` returns the same fields
— no more generic "something went wrong".

**Fabricated column for a deleted task.** My first fix fell back to
`fromColumn` when the final read found no row, so a task soft-deleted
mid-switch was reported as having its old column *preserved*. Absent now
reads as absent (the optional `reconciliation` is omitted). Extracted as
the pure `buildSwitchReconciliation` seam because the window is not
reachable through the public call — `selectTaskWorkflow` rejects an
already-deleted task up front — so it is a genuine race, and I test the
decision directly rather than asserting it from reading the code.

### Revert-proof, measured

New `workflow-reconciliation-production-shape.pg.test.ts` — 6 cases,
with the flag **never written**, which is the configuration every real
project has. Each flip reverted individually:

- re-gate the edit guard → **2 failures** (OccupiedColumnsError case;
rehomeTo re-home case)
- re-gate the delete capture → **1 failure** (card stays in
`custom-hold`)
- restore the switch early return → **2 failures** (`reconciliation`
undefined; card does not move)
- all three in place → **6/6 green**

Round-2 fixes, also measured:
- restore the `fromColumn` fallback → the "row is gone" case fails
(reports `preserved: true` for a deleted task)
- drop the `!outcome.moved` throw → the capacity-blocked case fails
(resolves instead of raising)
- **move the capacity pre-flight back AFTER the commit → the case fails
on the SELECTION assertion** (expected `WF-002`, received `WF-001`),
i.e. it proves the ordering, not the wording

The pre-existing coverage in `workflow-authoritative-reads.pg.test.ts`
reached the occupied-column guard by **writing the flag ON itself** —
same pattern as the ListView/Board suites in part 1. Its flag write is
removed; it now runs in the production shape.

### Where I nearly got this wrong

My first revert harness was buggy and I briefly concluded the delete
re-home was **redundant** — I had probed the stored column and seen
`triage` with what I thought was the flip reverted. It wasn't.
`workflow-ops.ts` contains two identical `const occupantTaskIds = await
store.listWorkflowOccupantTaskIds(id, false)` lines (field-reconcile
block, delete path), so my first-match edit reverted the wrong one.
Re-run anchored on surrounding context, the delete case fails as
predicted. Recorded in the test header as a caution. I also chased and
**refuted** a scarier hypothesis along the way — that an unrelated
`updateTask` coerces a custom column back to `triage`. It does not; the
column survives.

### Deliberately NOT in this PR

The v1-IR rollback-compat persistence (`downgradeIrToV1IfPure`) on the
workflow UPDATE path. It shared the same `flagOn` variable, which is how
it surfaced: **one flag read was feeding two unrelated decisions, so the
flag has more decision sites than call sites** — my earlier 9-site
inventory undercounted. It chooses the stored *shape* of the graph
rather than gating a guard, so it is a persistence-format change with a
different blast radius. It now reads the flag explicitly, behaviour
unchanged, for a follow-up.

The `moves.ts` group remains U2b's.

### Verification

`pnpm test:gate` (307 + 10 + 71), `pnpm lint`, `pnpm verify:fast` (17
steps), both typechecks green. Full `packages/core` PostgreSQL suite:
**1042 passed, 3 failed** — `central-archive-secrets.test.ts`
(log-prefix assertion) and
`workflow-settings-project-identity.pg.test.ts` (×2, project-id
resolution). I confirmed the identical 3 failures on a stashed clean
tree: pre-existing, unrelated. No Fusion instance booted.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Workflow edits now prevent removal of occupied columns unless cards
are moved to a specified destination.
* Cards are automatically re-homed when workflows are deleted or
switched.
* Workflow switches now check destination capacity before committing and
provide clear conflict details when re-homing fails.
* Reconciliation results now indicate whether cards were moved or
preserved.


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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:52:46 -07:00
dependabot[bot]
74d6513fae chore(deps): bump actions/upload-artifact from 4 to 7 (#2444)
Bumps
[actions/upload-artifact](https://github.com/actions/upload-artifact)
from 4 to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>v7 What's new</h2>
<h3>Direct Uploads</h3>
<p>Adds support for uploading single files directly (unzipped). Callers
can set the new <code>archive</code> parameter to <code>false</code> to
skip zipping the file during upload. Right now, we only support single
files. The action will fail if the glob passed resolves to multiple
files. The <code>name</code> parameter is also ignored with this
setting. Instead, the name of the artifact will be the name of the
uploaded file.</p>
<h3>ESM</h3>
<p>To support new versions of the <code>@actions/*</code> packages,
we've upgraded the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Add proxy integration test by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
<li>Upgrade the module to ESM and bump dependencies by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/762">actions/upload-artifact#762</a></li>
<li>Support direct file uploads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/764">actions/upload-artifact#764</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Link"><code>@​Link</code></a>- made
their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v6...v7.0.0">https://github.com/actions/upload-artifact/compare/v6...v7.0.0</a></p>
<h2>v6.0.0</h2>
<h2>v6 - What's new</h2>
<blockquote>
<p>[!IMPORTANT]
actions/upload-artifact@v6 now runs on Node.js 24 (<code>runs.using:
node24</code>) and requires a minimum Actions Runner version of 2.327.1.
If you are using self-hosted runners, ensure they are updated before
upgrading.</p>
</blockquote>
<h3>Node.js 24</h3>
<p>This release updates the runtime to Node.js 24. v5 had preliminary
support for Node.js 24, however this action was by default still running
on Node.js 20. Now this action by default will run on Node.js 24.</p>
<h2>What's Changed</h2>
<ul>
<li>Upload Artifact Node 24 support by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/719">actions/upload-artifact#719</a></li>
<li>fix: update <code>@​actions/artifact</code> for Node.js 24 punycode
deprecation by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/744">actions/upload-artifact#744</a></li>
<li>prepare release v6.0.0 for Node.js 24 support by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/745">actions/upload-artifact#745</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0">https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0</a></p>
<h2>v5.0.0</h2>
<h2>What's Changed</h2>
<p><strong>BREAKING CHANGE:</strong> this update supports Node
<code>v24.x</code>. This is not a breaking change per-se but we're
treating it as such.</p>
<ul>
<li>Update README.md by <a
href="https://github.com/GhadimiR"><code>@​GhadimiR</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/681">actions/upload-artifact#681</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/712">actions/upload-artifact#712</a></li>
<li>Readme: spell out the first use of GHES by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/727">actions/upload-artifact#727</a></li>
<li>Update GHES guidance to include reference to Node 20 version by <a
href="https://github.com/patrikpolyak"><code>@​patrikpolyak</code></a>
in <a
href="https://redirect.github.com/actions/upload-artifact/pull/725">actions/upload-artifact#725</a></li>
<li>Bump <code>@actions/artifact</code> to <code>v4.0.0</code></li>
<li>Prepare <code>v5.0.0</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/734">actions/upload-artifact#734</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="043fb46d1a"><code>043fb46</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/797">#797</a>
from actions/yacaovsnc/update-dependency</li>
<li><a
href="634250c138"><code>634250c</code></a>
Include changes in typespec/ts-http-runtime 0.3.5</li>
<li><a
href="e454baaac2"><code>e454baa</code></a>
Readme: bump all the example versions to v7 (<a
href="https://redirect.github.com/actions/upload-artifact/issues/796">#796</a>)</li>
<li><a
href="74fad66b98"><code>74fad66</code></a>
Update the readme with direct upload details (<a
href="https://redirect.github.com/actions/upload-artifact/issues/795">#795</a>)</li>
<li><a
href="bbbca2ddaa"><code>bbbca2d</code></a>
Support direct file uploads (<a
href="https://redirect.github.com/actions/upload-artifact/issues/764">#764</a>)</li>
<li><a
href="589182c5a4"><code>589182c</code></a>
Upgrade the module to ESM and bump dependencies (<a
href="https://redirect.github.com/actions/upload-artifact/issues/762">#762</a>)</li>
<li><a
href="47309c993a"><code>47309c9</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/754">#754</a>
from actions/Link-/add-proxy-integration-tests</li>
<li><a
href="02a8460834"><code>02a8460</code></a>
Add proxy integration test</li>
<li><a
href="b7c566a772"><code>b7c566a</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/745">#745</a>
from actions/upload-artifact-v6-release</li>
<li><a
href="e516bc8500"><code>e516bc8</code></a>
docs: correct description of Node.js 24 support in README</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/upload-artifact/compare/v4...v7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-07-27 19:06:09 -07:00
gsxdsm
4158cf1ab7 Phase A: workflow-owned lifecycle foundation (U1, U2, U3) (#2467)
Phase A (Foundation) of
`docs/plans/2026-07-26-001-refactor-workflow-owned-lifecycle-plan.md`.
Three units, one commit each. No operator-visible behavior change.

## U1 — Lifecycle-column resolution seam

`resolveLifecycleColumns(ir)` returns `{ intake, hold, wip, review,
complete, archived }` — the first column carrying each trait,
`undefined` for a role no column carries.
`resolveTaskLifecycleColumns(store, taskId, cache?)` is the store-aware
form; the cache is caller-owned so a sweep reads one IR per workflow
rather than one per card.

A v1/column-less IR resolves to `undefined` for the **whole struct**
rather than a struct of undefined roles. A caller must be able to
distinguish "this workflow declares no hold column" (a real shape to
honor) from "no column vocabulary at all" (skip and log) — only the
second licenses conservative fallback.

Nothing consumes the seam yet; Phases B–D convert the ~207 hardcoded
column literals onto it.

## U2 — Delete the pre-cutover parity machinery (delete-only)

**`workflow-columns-settings.ts`** — `isWorkflowColumnsEnabled` had the
body `return true`. Six live call sites branched on it, so every
flag-OFF arm was dead code that read as a supported configuration.
Deleted; surviving side inlined at self-healing's transitionPending
sweep, the scheduler's per-column capacity diagnostic, merge-trait's
policy resolver, the board-workflows payload, two task-workflow routes,
and the CLI TUI's column enrichment.

**`workflow-parity.ts`** — asserted the default workflow's adjacency
*equals* the legacy `VALID_TRANSITIONS`. U11 deliberately breaks that
equality by merging Todo into Planning, so this is not a stale assertion
to update; it is a contract against the target state. Its emitter
(`workflow-parity-observer.ts`) is already a tombstone, so
`getWorkflowParitySummary` and `computeWorkflowColumnsGraduationReport`
aggregated run-audit rows nothing writes and had no caller outside
`TaskStore`. Both store methods go with it.

`flagEnabled` stays on the board-workflows **wire** as a constant `true`
— shipped dashboard clients still branch on it, and changing the
response shape is not a deletion. U10 retires the field once no client
reads it.

The `legacy-tombstones` ratchet is extended to both files plus seven
symbols, each with the reason it is gone.

### ⚠️ Finding: the third listed deletion was NOT dead

The plan also lists "the flag-off inline move path" in
`task-store/moves.ts`. It is **not** deleted, per U2's execution note
("any behavior change found while removing a branch means the branch was
not dead").

That path is gated on `isWorkflowColumnsCompatibilityFlagEnabled`
(`store.ts:38`) — a **different** function from the always-true public
helper. It reads the raw `experimentalFeatures.workflowColumns` setting,
which nothing in production sets (`settings-schema.ts:396` — "no default
flags are emitted"; zero non-test writers; the operator's own
`~/.fusion/settings.json` has no such key). So `useWorkflow` is false
for effectively every real project: the flag-OFF inline side effects are
the **live** default move path and the flag-ON `default-workflow-hooks`
path is the dead one. The code says so itself at `moves.ts:638`.

Deleting that branch would swap every project onto an untravelled code
path — a behavior change, not a deletion.

**Carry this into Phases B and C, stated plainly so the plan's error is
not repeated:**

> **The inline move path in `moves.ts` is LIVE.
`default-workflow-hooks.ts` (the trait-hook path) is DEAD.** KTD-6
asserted the inverse. Until the convergence unit lands, **nothing may
assume trait hooks run** — a guard, sweep, or subscriber written against
`applyDefaultWorkflowMoveEffects` would never fire in production and
would still pass its tests.

Convergence is **not** attempted here. It is its own unit (Phase A2)
with a proper equivalence proof, per operator decision.

### U3's emit point is on the LIVE path — the seam is not born dead

Worth stating explicitly because it is the failure mode that would make
every later subscriber silently never fire: the `TaskTransitioned` emit
is **not** inside the `if (useWorkflow)` branch. That block closes at
`moves.ts:1212`; the emit sits at `:1214`, beside the existing
`store.emit("task:moved", …)`, on the unconditional post-commit path. It
therefore fires on **both** the live inline path and the dead hooks
path, and the convergence unit inherits the obligation to keep it firing
on whichever path survives — same events, same order, same payloads.

The graph-side emitters (`NodeEntered`, `RunSuspended`) carry the same
risk from a different direction: the bus refuses an invalid payload
*silently* by design, so an emitter regression would stop the event with
no test failure. They are asserted end-to-end through the real bus —
"did a subscriber actually receive it", not "was emit called" — because
a spy passes on a refused payload. The `moveTaskInternalImpl` emit does
**not** yet have that end-to-end assertion against a real store move;
that proof belongs to the convergence unit, which has to build the
both-paths fixture anyway.

## U3 — Post-commit event seam with a transactional outbox

**The bus is not a queue, not a transaction participant, and not a
delivery guarantee.** Durable follow-on work uses the transactional
outbox — a `workflow_work_items` row written *inside* the transition
transaction (the shape `createCompletionHandoffWorkflowWork` already
uses). "Emit after commit, let a subscriber enqueue the work" has a
crash window where a process dies between commit and subscriber, leaving
no event *and* no work-item row, so required work is skipped permanently
with nothing to recover from. Post-commit subscribers therefore carry
only losable reactions.

Emission is consequently lossy and isolated by design: a throwing or
rejecting subscriber is caught and logged, cannot roll back the
transition, and cannot stop the others. Deliveries append to one serial
chain, so two transitions on a task deliver in commit order.

The ids/outcomes-only rule is **mechanised, not documented** —
run-audit's equivalent lives only in prose and has been violated
repeatedly. A payload carrying an object body or a prose string is
refused at the emit boundary and never reaches a subscriber or log sink.
It degrades rather than throws: the emitter is post-commit, so a shape
bug must not become a lifecycle failure.

Emit points: `TaskTransitioned` from the single post-commit point in
`moveTaskInternalImpl`; `NodeEntered` and `RunSuspended` from the graph
column boundary, the latter *after* the durable continuation is
persisted so an observed suspension implies a resumable run.

`registerWorkflowEventSubscribers` (engine) is empty on purpose —
U7/U8/U10 move real reactions onto it, each with the characterization
test proving the reaction was non-authoritative first.

## Verification

- `pnpm test:gate` — green (2/10, 16/299, 1/71).
- `pnpm lint`, `pnpm build`, `tsc --noEmit` on core and engine — green.
- U1: 20 tests in `workflow-lifecycle-traits.test.ts`, including the
fully-renamed-workflow case (fails if the resolver falls back to a
literal) and a shared-cache read-count assertion.
- U2: `legacy-tombstones.test.ts` green with the extended ratchet;
`board-workflows`, `merge-trait`, `workflow-graph-executor-parity`, and
move-hook suites green with no expectation edits.
- U3: 20 bus-invariant unit tests (isolation, ordering, the allowed-key
and required-key halves of the ids-only rule, lossiness) plus 3
end-to-end emitter-delivery tests; 5 outbox tests against a **real
PostgreSQL** work-item table (crash survival, rollback, at-least-once
redelivery on lease expiry, idempotent handler → one effect,
dropped-subscriber vs. durable work). A hand-written fake of the lease
predicate would only prove the fake redelivers.

**Not verified:** the `moveTaskInternalImpl` emit is confirmed on the
unconditional post-commit path by structure and by the surrounding
tests, but is *not* yet asserted end-to-end against a real store move on
both flag settings — that is Phase A2's fixture. The engine subscriber
registry ships empty by design, so no production subscriber exercises
the bus end-to-end yet. `settings-defaults.test.ts` has one pre-existing
failure on `main` (a logger-prefix mismatch in the
`mergeIntegrationWorktree=cwd-main` warning) — confirmed present on a
clean tree, unrelated to this branch.

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

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

* **New Features**
* Workflow lifecycle columns are now derived from workflow definitions,
supporting renamed and custom workflows.
* Added post-commit lifecycle events for task transitions, node entry,
and run suspend/resume with validated payloads.
* Follow-on processing for lifecycle emissions is now more robust
(rollback-safe, at-least-once delivery, idempotent handling).
* **Bug Fixes**
* Workflow board responses, task enrichment, and promotion no longer
depend on workflow-columns feature-flag gating.
  * Subscriber failures no longer impact committed workflow transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-27 13:30:13 -07:00
gsxdsm
8b039a543e fix(desktop): advance Pi runtime pin to 0.82.1 for packaging PR lane (#2465)
## Summary
- Advance the matched Pi runtime pin (`pi-ai`, `pi-coding-agent`,
`pi-agent-core`, `pi-tui`) from **0.82.0 → 0.82.1** so
electron-builder's production-dependency walk accepts `pi-agent-core`'s
`pi-ai@^0.82.1` requirement.
- Fixes the Desktop packaging PR-lane failure:
`Production dependency @earendil-works/pi-ai not found for package
@earendil-works/pi-agent-core` (required `^0.82.1`).
- Keep the workspace override guard; update pin-policy fixtures and CLI
package-config expectations.
- Tighten the advisory packaging step-order test so it asserts against
the real `electron-builder --dir` step (not a missing release-only step
name that previously passed via `indexOf === -1`).
- Run `pnpm dedupe` so the packaging lane's lockfile dedupe
early-warning is clean.

## Context
#2439 pinned the full Pi closure at 0.82.0 and made recent main-based
packaging runs green. This advances to the current upstream patch so
deploy + electron-builder stay aligned with `pi-agent-core@0.82.1`'s
declared dependency range.

## Test plan
- [x] `node scripts/check-pi-versions-pinned.mjs`
- [x] `node --test scripts/__tests__/check-pi-versions-pinned.test.mjs`
- [x] `pnpm --filter @runfusion/fusion exec vitest run
src/__tests__/package-config.test.ts`
- [x] `pnpm --filter @fusion/desktop exec vitest run
src/__tests__/release-workflow.test.ts`
- [x] `pnpm dedupe --check`
- [ ] GitHub: Desktop packaging (should run full packaging walk —
lockfile/package.json touched)
- [ ] GitHub: PR Checks (Lint, Typecheck, Build, Gate)
2026-07-26 23:47:49 -07:00
gsxdsm
3f33cb000f feat: per-origin workflow selection + feedback-derived refinement titles
Two task origins had no workflow picker in front of the operator and always
inherited the project default: `fn task create` (CLI + the `fn_task_create`
agent tool) and refinement tasks. Add a Project General setting for each, where
blank/unset means "Selected workflow" (the operator's current Board lane,
falling back to the project default) and a concrete id pins that origin.

Because the Board lane lives in browser localStorage, non-browser callers could
not resolve "Selected workflow" at all. `boardSelectedWorkflowId` mirrors the
lane into project settings so they can. Note this makes the mirrored lane
project-scoped: two operators on one project share it, last switch wins. The
Board never reads it back, so the only effect is which workflow a newly created
task inherits.

Resolution is `TaskStore.resolveOriginWorkflowOverrideId(origin)`: pinned
setting -> mirrored lane -> `undefined` to inherit each caller's existing
default-workflow path unchanged. A deleted or fragment id degrades to inherit
rather than throwing, so a stale settings value can never break task creation.
An explicit `workflow_id` argument to `fn_task_create` still wins.

Separately, a refinement is now titled by the operator's own feedback via the
shared `deriveFallbackTaskTitle`, not `Refinement: <parent title>`. Ten
refinements of one task previously rendered ten identical titles, so the board
could not tell them apart while the text saying what each one asked for sat in
the description. Provenance moves to a `Refines <id>` card chip alongside the
existing detail-view parent link and dependency edge.

Verified: merge gate (299 tests), lint, full build, and typecheck for core, CLI,
and dashboard all pass. New coverage: origin resolution across both origins and
the full precedence ladder, the two settings pickers, the board-lane mirror,
refinement titling (including sibling distinctness), and the card chip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:33:25 -07:00
gsxdsm
ab87d0d803 fix(api): return 404 for missing tasks, and make task deletions attributable
Three related fixes, all originating from a `[api:error] Request failed`
log line showing a 500 on `GET /api/tasks/FN-8610/runtime-fallback`.

1. Missing/deleted tasks now return 404 instead of 500.
   `getTaskImpl` signalled a miss with a bare `Error`, and route catches
   only mapped errno `ENOENT` to 404 — a leftover from the file-backed
   storage era. In Postgres mode nothing sets an errno code, so every
   unknown/missing/soft-deleted/wrong-project read returned 500. Adds a
   typed `TaskNotFoundError` (message byte-identical) plus a shared
   `task-lookup-error` mapper applied across the task, session-diff,
   git/GitHub, workflow and file-workspace route registrars. The same
   bare throw existed on both archive-lifecycle delete paths, so
   `DELETE /tasks/:id` was affected too.

2. 5xx logs now carry the origin stack.
   `rethrowAsApiError` constructed a fresh `ApiError` from the message
   and discarded the original, so the `FNXC:ApiErrorDiagnostics`
   contract logged the rethrow site rather than the throw site — the
   reported log entry had no stack at all. Threads `cause` through the
   error factories and walks the chain (bounded, cycle-guarded).

3. Task deletions are attributable, and non-operator deletes notify.
   `task:deleted` audit rows recorded `agentId: "system"` for every HTTP
   delete, making an operator click indistinguishable from a script or
   an agent; the calling agent's task id was accepted by the store and
   then never persisted. Adds a `callerKind` union recorded in audit
   metadata, tags every delete call site, and stamps a self-reported
   `x-fusion-client` header from the dashboard client. When the caller
   is `agent-tool` or `api-unattributed`, a best-effort notice is sent
   to the operator mailbox; operator and engine deletes stay silent.

`x-fusion-client` is attribution, not authentication — anything can send
it. No delete-blocking, gating or permission logic is added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:15:36 -07:00
gsxdsm
106c61e6ee fix(agent-tools): close the fn_delegate_task Deny bypass and the store's window clamp
Follow-up to 13a2b2a9d, from a multi-agent review of that commit. Three of its
claims did not hold.

1. fn_delegate_task bypassed the gate entirely (P0). It reaches the same
   createAgentTask primitive, was registered unconditionally in both session
   lanes, and validated only that the TARGET agent is non-ephemeral — never the
   caller. Under Deny an ephemeral worker could enumerate agents and delegate
   unlimited tasks. It is now withheld under Deny, and also under
   upon_validation: delegation has no proposal channel, so leaving it available
   would launder a create past the operator review that policy requires.

2. The widened dedupe window was capped at 5 minutes. The store query in
   branch-and-pr-entities.ts carried its own independent `?? 60_000` /
   `min(300_000, …)` pair, so widening only duplicate-guard.ts under-delivered
   and made the new ceiling unreachable. Both sites now share
   FINGERPRINT_WINDOW_DEFAULT_MS / FINGERPRINT_WINDOW_MAX_MS.

3. The pi-extension gate does not fire at all. pi's ExtensionContext carries no
   agentId — the read is a speculative cast and only tests supply one, so every
   real call short-circuits as a human caller. The fail-closed direction is kept
   for the day an identity signal exists, but the limitation is now documented
   instead of implied to be enforcement.

Also: the session prompt now states when creation is disabled and names
fn_task_log as the fallback (the base prompt still taught fn_task_create, which
is the same instruction/capability mismatch that fed the retry storm);
suppression emits an `agent:task-create-withheld` run-audit event; and the two
source-text ratchet tests are replaced with behavioral assertions on the tool
list the executor actually hands the model — verified to fail when the guard is
broken, which the string assertions did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:44:56 -07:00
gsxdsm
13a2b2a9da fix(agent-tools): hide fn_task_create under Deny and widen the dedupe window
Operator report: with project policy "Ephemeral agent follow-up tasks = Deny",
an executing agent filed ten follow-up tasks — five parallel fn_task_create
calls it reported as timed out, then five sequential retries.

Two defects:

1. Deny was advisory. fn_task_create was registered for every session and only
   refused inside execute(), so the model still saw the tool, planned around it,
   and retried it. The pi extension's isEphemeralCallerAgent also failed OPEN
   whenever the caller id did not resolve to an agent row — which is the normal
   shape of an ephemeral task-worker — so on that lane Deny was a no-op.

2. The deterministic content-fingerprint duplicate window was 60s, which only
   covered concurrent in-flight creates. A retry two minutes later saw nothing
   and filed a second task.

Fixes: isAgentTaskCreateToolAvailable() withholds the tool from ephemeral
sessions under Deny in both engine lanes (outer execution session, per-step
workflow session); isEphemeralCallerAgent fails closed on an unresolvable
caller id; the fingerprint window goes 60s -> 10m (clamp ceiling 5m -> 1h).
upon_validation keeps the tool, and permanent-agent and human/chat callers are
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:03:46 -07:00
gsxdsm
99b80ad748 feat(dashboard): add opt-in auto-update and harden restart supervision
Add the `autoUpdateAndRestart` global setting (default off, Settings ->
General next to Release channel). When enabled, the dashboard host installs
available updates on the selected channel by itself and requests the
supervised in-place restart. Supervised hosts only: without a parent to
respawn, installing would leave a running process whose code no longer
matches its own install.

Fix two ways the restart affordance could silently do nothing:

- The supervisor now stamps FUSION_SUPERVISOR_PID and supervision is only
  counted when that pid is the real parent. FUSION_RESTART_SUPERVISED is
  inherited by every process Fusion spawns, so `fn dashboard` launched from
  an agent terminal skipped its own supervisor while still advertising
  restart support -- a restart request then killed it for good.
- Settings and the update banner probe /system/info on mount and treat
  capability as advisory: the button always issues the request and shows the
  server's actual refusal instead of sitting disabled after a failed probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:52:53 -07:00
gsxdsm
2ea5ac206d fix(cli-tests,dashboard): align quiet-mode result spies and voiceInput allowlist
CLI JSON/create success lines write via result() (raw stdout) so quiet mode
cannot drop machine-readable output; capture that seam in research/update/task
tests instead of console.log. Allowlist nested voiceInput settings for the
FN-7505 default-description guard and ship locale keys for Voice Input UI.
2026-07-25 09:02:59 -07:00
gsxdsm
b31bee03a8 FN-8576: add global quiet flag to CLI
Add a global quiet mode that suppresses informational CLI stdout without hiding requested results.

- Parse --quiet/-q and FUSION_QUIET with command and output exemptions.
- Route result output and interactive prompts around the reversible stdout gate.
- Document the flag and cover quiet output, prompts, and argument parsing.

Files changed:
 .changeset/fn-8576-cli-quiet-flag.md               |   7 ++
 docs/cli-reference.md                              |   8 ++
 packages/cli/src/__tests__/bin.test.ts             |  15 +++
 packages/cli/src/__tests__/cli-quiet-mode.test.ts  |  63 +++++++++++++
 .../__tests__/cli-quiet-prompt-surfaces.test.ts    |  33 +++++++
 packages/cli/src/bin.ts                            |  39 ++++++--
 packages/cli/src/commands/experiment-finalize.ts   |   3 +-
 packages/cli/src/commands/git.ts                   |  17 ++--
 packages/cli/src/commands/goals.ts                 |   4 +-
 packages/cli/src/commands/mission.ts               |   8 +-
 packages/cli/src/commands/node.ts                  |   4 +-
 packages/cli/src/commands/onboard.ts               |   4 +-
 packages/cli/src/commands/org-import.ts            |   5 +-
 packages/cli/src/commands/plugin.ts                |  14 ++-
 packages/cli/src/commands/port-prompt.ts           |   4 +-
 packages/cli/src/commands/project.ts               |   6 +-
 packages/cli/src/commands/research.ts              |   3 +-
 packages/cli/src/commands/task.ts                  | 101 +++++++++++---------
 packages/cli/src/commands/update.ts                |   3 +-
 packages/cli/src/commands/workflow.ts              |  13 +--
 packages/cli/src/output.ts                         | 104 +++++++++++++++++++++
 packages/cli/src/project-resolver.ts               |  20 ++--
 22 files changed, 388 insertions(+), 90 deletions(-)

Fusion-Task-Id: FN-8576

Fusion-Task-Lineage: 2493d6d5-bbd9-4fc2-b903-950457ef30b0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-25 00:54:46 -07:00
gsxdsm
e3dba364d1 FN-8564: update bundled Pi runtime to 0.82.0
Update Fusion's matched Pi dependencies and compatibility coverage for version 0.82.0.

- Pin Pi AI and coding-agent packages to the exact 0.82.0 release pair.
- Refresh provider catalog, supplemental model, auth storage, and Droid thinking coverage.
- Add the published CLI patch changeset.

Files changed:
 .changeset/fn-8564-pi-082.md                       |   7 +
 packages/cli/package.json                          |   4 +-
 packages/cli/src/__tests__/package-config.test.ts  |   2 +-
 packages/core/package.json                         |   2 +-
 packages/dashboard/package.json                    |   2 +-
 ...ister-model-routes-kimi-k3-supplemental.test.ts |   6 +-
 packages/engine/package.json                       |   4 +-
 .../src/__tests__/provider-registration.test.ts    |   4 +-
 packages/engine/src/auth-storage.ts                |  11 +-
 packages/engine/src/pi.ts                          |   6 +
 packages/pi-claude-cli/package.json                |   8 +-
 .../src/thinking-config.ts                         |  10 +-
 pnpm-lock.yaml                                     | 176 +++++++++++----------
 pnpm-workspace.yaml                                |   6 +-
 .../__tests__/check-pi-versions-pinned.test.mjs    |   8 +-
 15 files changed, 142 insertions(+), 114 deletions(-)

Fusion-Task-Id: FN-8564

Fusion-Task-Lineage: 543c5e17-4cb2-446f-9a1c-ec7ec8b8117a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-24 19:06:14 -07:00
gsxdsm
749167cbed fix(engine-controls): surface a paused engine instead of showing it as idle
A paused engine with nothing running derived executorState "idle", so the
footer badge was indistinguishable from a healthy engine waiting for work.
That is exactly the state a pause settles into once in-flight tasks drain:
triage and planning stall while the board keeps moving, with the pause only
visible by opening the Engine Control menu. Pause state now dominates run
state; the adjacent counters still report throughput.

In the CLI TUI, the global `t` (Git view) branch returned before the Utilities
dispatch in the same key handler, so the advertised "[t] Toggle Engine Pause"
was unreachable dead UI. It now yields when Utilities owns input, and because
the shortcut can stop the board, pausing takes a second `t` within 5s while
resuming stays single-press.

Tests assert the invariant across the whole state matrix (both pause flags x
0/1/5 running), not just the zero-running repro, plus the TUI routing, the
two-press pause, single-press resume, and re-arm behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 18:49:19 -07:00
gsxdsm
ab41554980 fix(cli-tests): load task.js once for lock-retry suite under real timers
Full Suite shard 4 timed out runTaskShow lock-exhaustion at the default 5s
budget (run 30096660913): each case re-imported the heavy task.js graph under
fake timers, so cold CI workers spent the whole budget on transform and left
unhandled store.getTask / process.exit races after the timeout.

Import task.js once per cached-store describe under real timers via a mutable
store holder, and enable fake timers only around the backoff body. Exhaustion
cases now finish in ~1ms locally while keeping the shipped entry path.
2026-07-24 08:27:57 -07:00
gsxdsm
e17151cba5 fix(cli-tests): align CLI suite with inbox-mail help, beta versions, and planning-session store
Stale-test drift, no product changes: FN-8424 inbox reply help text and
fn message inbox --user routing, beta-track prerelease suffixes in version
regexes (0.73.0-beta.N), FN-8399 onMigrationProgress in createTaskStoreForBackend,
#2400 workflow-docs heading, and the durable planning-session store mocks for
fn task plan (0412113de/fdd120232).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:35:12 -07:00
gsxdsm
0412113de7 fix(dashboard,cli): un-dead-end deleted plan tasks; harden fn task plan per review
Reported bug (screenshot): deleting the task created from a plan left the
session permanently stuck on PLANNING_CREATED_TASK_MISSING — Retry create
replayed the same 409 forever. A linked task absent from the
include-archived scan (task-row authority; a successful scan proves
deletion, not a flaky read) now clears the stale linkage and creates a
fresh task, in both the create-task route and createTaskFromPlanSession;
a still-listed-but-unreadable task keeps failing closed.

Multi-agent review of fdd120232 (correctness/adversarial/reliability):
- P1: CLI planning sessions were memory-only — setAiSessionStore only ran
  in the dashboard server, so --resume could never find a session across
  invocations. New ensureDurablePlanningSessionStore wires the durable
  AiSessionStore over the board store's public asyncLayer in runTaskPlan.
- P1: resume failures now THROW instead of process.exit (fn_task_plan
  runs inside the pi host — an exit killed the whole agent session), and
  a no-question resume requires an explicit refine focus (the provided
  description) so merely resuming never rotates the epoch.
- P2: claim and finalize CAS gained the same expected-epoch WHERE guard
  as reconcile, so a stale-epoch creator can no longer finalize an
  old-epoch task onto a rotated session.
- Side-effect failures (documents, logEntry, validate, reconcile) are now
  logged instead of swallowed; post-insert failures no longer mislabel
  the just-created task alreadyCreated:true; the keep-refining readline
  closes on thrown prompts and a failed refine after creation returns the
  created task id with a resume hint; cross-process generating guard
  added to createTaskFromPlanSession.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:19:18 -07:00
gsxdsm
fdd1202328 feat(cli): claim-aware multi-task planning parity for fn task plan
Closes the P1 agent-native gap from the multi-task review: the CLI and
fn_task_plan pi tool created tasks via a raw store.createTask with no
proposalClaimId — no idempotency, no session linkage, and tasks outside
the epoch sequence, so a later dashboard Proceed would duplicate them.

- New shared createTaskFromPlanSession in @fusion/dashboard/planning:
  the agent-surface twin of POST /planning/create-task (epoch-derived
  claim key, claim/finalize/reconcile/release CAS lifecycle with the 30s
  stale-lease takeover, formatPlanningPlanMd task shape, plan/original-
  description documents, validate-on-create, generating guard).
- runTaskPlan creates through it (making the FN-7734 retry wrapper
  genuinely safe), prints the session id, and offers an interactive
  keep-refining loop that creates further tasks from the evolved plan.
- fn task plan --resume <sessionId> / fn_task_plan resumeSessionId reopen
  an existing session — even a validated one whose task exists — and the
  no-question resume regenerates the interview via a refine turn, which
  rotates the creation epoch server-side.

Tests: CLI suite pins claim-aware creation, the continue prompt, and the
resume flow; dashboard suite pins createTaskFromPlanSession idempotent
replay and epoch-aware second creation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:01:50 -07:00
gsxdsm
11fab37379 feat(ci): incremental caching for the PR merge-gate jobs (Gate, Build, Typecheck) (#2421)
## Summary

Every PR's blocking checks were dominated by redundant full rebuilds,
not by tests. Measured on recent runs: the Gate job spent ~6 of its ~7.5
min on a cold `pnpm build` (the exact-key dist cache missed on virtually
every PR) for ~45s of actual boot smoke + gate tests; Build ran ~8 min
and Typecheck ~4 min, both fully cold every time. Expected end state
once the warm job has run on main: all four blocking checks in roughly
2–4 min wall-clock.

### Gate job
- New `gate-dist-*` cache namespace with `restore-keys`, additionally
caching `.fusion/cache/plugin-build-cache.json` (build-workspace's
per-package content-hash skip cache) and `packages/cli/dist`. The
always-run `pnpm build` reconciles a near-match restore by content hash
and rebuilds only the packages the PR touched. This is safe *because*
the gate builds after restoring — the shard jobs' "no restore-keys" rule
(FN-4232/FN-4605 stale-dist incidents) still stands there, since they
consume dist without building.
- `FUSION_CLI_FULL_PACKAGE=0` on the gate build: skips the multi-minute
CLI desktop/plugins/DTS packaging tail nothing in the gate consumes
(same shape `pnpm verify:fast` proves locally). Full CLI packaging
coverage stays blocking in the Build job.

### Build job
- Restore-only tap (`actions/cache/restore`) of the same warmed cache.
Restore-only because this job runs FULL CLI packaging (`CI=true`) and
saving that shape would swap the cache's canonical fast-CLI contents out
from under the Gate job. Its distinctive coverage is preserved:
`ensureFullPackageCliPlanned` force-plans the CLI in full mode
regardless of cache state.

### Typecheck job
- Caches per-package tsc incremental buildinfo — self-validating (tsc
hashes every input against it and re-checks whatever changed), so
`restore-keys` is correctness-neutral by construction.
- **Fixes a real incrementality bug:** `tsconfig.json` and
`tsconfig.app.json` in the dashboard both inherited
`${configDir}/dist/.tsbuildinfo` from `tsconfig.base.json`, so the two
typecheck programs clobbered each other's buildinfo and re-checked the
full program every run — incremental typechecking never worked for the
dashboard, in CI or locally. `tsconfig.app.json` now writes
`dist/.tsbuildinfo-app`. Measured: dashboard typecheck 44s cold → 5.6s
warm.

### Warm job (full-suite.yml, push to main)
- New `warm-gate-build-cache` job saves both caches from main on every
push. Caches saved on a PR merge ref are invisible to other PRs, so
without this every PR's *first* run would still build/check cold.

## Guardrails

`ci-workflow.test.ts` pins the coupled invariants so they can't drift
apart silently:
- restore-keys requires the reconciling `pnpm build` after restore,
before boot smoke
- the mtime-defeating seed step stays exact-hit-only
- byte-identical cache path lists between the Gate/Build/warm blocks
(actions/cache versions caches by path list — a drifted list makes
caches mutually invisible)
- Build stays restore-only and must NOT opt out of full CLI packaging
- Typecheck cache shape + the distinct dashboard app buildinfo path

## Notes

- First PR runs after this lands still build cold until the warm job has
run once on main.
- No changeset: CI config + test-only per AGENTS.md.

## Verification

- `ci-workflow.test.ts` + `package-config.test.ts`: 106 tests pass
- Dashboard typecheck run twice locally: 44s cold → 5.6s warm, both
`.tsbuildinfo` and `.tsbuildinfo-app` written, exit 0
- Cache block path/key parity verified programmatically across both
workflow files

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

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

## Summary by CodeRabbit

* **Performance**
* Improved CI build and type-check performance through incremental
caching.
* Added cache warming from the main branch to speed up pull request
checks.
* Enabled faster CLI packaging during gate validation while retaining
full packaging coverage elsewhere.

* **Bug Fixes**
* Prevented dashboard TypeScript build information from being
overwritten, preserving incremental type-checking reliability.

* **Tests**
* Added coverage to verify CI cache behavior, build ordering, cache
paths, and packaging modes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:53:31 -07:00
gsxdsm
e5caea542a FN-8544: gate mission remediation behind autopilot
Keep mission validation report-only until an operator explicitly enables autopilot.

- Gate validator-created remediation features and task dispatch behind mission autopilot.
- Audit attributed status and autopilot transitions atomically across mission stores.
- Expose mission autonomy controls and document the opt-in lifecycle.

Files changed:
 .changeset/fn-8544-mission-autonomy-audit.md       |   7 ++
 docs/missions.md                                   |  10 +-
 packages/cli/src/extension.ts                      |  26 ++++-
 .../__tests__/postgres/mission-store.pg.test.ts    |  27 +++++
 packages/core/src/async-mission-store.ts           |  70 +++++++++++--
 packages/core/src/index.gate.ts                    |   3 +
 packages/core/src/index.ts                         |   3 +
 packages/core/src/mission-store.ts                 | 113 +++++++++++++--------
 packages/core/src/mission-types.ts                 |  22 ++++
 .../dashboard/app/components/MissionManager.tsx    |   1 +
 packages/dashboard/src/mission-routes.ts           |  25 +++--
 .../src/__tests__/agent-mission-tools.test.ts      |  17 +++-
 .../src/__tests__/mission-execution-loop.test.ts   |  21 ++++
 packages/engine/src/agent-heartbeat.ts             |   4 +-
 packages/engine/src/agent-tools.ts                 |  30 +++++-
 packages/engine/src/executor.ts                    |   5 +-
 packages/engine/src/mission-autopilot.ts           |  51 +++++-----
 packages/engine/src/mission-execution-loop.ts      |  49 ++++++---
 packages/engine/src/triage.ts                      |   5 +-
 19 files changed, 377 insertions(+), 112 deletions(-)

Fusion-Task-Id: FN-8544

Fusion-Task-Lineage: 23a69923-5a19-407e-9fe2-8973c166ee9a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-23 15:25:09 -07:00
gsxdsm
0d355f371c FN-8547: add local OpenAI-compatible provider onboarding
Add guided onboarding for OpenAI-compatible local model providers.

- Add custom/local provider prompts and atomic models registry updates.
- Preserve registry fields, validate endpoint configuration, and support Qwen thinking compatibility.
- Document setup and cover registry persistence with onboarding tests.

Files changed:
 .changeset/fn-8547-local-provider-onboarding.md    |   7 +
 docs/cli-reference.md                              |  13 ++
 docs/settings-reference.md                         |  35 ++++-
 packages/cli/src/commands/__tests__/onboard.test.ts |  55 ++++++-
 packages/cli/src/commands/onboard.ts               | 161 ++++++++++++++++++++-
 5 files changed, 261 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8547

Fusion-Task-Lineage: 740221de-fce8-43ae-a095-13b8abf1904a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-23 12:31:55 -07:00
gsxdsm
961edf2145 fix(dashboard): mount plugin routes without engine
Keep plugin-defined APIs reachable in UI-only dashboards by sourcing routes from the plugin loader when no engine runner exists.
2026-07-22 13:47:58 -07:00
gsxdsm
2f014f5c0c fix(cli): install agent-browser across Windows, Linux, and macOS (#2405)
## Summary

Fusion npm installs now expose a working `agent-browser` command on
Windows, Linux, and macOS. Fusion publishes a top-level shim backed by
the exact pinned native package, preserving the existing Browser
Verification probe contract on every supported desktop OS.

## Validation

- Added a packed consumer-install matrix for `ubuntu-latest`,
`macos-latest`, and `windows-latest`.
- Each platform executes npm's generated shim, verifies `agent-browser
0.26.0`, and asserts the matching native binary is installed.
- Confirmed the packed Fusion manifest, installed dependency, and
command output all retain the exact declared version pin.
- Passed 101 focused CLI workflow/package tests, full workspace lint and
typecheck, strict changeset validation, and a real macOS ARM64
packed-install smoke.

## Post-Deploy Monitoring & Validation

- Search task logs for `agent-browser not found on PATH`, `Missing
native executable`, and Browser Verification availability warnings.
- Healthy signal: npm installs on Windows, Linux, and macOS resolve
`agent-browser --version` without missing-shim or native-payload errors.
- Failure signal: command resolution errors, version-pin mismatches,
missing native payloads, or increased browser-verification fast-bails.
- Validation window: first release cycle after publish; owner: Fusion
maintainers.
- Mitigation: revert the dependency and top-level shim if install
compatibility regresses.

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)


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

## Summary by CodeRabbit

* **New Features**
* Added cross-platform `agent-browser` installation through Fusion CLI.
  * Exposed an `agent-browser` command for Windows, macOS, and Linux.
* Pinned the included `agent-browser` version to ensure consistent
installations.

* **Bug Fixes**
* Improved reliability of platform-specific executable selection and
command setup across supported operating systems.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 13:37:44 -07:00
gsxdsm
241a5c94ea chore: bump @earendil-works/pi to 0.81.1 (#2399)
## Summary
- Bump `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent`
from **0.80.10 → 0.81.1** (exact matched pins).
- Update `pnpm-workspace.yaml` overrides so floating `*` consumers
(`droid-cli`, `pi-llama-cpp`, runtime plugins) stay on the same
ModelRuntime surface.
- Refresh pin-guard tests, package-config assertions, and FNXC notes for
the new pin.

## What's new in pi 0.81.x
- Qwen Token Plan providers
- Expanded usage accounting (tools/compaction/branch summaries)
- Resilient compaction retries + retry lifecycle events
- Full provider-extension registration API
- Built-in llama.cpp router management
- Provider/catalog fixes (Bedrock env credentials, OpenAI Responses
early-stream retry, Codex 272K defaults, extension stream-fallback
restore)

## Test plan
- [x] `scripts/check-pi-versions-pinned` (4/4)
- [x] Typecheck: core, engine, dashboard, cli, pi-claude-cli
- [x] `package-config.test.ts` (35)
- [x] `provider-registration.test.ts` (14)
- [x] `auth-storage-concurrency` + `model-registry-refresh` (15)
- [x] `register-model-routes-kimi-k3-supplemental` (1)
- [ ] CI gate green
- [ ] Spot-check Anthropic OAuth + API key session
- [ ] Spot-check openai-codex model picker / supplemental models

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

## Summary by CodeRabbit

* **Updates**
  * Updated the bundled Pi runtime to version 0.81.1.
* Added support for newer models and providers, including Qwen Token
Plan.
  * Improved usage accounting and session reliability.
* Strengthened compaction retry handling and provider catalog accuracy.
  * Added support for the expanded maximum thinking level.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 01:32:14 -07:00
gsxdsm
396090fc03 fix(startup): bound model registry refresh so dashboard cannot hang
Post-extension modelRegistry.refresh() had no timeout, so a hung remote
catalog fetch left the TUI on "Loading extensions…" forever. Use a shared
15s-bounded refresh across dashboard/serve/daemon and related registration paths.
2026-07-21 17:14:30 -07:00
gsxdsm
5f0502e166 FN-8452: reject invalid update flags and announce beta releases
Make update commands fail clearly on invalid arguments while helping stable users discover newer beta releases.

- Parse update and upgrade options strictly, rejecting unknown, duplicate, and malformed flags before running an update
- Show a live-registry beta availability notice for stable human-readable output without affecting JSON or cached results
- Add CLI coverage, beta bootstrap documentation, and a patch changeset

Files changed:
 .changeset/fn-8452-update-unknown-flags.md         |   7 +
 RELEASING.md                                       |   2 +-
 docs/cli-reference.md                              |   4 +
 docs/getting-started.md                            |   2 +
 packages/cli/src/__tests__/bin-update-args.test.ts |  75 ++++++++++
 packages/cli/src/bin.ts                            |  25 +---
 packages/cli/src/commands/__tests__/update.test.ts |  44 ++++++
 packages/cli/src/commands/update.ts                | 162 +++++++++++++++++++--
 8 files changed, 285 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8452

Fusion-Task-Lineage: b29a1ce5-5a40-40ec-ac18-07107fa18344

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-21 10:30:19 -07:00