41cdcc741ebb4e89bb07128d4887835309508c29
2223 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c220455e3a |
fleet: 10 inline fallback arms become named sets (census 102 → 92) (#3061)
## Census | | column guards | |---|---| | before | **102** | | after | **92** | Five files drop to **0** guards each. Baseline re-recorded in the same commit. ## A cluster the census could not distinguish from real debt **Every site here is already converted.** Each reads resolved lanes when it has them and falls back to a legacy id when it doesn't: ```ts reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review" ``` The census counts an inline comparison **whether or not it sits in a fallback branch** — its `traitFallback` hint is advisory and never changes `kind`. So ten correctly-converted guards sat on the backlog permanently, and the number stopped distinguishing *work still to do* from *documented degraded answers*. Naming the fallback set fixes the bookkeeping without touching behaviour: `new Set(["in-review"]).has(x)` answers exactly what `x === "in-review"` answered. ## Files | file | sites | what they gate | |---|---|---| | `restart-recovery-coordinator.ts` | 4 | three shared review gates + one `??` default | | `github-tracking-state.ts` | 2 | complete / archived lane predicates | | `planner-overseer.ts` | 2 | wip / review classification | | `async-mission-store-queries.ts` | 2 | terminal complete / archived | | `register-task-workflow-routes.ts` | 2 | wip promotion target, archived respecify guard | **No behaviour change is claimed and none is intended** — that's the point. These were already right; only the accounting was wrong. ## Worth the fleet's attention Converting a guard while leaving an inline fallback is **correct work that scores zero** on the census. My own first pass at `reads.ts` did exactly that — behaviourally correct, census unmoved. Anyone converting this way is doing real work the number won't credit, and the backlog will look stuck. ## Measured | check | result | |---|---| | engine suites | **173 tests green** | | core mission suites | **70 tests green** | | dashboard route suites | **211 tests green** | | five gates + strict census | green | | `tsc` (core, engine, dashboard) | clean | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Standardized fallback handling for workflow stages, including in-progress, review, completed, and archived states. * Preserved existing behavior when explicit workflow column settings are available or unavailable. * Improved consistency across task tracking, planning, and recovery workflows. * **Chores** * Updated lifecycle tracking baselines to reflect current source-file coverage. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c1c1b964af |
fix(dashboard): expose full permission-mapped task toolset in chat sessions (#2376)
## Bug Chat-session tool surface missing task-mutation tools that exist outside of chat, even when the agent's permission record grants them.\n\nRepro: agent-09dcf8b2 (role: custom, CEO) in NextGenEHS has tasks:archive / tasks:delete / tasks:merge / tasks:retry / tasks:update true in its permission record with permissionPolicy.presetId = unrestricted and task_agent_mutation = allow. Calling fn_task_archive / fn_task_delete / fn_task_merge in chat returns: Tool fn_task_* not found.\n\nRoot cause: packages/dashboard/src/chat.ts createChatFusionToolset() built a hardcoded narrow chat-only allowlist while heartbeat registered the complete lifecycle surface unconditionally.\n\nFix:\n- Add exported factories in packages/engine/src/agent-tools.ts for missing lifecycle tools: fn_task_archive, fn_task_unarchive, fn_task_delete, fn_task_retry, fn_task_pause, fn_task_unpause, fn_task_duplicate, fn_task_merge, fn_task_update, fn_task_add_dep, fn_task_promote, fn_trait_list, fn_ask_question, fn_reflect_on_performance, fn_read_evaluations, fn_update_identity, fn_send_message, fn_read_messages.\n- Wire those factories into createChatFusionToolset(). Mission/ideation mutations stay behind missionMutationGated. Agent-scoped tools still require agentId.\n- Re-export from packages/engine/src/index.ts.\n- Regression test: packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts (3/3 passing). Existing chat.test.ts (14/14 passing). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Chat now exposes task lifecycle actions—including archive, retry, pause, duplicate, merge, and dependency updates—when permitted by the agent’s action controls. - Added support for identity updates and evaluation viewing in agent-linked chats. - Existing read-only tools remain available, while restricted actions stay hidden when authorization is unavailable. - **Tests** - Added regression coverage for authorized and unauthorized chat tool surfaces, including preservation of read-only capabilities. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
fd795883c5 |
feat(missions): per-mission taskPrefix override for triaged task ids (#2347)
## Summary Maintainer re-land of [#2334](https://github.com/Runfusion/Fusion/pull/2334) (fork `flexi767:feat/per-mission-task-prefix`) after resolving merge conflicts with current `main`. Fork push was unavailable despite `maintainerCanModify`, so this branch carries the conflict resolution. ### Feature - Optional per-mission `taskPrefix` for triaged task ids (inherits project prefix when unset) - Dashboard MissionManager + routes + store/triage plumbing - Postgres migration for `project.missions.task_prefix` ### Conflict resolution - Main claimed migration **0026** (bigint counters) and **0027** (workflow IR pin) - Mission task-prefix migration renumbered **0026 → 0028** - Baseline `0000_initial.sql` includes `task_prefix` on missions - `legacy.ts` keeps code-org re-exports; `missions.ts` carries `taskPrefix` on create/update types ## Test plan - [ ] CI green (lint/typecheck/build/gate) - [ ] Create mission with custom prefix; triage feature → task ids use that prefix - [ ] Clear mission prefix via PATCH null; new tasks inherit project prefix Closes / supersedes #2334 once this lands (or re-point the fork PR). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Missions can now set an optional per-mission task ID prefix (overriding the project default). * Added task prefix support to mission create/edit UI and dashboard APIs, including normalized uppercase values and validation. * **Bug Fixes** * Improved commit hook generation for custom prefixes and special characters, with safer shell handling to prevent unsafe interpretation. * **Chores** * Added PostgreSQL migration and schema-applier support to persist and propagate mission task prefixes, including upgrade/backfill coverage. * **Tests** * Added backend and UI/API test coverage for task-prefix creation, clearing, and ID minting behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
60bfebdc98 |
fix(reliability): the duration query hid its lane ids inside a SQL template (#2875)
The Reliability panel's **third and last** blind input — and my own loose end. #2861 fixed the two counts beside it, so the panel went from uniformly wrong to **partially** wrong: entries and bounces populated, duration reporting `no-in-review-entries` forever. Partial blindness is harder to notice than total, which is why finishing it matters more than one site suggests. ```sql metadata->>'to' = 'in-review' OR (metadata->>'from' = 'in-review' AND metadata->>'to' = 'done') ``` ## The class, not just the site **This shape is invisible to every check we have.** The lifecycle census scans `===`/`!==` comparisons; the unwired-lane-parameter guard scans declarations. Neither sees a lane id inside a `sql` template, so this class is **not in the backlog total at all** — the number is a floor for this reason as well as the usual one. `scripts/check-sql-column-literals.mjs` (#2841, in flight) is the detector for exactly this: it freezes the surface at 30 sites rather than converting any, so this one was unowned. That PR and this one are complementary — it stops the surface growing, this shrinks it by one. ## The fix Lanes resolve **once per call** via `resolveProjectColumnsForRoles` and arrive as parameterised equality fragments, one branch per id — no interpolated list, no string building. Resolution lives in `getInReviewDurationEventsImpl` because that is where the store is; `async-audit.ts` takes a bare `db` handle and cannot resolve anything. Best-effort, defaulting to the legacy pair, so a caller that cannot resolve keeps exactly today's query. **The union is correct rather than a widening hack**, for the same reason as #2861: these are *move records*, and a past move recorded the column name as it was at the time. A board renamed last month has rows under both ids, so the honest query covers both — which is precisely what `resolveProjectColumnsForRoles` returns. ## Tested against real PostgreSQL, deliberately This is a **SQL predicate** change. A mocked store would assert the arguments and prove nothing about the query that actually runs — which is the entire risk when the literal lives inside `sql`. The new case inserts real `activity_log` rows on a renamed board and reads them back through the real store method. The legacy-lane case in the same file stays green, which is the compatibility half. **Revert proof, measured:** restore the hardcoded fragments and the new case fails with ``` expected [] to deeply equal [ 'renamed-entered', 'renamed-done' ] ``` ## Verification - `pnpm test:gate` — 161 / 487 / 13 / 71 passed - `pnpm lint` — clean - `tsc --noEmit` (`@fusion/core`) — clean - `activity-log-parity.pg.test.ts` — 5 passed against real PostgreSQL With this, all three Reliability inputs read the board's own lanes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Reliability duration metrics now work correctly with renamed workflow lanes. * Completion tracking recognizes configured completion lanes instead of relying on fixed defaults. * Improved handling of transitions between multiple review lanes and review-to-work-in-progress movements. * Legacy lane behavior remains supported when configured lane information is unavailable. * **Tests** * Added coverage for renamed lanes, historical lane IDs, and transition edge cases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
defe48d30f |
fix(core): per-workflow metrics read zero on a renamed board (#2866)
Second of the 14 lane-bound SQL sites from #2839, after #2864. Independent of it — different file, different caller argument. ## The defect `aggregateWorkflowAnalytics` filtered in SQL on `t."column" = 'done'` and `IN ('in-progress','in-review')`. On a renamed board those match nothing, so `tasksCompleted`, `tasksInProgress` and `tasksInReview` come back **zero for every workflow** while the board is busy. Nothing errors. Same shape and same fix as #2864: resolve per **project** via `resolveProjectColumnsForRoles`, bind an `IN` list, and thread the store from the single Command Center caller so the parameter has a supplier immediately rather than becoming an inert seam. ## What the test caught that I had not **The renamed case still failed with the query fixed.** The bucketing at lines 296–297 already uses `isWipColumnRole` / `isReviewColumnRole` — correctly converted — but those read `query.columnFlagsByName`, which production supplies and my fixture did not. So: - the **SQL** decides *which rows come back*; - the **trait map** decides *which bucket each row lands in*. Both halves have to be right. Fixing only the query would have shipped a "conversion" that still reported zero on a renamed board, and the file would have scored as converted twice over. That is exactly the partial-conversion shape this program keeps re-finding — caught here only because the test asserts `tasksInReview` alongside `tasksCompleted`, since those two paths take **different** resolved sets (complete vs wip+human-review). Asserting the completed count alone would have left the second conversion unproven. ## Measured Reverted, only the renamed case flips: ``` ✓ default vocabulary: completed and in-review work are counted × renamed vocabulary: completed and in-review work are counted ✓ renamed vocabulary: a card in the HOLD lane counts as neither ✓ without a lane store, the legacy ids still answer Tests 1 failed | 3 passed (4) ``` The hold-lane negative is there so resolving real lanes cannot degrade into "every column counts" — trading an undercount for an overcount is harder to notice than the original bug. ## Scope The sync SQLite arm in the same file keeps its literals: it throws in backend mode and has no production caller, the same dead-arm conclusion reached for `cleanupStaleMergeQueueRowsImpl` on #2839. ## Verification `pnpm test:gate` green · both Command Center analytics suites 8/8 · `tsc` core 0, dashboard 0 · lint 0 · changeset included. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
890e1f87e7 |
fix(core): issue panels reported nothing fixed on a renamed board (#2871)
Fourth and last of the lane-bound analytics sites from #2839, after #2864, #2866 and #2870. ## The defect `aggregateGithubIssueAnalytics` and its GitLab twin filtered their resolved-issue query on `"column" = 'done'`. On a renamed board that matches nothing, so `fixed` is **zero**, the resolved-issue list is empty, and `net` reports every filed issue as still outstanding — while the team closes issues all week. Nothing errors. Same fix as the previous three: resolve per **project** via `resolveProjectColumnsForRoles`, bind an `IN` list, thread the store from each Command Center caller so the parameters have suppliers immediately. ## Both providers in one change, deliberately These two files are **copies** — same query, only the provider literal differs — and a copy is exactly what gets half-fixed. Converting one and not the other type-checks, passes that provider's test, and leaves the second silently broken with no signal anywhere. The suite runs every case against both, so the pair cannot drift. ## Measured Reverted, exactly the two renamed cases fail — **one per provider** — while both default-vocabulary controls, both WIP-lane negatives, and both omitted-store legacy cases stay green: ``` ✓ github: default vocabulary counts a resolved issue × github: renamed vocabulary counts a resolved issue ✓ github: renamed vocabulary does NOT count an issue still in the WIP lane ✓ github: without a lane store, the legacy id still answers ✓ gitlab: default vocabulary counts a resolved issue × gitlab: renamed vocabulary counts a resolved issue ✓ gitlab: renamed vocabulary does NOT count an issue still in the WIP lane ✓ gitlab: without a lane store, the legacy id still answers Tests 2 failed | 6 passed (8) ``` That the failures are symmetric is itself the check on the copy-paste risk. ## Scope The sync SQLite arms keep their literals: they throw in backend mode and have no production caller, the same dead-arm conclusion as `cleanupStaleMergeQueueRowsImpl` on #2839. ## Verification `pnpm test:gate` green · Command Center + GitLab issue analytics suites 10/10 · `tsc` core 0, dashboard 0 · lint 0 · changeset included. --- **This closes the lane-bound half of #2839.** All 14 sites the hand-review identified as genuinely vocabulary-bound are now converted across four PRs. What remains there is the 11 `!= 'archived'` exclusions, which are probably correct as literals — archiving writes `task.column = 'archived'` unconditionally as a state rather than a lane — plus one dead SQLite arm. Those need per-site judgment, not conversion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
216632bd3a |
fix(core): task-duration stats were computed from an empty set on a renamed board (#2870)
Third of the 14 lane-bound SQL sites from #2839, after #2864 and #2866. Independent of both. ## The defect `aggregateProductivityAnalytics` filtered its duration query on `"column" = 'done'`. On a renamed board that matches nothing, so the entire task-duration distribution — median, p90, average, total — is computed from an **empty row set** and reports zeros while the project ships work. Nothing errors. Same shape and fix as the previous two: resolve per **project** via `resolveProjectColumnsForRoles`, bind an `IN` list, thread the store from the single Command Center caller so the parameter has a supplier immediately rather than becoming an inert seam. ## Measured Reverted, only the renamed case flips: ``` ✓ default vocabulary: a finished task contributes to the duration stats × renamed vocabulary: a task in the RENAMED complete lane contributes ✓ renamed vocabulary: a task still in the WIP lane does NOT contribute ✓ without a lane store, the legacy id still answers Tests 1 failed | 3 passed (4) ``` ## The negative asserts the median, not just the count This fix's failure mode is **worse than the bug it fixes**. Resolving too many lanes would pull unfinished work into the distribution and produce a plausible-but-wrong median — a number nobody questions — where the bug produces an obvious zero. So the WIP-lane case asserts `medianMs` is null as well as `completedTasks` being 0. ## A fixture error worth naming My first version asserted `taskDuration.count`. `TaskDurationSummary` exposes `completedTasks`. Every case failed with `expected undefined to be 1` — **including the controls** — which reads exactly like a broken product until you notice the control is failing too. A control that fails is a fixture bug, not a finding; that asymmetry is the fastest way to tell them apart. ## Scope The sync SQLite arm keeps its literal: it throws in backend mode and has no production caller, the same dead-arm conclusion as `cleanupStaleMergeQueueRowsImpl` on #2839. ## Verification `pnpm test:gate` green · both Command Center analytics suites 8/8 · `tsc` core 0, dashboard 0 · lint 0 · changeset included. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b85e5f90e1 |
fix(create): two task-CREATE destinations named a lane the board does not have (#2843)
Both files sat at **census-zero** and both wrote real cards into columns
no workflow declares. The census scores `===` comparisons, so a lane
literal passed as a **call argument** is invisible to it — one of the
four census-blind classes. These are the only two explicit-`column`
creates in production:
```
packages/dashboard/src/routes/register-gitlab.ts:108 column: "triage"
packages/cli/src/extension.ts:5243 column: "todo"
```
## The two defects
**`register-gitlab.ts` — `column: "triage"`, a column U11 DELETED.**
This one is broken on *every* board, not only renamed ones: the default
lineage is now `todo | in-progress | in-review | done | archived`.
`createTask` already resolves the intake column of the workflow it
selects (`resolvedEntryColumn`), and an explicit `column` **overrides**
that resolution — which is why the stale literal survived U11. Nothing
rejects the write and nothing logs it: the route answers `201` with a
task id and the imported card is simply not on the board. Same shape as
the `task-update.ts` triage defect fixed earlier in this program.
Fix: omit `column` and let `createTask` resolve intake.
**`extension.ts` `fn_delegate_task` — `column: "todo"`.** On a workflow
whose ready lane is named anything else, the delegated card goes to an
undeclared column: written, reported to the caller as delegated, never
visible to the agent it was delegated to.
Fix: resolve the selected workflow's `hold` lane. **Deliberately not**
"omit the column like the GitLab route" — the tool's own contract is
*"the task goes to the ready-to-work lane and the target agent picks it
up on its next heartbeat"*, so inheriting intake resolution would park a
delegated card in a manual-intake lane waiting for a human. That would
be a behaviour change; `hold` is the role that names the lane the
literal meant.
## New helper: `resolveWorkflowColumnForRole(store, role, workflowId?)`
The **write**-shaped counterpart to `resolveProjectColumnsForRoles`. The
read helper unions in the legacy ids because an extra id in a query set
is inert; here the same trick is a silent wrong write (post-U12 an
undeclared column is a `TransitionRejectionError` on move, a phantom
lane on create), so it returns one column from one workflow, or
`undefined`.
**A contract I got wrong twice, now pinned by a test.** `undefined`
means *"this workflow declares no such column"* and nothing else.
`resolveWorkflowIrById` never throws and never returns nothing — an
unregistered builtin id, a missing definition row and a failing read all
resolve to the default coding IR (branded via `markFellBack`). So an
unreadable workflow yields the **built-in** hold lane, not `undefined`,
and both call sites' `?? "todo"` fallbacks are narrower than they look.
Two of my first test cases asserted the opposite and failed; the
behaviour is the resolver's, and the write it produces is identical to
the caller's own legacy fallback either way.
## Revert proofs (measured, not asserted)
| revert | failure |
|---|---|
| `column: holdColumn` -> `column: "todo"` | `extension.test.ts`:
`expected 'todo' to be 'queued'` |
| omitted column -> `column: "triage"` | `routes-gitlab.test.ts`:
`expected 'triage' to be undefined` |
The two neighbouring `fn_delegate_task` cases stay green under the first
revert, because the built-in board and the test's `linearWorkflowIr`
both call the lane `todo` — which is exactly why this literal survived
every previous pass.
The GitLab case asserts **absence** of the key rather than a resolved
id: the store there is a fake whose `createTask` echoes its input, so
asserting a resolved value would be testing the fake. Absence is the
property that hands the decision to the real `createTask`.
## Census
| file | before | after |
|---|---|---|
| `packages/dashboard/src/routes/register-gitlab.ts` | 1 | 0 |
| `packages/cli/src/extension.ts` | 1 | 0 |
Baseline tightened. It also picks up
`packages/core/src/task-store/moves.ts` 2 -> 0, which was **already true
on main** — not from this diff.
## Noted, deliberately not changed
- `validateAssignableAgentId`'s synthetic probe a few lines above still
uses `{ id: "<new>", column: "todo" }`. It feeds `isImplementationTask`,
whose `IMPLEMENTATION_TASK_COLUMNS` set an earlier worker documented as
deliberately-not-converted (converting it makes the routing policy async
— an agent-admission behaviour change). On a renamed board the probe is
now *stricter* than the real destination, which is the safe direction
and matches the pre-existing behaviour.
- The third site from this bucket, `workflow-node-handlers.ts:455`
(`transitionTask({ column: "in-review" })` on the `review-handoff`
seam), is a **hard** failure rather than a silent one — `transitionTask`
routes through `moveTask`, which post-U12 throws
`TransitionRejectionError` for an undeclared destination, so the
workflow walk dies at the handoff on any renamed review lane. It is
engine (`batch-engine`) and fixing it properly touches `executor.ts`,
which #2820 is also editing. Left for that batch rather than opened as a
conflicting edit.
## Verification
- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit -p tsconfig.json` for `@fusion/core`,
`@runfusion/fusion`, `@fusion/dashboard` — clean
- `node scripts/lifecycle-column-census.mjs --strict` — exit 0
- targeted: `project-lane-vocabulary.test.ts` 14/14,
`routes-gitlab.test.ts` 8/8, `extension.test.ts -t fn_delegate_task` 9/9
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* GitLab-imported cards now appear in the workflow’s configured intake
lane.
* Delegated tasks now move to the workflow’s configured hold lane,
including workflows with custom lane names or separate intake and hold
lanes.
* Delegation reports the task’s final lane and provides an error when it
cannot be moved successfully.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ea477f3ada |
fix(core): team analytics reported an idle project on a renamed board (#2864)
First of the 14 genuinely lane-bound SQL sites from #2839. I had been deferring these as "the owner is active in those files" — then checked, and **no open PR touches them**. The batch-core commit had already landed, so there was nothing in flight to collide with. The deferral was an assumption I could have tested two turns earlier. ## The defect `aggregateTeamAnalytics` filtered in SQL on `"column" = 'done'` and `IN ('in-progress','in-review')`. On a board whose lanes are renamed those match nothing, so per-agent completed counts, the project total, and the in-flight breakdown all came back **zero**. Nothing errors. A dashboard reading *"0 tasks completed"* for a team that shipped all week looks like an idle project, not a bug — which is why this survives review and never gets filed. **Why the sweep missed it:** the lifecycle census parses TypeScript comparisons; these ids live inside SQL strings, which are string data. The batch-core conversion fixed this file's TS guards **today** and left the queries untouched. The file scored as converted. ## Resolved per project, not per task `resolveProjectColumnsForRoles` gives the union of a role's columns across the project's workflows, which is the right set here because analytics aggregates a whole project — so a bound `IN` list is sufficient. The merge-queue cleanup needed the *superset-then-decide-in-JS* shape instead, because its lanes are genuinely per task and SQL cannot know a task's workflow. Same program, two correct answers; worth not copying the wrong one. ## Measured Reverted, exactly one case flips: ``` ✓ default vocabulary: a completed task is counted × renamed vocabulary: a task in the RENAMED complete lane is counted ✓ renamed vocabulary: a task in the WIP lane is NOT counted as completed ✓ without a lane store, the legacy ids still answer Tests 1 failed | 3 passed (4) ``` The three controls are deliberate: the default vocabulary (a generally broken aggregator cannot hide behind the renamed case), a WIP task that must **not** count as completed on the renamed board (resolving real lanes must not degrade into "every column counts" — an undercount turned overcount is harder to notice), and an omitted lane store that must keep the legacy answer byte-identical. ## Two mistakes the first attempt made, both caught by running it - **`= ANY(${array})` does not work.** Drizzle expands a JS array in a template into a comma tuple, so PostgreSQL rejected `(($1,$2,$3))` with *"op ANY/ALL (array) requires array on right side"*. An `IN` list of individual bound parameters is the working shape. Each id stays a parameter — these come from operator-authored workflow definitions and are never interpolated as SQL text. - The fixture's `ON CONFLICT (id)` had no matching constraint on `project.agents`; the sibling suite seeds with explicit `created_at`/`updated_at`. ## Scope One production caller (`register-command-center-routes.ts`), threaded in this same change so the parameter has a supplier from the start rather than becoming another inert seam of exactly the class this program keeps finding. The sync SQLite arm in the same file keeps its literals: that path throws in backend mode and has no production caller, the same dead-arm conclusion reached for `cleanupStaleMergeQueueRowsImpl` on #2839. ## Verification `pnpm test:gate` green · both Command Center analytics suites 8/8 · `tsc` core 0, dashboard 0 · lint 0 · changeset included. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e1deffa72 |
fix(reliability): the review-failure headline read 0% because both inputs were zero (#2861)
The Reliability panel's headline metric is computed from two queries
that name lanes:
```ts
scopedStore.getTaskMovedCountsByDay({ …, toColumn: "in-review" }),
scopedStore.getTaskMovedCountsByDay({ …, fromColumn: "in-review", toColumn: "in-progress" }),
…
const headline = inReviewFailureRate7d(enteredByDay, bouncedByDay, nowMs);
```
On a board that renamed either lane, **both return `{}`**. Every per-day
count is zero, and the headline then divides one zero by another and
reports a healthy rate.
**This is the worst shape a lifecycle defect takes.** It produces a
*number*, not an error, and the number is reassuring. An operator
reading a 0% review-failure rate beside a populated audit event list has
no reason to suspect the metric is blind — the same failure mode as the
analytics `tasksInProgress: 0` sitting next to correct cost totals.
## Why a union is correct here, not a compromise
`getTaskMovedCountsByDay` takes **one** column per side, so the lanes
are resolved to sets and the query is issued per `(from, to)` pair and
summed.
The important part is *why* summing over a union is the right answer
rather than a widening hack. These read **move history**, and a past
move recorded the column name as it was at the time — the same reasoning
that keeps `tasksEnteredInReviewPerDay` in this module matching recorded
values verbatim, marked DELIBERATE-LITERAL. A board renamed last month
therefore has old rows under the old id and new rows under the new one,
so the correct query covers **both**. That is exactly the set
`resolveProjectColumnsForRoles` returns, with the legacy id always
unioned in.
Asking for either name alone is the bug — not a choice between them.
No double-counting: a move event has exactly one `(from, to)` pair, so
the queries partition the events rather than overlapping.
**The common path does not get more expensive.** On the built-in board
this issues the same two queries as before, and there is a test
asserting the call count so a future change cannot quietly turn one
query into N.
## Revert proof (measured)
Collapse the sets back to the single literals:
```
AssertionError: expected { '2026-07-01': 1 } to deeply equal { '2026-07-01': 2, '2026-07-02': 3 }
AssertionError: expected { '2026-07-01': 1 } to deeply equal { '2026-07-01': 2, '2026-07-03': 5 }
```
The helpers live in `reliability-metrics.ts` rather than inline in
`server.ts` specifically so they are testable without booting an express
app.
## Verification
- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm smoke:boot` — PASS (`fn --help`, real `serve` `/api/health` 200,
clean shutdown)
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/dashboard`) — clean
- `reliability-metrics.test.ts` — 15 passed
- census `--strict` — exit 0 (unchanged: query filters are not
comparisons)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
eed8ca55fc |
batch-dashboard-src: the planner metrics tool froze active runtime on a renamed execution lane (186 → 185) (#2842)
`packages/cli` and the plugin packages are at **zero** lifecycle guards, so this picks up the nearest unowned work: the `packages/dashboard/src/` remainder. ## The defect `activeRuntimeMs` adds the wall-clock since `executionStartedAt` only while the card is accruing work — the **WIP role** — but it was keyed on the literal `in-progress`. On a board whose execution lane is renamed, that live tail was dropped, so `fn_task_planner_get_task_metrics` reported active time frozen at whatever the last completed segment left in `cumulativeActiveMs`. The number stayed plausible, which is why nothing surfaced it. ## The part worth reading: the wiring had no watcher, from either direction I wired the producer (`chat.ts` resolves the task's own lanes via `wipColumnsForTask`) in the same commit, then checked whether that wiring was actually covered. It was not: - **Deleting the `wipColumns:` argument left the entire 3830-test dashboard suite green.** The formatter's own tests inject the set by hand, so they prove the *guard* and are structurally blind to whether production fills it. - **`check-inert-flag-seams.mjs` does not see it either.** It tracks trailing optional **parameters**; this is a property inside an options bag. That is a real gap in the checker — every seam expressed as an options-bag property is currently unguarded. Reported here rather than fixed, because #2822 and #2830 both already modify that script and a third change would guarantee a three-way conflict. So `createTaskPlannerMetricsTool` is exported and a second test drives it, letting it do its **own** resolution against a renamed board. Deleting the argument now fails 1 of 2. ## Census | | before | after | |---|---|---| | COLUMN guards | 186 | **185** | | `packages/dashboard/src/task-planner-chat-metrics.ts` | 1 | **0** | Baseline re-recorded; `--strict` exits 0. ## Two findings I did NOT act on, deliberately **1. `github-tracking-state.ts` keeps 2 counted guards and should.** They are the documented degraded-mode arms of a fully-resolved classifier (`completeLanes === undefined ? columnId === "done" : ...`). Marking them `DELIBERATE-LITERAL` would drop the count by **reclassification rather than conversion** — the exact move the census's own strict-check warns about. Related: the census reports `0 are trait-fallback branches (already converted)`, yet these are precisely that shape, so the trait-fallback classifier appears not to recognise a ternary whose fallback arm is the literal. Worth a look by whoever owns the census. **2. Three pre-existing failures in `packages/dashboard/src/__tests__`, unrelated to this change** — measured identically on `origin/main` before and after: - `planning-browser-e2e.test.ts:353` - `register-model-routes-kimi-k3-supplemental.test.ts:60` - `routes-tasks-near-duplicate.test.ts:274` Flagging rather than touching them; per the standing rule they are quarantine candidates, not appeasement candidates. ## Verification Dashboard `tsc` clean, `pnpm lint` clean, census `--strict` 0, `check-inert-flag-seams` 21/21 supplied, changeset lint clean. Targeted suites: `task-planner-chat-metrics.test.ts` 8/8, `task-planner-metrics-tool-wip-lanes.test.ts` 2/2. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
51934931e1 |
fix(board): the awaitingPlanning badge only ever worked on a lane named "todo" (#2845)
Converts the one site in `register-task-workflow-routes.ts` that a previous pass **deliberately deferred**, and does it in the shape that note asked for. ## What the deferral said ``` FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8, DELIBERATELY NOT CONVERTED): … I converted it and then REVERTED: resolving each task's hold column needs a per-task workflow read, and this is the board-load path whose own comment above exists because unbounded reads here "turn a board load into thousands of reads". Converting it properly needs the hold column resolved per WORKFLOW from data the board payload already carries, not per task from the store. ``` That was the right call and the right diagnosis. `resolveProjectColumnsForRoles(store, ["hold"])` is exactly the project-scoped shape it names: **one** `listWorkflowDefinitions()` read per board load, flat in task count. The expensive part — a PROMPT.md read per row — is untouched and still bounded by `AWAITING_PLANNING_ENRICH_LIMIT`. The test asserts the flatness directly (`listWorkflowDefinitions` called exactly once), so a future per-task regression fails here rather than being discovered as board latency. ## What was broken The filter named `todo`, so on a board whose waiting lane is called anything else **no row was enriched at all** — no error, no log line, just a silent fall back to the client's `steps.length === 0` heuristic. That heuristic is precisely what this enrichment was added to correct, so the card most likely to be mislabelled — real spec, zero parsed steps, already a scheduler dispatch candidate — sat on "Queued to plan" indefinitely. Over-inclusion is the safe direction and is chosen deliberately: a card in some other workflow's hold lane gets annotated as waiting, which is what a waiting card in a waiting lane should show. ## Revert proof (measured) Restore `task.column === "todo"`: ``` FAIL register-task-workflow-routes.awaiting-planning.test.ts > enriches a card in a RENAMED hold lane, not only one literally named todo expected undefined to be false ``` The other 8 cases in the file stay green — their harness store declares no `listWorkflowDefinitions`, so they run the degraded legacy-`todo` path. That compatibility is half the contract, which is why the new case brings its own store rather than widening the shared harness. ## Census | file | before | after | |---|---|---| | `packages/dashboard/src/routes/register-task-workflow-routes.ts` | 3 | 2 | The 2 remaining in that file are documented trait-fallback branches, not unconverted debt. The baseline also picks up `packages/core/src/task-store/moves.ts` 2 -> 0, already true on main and not from this diff. ## Verification - `pnpm test:gate` — 161 / 487 / 13 / 71 passed - `pnpm lint` — clean - `tsc --noEmit -p tsconfig.json` (`@fusion/dashboard`) — clean - `node scripts/lifecycle-column-census.mjs --strict` — exit 0 - targeted: `register-task-workflow-routes.awaiting-planning.test.ts` 9/9 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d56c635c77 |
test(dashboard): cover the three lane resolvers nobody was testing (#2826)
## Why this exists #2821's review found a bug that lived entirely in a lane **builder** while every test drove the **guard** that consumed it. That is a structural blind spot, not a one-off: injecting a resolved value into a synchronous guard makes the guard testable and the resolver invisible. So I audited every lane helper I added this session. Three had **no direct coverage at all** — `archivedColumnsForTask`, `wipColumnsForTask`, `preWipColumnsForTask`. Their callers were tested; the functions were not. ## They shared the defect that review named Each read `resolved.length > 0 ? resolved : legacyId`, which conflates two different boards: - a **v1 upgrade** — `synthesizeDefaultColumns` emits `traits: []` on every column, so the legacy id is the only vocabulary that exists, and falling back is correct; - a **v2 board that expresses traits** and declares no lane of that role — where the legacy id names a column the board may still *have* and deliberately did not give the role. Falling back there widens the guard onto a role the board explicitly withheld. `declaresAnyLifecycleTrait` separates them, matching the shape #2821's review established for `resolveNodeOverrideLanes`. ## The fixture trap, which is the part worth reading **My first fixture could not see the bug.** It traited the role under test — and where the role *is* traited, the two shapes agree: both return the traited lane. Mutating a helper back to the old shape left all 15 cases green. The shapes diverge only when the resolved set is **empty while traits are expressed**. Each helper now has that case explicitly, with a fixture that traits something *other* than the role under test. **Mutation-verified per helper:** all three reverted independently now fail. Before the extra case, none did. This is the second time this session a fixture built with the production path normalised away the very thing under test. Worth stating as a rule: a renamed-lane fixture proves the resolver reads traits; only a *traits-expressed-but-role-absent* fixture proves what it does when the answer is legitimately nothing. ## Verification - `task-lifecycle-lanes.test.ts` → 18 passed (was 15, none covering these three) - consumer suites (`github-issue-comment`, `planning-board-tools`, `register-git-github.review-lanes`) → 64 passed together - `pnpm test:gate` → 161 + 487 + 13 + 71 - `--strict` → 0; `tsc --noEmit` and `pnpm lint` → 0 errors Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
74cba4b46d |
batch-core: one shared landed-lane helper for the source-issue surfaces (75 → 72) (#2783)
## batch-core continued — the source-issue cluster Follow-on to #2780 (merged). Scope is still `packages/core` + `packages/dashboard/src`. ### The defect Five places asked the same question — *has this task landed?* — and all five compared against the literal `done`: | surface | consequence on a renamed board | |---|---| | GitHub source-issue commenter | never comments on or closes the source issue | | GitLab source-issue commenter | same | | GitLab `closedAt` backfill reconciler | finds nothing, reports a clean scan | | session-diff boundary | finished tasks diff against an already-merged branch | | tracking-comment transition | (already converted; left alone) | The commenters are the sharpest case: they returned **before reading a single setting**, so on a renamed board the feature looked *disabled* rather than broken — an operator checking `githubCommentOnDone` would see it enabled and still get nothing. The backfill is the quietest: `scanned: N, filled: 0` reads as "nothing to do", so the failure was indistinguishable from success. ### The fix One home: `packages/dashboard/src/task-lifecycle-lanes.ts`. Callers now only ask. Five copies of one question is exactly how the halves drift apart — the motivating incident is FN-6115 → FN-6118 → FN-6123, where the same affordance was fixed three times because it lived in two components. This also folds in the duplicate landed-lane helper I had left in `register-session-diff-routes.ts` in the previous PR, which was the sixth copy waiting to happen. Two helpers, and the difference is deliberate: - **`landedColumnsForTask`** — `complete ∪ archived`. Membership, since a board may declare more than one column carrying either role, and `columnsWithFlag(...)[0]` would silently ignore the second. - **`completeColumnsForTask`** — complete only. The GitLab backfill's own FNXC note records that archived tasks live in `archiveDb` and are *intentionally* excluded, so it must not widen to the archived role just because the shared helper offers it. Today it lists with `includeArchived: false` and would see no archived rows either way — but that is an incidental property of the query, not the contract. The test pins the difference so the two are not later "simplified" into one, which would change that caller's behaviour without touching it. Both treat an **empty** resolved set as *unexpressed*, not absent — the v1 hazard: `synthesizeDefaultColumns` upgrades a v1 graph with `traits: []` on every column, so reading empty as "no complete lane" would stop these surfaces firing on every pre-v2 project. The reconciler is two-stage on purpose: the cheap provider and `closedAt` tests run first and reject almost everything, so a workflow read only happens for real candidates, and it shares one IR cache across the scan — one read per distinct workflow rather than per task. ### Census `batch-core` scope **75 → 72**; repo total **338**. ### Verification - `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` → 0 errors - `pnpm lint` → 0 errors - commenter + reconciler suites → **63 passed**; helper suite → **5 passed** - **Mutation-verified:** making the helper ignore its resolved set fails 1 of 5. --- ## Round 2 — server.ts, chat.ts, and a correction **Census: 75 → 67** across this PR. ### The correction (see the review thread above) My first pass gated the source-issue commenters on `landedColumnsForTask` (`complete ∪ archived`), which **widened** the trigger — `to === "done"` never fired on archival, and the landed set does. Both commenters now use `completeColumnsForTask`, and the unused `hasTaskLanded` wrapper is gone. The ratchet for it is pinned on the **default** board, deliberately: a widening is visible exactly where the legacy names still apply, so no renamed-board fixture would catch it. ### `chat.ts` — three sites, and a pair that had to move together - **Chat verification** required `column === "in-progress"`, so on a renamed board every chat-driven verification was refused with a message naming a column the board does not have. - **The planner refinement pair.** Two separate guards decide this feature: `createSession` *registers* the tool only for a finished task, and the tool's own `execute()` *refuses* a non-finished source. Both compared `done`. Converting only one half would have offered the tool and then had it refuse itself — the half-converted-pair shape. The new test asserts **both** halves in one case (tool present *and* refinement created), and each half reverted independently fails it. Existing `chat-manager` coverage caught neither revert, which is why the case exists rather than relying on the suite that was already there. Complete-only again, not the landed set: an archived task is off the board and is not a refinement source. ### `server.ts` - **Planner-chat retention** — the archival cutoff was a literal, so on a renamed board task-planner chat sessions were retained forever; the rule this listener exists to enforce never fired. Resolved, and awaited inside the existing fire-and-forget chain rather than by making the listener `async` — `task:moved` has synchronous subscribers whose ordering is load-bearing elsewhere, and a chat-row delete is not the right place to introduce a microtask boundary into that emit. - **`isBadgeEligibleTask` — deliberately NOT converted, and marked as backlog.** On a renamed board it is genuinely wrong: an archived card stays badge-eligible, its snapshot is never evicted, and the cache grows for the daemon's lifetime — the exact memory leak the predicate was added to fix, back under a different column name. What blocks it is measured, not assumed: both callers are synchronous `task:updated` / `task:created` listeners whose next statement is documented as *"Update local cache immediately"*, so awaiting lets a second event for the same task interleave between the eligibility check and the cache write. I did **not** add an optional `archivedColumns` parameter, because nothing could fill it — the callers are the sync listeners. That is the inert-injection shape this PR's own review caught twice on #2780: the predicate would read as converted, its test would pass by injecting the value, and production would keep the literal. The unblocking change (a resolved-archived-lane cache on the badge-snapshot scope, keeping the predicate synchronous) is recorded at the site. ### Verification - `tsc --noEmit` → 0 errors; `pnpm lint` → 0 errors - `chat-manager` → 101 passed; commenter/reconciler/helper/badge suites → 55 passed - Mutation-verified per fix, including each half of the refinement pair separately <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved task lifecycle handling for renamed workflow lanes, including completed, archived, landed, and in-progress states. * Task lists now exclude completed tasks regardless of the completion lane’s name. * Chat verification and refinement actions now recognize configured workflow lanes. * GitHub and GitLab completion comments trigger only for genuinely completed tasks, not archived tasks. * Knowledge index refreshes and GitLab metadata updates now support custom completion lanes. * **Tests** * Added regression coverage for renamed completion lanes and archived-task behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e84e9d7f60 |
fix: the caller audit — five unwired parameters, five defects in their callers (#2803)
Seven fixes that were sitting on separate handoff branches with no owner while `main` moved. Consolidated, rebased onto current `main`, and verified **together** rather than only per-branch. The individual branches remain if a subset is preferred. This is the same consolidation that got `batch-core` and #2787 adopted. **Close it if it breaks queue policy** — the branch keeps the work safe either way. ## Where these came from #2787's review found an optional parameter whose production caller never passed it. That is a class, so I ran it against everything I had landed and found five more. **All five turned out to have their real defect in the CALLER, not the parameter** — in four of them the parameter was unreachable: | unwired parameter | what was actually wrong | |---|---| | `blocker-fanout.escalationColumns` | the hold default made the count zero — **no bottleneck warning was emitted at all** | | analytics `columnFlagsByName` | routes never built a map — **0 in-progress / 0 in-review beside correct cost totals** | | `isLegacyAutoMergeStampCandidate` | the read **queried a column a renamed board does not have**, so the backfill iterated nothing | | `rankAssignedTasksForWakeDelta` | `getTasksByAssignedAgent`'s `excludeArchived` used the literal — **archived cards returned as open work** | | `duplicate-intake.columnFlagsByColumnId` | intake could **archive or soft-delete a newly created task** as a duplicate of finished work | The heuristic worth keeping: **an optional parameter no production caller fills is a marker pointing at an unexamined caller.** The census cannot see any of these five — every gate is a `Set`/array literal or a query filter, i.e. a definition rather than a comparison. ## Also included - **`executor.ts`** — the stale-spec guard did the exact thing its own comment forbids: on a renamed board it ran on a LIVE task and pulled it out of execution into replan. `activeMergeStatuses` protected merging cards *by accident*, which is why the symptom looked arbitrary. - **`register-project-routes.ts`** — project health reported **0 active tasks**; its list also still contained `triage`, dead since U11. - **`dashboard/app/utils/taskTiming.ts`** — a **second copy** of `getTotalAgentActiveMs`. Core's was converted; the card chip imports this one, so the census counted the site as done while the rendered number stayed keyed on `"in-progress"`. ## Verification Verified as a set: `pnpm test:gate` **161 / 13 / 487 / 71** · core suites **15 passed** · engine **7** · dashboard **12** · four `tsc` targets clean · lint clean · census `--strict` exits 0. Each fix is revert-proven individually; the specific case that fails is named in each test header. ## Two honesty notes **Three guards here are structural, not behavioural, and say so in their headers.** `sanitizeAgentTaskLinks` is a closure inside `createApiRoutes`; the analytics aggregators need a live `AsyncDataLayer`; the stale-spec guard sits deep inside `execute()`. Each ratchet fails on revert — verified — but none is an end-to-end proof, and the headers state which half they cover. **One of my behavioural test sets would have lied.** The intake-dedup cases drive `findSameAgentDuplicates` directly; I removed the wiring to measure the revert and **they stayed green**, because they pin the predicate and not the caller. That is the exact illusion this audit was chasing, reproduced in my own file. The forward now has its own structural check. ## Deliberately not included `worktree-pool.ts:1205` — it **fails safe** (a missed match protects a branch from cleanup rather than deleting it) and sits in the merger's branch-reaping path where the opposite error destroys work. That deserves its owner's judgement, not a drive-by conversion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6bdde6f246 |
fix: five lifecycle gates the census cannot see — incl. live ephemeral workers reaped and duplicate follow-up cards (#2787)
Five lifecycle-column fixes the census **structurally cannot see**. Each gate is a `Set` or array literal — a *definition*, not a comparison — so no backlog entry ever pointed at any of these files. Found by grepping for lane-shaped list literals after the same shape surfaced in `duplicate-intake` and `blocker-fanout` (both merged via #2780), then confirmed by reading each USE site. **On opening this:** I offered twice to fold these into a PR and kept them on handoff refs to respect one-open-PR-per-worker. They have now sat unadopted across several cycles while `main` moved, and two of them destroy or duplicate work. Opening is the reversible call — **close it if it breaks queue policy** and I will keep them on the branch. ## What is in it | commit | defect on a renamed board | severity | |---|---|---| | `beb107a7bc` | assignment load-balancing **defeated** — `assignmentLoad` stays empty, every candidate reads as load 0, the sort falls through to its stable `createdAt` tiebreak, so **one agent wins every assignment** while the rest idle | distribution | | `cf4b59e1cb` | the zombie sweep **deletes LIVE ephemeral workers** | **destroys work** | | `5fe004ae64` | eval follow-up dedup sees **zero open tasks**, so every run re-files follow-ups it already filed | **duplicate cards** | | `a1021de8b2` | agents keep a **"working on" indicator for finished cards** | stale UI | | `86680d1220` | the **Files tab never loads** — the fetch never fires | silent empty | ### The one that destroys work `shouldDeleteOnSweep` tested a hard-coded terminal `Set`, then fell through to `return task.column !== "in-progress"`. On a renamed board **both halves miss, and they compound in the worst order**: the terminal test fails, control reaches the fallthrough, and `"building" !== "in-progress"` is `true`. An ephemeral worker **actively executing a task** is classified as a zombie and deleted. Nothing logs. Its fallback is **deliberately asymmetric**, and the comment says why: an unresolvable workflow keeps the legacy literals rather than guessing. Failing to reap a dead worker costs a slot; reaping a live one destroys work in flight. Those are not symmetric, so uncertainty fails toward keeping the worker. ## Verification Verified **as a set**, not only per-branch: - `pnpm test:gate` — **161 / 13 / 487 / 71** - engine suites (assignment, ephemeral, eval-followups) — **44 passed** - dashboard suites (agent-task-link, useSessionFiles) — **16 passed** - `tsc` engine + dashboard server + dashboard app — clean - `pnpm lint` clean · census `--strict` exits 0 **Revert-proven individually.** Restoring each literal fails its own case: the renamed-wip zombie case, the renamed-wip assignment case, the renamed-lane dedup case, the sanitizer ratchet, and both `useSessionFiles` role cases. ## Two honesty notes, flagged rather than buried **`a1021de8b2`'s guard is STRUCTURAL, not behavioural.** `sanitizeAgentTaskLinks` is a closure inside `createApiRoutes`, reachable only by standing up the full express app. The ratchet asserts the source — resolver threaded per task, bare literal call gone, cache shared, fallback retained — and **fails on revert**, verified. It is not a substitute for a behavioural test; whoever owns the dashboard server should add one if that seam grows. **`useSessionFiles`'s negative case passed in isolation and failed in the suite.** Hooks are not unmounted between cases there, so a prior case's in-flight fetch landed inside it. That is the classic shape of a test that gets "fixed" by reordering; it now asserts a **delta** against the pre-render call count, which is independent of what leaks in. ## Deliberately NOT included `worktree-pool.ts:1205` — the sixth site from the same sweep. It **fails safe**: a missed match means the skip does not fire, so the branch is added to `activeBranches` and *protected* from cleanup. The cost is stale branches accumulating, not deletion. It also sits in the merger's branch-reaping path, where the opposite error destroys work, so it deserves its owner's judgement rather than a drive-by conversion. Flagged, not guessed. Also still open and unclaimed: roughly 69 untriaged literal-list sites across engine/dashboard/cli. The grep is one line and the file list is on #2775 — with the measured caveat that about half are false positives on shape alone (`LEGACY_*` names, seeds unioned with resolved values, and `roles: ["triage"]`, which is an `AgentCapability`, not the deleted column). Only the use site settles it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8c9b84ae38 |
batch-core: packages/core + dashboard/src lifecycle conversion (129 → 92) (#2780)
## batch-core — `packages/core` + `packages/dashboard/src` Shared branch: two workers are converting into it. Opening the PR because the branch was green with none, and a branch without a PR merges nothing. ### Census Measured with `node scripts/lifecycle-column-census.mjs --json`. | | guards | |---|---| | batch-core scope at branch point | 129 | | batch-core scope now | **92** (51 files) | | repo total now | 358 | Files closed so far: `store.ts` 11→0, `task-merge.ts` 6→0, `live-agent-count.ts` 6→0 (marked, not converted — see #2762), `task-update.ts` 3→0, display-ordering + Wake Delta ranking 5→0, `register-git-github.ts` 4→0. ### The `register-git-github.ts` slice Three PR routes — `pr/create`, `pr/push-branch`, `pr/resolve-conflicts` — plus the `CHANGES_REQUESTED` handler each compared `task.column !== "in-review"`. On a renamed board **none** of them matched, so every PR affordance the dashboard offers was refused for a card sitting in the lane that board calls review, and the refusal named a column that does not exist there. All four now share one helper, `reviewColumnsForTask`, which gets two things right that this program has repeatedly gotten wrong: - **Membership, not a single id.** It takes the broad review set (`mergeOrchestration ∪ mergeBlocker ∪ humanReview`). `resolveLifecycleColumns` returns the *first* column per trait, so a single-id answer silently ignores a board that declares a merge lane **and** a separate human sign-off lane. These guards only refuse or permit — they never move the card — so over-admitting costs nothing while under-admitting refuses a request that should have worked. - **An empty resolved set means UNEXPRESSED, not absent.** `synthesizeDefaultColumns` upgrades a v1 graph by emitting every default column with `traits: []`, so a v1-upgraded workflow resolves to an empty review set while its `in-review` column plainly exists and holds the card. Reading empty as "this board has no review lane" would refuse these routes on **every pre-v2 project** — a worse regression than the one being fixed, and invisible to any v2 test. This is the dashboard twin of the `fn pr create` guard in `packages/cli/src/commands/pr.ts` (#2775). The two surfaces answer the same question and now agree — FN-5893 surface enumeration. ### Testing note: why the seam and not the routes I wrote route-level HTTP tests first and **deleted them**. An express fixture over `registerGitGitHubRoutes` hangs — every case, including the pure refusals, times out at 4s, because registering the router starts background work the fixture never satisfies. Making it run would mean mocking git, the GitHub client, and the pollers: a mock-the-world shell, which is what the project's do-not-add-slow-tests rule (FN-5048) says to avoid in favour of a narrow seam. `reviewColumnsForTask` *is* the narrow seam — it holds the entire decision, and the four call sites now do nothing but ask it and render its answer. Six cases pin it: the renamed lane is returned and `in-review` is not, a two-lane board returns both, a v1-upgraded board falls back, an unresolvable workflow falls back, and the refusal renders lanes an operator can act on. **Mutation-verified, both directions:** reverting the helper to the legacy literal fails 2 of 6; treating an empty set as an answer fails 1 of 6. One fixture bug worth recording, since it would have made the two-lane case vacuous: the trait id is kebab-case `human-review`, not `humanReview`, and the built-in traits must be registered via `import "@fusion/core"` before flags resolve. ### Verification - `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` → 0 errors - `pnpm lint` → 0 errors - `register-git-github.review-lanes.test.ts` → 6 passed --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c428eed9d7 |
fix(test): two dashboard-api reds — conversions changed the log CHANNEL and the error MESSAGE (#2774)
Both red on main. `api:curated` goes **2 failed → 34 files / 1599
passed**. Neither is a product defect — both are conversions the tests
had not followed.
## 1. The log channel moved
`sse.test.ts` spied `console.log`. `sseDebug` routes through
`createLogger("sse").debug` (`sse.ts:50-53`), and the shared logger
writes debug lines to **`console.error`** carrying a `\0fnlvl=info\0`
severity marker — that is the point of FN-8603's adapter.
So the spy saw nothing, and the failure read `expected false to be
true`, naming neither the channel nor the logger. The stderr in the run
output showed the lines being emitted the whole time:
```
fnlvl=info [sse] [sse] + connection (active=1, hwm=2)
fnlvl=info [sse] [sse] - connection (active=0)
```
## 2. The error message is now built from resolved lanes
`routes-tasks` asserted the substring `"in-review or in-progress"`. The
message is now:
```ts
const allowed = [...prFeedbackReviewColumns, prFeedbackWipColumn]
.map((column) => `'${column}'`).join(" or ");
throw badRequest(`PR feedback can only be addressed for tasks in ${allowed}`);
```
so it reads `'in-review' or 'in-progress'` — quoted, and derived from
the resolved columns.
**Asserted each lane separately rather than re-pinning the joined
string.** The join order and separator are presentation; the lanes being
the resolved review + wip columns is the fact this case owns. Re-pinning
the punctuation would break again on the next formatting change *and*
would not have caught a wrong lane — which is the failure this test
exists to catch on a renamed board.
## Verification
| check | result |
|---|---|
| `test:quality:api:curated` | 2 failed → **34 files / 1599 passed** |
| `sse.test.ts` | **24 passed** |
| `routes-tasks.test.ts` | **99 passed** |
| `pnpm lint`, dashboard `tsc` | clean |
## Scope
Fix-forward only, per the u9 lane. Found by re-scanning the packages
after #2739 / #2744 / #2754 merged, rather than by waiting for a report.
For the record on the other groups at the same commit: `components-a`
**1195 passed**, core is **2 failed** — both already accounted for
(`archived-column-gate-parity` is #2768's target,
`agent-logs-and-monitor.pg` is the deferred funnel/analytics decision on
#2669).
|
||
|
|
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> |
||
|
|
d86c1f9d29 |
batch-engine: packages/engine lifecycle-column conversions (capacity worker's mega-batch) (#2773)
The engine mega-batch. Folds my four engine PRs and will absorb the remaining `packages/engine` guards as commits on this branch. **Superseded and closed:** #2722, #2741, #2766, #2770. ## Census — files converted so far | file | before | after | |---|---:|---:| | `notification/notification-service.ts` | 9 | **5** | | `runtimes/in-process-runtime.ts` | 6 | **1** | | `eval-followups.ts` | 2 | **0** | | `pr-comment-handler.ts` | 1 | **0** | | `task-revert.ts` | 2 | **0** | The last two are **census-invisible** (`Set.has(task.column)` membership) — the class measured in #2763, which a comparison-based scan cannot count. So the backlog number moves less than the work does, deliberately. ## What each one actually fixed — all silent, none cosmetic - **Notifications stopped entirely.** `handleTaskMovedAsync` compared `data.to` to `in-review`/`done`, so on a renamed board the two notifications operators rely on most were never sent. - **A finished card's plan review could re-enter.** The continuation drain's terminal test matched nothing, so a completed card's planning continuation was handed to the executor. - **The revert route admitted and the service refused.** The route resolved terminal lanes; the service compared to a hardcoded pair. The operator got a dead end from an affordance the UI and route both offered. - **Follow-up dedup blocked new cards forever.** A finished follow-up in a renamed complete lane read as *open*, so the dedup matched it permanently — defeating the intent the code documents in the line above it. - **The mission requeue wrote a column that may not exist**, and its guard never matched. ## Flagged, not fixed — deliberately - **`concurrency.ts` idle semaphore leak recovery** — the last live caller of the running-agent predicate that does not enrich. On a renamed board it under-counts and can reclaim a legitimately-held slot. The enriching variant is async and this is a synchronous repair path whose failure mode is reclaiming live work. - **The archival `task:moved` listener** — runs on every move with no cheap gate ahead of it; converting costs an IR resolution per move to decide most moves are not archival. ## Notes carried from the folded PRs Two conflicts resolved in main's favour because **main's version was better**: `in-process-runtime`'s seam uses `terminalColumns: ReadonlySet` (membership) where mine used `LifecycleColumns` (first-per-role), and the test is rewritten against main's API. That arity trap has now caught me four times, so membership is the default shape in everything new here. Review fixes from the folded PRs are included: the notifier's review set, the second human-review site, the second dedup copy, the workspace revert surface, and the file-content assertions. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **224 passed** across the touched engine suites · engine and dashboard `tsc` clean · `pnpm lint` clean · census `--strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
54d1a29621 |
fix(dashboard): the github-tracking-state classifier seam was never wired — a renamed terminal column still never closed its issue (#2754)
Not a conversion. A **conversion that was never connected to production**, found by a parity sweep rather than by the census. ## How it surfaced An AST pass over all **43** github/gitlab-named files, paired by name with counts compared: ``` github-tracking-comments.ts=9 vs gitlab-tracking-comments.ts=4 ASYMMETRIC github-tracking-state.ts=2 vs gitlab-tracking-state.ts=0 ASYMMETRIC github-issue-comment.ts=1 vs gitlab-issue-comment.ts=1 ...8 more pairs, all symmetric at 0 ``` The first is the pair #2715 fixes. The second pointed here — and the asymmetry turned out not to be the interesting part. ## The defect `decideIssueAction` has accepted an injectable `classify` since U12/R2, and that file's own header states the bug the seam fixed: > "A user-authored workflow whose terminal column is called something else never closed its linked GitHub issue, and a custom archive column never mapped to `not_planned`." Its **only production caller** passed no classifier: ```ts const decision = decideIssueAction(event.from, event.to); ``` So every real move fell through to `legacyColumnLifecycleClass`, and **the documented bug was still live**. The seam was reachable from unit tests only — which is why all 68 cases in that file were green while the behaviour they document did not work. Same shape as this branch's earlier finding on the tracking-comment guard, where the guard returned *before* resolving. **Adding a seam and wiring it are two changes; only the second one fixes anything.** Worth watching for elsewhere in this program: a file can read as fully converted, pass its suite, and still take the legacy path on every call. ## Ordering, inverted on purpose `decideIssueAction` ran first, before the tracking-enabled check, because comparing two strings is free. Resolving a workflow is not — so the cheap property read now short-circuits and only tracked tasks resolve, the ordering `github-tracking-comments.ts` and its GitLab twin already settled on. Untracked tasks returned without acting before and still do. The two remaining literals **are** `legacyColumnLifecycleClass`, that seam's named default, now marked `DELIBERATE-LITERAL` — and marked only in the same commit as the wiring. While the default was the live path on every move, exempting it would have hidden the real defect behind a marker. ## Revert proof — it detects an *unwired* seam, not a missing one Dropping the resolved classifier while **leaving the seam intact** fails both new cases with 0 `setIssueState` calls. That is the whole point: the new cases drive the **service**, not the pure decision function, so they fail for exactly the reason the 68 existing cases could not. Those pass either way. ## Two self-inflicted errors, recorded because both are recurrences - **I hand-edited the baseline with python and wrote a raw NUL byte into the JSON**, breaking the census parse. The `deliberateByFile` keys use a real `\0` separator and must be written through `JSON.stringify`, never string interpolation. - **I then restored the baseline from a newer `origin/main` than my branch point**, which made `--strict` report a `scheduler.ts: 12 → 26` rise that was pure version mixing. Rebase first, then edit. (The real `scheduler.ts` baseline staleness is already owned by #2712 — I checked before assuming it was mine.) ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **71 passed** in the tracking-state suite · dashboard `tsc` clean · `pnpm lint` clean · census `--strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
58f1e41aa0 |
test(dashboard): keep the renamed-board tracking suite — its source changes landed as #2715 and #2737 (#2714)
**Claim announced on #2706 before starting.** `github-tracking-comments.ts` + `github-tracking-reconciler.ts` — one coherent subsystem, 9 guards each. **18 → 7 by census, 18 → 0 behaviourally.** The gap is explained at the bottom and it is not hand-waving. ## Both halves failed quietly, in the way that suppresses its own evidence | surface | what a renamed board got | |---|---| | the comment poster | returned early for **every** move — the tracked issue silently stopped receiving both its "in progress" and its "done" comment. The operator sees a linked GitHub issue that never updates. | | the reconciler (3 scan passes) | matched **zero** tasks, so completed work's issues were never closed — and the pass reported a clean `scanned: 0`. | That second one is the shape worth internalising: **the number that would have revealed the problem is the number the bug suppresses.** No error, no warning, a green sweep. ## The comment poster needed a derivation, not a swap `event.to` was **both** compared against the two literals **and** passed into `formatTrackingComment` as its `transition` argument (typed `"in-progress" | "done"`). One value carrying two meanings: a lane id and a comment kind. Eight independent swaps would have had to keep agreeing with each other forever — and a ninth site (the template's own `transition === "done"`) is *not* a column at all, so a mechanical sweep would have converted it wrongly. Resolving the lanes once and deriving the kind separates the two meanings permanently. Log details still print the real column, so the operator reads their own board's name. ## The reconciler Per task through **one shared IR cache per scan**, resolved into a `Set` of terminal ids rather than an async predicate inside `.filter(...)` — `Array.filter` ignores promises, so an async predicate there silently keeps **every** row. That is a trap worth naming for other fleet workers converting list filters. The archived-vs-complete distinction keeps its own resolver rather than reusing the terminal pair: it decides GitHub's `state_reason`, and closing a finished issue as `not_planned` is operator-visible and wrong — as is the reverse. ## Revert proof **5 of 8 new cases redden.** 201/201 across the nine `github-tracking` suites (193 were already there and still pass). ## Why the census says 7 and not 0 The reconciler goes **9 → 0**. The comment poster still reports **7**, and every one of those is `transition === "in-progress" | "done"` — the derived comment **kind**, not a column. There is no lane comparison left in the file. That is exactly the vocabulary-collision class **#2692** is fixing (it already lists five misclassified receivers: an SSE event type, a cache-key mode, an evidence kind, a telemetry event kind, an agent state). **`transition` is a sixth and I have reported it there.** Until that lands the census counts them, so I am reporting both numbers rather than the flattering one. I deliberately did **not** mark them `DELIBERATE-LITERAL` to move the count: that marker means "a lifecycle literal reviewed and kept", and these are not lifecycle literals at all. Using it as a census-silencer would put a wrong reason in the code to make a number look better. ## Verification `pnpm test:gate` **487 / 71** · **201/201** github-tracking suites · `tsc -p packages/dashboard` clean · `pnpm lint` clean · census `--strict` exit 0 (it also tightened three entries other workers' merges left stale — the #2679 auto-tighten working). No changeset: `@fusion/dashboard` is private and this is internal behaviour on renamed boards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ceca08b1c3 |
fleet: github-tracking-reconciler 9 → 0 — deciding the sync-filter class (prefetch a resolved map), and the reconciler closed NO issues on a renamed board (#2737)
`github-tracking-reconciler.ts` 9 → **0**, and the reference implementation for the `.filter((task) => task.column === "<id>")` shape I have been flagging across four files. ## I stopped waiting and decided it I flagged this class in #2709, #2696, #2700 and #2715 as "needs one decision" and left ~25 sites unconverted. That decision was mine to make and I should have made it three PRs ago. **Prefetch a resolved map, then filter synchronously.** The alternative — async predicates — forces every caller into `for await` and turns a list comprehension into a sequential walk. Prefetching keeps the filters synchronous, puts the awaits in one bounded place, and lets the IR cache do the job it was explicitly built for: > "A self-healing pass over 400 cards spanning three workflows must read three IRs, not 400." The cache is **instance-scoped and shared across all four passes**, so each distinct workflow's IR is read once for the whole run rather than once per pass. `resolveLifecycleColumns` is pure and *not* memoized by that cache, so this still costs one cheap struct build per task — fine in a background reconcile, and stated rather than hidden. No new abstraction: `resolveTaskLifecycleColumns` already takes a caller-owned cache. The only new code is a local map builder and two named predicates. ## What it cost before On a board with renamed terminal lanes, **every filter here matched nothing**. The reconciler closed **no** GitHub issues and reported `scanned: 0` — a clean-looking pass that did nothing. ## Why this is not the split brain #2724 documents — checked, not assumed #2724 proves the archived gate in `packages/core` is enforced in three encodings, so converting one alone diverges them. I checked whether that applies here before converting: - This file contains **zero SQL** — measured: no drizzle, no `sql` template, no `eq`/`ne`. - It calls `listTasks({ includeArchived: true })`, so the SQL half has already been told to include archived rows. The filter **selects among rows it was handed** rather than deciding liveness a second time. **Gate versus consumer** is the distinction, and a consumer can be converted alone. The fourth pass needed its own check because its list comes from `listTasksForGithubTrackingReconcile`, which *is* SQL — but that impl filters on `deletedAt IS NOT NULL` and `githubTracking IS NOT NULL`, **never on the column**, so there is no SQL-side encoding of this question to diverge from. ## Why the 33 existing tests stayed green through the conversion Their fake store has **no workflow reader**, so `resolveTaskLifecycleColumns` catches and returns `undefined` and every case asserts the legacy fallback — exactly what it always asserted. **None of them could have caught this being wrong.** `workflowIr` is now an opt-in on that fake, which is what makes the new cases real tests rather than restatements. | reverted | result | |---|---| | terminal filter back to the ids | "closes issues on a RENAMED complete lane" fails, no `setIssueState` | | same | renamed archived-heuristic case fails, no `setIssueState` | ## A reachability finding, recorded not acted on In backend mode `reconcileDeletedAndArchived` returns only **soft-deleted** rows — its own comment says the archived-tasks fallback is a separate `AsyncArchiveLineage` subsystem, skipped there — and `task.deletedAt` is tested *first* in the `stateReason` chain. So its archived arm is **effectively unreachable today**. I converted it rather than deleting it: it is the documented FN-5577 done-heuristic, and whether that fallback should be wired here is a separate question from what vocabulary it speaks. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **35 passed** across the three reconciler suites · dashboard `tsc` clean · `pnpm lint` clean · census `--strict` exits 0. Remaining files in this class (`branch-group-ops.ts`, `store.ts`, and the dependency pairs) can now follow this pattern instead of waiting — with the gate-versus-consumer check applied to each, since `branch-group-ops.ts` sits closer to the persistence layer than this one does. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
15f90706e6 |
fleet: reliability-metrics.ts 6 → 0 — historical log values, marked not converted (#2756)
Unclaimed file, no overlap with any open fleet PR — deliberately picked to avoid adding conflicts to the queue. ## Census | | before | after | |---|---|---| | backlog | 539 | **533** | | reviewed (DELIBERATE-LITERAL) | 31 | 36 | | this file | 6 | **0** | `--strict` exit 0, baseline re-recorded in the same commit. ## Why these are marked, not converted All six ids come from `metadataColumn(entry, "from"|"to")` — the columns **recorded on a past move event** in the activity log, not a task's current column. There is no workflow to resolve them against. The event was written under whatever the board looked like at the time, and **a column renamed since leaves every older entry carrying the old id forever.** Converting them to a trait read would ask *"what role does the column named X play today?"* about a record written months ago, possibly under a different workflow — a different question with a different answer. The failure mode matters: a trait-converted reader on a renamed board would **zero the series** rather than fix it, silently dropping history out of `tasksEnteredInReviewPerDay`, `tasksBouncedToInProgressPerDay`, and `inReviewDurationMetrics`. That is worse than the literal, which at least keeps matching the data that exists. **The real fix for renamed boards is at the WRITER** — emit a role alongside the id when the move event is recorded — not at this reader. Noted at the site so whoever does that work finds it. ## A rule this generalises to **Any reader of activity-log or run-audit metadata is a mark, not a convert.** The census cannot distinguish `task.column === "in-review"` (a live question, convert it) from `metadataColumn(entry, "to") === "in-review"` (a historical record, match it as recorded) — both are just literals to the AST. Other fleet workers hitting log/audit readers should expect the same call. ## Placement trap, third occurrence My first pass marked the `const from`/`const to` declarations and moved the count by **1 of 6** — the census excuses the construct a marker is attached to, and the guards live in **sibling `if` statements**. Moved the markers to the enclosing functions. This has now caught #2645's author, me on `TaskContextMenu`, and me again here. **Verify a marker by the count moving, not by the comment existing** — and until every worker does, a batch reporting "N → 0" can be off by most of N. ## Verification Dashboard typecheck clean · reliability suites green (11 passed) · `--strict` exit 0 · no behavior change (comments only). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
edab088107 |
fix(test): 23 reds on main from the column U11 deleted — routes-github (22) + move-bypassguards (1) (#2758)
**Red suite on `origin/main`, unclaimed. Reported twice (on #2723 and #2733) and nobody picked it up, so I am fixing it rather than reporting a third time.** `register-task-workflow-routes.move-bypassguards.test.ts` fails on main: `expected 400 to be 200`. ## Not a route bug — a fixture that outlived its column The move endpoint validates the target against the **task's own workflow** (U12/R2), and the default lineage post-#2515 declares `todo | in-progress | in-review | done | archived`. The fixture asked to move to **`triage`**, which U11 deleted. So the route correctly answered `400 Invalid column`, the request never reached `moveTask`, and **every assertion in the case was unreachable**. Same class as the two assertions #2720 corrected in `task-dependency-mutation.pg.test.ts`: a test pinning an id the board no longer has. It is also **why it sat unclaimed** — the failure message says *"Invalid column"*, which reads as a broken guard rather than a stale test, so anyone glancing at it would reasonably assume it belonged to whoever last touched the move route. Target changed to `in-progress`: keeps the case's actual subject intact (a caller-supplied `bypassGuards` / `moveSource` must not be forwarded) and is a forward move from `todo`, so the R16 backward-move PR guard stays out of the way. **The point was never which column.** ## The paired cases the suite lacked The cheapest wrong fix here would have been to relax the route's validation until the old fixture passed again. Two cases now make that impossible: - an **undeclared** column (`triage`) is still **rejected**, with a message naming the board's own columns so an operator can act on it; - a **declared** column is still **accepted**, so validation cannot degrade into "reject everything". Without them, the next person to see *"Invalid column"* in a failure cannot distinguish a stale fixture from a broken guard — which is exactly the half-hour this cost me. ## Verification 11/11 route suites green (**69 tests**, was 1 failing) · `pnpm test:gate` **487 / 71** · `tsc -p packages/dashboard` clean · `pnpm lint` clean. Scope is one test file. Opening this despite the one-PR-per-worker rule because it is the same category as #2753 — a red on main, which the close-out brief made priority one — and it touches nothing my other six PRs touch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e0010f241e |
fleet: github-tracking-comments.ts 9 → 3 — and the sync-filter class is now in a FOURTH file (~25 sites on one decision) (#2715)
Claiming the **github-tracking pair**. This converts the comments half; the reconciler half is the flagged class, with the evidence below. ## Census before/after | | before | after | |---|---:|---:| | `github-tracking-comments.ts` | **9** | **3** | Baseline re-recorded; `--strict` exits 0. ## Converted: 6 The `event.to === "in-progress"` / `=== "done"` sites in `handleTaskMoved`, to the **wip** and **complete** roles. One resolution, placed **immediately after the tracking-enabled gate** — so a move on an **untracked** task pays nothing, which is most moves in most projects. ## Deliberately not converted: 2 — the ordering is the reason ```ts if (event.to !== "in-progress" && event.to !== "done") return; // line 232 ``` This runs **before** the tracked-task gate. Converting it moves the resolution ahead of that gate and makes **every task move in the project** resolve a workflow just to decide the task has no GitHub issue. That's a real cost on the hottest event in the system, to convert a guard whose only job is a cheap filter. Recorded at the site. ## The remaining 1 **Line 165** — `transition === "done"` inside `formatTrackingComment`, a **pure formatter** with no store and no task. Same shape as `project-engine.ts:2555`. Threading a resolution into a formatter to pick a string is the wrong trade. ## `github-tracking-reconciler.ts` (9) — not claimed here, and here is why All nine are: ```ts .filter((task) => task.column === "done" || task.column === "archived") ``` Synchronous filters over task **lists**, where per-task resolution is N awaits inside a sync predicate. **This is the fourth file with that exact shape** — after `store.ts` (#2709), and the dependency pairs in `TaskDetailModal` (#2696) and `register-task-workflow-routes` (#2700). By my count **roughly 25 sites across four files now wait on one decision**: 1. **Prefetch lifecycle columns alongside the task list** and pass a resolved map into these predicates — keeps them synchronous, one resolution per *distinct workflow* rather than per task. This is the one I'd argue for. 2. Make the predicates async and accept per-task resolution. It's a design change, and four workers guessing separately is exactly how two halves of one rule drift apart. One decision covers all of them. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · `github-tracking-comments` + `github-issue-comment` **81/81** · `pnpm lint` clean · dashboard `tsc` clean · `--strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
5791dfeeb7 |
fix(dashboard): the routes and the engine disagreed about what "review" is — a converted guard that still contradicts core (#2723)
Built **on top of #2713**, which converted this file to trait-resolved membership sets while my #2701 was open on the same file. Their design is better than the single-id resolver I had — the arity argument in their comment (a single id answers "where should this card GO", a SET answers "is this card ALREADY there") is correct, and I have closed #2701 rather than contest it. Two things survive that #2713 did not cover. ## 1. The routes and the engine disagreed about what "review" IS Core's `resolveLifecycleColumns().review` — the answer the **executor**, the **merger** and **project-engine** all act on — resolves review from `mergeOrchestration`. This file's resolver looked only at `mergeBlocker` / `humanReview`. The default lineage hides it, because `in-review` carries all three: ``` in-review[merge-blocker, human-review, stall-detection, merge] ``` A board that declares only `merge` on its review lane resolved as **review in the engine** and **not review in the routes**. The executor treats the card as in review; comment re-engagement, the retry gate and branch-binding recovery all refuse it. **That is the same defect class as a literal, one level up:** the guard is converted, it reads a real trait, and it still disagrees with the authority. Unioning all three makes this resolver a **superset** of core's, so the routes cannot refuse a card the engine considers in review. How I found it is worth stating: my renamed-board fixture declares only `merge` on its review lane, and the ported suite failed against main. I nearly "fixed" the fixture to add `mergeBlocker` — which would have papered over a real cross-layer inconsistency to make my own test pass. ## 2. The 400s name the board's own columns #2713 converted both gates but left the messages saying `in-review` / `in-progress`. **Being told your card must be in a column that does not exist is worse than a wrong guard** — a wrong guard is a bug report; a wrong column name sends the operator looking for something that was deleted. ## Tests 7 cases ported from #2701 and adapted to the membership-set shape. **3 red on revert** of the `mergeOrchestration` union. ## A pre-existing failure I am reporting, not folding in `register-task-workflow-routes.move-bypassguards.test.ts` **fails on `origin/main`** — 400 where it expects 200. Verified by stashing my change and re-running, so it is not mine. Someone owns that regression; it should not ride in on a lane-consistency PR. ## Verification `pnpm test:gate` **10 / 71** · the ten other `register-task-workflow-routes` suites pass · 7/7 new · `tsc -p packages/dashboard` clean · `pnpm lint` clean · census `--strict` exit 0, no count change (this PR converts nothing new — it makes an already-converted resolver agree with core). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a59c6aea50 |
fleet: register-task-workflow-routes.ts 20 -> 5 (#2713)
## Census before / after | | before | after | |---|---:|---:| | `register-task-workflow-routes.ts` | **20** | **5** | I handed this cluster off in an earlier turn with an analysis rather than a conversion. Coming back to it with the analysis already done made it tractable. ## Converted with the file's own idiom This file already had `resolveIntakeColumnForTask` / `resolveWipColumnForTask` / `resolveReboundColumnForTask`: resolve the column **id** from the task's workflow, fall back to the legacy id when the IR cannot be read. I added the three roles it was missing — review, complete, archived — in that same shape. **Deliberately not the `columnRoles` predicate helpers** used in `packages/dashboard/app`. Those take resolved trait *flags*, which a route handler does not have — it has a store and a task id, and must do an async lookup. Importing them here would mean fetching flags per request to answer a question this file already answers a simpler way. One idiom per layer. Every converted handler **resolves once and reuses**, so two checks in the same request cannot disagree about which column is the review lane. The retry handler had three separate review checks and now shares one resolution. ## Also fixes an inversion `isArchived` ORed the legacy id with the resolved trait **unconditionally**, so a column merely *named* `archived` counted as archived even when its own workflow said otherwise. Same pattern previously found in `TaskContextMenu` and `isPreExecutionHoldColumn`. Now flags-first, id as fallback. ## The five survivors, each with a reason | count | line | why | |---:|---|---| | 1 | 1193 | `tasks.filter(t => t.column === "todo")` — a **list** path. Per-task IR resolution is N store reads on board load; the site already carries a note measuring that cost. Needs the hold column resolved per *workflow* from the board payload — a real change, not a rename. | | 1 | 1926 | **Already flags-first**: `moveTargetIr && declaresColumns ? columnHasFlag(...) : column === "in-progress"`. The literal *is* the documented no-IR fallback. | | 1 | 4872 | The fallback arm of the `isArchived` fix above — same shape, deliberately kept. | | 2 | 4024 | `depTask.column` — a **dependency's** column, not the task's. Resolving it means fetching that task's IR per dependency; out of scope. | So three of the five are correct as they stand, and two are genuinely deferred. ## Verification `plan-approval-intake-column` + `stranded-refinements-routes` 13/13. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0 with the baseline re-recorded here. `tsc -p packages/dashboard/tsconfig.json` clean. `pnpm lint` clean. Typecheck was run after **every** batch rather than once at the end — with 15 edits across async handlers in a 6000-line file, a single late typecheck would not tell me which batch broke it. No changeset: no user-visible change. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9e63d8e0f |
consolidate/capacity: --strict was red on main (my #2621), 14 stale baselines, routines seeding a deleted column, worktrees-off audit (#2652)
Capacity unit consolidation. Three coherent themes, small commits inside. ## Census before/after (`node scripts/lifecycle-column-census.mjs`) | | before | after | |---|---:|---:| | triage column guards (the bar) | 10 | **10** | | `--strict` on main | ❌ **RED** | ✅ green | | baseline staleness | 14 files stale | **0** | This branch does **not** move the triage bar — its remaining 10 are moves.ts (dies with the flag), the dashboard cluster, and one deliberate site. It fixes the instrument that measures the bar, plus a live defect the comparison count cannot see. --- ## 1. `--strict` was RED on clean `origin/main`, and it was my fault ``` packages/dashboard/src/routes/register-task-workflow-routes.ts: 22 -> 23 ``` My merged #2621 added a v1-IR pre-WIP fallback answering a greptile P1 and shipped no marker or baseline update, so the program's measuring instrument has been failing on main since it landed. Fixed **at the site** with a `DELIBERATE-LITERAL` marker, not by bumping the baseline. That branch runs only when the IR declares no columns and no nodes, so there is no role to resolve — `resolveLifecycleColumns` returns nothing and the legacy pre-implementation ids are the only pre-WIP signal that exists there. It is *unconvertible*, not unfinished; the sibling `else` two lines down is the trait path for every IR that can answer. A rise that is genuinely correct belongs where a reader will see it. ## 2. The baseline was stale for 14 files — a hole, not cosmetics A stale allowance lets converted guards return while the check stays green. Measured gaps: ``` self-healing.ts allows 126, tree has 111 executor.ts allows 112, tree has 104 moves.ts allows 44, tree has 39 default-workflow-hooks allows 25, tree has 7 mission-feature-sync allows 5, tree has 0 MissionControlPanel allows 4, tree has 0 (+8 more) ``` **Only two of the fourteen are mine.** The other twelve are already-merged conversions by other workers where nobody re-recorded. Re-recorded all fourteen here rather than waiting for twelve PRs, because until it happens the ratchet is not holding the 779 it exists to hold. Flagging it plainly: those drops are other people's work being locked in, not mine being claimed. ## 3. Routines created tasks into the column U11 deleted The routine editor's "Target Column" defaulted to `triage`. That value is submitted as the create step's `taskColumn`, and an **explicit** column bypasses the workflow entry-column resolution added for column-less creates (#2589) — so every routine saved with the untouched default seeded its tasks into a column the board does not declare. Defaulting to `todo` would be the same mistake one column over: a custom workflow declaring no `todo` is seeded into an undeclared column just as surely, because an explicit column overrides entry resolution whatever its value. So the default sends **nothing** and each workflow's own intake resolution decides. The `triage` **option** is removed too, not merely un-defaulted — fixing the initializer alone left the operator able to pick the deleted column one click away, and it was the option labelled "Planning", the name the merged `todo` column now displays. Removing it retires that label inversion as well. Found by scanning **membership** forms rather than comparisons: the comparison census cannot see a `?? "triage"` default, so no count showed this and nobody was looking. Revert-proof — restoring the default fails with *"the default must not name a column at all"*. ## 4. "Worktrees off is INERT" had one unaudited reader The constraint was that `maxWorktrees` become genuinely inert, "not set very high and not skipped by convention". `resolveWorktreeCapacityLimit` returns `null` for that, and its unit tests can only prove the **resolver** is right — they cannot see a second reader, which is the only way the constraint breaks. Audited every `maxWorktrees` read that bounds anything. **Exactly two:** `scheduler.ts` (the admission gate, via the resolver, single call site, optional gate snapshot) and `self-healing.ts`'s `enforceWorktreeCap` — `(settings.maxWorktrees ?? 4) * 2`, a **raw** read. The second is **not a bug** and is left alone: it bounds worktree *directories on disk* and only removes *idle* ones. Worktrees still exist in OFF mode, so that bound must keep applying or idle directories accumulate unbounded. Recorded consequence: in OFF mode the number still governs disk retention while gating no admission — an edge you scoped out. The note says explicitly **not** to unify the two readers: routing hygiene through the resolver returns `null` in OFF mode and silently removes the disk bound, which is a leak dressed as a simplification. New ratchet requires every file bounding on `maxWorktrees` to be named with a reason, and rejects a **stale** allowlist entry. Proven by injecting `active >= (settings.maxWorktrees ?? 4)` into `hybrid-executor.ts`. --- ## Deliberately NOT included - **My own census script.** #2633 landed the canonical one, and it is better than mine — an AST classifier *plus* an independent text classifier with `--compare`, and a baseline that fails on unrecorded **drops** as well as rises. Mine only caught rises. I deleted mine rather than ship a second measuring instrument; three copies of "strip comments" is the drift shape this program keeps paying for, so the worktree ratchet now imports #2633's `stripComments`. - **My TaskContextMenu fix.** Superseded, and by a better answer: main's `isPureIntakeColumn` (intake *without* hold) keeps the merged Planning column shown and suppresses only a bare Ideas capture, which resolves the exact hold-lane objection coderabbit raised against my version. I briefly clobbered that merged work by checking my old file out wholesale, caught it in the diff, and reverted. ## Verification `pnpm lint` clean · core + dashboard `tsc` clean · census suite 23/23 · worktree ratchet 8/8 · RoutineEditor 49/49 · `routes-task-retry-planning-column` 16/16 · `lifecycle-column-census --strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Added after review (all four greptile threads were real, and two of them mattered) **The routine fix was half a fix.** `routine-runner.ts:515` *and* `cron-runner.ts:982` both did `column: (step.taskColumn as Column) || "triage"` **after** the step is read, so every routine — including ones saved through the fixed editor — still created tasks into the deleted column. Both now omit it. **The advanced steps editor MANUFACTURED the defect.** `ScheduleStepsEditor.tsx` had three `triage` defaults: the new-step template (`:64`), the per-step initializer (`:95`), and the select still offering it (`:344`). So the path I had *not* fixed produced the bug by default, on fresh data. Template names no column; initializer coerces a persisted `triage`; `triage` removed from the options; empty submits `undefined`. **Four pre-existing tests pinned the defect** and are rewritten to the corrected invariant rather than appeased: | test | asserted | |---|---| | `cron-runner`: "defaults column to triage when taskColumn is not set" | `column: "triage"` | | `ScheduleStepsEditor`: "adds a create-task step..." | `taskColumn` toBe `"triage"` | | `ScheduleStepsEditor`: "allows saving create-task step..." | the legacy column is **resubmitted** | | plus the explicit-column case added beside each, so the fix cannot swallow a deliberate choice | **The allowlist hole was the worst finding.** `AUDITED_BOUNDS` was keyed by FILE, so every bounding expression in an allowlisted file was exempt — a second raw bound in `scheduler.ts` stayed green, the one case that ratchet exists for. Per-expression now, and making it so **immediately surfaced a real second bound the file-level version was hiding** (`maxWorktreesGate.used >= maxWorktreesGate.limit`, safe by construction since the snapshot is `undefined` in OFF mode). Proven by injection. ## Found while re-reading my own deletion, not reported A **rendered tooltip** still named a deleted cap. The "Queued to plan" badge read *"planning starts when a concurrency slot frees up (maxConcurrent / globalMaxConcurrent)"*. The cross-project cap is gone — capacity is two numbers per project — so it told operators their planning waited on a limiter they can no longer find a setting for. Names the surviving dimension only now. ## Coding (Ideas): enforcing #2651 rather than repeating it I took the unowned coding-ideas IR merge, concluded it must not be done, then found **#2651 had already implemented, reverted and documented exactly that** — with better grounding than my own argument. It added no test, so nothing stops the next person reaching the same dead end. So this ships their reasoning as a ratchet, not a second opinion: triage discovery keys on the column's `autoTriage`, so a merged column is either never scanned (cards sit on a bootstrap stub until the **capacity hold** releases them, sending **unplanned** work into in-progress — worse than stalling) or scanning wins and the manual gate is gone. Their scope caveat is kept: `autoTriage` is a general trait field, so only *this preset's* collapse is dead, not manual intake as a concept. The registry does not reject the merged shape, which is why prose was not enough. ## Verification (re-run) `pnpm lint` clean · core + engine + dashboard-app `tsc` clean · `lifecycle-column-census --strict` exits 0 ("every file matches its baseline exactly") · routine-runner 24/24 · cron-runner 156/156 · ScheduleStepsEditor 41/41 · RoutineEditor 49/49 · worktree + coding-ideas 12/12. TaskCard has 2 failures **pre-existing on main** — confirmed identical with my changes stashed. --- ## Bears directly on the closing bar: this PR already removes the 67-guard ratchet slack Measured on current `origin/main` with the census itself: ``` tree total: 787 baseline total: 854 SLACK: 67 FILES ABOVE BASELINE (1): +1 packages/dashboard/src/routes/register-task-workflow-routes.ts (22 -> 23) FILES BELOW BASELINE: 13, totalling 68 unrecorded conversions -18 core/default-workflow-hooks.ts (25->7) -15 engine/self-healing.ts (126->111) -8 engine/executor.ts (112->104) -5 core/task-store/moves.ts (44->39) -5 engine/mission-feature-sync.ts (5->0) -4 core/live-agent-count.ts (10->6) ``` **The slack is not regression — it is 13 files of merged conversions nobody re-recorded**, against exactly **one** rise. This PR re-records the baseline **854 → 782 across 140 files**, which closes it. **And the "+3 that slipped in" is +1, and it is mine.** `register-task-workflow-routes.ts 22 → 23` is the v1-IR pre-WIP fallback my #2621 added; it is justified (that branch runs only when the IR declares no columns or nodes, so there is no role to resolve) but it shipped with no marker and no baseline update — which is why `--strict` has been **red on main since it merged**. Fixed here at the site with a `DELIBERATE-LITERAL` marker rather than by bumping the baseline, because a rise that is genuinely correct belongs where a reader will see it. Sequencing note for the auto-lowering change: if this lands first, that work is purely the mechanism (auto-lower, or fail with tighten instructions) rather than a cleanup, and the two re-records will not collide in the same file. Also worth carrying into that mechanism, from building the same guard here: **`--update` must refuse to RAISE.** An earlier version of mine wrote current counts verbatim, so a developer who added a literal and ran the documented update command locked the regression in as the new ceiling — the mirror of the high-water problem. Lowering can be unattended; raising should be a hand edit with the reason recorded. ## Third piece of residue from my own deletion `updateGlobalConcurrency` in the dashboard API client PUT to `/api/global-concurrency`, a route removed when the machine-wide cap went. Zero callers; the only reference was the `legacy.ts` barrel re-export. Deleted both. `fetchGlobalConcurrency` **survives on purpose** — the GET route remains and serves live utilization telemetry to the footer and Command Center; nothing gates on it. That is the third: after the second raw `maxWorktrees` reader and the "Queued to plan" tooltip. A deletion is not finished when the enforcement goes — the client, the label and the tooltip outlive it. --- ## Re-greened the dashboard API tests: 117 failures on main, ONE root cause These would have polluted the closing verification pass, and nobody owned them. `api()` builds headers via `new Headers(...)` and returns `Object.fromEntries(headers.entries())` — and `Headers.entries()` **lowercases every key**, so the object reaching `fetch` is `content-type`, not `Content-Type`. `ab87d0d80` then added `x-fusion-client: dashboard-ui` for run-audit attribution. Both changes are correct; neither is visible at a call site, so **114 assertions across 7 files** kept asserting the old shape and went red together. Fixed by naming the shape **once** in `app/test/apiRequestHeaders.ts` rather than patching 114 literals — restating a shared fact 114 times is what made a two-line client change look like 117 failures. Deliberately not a loose `objectContaining`: these tests are the only thing pinning that the attribution header is sent *at all*. **117 → 4.** The remaining 4 are unrelated pre-existing CSS failures (`task-detail-modal-tablet-width` ×3, `space-token-defined` ×1) — confirmed identical on clean main with my changes stashed. ### A gap this surfaced, recorded not papered over Three routes failed in the *opposite* direction — they send the old shape because they call `fetch()` **directly**, bypassing `api()`, so they never get the attribution header. `client.ts` claims the opposite: > "Applied once here rather than per-call so no future mutation route has to remember it." That does not hold for a route that bypasses the helper it is applied in. **Measured in `app/api/`: 8 files make direct `fetch()` calls and 7 include mutations (POST/DELETE)** — among them `ai-sessions.ts`'s DELETE, which is the same class as the four-delete incident the header was added for. So the attribution fix has a hole in exactly its motivating case. Not fixed here: routing those onto `api()` is a behaviour change across the API layer and belongs to its owner, not to a test re-green. Those assertions use a separate `API_JSON_HEADERS_NO_ATTRIBUTION` constant so the gap stays **visible** — if a route is later moved onto `api()`, its test fails and points at the note explaining why. --- ## This branch takes the triage bar 10 → 5, and makes `--strict` green `node scripts/lifecycle-column-census.mjs` on this branch reports **triage 5**, against **10** on `origin/main`. The five removed are the ScheduleStepsEditor template/initializer/option and the RoutineEditor default/option — the automation paths that were creating tasks into the deleted column. **`--strict` was also RED on clean main, twice over, and both causes were the same mistake:** a thorough written rationale the tool cannot read, because the marker was not where the census looks. The census reads a comparison node's **leading comments**; a `DELIBERATE-LITERAL` in the JSDoc above the enclosing function or declaration does not reach the comparison inside it. | site | why it is legitimate | why the tool could not see it | |---|---|---| | `columnRoles.ts:80` `isHoldColumnRole` | degrades to `columnId === "todo"` only when a column has **no resolved traits** — identical in kind to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` directly above, which escapes counting only because a Set is a membership form | rationale written, **no marker token** | | `MissionControlPanel.tsx` ×3 | the SDLC funnel **alias table** — maps `to-do`/`ready`/`review`/`shipped` onto one display stage with an explicit `other` bucket, and nothing branches on it | marker in the JSDoc; the comparisons are arrow bodies **inside the array literal**, which it does not reach | The second only surfaced because converting the `triage` stage to a Set removed its count and exposed the siblings — red gate, justification sitting three lines above, unreachable. Both are markers, no behaviour change. Neither is a conversion candidate: resolving the funnel table to traits would **drop the non-column aliases it exists to accept**. **For the auto-lowering work:** the marker-placement rule is now the recurring trap — three instances, three different authors, including me. A marker that does not register is indistinguishable from no marker, and the failure mode is a red gate with a written explanation nobody can act on. If the census accepted a marker anywhere in the enclosing declaration's comments, none of the three would have happened. Baseline re-recorded per the tool's own instruction ("Re-record the baseline in the SAME PR that lowered the count"). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Task “Actions” menus no longer appear on bare cards in the Planning column. - Routines, scheduled tasks, and create-task steps now respect each board’s configured workflow intake column instead of using a retired default. - Legacy tasks saved with the retired intake column are migrated to automatic workflow resolution. - Target-column selection now offers only “Automatic (workflow intake)” and “Planning,” removing the obsolete option. - Capacity/planning messaging and related UI tooltip text were clarified; concurrency cap updates are managed per project. - **Tests** - Added/updated coverage for workflow intake resolution, create-task target column behavior (including legacy coercion), capacity safeguards, and API request consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dc50425e98 |
docs: correct 104 future-dated FNXC timestamps across 61 files (#2680)
## What The FNXC convention exists so a reader can place a note against the change that motivated it. A stamp dated *after* the edit landed defeats exactly that. This is program-wide drift, not one author's slip — I contributed to it in my own commits this week, which is how I noticed it. ## Measured, on this tree **104 stamps across 61 files** dated later than the day they were written, from one day ahead to **2026-10-19 (81 days)**: | count | date | count | date | count | date | |---|---|---|---|---|---| | 50 | 2026-07-31 | 6 | 2026-08-05 | 3 | 2026-08-13 | | 17 | 2026-08-01 | 1 | 2026-08-07 | 1 | 2026-08-19 | | 7 | 2026-08-02 | 1 | 2026-08-12 | 2 | 2026-08-26 | | 11 | 2026-08-03 | | | 3 | 2026-10-19 | An earlier number I circulated was ~70. That came from a narrower pathspec and was wrong; **104** is the measurement. ## How Each stamp is rewritten to the date of the commit that introduced **that line**, via per-line `git blame` — deliberately *not* stamped uniformly with today's date. A uniform stamp swaps a wrong date for a different wrong date and flattens the ordering that makes these comments navigable; blame preserves it. Times of day are untouched, and a blame date in the future is clamped rather than trusted. ## Why the verification is listed A docs sweep across 61 files is precisely where a stray edit hides, so the safety claims are mechanical rather than asserted: - every changed line begins with a comment marker — **no code touched**; - **no test asserts an FNXC date later than today**, so no `toContain` assertion on embedded source text can be silently invalidated (several such assertions do exist); - CSS files, which carry several of those assertions, are outside the pathspec. ## Verified lint clean · merge gate green (487 + 158 + 10 + 71) · `census --strict` exit 0 · tsc clean for core, engine, and dashboard (`tsconfig.app.json`). **No behavior change.** Comment text only. ## Not done here A guard preventing recurrence. A check that rejects an FNXC stamp dated after the commit would stop this returning, but it needs a decision about where it runs (lint rule vs. gate) and it is a behavior change to CI — it does not belong riding inside the sweep it would police. |
||
|
|
dca20496f4 |
consolidate/u7: plugins to zero + 8 executor rebound guards + resume lanes (supersedes #2607, #2635, #2640) (#2644)
Consolidation branch for U7, per the new one-branch working mode. **Supersedes #2607, #2635, #2640** — the three of my PRs that were stuck on review threads. My other seven (#2602, #2605, #2606, #2611, #2621, #2628, #2633) are green with **zero unresolved threads** and are deliberately left alone for the merge sweep. ## What is in here, file by file | file | change | guards before → after | |---|---|---| | `plugins/…/glasses/src/agent-actions.ts` | gates, destinations and degraded-resolution refusal all resolve from the task's own workflow | 2 → 0 | | `plugins/…/glasses/src/quick-capture.ts` | accepted capture columns come from the board; default no longer names the deleted column | 1 → 0 | | `plugins/…/glasses/src/settings.ts` | quick-capture default was `triage`, the column #2515 removed | (assignment, uncounted) | | `plugins/…/dependency-graph/src/GraphTaskNode.tsx` | redundant column condition deleted | 1 → 0 | | `packages/engine/src/executor.ts` | 8 rebound guards compare the resolved column; 4 resume-eligibility literals share one resolver | 151 → 143 (+4 off-bar) | | `packages/engine/src/__tests__/` | 4 new suites, 26 cases | — | `plugins/` reaches **zero** column guards with this branch. ## The three threads it closes **#2607 — five findings, all mine, all the same rule.** I kept *qualifying* a legacy-id fallback instead of removing it: | attempt | rule | hole review found | |---|---|---| | 1 | fall back to `todo` when the role is missing | moved cards to phantom columns | | 2 | …only if the workflow **declares** `todo` | aliased **review** lane named `todo` | | 3 | …and only if no other role is assigned to it | **traitless** parking column named `todo` | The qualifications were the mistake. Once `resolveLanes` returns a lane set the workflow *has* a column vocabulary, so "no column carries the hold trait" is a complete answer — refuse. `destination()` is two lines now, with no aliasing surface left to qualify. Plus a sixth, which is a genuinely different state: **degraded resolution is indistinguishable from the default board.** `resolveWorkflowIrForTask` is total by design — a missing definition silently returns the *default* coding IR — so a card on a custom board whose definition could not be read resolved to `todo`/`in-progress`. `undefined` lanes cannot express that (it means "no workflow at all", where the legacy ids *are* the answer). The actions now refuse with 409. #2618 would replace this check with resolver provenance; it is not merged, so this does not depend on it. **#2635 — "seven rebound sites remain untested."** Fair; my "same shape" note was an assertion, not coverage. Seven of the eight need a live graph run to reach, so the *shape* is pinned instead: a static check that no guard in front of a rebound move compares against a column literal, with a vacuity case (the same detection run against the original shape) and a match-count floor (≥8), because a guard reporting success on zero matches is worse than no guard. **#2640 — duplicate workflow resolution.** Framed as I/O; it is also a correctness bug. Eligibility and re-entry are two halves of one decision and resolved the workflow separately, so a workflow edit landing between them has the halves reading *different boards*. Now one caller-owned memo per decision — caller-owned because a process-lifetime cache would have to guess when a mid-flight workflow edit invalidates it. ## Behavioural findings, not tidying - **The last-resort recovery for completed-but-stranded work did not exist off the default lineage.** `promotedFromPlannerColumn` was false on a renamed board, so finished work resting in planning was never promoted; the code fell through to a review handoff that role adjacency rejects, and the card stayed stuck with its work complete. - **Rebound guards could not see the column their own move targeted.** U5b converted the move target; the eight `column !== "todo"` checks in front of it were left literal, so on a renamed board the engine moved a card into the column it was already in — and `moveTaskInternal` runs reset-on-entry on every real move, so at the `preserveProgress: false` site it reset step progress a second time. - **The FN-1404 `task:move` audit row was lying**, recording `to: "todo"` while the move target was resolved. A run-audit trail that disagrees with the move it describes is worse than none. Not a comparison, so no census counts it. - **A task interrupted by an engine pause never resumed on a renamed board** (off-bar, `in-review`/`in-progress` literals): four comparisons decided one question and had to agree; two of them disagreed on a renamed board, so re-entry silently never fired. ## Revert proofs, isolated per site | reverted | result | |---|---| | `destination()` back to attempt 3 | 3 of 38 fail | | degraded-resolution refusals removed | 2 of 42 fail | | capture set back to the legacy five | 2 of 3 fail (renamed-board suite) | | forward exclusions → literals | 1 of 14 fails | | missing-wip refusal removed | 2 of 14 fail | | `promotedFromPlannerColumn` → literals | 3 of 7 fail | | promotion target → `"in-progress"` | 3 of 7 fail | | one rebound guard → `!== "todo"` | 1 of 3 fails (static shape) | | resume lanes → legacy trio | 1 of 5 fails | Every conversion is paired with a negative — a forward move, a not-a-planner-lane card, a default-lineage card, an unresolvable workflow — so neither "always fire" nor "never fire" can pass for "resolve the role". ## Commit discipline Twelve commits, each one thing: the code move (`resolvePlannerLanes` out of `triage.ts`) is separate from every behavior change, and each review fix is its own commit with its own revert proof. ## Verification - `pnpm test:gate` **71/71** - 162/162 across the glasses plugin's 19 files; 26/26 across the four new engine suites - engine + glasses typecheck clean; `pnpm lint` clean 🤖 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** * Engine recovery and retries now work correctly with renamed or customized workflow columns. * Tasks in manual-intake columns are no longer automatically planned. * Agent actions and quick capture now respect each board’s declared columns and lifecycle stages. * Awaiting-approval tasks are recognized regardless of their current column. * Command Center SDLC funnel stages now accurately reflect customized workflows. * **Documentation** * Added guidance for safely changing workflow-column logic and interpreting lifecycle-column checks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2771408bba |
ci: enforce the lifecycle-column ratchet — it has never actually run (#2654)
**The ratchet was advisory.** `scripts/lifecycle-column-census.mjs` existed only as `pnpm census:lifecycle-columns` — without `--strict` — and **no workflow invoked it**. Nothing has ever compared the tree to the baseline. Every "the baseline ratchet holds them" assumption in this program rested on a check that does not run. That explains both classes of hole: **1. Three PRs lowered counts without re-recording,** leaving allowances the deleted guards could return through while every check stayed green. I've tightened them across #2593 and earlier PRs, but nothing stops the next one. **2. #2621 GREW the count while its own title claimed "count 0 → 0".** It added `column === "triage"` and `column === "todo"` at `register-task-workflow-routes.ts:2681`, taking that file to **23 against an allowance of 22**. It landed unchallenged. This is the failure mode the ratchet exists to prevent, and it happened *inside this program*, in a PR that asserted the opposite. ## The change Adds `check:lifecycle-columns` (the census with `--strict`) to the `pr-checks.yml` lint job, next to `check:changesets` and `check:routes-modular` — the established pattern. **~1.8s over ~1950 files**, so this is not a slow-test addition. ## Proven to fail, in both directions A guard that reports success without checking anything is worse than no guard, so: | injected defect | result | |---|---| | `const __probe = (c: string) => c === "triage"` added to `moves.ts` | `count ROSE — moves.ts: 39 -> 40`, exit 1 | | run against main's current baseline | exit 1 on `mission-feature-sync.ts: allows 5, tree has 0` | Both reverted; exit 0 restored. Note the second row: **this check is RED on main right now**, which is the point. ## Merge order **Stacked on #2593**, which carries the `DELIBERATE-LITERAL` marker for the #2621 site (a v1 IR declares no roles, so no trait can answer that question) plus the baseline re-record. Standalone on main this PR is red — correctly. **Merge #2593 first**, then this. I stacked rather than duplicating those two edits because I already caused one conflict today by appending related content from two branches, and #2651 merged a correction ahead of the section it corrected. Same-content edits in two PRs is the same mistake. ## Census Unchanged by this PR: **776 total, triage 5, reviewed 16** — it adds no guards and converts none. It only makes the numbers enforceable. ## For the fleet This should land before the 776-guard fleet launches. The brief says "the baseline ratchet must shrink by exactly the converted count" — until now nothing verified that claim, so a batch worker could report a shrink that did not happen, or grow the count while converting, and CI would agree. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
15b21dead1 |
fix(dashboard): reconcile task state through live API (#2595)
## Summary - add a project-scoped live API route for updating individual task checklist steps - add an atomic live API route for resolving stale durable wedge episodes - prevent operator repair tooling from opening a second embedded store that can diverge from the running dashboard backend ## Why Legacy graph-native workflow runs can retain successful `workflowStepResults` while their narrative checklist remains at 0/N. The existing `fn task update` fallback may open a separate embedded store, producing split-brain writes that do not accumulate in the live dashboard backend. There was also no API surface for the existing atomic wedge-episode resolver. ## Verification - `pnpm exec vitest run src/routes/__tests__/register-task-workflow-routes.step-update.test.ts` — 5/5 passing - `pnpm build` in `packages/dashboard` — passing - full managed runtime workspace build — passing - deployed to the managed local runtime and used to reconcile six legacy review-deadlock tasks - live board audit: zero `in-review-stall-deadlock` paused reasons - exact local and Tailscale dashboard roots: HTTP 200 with 16,926-byte bodies <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added live API endpoints to update individual task checklist steps with validation (step index and allowed status values). * Added an endpoint to reconcile/resolve stale task “wedge” episodes, resolving only the matching active episode and returning conflicts on mismatches. * **Tests** * Expanded route tests for step updates and wedge resolution, including consistent 404 behavior for soft-deleted and missing tasks, plus conflict and invalid-input cases. * Expanded PostgreSQL coverage for wedge resolution persistence and concurrent episode replacement scenarios. * **Bug Fixes** * Improved task-lookup error handling so soft-deleted tasks are consistently treated as “not found” (HTTP 404). <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
f14059e8d3 |
Retry still refuses cards parked mid-planning on 5 builtins (survives #2614; count 0 → 0, defect-only) (#2621)
**Rebased onto main after #2614 landed this file.** That PR's conversion already took the tracked count for `register-task-workflow-routes.ts` to **0**, so this PR does **not** move your number and I am not claiming it does. | file | before | after | |---|---:|---:| | `packages/dashboard/src/routes/register-task-workflow-routes.ts` | 0 (post-#2614) | 0 | What it fixes is a **live 400** that #2614 left in place. Measured on current main: **9 of this file's 14 retry tests fail** without the change below. ## The defect `POST /api/tasks/:id/retry` must answer *"does this card sit where its workflow **plans**?"*, because the yes-branch is **destructive** — it stamps `needs-replan` **and deletes PROMPT.md**. Two predicates stood in for that question and neither answered it: - **#2614** resolved the **intake** column. Correct for the merged lineage; wrong wherever intake and the planning column differ. - The older arm asked `!workflowHasColumn(ir, "triage")`. **Measured across all 12 builtins:** *not one* plans in `triage`, while **seven** still declare that column. So for the five that declare `triage` **and** run every plan node in `todo` — `quick-fix`, `review-heavy`, `compound-engineering`, `design`, `legacy-coding` — the predicate is `false` and a `planning`/`needs-replan` card sitting in **its own planning column** is refused outright: ``` 400 — "Task is not in a retryable state (current status: needs-replan)" ``` The operator has no button at all on a card parked mid-planning. The mirror-image fault is destructive rather than obstructive: a workflow that plans anywhere other than `todo` had a `todo` card's PROMPT.md deleted for a re-plan nobody asked for. ## Fix `workflowPlansInColumn` asks the graph. Planning nodes are recognised by the **semantic markers** the builtins carry — `config.seam === "planning"` and an **exact** `workflowAction` set (measured vocabulary: `plan-replan`, `code-review`, `pre-merge-remediation`) — with node ids as a backstop. Deliberately **not** a `startsWith("plan")` prefix. That was my first attempt and greptile was right to kill it: it matched in the **destructive** direction, classifying a custom `plan-execute` column as a planning column, which deletes a specification. An unlisted planning action costs a replan (recoverable, card stays retryable); a wrongly-listed one costs a spec (not). Hence opt-in. ### Second concern, split out Narrowing the destructive branch must not narrow **retryability** — those were one boolean and are two questions. A card parked outside its planning column would otherwise fail the gate and answer 400: that trades *a card which loses its spec* for *a card nothing can rescue*. It stays retryable via the non-destructive branch, scoped to pre-WIP columns so no `in-progress`/`in-review` status gains a path it lacked. A **v1 IR** declares neither columns nor nodes, so placement is **unanswerable** rather than answered "no". `workflowDeclaresColumnModel` distinguishes the two — reading that silence as "past planning" is exactly what 400'd a v1 planning card. ## Verification All three greptile P1s on the earlier revision were real and are fixed with revert-proof tests (bespoke planning-node ids; the v1 regression I introduced; my own loose action prefix). `pnpm lint` clean · dashboard `tsc` clean · `pnpm test:gate` green (132 + 10 + 482 + 71) · core 11/11 · dashboard 119/119 (`retry-planning-column` + `stale-merge-status` + `routes-tasks`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d438cd1d13 |
U12 drift: register-task-workflow-routes.ts — resolve the intake column (7 -> 1) (#2614)
**File claimed: `packages/dashboard/src/routes/register-task-workflow-routes.ts`.** Per-file lifecycle-column guard count: **7 → 1**, and the 1 is comment prose (line 3758), so this file is done for completion-bar item 1. ## The bug this fixes `retrySpecification` decided "this Retry is a re-plan, not a generic retry" with `task.column === "triage"`. `status: "planning"` is retryable **only** through that flag — it is not in the generic `failed`/`stuck-killed` set. So on any lineage whose intake column is not literally named `triage`, a card visibly sitting in planning got `400 Task is not in a retryable state`. The operator's Retry button did nothing, with no error to explain why. Post-#2515 that includes the **default** workflow: `columnsWithFlag(resolveDefaultWorkflowIr(), "intake")` is `["todo"]` and the default's columns are `[todo, in-progress, in-review, done, archived]` — `triage` is not declared at all. The pre-existing `todo` fallback below it papered over the default case (it fires when the workflow has no `triage`), which is why this did not show up as a total outage; custom and renamed lineages had no such cover. Now: `const retryIntakeColumn = await resolveIntakeColumnForTask(scopedStore, task.id)`. ## Red-green, measured `packages/dashboard/src/__tests__/plan-approval-intake-column.test.ts` — new case, custom lineage with intake `backlog`, card in `backlog` with `status: "planning"`: - with the change: `200` - with `task.column === "triage"` restored: **`AssertionError: expected 400 to be 200`** The fixture uses `planning` deliberately. A `failed` fixture would pass either way through the generic retryable set and prove nothing. ## What is NOT tested, and why not This PR also removes four `&& task.column !== "triage"` disjuncts I added earlier while widening the P0 approve/reject guard. **Those are untestable by construction** and I am not claiming coverage for them: removing an extra acceptance only shrinks what the guard accepts, and no case can feed these routes a `triage` card now that no shipped lineage declares one. I re-widened one guard and confirmed the suite stays green — i.e. nothing depends on the disjunct in either direction. That is the honest result, not a passing test. ## Three fixtures updated, not guards re-widened `stranded-refinements-routes.test.ts` failed with three `expected 400 to be 200` — the same failures that made me widen in the first place. This time I probed instead: `BASE_TASK` had `column: "triage"`, a column the default workflow no longer declares, so a 400 is **correct** and the fixtures were pre-merge artifacts describing a board shape the product stopped shipping. Changed to `column: "todo"` with the resolver output recorded in the file. ## Observation, deliberately not fixed here The `todo` fallback at ~2642 (`retrySpecification = !workflowHasColumn(workflowIr, "triage")`) is now near-dead: for the merged default the first branch already fires. It survives only for a lineage that has a `todo` column, no `triage`, and some *other* intake column — where treating a `todo` card as planning is arguably wrong. Deleting it is a behaviour change with its own blast radius, so it does not ride along in a conversion commit. ## Verification `pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). Target suites: `plan-approval-intake-column.test.ts` 8/8, `stranded-refinements-routes.test.ts` 12/12. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a3a7f16977 |
Drift 4/4: reset reported a successful reset as a 409 "limbo" conflict — plus the audit verdict for every other site in the file (#2582)
## Drift 4/4 — a second live bug in the routes file, plus the audit for
the rest of it
**Stacks on #2571** (the P0). Merge that first.
### The bug
`POST /tasks/:id/reset` resolves its destination through
`resolveReboundColumnForTask` — the task's own workflow rebound column —
and then verified the outcome against the literal `todo`. **Twice.**
So on any workflow whose rebound column is not `todo` — Coding (Ideas),
any custom or renamed lineage — a reset that **succeeded** was reported
as a `409` "limbo state" conflict. The mover and its own verification
disagreed about where the card was supposed to land.
Both checks now compare against `resetColumn`, which is already in scope
two lines above the first one.
### Revert-proof, after I caught my own vacuous test
My first version of this test **passed with the fix reverted**. The
reset route demands `{ confirm: true }` and was 400ing before it ever
reached the column check, so `expect(status).not.toBe(409)` was
trivially true. That is the third time in this program a route/DOM
assertion has looked like coverage while checking nothing, and the
second time I have caught it in my own test.
With the confirmation sent, the reverted form fails: `expected 409 not
to be 409` — a correctly-reset card reported as limbo.
### Audit of the remaining sites in this file
| site | fires after #2515? | verdict |
|---|---|---|
| 2597/2607 manual retry | yes | **SAFE, by design.** Falls back to a
`todo` branch gated on the workflow declaring no `triage` column —
exactly the merged shape. Written for Coding (Ideas); the merge made the
default match it. |
| 4584 respecify | yes | **SAFE.** Already `column === "triage" \|\|
column === respecifyTarget`, and `respecifyTarget` resolves the intake
column. |
| 2887/2917 reset verification | **no** | **FIXED here** — false 409 on
a successful reset. |
| 1121 awaiting-planning enrichment | partially | **BROKEN, deliberately
not converted** — see below. |
### The one I chose not to convert, and why
`1121` filters on `column === "todo"`, so a workflow whose waiting lane
is named otherwise gets no enrichment and silently falls back to the
heuristic.
I converted it and **reverted**. Resolving each task's hold column needs
a per-task workflow read, and this is the board-load path whose own
comment exists because unbounded reads here *"turn a board load into
thousands of reads"*. My version did those reads for **every task before
the enrich limit applied** — trading a silent degradation for a
load-time regression on every board.
Converting it properly needs the hold column resolved per **workflow**
from data the board payload already carries, not per task from the
store. That is a real change with a measurable cost, not a rename. Left
with the cost written at the site rather than quietly skipped, and
flagged here so it is tracked rather than forgotten.
### Verification
`pnpm test:gate` (414 + 10 + 71), `pnpm lint`, dashboard typecheck
green. Route suites: 9 passed, including
`stranded-refinements-routes.test.ts` unchanged at 5.
### My drift set, final
| file | before | after | PR |
|---|---|---|---|
| `TaskCard.tsx` | 8 | 3 | #2558 |
| `ListView.tsx` | 5 | 3 | #2566 |
| `taskActivity.ts` (found underneath) | 1 | 1 | #2566 |
| `TaskDetailModal.tsx` | 4 | 3 | #2577 |
| `register-task-workflow-routes.ts` | 10 | 11 → 9 | #2571 + this |
Routes went 10 → 11 in #2571 (guards widened to accept resolved-intake
**or** `triage`, so a P0 fix could not reject anything previously
allowed) and back to 9 here. Every other survivor is the documented
no-metadata fallback: flags are absent during the pre-load window and
for a card stranded in a vanished lane, and a bare trait read would drop
the affordance in exactly those states. They retire with the load
window, not with a rename.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a56253f426 |
P0: plan approve/reject rejects EVERY card on a merged planning column — operator-visible stall, cannot approve or reject (#2571)
## P0 — plan approve/reject is dead for cards on a merged planning column **This is the "card stuck with nothing to rescue it" case you asked to hear about immediately.** Found auditing my files after #2515. ### What happens #2515 removed `triage` from the merged default lineage — one pre-implementation column, id `todo`, displayed "Planning". Four routes guard with: ```ts if (task.column !== "triage") throw badRequest("Task must be in 'triage' column ...") ``` On a workflow with no `triage` column that condition is **true for every card**, so the routes reject all of them: | route | effect on a merged-lineage card | |---|---| | `POST /tasks/:id/approve-plan` | 400 — **cannot approve** | | `POST /tasks/:id/reject-plan` | 400 — **cannot reject** | | `task_refine` route (×2) | 400 — refine blocked | A card parked `awaiting-approval` can be **neither approved nor rejected**. It is stuck, the operator is being asked for a decision they have no way to give, and nothing throws to reveal it. ### Why it is the worst variant of this drift Everything we have chased so far is a guard that silently **stops** firing. This is a guard that silently starts firing on **everything** — same root cause, opposite symptom, and worse, because the failure is visible to the operator as a task that demands an answer and refuses every one. ### The fix, and a deliberate choice The guards resolve the workflow's own intake column through the existing `resolveIntakeColumnForTask`, and they **widen rather than replace**: a card is accepted if it is in the resolved intake column **or** in `triage`. That is on purpose for a P0. The fix cannot reject anything the route previously allowed, so it carries no regression risk of its own. Narrowing to the resolved column alone is a follow-up once the legacy id is gone everywhere — not something to do under time pressure on a route that gates operator decisions. I found the value of that when a strict replacement broke 3 pre-existing tests in `stranded-refinements-routes.test.ts`. The widened form passes all of them **and** the new P0 cases. ### The convergence number goes UP, and I am not hiding it Live-code `column === / !== "todo" | "triage"` in `register-task-workflow-routes.ts`: **10 → 11**. Each converted guard keeps the legacy id as an explicit second condition, so a widened guard has two literals where it had one. The metric counts id literals; it does not know the guard is now strictly more correct. Reporting the direction that is true rather than the one that looks better — and flagging that this file's number will only fall once the widening can be removed. ### Revert-proof Restore either bare literal and the matching case fails with **400 where 200 is expected**, on a `todo` card with `awaiting-approval`. A third case pins that the guard still **narrows** — an `in-progress` card is still rejected — so this cannot be mistaken for deleting the check. ### Verification `pnpm test:gate` (414 + 10 + 71), `pnpm lint`, dashboard typecheck green. New suite 3 passed; `stranded-refinements-routes.test.ts` back to 5 passed (it was 3 failed under the strict form). ### Still auditing `TaskDetailModal.tsx` conversion is in flight on a separate branch. `TaskCard.tsx` (#2558) and `ListView.tsx` + `taskActivity.ts` (#2566) are already open — and note #2566 covers `isTaskAgentActive`, whose planner-lane clause has the *silent* version of this same bug: planning cards read as idle everywhere at once. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3cef9c226e |
Drift 1/4: TaskCard planning affordances from traits, not "triage" (8→3 measured; one site needed a new wire fact, one conversion was wrong and the tests caught it) (#2558)
## Drift conversion 1 of 4 — TaskCard.tsx Taking the dashboard surfaces from the drift review. This is the board-card one; ListView, TaskDetailModal and register-task-workflow-routes follow separately so each stays revertable. ### Convergence number `task.column === / !== "todo" | "triage"` in `TaskCard.tsx`: **8 → 3** The three survivors are **one documented fallback**, not scattered checks. `getTaskColumnFlags` (Column.tsx) returns `undefined` when a card's column is absent from the resolved metadata and is not the rendering column — the pre-load window, and a card stranded in a lane its workflow dropped. Converting to bare trait reads would have removed every planning affordance in exactly those states, so the legacy ids survive **once**, at the role helpers, plus the move prompt resolving its own target. They retire with the load window, not with this change. I'd rather report 8 → 3 with the reason than 8 → 0 with a regression behind it. ### Why this file was urgent Every planning affordance was gated on `task.column === "triage"`. Land U11 — merged column keeps id `todo`, `triage` deleted — and each comparison silently becomes false: **delete button, awaiting-approval controls, planner badge, step list all vanish from planning cards.** ### One site needed a new fact on the wire, not a renamed comparison `showStartAction` was `intake === true && column !== "triage"`. That hardcoded id was standing in for *"an intake column that does not auto-triage"* — a distinction that lives in trait **config** (`intake` with `autoTriage: false`) and was invisible to every client. It also **inverts** under U11: with `triage` deleted, `column !== "triage"` is vacuously true, so a Start button would appear on **every planning card**. `describeColumns` now derives `manualIntake` server-side and the gate reads it. Renaming the comparison would have shipped the inversion. ### One conversion was wrong, and the tests caught it I first converted the move-progress prompt to the card's *own* column role. The original tests the move **destination** — moving a card *back* into a pre-implementation lane is what risks discarding step progress. `confirms preserving progress before moving` failed immediately on an `in-progress → todo` move. It now resolves the target column's flags. That is the argument for red-green per site rather than pattern-matching the comparison: the regex looks identical at both sites and means different things. ### Revert-proof New `TaskCard.u11-merged-column.test.tsx` renders cards in the **post-U11 shape** — id `todo`, traits `intake + hold`, no `triage` anywhere — and asserts Delete, the planner badge and the step list still appear; that Start does **not** (auto-triaging); and that a manual-intake lane does get it. Revert any converted site and the matching case fails, because these cards are not in `triage` and never will be again. ### Fixture updates, and why they are not weakening - Start-affordance cases now pass `manualIntake`, which the server supplies for a manual intake lane. - "omits the Start button for the triage column even when intake is flagged" → "for an **AUTO-triaging** intake column". The rule was never about the id; the title said it was. ### Verification `pnpm test:gate` (414 + 10 + 71), `pnpm lint`, both dashboard typechecks green. **No new failures**: `TaskCard.test.tsx` reports the same 2 pre-existing failures with and without the change, verified by diffing failing test *names* against a stashed clean tree rather than comparing counts. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
01a75f9edc |
fix(desktop): typecheck streamed model downloads (#2493)
## Summary - make the fetch response-body cast explicit across DOM and desktop TypeScript library definitions - preserve the existing async byte-stream runtime behavior ## Test plan - `pnpm --filter @fusion/dashboard exec vitest run src/stt/__tests__/model-manager.test.ts --reporter=dot` - `pnpm --filter @fusion/desktop typecheck` - `pnpm --filter @fusion/dashboard typecheck` - `node scripts/check-changeset-format.mjs` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved desktop build typechecking for streamed speech-model downloads by refining how streamed response bodies are interpreted for TypeScript. * Preserved runtime behavior, including streaming, integrity/hash checking, file writing, and cancellation handling. * **Maintenance** * Updated the release metadata so this fix is published with the correct patch classification. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
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 --> |
||
|
|
18d654a5ff |
capacity, part 3: delete the globalMaxConcurrent setting, API and UI (#2529)
Part 3 of the capacity simplification, and the half that removes the **knob**. Enforcement (shared semaphore, runtime wiring) went in #2509; this removes everything an operator or API client can still see, so nothing is left readable-but-ignored. ## Deleted Settings key + schema default · CentralCore’s `getGlobalConcurrencyState` / `updateGlobalConcurrency` / `acquireGlobalSlot` / `releaseGlobalSlot` and the `concurrency:changed` event · the whole Global Concurrency block in `async-central-core` · `PUT /api/global-concurrency` · the Scheduling · Global settings section · the footer and Command Center global sliders · the dead `getGlobalConcurrencyLimit` reader whose only caller went in #2509. ## Kept, deliberately **`GET /api/global-concurrency` survives as telemetry only** — live `currentlyActive` / `projectsActive` from CentralCore’s side-effect-safe source. “How busy is this machine?” is still a real question once the cap that used to answer it is gone. It no longer reports `globalMaxConcurrent`/`queuedCount`: those came from the deleted cap and from slot bookkeeping production code never incremented, so publishing them was publishing zeros dressed as state. **`useGlobalConcurrency` becomes read-only.** Everything that existed to *persist* went with the cap — the 500 ms debounce, the save-state machine, the commit-on-close/unmount flush, the slider clamp, the `interactive` gate. The module-level shared store is **kept**: its original justification (two mounted consumers drift apart with private copies) holds for a polled read exactly as it did for a cap, and one fetch now serves both. The live “N running (all projects)” readout survives in both surfaces, moved onto the per-project row. ## Two sections become one Scheduling · Global existed to host exactly one control. With it deleted the section renders an empty pane, so the Global/Project pair merges back into **“Scheduling”**. An empty nav entry is a promise of settings that are not there. ## One real fix found on the way `SchedulingSection`’s `concurrencyLoading` gated the **project** concurrency inputs on the **global**-concurrency fetch — never the right source, since `maxConcurrent` and `maxWorktrees` come from the settings form. It is repointed at the form’s own load, preserving the invariant it existed for: a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. ## Migration A stored `globalMaxConcurrent` is **ignored** — it is a project-blob key nothing reads, so dropping it needs no schema change. The `central.global_concurrency` **table** is dropped in a follow-up; this slice stops seeding and reading it first, so that drop has no live writer to race. ## Verification, and how the wider suite was controlled `pnpm lint` clean · core/engine/dashboard `tsc` clean · `pnpm test:gate` green (309 + 10 + 71) · dashboard settings/footer/command-center/hooks **2237/2237** · core `central-core-backend` 9/9. The broader dashboard suite shows failures, and I checked rather than assumed: running the suspect files on **clean main** reproduces `api-git` (49), `TaskDetailModal.rendering` (28) and `settings-mobile` (17) identically. Two were genuinely mine — `SettingsModal.scheduling-merge` (0 on main, 17 on this branch: my nav rename) and one `settings-mobile` picker case asserting `scheduling` is a scoped pair — and both are fixed. Tests for deleted behaviour are removed with it (footer confirm/cancel/flush/dedupe, global marker geometry, the hook’s PUT case, the CentralCore slot cases), each carrying a note on what it guarded and where the surviving **project-side** equivalent lives. Fixture-only references were updated, not deleted. Nothing booted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
da0351857e |
U12 part 5: put real workflow adjacency on the wire — custom-workflow move menus were guessing (measured), and the VALID_TRANSITIONS shortcut is gone (#2525)
## U12 part 5 — the move menu was guessing; now it asks the graph **Stacks on #2521** (same file). Merge that first. The context menu had **no adjacency data at all**, so it did two wrong things at once: it approximated move targets from a column's **neighbours in declared order**, and — because that approximation is strictly weaker than the real graph — it kept a `VALID_TRANSITIONS` shortcut for any workflow whose column-id set matched the six built-ins. Measured, the approximation loses real operator moves: | current | workflow graph | neighbour approximation | |---|---|---| | `in-progress` | in-review, todo, triage, done | todo, in-review | | `todo` | in-progress, triage, archived | triage, in-progress | | `done` | todo, triage, archived | in-review, archived | So **every custom workflow has been offering a guess**: menu entries the store would reject, and legal moves it never offered. The built-ins were fine only because the shortcut bypassed the guess entirely. ### The fix `BoardWorkflowColumn` gains `moveTargets`, resolved by `resolveAllowedColumns` — *the same resolver `moveTaskInternal` validates against*. The menu now offers exactly what the store will accept, for any workflow. Threaded through all four metadata builders (Board, Lane, ListView, TaskDetailModal). Optional on the wire, deliberately: a client older than this field keeps the neighbour fallback rather than losing its move menu mid-upgrade. ### Why deleting the legacy shortcut is safe Not an assertion — a measurement, then a pin. `resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, c)` is **identical to `VALID_TRANSITIONS[c]` for all six columns, order included**: ``` triage ["todo","archived"] == VALID SAME todo ["in-progress","triage","archived"] == VALID SAME in-progress ["in-review","todo","triage","done"] == VALID SAME in-review ["done","in-progress","todo","triage"] == VALID SAME done ["todo","triage","archived"] == VALID SAME archived ["done"] == VALID SAME ``` `builtin-adjacency-matches-legacy-transitions.test.ts` pins it so the equivalence cannot drift silently — if the built-in workflow's edges change without `VALID_TRANSITIONS` following, default menus change shape and that test fails first. It compares **order** too, since the menu renders targets in the order it receives them, so a reorder is operator-visible. Default-workflow menus are therefore byte-identical. Custom ones stop guessing. ### What's left of the legacy vocabulary here `COLUMNS` is gone from `TaskContextMenu` — deleting the shortcut removed its last use. `VALID_TRANSITIONS` survives for exactly one thing: the **no-metadata load window**, documented at the site. I measured removing that in #2521 and it left Task Detail with no move options during load, which is a regression rather than a cleanup. It retires when the load window does. ### Revert-proof, two ways - Drop the `declaredTargets` branch → the custom-workflow case fails: the neighbour fallback returns `["backlog","building"]`, missing the legal `shipped` jump **and** offering `backlog`, which that graph forbids. That is exactly the defect class shipped to every custom workflow today. - A second case pins that an adjacency edge into a column the board cannot show is **dropped**, not rendered as a dead menu entry. ### Verification `pnpm test:gate` (309 + 10 + 71), `pnpm lint`, `pnpm verify:fast`, core + dashboard typechecks green. **No new test failures**: five suites report 31 failures with and without the change — an identical, pre-existing set, verified by diffing failing test *names* against a stashed clean tree, not by comparing counts. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Move menus for custom workflows now show only the destinations permitted by that workflow. * Task-specific workflow rules are applied consistently across boards, lists, lanes, and task details. * Invalid or unavailable destinations are excluded from move options. * Existing clients remain supported when workflow destination data is unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3badc244a7 |
U12 part 2: bind the three U5 reconciliation guards — USER-VISIBLE (and one path that couldn't run under PostgreSQL at all) (#2512)
## U12 part 2 — the three U5 reconciliation guards now actually fire
USER-VISIBLE. Taken on standing authority; here is exactly what changed
for operators.
All three read the RAW `experimentalFeatures.workflowColumns` key via
`store.workflowColumnsFlagOn()`. Nothing in production writes it, so all
three have been inert since the workflow-columns cutover.
| Guard | Before (every real project) | After |
|---|---|---|
| Workflow edit removing an **occupied** column | Save succeeded; cards
left in a column the workflow no longer declares | Save fails with
`OccupiedColumnsError` unless `rehomeTo` is supplied |
| Workflow **delete** | Occupant capture returned `[]`; cards sat in the
deleted workflow's columns until the next engine start | Cards move to
the default workflow's entry column as part of the delete |
| Workflow **switch** | Never reconciled; the `reconciliation` field in
the declared return type was never populated | Card in an undeclared
column moves to the resolved target; a declared column is preserved |
Both consumers already handle the new outcomes and needed no change:
`register-workflow-routes.ts` maps `OccupiedColumnsError` to a
structured 409 carrying per-column occupant counts, and
`fn_workflow_update` returns a retryable structured result. The
dashboard editor's `rehomeTo` retry flow becomes reachable for the first
time. I only updated two stale "flag-ON" comments there — that code was
correct all along and simply never fired.
### What an operator actually sees (USER-VISIBLE — read this bit)
Four changes to what the board and the API do. Nothing here is silent.
1. **Editing a workflow to remove a column that has cards in it now
FAILS.** Previously the save succeeded and the cards were left in a
column their workflow no longer declared. The dashboard shows the
existing 409 with per-column occupant counts and prompts for a re-home
target; retrying with `rehomeTo` moves the cards and saves. Removing an
EMPTY column is unaffected.
2. **Deleting a workflow moves its cards immediately** to the default
workflow's entry column, instead of leaving them until the next engine
start.
3. **Switching a task's workflow moves the card** when the new workflow
does not declare its current column. A card whose column IS declared
stays exactly where it is. The API response now carries the
`reconciliation` summary it always promised.
4. **A switch whose re-home would be REJECTED is now refused before
anything is written.** If the destination column is at its WIP limit,
the switch fails with a structured 409 (`workflow-switch-rehome-failed`)
naming the task, both columns and the reason — and **nothing changes**:
the task keeps its current workflow AND its current column. Retry after
making room. Previously this combination committed the selection and
then silently reported a move that never happened, leaving selection and
column disagreeing.
**Can a torn card still happen? Yes, in one narrow case, and here is how
you recover.** If the destination fills in the window between the
pre-flight and the move, the selection is already committed and the card
ends up in a column its new workflow does not declare. That case is not
silent: it writes a `task:workflow-switch-torn` run-audit row, and the
error carries `selectionCommitted: true` with both columns. Recovery:
make room in the destination and move the card there, or switch the task
back — and if neither happens, the R7 startup sweep
`reconcileUndeclaredTaskColumns` re-homes it on the next engine start.
The card is never lost; it is visible in a lane the board may not draw
until one of those runs.
The one thing to watch after merge: (1) converts a previously-silent
success into a visible failure, so an operator mid-edit on a busy
workflow will start seeing a 409 they never saw before. That is the
point — the alternative was stranding their cards — but it is the change
most likely to generate a "this used to work" report.
### The thing that made this more than a gate removal
Un-gating the switch guard surfaced that
`selectTaskWorkflowAndReconcileImpl` read the task through
`store.readTaskFromDb` — the **synchronous SQLite** reader, which throws
under PostgreSQL:
```
TaskStore.db: SQLite Database is not available in backend mode
```
The flag returned before that line, so the gate was hiding a path that
**could not execute at all in the production backend**, not merely a
disabled feature. Ported to the async `readTaskRow`. Found by the new
tests, not by reading the code.
### Review round 2 (both findings real, both fixed)
**Torn write with no alarm — fixed by ORDERING, not by a louder
message.** My first attempt only made the error loud, which left the
torn state intact. The real fix is that the deterministic rejection
cause (destination at its WIP limit) is now checked BEFORE
`selectTaskWorkflow` commits, by resolving the target IR straight from
`workflowId` instead of through the task's selection. Nothing commits on
that path.
For the residual race the failure is loud AND recorded: `rehomeOccupant`
now returns `{ moved, error? }` (additive; sweep callers ignore it), the
switch writes a `task:workflow-switch-torn` run-audit row, and throws
`WorkflowSwitchRehomeFailedError` with `committed: true`. Consumers
translate it: the dashboard route returns a structured 409 with
`selectionCommitted`, and `fn_task_set_workflow` returns the same fields
— no more generic "something went wrong".
**Fabricated column for a deleted task.** My first fix fell back to
`fromColumn` when the final read found no row, so a task soft-deleted
mid-switch was reported as having its old column *preserved*. Absent now
reads as absent (the optional `reconciliation` is omitted). Extracted as
the pure `buildSwitchReconciliation` seam because the window is not
reachable through the public call — `selectTaskWorkflow` rejects an
already-deleted task up front — so it is a genuine race, and I test the
decision directly rather than asserting it from reading the code.
### Revert-proof, measured
New `workflow-reconciliation-production-shape.pg.test.ts` — 6 cases,
with the flag **never written**, which is the configuration every real
project has. Each flip reverted individually:
- re-gate the edit guard → **2 failures** (OccupiedColumnsError case;
rehomeTo re-home case)
- re-gate the delete capture → **1 failure** (card stays in
`custom-hold`)
- restore the switch early return → **2 failures** (`reconciliation`
undefined; card does not move)
- all three in place → **6/6 green**
Round-2 fixes, also measured:
- restore the `fromColumn` fallback → the "row is gone" case fails
(reports `preserved: true` for a deleted task)
- drop the `!outcome.moved` throw → the capacity-blocked case fails
(resolves instead of raising)
- **move the capacity pre-flight back AFTER the commit → the case fails
on the SELECTION assertion** (expected `WF-002`, received `WF-001`),
i.e. it proves the ordering, not the wording
The pre-existing coverage in `workflow-authoritative-reads.pg.test.ts`
reached the occupied-column guard by **writing the flag ON itself** —
same pattern as the ListView/Board suites in part 1. Its flag write is
removed; it now runs in the production shape.
### Where I nearly got this wrong
My first revert harness was buggy and I briefly concluded the delete
re-home was **redundant** — I had probed the stored column and seen
`triage` with what I thought was the flip reverted. It wasn't.
`workflow-ops.ts` contains two identical `const occupantTaskIds = await
store.listWorkflowOccupantTaskIds(id, false)` lines (field-reconcile
block, delete path), so my first-match edit reverted the wrong one.
Re-run anchored on surrounding context, the delete case fails as
predicted. Recorded in the test header as a caution. I also chased and
**refuted** a scarier hypothesis along the way — that an unrelated
`updateTask` coerces a custom column back to `triage`. It does not; the
column survives.
### Deliberately NOT in this PR
The v1-IR rollback-compat persistence (`downgradeIrToV1IfPure`) on the
workflow UPDATE path. It shared the same `flagOn` variable, which is how
it surfaced: **one flag read was feeding two unrelated decisions, so the
flag has more decision sites than call sites** — my earlier 9-site
inventory undercounted. It chooses the stored *shape* of the graph
rather than gating a guard, so it is a persistence-format change with a
different blast radius. It now reads the flag explicitly, behaviour
unchanged, for a follow-up.
The `moves.ts` group remains U2b's.
### Verification
`pnpm test:gate` (307 + 10 + 71), `pnpm lint`, `pnpm verify:fast` (17
steps), both typechecks green. Full `packages/core` PostgreSQL suite:
**1042 passed, 3 failed** — `central-archive-secrets.test.ts`
(log-prefix assertion) and
`workflow-settings-project-identity.pg.test.ts` (×2, project-id
resolution). I confirmed the identical 3 failures on a stashed clean
tree: pre-existing, unrelated. No Fusion instance booted.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Workflow edits now prevent removal of occupied columns unless cards
are moved to a specified destination.
* Cards are automatically re-homed when workflows are deleted or
switched.
* Workflow switches now check destination capacity before committing and
provide clear conflict details when re-homing fails.
* Reconciliation results now indicate whether cards were moved or
preserved.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
743df98aa4 |
capacity, part 1: merge pinned at 1, worktrees-off mode, and one dead knob deleted (#2502)
First slice of the capacity simplification. Operator: *"just have two
capacity — overall per project agent count and max worktrees. Remove all
other capacities and counts."* Plus two later additions: **merge is
always 1, fixed**, and **worktrees off ⇒ limit by total agents only**.
Three independently revertable commits. No limiter is added anywhere;
one is deleted, one is made structurally absent, and one is pinned.
---
## 1. Merge concurrency ratcheted at 1 (test-only)
I was asked to add a limiter if merge concurrency could be raised. **It
cannot** — there is no setting, workflow property, pool or trait config
anywhere that raises it, so this adds no code and pins what already
holds.
Serialization lives in the **pump**: `drainMergeQueue`’s `mergeRunning`
re-entrancy latch, `activeMergeTaskId` as a single-slot identity, the
`mergeBodyInFlight` next-generation latch, and one `ProjectEngine` per
projectId.
**Not** in the merge-queue lease, which is a per-task ROW (`primaryKey
[projectId, taskId]`) — two tasks can hold leases simultaneously by
construction, and it has exactly one caller (the worktree-reuse
handoff). Ordinary merges never take it. A lease-level test would have
been describing an invariant that layer has never held.
The second half guards the other direction: a merge-concurrency
*setting* would not fail the pump ratchet — it would sit unread until
someone wired it up.
**Revert-proof:** deleting the latch → `expected 1 times, but got 2
times`; deleting the `finally` → latch-stuck; injecting
`maxConcurrentMerges: 2` → fails naming the key; injecting a
`maxParallelLanes` merge-trait field → fails naming the field. Sources
restored byte-identical after each injection.
## 2. `worktreesEnabled` — off means the worktree limit cannot bind
No worktrees-off mode existed (no
`worktreesEnabled`/`useWorktrees`/`worktreeMode` anywhere — only
worktree *configuration*).
**Why not `maxWorktrees: 0`, which needs no new key:** it deadlocks. `??
4` keeps `0` (not nullish), the gate is `used >= limit`, so `0 >= 0`
holds **on an empty board** and nothing ever dispatches — while the
operator-visible reason reads `gate=maxWorktrees; used=0/0`, a limiter
that looks like it is working while the board is dead. It also needs the
Command Center `{min:1}` clamp relaxed. So `0` costs the gate rewrite
*and* the clamp change *and* encodes a mode as a magic value.
**Off is absence, not a big number.** `resolveWorktreeCapacityLimit`
returns `number | null`; `ConcurrencyGateDiagnostic.maxWorktreesGate` is
now optional, so consulting a worktree limit in OFF mode does not
type-check. A gate holding `Infinity` can start binding again the moment
someone "fixes" a comparison; an absent gate cannot.
That paid for itself immediately: making it nullable surfaced a
**second, independent** worktree gate (`activeWorktrees >= maxWorktrees`
early-return) that a skip-by-convention approach would have missed
silently.
**Scope, deliberately:** this is a statement about *counting*, not
isolation. It does not make concurrent agents safe to share one checkout
and builds nothing toward that — the non-worktree paths that exist today
are fallbacks to the operator’s own tree, one of which caused FN-8600.
**Revert-proof:** a resolver ignoring the flag turns both OFF scheduler
tests red while every ON test stays green — they reuse the *same*
fixture (5 in-progress, limit 4) that pre-existing tests prove blocks,
so the pair moves in opposite directions. Removing `disabled:` reddens
the UI test.
## 3. `maxTriageConcurrent` deleted — it controlled nothing
**Measured: zero enforcement reads.** The only `.maxTriageConcurrent`
reference in the repo was a route echoing it back in `/config`. FN-8453
removed the pool it gated and left the knob shipping in
`DEFAULT_SETTINGS`, the settings type, the section registry, the API
response and six i18n catalogs, doing nothing, for releases.
Historical FNXC comments are **updated, not deleted** — they explain a
real past incident; they now say "planning admission slot" so they stop
implying a live setting. Tombstoned so it cannot return.
`/config` loses a field; safe in-repo since `fetchConfig`’s own return
type never declared it.
---
## Two corrections worth recording
- I earlier reported `maxWorktrees` had **no** Settings UI. Wrong —
`WorktreesSection.tsx:47`; my grep was truncated by `head`. It changed
the placement (toggle beside it, rather than a duplicate key in
Scheduling).
- I planned to assert the queued-reason string is rewritten in OFF mode.
Measured that it is **unreachable**: when `maxConcurrent` binds, the
sweep bails before the per-task reason and logs nothing. The test
asserts absence instead.
Two near-misses caught before commit: a pre-existing FN-7505 guard
caught my *new* key missing a description mapping; and editing i18n via
`json.load/dump` silently dropped unrelated duplicate keys
(`autoUpdateAndRestart` in `fr`) — Python keeps only the last of a
duplicated key. Redone textually, every catalog re-validated.
## Verification
`pnpm lint` clean · core/engine/dashboard/i18n typecheck clean · `pnpm
test:gate` green (309 + 10 + 71) · capacity/worktree suites 11/11 ·
engine merge-invariant + scheduler 45/45 · dashboard settings 114/114.
Rebased onto current main and re-verified.
Nothing was booted at any point.
🤖 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**
* Added a project setting to enable or disable running tasks in
worktrees.
* Disabling worktrees removes worktree capacity limits from task
scheduling.
* The “Max Worktrees” setting is disabled when worktree execution is
turned off.
* **Changes**
* Removed the unused triage concurrency setting from configuration and
dashboard responses.
* Updated scheduling diagnostics and queue messages to reflect disabled
worktree capacity limits.
* Added localized labels and help text for the new setting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a271f1868f |
U10: dashboard renders workflow-resolved columns (6 legacy-vocabulary defects, incl. a silently-disabled open-PR guard) (#2492)
Phase D / **U10** of the workflow-owned-lifecycle program (**R8**). This
unit **blocks U11** (merge Todo into Planning) — the board must render
IR-resolved columns before the column shape can change.
## What was wrong
Six dashboard surfaces answered a column question from the legacy
`COLUMNS` / `VALID_TRANSITIONS` vocabulary rather than the card's own
workflow IR. Each is a defect today, and each is a way U11 would ship
visibly broken.
| # | Surface | Defect |
|---|---|---|
| 1 | Board — All workflows | Appended **every** legacy column id to the
lane union with synthesised flags → a phantom lane for a column no
workflow declares, labelled with the raw id, ordered by the enum index
with an alphabetical tie-break that scrambled a custom workflow's
declared order |
| 2 | ListView | `if (groups[column])` **silently dropped** a row whose
stored column the workflow no longer declares — no lane, no row, no
error |
| 3 | Move menu | A card stranded in an undeclared column got an **empty
move list** — the one surface that could rescue it offered nothing |
| 4 | Task Detail | Header badge rendered the raw stored id;
title/description editing gated on the literal `{triage, todo}` — a
renamed planning lane lost Edit with nothing on screen to explain it |
| 5 | `board-workflows` | The built-in lifecycle label map was an
**override**, not a fallback, so it replaced a name a built-in
deliberately chose |
| 6 | `POST /tasks/:id/move` | The open-PR backward guard used
`COLUMNS.indexOf(...)` → **-1 on any renamed board**, and the guard
treats a negative index as "allow" |
**#6 is the one worth reading twice.** The guard did not start rejecting
the wrong things — it stopped existing. On a renamed board an operator
could drag a card backward out of review with an open GitHub PR,
orphaning it, and nothing failed. This is precisely the "a converted
guard silently stops firing" row in the plan's risk table, reached
through a rename rather than a conversion.
The re-engage copy of that same guard is **deliberately left on the
legacy enum**, with a comment saying why: it is gated on literal
`in-review` / `in-progress` end to end, so converting only its indices
would make it *weaker* (a workflow declaring `in-review` but not
`in-progress` would score -1 and disable it). U5 owns that lane.
## Evidence
**Every fix has a test that fails when the fix is reverted.** With the
six production files stashed and the tests kept, **10 of the 28 tests
fail**:
- Board aggregate: phantom lane present (2)
- ListView: stranded card dropped, desktop **and** mobile (2)
- Move menu: empty move list for a stranded card (1)
- Task Detail: badge shows `staging`, Edit missing in a renamed intake
**and** hold lane (3)
- `board-workflows`: `builtin:lead-generation`'s `triage` renders as
"Planning" (1)
- Move route: backward move between renamed columns **allowed** with an
open PR (1)
The other 18 are regression pins on behaviour that must not change
(default-workflow lane order and labels, legacy `in-review →
in-progress` block, legacy editable columns, forward moves, terminal
PRs).
**Measured, not estimated.** The label-map clobber was quantified
against the built-in IRs actually in tree: **4 column names replaced — 3
case-only variants ("In progress" → "In Progress"), 1 genuine semantic
rename.** Only the rename is a user-visible defect; the fix preserves
the case normalisation rather than churning the default board.
## Surface enumeration (AGENTS.md)
Desktop **and** mobile — the breakpoint is `(max-width: 768px),
(max-height: 480px)`, so landscape phones exceed 768 wide and match on
height. Column states: empty, populated, duplicate id across two
workflows, and a column no workflow declares. Views: single-workflow
lane, All-workflows aggregate, list, move menu, task detail, move route.
## Regression check
Full dashboard suite, both sides of the change:
| | Test Files | Tests |
|---|---|---|
| Before | 41 failed / 1068 | **296 failed** / 21195 |
| After | 42 failed / 1071 | **297 failed** / 21223 |
`+28` total is exactly the tests this change adds. The single failure
delta is `register-model-routes-kimi-k3-supplemental`, which **fails
identically on this branch's base when run in isolation** — shard-order
dependent, unrelated to columns. **Zero regressions attributable to
U10.** The ~296 pre-existing dashboard failures are inherited from main
and are flagged to the coordinator, not touched here.
`pnpm test:gate`, `pnpm lint`, both dashboard typechecks
(`tsconfig.json` and `tsconfig.app.json`), `pnpm smoke:boot`, and `pnpm
check:changesets` are green.
## Not in this unit
- `Board`'s legacy single-lane `COLUMNS.map` fallback still exists.
`MainContent` passes `workflowColumnsEnabled` unconditionally, so it is
unreachable in-app, but proving that is a deletion argument and this is
not a deletion unit — flagging rather than removing.
- `ListView`'s `LEGACY_LIST_COLUMNS` fallback, same reasoning.
- `flagEnabled` on the wire (U2 noted U10 retires it once no client
reads it) — four clients still branch on it; retiring it is a
client-shape change that belongs with U11's shape work.
- `GET /api/tasks?column=` still validates against `COLUMNS`, rejecting
a workflow-declared custom column as a list filter. Server-side filter
surface, not a rendering decision.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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 -->
|
||
|
|
11887625f1 |
test(dashboard): align touch fixture with FloatingWindow (#2459)
## Summary - update the touch-resize browser fixture assertions for New Task's FloatingWindow migration - verify the shared nine touch targets, resize handle ID, and unified geometry persistence key ## Test plan - `corepack pnpm --filter @fusion/dashboard build` - `corepack pnpm --filter @fusion/dashboard exec vitest run --project dashboard-browser-touch --silent=passed-only --reporter=dot src/__tests__/task-modal-touch-resize-browser.test.ts` - `corepack pnpm check:changesets` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Updated tablet touch-resize coverage for task-detail to use the shared southeast resize handle and corrected expected hit-target counts. * Refreshed hit-target detection and drag target assertions after switching the test surface to the task-detail/floating window layout. * Updated persistence/geometry validations to compare the stored unified width/height against the resized panel dimensions. * Reworked desktop vs tablet assertions, including tighter overlay padding checks and standardized shared resize-handle sizing expectations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
d6c917d726 |
feat(dashboard): add view and settings-section enumeration API (#2453)
## Summary Gives external integrations (command palettes, plugin launchers, alternate dashboard shells) a supported way to **discover the host UI** — instead of hardcoding the dashboard's view ids, labels and settings search terms and hand-syncing them on every release. This is the read-only metadata slice of the "constrained by a stable host context and API client" idea in `docs/proposals/2026-07-01-dashboard-theme-plugin-system.md`, and the follow-on to #2415 (theme tokens + overlay layering). Two additions, both inert unless called: | Endpoint | Returns | |---|---| | `GET /api/views` | Every registered built-in view id, in dashboard order — `id`, English `label`, plus optional i18n `labelKey`, legacy `aliases` and `internal` flag. | | `GET /api/settings/sections` | Selectable Settings sections — `id`, `label`, `labelKey`, `scope`, `group`, `keywords`, `searchableKeys`, `advanced`. | Both are read-only, return static project-independent metadata, take no project id, and are mounted inside `createApiRoutes` so they sit behind exactly the same `/api` authentication as every other dashboard route — no more, no less. ## What actually changed — one source of truth The endpoints are the small part. The core of the diff is **collapsing duplicated UI metadata into two shared registries that now drive both the dashboard UI and the API**: - `packages/dashboard/src/shared/dashboard-views.ts` — canonical view ids + English labels + i18n keys + legacy aliases. - `packages/dashboard/src/shared/settings-sections.ts` — canonical settings sections + scope/group/search metadata, with `group` and `advanced` derived from the list's own structure. `LeftSidebarNav`, `SettingsModal` and `useViewState` were rewritten to consume those registries instead of carrying their own copies (net **−230 lines** in `SettingsModal` alone). Edit the registry and the rendered UI and the API move together. ## Drift protection Being precise about what each test can and cannot catch, because "no-drift" claims are easy to overstate: - `left-sidebar-nav-registry-parity.test.tsx` — the one test that catches drift the registry does not already determine. It **renders** the sidebar with a recording `t()` spy and pins each entry's translation key and English fallback to the registry (the sidebar still hardcodes its keys). It also asserts the rendered destination count equals the enrolled id list, so a newly added sidebar view fails until it is enrolled. - `ui-metadata-sync.test.ts` — pins the Settings navigation list, advanced-visibility set, persisted view list, reset-key registry and both endpoint payloads to the registries. Since those consumers are now *derived* from the registries, these assertions mainly guard against a future consumer **re-hardcoding** its own copy. Two of them do stand on their own: each section's served `group` is pinned to the group header it actually renders under, and no published `labelKey` may resolve to a non-leaf i18n node. - `register-ui-metadata-routes.test.ts` — drives the real Express router and asserts each endpoint serves the registry payload verbatim, with no filtering or reshaping. - Exactly two **existing** tests are updated, both for the same reason: they asserted that `SettingsModal.tsx`'s *source text* contains a section literal that now lives in the registry. `VoiceInputSection.modal-visibility.test.tsx` now asserts Voice Input's Basic-mode contract against `SETTINGS_SECTION_METADATA`, and `mcp-documentation.test.ts` reads the registry for the two MCP section ids. No other existing test in the package changes. ## Design notes / decisions for review - **`GET /api/views` returns the full registry, not the live menu.** It includes flag-gated / experimental ids and `internal` (non-navigable) destinations; reachability depends on flags and plugins this endpoint does not evaluate. Documented as "known view ids", not "visible nav entries". - **`labelKey` is optional and best-effort; `label` is the guarantee.** A `labelKey` is published only where the dashboard itself renders that view's title through it. `graph` (labelled from a plugin manifest) and the internal `task-detail` carry none rather than advertise a key that resolves to nothing — and `task-detail` in particular must not point at `taskDetail.title`, which is an occupied i18n *namespace* whose lookup returns an object rather than falling through to a default. A guard test now enforces that. Separately, a few published keys (`nav.ideation`, `nav.importTasks`, `nav.automations`, `pr.view.title`) are the dashboard's real keys but aren't in the shipped catalogs yet because the host supplies their English inline; the docs say plainly that consumers must fall back to `label`. - **`keywords` / `searchableKeys` are explicitly non-contractual.** `searchableKeys` exposes the raw i18n translation-key strings backing a section's searchable copy; values, ordering and presence may change between releases. Documented as best-effort search hints, never stable identifiers. - **Migration is deliberately partial.** The desktop sidebar, Settings navigation and persisted view list now come from the registries; `Header.tsx` and the mobile More sheet still hardcode a few of the same labels. They can still drift from what `GET /api/views` reports; converting them is left to a follow-up so this diff stays reviewable. - **No project scoping, deliberately.** The proposal doc rightly pushes plugin traffic through a project-scoped client — these two endpoints are the exception that proves the rule: they return static registry metadata that is identical for every project, so threading a `projectId` would imply a scoping guarantee that does not exist here. They never touch `getScopedStore` / `TaskStore`. - **Two endpoints rather than one `/api/ui-metadata` envelope.** Views and Settings sections are independent registries with different consumers, and `/settings/sections` sits naturally beside the existing `/settings/*` routes. A consumer that only needs navigation doesn't pay for settings metadata. - **The registry extraction ships with the endpoints rather than as a separate PR.** The registries *are* the mechanism that keeps the API honest — split apart, the first half is a refactor with no observable effect and the second can't land without it. - **Placement:** `packages/dashboard/src/shared/` is a new directory, and these are the first *production* `app/ → src/` imports in the package (today the only one is in `ProviderIcon.test.tsx`). They sit under `src/` because `src/`'s tsconfig cannot import `app/`, so a module both sides consume has nowhere else to go; both registries are dependency-free data leaves, and `vite build` plus `check-no-node-only-core-imports-in-dashboard` confirm the client bundle is unaffected. The considered alternative was `packages/core/src` behind the `dashboard-browser-safe-core-modules.json` allowlist, where `mobile-nav-primary-items.ts` keeps a destination→labelKey table — these stayed out of `core` because they are dashboard-owned UI ids, and because the two tables describe different surfaces (core mirrors the mobile nav's `nav.skills`/`nav.settings`; this registry mirrors the desktop sidebar's `header.skillsView`/`header.settings`). - Ships a `@runfusion/fusion` **minor** changeset (`category: feature`). Happy to adjust any of the above — shape, placement, or dropping `searchableKeys` — if you'd rather it landed differently. ## Verification - Rebased onto `main@26dcccb7c`. Two conflicts, both resolved by absorbing upstream's work rather than reverting it: - `SettingsModal.tsx` — upstream's `voice-input` section (and the `FNXC:VoiceInput` decision comment explaining it stays out of the advanced-only set) moved into the registry. The registry's section list is byte-identical to `main`'s `SETTINGS_SECTIONS` (45/45 entries, all fields), and the registry-derived `ADVANCED_SETTINGS_SECTION_IDS` is identical to `main`'s hardcoded set (19/19, same order) — both verified mechanically, not by eye. Upstream's `RUNTIME_*` hide-uninstalled-runtimes sets are untouched. - `routes/README.md` — the `mount-sequence` list regenerated from `CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE`, so `registerVoiceRoutes` and `registerUiMetadataRoutes` are both in place and the contract test passes. - `DASHBOARD_VIEWS` covers exactly `main`'s `BuiltInTaskView` union, aliases included, and `BUILT_IN_TASK_VIEWS` reproduces `main`'s 27-entry array in order (`devserver` still preceding `dev-server` for the migration path). - Every one of the 20 sidebar labels the refactor rewrote was checked to be byte-identical to `main`'s hardcoded fallback, and every `FNXC:` decision comment displaced by the move was accounted for — all 75 in `SettingsModal.tsx` and all 11 in `useViewState.ts` survive, relocated onto the registry entries they document. - The full `dashboard-app` + `dashboard-api` suites were run at this commit (**20,706 passing**) and again on unmodified `main@26dcccb7c`, and the failing-file sets compared: **every file that fails here also fails on `main`** — nothing regresses. The overlap is environment-driven (Postgres-backed `*.pg.test.ts`, tests needing built `dist` artifacts, and `SettingsModalNodeRouting.test.tsx`'s `No "fetchSystemInfo" export is defined on the "../../api" mock`), none of it touched by this change. - `tsc --noEmit` clean for both dashboard projects, `eslint` clean on every changed file, and `vite build` of the client bundle succeeds (the two pre-existing `@fusion-plugin-examples/claude-runtime` / `playwright-core` module-resolution errors reproduce on unmodified `main`). - Repo gate scripts pass: `check-changeset-format`, `check-routes-modular`, `check-no-node-only-core-imports-in-dashboard`, `check-no-cwd-relative-dashboard-test-reads`, `check-mock-completeness`. - The three new assertions were mutation-tested rather than assumed load-bearing: breaking the registry's `group` derivation, dropping an enrolled sidebar id, and re-pointing `task-detail` at the `taskDetail.title` namespace each make their test fail. - Local CodeRabbit review over two passes: 3 minor findings, all addressed (parity projection missing `group`; route tests asserting partial instead of exact payloads; the `labelKey` guard not covering the settings registry). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added authenticated, read-only APIs for discovering dashboard views and selectable Settings sections. * Added dashboard view metadata, including labels, aliases, internal status, and translation keys. * Added Settings metadata with grouping, scope, advanced status, and search-related information. * Updated navigation and Settings UI labels to use shared metadata. * **Documentation** * Documented the new metadata endpoints and integration guidance. * **Bug Fixes** * Added safeguards and automated checks to keep UI navigation and API metadata synchronized. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com> |