TAKING default-workflow-hooks.ts + executor.ts + live-agent-count.ts + 6 dashboard files: reopen semantics by role, and the census's blind spot in both directions (13 sites) (#2628)

Batched conversion of every lifecycle-column guard I hold, plus the
three the census could not see. **Six files to zero, repo-wide 60 → 49
by a comment-stripped unanchored sweep.** Each conversion has an
isolated revert proof and a paired negative case, and the one code move
is a separate commit from the behavior changes.

## Per-file before → after

Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']`
sweep over `packages/*/src` + `plugins/*/src`, excluding tests.

| file | before | after | note |
|---|---:|---:|---|
| `core/default-workflow-hooks.ts` | 4 | **0** | |
| `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror
converted; the flag-OFF inline block is the parity reference and stays |
| `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** —
see below |
| `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer
deliberately unchanged |
| `engine/replan-target.ts` | 2 | **0** | both were comment prose, not
guards |
| `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column
guards |
| `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons |
| `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column
guard |
| `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE |
| `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE |
| `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** |
ROLE |
| `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE |
| `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias
table, marked `DELIBERATE-LITERAL` with its reason |

## The census errs in BOTH directions

This is the finding I would most like carried into the remaining work.

- It **flagged 10 sites that were never column guards.** `role ===
"triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The
planner *lane* is named `triage` and keeps that name — U11 removed the
*column*. Worse than noise: the obvious "finish the migration" edit is
to rename the role, and that silently empties the planner's prompt
template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names
it, so the two vocabularies are distinguishable by grep and a rename
fails loudly (revert proof: 4 tests, two of them pre-existing).
- It **missed 3 real guards in `executor.ts`**, because the pattern
matches `column`/`toColumn`/`fromColumn` and those locals are named
`from` and `originColumn`. A census keyed on variable names will keep
missing guards wherever a local was named for its role in the function.

## Two real defects, not tidying

**1. A renamed board could merge with its re-review never run.**
`default-workflow-hooks.ts` is named for the default workflow, but the
store runs it on the flag-ON path for *every* workflow — the trait
registry resolves hooks by trait id, not by workflow. Its reopen
predicates listed the default lineage's column names, so on a renamed
board **no reopen effect fired at all**. One of them clears
`workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced
out of review carried its old `passed` result back in, and that
satisfies the merge gate. Same regression the graph-owned-crossing
carve-out exists to prevent, arriving through the other door. (Two
smaller ones rode along: failure state never cleared on a renamed
reopen, and an operator dragging a card back to the queue never parked
it, so the scheduler re-dispatched what they had just pulled back.)

**I forgot the carve-out on my first pass, and that was worse than not
converting.** A role-resolved clear plus a *name*-matched exemption
means a renamed board takes the clear and never the exemption,
destroying the remediation input the graph had just written. My own
paired negative test caught it.

**2. The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** In `recoverCompletedTask`,
`promotedFromPlannerColumn` was false on a renamed board, so finished
work resting in the planning lane was never promoted — the code fell
through to `handoffTaskToReview` straight from the planning column, and
role adjacency has no planning → review edge, so the handoff was
rejected and the card stayed stuck with its work complete. I converted
the promotion **target** too: resolving the lane and then moving to a
literal `in-progress` is the half-conversion I have already been burned
by twice this program, where the guard starts admitting cards and the
move then sends them to a column the board does not declare.

## E2E evidence

`renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and
a real `moveTask` on a workflow whose columns carry the standard traits
under non-default names. The unit tests cannot show this: if `moves.ts`
passed `undefined`, every unit case still passes via the no-basis
fallback while the real board keeps the old behavior. **Proof it is
load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of
3.** The executor suite covers both the split-role and the MERGED
post-U11 shape.

## Revert proofs, isolated per site

| change reverted | result |
|---|---|
| reopen predicate → literal names | 4 of 10 fail |
| reopen field clears → literal names | 2 of 10 fail |
| `userPaused` hold lane → literal `todo` | 1 of 10 fail |
| graph carve-out → literal names | 1 of 10 fail |
| store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| two-hop condition → `=== "triage"` | 1 of 7 fails |
| promotion target → `"in-progress"` | 3 of 7 fail |
| `isPlannerColumnFor` → literals | 1 of 7 fails |
| live-agent-count: one arm dropped | 2 of 11 fail |
| DocumentsView: trait branch removed | 3 of 7 fail |
| planner role renamed to `"planner"` | 4 fail (2 pre-existing) |

Every conversion is paired with a negative case (a forward move, a
not-a-planner-lane card, a default-lineage card, a renamed column with
no traits), so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Deliberately NOT converted, with reasons

- **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy
path, kept verbatim so the two can be parity-checked. Converting it
erases the reference implementation.
- **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is
nothing to resolve from — `enrich…FromFlags` exists for callers with
board flags rather than an IR, so a column missing from that map is the
renamed case. "Not intake" is as much a guess as "todo is intake", and
Running/Waiting are complements, so a card matching neither arm is
reported as neither and the footer's queued total under-reports it. The
real fix is at the caller; four new cases pin that flags override the
legacy answer **in both directions**. What did change is the
duplication: two hand-written copies of one rule now call one named
function.
- **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column
*names* where `triage` sits beside `signal` and `backlog`. Command
Center aggregates across projects, so there is no single workflow to
resolve traits from — the honest conversion is a data change, not a
predicate change.
- **`DocumentsView` with no traits.** Same no-basis rule; the documents
list is full of historical columns absent from the current board. A case
asserts a renamed column with no traits still reads as "working",
documenting the gap rather than hiding it.

## Fixture findings

Each cost a red run that looked like the code under test:

- a `merge-blocker` column needs a reachable merge-class node, or
`parseWorkflowIr` rejects the workflow;
- a back-edge must be `kind: "rework"`, and a rework edge is legal only
**into** a node with `config.reworkRegion: true`;
- a workflow gets role-level transitions only when it declares wip +
review + complete + **archived** plus a planning lane — without the
archived column, adjacency falls back to order-derived neighbours and
`checking -> queued` is not a legal move at all;
- `recoverCompletedTask` only *reaches* the promotion seam when nothing
is left to gate; without passed `plan-review`/`code-review` rows it
re-enters the workflow graph and returns first, so a naive fixture
silently tests the wrong branch and every assertion reads "no moves
happened" for an unrelated reason.

## Verification

- `pnpm test:gate` **71/71**
- new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real
PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4
planner-role-is-not-a-column
- neighbours: 132 + 10 + 482 (gate shards), 350/351 engine
planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector,
11/11 live-agent-count, 11/11 dashboard hook/log suites
- the single engine failure (`executor-fast-mode-workflows.test.ts` ›
"raw fast mode still invokes non-executable review seam nodes")
**reproduces with my changes stashed** — pre-existing on `origin/main`
- typechecks clean for core, engine, and dashboard-app
(`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`);
`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-29 22:39:14 -07:00
committed by GitHub
parent c9f117bcd9
commit 31e49b684a
30 changed files with 1427 additions and 75 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Task Documents shows the correct status dot for tasks on renamed or custom board columns.
category: fix
dev: `DocumentsView` takes optional per-task column traits (threaded from App's existing footer map through `MainContent`) and resolves the dot by role; five dashboard `agent === "triage"` role comparisons now use `PLANNER_AGENT_ROLE`.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Completed work stranded in a renamed planning column is now recovered instead of stuck there.
category: fix
dev: `recoverCompletedTask` resolves the planner lanes and the promotion target from the task's own workflow (`resolvePlannerLanes`, now shared from `replan-target.ts`), and the planning-evacuation branch of the `task:moved` handler uses the same classification via `isPlannerColumnFor`.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The processing/queued footer counts now share one rule for columns with no trait flags.
category: internal
dev: `live-agent-count.ts`'s two duplicate no-flags fallbacks collapse into `isLegacyPreImplementationColumn`; deliberately still the legacy pair, with the reason recorded at the helper. `replan-target.ts` comment prose restated by role.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Reopening a card on a renamed board now clears its stale review results, branch and failure state.
category: fix
dev: `default-workflow-hooks.ts` reopen predicates resolve intake/hold/wip/review/complete by trait from the task's own IR (passed in from `moves.ts` as `DefaultWorkflowMoveContext.lifecycleColumns`) instead of matching the default lineage's column names. `isReopenIntoPlanning` is exported so the store's former "parity mirror" calls it. The flag-OFF inline block in `moves.ts` stays name-based as the parity reference.

View File

@@ -88,3 +88,51 @@ describe("live agent count predicates", () => {
});
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-10:30 (Phase C convergence — live-agent-count.ts):
The no-flags fallback is DELIBERATELY the legacy pair, and these cases exist so a future
"finish the conversion" pass cannot quietly change the answer. Running and Waiting are
complements over the same rows, so if the two former literal sites ever disagree a card
lands in both counts or in neither, and the footer's queued total misreports it.
What is pinned:
- with NO flags, the legacy planner ids are Waiting (unchanged behavior);
- with NO flags, a renamed planner column is NOT Waiting — the known gap, whose fix is at
the caller (supply flags, or use the IR-based `enrichRunningAgentTaskShape`), not a guess
about what an absent flag set means;
- flags always WIN over the fallback, in both directions, which is what makes the caller
fix effective.
*/
describe("the no-flags fallback keeps the legacy planner vocabulary", () => {
const bare = (column: string) => ({ id: "FN-1", column } as Parameters<typeof isWaitingAgentTask>[0]);
it("treats the legacy planner ids as waiting when no flags are supplied", () => {
expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(bare("triage")))).toBe(true);
expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(bare("todo")))).toBe(true);
expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(bare("in-review")))).toBe(false);
});
it("answers identically whether the shape was enriched or read raw", () => {
// The two former literal sites: `enrich...FromFlags` and `isWaitingAgentTask`'s own
// `??` fallback. One rule, so one answer.
for (const column of ["triage", "todo", "in-progress", "backlog"]) {
expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(bare(column))))
.toBe(isWaitingAgentTask(bare(column)));
}
});
it("does NOT invent a planner lane for a renamed column with no flags", () => {
expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(bare("backlog")))).toBe(false);
});
it("lets supplied flags override the legacy answer in both directions", () => {
// A board that declares `todo` as a WIP column: flags win, so it is Running, not Waiting.
const wipTodo = enrichRunningAgentTaskShapeFromFlags(bare("todo"), { countsTowardWip: true });
expect(isWaitingAgentTask(wipTodo)).toBe(false);
expect(isRunningAgentTask(wipTodo)).toBe(true);
// And the renamed planner lane becomes Waiting as soon as its flags arrive.
expect(isWaitingAgentTask(enrichRunningAgentTaskShapeFromFlags(bare("backlog"), { intake: true }))).toBe(true);
});
});

View File

@@ -0,0 +1,63 @@
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-11:10 (Phase C convergence — vocabulary hygiene):
THE INVARIANT: `"triage"` names TWO different things, and only one of them was removed.
- the planner AGENT ROLE — the engine lane, its prompt templates, its usage-limit
accounting. Still called `triage`, and nothing in this program renames it.
- the intake COLUMN on the default lineage — deleted by U11 (#2515), which merged Todo
into Planning.
WHY IT NEEDS A TEST. The lifecycle-column census greps `=== "triage"`, so every
`role === "triage"` and `agentType === "triage"` matched it and read as an un-migrated column
guard. That is not a cosmetic accounting problem: the obvious "finish the migration" edit is
to rename the role, and renaming it silently breaks prompt-template resolution (the planner
lane falls back to an empty prompt) and usage-limit lane accounting — neither of which fails
loudly. `PLANNER_AGENT_ROLE` makes the two vocabularies distinguishable by grep, and these
cases make the distinction fail loudly if someone collapses them.
The census's error ran in BOTH directions, which is the real lesson: it flagged role
comparisons that were never column guards while MISSING real column guards in executor.ts
whose locals were named `from` and `originColumn`. A census over a shared string is only as
good as its ability to tell the vocabularies apart.
*/
import { describe, expect, it } from "vitest";
import { PLANNER_AGENT_ROLE } from "../types/task-log.js";
import { resolveAgentPrompt } from "../agent-prompts.js";
import { resolveDefaultWorkflowIr } from "../builtin-workflows.js";
import { resolveLifecycleColumns } from "../workflow-lifecycle-traits.js";
describe("the planner ROLE and the deleted intake COLUMN share a name and nothing else", () => {
it("keeps the planner role named `triage` after U11 removed the column", () => {
// If a future edit renames the role to match the column vocabulary, this fails FIRST —
// before the silent prompt-resolution and usage-accounting breakage downstream.
expect(PLANNER_AGENT_ROLE).toBe("triage");
});
it("resolves the planner lane's prompt through that role", () => {
const prompt = resolveAgentPrompt(PLANNER_AGENT_ROLE);
expect(prompt.length).toBeGreaterThan(0);
expect(prompt).toContain("task specification agent");
});
it("proves the default workflow no longer declares a column by that name", () => {
// Both halves of the invariant in one assertion: the role name survives, the column
// name does not. A test that only checked the role would pass even if U11 were reverted.
const columns = (resolveDefaultWorkflowIr() as { columns?: Array<{ id?: string }> }).columns ?? [];
expect(columns.map((c) => c.id)).not.toContain(PLANNER_AGENT_ROLE);
});
it("resolves the default lineage's planning lane by TRAIT, not by that name", () => {
const lifecycle = resolveLifecycleColumns(resolveDefaultWorkflowIr());
expect(lifecycle).toBeDefined();
expect(lifecycle?.intake).toBeDefined();
expect(lifecycle?.intake).not.toBe(PLANNER_AGENT_ROLE);
// Post-U11 the merged planning column carries intake AND hold, so the two roles resolve
// to the same column. That collapse is the intended end state, not a degenerate case.
expect(lifecycle?.hold).toBe(lifecycle?.intake);
});
});

View File

@@ -0,0 +1,187 @@
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:45 (Phase C convergence — E2E evidence):
THE STORE PATH, not the hook in isolation. `default-workflow-hooks.ts`'s reopen effects
are unit-covered in `reopen-semantics-by-role.test.ts`, but that proves nothing about
whether `moves.ts` actually HANDS the hooks the moving task's resolved lifecycle columns.
If it passes `undefined`, every one of those unit cases still passes (the no-basis
fallback) while the real board silently keeps the old behavior. So this drives a real
PostgreSQL store, a real `moveTask`, and a workflow whose columns carry the standard
traits under NON-default names.
WHAT IT WOULD HAVE CAUGHT: a card bounced out of the renamed review lane kept its
`passed` review result, because the clear was gated on the literal `in-review`/`todo`.
`getTaskMergeBlocker` reads that array, so the card could re-enter review and merge with
its re-review never run.
The flag-ON path is the one under test — `isWorkflowColumnsCompatibilityFlagEnabled`
reads the RAW experimental flag, so without enabling it this suite would exercise the
legacy inline branch (which is deliberately left name-based as the parity reference) and
prove nothing.
*/
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
} from "../../__test-utils__/pg-test-harness.js";
import type { WorkflowIr } from "../../workflow-ir-types.js";
/** Standard lifecycle traits under non-default column names, with a reopen edge. */
function renamedBoardIr(): WorkflowIr {
return {
version: "v2",
name: "test:renamed-board",
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{
id: "queued",
name: "Queued",
traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }],
},
{
id: "building",
name: "Building",
traits: [
{ trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } },
{ trait: "abort-on-exit" },
{ trait: "timing" },
],
},
{
id: "checking",
name: "Checking",
traits: [{ trait: "merge" }, { trait: "merge-blocker" }],
},
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
/*
FOURTH FIXTURE FINDING, and the one that actually matters for reading this file: the
ARCHIVED column is not decoration. `resolveRoleColumns` returns undefined unless the
workflow declares wip + review + complete + archived + a planning lane, and without
it adjacency silently falls back to ORDER-DERIVED neighbours — under which
`checking -> queued` is not a legal move at all ("Valid targets: building, shipped").
So a renamed board only gets role-level transitions once its role set is complete;
an incomplete one is treated as a genuinely custom shape, by design.
*/
{ id: "archive", name: "Archive", traits: [{ trait: "archived" }] },
],
nodes: [
{ id: "start", kind: "start", column: "backlog" },
/*
THIRD FIXTURE FINDING: a rework edge is legal only INTO a node marked
`config.reworkRegion: true` (or inside a foreach template). So a workflow that wants
a review bounce must declare its bounce TARGETS as rework-region heads — the shape is
opt-in per node, not a property of the edge alone.
*/
{ id: "plan", kind: "prompt", column: "queued", config: { name: "Plan", prompt: "Specify.", reworkRegion: true } },
{ id: "build", kind: "prompt", column: "building", config: { name: "Build", prompt: "Do it.", reworkRegion: true } },
{ id: "check", kind: "prompt", column: "checking", config: { name: "Check", prompt: "Review it." } },
/*
FIXTURE NOTE, and it is the finding of a real rule rather than boilerplate: a column
declaring `merge-blocker` must have a reachable merge-class node or `parseWorkflowIr`
rejects the whole workflow ("the merge-blocker gate can never clear without one"). My
first fixture omitted it and all three cases failed at workflow CREATION, not at the
move — a failure that looks like the code under test and is not.
*/
{ id: "merge", kind: "merge-attempt", column: "checking", config: { capability: "task-merge" } },
{ id: "end", kind: "end", column: "shipped" },
// No node in the archive column — the builtin coding IR declares its `archived`
// column the same way, and adding one only made it an unreachable node.
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "build", condition: "success" },
{ from: "build", to: "check", condition: "success" },
{ from: "check", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
/*
The reopen edges under test: a rejected check goes back to planning, and a renamed
board is entitled to the same bounce the default lineage has.
SECOND FIXTURE FINDING: these MUST be `kind: "rework"`. `validateNoIllegalCycles`
exempts only rework edges from the acyclicity rule, so a plain back-edge rejects the
whole workflow. Worth knowing before writing any reopen fixture — a bounce edge in
this IR is a rework edge by definition, not an ordinary conditional one.
*/
{ from: "check", to: "plan", kind: "rework", condition: "failure" },
{ from: "check", to: "build", kind: "rework", condition: "retry" },
],
} as WorkflowIr;
}
pgDescribe("a renamed board gets the same reopen effects as the default lineage", () => {
const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_renamed_reopen" });
beforeAll(harness.beforeAll);
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
afterAll(harness.afterAll);
beforeEach(async () => {
await harness.store().updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
/** Force a column directly so one move edge can be exercised in isolation. */
async function forceColumn(taskId: string, column: string): Promise<void> {
const store = harness.store();
const layer = store.getAsyncLayer();
if (!layer) throw new Error("expected async layer in backend mode");
const { project } = await import("../../postgres/schema/index.js");
await layer.db.update(project.tasks).set({ column }).where(eq(project.tasks.id, taskId));
}
async function seedCardInCheck(): Promise<{ store: ReturnType<typeof harness.store>; taskId: string }> {
const store = harness.store();
const def = await store.createWorkflowDefinition({ name: "Renamed Board", ir: renamedBoardIr() });
const task = await store.createTask({ description: "renamed board card", workflowId: def.id });
await store.updateTask(task.id, {
status: "failed",
error: "review rejected",
branch: "fusion/renamed",
summary: "a summary from the failed attempt",
workflowStepResults: [
{ workflowStepId: "code-review", status: "passed", completedAt: "2026-07-30T00:00:00.000Z" },
] as never,
});
await forceColumn(task.id, "checking");
return { store, taskId: task.id };
}
it("clears the stale review result when the renamed review lane bounces to the renamed hold lane", async () => {
const { store, taskId } = await seedCardInCheck();
const moved = await store.moveTask(taskId, "queued", { moveSource: "engine" });
expect(moved.column).toBe("queued");
// The safety assertion: a surviving `passed` result satisfies getTaskMergeBlocker.
expect(moved.workflowStepResults ?? []).toHaveLength(0);
expect(moved.branch ?? null).toBeNull();
expect(moved.summary ?? null).toBeNull();
expect(moved.status ?? null).toBeNull();
expect(moved.error ?? null).toBeNull();
});
it("parks a user-source bounce into the renamed hold lane", async () => {
const { store, taskId } = await seedCardInCheck();
const moved = await store.moveTask(taskId, "queued", { moveSource: "user" });
expect(moved.userPaused).toBe(true);
});
it("does NOT strip results on a forward move within the renamed board", async () => {
// The paired negative: "clears on every move" must not pass for "resolves the roles".
const store = harness.store();
const def = await store.createWorkflowDefinition({ name: "Renamed Fwd", ir: renamedBoardIr() });
const task = await store.createTask({ description: "forward card", workflowId: def.id });
await store.updateTask(task.id, {
workflowStepResults: [{ workflowStepId: "code-review", status: "passed" }] as never,
});
await forceColumn(task.id, "queued");
const moved = await store.moveTask(task.id, "building", { moveSource: "engine" });
expect(moved.column).toBe("building");
expect(moved.workflowStepResults ?? []).toHaveLength(1);
});
});

View File

@@ -0,0 +1,243 @@
// @vitest-environment node
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:20 (Phase C convergence — default-workflow-hooks.ts):
THE INVARIANT: a reopen is "live work (wip/review/complete) back into a planning lane
(intake/hold)", decided from the moving task's OWN workflow — not from the default
lineage's column names.
WHY THIS IS A SAFETY TEST AND NOT A TIDYING TEST. `default-workflow-hooks.ts` is named
for the default workflow but the store runs it on the flag-ON path for EVERY workflow
(the trait registry resolves each hook by trait id, not by workflow). Its reopen
predicates were lists of the default lineage's names, so on a renamed board every reopen
effect silently did nothing. One of those effects clears `workflowStepResults`, and
`getTaskMergeBlocker` reads exactly that: a card bounced out of review kept its OLD
review results, and a `passed` result satisfies the merge gate. So a renamed workflow
could merge with its re-review never run — the same regression the graph-owned-crossing
carve-out in `applyReopenFieldClears` exists to prevent, arriving through the other door.
The `todo`/`triage` names are the thing being removed here, which is why this file is
part of the triage-guard convergence and not a separate refactor: `default-workflow-hooks.ts`
held 4 of them and `moves.ts`'s flag-ON mirror held 1 more.
WHAT IS DELIBERATELY NOT CONVERTED: the flag-OFF inline block in `moves.ts` (~line 867).
That branch IS the legacy path and is kept verbatim on purpose so the two paths can be
parity-checked; converting it would erase the reference implementation.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { __resetTraitRegistryForTests } from "../trait-registry.js";
import { registerBuiltinTraits } from "../builtin-traits.js";
import {
__resetDefaultWorkflowHooksForTests,
applyDefaultWorkflowMoveEffects,
isReopenIntoPlanning,
registerDefaultWorkflowHooks,
type DefaultWorkflowMoveContext,
} from "../default-workflow-hooks.js";
import { resolveLifecycleColumns } from "../workflow-lifecycle-traits.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import type { Task } from "../types.js";
/** A board whose columns carry the SAME traits under DIFFERENT names. */
const RENAMED_IR = {
version: "v2",
id: "wf-renamed",
name: "renamed",
nodes: [],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }, { trait: "merge-blocker" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
/** The post-U11 default lineage: `todo` is intake AND hold; `triage` is gone (#2515). */
const DEFAULT_IR = {
version: "v2",
id: "wf-default",
name: "default",
nodes: [],
edges: [],
columns: [
{ id: "todo", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "In Progress", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "in-review", name: "In Review", traits: [{ trait: "merge" }, { trait: "merge-blocker" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
function makeCtx(
ir: WorkflowIr | undefined,
fromColumn: string,
toColumn: string,
overrides: Partial<DefaultWorkflowMoveContext> = {},
): DefaultWorkflowMoveContext {
const task = {
id: "FN-1",
column: toColumn,
columnMovedAt: "2026-07-30T00:00:00.000Z",
steps: [],
dependencies: [],
status: "failed",
error: "boom",
branch: "fusion/FN-1",
summary: "old summary",
workflowStepResults: [{ workflowStepId: "review", status: "passed" }],
} as unknown as Task;
return {
task,
fromColumn,
toColumn,
moveSource: "engine",
bypassGuards: false,
movedAt: "2026-07-30T00:00:01.000Z",
settings: undefined,
options: {},
lifecycleColumns: ir ? resolveLifecycleColumns(ir) : undefined,
resetSteps: () => {},
...overrides,
};
}
function applyOn(ir: WorkflowIr | undefined, fromColumn: string, toColumn: string, overrides = {}) {
const ctx = makeCtx(ir, fromColumn, toColumn, overrides);
applyDefaultWorkflowMoveEffects(ctx);
return ctx.task;
}
describe("a reopen is decided by lifecycle ROLE, not by the default lineage's names", () => {
beforeEach(() => {
__resetTraitRegistryForTests();
__resetDefaultWorkflowHooksForTests();
registerBuiltinTraits();
registerDefaultWorkflowHooks();
});
it("clears stale review results when a RENAMED board bounces review -> hold", () => {
// THE SAFETY CASE. Pre-fix: `checking`/`queued` matched none of the hard-coded
// names, so the `passed` review result survived the bounce and `getTaskMergeBlocker`
// would have let the card merge with its re-review never run.
const task = applyOn(RENAMED_IR, "checking", "queued");
expect(task.workflowStepResults).toBeUndefined();
expect(task.branch).toBeUndefined();
expect(task.summary).toBeUndefined();
});
it("clears the failure state when a RENAMED board bounces wip -> intake", () => {
const task = applyOn(RENAMED_IR, "building", "backlog");
expect(task.status).toBeUndefined();
expect(task.error).toBeUndefined();
});
it("parks a user-source rebound into the renamed HOLD lane", () => {
// Pre-fix the park never happened off the default lineage, so the scheduler
// re-dispatched the card the operator had just pulled back.
const task = applyOn(RENAMED_IR, "building", "queued", { moveSource: "user" as const });
expect(task.userPaused).toBe(true);
});
it("still does the same on the DEFAULT lineage (the conversion is not a rename)", () => {
const task = applyOn(DEFAULT_IR, "in-review", "todo");
expect(task.workflowStepResults).toBeUndefined();
expect(task.branch).toBeUndefined();
expect(task.status).toBeUndefined();
});
it("does NOT treat a forward move into wip as a reopen on a renamed board", () => {
// The paired negative: "clears everything always" must not be able to pass for
// "reads the roles". A backlog -> building move keeps the card's own state.
const task = applyOn(RENAMED_IR, "backlog", "building");
expect(task.status).toBe("failed");
expect(task.workflowStepResults).toHaveLength(1);
});
it("does NOT clear results on the graph's own review -> wip crossing (carve-out survives)", () => {
const task = applyOn(RENAMED_IR, "checking", "building", {
workflowMoveSource: "workflow-graph",
});
expect(task.workflowStepResults).toHaveLength(1);
});
it("DOES clear results on an operator-dragged review -> wip crossing", () => {
const task = applyOn(RENAMED_IR, "checking", "building");
expect(task.workflowStepResults).toBeUndefined();
});
});
describe("no column vocabulary is the only case a legacy name is legitimate", () => {
beforeEach(() => {
__resetTraitRegistryForTests();
__resetDefaultWorkflowHooksForTests();
registerBuiltinTraits();
registerDefaultWorkflowHooks();
});
it("falls back to the legacy names for a v1 / column-less IR", () => {
// `resolveLifecycleColumns` returns undefined for the WHOLE struct here, which means
// "no basis to decide" — not "declares no hold column". The legacy names are all
// there is, and a pre-v2 row really does live in `in-review`/`todo`.
const task = applyOn(undefined, "in-review", "todo");
expect(task.workflowStepResults).toBeUndefined();
expect(task.status).toBeUndefined();
});
it("does NOT substitute a legacy name for a role the workflow genuinely lacks", () => {
// A workflow with intake but NO hold: `queued` is not a lane on this board, so a move
// there is not a reopen. Substituting `todo` would invent a lane the operator removed.
const holdlessIr = {
version: "v2", id: "wf-no-hold", name: "no-hold", nodes: [], edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
],
} as unknown as WorkflowIr;
const lifecycle = resolveLifecycleColumns(holdlessIr);
expect(isReopenIntoPlanning(lifecycle, "building", "todo")).toBe(false);
expect(isReopenIntoPlanning(lifecycle, "building", "backlog")).toBe(true);
});
});
describe("the store's reopen check and the hooks' cannot disagree", () => {
beforeEach(() => {
__resetTraitRegistryForTests();
registerBuiltinTraits();
});
it("answers identically for every from/to pair on a renamed board", () => {
/*
`moves.ts` used to carry its own hand-written copy of this predicate, annotated
"parity mirror". Two copies of one rule diverge on whichever the next edit misses;
it now calls this function. This pins that there is ONE answer per pair, which is the
property the mirror was trying to have.
*/
const lifecycle = resolveLifecycleColumns(RENAMED_IR);
const columns = ["backlog", "queued", "building", "checking", "shipped"];
const reopens = columns.flatMap((from) =>
columns.filter((to) => isReopenIntoPlanning(lifecycle, from, to)).map((to) => `${from}->${to}`),
);
expect(reopens.sort()).toEqual([
"building->backlog",
"building->queued",
"checking->backlog",
"checking->queued",
"shipped->backlog",
"shipped->queued",
]);
});
});

View File

@@ -16,6 +16,9 @@
*/
import type { AgentCapability, AgentPromptTemplate, AgentPromptsConfig } from "./types.js";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:00: these are ROLE comparisons, not column
// guards — the planner LANE is named `triage` and stays named that. See PLANNER_AGENT_ROLE.
import { PLANNER_AGENT_ROLE } from "./types/task-log.js";
// ---------------------------------------------------------------------------
// Built-in prompt text (canonical source for workflow seam prompts)
@@ -1466,10 +1469,10 @@ export function resolveAgentPrompt(
);
}
if (role === "triage" && template.builtIn && template.id === "default-triage") {
if (role === PLANNER_AGENT_ROLE && template.builtIn && template.id === "default-triage") {
return `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
}
if (role === "triage" && template.builtIn && template.id === "concise-triage") {
if (role === PLANNER_AGENT_ROLE && template.builtIn && template.id === "concise-triage") {
return `${CONCISE_TRIAGE_PROMPT_TEXT}\n\n${buildConciseTriageHeartbeatGuidance(options)}`;
}
return template.prompt;
@@ -1477,7 +1480,7 @@ export function resolveAgentPrompt(
// Fall back to built-in default for the role
const builtIn = BUILTIN_AGENT_PROMPTS.find((t) => t.role === role && t.id === `default-${role}`);
if (role === "triage" && builtIn?.id === "default-triage") {
if (role === PLANNER_AGENT_ROLE && builtIn?.id === "default-triage") {
return `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
}
return builtIn?.prompt ?? "";

View File

@@ -32,6 +32,7 @@
*/
import { getTraitRegistry } from "./trait-registry.js";
import type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
import type { TraitAuditWarning } from "./trait-registry.js";
import { getTaskMergeBlocker } from "./task-merge.js";
import type { Settings, Task } from "./types.js";
@@ -98,6 +99,23 @@ export interface DefaultWorkflowMoveContext {
preserveWorktree?: boolean;
preservePause?: boolean;
};
/**
* FNXC:WorkflowLifecycleColumns 2026-07-30-08:05 (Phase C convergence):
* The moving task's OWN lifecycle columns, resolved by trait from its workflow IR by
* the store (which already holds the IR on this path) and passed in because these
* hooks are sync and in-lock — they cannot resolve anything themselves.
*
* WHY THIS FILE NEEDED IT AT ALL. Its name says "default workflow", but the store
* runs these hooks on the flag-ON path for EVERY workflow — the trait registry
* resolves the hook by trait id, not by workflow. So the column names hard-coded
* here were the DEFAULT lineage's names being applied to a renamed board, where the
* reopen effects simply never fired. See `applyResetOnEntryEffects`.
*
* `undefined` means the workflow has no column vocabulary at all (v1 IR), which is
* NOT the same as "declares no hold column" — the hooks keep the legacy literals
* only in that no-basis case, never as a substitute for an absent role.
*/
lifecycleColumns?: LifecycleColumns | undefined;
/** Reset all steps to pending + currentStep 0 (store owns the impl). */
resetSteps: () => void;
}
@@ -152,14 +170,51 @@ export function applyCompletionTimingEffects(ctx: DefaultWorkflowMoveContext): v
}
}
/** `reset-on-entry` trait (todo/triage reopen) + `abort-on-exit` userPaused
* semantics. Reproduces the legacy reopen block. */
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:05 (Phase C convergence — reopen semantics):
WHAT A "REOPEN" IS, stated once. A card leaving live work (wip / review / complete) for a
PLANNING lane (intake or hold). The three predicates below were each written as a list of
the default lineage's column names, which meant every reopen effect — status/error clear,
step reset, `workflowStepResults` clear, branch clear — was a no-op on any workflow that
renamed its columns.
THE CONSEQUENCE WAS NOT COSMETIC. `getTaskMergeBlocker` reads `workflowStepResults`; the
executor's documented bounce invariant is "moveTask(in-review -> planning) clears ALL
results". On a renamed board that clear never happened, so a card bounced out of review
and back in carried its OLD review results — and a `passed` result satisfies the merge
gate. A renamed workflow could merge with its re-review never run. That is the same
safety regression the graph-owned-crossing carve-out above was written to prevent,
arriving through the other door.
LEGACY IDS ARE A NO-BASIS FALLBACK, NOT A ROLE. When the struct is undefined (a v1 IR
with no column vocabulary) there is nothing to reason from and the legacy names are all
we have. When the struct EXISTS but a role is absent, the workflow genuinely has no such
lane and no substitution is made — that is the distinction `resolveLifecycleColumns`
returns `undefined`-for-the-whole-struct to preserve.
*/
const LEGACY_PLANNING_COLUMNS = ["todo", "triage"] as const;
const LEGACY_LIVE_WORK_COLUMNS = ["in-progress", "done", "in-review"] as const;
/** The planning lanes of THIS workflow: intake and hold. */
function planningColumnsOf(lifecycle: LifecycleColumns | undefined): readonly string[] {
if (!lifecycle) return LEGACY_PLANNING_COLUMNS;
return [lifecycle.intake, lifecycle.hold].filter((c): c is string => typeof c === "string");
}
/** The lanes a card is reopened OUT of: wip, review, complete. */
function liveWorkColumnsOf(lifecycle: LifecycleColumns | undefined): readonly string[] {
if (!lifecycle) return LEGACY_LIVE_WORK_COLUMNS;
return [lifecycle.wip, lifecycle.review, lifecycle.complete].filter(
(c): c is string => typeof c === "string",
);
}
/** `reset-on-entry` trait (reopen into a planning lane) + `abort-on-exit` userPaused
* semantics. Reproduces the legacy reopen block, by role rather than by name. */
export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn, moveSource, options } = ctx;
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") &&
(toColumn === "todo" || toColumn === "triage");
if (!isReopenToTodoOrTriage) return;
if (!isReopenIntoPlanning(ctx.lifecycleColumns, fromColumn, toColumn)) return;
/*
FNXC:WorkflowLifecycle 2026-07-12-09:05:
@@ -179,8 +234,19 @@ export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void
task.paused = undefined;
task.pausedByAgentId = undefined;
}
// abort-on-exit userPaused: only for user-source moves to todo (KTD-9).
if (moveSource === "user" && toColumn === "todo") {
/*
abort-on-exit userPaused: only for user-source moves to the HOLD lane (KTD-9).
FNXC:WorkflowLifecycleColumns 2026-07-30-08:05: `todo` was the hold lane's name on the
pre-U11 default lineage and is still its id post-U11 (#2515 merged Todo into Planning
keeping `todo`), so this reads as hold-then-intake. The role matters, not the name: an
operator dragging a card back to the queue is parking it, and on a renamed board that
park silently stopped happening — the scheduler then re-dispatched the card the
operator had just pulled back.
*/
const holdLane = ctx.lifecycleColumns
? ctx.lifecycleColumns.hold ?? ctx.lifecycleColumns.intake
: "todo";
if (moveSource === "user" && toColumn === holdLane) {
task.userPaused = true;
} else if (!options.preservePause) {
task.userPaused = undefined;
@@ -206,6 +272,25 @@ export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void
}
}
/**
* Is this move a reopen — live work (wip/review/complete) back into a planning lane
* (intake/hold)?
*
* FNXC:WorkflowLifecycleColumns 2026-07-30-08:05: EXPORTED so the store's flag-ON
* `preserveStepProgress` mirror asks the same question. Those two predicates were
* separately hand-written copies of the same column list ("Parity mirror of the gate in
* applyReopenFieldClears"), and a hand-copied predicate is a divergence waiting for
* whichever copy the next edit misses. One function cannot disagree with itself.
*/
export function isReopenIntoPlanning(
lifecycle: LifecycleColumns | undefined,
fromColumn: string,
toColumn: string,
): boolean {
return liveWorkColumnsOf(lifecycle).includes(fromColumn)
&& planningColumnsOf(lifecycle).includes(toColumn);
}
/** `merge` trait onEnter (in-review): scheduler-state clearing while
* preserving explicit per-task autoMerge overrides. The queue enqueue itself is
* in-txn and store-owned (handoff path); the field effects mirror the legacy
@@ -246,17 +331,30 @@ export function applyReopenFieldClears(ctx: DefaultWorkflowMoveContext): void {
executor's documented bounce invariant ("moveTask(in-review->todo) already clears ALL results")
survives unchanged.
*/
const lifecycle = ctx.lifecycleColumns;
const planning = planningColumnsOf(lifecycle);
const reviewLane = lifecycle ? lifecycle.review : "in-review";
const wipLane = lifecycle ? lifecycle.wip : "in-progress";
const completeLane = lifecycle ? lifecycle.complete : "done";
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:30 (Phase C convergence):
THE CARVE-OUT MUST BE RESOLVED TOO, and forgetting it was worse than leaving the whole
function alone. A role-resolved clear plus a NAME-matched exemption means the renamed
board takes the clear and never the exemption — so the graph's own remediation crossing
destroyed the `failed` result it had just written, which is precisely the three breakages
the note above enumerates. My own paired negative test caught this; a conversion that
moves the rule and leaves its exception behind inverts the exception.
*/
const graphOwnedReviewToWip = ctx.workflowMoveSource === "workflow-graph"
&& fromColumn === "in-review"
&& toColumn === "in-progress";
if (
!graphOwnedReviewToWip
&& ((fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage"))
|| (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")))
) {
&& fromColumn === reviewLane
&& toColumn === wipLane;
const leftReviewForPlanningOrWip =
fromColumn === reviewLane && (planning.includes(toColumn) || toColumn === wipLane);
const leftCompleteForPlanning = fromColumn === completeLane && planning.includes(toColumn);
if (!graphOwnedReviewToWip && (leftReviewForPlanningOrWip || leftCompleteForPlanning)) {
task.workflowStepResults = undefined;
}
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
if (fromColumn === reviewLane && planning.includes(toColumn)) {
task.branch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;

View File

@@ -38,7 +38,7 @@ export type {
MissionLineageApprovalResult,
MissionLineageSnapshot,
} from "./symbol-lock-lineage-approval.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export { PLANNER_AGENT_ROLE, AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export {
resolveEntryPointBranchAssignment,
sanitizeBranchSegment,

View File

@@ -65,6 +65,37 @@ export function resolveColumnTerminalKind(columnId: string, ir: WorkflowIr): Col
return "none";
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-10:20 (Phase C convergence — live-agent-count.ts):
THE PRE-IMPLEMENTATION FALLBACK, named once instead of spelled out at two call sites.
DELIBERATE-LITERAL, and the reason is not "we ran out of time": this is the answer used when
the caller supplies NO trait flags at all. There is nothing to resolve from. `enrich...FromFlags`
exists precisely for callers that have board-column flags rather than an IR (the dashboard
footer), and a column missing from that flag map is the renamed-or-undeclared case.
Converting it would mean deciding what an ABSENT flag set means, and "not intake" is as much a
guess as "todo is intake" — either choice silently moves an operator-visible count. The two
counts this feeds (Running and Waiting) are complements over the same rows, so a card matching
neither arm is reported as neither running nor waiting and the footer's queued total
under-reports it. Guessing here is worse than the known legacy answer.
The real fix for a renamed board is at the CALLER: supply flags (or use
`enrichRunningAgentTaskShape`, which takes the IR and resolves every role by trait). This
fallback only has to keep behaving exactly as it did for legacy rows.
Both former literal sites now share this function, so the pair cannot drift apart — they were
two hand-written copies of one rule, and line 84 answering differently from line 143 would put
a card in both counts or neither.
*/
const LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]);
/** Legacy-vocabulary "is this column a planner lane?", for callers that supply no traits. */
function isLegacyPreImplementationColumn(columnId: string): boolean {
return LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS.has(columnId);
}
/** Attach the workflow traits required by the pure Running and Waiting predicates. */
export function enrichRunningAgentTaskShape<T extends RunningAgentTaskShape>(task: T, ir: WorkflowIr): T & Required<Pick<RunningAgentTaskShape, "columnTerminalKind" | "columnIsIntakeOrHold" | "columnCountsTowardWip" | "columnIsReviewOrMerge">> {
return {
@@ -81,18 +112,13 @@ export function enrichRunningAgentTaskShapeFromFlags<T extends RunningAgentTaskS
return {
...task,
columnTerminalKind: flags?.archived ? "archived" : flags?.complete ? "complete" : "none",
columnIsIntakeOrHold: flags ? flags.intake === true || flags.hold === true : task.column === "triage" || task.column === "todo",
columnIsIntakeOrHold: flags ? flags.intake === true || flags.hold === true : isLegacyPreImplementationColumn(task.column),
columnCountsTowardWip: flags ? flags.countsTowardWip === true : task.column === "in-progress",
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-23:10:
These id fallbacks are REACHABLE, not fixture-only — callers may pass no flags for a column
absent from the board's flag map, which is the renamed or undeclared column case. A card in
such a column then matches no arm and is counted as neither running nor waiting, so the
footer's queued total under-reports it.
Converting them means deciding what an ABSENT flag set should mean, and "not intake" is as
much a guess as "todo is intake"; either choice moves an operator-visible count. Supply flags
rather than relying on these.
FNXC:WorkflowLifecycleColumns 2026-07-29-23:10 (reason now at
`isLegacyPreImplementationColumn`): these id fallbacks are REACHABLE, not fixture-only —
a column absent from the board's flag map is the renamed-or-undeclared case. Supply flags
rather than relying on them.
*/
columnIsReviewOrMerge: flags ? flags.mergeOrchestration === true || flags.mergeBlocker === true : task.column === "in-review",
};
@@ -140,7 +166,7 @@ export function isRunningAgentTask(task: RunningAgentTaskShape): boolean {
/** Exact footer waiting membership: unpaused, non-terminal intake/hold work that is not live. */
export function isWaitingAgentTask(task: RunningAgentTaskShape): boolean {
if (task.paused || task.userPaused || terminalKind(task) !== "none" || isRunningAgentTask(task)) return false;
return task.columnIsIntakeOrHold ?? (task.column === "triage" || task.column === "todo");
return task.columnIsIntakeOrHold ?? isLegacyPreImplementationColumn(task.column);
}
export function countRunningAgentTasks(tasks: readonly RunningAgentTaskShape[]): number {

View File

@@ -25,7 +25,8 @@ import {
evaluateCapacityRejection,
evaluateTransitionInvariants,
} from "../workflow-transition-policy.js";
import {type DefaultWorkflowMoveContext, applyDefaultWorkflowMoveEffects} from "../default-workflow-hooks.js";
import {type DefaultWorkflowMoveContext, applyDefaultWorkflowMoveEffects, isReopenIntoPlanning} from "../default-workflow-hooks.js";
import {resolveLifecycleColumns} from "../workflow-lifecycle-traits.js";
import {makeTransitionRejection, makeTransitionPending} from "../transition-types.js";
import {writeTransitionPendingAsync, clearTransitionPendingAsync} from "./async-transition-pending.js";
import type {WorkflowIr} from "../workflow-ir-types.js";
@@ -787,6 +788,14 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
task.updatedAt = movedAt;
if (useWorkflow) {
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:10 (Phase C convergence):
Resolved ONCE for both the hook context and the store's own reopen check, so the two
cannot be handed different answers. `undefined` here means either no IR on this path
or a v1 column-less IR; the hooks treat that as "no basis" and keep the legacy names,
which is the only case where a legacy literal is legitimate.
*/
const moveLifecycleColumns = workflowIr ? resolveLifecycleColumns(workflowIr) : undefined;
// ── Flag-ON: route the legacy per-column side effects through the
// default-workflow trait hooks (timing, reset-on-entry, abort-on-exit,
// merge.onEnter). "Moved, not duplicated" applies to this path; the
@@ -810,10 +819,22 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
preservePause: options?.preservePause,
},
resetSteps: () => store.resetAllStepsToPending(task),
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:10 (Phase C convergence):
The hooks are sync and in-lock, so they cannot resolve a workflow themselves — but
this path already holds `workflowIr`, so the roles cost one trait resolution and no
extra read. Without them the hooks compared against the DEFAULT lineage's column
names on every workflow, so a renamed board got no reopen effects at all.
*/
lifecycleColumns: moveLifecycleColumns,
};
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") &&
(toColumn === "todo" || toColumn === "triage");
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:10: the store's own copy of the reopen
predicate now CALLS the hooks' version instead of restating its column list. The
comment below used to say "parity mirror" — two hand-written copies of one predicate,
which is a divergence waiting for whichever copy the next edit misses.
*/
const isReopenToTodoOrTriage = isReopenIntoPlanning(moveLifecycleColumns, fromColumn, toColumn);
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
options?.preserveResumeState ||

View File

@@ -346,6 +346,10 @@ export interface BatchStatusResponse {
// ── task-log ──────────────────────────────────────────────────────────
// FNXC:CodeOrganization 2026-07-22-14:00: Peels live in types/task-log.ts
// FNXC:WorkflowLifecycleColumns 2026-07-30-10:55: a VALUE re-export (not a type) — the
// planner AGENT ROLE name, so role comparisons stop reading as column guards.
export { PLANNER_AGENT_ROLE } from "./types/task-log.js";
import type {
StepStatus,
WorkflowTransitionNotificationKind,

View File

@@ -89,6 +89,26 @@ export interface ActivityLogEntry {
/** The set of agent roles that produce log entries. */
export type AgentRole = "triage" | "executor" | "reviewer" | "merger";
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-10:55 (Phase C convergence — vocabulary hygiene):
A ROLE IS NOT A COLUMN, and the string `"triage"` names both. The planning LANE — the engine
service, its prompt templates, its usage-limit accounting — is called `triage` in the
AgentRole vocabulary, and that name is not going anywhere: U11 removed the `triage` COLUMN
from the default lineage, not the planner lane.
This constant exists so those comparisons stop reading as un-migrated column guards. The
lifecycle-column census greps `=== "triage"`, and `role === "triage"` / `agentType ===
"triage"` matched it — sending reviewers to sites that were never column guards while the
pattern simultaneously MISSED real guards whose locals were named `from` or `originColumn`.
Naming the role makes the two vocabularies distinguishable by grep, which is the only way a
census over a shared string can be trusted.
Use this at every role comparison. If you are comparing a task's COLUMN, you want a lifecycle
role resolved from the workflow IR (`resolveLifecycleColumns`), not this.
*/
export const PLANNER_AGENT_ROLE: AgentRole = "triage";
/*
FNXC:AgentLog-EntryTypes 2026-07-15-11:20:
`text` means a STREAMED DELTA FRAGMENT: renderers re-glue consecutive `text` rows with `join("")` and no separator, because that is the only way to reconstitute a streamed message (the FN-5787/5789/5803 streamed-spacing lineage). `AgentLogger` is the only producer of true deltas.

View File

@@ -1710,6 +1710,9 @@ function AppInner() {
capacityRiskDismissed,
capacityRiskSignal,
handleDismissCapacityRisk,
// FNXC:WorkflowLifecycleColumns 2026-07-30-12:15: reuse the footer's per-task column traits
// so main-content views resolve lifecycle roles instead of matching column names.
columnFlagsByTaskId: footerColumnFlagsByTaskId,
AgentsView,
ChatView,
CommandCenter,

View File

@@ -0,0 +1,64 @@
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-12:25 (Phase C convergence — DocumentsView.tsx):
The task-documents status dot encodes four LIFECYCLE ROLES: complete, archived,
pre-implementation (waiting), and everything else (working). Only the pre-implementation arm
named columns — and it named the default lineage's two — so on a renamed board a queued card
showed the "working" dot.
Both halves are pinned here because either alone is misleading: flags must DECIDE when present
(otherwise the conversion is decoration), and the legacy names must still answer when flags are
ABSENT (the documents list spans archived and historical tasks whose columns are not on the
current board, and "not pre-implementation" would be as much a guess as the legacy pair — the
same no-basis rule as `live-agent-count.ts`).
*/
import { describe, expect, it } from "vitest";
import { getTaskColumnStatusDotClass } from "../components/DocumentsView";
const PENDING = "status-dot status-dot--pending";
const WORKING = "status-dot status-dot--connecting";
const DONE = "status-dot status-dot--online";
const ARCHIVED = "status-dot status-dot--offline";
describe("the documents status dot resolves lifecycle roles when traits are available", () => {
it("shows the waiting dot for a RENAMED planning column via its traits", () => {
// Pre-fix: `backlog` matched neither literal, so a queued card read as working.
expect(getTaskColumnStatusDotClass("backlog", { intake: true })).toBe(PENDING);
expect(getTaskColumnStatusDotClass("queued", { hold: true })).toBe(PENDING);
});
it("shows the working dot for a renamed WIP column", () => {
expect(getTaskColumnStatusDotClass("building", {})).toBe(WORKING);
});
it("prefers archived over complete when a column carries both", () => {
// Order matters: an archived-and-complete column is archived to an operator scanning dots.
expect(getTaskColumnStatusDotClass("shipped", { archived: true, complete: true })).toBe(ARCHIVED);
expect(getTaskColumnStatusDotClass("shipped", { complete: true })).toBe(DONE);
});
it("lets traits OVERRIDE a legacy name in both directions", () => {
// A board that declares `todo` as its complete column: traits win, not the name.
expect(getTaskColumnStatusDotClass("todo", { complete: true })).toBe(DONE);
// And a board that declares `done` as intake.
expect(getTaskColumnStatusDotClass("done", { intake: true })).toBe(PENDING);
});
});
describe("with no traits the documented legacy names still answer", () => {
it("keeps the pre-U11 planner ids on the waiting dot", () => {
expect(getTaskColumnStatusDotClass("todo")).toBe(PENDING);
expect(getTaskColumnStatusDotClass("triage")).toBe(PENDING);
});
it("keeps done and archived", () => {
expect(getTaskColumnStatusDotClass("done")).toBe(DONE);
expect(getTaskColumnStatusDotClass("archived")).toBe(ARCHIVED);
});
it("does NOT invent a role for a renamed column with no traits", () => {
// The known, documented gap: unchanged behavior, not a guess. Supplying flags is the fix.
expect(getTaskColumnStatusDotClass("backlog")).toBe(WORKING);
});
});

View File

@@ -1,4 +1,7 @@
import type { AgentLogEntry } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE } from "@fusion/core";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ProviderIcon } from "./ProviderIcon";
@@ -101,7 +104,7 @@ export const markdownComponents: Components = {
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
function getAgentDisplayName(agent: string, t: TFunction<"app">): string {
if (agent === "triage") return t("agentLog.agentNameTriage", "Plan");
if (agent === PLANNER_AGENT_ROLE) return t("agentLog.agentNameTriage", "Plan");
return agent;
}

View File

@@ -18,6 +18,7 @@ import { LoadingSpinner } from "./LoadingSpinner";
import { ArtifactsGallery, getArtifactCategory, type ArtifactCategory } from "./ArtifactsGallery";
import { ViewHeader } from "./ViewHeader";
import { useColumnLabel } from "../i18n/labels";
import type { ExecutorColumnFlags } from "../hooks/useExecutorStats";
const MOBILE_BREAKPOINT = 768;
@@ -42,8 +43,17 @@ const TASK_ARTIFACT_CATEGORY_ICONS: Record<ArtifactCategory, typeof ImageIcon> =
other: Package,
};
/** Board-workflow column traits, indexed by task id — the shape App already builds for the footer. */
export type DocumentsColumnFlags = Pick<ExecutorColumnFlags, "complete" | "archived" | "intake" | "hold">;
export interface DocumentsViewProps {
projectId?: string;
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-12:05: optional on purpose — the status dot degrades
to the documented legacy names when a task's column has no traits (remote rows, historical
columns absent from the current board), rather than guessing.
*/
columnFlagsByTaskId?: ReadonlyMap<string, DocumentsColumnFlags>;
addToast: (message: string, type?: ToastType) => void;
onOpenDetail: (task: TaskDetail) => void;
onOpenArtifactTaskDetail?: (task: TaskDetail) => void;
@@ -67,13 +77,38 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getTaskColumnStatusDotClass(taskColumn: string): string {
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-12:05 (Phase C convergence — DocumentsView.tsx):
WHAT THE GUARD MEANT, checked rather than swapped: the four dots are LIFECYCLE ROLES —
complete, archived, pre-implementation (waiting), and everything else (working). Only the
pre-implementation arm named columns, and it named the default lineage's two, so on a renamed
board a queued card showed the "working" dot.
FLAGS FIRST, legacy names only with NO BASIS. `flags` are the board-workflow column traits the
dashboard already threads to the footer (`ExecutorColumnFlags`); when present they decide, and
they cover renamed and custom columns. When ABSENT there is nothing to resolve from — the
documents list spans archived and historical tasks whose columns are not in the current board
map — and "not pre-implementation" would be as much a guess as the legacy pair. Same rule as
`live-agent-count.ts`'s no-flags fallback, and same reason: an invented answer moves what the
operator sees.
*/
export function getTaskColumnStatusDotClass(taskColumn: string, flags?: DocumentsColumnFlags): string {
if (flags) {
if (flags.archived) return "status-dot status-dot--offline";
if (flags.complete) return "status-dot status-dot--online";
if (flags.intake || flags.hold) return "status-dot status-dot--pending";
return "status-dot status-dot--connecting";
}
if (taskColumn === "done") return "status-dot status-dot--online";
if (taskColumn === "archived") return "status-dot status-dot--offline";
if (taskColumn === "todo" || taskColumn === "triage") return "status-dot status-dot--pending";
if (LEGACY_PRE_IMPLEMENTATION_COLUMNS.has(taskColumn)) return "status-dot status-dot--pending";
return "status-dot status-dot--connecting";
}
/** The pre-U11 planner column ids, used only when a column has no trait flags. */
const LEGACY_PRE_IMPLEMENTATION_COLUMNS: ReadonlySet<string> = new Set(["triage", "todo"]);
function getTaskArtifactCategoryLabel(t: TFunction<"app">, category: ArtifactCategory): string {
switch (category) {
case "image": return t("documents.artifactCategoryImage", "Image");
@@ -213,7 +248,7 @@ function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error
);
}
export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
export function DocumentsView({ projectId, columnFlagsByTaskId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
const { t } = useTranslation("app");
// FNXC:ArtifactsView 2026-07-11-11:30: Artifacts is the first tab and the landing tab — the view is the artifact gallery first, with project files and task documents as secondary tabs.
const [activeTab, setActiveTab] = useState<DocumentsTab>("artifacts");
@@ -1048,7 +1083,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
<aside className="documents-view-sidebar documents-task-documents-sidebar" aria-label={t("documents.taskDocumentsListLabel", "Task documents")}>
{groupedTaskItems.map(({ taskId, taskTitle, taskColumn, documents: taskDocs, artifacts: taskArtifacts }) => {
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
const taskStatusDotClass = taskColumn
? getTaskColumnStatusDotClass(taskColumn, columnFlagsByTaskId?.get(taskId))
: "status-dot";
return (
<section key={taskId} className="documents-task-sidebar-group" aria-labelledby={`documents-task-group-${taskId}`}>

View File

@@ -12,6 +12,9 @@ import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import type { ToastType } from "../hooks/useToast";
import { getErrorMessage } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE } from "@fusion/core";
import { linkifyFilePaths } from "../utils/filePathLinkify";
import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
import { ProviderIcon } from "./ProviderIcon";
@@ -79,7 +82,7 @@ function getRoleLabel(role: AgentLogRole, t: TFunction<"app">): string {
function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null {
if (entry.type !== "status" && entry.type !== "text") return null;
const role = entry.agent === "triage" ? "Planning" : entry.agent === "executor" ? "Executor" : entry.agent === "reviewer" ? "Reviewer" : null;
const role = entry.agent === PLANNER_AGENT_ROLE ? "Planning" : entry.agent === "executor" ? "Executor" : entry.agent === "reviewer" ? "Reviewer" : null;
if (!role) return null;
return parseRuntimeModelMarker(entry.text, role);
}
@@ -90,7 +93,7 @@ function makeModelInfo(provider: string | undefined, modelId: string | undefined
}
function getExplicitModelForRole(task: Task | TaskDetail, role: AgentLogRole): TaskChatModelInfo | null {
if (role === "triage" && task.planningModelProvider) {
if (role === PLANNER_AGENT_ROLE && task.planningModelProvider) {
return makeModelInfo(task.planningModelProvider, task.planningModelId);
}
if (role === "executor" && task.modelProvider) {

View File

@@ -41,6 +41,16 @@ const LIVE_REFETCH_EVENTS = [
* these canonical stage ids; any column that does not map to a known stage is
* folded into an "other" bucket so custom workflow columns still contribute a
* count rather than being silently dropped.
*
* FNXC:WorkflowLifecycleColumns 2026-07-30-12:30 DELIBERATE-LITERAL: this is an ALIAS TABLE of
* column NAMES, not a lifecycle guard — `triage` sits beside `signal` and `backlog` as one of
* several names operators give the same funnel stage. Command Center aggregates across
* PROJECTS, so there is no single workflow to resolve traits from here; the honest conversion
* needs per-project trait resolution feeding this panel, which is a data change rather than a
* predicate change. Until then the "other" bucket keeps unrecognised columns counted instead
* of dropped, which is what stops a renamed board from silently reading as empty.
*
* Recorded for the U12 literal ratchet's allowlist; grep DELIBERATE-LITERAL to enumerate.
*/
const FUNNEL_STAGES: Array<{ id: string; match: (column: string) => boolean }> = [
{ id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" },

View File

@@ -29,6 +29,7 @@ import type { SectionId } from "../SettingsModal";
import type { MainContentProps } from "./types";
export function MainContent({
columnFlagsByTaskId,
showBackendConnectionErrorPage,
projectsError,
t,
@@ -555,6 +556,7 @@ export function MainContent({
<Suspense fallback={null}>
<DocumentsView
projectId={currentProject?.id}
columnFlagsByTaskId={columnFlagsByTaskId}
addToast={addToast}
onOpenDetail={openDetailTask}
onOpenArtifactTaskDetail={popOutTaskDetail}

View File

@@ -62,6 +62,13 @@ import { TodoView } from "../TodoView";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
export interface MainContentProps {
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-12:15: board-workflow column traits per task id, the
same map the footer's live-agent predicate uses. Optional: absent for remote rows and for
columns not on the current board, where the consumer degrades to the documented legacy names
rather than guessing.
*/
columnFlagsByTaskId?: ReadonlyMap<string, { complete?: boolean; archived?: boolean; intake?: boolean; hold?: boolean }>;
showBackendConnectionErrorPage: boolean;
projectsError: string | null;
t: TFunction;

View File

@@ -1,5 +1,7 @@
import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
import { resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
import { ACTIVE_STATUSES } from "../utils/taskActivity";
export type ModelSelection = ResolvedModelSelection;
@@ -145,7 +147,7 @@ export function resolveEffectiveValidator(
export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => {
if (entry.agent !== "triage" || !isEngineMarkerEntryType(entry.type)) return;
if (entry.agent !== PLANNER_AGENT_ROLE || !isEngineMarkerEntryType(entry.type)) return;
const match = parseRuntimeModelMarker(entry.text, "Planning");
if (match) {
result = match;

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
import { normalizeColumnId } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE, normalizeColumnId } from "@fusion/core";
import * as api from "../api";
import { subscribeSse } from "../sse-bus";
import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
@@ -159,7 +161,7 @@ function addRecentPlannerActivityForFreshAgentLog(task: Task, entry: AgentLogAct
if (
!PLANNER_ACTIVITY_COLUMN_IDS.has(task.column)
|| task.status === "planning"
|| entry.agent !== "triage"
|| entry.agent !== PLANNER_AGENT_ROLE
|| !hasFreshAgentLog(task, entry)
) {
return task;

View File

@@ -0,0 +1,303 @@
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-09:55 (Phase C convergence — executor.ts):
TWO EXECUTOR DECISIONS THAT NAMED THE DEFAULT LINEAGE'S COLUMNS, and what each one
silently stopped doing on a renamed board:
1. STRANDED-COMPLETED RECOVERY (`recoverCompletedTask`). `promotedFromPlannerColumn` was
`originColumn === "todo" || === "triage"`. On a renamed board it was false, so
finished work resting in the planning lane was not promoted — the code fell through to
`handoffTaskToReview` straight from the planning column, and role adjacency has no
planning -> review edge, so the handoff was rejected and the card stayed stranded with
its work complete. This is the recovery of LAST RESORT; a literal here means the last
resort does not exist off the default lineage.
2. PLANNING EVACUATION (the `task:moved` branch). `from === "todo" || === "triage"`
decided whether a card had been pulled BACKWARD out of a lane where pre-execution graph
work runs. On a renamed board a withdrawn card kept its reviewer streaming and its
pre-execution worktree on disk.
THE PROMOTION TARGET IS CONVERTED TOO, deliberately. Resolving the planner lane and then
moving to a literal `in-progress` is the half-conversion this program has already been
burned by twice: the guard starts admitting cards on a renamed board and the move then
sends them to a column that board does not declare — strictly worse than refusing, because
the refusal was at least visible.
*/
import { describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore } from "./executor-test-helpers.js";
import type { WorkflowIr } from "@fusion/core";
/** Standard traits, non-default names, intake and hold SEPARATE (pre-U11 shape renamed). */
const RENAMED_SPLIT_IR = {
version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
/** The post-U11 MERGED shape, renamed: one column carries intake AND hold. */
const RENAMED_MERGED_IR = {
version: "v2", id: "wf-merged", name: "merged", nodes: [], edges: [],
columns: [
{
id: "planning",
name: "Planning",
traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }],
},
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
function completedTaskIn(column: string) {
return {
id: "FN-STRANDED",
title: "completed but stranded",
description: "",
column,
worktree: "/repo/.worktrees/stranded",
branch: "fusion/fn-stranded",
steps: [{ name: "Implement", status: "done" as const }],
currentStep: 0,
dependencies: [],
log: [],
executionMode: "normal",
/*
FIXTURE NOTE: the promotion seam is only REACHED when recovery has nothing left to gate.
With unsatisfied pre-merge gates, `recoverCompletedTask` re-enters the workflow graph and
returns before ever classifying the origin column — so a fixture without these passed rows
silently tests the graph re-entry branch instead, and every assertion below reads as "no
moves happened" for a reason that has nothing to do with column vocabulary.
*/
enabledWorkflowSteps: ["plan-review", "code-review"],
workflowStepResults: [
{ workflowStepId: "plan-review", phase: "pre-merge", status: "passed" },
{ workflowStepId: "code-review", phase: "pre-merge", status: "passed" },
],
createdAt: "2026-07-30T00:00:00.000Z",
updatedAt: "2026-07-30T00:00:00.000Z",
};
}
function harness(ir: WorkflowIr | undefined, column: string) {
const store = createMockStore();
let task: Record<string, unknown> = completedTaskIn(column);
const moves: Array<[string, string]> = [];
(store as unknown as { resolveTaskWorkflowIrSync: (id: string) => WorkflowIr | undefined })
.resolveTaskWorkflowIrSync = () => ir;
store.getTask.mockImplementation(async () => ({ ...task }));
store.updateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => {
task = { ...task, ...updates };
return task;
});
store.moveTask.mockImplementation(async (id: string, to: string) => {
moves.push([id, to]);
task = { ...task, column: to };
return { ...task };
});
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const executor = new TaskExecutor(store as never, "/repo");
/*
The review handoff is the boundary AFTER the decision under test — it opens sessions and
talks to git. Stubbing it keeps the assertion on the promotion moves; without the stub the
test would fail for reasons unrelated to which column the promotion targeted.
*/
const handoff = vi
.spyOn(executor as unknown as { handoffTaskToReview: (...a: unknown[]) => Promise<void> }, "handoffTaskToReview")
.mockResolvedValue(undefined);
return { store, executor, moves, handoff, task: () => task };
}
describe("stranded-completed recovery promotes through the task's OWN planner lanes", () => {
it("re-homes intake -> hold -> wip on a renamed board that separates the two roles", async () => {
const h = harness(RENAMED_SPLIT_IR, "backlog");
const recovered = await h.executor.recoverCompletedTask(completedTaskIn("backlog") as never);
expect(recovered).toBe(true);
// Pre-fix: `backlog` matched neither literal, so NO promotion happened and the handoff
// was attempted from the planning column, which role adjacency rejects.
expect(h.moves).toEqual([["FN-STRANDED", "queued"], ["FN-STRANDED", "building"]]);
expect(h.handoff).toHaveBeenCalled();
});
it("takes the single hop when the card is already in the renamed hold lane", async () => {
const h = harness(RENAMED_SPLIT_IR, "queued");
await h.executor.recoverCompletedTask(completedTaskIn("queued") as never);
expect(h.moves).toEqual([["FN-STRANDED", "building"]]);
});
it("collapses to a single hop on a MERGED planning column (the post-U11 shape)", async () => {
// hold === intake here, so the re-home would be a no-op move; it must not be emitted.
const h = harness(RENAMED_MERGED_IR, "planning");
await h.executor.recoverCompletedTask(completedTaskIn("planning") as never);
expect(h.moves).toEqual([["FN-STRANDED", "building"]]);
});
it("does NOT promote a card that is not in a planner lane at all", async () => {
// The paired negative: "always promote" must not pass for "resolve the lanes". A card in
// the review lane is already past planning and owns its own handoff.
const h = harness(RENAMED_SPLIT_IR, "checking");
await h.executor.recoverCompletedTask(completedTaskIn("checking") as never);
expect(h.moves).toEqual([]);
expect(h.handoff).toHaveBeenCalled();
});
it("still promotes on the default lineage (the conversion is not a rename)", async () => {
const h = harness(undefined, "todo");
await h.executor.recoverCompletedTask(completedTaskIn("todo") as never);
expect(h.moves).toEqual([["FN-STRANDED", "in-progress"]]);
});
});
describe("planner-column classification for the planning-evacuation branch", () => {
it("recognises both renamed planner lanes and nothing else", () => {
const h = harness(RENAMED_SPLIT_IR, "backlog");
const isPlanner = (column: string) =>
(h.executor as unknown as { isPlannerColumnFor: (id: string, c: string) => boolean })
.isPlannerColumnFor("FN-STRANDED", column);
expect(isPlanner("backlog")).toBe(true);
expect(isPlanner("queued")).toBe(true);
expect(isPlanner("building")).toBe(false);
expect(isPlanner("checking")).toBe(false);
// The default lineage's names are NOT planner lanes on this board — the point of the
// conversion is that the answer follows the workflow, in both directions.
expect(isPlanner("todo")).toBe(false);
expect(isPlanner("triage")).toBe(false);
});
it("falls back to the legacy pair when the workflow cannot be resolved", () => {
const h = harness(undefined, "todo");
const isPlanner = (column: string) =>
(h.executor as unknown as { isPlannerColumnFor: (id: string, c: string) => boolean })
.isPlannerColumnFor("FN-STRANDED", column);
expect(isPlanner("todo")).toBe(true);
expect(isPlanner("triage")).toBe(true);
expect(isPlanner("in-progress")).toBe(false);
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-17:05 (PR #2628 review — greptile P1 x2):
Both findings are over-reaches in my own first version, and the first one made the branch WORSE
than the bug it replaced. Recording that plainly because it is the third time this program has
produced the same shape: role-aware gate, name-matched destinations.
1. FORWARD MOVES TRIGGERED EVACUATION. The evacuation branch's source check became role-aware
while its destination exclusions stayed literal, so on a renamed board an ordinary forward
move (planning -> building) passed the source test and matched no exclusion. The evacuation
fired on a card that was simply advancing: live planning work aborted, valid pre-execution
worktree deleted. Before the conversion the source check failed and nothing happened — so a
half-conversion turned a missed rescue into active damage.
2. A MISSING WIP ROLE INVENTED A COLUMN. `resolvePlannerLanes` substituted the legacy
`in-progress` when a workflow declared no WIP role, so the promotion targeted a column that
board does not declare. `moveTask` rejects it, recovery reports failure — and since the
intake -> hold re-home runs FIRST, the card could be left half-moved. Now `wip`/`review`/
`complete` are OPTIONAL when the workflow speaks columns, and the caller refuses BEFORE any
move.
*/
/** Planning lanes but NO wip role — a legal shape with nowhere to promote completed work to. */
const NO_WIP_IR = {
version: "v2", id: "wf-no-wip", name: "no-wip", nodes: [], edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
describe("a forward move off a renamed planner lane is not an evacuation", () => {
const isBackward = (h: ReturnType<typeof harness>, from: string, to: string) =>
(h.executor as unknown as { isBackwardMoveOutOfPlanning: (id: string, f: string, t: string) => boolean })
.isBackwardMoveOutOfPlanning("FN-STRANDED", from, to);
it("does NOT evacuate a card advancing into the renamed wip/review/complete lanes", () => {
// Pre-fix each of these returned true, so the executor aborted live planning work and
// deleted the pre-execution worktree of a card that was merely advancing.
const h = harness(RENAMED_SPLIT_IR, "backlog");
expect(isBackward(h, "backlog", "building")).toBe(false);
expect(isBackward(h, "queued", "checking")).toBe(false);
expect(isBackward(h, "queued", "shipped")).toBe(false);
});
it("DOES evacuate a card withdrawn to a non-lifecycle column", () => {
// The paired positive: the branch must still fire for the case it was written for
// (the reported symptom was todo -> Ideas).
const h = harness(RENAMED_SPLIT_IR, "backlog");
expect(isBackward(h, "backlog", "ideas")).toBe(true);
});
it("keeps the legacy answer when the workflow has no column vocabulary", () => {
const h = harness(undefined, "todo");
expect(isBackward(h, "todo", "in-progress")).toBe(false);
expect(isBackward(h, "todo", "in-review")).toBe(false);
expect(isBackward(h, "todo", "done")).toBe(false);
expect(isBackward(h, "todo", "ideas")).toBe(true);
});
it("never fires for a card that was not in a planner lane", () => {
const h = harness(RENAMED_SPLIT_IR, "building");
expect(isBackward(h, "building", "ideas")).toBe(false);
});
});
describe("a workflow with no WIP lane is refused, not promoted to an invented column", () => {
it("withholds recovery without issuing ANY move", async () => {
// Pre-fix: the intake -> hold re-home was issued first, then the promotion targeted the
// undeclared `in-progress` and was rejected — leaving the card half-moved.
const h = harness(NO_WIP_IR, "backlog");
const recovered = await h.executor.recoverCompletedTask(completedTaskIn("backlog") as never);
expect(recovered).toBe(false);
expect(h.moves).toEqual([]);
expect(h.handoff).not.toHaveBeenCalled();
});
it("says so in the task log rather than skipping the card silently", async () => {
// Nothing else owns this state, so a silent withhold is indistinguishable from the
// stranding this recovery exists to fix.
const h = harness(NO_WIP_IR, "queued");
await h.executor.recoverCompletedTask(completedTaskIn("queued") as never);
const messages = (h.store.logEntry as unknown as { mock: { calls: unknown[][] } }).mock.calls
.map((call) => String(call[1] ?? ""));
expect(messages.some((m) => m.includes("no WIP column"))).toBe(true);
});
it("still promotes when the workflow DOES declare a wip lane", async () => {
// The paired negative: "refuse when a role is missing" must not become "refuse always".
const h = harness(RENAMED_SPLIT_IR, "queued");
await h.executor.recoverCompletedTask(completedTaskIn("queued") as never);
expect(h.moves).toEqual([["FN-STRANDED", "building"]]);
});
});

View File

@@ -20,7 +20,7 @@ import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFa
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js";
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
import { moveTaskToReplanColumn, resolvePlannerLanes, resolveReplanTargetColumn } from "./replan-target.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult, WorkflowWorkItem } from "@fusion/core";
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult, type WorkflowColumnBoundaryHooks } from "./workflow-graph-task-runner.js";
import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js";
@@ -2873,6 +2873,60 @@ export class TaskExecutor {
this.activeSubagentSessions.delete(taskId);
}
/**
* FNXC:WorkflowLifecycleColumns 2026-07-30-09:40 (Phase C convergence):
* Is `column` one of THIS task's planner lanes (intake or hold)?
*
* Used by the planning-evacuation branch of the `task:moved` handler, which is why it is
* synchronous: that handler runs off a synchronous emitter, and an `await` here would
* reorder it against the other listeners.
*
* WHAT THE GUARD IS FOR, checked rather than assumed: the branch asks "was this card
* pulled BACKWARD out of a lane where pre-execution graph work is running?" — Plan Review
* and the planning session both run while the card sits there. Named literals answered that
* only on the default lineage, so on a renamed board a withdrawn card kept its reviewer
* streaming and its pre-execution worktree on disk.
*/
private isPlannerColumnFor(taskId: string, column: string): boolean {
const lanes = resolvePlannerLanes(this.store, taskId);
return column === lanes.hold || column === lanes.intake;
}
/**
* Was this card pulled BACKWARD out of a planner lane — as opposed to advancing forward
* out of it?
*
* FNXC:WorkflowLifecycleColumns 2026-07-30-16:55 (PR #2628 review, greptile P1):
* THE FORWARD EXCLUSIONS MUST RESOLVE TOO, and leaving them literal made this branch WORSE
* than before I touched it. With a role-aware source check and name-matched destinations, a
* renamed board's ordinary FORWARD move (planning -> building) passed the source test and
* matched none of the exclusions, so the evacuation fired on a card that was simply
* advancing: it aborted live planning work and deleted the valid pre-execution worktree.
* Before the conversion the source check failed and nothing happened; a half-conversion
* turned a missed rescue into active damage. Third time this program has produced that
* shape — gates converted, destinations left literal.
*
* Forward means the workflow's own wip, review, or complete lane. When a role is not
* declared it cannot be a forward target, so it is simply not excluded; when the workflow
* has no column vocabulary at all, `resolvePlannerLanes` returns the legacy names and this
* reads exactly as it did before.
*/
private isBackwardMoveOutOfPlanning(taskId: string, from: string, to: string): boolean {
const lanes = resolvePlannerLanes(this.store, taskId);
if (from !== lanes.hold && from !== lanes.intake) return false;
const forwardTargets = [lanes.wip, lanes.review, lanes.complete].filter(
(column): column is string => typeof column === "string",
);
/*
DELIBERATELY NOT ALSO EXCLUDING planner-to-planner moves. The literal version fired the
evacuation on `todo -> triage` (a replan rebound), and whether that is right is a separate
question from this review fix — the replan path is engine-initiated, so aborting the planning
session there may be exactly wrong, but changing it is a behavior change with its own
surfaces to enumerate. This conversion keeps that case behaving as it does today.
*/
return !forwardTargets.includes(to);
}
/**
* FN-5256: register an in-flight disposal so a subsequent dispatch (task:moved
* → in-progress) can await it before acquiring/creating a worktree. Swallows
@@ -3407,7 +3461,7 @@ export class TaskExecutor {
}
}),
);
} else if ((from === "todo" || from === "triage") && to !== "in-progress" && to !== "in-review" && to !== "done") {
} else if (this.isBackwardMoveOutOfPlanning(task.id, from, to)) {
/*
FNXC:PlanningEvacuation 2026-07-25-23:00:
A card pulled BACKWARD out of a planner lane (the reported case: todo → Ideas) must stop all
@@ -4949,7 +5003,37 @@ export class TaskExecutor {
}
await this.persistTokenUsage(task.id);
const originColumn = task.column;
const promotedFromPlannerColumn = originColumn === "todo" || originColumn === "triage";
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-09:30 (Phase C convergence):
Resolved from the task's OWN workflow. On a renamed board the literals matched nothing,
so completed work stranded in the planning lane was NOT recognised as needing promotion:
the code fell through to `handoffTaskToReview` directly from the planning column, and
role adjacency has no planning -> review edge, so the handoff move was rejected and the
card stayed stranded with its work finished and nothing left to rescue it. This is the
recovery of last resort — a literal here means the last resort does not exist off the
default lineage.
*/
const plannerLanes = resolvePlannerLanes(this.store, task.id);
const promotedFromPlannerColumn = originColumn === plannerLanes.hold || originColumn === plannerLanes.intake;
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-16:45 (PR #2628 review, greptile P1):
REFUSE BEFORE THE FIRST MOVE when the workflow declares no WIP lane. The previous version
let `resolvePlannerLanes` substitute the legacy `in-progress`, so the promotion targeted a
column that board does not declare: `moveTask` rejects it, recovery reports failure — and
because the intake -> hold re-home happens FIRST, the card could be left half-moved, which
is worse than the stranding this recovery exists to fix.
Checked here rather than at the move so no partial hop is issued. A workflow with planning
lanes and no WIP lane has nowhere to promote completed work TO; that is an operator
configuration question, not something to guess past. Logged so the card is not silently
skipped — the whole point of this recovery is that nothing else owns this state.
*/
if (promotedFromPlannerColumn && plannerLanes.wip === undefined) {
const message = `Auto-recovery withheld: completed work is in '${originColumn}' but this workflow declares no WIP column to promote it to`;
executorLog.warn(`${task.id}: ${message}`);
await this.store.logEntry(task.id, message).catch(() => undefined);
return false;
}
let completionTask = task;
if (promotedFromPlannerColumn) {
this.recoveringCompleted.add(task.id);
@@ -4961,8 +5045,15 @@ export class TaskExecutor {
triage -> todo -> in-progress path while the recovery ownership set prevents
scheduler/executor dispatch. Todo callers retain their existing single hop.
*/
if (originColumn === "triage") {
completionTask = await this.store.moveTask(task.id, "todo", {
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-09:30: the two-hop is needed whenever the
card sits in a DISTINCT intake lane, because role adjacency gives intake only
hold/archived — never wip. Post-U11 the default lineage merges the two roles onto one
column, so `hold === intake` and the hop correctly collapses to the single move below;
a board that still separates them (pre-U11, or a custom lineage) keeps the re-home.
*/
if (originColumn === plannerLanes.intake && plannerLanes.hold !== plannerLanes.intake) {
completionTask = await this.store.moveTask(task.id, plannerLanes.hold, {
moveSource: "engine",
recoveryRehome: true,
bypassGuards: true,
@@ -4971,7 +5062,8 @@ export class TaskExecutor {
preserveResumeState: true,
});
}
completionTask = await this.store.moveTask(task.id, "in-progress");
// Non-undefined: the guard above returned early when this workflow declares no WIP lane.
completionTask = await this.store.moveTask(task.id, plannerLanes.wip as string);
}
await this.handoffTaskToReview(completionTask, "completed-task-recovered");
if (promotedFromPlannerColumn) {

View File

@@ -1,5 +1,6 @@
import type { Task, TaskStore } from "@fusion/core";
import { resolveWorkflowIrForTask, workflowHasColumn } from "@fusion/core";
import { resolveLifecycleColumns, resolveWorkflowIrForTask, workflowHasColumn } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
/*
FNXC:WorkflowReplan 2026-07-12-23:15:
@@ -31,6 +32,87 @@ Recorded here for the U12 literal ratchet's allowlist. That ratchet does not exi
tree yet; grep `DELIBERATE-LITERAL` to enumerate the sites it must admit, with the reason
attached at the site rather than in a separate list that can drift from it.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-09:20 (Phase C convergence — shared planner lanes):
MOVED, NOT CHANGED. This is triage.ts's private `resolvePlannerLanes` verbatim, lifted here
so the executor can ask the same question with the same answer. triage.ts now delegates to
it; every returned value, the fail-soft legacy pair, and the synchronous shape are
unchanged, because the executor sites that need it sit in a `task:moved` listener where
introducing an `await` would reorder handlers relative to a synchronous emitter.
Its original note, preserved because it is still the reason for every property:
Triage's column decisions are all one of two questions — "is this card in a planner
lane?" (hold or intake) and "where does a finished plan get released to?" (hold). Under a
renamed workflow every literal answer silently stops matching; after U11 deletes `triage`
from the builtins they stop matching everywhere. Fail-soft to the legacy pair so an
unresolvable or column-less workflow behaves exactly as before.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-09:35 (Phase C convergence):
`wip` is returned alongside the two planner lanes because a caller that PROMOTES a card out
of planning needs both halves at once. Resolving the planner lane and then moving to a
literal `in-progress` is the half-conversion this program has already been burned by twice:
the guard starts admitting cards on a renamed board and the move then sends them to a column
that board does not declare — strictly worse than refusing, because the refusal was visible.
*/
export interface PlannerLanes {
hold: string;
intake: string;
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-16:40 (PR #2628 review — greptile P1 x2):
`wip`, `review` and `complete` are OPTIONAL, and that is the whole correction. The first
version substituted the legacy id whenever a role was missing, so a workflow that declares
columns but no WIP role got `moveTask(..., "in-progress")` — a column that board does not
declare. The move is rejected, the caller reports failure, and the card can be left half-moved.
A MISSING ROLE IS NOT A LICENCE TO INVENT A COLUMN; the caller must refuse instead.
`resolvedFromWorkflow` distinguishes the two cases a single `?? legacy` collapsed:
- false: no column vocabulary at all (v1 IR, unresolvable store). No basis to decide, so the
legacy names ARE the answer and every role is populated.
- true: the workflow speaks columns. Roles it does not declare stay undefined, because
substituting there is the invention above.
*/
wip: string | undefined;
review: string | undefined;
complete: string | undefined;
/** True when the answer came from the task's workflow rather than the legacy fallback. */
resolvedFromWorkflow: boolean;
}
const LEGACY_PLANNER_LANES: PlannerLanes = {
hold: "todo",
intake: "triage",
wip: "in-progress",
review: "in-review",
complete: "done",
resolvedFromWorkflow: false,
};
export function resolvePlannerLanes(store: TaskStore, taskId: string): PlannerLanes {
try {
const ir = (store as unknown as { resolveTaskWorkflowIrSync?: (id: string) => WorkflowIr }).resolveTaskWorkflowIrSync?.(taskId);
const lifecycle = ir ? resolveLifecycleColumns(ir) : undefined;
if (!lifecycle) return LEGACY_PLANNER_LANES;
return {
/*
hold and intake still fall back INDIVIDUALLY: a workflow that declares columns but no hold
column has its planning work rest in intake (and vice versa), which is a real shape rather
than an invented column — both are planner lanes for the callers that ask. The forward
lanes are different: a move needs a column that exists.
*/
hold: lifecycle.hold ?? lifecycle.intake ?? "todo",
intake: lifecycle.intake ?? lifecycle.hold ?? "triage",
wip: lifecycle.wip,
review: lifecycle.review,
complete: lifecycle.complete,
resolvedFromWorkflow: true,
};
} catch {
return LEGACY_PLANNER_LANES;
}
}
/*
* FNXC:WorkflowReplan 2026-07-15-13:15:
* FN-7977: a planning/provider recovery may finish after another engine lane has
@@ -125,11 +207,15 @@ export function hasAdvancedPastPlanning(
while a replan is a legitimate BACKWARD move. `firstExecutionAt`/`executionStartedAt` are never
cleared once implementation starts, so a card that executed, failed Plan Review, and was rebounded
to a planner lane (`needs-replan`) read as "advanced past planning" forever: triage's discovery
filter (`column === "triage" && isTaskStillInPlanningStage`) never re-admitted it and the card sat
in triage/needs-replan permanently — "stuck in planning" on the board (FN-8594). It hit every
triage-column workflow (builtin:coding, the default); plan-in-place Ideas cards escaped only
because todo discovery admits `needs-replan` without consulting this guard.
This check covers BOTH planner lanes — the "triage" column and the plan-in-place "todo" lane.
filter (intake column AND `isTaskStillInPlanningStage`) never re-admitted it and the card sat
parked in the intake lane with `needs-replan` permanently — "stuck in planning" on the board
(FN-8594). It hit every workflow with a SEPARATE intake column (builtin:coding, the default at
the time); plan-in-place cards escaped only because hold-lane discovery admits `needs-replan`
without consulting this guard.
This check covers BOTH planner lanes — the intake column and the plan-in-place hold lane.
FNXC:WorkflowLifecycleColumns 2026-07-30-10:40: the column names in this note were the census
pattern's only hits in this file; they were always prose about a filter that lives in triage.ts,
never a guard here. Restated by ROLE so the history stays readable after a rename.
*/
if (task.status != null && REPLAN_PARK_STATUSES.has(task.status)) {
return false;
@@ -181,12 +267,12 @@ export function hasAdvancedPastPlanning(
status left a hole that stranded the same card a second time: after the stale-status sweep
cleared `planning` to null, the card had stale stamps and NO status, so planning excluded it
(stamps read as advanced) AND `recoverAdvancedTriageTasks` — the designated owner of that
"stranded-advanced" class — also excluded it, because it bails on
`workflowIrPinColumnId === "triage"` (it cannot resume a card into the column it already sits
in). Nobody owned the card and it sat indefinitely.
"stranded-advanced" class — also excluded it, because it bails when the pinned column IS the
card's own intake column (it cannot resume a card into the column it already sits in). Nobody
owned the card and it sat indefinitely.
Arrival order alone is the honest signal: a stamp written BEFORE the card reached the planner
column belongs to a previous pass, whatever the status is now. A card that genuinely advanced
out of triage is caught by the column check at the top, and one that was claimed by execution
out of the planner lane is caught by the column check at the top, and one that was claimed by execution
AFTER landing here has a stamp NEWER than its arrival, so it still reads advanced and stays with
the advanced-recovery sweep.
*/

View File

@@ -118,7 +118,7 @@ import type {
AgentSession,
} from "@earendil-works/pi-coding-agent";
import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js";
import { hasAdvancedPastPlanning, isTaskStillInPlanningStage } from "./replan-target.js";
import { hasAdvancedPastPlanning, isTaskStillInPlanningStage, resolvePlannerLanes } from "./replan-target.js";
import {
createResolvedAgentSession,
extractRuntimeHint,
@@ -314,16 +314,11 @@ NOTE FOR U11: once `triage` carries the capacity hold, `hold` and `intake` resol
to the SAME column. Every `hold || intake` check below then collapses to one
column, and the release move becomes a no-op the guard already skips — which is
the intended end state, not a degenerate case.
MOVED to `replan-target.ts` (2026-07-30) so the executor's stranded-completed recovery and
planning-evacuation branches resolve the SAME lanes rather than growing a second copy. The
note above is kept here because this is where the eight decisions it describes live.
*/
function resolvePlannerLanes(store: TaskStore, taskId: string): { hold: string; intake: string } {
try {
const ir = (store as unknown as { resolveTaskWorkflowIrSync?: (id: string) => WorkflowIr }).resolveTaskWorkflowIrSync?.(taskId);
const lifecycle = ir ? resolveLifecycleColumns(ir) : undefined;
return { hold: lifecycle?.hold ?? "todo", intake: lifecycle?.intake ?? "triage" };
} catch {
return { hold: "todo", intake: "triage" };
}
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-19:10 (U11 — STALL 3):
The pre-implementation column ids that shipped as the builtin lifecycle vocabulary,

View File

@@ -11,7 +11,9 @@
*/
import type { Task, TaskStore } from "@fusion/core";
import { resolveTaskLifecycleColumns, type WorkflowIr } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:00: `agentType` is an AGENT ROLE, not a column.
// The planner lane is named `triage` and keeps that name; only the COLUMN was removed by U11.
import { PLANNER_AGENT_ROLE, resolveTaskLifecycleColumns, type WorkflowIr } from "@fusion/core";
import {
resolveExecutorSessionModel,
resolveMergerSessionModel,
@@ -141,7 +143,7 @@ export class UsageLimitPauser {
vocabulary conversion, not to this fix.
*/
const isPreImplementation = preImplementationColumns?.has(task.column) === true;
const providersByActiveLane = agentType === "triage"
const providersByActiveLane = agentType === PLANNER_AGENT_ROLE
? (isPreImplementation ? [
resolvePlanningSessionModel(task.planningModelProvider, task.planningModelId, settings).provider,
resolveValidatorSessionModel(task.validatorModelProvider, task.validatorModelId, settings).provider,
@@ -204,7 +206,7 @@ export class UsageLimitPauser {
*/
const irCache = new Map<string, WorkflowIr>();
const preImplementationByTask = new Map<string, ReadonlySet<string>>();
if (agentType === "triage") {
if (agentType === PLANNER_AGENT_ROLE) {
await Promise.all(tasks.map(async (task) => {
const columns = await resolveTaskLifecycleColumns(this.store, task.id, irCache).catch(() => undefined);
/*