fleet: planning drain + archive writers 12 → 4 — one stale row starves planning, and a finaliser that wrote an undeclared column (#2742)

**Claimed on #2733 before starting.** `in-process-runtime.ts` +
`task-artifacts-ops.ts` — **12 → 4**.

## 1. The planning drain: one stale row stops planning for the whole
project

FN-8470's own note on this code says it: **one orphan earlier in
created_at FIFO prevented every later planning continuation from
dispatching.** So on a renamed board the literal terminal pair did not
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**.

The two classifiers take an **optional** terminal set, which is this
file's own injection idiom (the specification-complete reaction already
takes a `resolveIr` dependency so the pure passes are testable without
constructing a runtime that would attach to the real project registry).

**Optional is load-bearing:** a *required* parameter would have compiled
at every existing caller and then answered "not terminal" for
everything. That is the silent direction, and both halves are asserted
in the test.

## 2. `moveToDoneImpl` writes `task.column` directly

This is the store's own finaliser, not a `moveTask` caller — so its
literal is **not** caught by `moveTask`'s unknown-column validation the
way every converted call site in this program is. It silently persisted
`done` on a board that does not declare it, and then emitted `to:
"done"` to every listener.

**This is one of the few sites where a literal writes bad state rather
than merely failing to act.** A workflow declaring no complete lane now
throws instead of inventing one — #2733's rule: a missing field on a
resolved struct *is* an answer, and `?? legacy` discards it.

## 3. The unarchive destination — three decisions in four lines, all
literal

| pre-archive column | lands in |
|---|---|
| unusable / archived | the **complete** lane |
| the **wip** or **review** lane | the **hold** lane (its worktree and
session are long gone) |
| anything else | back where it was |

The second is the expensive one: a card archived *from* the wip lane was
restored straight back *into* it **with no worktree**, and the scheduler
then counts it as a live holder **occupying a slot**. Made async — its
one production caller already is, and the sync alternative is the
PostgreSQL no-op documented in #2703.

## Also

- **The mission-error requeue** (guard *and* destination in one change):
an errored mission task stayed in the wip lane holding a slot, because
the guard never matched.
- **The planner-chat retention cutoff on archive** — the quiet direction
of this defect class: nothing breaks, data that should be deleted simply
accumulates, and the only symptom is storage growth nobody attributes to
a column name.

## The live defect is not where the census points

`reliability-metrics.ts`'s 6 guards are **pure historical readers** over
activity-log entries, and **the dashboard does not call them**. The live
path is `server.ts`'s `getTaskMovedCountsByDay({ toColumn: "in-review"
})` — a **SQL query filter**, the class the census counts separately.

So the operator's reliability panel reads zero on a renamed board
because of a *query* literal, and converting the six guards the census
reports **would change nothing an operator sees**. Converting historical
readers also risks reinterpreting past events under today's traits,
which is a different decision from converting a live guard — I am not
making it inside a vocabulary sweep.

Worth generalising for the fleet: **a file's census count and its live
exposure are different numbers.** This is the second file where the
reported guards are the inert copy and the real one is a query
(`executor.ts:5805` was the first).

## Verification

`pnpm test:gate` **10 / 158 / 487 / 71** · 31/31 continuation suites ·
8/8 archive PG suites · in-process-runtime PG suite green · 5 new cases,
**2 red on revert** · `tsc` clean in core and engine · `pnpm lint`
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 07:53:28 -07:00
committed by GitHub
parent c53d3aec38
commit 86639f2ce4
7 changed files with 388 additions and 29 deletions

View File

@@ -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");
});
});

View File

@@ -2072,8 +2072,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async archiveTaskAndCleanup(id: string): Promise<Task> {
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<Column> {
return resolveUnarchiveTargetColumnImpl(this, preArchiveColumn, taskId);
}
public async readPreArchiveColumnFromTaskFile(dir: string): Promise<Column | undefined> {
return readPreArchiveColumnFromTaskFileImpl(this, dir);

View File

@@ -397,7 +397,7 @@ export async function unarchiveTaskImpl(store: TaskStore, id: string): Promise<T
}
const preArchiveColumn = task.preArchiveColumn ?? "todo";
const toColumn = store.resolveUnarchiveTargetColumn(preArchiveColumn);
const toColumn = await store.resolveUnarchiveTargetColumn(preArchiveColumn, id);
/*
* FNXC:SqliteFinalRemoval 2026-06-25:

View File

@@ -10,6 +10,8 @@
*/
import { TaskStore } from "../store.js";
import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js";
import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js";
import { countAgentLogEntries, readAgentLogEntries } from "../agent-log-file-store.js";
import { toJsonNullable } from "../db.js";
import { DbTransaction, recordRunAuditEventWithinTransaction } from "../postgres/data-layer.js";
@@ -315,28 +317,139 @@ export async function archiveAllDoneImpl(store: TaskStore, options?: { removeLin
return archivedTasks;
}
export function resolveUnarchiveTargetColumnImpl(store: TaskStore, preArchiveColumn: unknown): Column {
if (!isColumn(preArchiveColumn) || preArchiveColumn === "archived") {
return "done";
/*
FNXC:WorkflowLifecycleColumns 2026-08-02-16:25 (fleet: the UNARCHIVE destination):
WHERE A RESTORED CARD LANDS is three lifecycle decisions in four lines, and all three were literals:
- no usable pre-archive column -> 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<Column> {
/*
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<string>(
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<Column | undefined> {
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<void> {
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 {

View File

@@ -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" });
});
});

View File

@@ -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<string>,
): 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<string> = 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<string> },
): 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<WorkflowWorkItem[]>;
getTask: (taskId: string) => Promise<Task | undefined>;
/** The task's own terminal columns; omitted in tests and legacy callers, which keep the legacy pair. */
resolveTerminalColumns?: (taskId: string) => Promise<ReadonlySet<string>>;
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);
});

View File

@@ -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
},