24f5ffaffabfde22cfd32f044f3c3db7a9e5ce2b
729 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
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
|
||
|
|
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.
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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) |
||
|
|
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 --> |
||
|
|
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 -->
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
3962222863 |
FN-8424: route CLI chat replies through inbox mail
Route agent replies to the correct CLI or dashboard mailbox with bounded polling deadlines. - add reply-parent routing validation and CLI/dashboard inbox selection - preserve named mailbox conversations while handling per-message reply deadlines - document chat and inbox interfaces and cover deadline and routing regressions Files changed: .changeset/fn-8424-cli-chat-reply-routing.md | 7 + docs/agents.md | 20 +- docs/cli-reference.md | 29 +- packages/cli/src/bin.ts | 15 +- packages/cli/src/commands/__tests__/chat.test.ts | 262 +++++++++--------- .../cli/src/commands/__tests__/message.test.ts | 12 + packages/cli/src/commands/chat.ts | 293 ++++++++++++--------- packages/cli/src/commands/message.ts | 18 +- ...tools-send-message-recipient-validation.test.ts | 86 +++++- packages/engine/src/agent-heartbeat-prompts.ts | 8 +- packages/engine/src/agent-tools.ts | 71 +++-- 11 files changed, 523 insertions(+), 298 deletions(-) Fusion-Task-Id: FN-8424 Fusion-Task-Lineage: 28d0ef88-717e-4f39-8880-64d2fef94706 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
9db0ffc1f9 |
FN-8425: route CLI chat through agent inbox
Route CLI chat messages through durable, named agent mailbox conversations. - Add conversation IDs and parsing for CLI chat sessions. - Filter CLI chat history and replies by mailbox conversation identity. - Surface conversation IDs to agents and document the inbox-based transport. Files changed: .changeset/fn-8425-cli-chat-conversation.md | 7 + docs/agents.md | 11 +- docs/cli-reference.md | 15 +- packages/cli/src/__tests__/bin-chat-args.test.ts | 34 +++++ packages/cli/src/bin.ts | 45 ++---- packages/cli/src/commands/__tests__/chat.test.ts | 162 +++++++++++++++++++++ packages/cli/src/commands/chat.ts | 132 +++++++++++++++-- packages/core/src/types/messages.ts | 6 + .../__tests__/agent-tools-read-messages.test.ts | 48 ++++++ packages/engine/src/agent-tools.ts | 10 +- 10 files changed, 418 insertions(+), 52 deletions(-) Fusion-Task-Id: FN-8425 Fusion-Task-Lineage: 5091f49f-1f12-4ff7-8d21-48f008cf984e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
81abe53957 |
FN-8430: simplify TUI log timestamps
Keep dashboard TUI log rows focused on useful message content. - Show capture times as HH:MM:SS across list, expanded, and copied log output. - Strip leading detailed upstream timestamps from list and clipboard text while preserving raw expanded messages. - Add regression coverage and a patch changeset. Files changed: .changeset/fn-8430-tui-log-timestamps.md | 7 ++ .../commands/dashboard-tui/__tests__/app.test.tsx | 93 +++++++++++++++++++++- packages/cli/src/commands/dashboard-tui/app.tsx | 33 ++++++-- 3 files changed, 125 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-8430 Fusion-Task-Lineage: b656dfa1-3c16-4a6f-8ba8-a936fdfdd42d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
4f0d89e106 |
FN-8419: safeguard project partition reconciliation
Safely reconcile fallback and registered project partitions during dashboard startup. - Merge duplicate partition rows with fallback data taking precedence. - Validate unique indexes and foreign-key dependencies before rekeying. - Bind safely after failed promotion and stop non-retryable dashboard failures. - Add PostgreSQL reconciliation and supervisor coverage. Files changed: .changeset/fn-8419-rekey-partition-merge.md | 7 + packages/cli/src/bin.ts | 5 +- .../commands/__tests__/dashboard-supervise.test.ts | 16 +- packages/cli/src/commands/dashboard.ts | 41 ++- .../src/__tests__/postgres/schema-applier.test.ts | 178 +++++++++++- packages/core/src/async-secrets-store.ts | 9 +- packages/core/src/index.ts | 8 +- packages/core/src/postgres-errors.ts | 9 + packages/core/src/postgres/index.ts | 5 + packages/core/src/postgres/migration-stamping.ts | 318 ++++++++++++++++----- packages/core/src/postgres/startup-factory.ts | 49 +++- packages/core/src/process-supervisor.ts | 3 + packages/core/src/task-store/async-persistence.ts | 7 +- 13 files changed, 546 insertions(+), 109 deletions(-) Fusion-Task-Id: FN-8419 Fusion-Task-Lineage: bfc54e40-a31e-4b61-b6ae-01eb147efde1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
5c67b19cb2 |
FN-8394: rescue deterministic quarantined tests
Restore reliable test coverage and delete quarantined tests that could not be rescued. - Replace process- and database-dependent tests with bounded dependency seams - Restore stabilized CLI, dashboard, and plugin test coverage - Remove unrescuable bundle and merge-worktree test suites and clear the quarantine ledger Files changed: packages/cli/src/__tests__/bundle-output.test.ts | 519 ------------ .../src/commands/__tests__/task-lock-retry.test.ts | 10 + packages/cli/vitest.config.ts | 8 - .../TaskDetailModal.tab-persistence.test.tsx | 2 +- .../__tests__/TaskDetailModal.test-helpers.ts | 7 + .../src/__tests__/dev-server-process.test.ts | 391 ++++----- packages/dashboard/src/dev-server-process.ts | 22 +- packages/dashboard/vitest.config.ts | 21 +- .../merge-reuse-task-worktree.slow.test.ts | 876 --------------------- packages/engine/vitest.config.ts | 7 - .../src/__tests__/process-lifecycle.test.ts | 21 +- .../fusion-plugin-grok-runtime/vitest.config.ts | 2 - .../src/__tests__/async-quality-store.pg.test.ts | 148 +++- plugins/fusion-plugin-quality/vitest.config.ts | 3 +- scripts/lib/test-quarantine.json | 43 +- 15 files changed, 323 insertions(+), 1757 deletions(-) Fusion-Task-Id: FN-8394 Fusion-Task-Lineage: e949b33e-b8d5-4f73-a002-e550b97ee125 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
e4a032d9d9 |
FN-8399: expose incomplete migration status on dashboard
Expose durable SQLite-to-PostgreSQL migration state through dashboard health and banners. - Read per-project running and failed migration markers from PostgreSQL - Surface degraded migration state in health endpoints and dashboard banners - Preserve migration context across CLI and runtime startup paths - Document the recovery workflow and add a patch changeset Files changed: .changeset/FN-8399-migration-status-dashboard.md | 7 +++ docs/storage.md | 5 ++ packages/cli/src/commands/daemon.ts | 19 +++++++ packages/cli/src/commands/desktop.ts | 6 +++ packages/cli/src/commands/serve.ts | 20 +++++++- packages/core/src/index.ts | 2 + packages/core/src/postgres/index.ts | 2 + packages/core/src/postgres/sqlite-migrator.ts | 43 ++++++++++++++++ packages/dashboard/app/api/health.ts | 7 ++- .../app/components/dashboard/DashboardBanners.tsx | 18 ++++++- .../dashboard/__tests__/DashboardBanners.test.tsx | 12 ++++- .../__tests__/dashboard-postgres-health.test.ts | 45 +++++++++++++++++ .../dashboard/src/dashboard-postgres-health.ts | 58 ++++++++++++++++++++++ packages/dashboard/src/server.ts | 27 ++++++++-- packages/engine/src/project-engine-manager.ts | 4 ++ packages/engine/src/project-runtime.ts | 9 +++- packages/engine/src/runtimes/in-process-runtime.ts | 1 + 17 files changed, 275 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-8399 Fusion-Task-Lineage: e196d6c4-ea9a-48ba-bedc-9e6fa44c33d3 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
3e376b98ba |
FN-8369: centralize GitHub issue import deduplication
Use provenance-first deduplication consistently across dashboard, CLI, and extension GitHub imports. - Prioritize persisted sourceIssue and legacy metadata over editable descriptions. - Reuse the dashboard deduplication helper for CLI and extension imports. - Prevent duplicate issue creation within a dashboard batch import. Files changed: .changeset/fn-8369-github-import-dedup.md | 7 ++++++ packages/cli/src/__tests__/extension.test.ts | 27 ++++++++++++++++++++++ packages/cli/src/commands/__tests__/task.test.ts | 20 ++++++++++------ packages/cli/src/extension.ts | 25 +++++++++++--------- packages/dashboard/src/__tests__/github.test.ts | 26 ++++++++++++++++----- packages/dashboard/src/__tests__/routes-github.test.ts | 22 ++++++++++++++++++ packages/dashboard/src/github.ts | 27 +++++++++++++--------- 7 files changed, 119 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-8369 Fusion-Task-Lineage: 8eb6d19d-bd0e-487a-9f11-8945d744b7df Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
954ebe816a |
FN-8362: isolate agent lifecycle pauses and backlog claims
Keep agent lifecycle transitions independent from task pause state and make automatic backlog pickup executor-first. - Prevent agent stop, sleep, resume, and heartbeat recovery from changing assigned task pause state - Limit engineer automatic backlog pickup to explicit opt-in while preserving explicit routing - Document CLI and extension lifecycle behavior and cover the boundaries with regression tests Files changed: .../cli/skill/fusion/references/extension-tools.md | 4 +-- .../skill/fusion/references/fusion-capabilities.md | 4 +-- packages/cli/src/__tests__/extension.test.ts | 13 +++++++ packages/cli/src/commands/__tests__/agent.test.ts | 27 ++++++++++++-- packages/cli/src/commands/agent.ts | 4 +++ packages/cli/src/extension.ts | 8 +++-- .../core/src/__tests__/agent-role-policy.test.ts | 7 +++- packages/core/src/agent-role-policy.ts | 7 ++++ .../src/__tests__/heartbeat-executor.test.ts | 35 +++++++++--------- packages/engine/src/__tests__/self-healing.test.ts | 30 ++++++++++++++++ packages/engine/src/agent-heartbeat.ts | 42 ++++++++++++++++------ packages/engine/src/self-healing.ts | 12 +++++++ 12 files changed, 157 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-8362 Fusion-Task-Lineage: a1fb37f3-f95e-4eb7-9fbf-bdc0ab5d5f9e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
2302fb8a3d |
feat: add beta/stable release tracks with switchable update channel (#2345)
## Summary Fusion can now ship on two release tracks. Betas are cut from `main` as `vX.Y.Z-beta.N` (npm dist-tag `beta`, GitHub prerelease), stable releases are promoted to a long-lived `release` branch and published to `latest`, and users pick their track with the new `updateChannel` global setting — via **Settings → General → Release channel** or `fn update --channel <stable|beta>`. Previously everything was single-track: every publish landed on `latest` and every update surface could only see it. | | beta | stable | |---|---|---| | Cut from | `main` | `release` branch | | Version | `X.Y.Z-beta.N` (changesets pre-mode) | `X.Y.Z` | | npm dist-tag | `beta` | `latest` | | GitHub Release | prerelease | latest | | Homebrew tap / X draft | skipped | bumped / printed | ## How releasing works now `pnpm release` prompts for the channel and **defaults to beta**, so day-to-day releases are betas; stable is always an explicit choice. Choosing stable from `main` triggers assisted promotion: the script proposes the newest beta tag reachable from HEAD, verifies `release` fast-forwards to it, then runs the whole stable release inside a temporary git worktree on `release` — the primary checkout never leaves `main`. Changesets pre-mode preserves changeset files across betas, so the promoted stable release aggregates every changeset since the last stable into one clean changelog entry. ## Design decisions - **Every publish path names an explicit `--tag`.** A beta accidentally landing on `latest` is the one unrecoverable failure of a dual-track scheme, so nothing relies on npm's implicit default (`release.mjs`, `version.yml`). - **Beta channel resolves to semver-max of `latest` and `beta`**, so beta users are offered each promoted stable once it overtakes their prerelease. Switching beta → stable never downgrades; `fn update --channel stable --force` is the explicit escape hatch. - **One comparator instead of three.** CLI, dashboard, and desktop each had their own `isRemoteNewer` that ignored prerelease identifiers — `0.73.0-beta.2`, `-beta.3`, and `0.73.0` all compared equal, which breaks the moment any beta exists. They now share full SemVer-precedence helpers (`compareVersions`, `resolveUpdateTargetVersion`) from `@fusion/core`. - **Installs pin exact versions** (`@runfusion/fusion@0.73.0-beta.2`), never a dist-tag, so an install can't silently land on the wrong track. - **Desktop channels via electron-updater manifests.** Beta tags build desktop artifacts with `publish.channel=beta` (emitting `beta*.yml`); the app sets `channel`/`allowPrerelease` from the shared setting, re-read on every manual check. - **Update caches are channel-stamped** — a cache written for one channel is never served to the other, so switching tracks takes effect on the next check instead of after TTL. ## Test plan - New unit coverage: SemVer precedence + channel resolution in `@fusion/core` (30), channel behavior of the dashboard update check (28, incl. 9 new) and `fn update` (16, incl. 8 new: persist `--channel`, no-downgrade, `--force`, cache channel mismatch). - `pnpm verify:fast` green (scoped typecheck, builds, CLI build, boot smoke); desktop + settings-section suites green. - `release.mjs` dry-run matrix exercised by hand: channel prompt (default/override/invalid), branch preflights per channel, assisted-promotion target selection, fast-forward guard against a diverged `release` branch, and bootstrap when no `release` branch exists. - Not exercised live: an end-to-end publish (needs TTY authorization + real npm publish). First real run is the first `pnpm release --channel beta`. --- [](https://github.com/EveryInc/compound-engineering-plugin)  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added beta and stable release channels across CLI, dashboard, and desktop updates. * Users can select a channel via Settings or `fn update --channel <stable|beta>` (stored as a global default). * Desktop beta releases now generate beta update manifests and publish as prereleases. * **Documentation** * Expanded release-track, settings, and CLI references to explain channel semantics and workflows. * **Bug Fixes** * Updates now pin the resolved version per channel, improve version comparison, and prevent unintended cross-channel downgrades unless `--force` is used. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
aa401123ea |
fix: make Windows updates and CE personas reliable (#2340)
## Summary Windows installs with slow native dependencies now get five minutes to finish, and a real timeout is reported as an actionable terminal retry instead of a wall of preceding npm deprecation warnings. Registry `ETIMEDOUT` errors keep their network diagnosis, including after the legacy-bin `--force` retry. Compound Engineering personas are now included in the published CLI bundle, with complete source-to-staged coverage for all persona definitions and a clear startup error if the bundled assets are missing or empty. The PostgreSQL statement visible in the report was validated by the existing real-Postgres schema reapply test. Its actual `caused by` detail was truncated, so this PR deliberately makes no speculative database change. ## Validation - Dashboard updater tests: 22 passed - CLI updater tests: 16 passed - CE persona installer tests: 7 passed - Published bundle persona assertion: passed against every source persona - CLI and CE plugin typechecks: passed - Changed production/config lint and strict changeset validation: passed - Real PostgreSQL schema reapply integration test: passed --- [](https://github.com/EveryInc/compound-engineering-plugin) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Windows CLI and dashboard updates now allow up to five minutes for installation and restore Compound Engineering agent personas during npm installs. * Update failures now surface clearer, terminal timeout guidance (while preserving specific network connection diagnostics) and avoid misleading “deprecated”/generic timeout text. * Persona assets are reliably included in plugin builds and bunded persona installation now errors clearly when definitions are missing or empty. * **Tests** * Expanded update and bundling coverage for the new 5-minute timeout and error-handling scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
4cc8002c97 |
FN-8341: rework planning interviews for reactive validation
Make planning interviews user-controlled and context-aware from validation through task creation. - Replace fixed-depth and deepening checkpoints with reactive follow-up questions - Add explicit planning validation and require it before creating or breaking down work - Expose validation through the API, CLI, routes, types, documentation, and release notes - Update localized copy and PlanningModeModal mocks and assertions for the revised workflow Files changed: .changeset/fn-8341-deepening-checkpoint-removal.md | 7 + .changeset/fn-8341-planning-reactive-backend.md | 7 + docs/dashboard-guide.md | 8 +- packages/cli/src/commands/task.ts | 12 +- packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 38 +- packages/dashboard/app/api/legacy.ts | 15 +- .../dashboard/app/components/PlanningModeModal.tsx | 192 +----- .../__tests__/PlanningModeModal.autosize.test.tsx | 4 + .../__tests__/PlanningModeModal.initial.test.tsx | 50 +- .../PlanningModeModal.planning-flow.test.tsx | 442 +------------ .../__tests__/PlanningModeModal.test-helpers.ts | 1 + .../PlanningModeModal.ui-interactions.test.tsx | 3 + .../__tests__/planning-infinite-interview.test.ts | 221 +++++++ packages/dashboard/src/planning.ts | 711 ++++++--------------- .../src/routes/register-planning-subtask-routes.ts | 64 +- packages/i18n/locales/en/app.json | 1 - packages/i18n/locales/es/app.json | 1 - packages/i18n/locales/fr/app.json | 1 - packages/i18n/locales/ko/app.json | 1 - packages/i18n/locales/zh-CN/app.json | 1 - packages/i18n/locales/zh-TW/app.json | 1 - 23 files changed, 536 insertions(+), 1249 deletions(-) Fusion-Task-Id: FN-8341 Fusion-Task-Lineage: 102f88af-2675-43f5-8e08-5403a0e17da8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
baa1baff9f |
FN-8283: add secret-scrubbed organization bundle CLI
Add portable, secret-scrubbed organization export and import workflows. - Assemble agents, raw skills, routines, automations, and settings into versioned bundles. - Add org-export and org-import CLI commands with dry-run and collision controls. - Preserve existing skills by default or materialize suffixed destinations, with CLI coverage. Files changed: .changeset/fn-8283-org-bundle.md | 7 + docs/cli-reference.md | 9 ++ docs/secrets.md | 10 ++ packages/cli/src/__tests__/bin.test.ts | 17 ++ packages/cli/src/bin.ts | 26 ++- .../cli/src/commands/__tests__/org-export.test.ts | 30 ++++ .../cli/src/commands/__tests__/org-import.test.ts | 31 ++++ packages/cli/src/commands/org-export.ts | 19 +++ packages/cli/src/commands/org-import.ts | 18 +++ packages/core/src/__tests__/org-bundle.test.ts | 69 ++++++++ packages/core/src/index.ts | 17 ++ packages/core/src/org-bundle.ts | 176 +++++++++++++++++++++ 12 files changed, 428 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-8283 Fusion-Task-Lineage: 93877746-8e57-4c79-bdbe-8e4ec7a2efe6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
afb2ed0650 |
FN-8271: restore quarantined CLI tests under shard load
Restore affected CLI and engine tests by removing load-amplifying fixture work and synchronizing fake-timer recovery. - Replace the dist-barrel PostgreSQL fixture with an injected in-memory task store. - Move mission and goal tool coverage to the shared PostgreSQL harness and complete plugin-store mocks. - Return rescued CLI and heartbeat tests to default lanes and clear their quarantine records. Files changed: .../src/__tests__/extension-dist-barrel.test.ts | 90 ++++++++-------------- .../__tests__/extension-mission-goal-tools.test.ts | 27 ++++--- packages/cli/src/commands/__tests__/plugin.test.ts | 11 +++ packages/cli/vitest.config.ts | 21 +---- .../src/__tests__/heartbeat-error-recovery.test.ts | 33 ++++---- packages/engine/vitest.config.ts | 7 +- scripts/lib/test-quarantine.json | 78 +------------------ 7 files changed, 84 insertions(+), 183 deletions(-) Fusion-Task-Id: FN-8271 Fusion-Task-Lineage: 212a3ec7-db6b-4e80-97c3-1c704822cf60 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
0b6c4cd4ca |
feat(dashboard,desktop): show live database-migration progress during boot
The one-time SQLite→PostgreSQL migration runs inside createTaskStoreForBackend before any HTTP server listens, so browsers saw "connection refused" and open tabs failed silently for minutes. Now: - CLI: a temporary holding server binds the dashboard port for the boot window, serving an auto-reloading "Database migration in progress" page and an /api/health payload with status "migrating" + structured progress; the port is handed off (awaited) to the real app.listen(). - Dashboard SPA: already-open tabs render the new MigrationInProgressBanner from the 15s health poll when status is "migrating". - Desktop: LocalRuntimeManager publishes migration progress on DesktopRuntimeStatus via the new core onMigrationProgress option; DesktopLaunchGate shows the live label and extends its 30s startup timeout while progress advances (2min stall cap), in both boot and first-run flows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9cafa045df |
fix(pr-merge): resolve repo from the project checkout, not process.cwd(), in PR-mode auto-merge (#2281)
## Summary In a centrally-installed, multi-project Fusion server (one process serving several repos, `process.cwd()` = the install dir, not any repo), every task under `mergeStrategy: "pull-request"` fails at the auto-merge stage with: ``` Could not determine repository. Specify owner/repo in params or run from a git repository with a GitHub remote. ``` PR creation from the dashboard and status polling work; only the engine's automatic PR path fails. This is the **non-workspace sibling of #1924** (FN-7610 routed workspace-mode tasks to direct merge but does not cover regular multi-project tasks) and the completion of #1797/FN-7133 (which fixed only the `getPrMergeStatus` arguments). ## Root cause `GitHubClient.resolveRepo()` (`packages/dashboard/src/github.ts`) falls back to a cwd-less `getCurrentRepo()` — i.e. `git remote get-url origin` in `process.cwd()` — whenever a PR method is called without explicit `owner`/`repo`. The engine merge path already resolves the correct repo from the per-project cwd (`prRepo = getCurrentRepo(cwd)`, FN-7133) but only threaded it into `getPrMergeStatus`. Every other GitHub call omitted it: - `processPullRequestMergeTask`: `findPrForBranch` / `createPr` / `mergePr` on both the per-task and shared-branch-group paths - `createGroupPrCallback` (group-PR promotion): `findPrForBranch` / `createPr` - `createPrNodeGithubOps` (`pr-create`/`pr-merge` workflow nodes): cwd-less `getCurrentRepo()` persisted `entity.repo` as `""` (poisoning the downstream `splitRepoSlug` consumers), and the git push/`createPr`/`mergePr` ran against `process.cwd()` - the engine's review-response run (`buildRespondCallback`): `respondOps.getCwd` collapses to `process.cwd()` because no CLI composition site wires `getTaskWorktree`, so its git ops and response agent ran outside the project repo In a central install the fallback throws; worse, if `process.cwd()` happens to be inside some *other* git repo, it silently targets the **wrong repository**. ## What changed - `fix(pr-merge): thread repo identity into PR auto-merge GitHub calls` — widens the CLI-local `GitHubOperations` interface (optional `owner`/`repo`, already accepted by `GitHubClient`'s `FindPrParams`/`CreatePrParams`/`MergePrParams`) and passes `prRepo` at all six call sites in `processPullRequestMergeTask`. - `fix(pr-merge): resolve group-PR repo from project cwd in createGroupPrCallback` — resolves via `getCurrentRepo(cwd)` from the callback input (same T4 pattern as `syncGroupPrCallback`) with a loud failure instead of a silent wrong-repo fallback. - `fix(pr-merge): resolve PR-node repo from task worktree instead of process cwd` — `resolvePrSource` resolves from `task.worktree`, git ops run in `getTaskWorktree(...) ?? task.worktree ?? process.cwd()`, and `createPr`/`mergePr` pass `owner`/`repo` parsed from `entity.repo`. - `fix(pr-merge): resolve review-response run cwd from the task worktree` — the engine owns the store, so `buildRespondCallback` prefers the task's recorded `worktree` for the response run's git ops + agent, keeping `ops.getCwd` as the single-project fallback (defensive against structural `PrNodeStore`s without `getTask`). - Changeset (`@runfusion/fusion` patch, structured body) included. Deliberately **not** done: a constructor-scoped default repo on `GitHubClient` — one client instance is shared across all projects in a central install (`serve.ts`/`daemon.ts`/`dashboard.ts`), so per-call `owner`/`repo` is the only correct scope. ## Testing - New regression tests simulate the central-install topology (`getCurrentRepo` mocked as `(cwd?) => cwd ? repo : null`, exactly the failing environment) and drive the merge flow end-to-end on the per-task path, the shared-branch-group path, `createGroupPrCallback`, and all three `createPrNodeGithubOps` ops, asserting every GitHub call carries explicit `owner`/`repo` (45 tests in `packages/cli/src/commands/__tests__/task-lifecycle.test.ts`, all green). - `packages/engine/src/__tests__/pr-respond-cwd-resolution.test.ts` covers the respond-run cwd: worktree preferred, `ops.getCwd` fallback when the task has no worktree, when the lookup fails, and when a structural store has no `getTask`. - Existing exact-argument assertions were extended to the new call contract (no assertions weakened or removed). - `pnpm lint`, `pnpm typecheck`, and `pnpm build` green locally; `pnpm test:gate`'s engine-core suite green (294/294) — its PostgreSQL-backend lane needs local PG credentials this environment lacks, so that lane defers to CI. `pnpm verify:fast` (scoped typecheck/build + CLI build + boot smoke) also passes. ## Repro 1. Install the CLI centrally; run the server from a dir that is not a git repo, serving ≥1 project with a GitHub `origin` and `mergeStrategy: "pull-request"`. 2. Run a task to completion and let it reach the merge stage. 3. Before this fix: the auto-merger throws `Could not determine repository …` (tasks with a persisted PR poll fine but never merge). Merging the same task from the Pull Requests tab succeeds, because the dashboard route resolves the repo explicitly (`parseBadgeUrl(...) ?? getCurrentRepo(rootDir)`). Full analysis: https://github.com/Tchori-Labs/Fusion/issues/4 --- Developed with Claude (co-authored on all commits). https://claude.ai/code/session_01ChEa8SHFYNAzjCdFbwFMfh <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Resolved pull request auto-merge failures in centrally installed, multi-project deployments. - Ensured explicit repository context (`owner/repo`) is used for pull request lookup, creation, and merging throughout the merge workflow. - Improved pull request response handling to prefer the task worktree for working-directory resolution, with safe error behavior when task details are unavailable. - **Tests** - Expanded coverage for multi-repository merge workflows and worktree-based repository/cwd resolution in PR response handling. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
065890ca01 |
FN-8222: align CLI tests with current store APIs
Repair CLI test mocks and retry-reset expectations for the current store contracts. - Add the backend store factory to the experiment-finalize mock. - Provide global settings directory access in backup test stores. - Assert all manual retry reset fields in task command tests. Files changed: .../extension-experiment-finalize.test.ts | 13 ++++++++++ .../commands/__tests__/backup-lock-retry.test.ts | 2 ++ packages/cli/src/commands/__tests__/backup.test.ts | 17 ++++++++++++- packages/cli/src/commands/__tests__/task.test.ts | 28 +++++++++++++++++----- 4 files changed, 53 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-8222 Fusion-Task-Lineage: 033ce6a6-c699-409c-a59e-2d1f5e041cfc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
b686fbd61d |
FN-8220: mock daemon model runtime setup
Align daemon startup mocks with the model runtime initialization path. - Add the auth-storage setModelRuntime mock. - Stub the Fusion model registry factory to use the shared test registry. Files changed: packages/cli/src/commands/__tests__/daemon.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) Fusion-Task-Id: FN-8220 Fusion-Task-Lineage: 6e00980f-2e24-4502-a4d8-e91849df33b9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> |
||
|
|
5acea6c0cf |
fix(pg): route residual SQLite-stub store paths through the async data layer (#2273)
## Summary An audit of the SQLite→PostgreSQL store migration found data-store paths still reaching the removed SQLite stub in backend (PG) mode. In backend mode `store.db`/`getDatabase()` throw the removed-SQLite error, so each of these either threw on every run or — worse — had the throw swallowed into a silent wrong result. This PR routes all of them through the `AsyncDataLayer` (and removes one dead primitive). ## The 6 live bugs fixed | Fix | Was | |-----|-----| | `executor.ts` authoritative assigned-agent fallback now inherits the TaskStore `asyncLayer` | silently returned `null` → model drift to the pi built-in (the exact thing its comment guards) | | `pruneAgentLogFilesAsync` replaces the sync self-healing prune call | threw `SQLite Database is not available` every maintenance sweep → agent-log pruning never ran | | `cleanupOrphanedMaterializedSteps` deletes PG `workflow_steps` rows on a failed create | swallowed the throw → leaked rows | | `deleteTaskBackendImpl` now runs the async mission feature/task-link unlink | PG hard delete left orphaned mission links | | `getWorkflowSettingsProjectId` returns `rootDir` in backend mode without touching the stub | swallowed throw for unscoped backend stores | | `fn plugin` unregistered-project fallback bootstraps a `CentralCore` `AsyncDataLayer` | layerless `PluginStore` threw in PG | ## The 4 latent traps, fixed properly - **`cleanupArchivedTasks`** — real async port (enumerate archived soft-deleted rows, guarantee cold snapshot, hard-delete project row + purge selection rows + rm dir). - **`deleteWorkflowStep`** — real async port (delete `workflow_steps` via the layer with `.returning()` to preserve the not-found contract). - **`applyTaskPatch`** — **removed** (zero-caller SQLite column-patch primitive with no backend analogue; impl + facade + import deleted). - **`AgentStore.importLegacyFileRuns`** — clean backend no-op (no legacy SQLite run-files exist in a PG deployment; its only `init()` caller early-returns in backend mode). ## Symptom Verification New PG regression suite `packages/core/src/__tests__/postgres/store-sqlite-residue-fixes.pg.test.ts` reproduces the original failures against real embedded Postgres and asserts they're gone: - orphaned `workflow_steps` are actually deleted (no swallowed throw) - `pruneAgentLogFilesAsync` resolves and prunes inactive-task log files - hard delete unlinks the mission feature from the task - `deleteWorkflowStep` removes the row / reports not-found - `cleanupArchivedTasks` hard-deletes the project row while retaining the cold snapshot ## Verification - `@fusion/core`, `@fusion/engine`, `@runfusion/fusion` typecheck clean - ~50 existing + 5 new PG tests pass; lint clean; changeset validates 🤖 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** * Prevented PostgreSQL backend maintenance from hitting removed legacy SQLite code paths, avoiding datastore failures and residue cleanup issues. * Fixed workflow-step deletion and “not found” behavior in backend mode. * Ensured backend hard-deletes correctly unlink related mission feature/task links and clean orphaned materialized steps. * Prevented legacy file-run imports from incorrectly reporting success in backend mode. * **New Features** * Added async agent-log pruning for inactive tasks and updated maintenance to use it. * **Tests** * Added PostgreSQL regression coverage for residue fixes and archive/workflow cleanup. * **Refactor** * Removed an unused task patch operation and updated task-store cleanup methods to be async where needed. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |