diff --git a/packages/core/src/__tests__/unarchive-target-lanes.test.ts b/packages/core/src/__tests__/unarchive-target-lanes.test.ts new file mode 100644 index 0000000000..cd5d2de26d --- /dev/null +++ b/packages/core/src/__tests__/unarchive-target-lanes.test.ts @@ -0,0 +1,101 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-19:25 (PR #2742 review — greptile P1, my third `?? legacy` slip): + +THE INVARIANT: the unarchive destination is a column the board DECLARES, or the restore refuses. + +`unarchiveTaskImpl` writes this destination DIRECTLY to the row — it bypasses `moveTask` on purpose, because +the transition graph only allows archived→done and a restore needs to reach any column. So unlike every +converted `moveTask` call site in this program, there is no unknown-column validation behind it: an invented +column is PERSISTED, the card renders nowhere, and no lifecycle guard can find it. + +THE RULE THIS PINS, stated for the third time in one session because I keep needing it: + `?? legacyId` is correct only when the resolver returned NOTHING. + A resolved struct with a missing field is an ANSWER — "this board has no such lane" — and `??` discards it. + +The two cases are asserted separately, because a fix that refuses in both would break every legacy board and a +fix that defaults in both is the bug. +*/ +import { describe, expect, it, vi } from "vitest"; +import type { TaskStore, WorkflowIr } from "../types.js"; + +import { resolveUnarchiveTargetColumnImpl } from "../task-store/task-artifacts-ops.js"; + +function storeWith(ir: WorkflowIr | undefined): TaskStore { + const selection = { workflowId: "wf", stepIds: [] as string[] }; + return { + getTaskWorkflowSelection: () => (ir ? selection : undefined), + getTaskWorkflowSelectionAsync: async () => (ir ? selection : undefined), + getWorkflowDefinition: async () => (ir ? { ir } : undefined), + } as unknown as TaskStore; +} + +const ir = (columns: Array<{ id: string; traits: Array<{ trait: string }> }>) => ({ + version: "v2", id: "wf", name: "wf", nodes: [{ id: "s", kind: "start", column: columns[0]!.id }], edges: [], + columns, +} as unknown as WorkflowIr); + +const FULL = ir([ + { id: "backlog", traits: [{ trait: "intake" }] }, + { id: "queued", traits: [{ trait: "hold" }] }, + { id: "building", traits: [{ trait: "wip" }] }, + { id: "signoff", traits: [{ trait: "merge" }] }, + { id: "shipped", traits: [{ trait: "complete" }] }, + { id: "filed", traits: [{ trait: "archived" }] }, +]); + +describe("the unarchive destination is a declared column or a refusal", () => { + it("restores an unusable pre-archive column to the board's COMPLETE lane", async () => { + expect(await resolveUnarchiveTargetColumnImpl(storeWith(FULL), "archived", "FN-1")).toBe("shipped"); + }); + + it("restores a card archived from the WIP lane to the board's HOLD lane", async () => { + /* + The expensive case: with the literal this returned `todo`, and on a renamed board the card went straight + back into the wip lane with no worktree — where the scheduler counts it as a live holder occupying a slot. + */ + expect(await resolveUnarchiveTargetColumnImpl(storeWith(FULL), "building", "FN-1")).toBe("queued"); + }); + + it("restores anything else exactly where it was", async () => { + expect(await resolveUnarchiveTargetColumnImpl(storeWith(FULL), "backlog", "FN-1")).toBe("backlog"); + }); + + it("REFUSES when the board declares no complete lane", async () => { + // Pre-fix: returned `done`, which unarchiveTaskImpl then wrote directly to a board without that column. + const noComplete = ir([ + { id: "backlog", traits: [{ trait: "intake" }] }, + { id: "queued", traits: [{ trait: "hold" }] }, + ]); + + await expect(resolveUnarchiveTargetColumnImpl(storeWith(noComplete), "archived", "FN-1")) + .rejects.toThrow(/declares no complete column/); + }); + + it("REFUSES when the board declares no hold lane and the card was mid-flight", async () => { + const noHold = ir([ + { id: "backlog", traits: [{ trait: "intake" }] }, + { id: "building", traits: [{ trait: "wip" }] }, + { id: "shipped", traits: [{ trait: "complete" }] }, + ]); + + await expect(resolveUnarchiveTargetColumnImpl(storeWith(noHold), "building", "FN-1")) + .rejects.toThrow(/declares no hold column/); + }); + + it("keeps the LEGACY answers when there is no lane information at all", async () => { + /* + The other half of the rule: a v1 IR or an unresolvable store has told us nothing, so today's behaviour is + correct. A blanket refusal would pass the two cases above and break every legacy board. + */ + const legacy = storeWith(undefined); + + expect(await resolveUnarchiveTargetColumnImpl(legacy, "archived", "FN-1")).toBe("done"); + expect(await resolveUnarchiveTargetColumnImpl(legacy, "in-progress", "FN-1")).toBe("todo"); + expect(await resolveUnarchiveTargetColumnImpl(legacy, "in-review", "FN-1")).toBe("todo"); + expect(await resolveUnarchiveTargetColumnImpl(legacy, "todo", "FN-1")).toBe("todo"); + }); + + it("keeps the legacy answers when no taskId is supplied, so an un-updated caller is unchanged", async () => { + expect(await resolveUnarchiveTargetColumnImpl(storeWith(FULL), "in-progress")).toBe("todo"); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2f84c0aa3f..fba0868bab 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -2072,8 +2072,11 @@ export class TaskStore extends EventEmitter { async archiveTaskAndCleanup(id: string): Promise { return this.archiveTask(id, true); } - public resolveUnarchiveTargetColumn(preArchiveColumn: unknown): Column { - return resolveUnarchiveTargetColumnImpl(this, preArchiveColumn); + /* FNXC:WorkflowLifecycleColumns 2026-08-02-16:30 (fleet): async because the restore destination is resolved + from the task's workflow; `taskId` is optional so any caller that has not been updated keeps the legacy + answer rather than silently resolving the wrong board. */ + public resolveUnarchiveTargetColumn(preArchiveColumn: unknown, taskId?: string): Promise { + return resolveUnarchiveTargetColumnImpl(this, preArchiveColumn, taskId); } public async readPreArchiveColumnFromTaskFile(dir: string): Promise { return readPreArchiveColumnFromTaskFileImpl(this, dir); diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index 8f6ac80ece..9a022a9ee1 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -397,7 +397,7 @@ export async function unarchiveTaskImpl(store: TaskStore, id: string): Promise the COMPLETE lane (a restored card with no history is finished work); + - it was mid-flight (wip or review) -> the HOLD lane, because its worktree and session are long gone; + - otherwise -> back where it was. + +On a renamed board the first fell back to `done` (a column that may not exist), the second never matched — so +a card archived FROM the wip lane was restored straight back INTO it with no worktree, which the scheduler +then treats as a live holder occupying a slot. That is the worst of the three: it does not just misfile the +card, it consumes capacity. + +ASYNC because the answer needs the workflow. Its one production caller (`archive-lifecycle-2`'s unarchive) is +already async, and the sync alternative is the PostgreSQL no-op documented in #2703. `taskId` is now required +so the lanes can be resolved for the card being restored rather than for the board in general. +*/ +export async function resolveUnarchiveTargetColumnImpl( + store: TaskStore, + preArchiveColumn: unknown, + taskId?: string, +): Promise { + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-19:10 (PR #2742 review — greptile P1, and this is the THIRD time + I have made this exact mistake in one session): + `?? legacyId` IS ONLY CORRECT WHEN THE RESOLVER RETURNED NOTHING. My first version wrote + `lifecycle?.complete ?? "done"` and `lifecycle?.hold ?? "todo"`, so a workflow that resolves but declares + no complete (or no hold) lane got an UNDECLARED column — and `unarchiveTaskImpl` writes this destination + DIRECTLY to the row, bypassing moveTask's unknown-column validation. That persists a column the board does + not have; the card then renders nowhere and no guard can find it. + + The rule, stated for the third time because I keep needing it: a resolved struct with a MISSING FIELD is an + answer — "this board has no such lane" — and `?? legacy` discards exactly that answer. The two cases are: + - lifecycle undefined (v1 IR / unresolvable): the legacy ids ARE the answer. + - lifecycle resolved, field absent: refuse. Restoring into an invented column is worse than refusing to + restore, because the refusal is visible and the invented column is not. + + Earlier occurrences: #2733 (`applyPrMergedTransition`'s move target) and moveToDoneImpl in this same PR. + */ + const lifecycle = taskId ? await resolveTaskLifecycleColumns(store, taskId) : undefined; + /* The board's declared ids, for the "is this pre-archive column usable?" test below. */ + const declaredColumnIds = new Set( + taskId && lifecycle + ? ((await resolveWorkflowIrForTask(store, taskId).catch(() => undefined)) as { columns?: Array<{ id: string }> } | undefined) + ?.columns?.map((column) => column.id) ?? [] + : [], + ); + const completeColumn = (lifecycle ? lifecycle.complete : "done") as Column | undefined; + const holdColumn = (lifecycle ? lifecycle.hold : "todo") as Column | undefined; + const wipColumn = lifecycle?.wip ?? "in-progress"; + const reviewColumn = lifecycle?.review ?? "in-review"; + const archivedColumn = lifecycle?.archived ?? "archived"; + + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-19:45 (found by my OWN test, and it is a bigger defect than the + one I came here to fix): + `isColumn` TESTS THE CLOSED LEGACY ENUM, so on a renamed board it rejects every declared column id — and + this branch then treats a perfectly good pre-archive column as "unusable" and restores the card to the + COMPLETE lane. Every restore on a renamed board landed in Done, whatever the card was doing when it was + archived. + + My lane conversion could not have helped while this stood: I converted the comparisons below and the + branch above them still swallowed every renamed id first. That is the half-conversion shape this program + keeps finding, in my own work, one line up from the part I was looking at — which is the argument for + reading the whole function rather than the sites the census points at. + + `isColumn` is correct for legacy ids and its own doc says workflow-scoped validity belongs to + `workflowHasColumn`. Accepting a column the RESOLVED workflow declares, and falling back to the enum when + there is no workflow, keeps both boards right. + */ + const declaresPreArchiveColumn = typeof preArchiveColumn === "string" + && (lifecycle !== undefined + ? declaredColumnIds.has(preArchiveColumn) + : isColumn(preArchiveColumn)); + if (!declaresPreArchiveColumn || preArchiveColumn === archivedColumn || preArchiveColumn === "archived") { + if (completeColumn === undefined) { + throw new Error(`Cannot resolve an unarchive target${taskId ? ` for ${taskId}` : ""}: its workflow declares no complete column`); + } + return completeColumn; } - if (preArchiveColumn === "in-progress" || preArchiveColumn === "in-review") { - return "todo"; + if (preArchiveColumn === wipColumn || preArchiveColumn === reviewColumn) { + if (holdColumn === undefined) { + throw new Error(`Cannot resolve an unarchive target${taskId ? ` for ${taskId}` : ""}: its workflow declares no hold column`); + } + return holdColumn; } - return preArchiveColumn; + return preArchiveColumn as Column; } export async function readPreArchiveColumnFromTaskFileImpl(store: TaskStore, dir: string): Promise { try { const raw = await readFile(join(dir, "task.json"), "utf-8"); const parsed = JSON.parse(raw) as { preArchiveColumn?: unknown }; - return isColumn(parsed.preArchiveColumn) ? parsed.preArchiveColumn : undefined; + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-19:55 (the same `isColumn` defect, one function over): + `isColumn` TESTS THE CLOSED LEGACY ENUM, so a renamed board's stored `preArchiveColumn` was DROPPED on + read — the restore then saw `undefined` and treated the card as having no usable history. Two legacy-enum + gates in one path, both upstream of the lane comparisons I came here to convert. + + A stored pre-archive column is DATA, not a claim: whatever id the row carries is what the card was in when + it was archived, and validating it against a closed enum is what loses renamed boards' history. The + destination resolver downstream decides whether the id is still usable, and now does so against the + workflow's declared columns. + */ + return typeof parsed.preArchiveColumn === "string" && parsed.preArchiveColumn.length > 0 + ? parsed.preArchiveColumn as Column + : undefined; } catch { return undefined; } } export async function moveToDoneImpl(store: TaskStore, task: Task, dir: string): Promise { - if (task.column === "done") { + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-16:10 (fleet: the archive/complete writer): + THE GUARD, THE WRITE AND THE EVENT are one decision and now share one snapshot. This function writes + `task.column` DIRECTLY (it is the store's own finaliser, not a moveTask caller), so a literal here is not + caught by moveTask's unknown-column validation the way every converted call site is — it silently persists + a column the board does not declare, and then emits `to: "done"` to every listener. + + That makes this one of the few sites where a literal writes bad state rather than failing to act. On a + renamed board: the already-complete short-circuit never fired (so the finaliser re-ran and re-stamped + executionCompletedAt), the row was written to `done` instead of the board's completion column, and the + event told the GitHub tracking poster and the auto-merge handoff about a column that does not exist. + + A workflow that declares columns but NO complete lane refuses rather than inventing one — the distinction + #2733 settled: a missing field on a resolved struct is an answer, and `?? legacy` discards it. + */ + const completeLifecycle = await resolveTaskLifecycleColumns(store, task.id); + const completeColumn = completeLifecycle ? completeLifecycle.complete : "done"; + if (completeColumn === undefined) { + throw new Error(`Cannot move ${task.id} to a completion column: its workflow declares none`); + } + if (task.column === completeColumn) { return; } @@ -346,7 +459,7 @@ export async function moveToDoneImpl(store: TaskStore, task: Task, dir: string): throw new Error(`Cannot move ${task.id} to done: ${mergeBlocker}`); } - task.column = "done"; + task.column = completeColumn; store.clearDoneTransientFields(task); task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; @@ -359,7 +472,7 @@ 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: "done" as Column, source: "engine" }); + store.emit("task:moved", { task, from: fromColumn, to: completeColumn as Column, source: "engine" }); } export function clearDoneTransientFieldsImpl(store: TaskStore, task: Task): boolean { diff --git a/packages/engine/src/__tests__/planning-continuation-terminal-lanes.test.ts b/packages/engine/src/__tests__/planning-continuation-terminal-lanes.test.ts new file mode 100644 index 0000000000..27ae7da501 --- /dev/null +++ b/packages/engine/src/__tests__/planning-continuation-terminal-lanes.test.ts @@ -0,0 +1,70 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-16:50 (fleet: the planning-continuation drain): + +THE INVARIANT: a due planning work item whose task is TERMINAL is an orphan to cancel, and "terminal" is the +task's own board. + +WHY THIS ONE IS NOT LOCAL. FN-8470's own note on this drain says it: one orphan earlier in created_at FIFO +prevented every later planning continuation from dispatching. So on a renamed board the literal pair did not +merely mis-handle one card — an archived or completed card's stale work item read as live, stayed in the due +set, and starved the drain behind it. A single stale row stops planning for the whole project. + +THE OPTIONAL SET IS THE POINT of the design, and both halves are asserted: omitting it keeps the legacy pair +(every existing caller and test relies on that), supplying it makes the renamed board work. A fix that +required the set would have broken every current caller silently — they would compile and answer "not +terminal" for everything. +*/ +import { describe, expect, it } from "vitest"; +import type { Task } from "@fusion/core"; + +import { + isPlanningContinuationTaskDispatchable, + resolvePlanningContinuationCandidate, +} from "../runtimes/in-process-runtime.js"; + +const ITEM = { id: "wi-1", taskId: "FN-1", waitReason: "planning", state: "runnable" } as never; + +function task(column: string): Task { + return { id: "FN-1", column, dependencies: [], steps: [], currentStep: 0 } as unknown as Task; +} + +const RENAMED_TERMINAL = new Set(["shipped", "filed", "done", "archived"]); + +describe("the planning-continuation drain resolves terminal from the board", () => { + it("treats a renamed board's COMPLETE card as terminal", () => { + // Pre-fix: `shipped` matched neither literal, so this item stayed live and starved the FIFO behind it. + expect(isPlanningContinuationTaskDispatchable(task("shipped"), RENAMED_TERMINAL)).toBe(false); + expect(resolvePlanningContinuationCandidate(ITEM, task("shipped"), { terminalColumns: RENAMED_TERMINAL })) + .toMatchObject({ kind: "orphan", reason: "task-terminal" }); + }); + + it("treats a renamed board's ARCHIVED card as terminal", () => { + expect(isPlanningContinuationTaskDispatchable(task("filed"), RENAMED_TERMINAL)).toBe(false); + }); + + it("still dispatches a card that is NOT terminal on that board", () => { + // The paired positive: the guard must not turn into "nothing is dispatchable". + expect(isPlanningContinuationTaskDispatchable(task("building"), RENAMED_TERMINAL)).toBe(true); + expect(resolvePlanningContinuationCandidate(ITEM, task("building"), { terminalColumns: RENAMED_TERMINAL })) + .toMatchObject({ kind: "actionable" }); + }); + + it("keeps the LEGACY pair when no set is supplied", () => { + /* + The compatibility half, and the reason the parameter is optional rather than required: every existing + caller and test omits it. A required parameter would have compiled and then answered "not terminal" for + everything, which is the silent direction. + */ + expect(isPlanningContinuationTaskDispatchable(task("done"))).toBe(false); + expect(isPlanningContinuationTaskDispatchable(task("archived"))).toBe(false); + expect(isPlanningContinuationTaskDispatchable(task("in-progress"))).toBe(true); + // And a renamed terminal column is NOT recognised without the set — which is exactly why the runtime + // wires a resolver at the call site. + expect(isPlanningContinuationTaskDispatchable(task("shipped"))).toBe(true); + }); + + it("still orphans a lookup failure regardless of lanes", () => { + expect(resolvePlanningContinuationCandidate(ITEM, undefined, { taskLookupFailed: true })) + .toMatchObject({ kind: "orphan", reason: "task-not-found" }); + }); +}); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 3dd0edda76..a02fae2a4c 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -25,6 +25,7 @@ import { isEphemeralAgent, isTaskBlockedOnApproval, resolveWorkflowIrForTask, + resolveTaskLifecycleColumns, } from "@fusion/core"; import { Scheduler } from "../scheduler.js"; import type { PrMonitor, PrComment } from "../pr-monitor.js"; @@ -116,16 +117,36 @@ export interface PlanningContinuationCandidate { * non-dispatchable so their orphaned work items can be cancelled instead of * blocking later due rows (FN-8470 tombstone starved FN-8471 plan-review). */ +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-15:10 (fleet: the planning-continuation drain): +THE TERMINAL PAIR ARRIVES FROM THE CALLER, matching this file's OWN injection idiom — the +specification-complete reaction already takes a `resolveIr` dependency for exactly this reason (the +classifiers are exported so they can be tested without constructing a runtime, which would attach to the real +project registry). + +These two classifiers decide whether a due planning work item is DISPATCHABLE or an ORPHAN to cancel. Spelled +as the default lineage's ids, a renamed board answered "not terminal" for every finished card — so an +archived or completed card's orphaned work item was treated as live and, per FN-8470's own note, ONE orphan +earlier in created_at FIFO prevented every later planning continuation from dispatching. The failure is not +local: one stale item starves the whole drain. + +Optional and defaulting to the legacy pair, so every existing caller and test is unchanged. +*/ export function isPlanningContinuationTaskDispatchable( task: Task | null | undefined, + terminalColumns?: ReadonlySet, ): task is Task { if (task == null) return false; if (task.paused === true || task.userPaused === true) return false; if (task.deletedAt) return false; - if (task.column === "archived" || task.column === "done") return false; + const terminal = terminalColumns ?? LEGACY_TERMINAL_PAIR; + if (terminal.has(task.column)) return false; return true; } +/** The terminal ids from before workflows owned the vocabulary; the fallback when no set is supplied. */ +const LEGACY_TERMINAL_PAIR: ReadonlySet = new Set(["done", "archived"]); + /** Outcome of resolving one due work item for the planning-continuation drain. */ export type PlanningContinuationResolution = | { kind: "actionable"; item: WorkflowWorkItem; task: Task } @@ -145,12 +166,13 @@ export type PlanningContinuationResolution = export function resolvePlanningContinuationCandidate( item: WorkflowWorkItem, task: Task | null | undefined, - opts?: { taskLookupFailed?: boolean }, + opts?: { taskLookupFailed?: boolean; terminalColumns?: ReadonlySet }, ): PlanningContinuationResolution { if (opts?.taskLookupFailed === true || task == null) { return { kind: "orphan", item, reason: "task-not-found" }; } - if (task.deletedAt || task.column === "archived" || task.column === "done") { + const terminal = opts?.terminalColumns ?? LEGACY_TERMINAL_PAIR; + if (task.deletedAt || terminal.has(task.column)) { return { kind: "orphan", item, reason: "task-terminal" }; } if (item.waitReason !== "planning") { @@ -306,6 +328,8 @@ export async function reactToSpecificationComplete( export interface DuePlanningContinuationDrainDeps { listDue: () => Promise; getTask: (taskId: string) => Promise; + /** The task's own terminal columns; omitted in tests and legacy callers, which keep the legacy pair. */ + resolveTerminalColumns?: (taskId: string) => Promise>; cancelOrphan: ( item: WorkflowWorkItem, reason: "task-not-found" | "task-terminal", @@ -356,7 +380,10 @@ export async function drainDuePlanningContinuations( }`, ); } - const resolved = resolvePlanningContinuationCandidate(item, task, { taskLookupFailed }); + const terminalColumns = taskLookupFailed + ? undefined + : await deps.resolveTerminalColumns?.(item.taskId).catch(() => undefined); + const resolved = resolvePlanningContinuationCandidate(item, task, { taskLookupFailed, terminalColumns }); if (resolved.kind === "orphan") { await deps.cancelOrphan(resolved.item, resolved.reason); continue; @@ -1127,8 +1154,23 @@ export class InProcessRuntime void (async () => { try { const latest = await this.taskStore.getTask(task.id); - if (latest?.column === "in-progress") { - await this.taskStore.moveTask(task.id, "todo"); + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-15:30 (fleet — GUARD AND DESTINATION together): + A mission task that errored is requeued from the WIP lane back to the HOLD lane. Both ends were + literals, so on a renamed board the guard never matched and the requeue never happened — the + errored mission task stayed in the wip lane holding a slot, which is worse than a requeue that + fails loudly. + + Converting the guard alone would be worse still: it would admit the card and then move it to a + `todo` the board may not declare, `moveTask` rejects an unknown column, and the task stays put + with an exception in the log. A board that declares no hold lane keeps the card in place + deliberately — the same outcome it has today. + */ + const requeueLifecycle = await resolveTaskLifecycleColumns(this.taskStore, task.id); + const requeueWip = requeueLifecycle?.wip ?? "in-progress"; + const requeueHold = requeueLifecycle ? requeueLifecycle.hold : "todo"; + if (latest?.column === requeueWip && requeueHold !== undefined) { + await this.taskStore.moveTask(task.id, requeueHold as never); } } catch (moveErr) { runtimeLog.warn(`Failed to requeue mission task ${task.id} after error:`, moveErr); @@ -2249,6 +2291,19 @@ export class InProcessRuntime limit: DUE_PLANNING_CONTINUATION_BATCH_LIMIT, }), getTask: (taskId) => Promise.resolve(this.taskStore.getTask(taskId)), + /* FNXC:WorkflowLifecycleColumns 2026-08-02-15:20 (fleet): the PRODUCTION resolver for the drain's + terminal check — the pure pass keeps the legacy pair when this is omitted, which is what every + existing test relies on. One IR read per due item, and the batch is capped by + DUE_PLANNING_CONTINUATION_BATCH_LIMIT. */ + resolveTerminalColumns: async (taskId) => { + const lifecycle = await resolveTaskLifecycleColumns(this.taskStore, taskId); + return new Set([ + lifecycle?.complete ?? "done", + lifecycle?.archived ?? "archived", + "done", + "archived", + ]); + }, cancelOrphan: (item, reason) => this.cancelOrphanedWorkflowWorkItem(item, reason), defer: (deferral) => this.deferParkedWorkflowWorkItem(deferral), dispatch: (task, item) => { @@ -2385,13 +2440,31 @@ export class InProcessRuntime // Forward task:moved events this.taskStore.on("task:moved", (data: { task: Task; from: string; to: string }) => { this.recordActivity(); - if (data.to === "archived") { - /* - FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45: - In-process task archival is the retention cutoff for task-local planner chats. Keep interacted planner chats when tasks reach done, but delete exact task-planner sessions on archive through ChatStore so normal conversations and other tasks remain untouched. - */ - void this.chatStore?.deleteSessionsForAgentId(`${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${data.task.id}`, { projectId: this.config.projectId }); - } + /* + FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45: + In-process task archival is the retention cutoff for task-local planner chats. Keep interacted planner chats when tasks reach done, but delete exact task-planner sessions on archive through ChatStore so normal conversations and other tasks remain untouched. + + FNXC:WorkflowLifecycleColumns 2026-08-02-15:50 (fleet): + ARCHIVAL IS THE CUTOFF, and on a renamed board the literal never matched — so task-planner chats were + never deleted on archive. That is the quiet direction of this defect class: nothing breaks, data that + should have been cleaned up simply accumulates, and the only symptom is storage growth nobody attributes + to a column name. + + The resolution is async and this is a sync event handler, so the branch moves inside a `void (async …)` + — the deletion was already fire-and-forget (`void this.chatStore?.…`), so nothing about the handler's + timing contract changes. `data.to` is still accepted when it equals the legacy `archived`, because a row + moved into a column the workflow no longer declares is still archived. + */ + void (async () => { + const archivedLifecycle = await resolveTaskLifecycleColumns(this.taskStore, data.task.id) + .catch(() => undefined); + const archivedColumn = archivedLifecycle?.archived ?? "archived"; + if (data.to !== archivedColumn && data.to !== "archived") return; + await this.chatStore?.deleteSessionsForAgentId( + `${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${data.task.id}`, + { projectId: this.config.projectId }, + ); + })(); this.emit("task:moved", data); }); diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 9791094941..08ada53c99 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -8,13 +8,10 @@ "packages/core/src/task-store/async-comments-attachments.ts": 9, "packages/dashboard/app/components/TaskContextMenu.tsx": 9, "packages/engine/src/notification/notification-service.ts": 9, - "packages/core/src/default-workflow-hooks.ts": 7, "packages/dashboard/app/components/Column.tsx": 7, "packages/core/src/live-agent-count.ts": 6, "packages/core/src/task-merge.ts": 6, - "packages/core/src/task-store/task-artifacts-ops.ts": 6, "packages/dashboard/app/components/ListView.tsx": 6, - "packages/engine/src/runtimes/in-process-runtime.ts": 6, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 5, "packages/engine/src/agent-tools.ts": 5, "packages/engine/src/project-engine.ts": 5, @@ -31,6 +28,7 @@ "packages/core/src/async-mission-store-queries.ts": 3, "packages/core/src/task-priority.ts": 3, "packages/core/src/task-store/async-merge-coordination.ts": 3, + "packages/core/src/task-store/task-artifacts-ops.ts": 3, "packages/core/src/task-store/task-update.ts": 3, "packages/dashboard/app/components/DockTaskList.tsx": 3, "packages/dashboard/app/components/TaskCard.tsx": 3, @@ -128,6 +126,7 @@ "packages/engine/src/merger.ts": 1, "packages/engine/src/plugin-runner.ts": 1, "packages/engine/src/pr-comment-handler.ts": 1, + "packages/engine/src/runtimes/in-process-runtime.ts": 1, "plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts": 1, "plugins/fusion-plugin-reports/src/store/report-types.ts": 1 },