## 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>
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>
Post at most one in-progress comment per Fusion task while keeping failed deliveries retryable. Persist a durable marker and fall back to the task log when local marker storage fails after GitHub accepts the comment.
FN-7575 (issue #1916) added "Current version:" / "Target release:" lines to
GitHubIssueCommentService, but that service is gated on `githubCommentOnDone`
— default false, with no Settings UI — so it effectively never fires. The
"✅ Done —" comments on linked issues are posted by GitHubTrackingCommentService,
which had no version logic. The lines were invisible in production for ~10 days;
issue #1916's own close comment is the proof.
Extract the self-repo check and next-minor computation into a shared
fusion-release-version.ts and apply it across all four done-comment surfaces
(GitHub/GitLab x tracking/issue) so they cannot drift again.
- Release lines join `optionalLines` rather than being appended to the finished
string, so they count against DONE_COMMENT_MAX_LENGTH and shrink the title
budget; appending would silently blow the cap on long titles.
- Version resolution is a lazy resolver, so getCliPackageVersion()'s filesystem
walk only runs for self-repo comments.
- GitLab self-repo matching uses item.projectPath: resolveGitLabTargetFromItem()
prefers the numeric projectId, which never matches the slug.
- Non-self repos stay byte-for-byte unchanged (asserted).
Per the Surface Enumeration rule, regression tests assert the invariant across
every done-comment surface — both the pure formatters and the services that
post — plus case-insensitive slug matching (issue #1916 is "Runfusion/Fusion"),
in-progress transitions, the 0.0.0 sentinel, unparseable versions, the
lazy-resolution guarantee, and the truncation ladder under the length cap.
Verified non-vacuous: 10 of the new tests fail against the pre-fix source.
Fusion-Task-Id: FN-7575
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes a race condition where task deletion could race against move-related GitHub tracking logs. Both `GitHubTrackingCommentService` and `GitHubTrackingStateService.handleTaskMoved` are hardened to gracefully swallow deleted-task races, with a regression test covering the delete-after-move scenario.
Fusion-Task-Id: FN-5245
Adds GitHub tracking title derivation so tasks can display meaningful titles when PR/issue tracking data is incomplete, falling back to the task description. The core implementation lives in `github-tracking.ts` with expanded test coverage across the tracking suite, and the feature is wired into pla
Fusion-Task-Id: FN-4148
Fusion-Task-Lineage: d57c6279-1b4f-4ade-9c3f-e20ac391ea45
Implements GitHub tracking comments for done tasks (FN-4142), extending the existing tracking comment system in the dashboard to emit comments when tasks complete, with corresponding tests and documentation.
Fusion-Task-Id: FN-4142
Implements a tracking auth resolver with forced GitHub client authentication mode, wiring it across the GitHub tracking lifecycle and settings UI. The feature spans six steps: adding the resolver, forced auth mode, routing tracking issue creation through the resolver, and wiring into lifecycle and s
Fusion-Task-Id: FN-3875
Added a GitHub tracking comments service that posts automated comments to GitHub PRs, with a full test suite and documentation. The service is wired into the GitHub registration flow and ships as a changeset patch.
Fusion-Task-Id: FN-3872