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:
101
packages/core/src/__tests__/unarchive-target-lanes.test.ts
Normal file
101
packages/core/src/__tests__/unarchive-target-lanes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user