fix(events): carry lanes on the archive and completion task:moved emits (#3120)

Follow-up to **#3109** (merged). Independent of my other branches.

## The gap

#3109 put resolved lanes on `task:moved` and wired the main move path in
`moves.ts`. **Two other emit paths still fired lane-less** — and to a
listener, a lane-less emit is not a *missing* answer, it is the
**legacy** answer, which on a renamed board is wrong.

Concretely: `archiveTaskBackendImpl` emits its own `task:moved`. The
executor's archive branch releases the task's active-session registry
entry, and **that entry is what blocks a successor task from acquiring
the same path**. Fixing the listener alone (#3112) leaves the leak
reachable *through this emitter*, because the listener still falls back
to the literal when the payload carries nothing.

That's the part worth noting for the pattern generally: **a
payload-carrying design is only as good as its emitters.** Converting
consumers without sweeping producers leaves a hole that looks fixed at
the call site.

## Scope

| Emit path | Action |
|---|---|
| `archive-lifecycle-2.ts:373` (`archiveTaskBackendImpl`) | carries
lanes |
| `task-artifacts-ops.ts:549` (`moveToDoneImpl`) | carries lanes |
| `lifecycle-ops.ts:715` | **untouched** — fires from a watcher callback
|
| `task-update.ts:962` | **untouched** — sync path |

Both converted sites are already `async` and already import
`resolveWorkflowIrForTask`, so this costs one IR read on a transition
that has just done database work. Fail-soft to `undefined`, matching
`moves.ts` — "unknown", never a wrong answer.

The two untouched ones each need their own look rather than a blanket
sweep; flagged, not guessed.

## Verification

- `@fusion/core` archive/lifecycle suites — **156 green**
- **`pnpm test:gate` green**; `tsc` clean
- Changeset added; `check:changesets` passes

## Census

**No change**, and that's expected — this converts emit *payloads*, not
comparison literals. The effect is that guards already converted in
#3109/#3112 receive a correct answer on these paths instead of a legacy
fallback.

🤖 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**
* Task move events now include accurate board lane information when
tasks are archived or marked complete.
* Improved handling for renamed workflows, ensuring task transitions use
the correct lane details.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 05:29:53 -07:00
committed by GitHub
parent c5d5978a01
commit c38d784b88
3 changed files with 33 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Archive and completion transitions now report the board's own lanes to engine listeners.
category: fix
dev: `archiveTaskBackendImpl` and `moveToDoneImpl` attach `lanes` to their `task:moved` emits, matching `moves.ts`.

View File

@@ -9,6 +9,7 @@
import {TaskStore, storeLog} from "../store.js";
import { columnsWithFlag, declaresAnyLifecycleTrait } from "../workflow-lifecycle-traits.js";
import { resolveWorkflowIrForTask } from "../workflow-ir-resolver.js";
import { toTaskMoveLanes } from "../workflow-lifecycle-traits.js";
import {getFeatureByTaskId as getMissionFeatureByTaskId, unlinkFeatureFromTaskId as unlinkMissionFeatureFromTaskId, recordGeneratedFixOperatorStop} from "../async-mission-store-queries.js";
import {TaskHasLineageChildrenError, TaskNotFoundError, TaskSelfDeleteError} from "./errors.js";
import {mkdir, writeFile} from "node:fs/promises";
@@ -370,7 +371,18 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
task.updatedAt = archivedAt;
task.deletedAt = archivedAt;
store.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" });
/*
FNXC:WorkflowEvents 2026-07-31-00:40 (fleet):
Carry the resolved lanes, like the main move path in `moves.ts`. Listeners read `task:moved`
synchronously and cannot resolve for themselves, so an emit WITHOUT lanes hands every consumer
its legacy fallback — which on a renamed board is the wrong answer, not a missing one.
Concretely: the executor's archive branch releases the task's active-session registry entry, and
that entry is what blocks a SUCCESSOR task from acquiring the same path. Emitting this transition
lane-less left that leak reachable through this path even after the listener itself was fixed.
*/
const movedLanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined));
store.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine", lanes: movedLanes });
// Best-effort near-duplicate cleanup.
await store.clearNearDuplicateReferencesToFailSoft(id, {

View File

@@ -13,6 +13,7 @@ import { TaskStore } from "../store.js";
import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js";
import {declaresAnyLifecycleTrait, resolveReviewColumns, resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js";
import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js";
import {toTaskMoveLanes} from "../workflow-lifecycle-traits.js";
import { countAgentLogEntries, readAgentLogEntries } from "../agent-log-file-store.js";
import { toJsonNullable } from "../db.js";
import { DbTransaction, recordRunAuditEventWithinTransaction } from "../postgres/data-layer.js";
@@ -563,7 +564,18 @@ export async function moveToDoneImpl(store: TaskStore, task: Task, dir: string):
// Update cache if watcher is active
if (store.isWatching) store.taskCache.set(task.id, { ...task });
store.emit("task:moved", { task, from: fromColumn, to: completeColumn as Column, source: "engine" });
/*
FNXC:WorkflowEvents 2026-07-31-00:40 (fleet):
Carry the resolved lanes, like the main move path in `moves.ts`. Listeners read `task:moved`
synchronously and cannot resolve for themselves, so an emit WITHOUT lanes hands every consumer
its legacy fallback — which on a renamed board is the wrong answer, not a missing one.
Concretely: the executor's archive branch releases the task's active-session registry entry, and
that entry is what blocks a SUCCESSOR task from acquiring the same path. Emitting this transition
lane-less left that leak reachable through this path even after the listener itself was fixed.
*/
const movedLanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined));
store.emit("task:moved", { task, from: fromColumn, to: completeColumn as Column, source: "engine", lanes: movedLanes });
}
export function clearDoneTransientFieldsImpl(store: TaskStore, task: Task): boolean {