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:
7
.changeset/dashboard-planner-role-and-documents-dot.md
Normal file
7
.changeset/dashboard-planner-role-and-documents-dot.md
Normal 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`.
|
||||||
7
.changeset/executor-planner-lanes-resolved.md
Normal file
7
.changeset/executor-planner-lanes-resolved.md
Normal 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`.
|
||||||
7
.changeset/live-agent-count-legacy-fallback.md
Normal file
7
.changeset/live-agent-count-legacy-fallback.md
Normal 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.
|
||||||
7
.changeset/reopen-semantics-by-role.md
Normal file
7
.changeset/reopen-semantics-by-role.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
243
packages/core/src/__tests__/reopen-semantics-by-role.test.ts
Normal file
243
packages/core/src/__tests__/reopen-semantics-by-role.test.ts
Normal 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentCapability, AgentPromptTemplate, AgentPromptsConfig } from "./types.js";
|
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)
|
// 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)}`;
|
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 `${CONCISE_TRIAGE_PROMPT_TEXT}\n\n${buildConciseTriageHeartbeatGuidance(options)}`;
|
||||||
}
|
}
|
||||||
return template.prompt;
|
return template.prompt;
|
||||||
@@ -1477,7 +1480,7 @@ export function resolveAgentPrompt(
|
|||||||
|
|
||||||
// Fall back to built-in default for the role
|
// Fall back to built-in default for the role
|
||||||
const builtIn = BUILTIN_AGENT_PROMPTS.find((t) => t.role === role && t.id === `default-${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 `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
|
||||||
}
|
}
|
||||||
return builtIn?.prompt ?? "";
|
return builtIn?.prompt ?? "";
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getTraitRegistry } from "./trait-registry.js";
|
import { getTraitRegistry } from "./trait-registry.js";
|
||||||
|
import type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
|
||||||
import type { TraitAuditWarning } from "./trait-registry.js";
|
import type { TraitAuditWarning } from "./trait-registry.js";
|
||||||
import { getTaskMergeBlocker } from "./task-merge.js";
|
import { getTaskMergeBlocker } from "./task-merge.js";
|
||||||
import type { Settings, Task } from "./types.js";
|
import type { Settings, Task } from "./types.js";
|
||||||
@@ -98,6 +99,23 @@ export interface DefaultWorkflowMoveContext {
|
|||||||
preserveWorktree?: boolean;
|
preserveWorktree?: boolean;
|
||||||
preservePause?: 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). */
|
/** Reset all steps to pending + currentStep 0 (store owns the impl). */
|
||||||
resetSteps: () => void;
|
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 {
|
export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void {
|
||||||
const { task, fromColumn, toColumn, moveSource, options } = ctx;
|
const { task, fromColumn, toColumn, moveSource, options } = ctx;
|
||||||
const isReopenToTodoOrTriage =
|
if (!isReopenIntoPlanning(ctx.lifecycleColumns, fromColumn, toColumn)) return;
|
||||||
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") &&
|
|
||||||
(toColumn === "todo" || toColumn === "triage");
|
|
||||||
if (!isReopenToTodoOrTriage) return;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowLifecycle 2026-07-12-09:05:
|
FNXC:WorkflowLifecycle 2026-07-12-09:05:
|
||||||
@@ -179,8 +234,19 @@ export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void
|
|||||||
task.paused = undefined;
|
task.paused = undefined;
|
||||||
task.pausedByAgentId = 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;
|
task.userPaused = true;
|
||||||
} else if (!options.preservePause) {
|
} else if (!options.preservePause) {
|
||||||
task.userPaused = undefined;
|
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
|
/** `merge` trait onEnter (in-review): scheduler-state clearing while
|
||||||
* preserving explicit per-task autoMerge overrides. The queue enqueue itself is
|
* preserving explicit per-task autoMerge overrides. The queue enqueue itself is
|
||||||
* in-txn and store-owned (handoff path); the field effects mirror the legacy
|
* 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")
|
executor's documented bounce invariant ("moveTask(in-review->todo) already clears ALL results")
|
||||||
survives unchanged.
|
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"
|
const graphOwnedReviewToWip = ctx.workflowMoveSource === "workflow-graph"
|
||||||
&& fromColumn === "in-review"
|
&& fromColumn === reviewLane
|
||||||
&& toColumn === "in-progress";
|
&& toColumn === wipLane;
|
||||||
if (
|
const leftReviewForPlanningOrWip =
|
||||||
!graphOwnedReviewToWip
|
fromColumn === reviewLane && (planning.includes(toColumn) || toColumn === wipLane);
|
||||||
&& ((fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage"))
|
const leftCompleteForPlanning = fromColumn === completeLane && planning.includes(toColumn);
|
||||||
|| (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")))
|
if (!graphOwnedReviewToWip && (leftReviewForPlanningOrWip || leftCompleteForPlanning)) {
|
||||||
) {
|
|
||||||
task.workflowStepResults = undefined;
|
task.workflowStepResults = undefined;
|
||||||
}
|
}
|
||||||
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
|
if (fromColumn === reviewLane && planning.includes(toColumn)) {
|
||||||
task.branch = undefined;
|
task.branch = undefined;
|
||||||
task.executionStartBranch = undefined;
|
task.executionStartBranch = undefined;
|
||||||
task.baseCommitSha = undefined;
|
task.baseCommitSha = undefined;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export type {
|
|||||||
MissionLineageApprovalResult,
|
MissionLineageApprovalResult,
|
||||||
MissionLineageSnapshot,
|
MissionLineageSnapshot,
|
||||||
} from "./symbol-lock-lineage-approval.js";
|
} 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 {
|
export {
|
||||||
resolveEntryPointBranchAssignment,
|
resolveEntryPointBranchAssignment,
|
||||||
sanitizeBranchSegment,
|
sanitizeBranchSegment,
|
||||||
|
|||||||
@@ -65,6 +65,37 @@ export function resolveColumnTerminalKind(columnId: string, ir: WorkflowIr): Col
|
|||||||
return "none";
|
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. */
|
/** 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">> {
|
export function enrichRunningAgentTaskShape<T extends RunningAgentTaskShape>(task: T, ir: WorkflowIr): T & Required<Pick<RunningAgentTaskShape, "columnTerminalKind" | "columnIsIntakeOrHold" | "columnCountsTowardWip" | "columnIsReviewOrMerge">> {
|
||||||
return {
|
return {
|
||||||
@@ -81,18 +112,13 @@ export function enrichRunningAgentTaskShapeFromFlags<T extends RunningAgentTaskS
|
|||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
columnTerminalKind: flags?.archived ? "archived" : flags?.complete ? "complete" : "none",
|
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",
|
columnCountsTowardWip: flags ? flags.countsTowardWip === true : task.column === "in-progress",
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowLifecycleColumns 2026-07-29-23:10:
|
FNXC:WorkflowLifecycleColumns 2026-07-29-23:10 (reason now at
|
||||||
These id fallbacks are REACHABLE, not fixture-only — callers may pass no flags for a column
|
`isLegacyPreImplementationColumn`): these id fallbacks are REACHABLE, not fixture-only —
|
||||||
absent from the board's flag map, which is the renamed or undeclared column case. A card in
|
a column absent from the board's flag map is the renamed-or-undeclared case. Supply flags
|
||||||
such a column then matches no arm and is counted as neither running nor waiting, so the
|
rather than relying on them.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
columnIsReviewOrMerge: flags ? flags.mergeOrchestration === true || flags.mergeBlocker === true : task.column === "in-review",
|
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. */
|
/** Exact footer waiting membership: unpaused, non-terminal intake/hold work that is not live. */
|
||||||
export function isWaitingAgentTask(task: RunningAgentTaskShape): boolean {
|
export function isWaitingAgentTask(task: RunningAgentTaskShape): boolean {
|
||||||
if (task.paused || task.userPaused || terminalKind(task) !== "none" || isRunningAgentTask(task)) return false;
|
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 {
|
export function countRunningAgentTasks(tasks: readonly RunningAgentTaskShape[]): number {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
evaluateCapacityRejection,
|
evaluateCapacityRejection,
|
||||||
evaluateTransitionInvariants,
|
evaluateTransitionInvariants,
|
||||||
} from "../workflow-transition-policy.js";
|
} 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 {makeTransitionRejection, makeTransitionPending} from "../transition-types.js";
|
||||||
import {writeTransitionPendingAsync, clearTransitionPendingAsync} from "./async-transition-pending.js";
|
import {writeTransitionPendingAsync, clearTransitionPendingAsync} from "./async-transition-pending.js";
|
||||||
import type {WorkflowIr} from "../workflow-ir-types.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;
|
task.updatedAt = movedAt;
|
||||||
|
|
||||||
if (useWorkflow) {
|
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
|
// ── Flag-ON: route the legacy per-column side effects through the
|
||||||
// default-workflow trait hooks (timing, reset-on-entry, abort-on-exit,
|
// default-workflow trait hooks (timing, reset-on-entry, abort-on-exit,
|
||||||
// merge.onEnter). "Moved, not duplicated" applies to this path; the
|
// 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,
|
preservePause: options?.preservePause,
|
||||||
},
|
},
|
||||||
resetSteps: () => store.resetAllStepsToPending(task),
|
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") &&
|
FNXC:WorkflowLifecycleColumns 2026-07-30-08:10: the store's own copy of the reopen
|
||||||
(toColumn === "todo" || toColumn === "triage");
|
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 hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
|
||||||
const preserveStepProgress =
|
const preserveStepProgress =
|
||||||
options?.preserveResumeState ||
|
options?.preserveResumeState ||
|
||||||
|
|||||||
@@ -346,6 +346,10 @@ export interface BatchStatusResponse {
|
|||||||
// ── task-log ──────────────────────────────────────────────────────────
|
// ── task-log ──────────────────────────────────────────────────────────
|
||||||
// FNXC:CodeOrganization 2026-07-22-14:00: Peels live in types/task-log.ts
|
// 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 {
|
import type {
|
||||||
StepStatus,
|
StepStatus,
|
||||||
WorkflowTransitionNotificationKind,
|
WorkflowTransitionNotificationKind,
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ export interface ActivityLogEntry {
|
|||||||
/** The set of agent roles that produce log entries. */
|
/** The set of agent roles that produce log entries. */
|
||||||
export type AgentRole = "triage" | "executor" | "reviewer" | "merger";
|
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:
|
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.
|
`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.
|
||||||
|
|||||||
@@ -1710,6 +1710,9 @@ function AppInner() {
|
|||||||
capacityRiskDismissed,
|
capacityRiskDismissed,
|
||||||
capacityRiskSignal,
|
capacityRiskSignal,
|
||||||
handleDismissCapacityRisk,
|
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,
|
AgentsView,
|
||||||
ChatView,
|
ChatView,
|
||||||
CommandCenter,
|
CommandCenter,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { AgentLogEntry } from "@fusion/core";
|
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 { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
@@ -101,7 +104,7 @@ export const markdownComponents: Components = {
|
|||||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
|
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
|
||||||
|
|
||||||
function getAgentDisplayName(agent: string, t: TFunction<"app">): string {
|
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;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LoadingSpinner } from "./LoadingSpinner";
|
|||||||
import { ArtifactsGallery, getArtifactCategory, type ArtifactCategory } from "./ArtifactsGallery";
|
import { ArtifactsGallery, getArtifactCategory, type ArtifactCategory } from "./ArtifactsGallery";
|
||||||
import { ViewHeader } from "./ViewHeader";
|
import { ViewHeader } from "./ViewHeader";
|
||||||
import { useColumnLabel } from "../i18n/labels";
|
import { useColumnLabel } from "../i18n/labels";
|
||||||
|
import type { ExecutorColumnFlags } from "../hooks/useExecutorStats";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
@@ -42,8 +43,17 @@ const TASK_ARTIFACT_CATEGORY_ICONS: Record<ArtifactCategory, typeof ImageIcon> =
|
|||||||
other: Package,
|
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 {
|
export interface DocumentsViewProps {
|
||||||
projectId?: string;
|
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;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
onOpenDetail: (task: TaskDetail) => void;
|
onOpenDetail: (task: TaskDetail) => void;
|
||||||
onOpenArtifactTaskDetail?: (task: TaskDetail) => void;
|
onOpenArtifactTaskDetail?: (task: TaskDetail) => void;
|
||||||
@@ -67,13 +77,38 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
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 === "done") return "status-dot status-dot--online";
|
||||||
if (taskColumn === "archived") return "status-dot status-dot--offline";
|
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";
|
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 {
|
function getTaskArtifactCategoryLabel(t: TFunction<"app">, category: ArtifactCategory): string {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "image": return t("documents.artifactCategoryImage", "Image");
|
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");
|
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.
|
// 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");
|
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")}>
|
<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 }) => {
|
{groupedTaskItems.map(({ taskId, taskTitle, taskColumn, documents: taskDocs, artifacts: taskArtifacts }) => {
|
||||||
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
|
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 (
|
return (
|
||||||
<section key={taskId} className="documents-task-sidebar-group" aria-labelledby={`documents-task-group-${taskId}`}>
|
<section key={taskId} className="documents-task-sidebar-group" aria-labelledby={`documents-task-group-${taskId}`}>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { useComposerDictation } from "../hooks/useComposerDictation";
|
|||||||
import { MicButton } from "./MicButton";
|
import { MicButton } from "./MicButton";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
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 { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||||
import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
|
import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
@@ -79,7 +82,7 @@ function getRoleLabel(role: AgentLogRole, t: TFunction<"app">): string {
|
|||||||
|
|
||||||
function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null {
|
function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null {
|
||||||
if (entry.type !== "status" && entry.type !== "text") return 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;
|
if (!role) return null;
|
||||||
return parseRuntimeModelMarker(entry.text, role);
|
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 {
|
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);
|
return makeModelInfo(task.planningModelProvider, task.planningModelId);
|
||||||
}
|
}
|
||||||
if (role === "executor" && task.modelProvider) {
|
if (role === "executor" && task.modelProvider) {
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ const LIVE_REFETCH_EVENTS = [
|
|||||||
* these canonical stage ids; any column that does not map to a known stage is
|
* 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
|
* folded into an "other" bucket so custom workflow columns still contribute a
|
||||||
* count rather than being silently dropped.
|
* 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 }> = [
|
const FUNNEL_STAGES: Array<{ id: string; match: (column: string) => boolean }> = [
|
||||||
{ id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" },
|
{ id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { SectionId } from "../SettingsModal";
|
|||||||
import type { MainContentProps } from "./types";
|
import type { MainContentProps } from "./types";
|
||||||
|
|
||||||
export function MainContent({
|
export function MainContent({
|
||||||
|
columnFlagsByTaskId,
|
||||||
showBackendConnectionErrorPage,
|
showBackendConnectionErrorPage,
|
||||||
projectsError,
|
projectsError,
|
||||||
t,
|
t,
|
||||||
@@ -555,6 +556,7 @@ export function MainContent({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DocumentsView
|
<DocumentsView
|
||||||
projectId={currentProject?.id}
|
projectId={currentProject?.id}
|
||||||
|
columnFlagsByTaskId={columnFlagsByTaskId}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
onOpenDetail={openDetailTask}
|
onOpenDetail={openDetailTask}
|
||||||
onOpenArtifactTaskDetail={popOutTaskDetail}
|
onOpenArtifactTaskDetail={popOutTaskDetail}
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ import { TodoView } from "../TodoView";
|
|||||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||||
|
|
||||||
export interface MainContentProps {
|
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;
|
showBackendConnectionErrorPage: boolean;
|
||||||
projectsError: string | null;
|
projectsError: string | null;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
|
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";
|
import { ACTIVE_STATUSES } from "../utils/taskActivity";
|
||||||
|
|
||||||
export type ModelSelection = ResolvedModelSelection;
|
export type ModelSelection = ResolvedModelSelection;
|
||||||
@@ -145,7 +147,7 @@ export function resolveEffectiveValidator(
|
|||||||
export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
||||||
let result: { provider: string; modelId: string } | null = null;
|
let result: { provider: string; modelId: string } | null = null;
|
||||||
entries.forEach((entry) => {
|
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");
|
const match = parseRuntimeModelMarker(entry.text, "Planning");
|
||||||
if (match) {
|
if (match) {
|
||||||
result = match;
|
result = match;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
|
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 * as api from "../api";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
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 (
|
if (
|
||||||
!PLANNER_ACTIVITY_COLUMN_IDS.has(task.column)
|
!PLANNER_ACTIVITY_COLUMN_IDS.has(task.column)
|
||||||
|| task.status === "planning"
|
|| task.status === "planning"
|
||||||
|| entry.agent !== "triage"
|
|| entry.agent !== PLANNER_AGENT_ROLE
|
||||||
|| !hasFreshAgentLog(task, entry)
|
|| !hasFreshAgentLog(task, entry)
|
||||||
) {
|
) {
|
||||||
return task;
|
return task;
|
||||||
|
|||||||
@@ -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"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFa
|
|||||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||||
import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.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 type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult, WorkflowWorkItem } from "@fusion/core";
|
||||||
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult, type WorkflowColumnBoundaryHooks } from "./workflow-graph-task-runner.js";
|
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult, type WorkflowColumnBoundaryHooks } from "./workflow-graph-task-runner.js";
|
||||||
import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js";
|
import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js";
|
||||||
@@ -2873,6 +2873,60 @@ export class TaskExecutor {
|
|||||||
this.activeSubagentSessions.delete(taskId);
|
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
|
* FN-5256: register an in-flight disposal so a subsequent dispatch (task:moved
|
||||||
* → in-progress) can await it before acquiring/creating a worktree. Swallows
|
* → 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:
|
FNXC:PlanningEvacuation 2026-07-25-23:00:
|
||||||
A card pulled BACKWARD out of a planner lane (the reported case: todo → Ideas) must stop all
|
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);
|
await this.persistTokenUsage(task.id);
|
||||||
const originColumn = task.column;
|
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;
|
let completionTask = task;
|
||||||
if (promotedFromPlannerColumn) {
|
if (promotedFromPlannerColumn) {
|
||||||
this.recoveringCompleted.add(task.id);
|
this.recoveringCompleted.add(task.id);
|
||||||
@@ -4961,8 +5045,15 @@ export class TaskExecutor {
|
|||||||
triage -> todo -> in-progress path while the recovery ownership set prevents
|
triage -> todo -> in-progress path while the recovery ownership set prevents
|
||||||
scheduler/executor dispatch. Todo callers retain their existing single hop.
|
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",
|
moveSource: "engine",
|
||||||
recoveryRehome: true,
|
recoveryRehome: true,
|
||||||
bypassGuards: true,
|
bypassGuards: true,
|
||||||
@@ -4971,7 +5062,8 @@ export class TaskExecutor {
|
|||||||
preserveResumeState: true,
|
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");
|
await this.handoffTaskToReview(completionTask, "completed-task-recovered");
|
||||||
if (promotedFromPlannerColumn) {
|
if (promotedFromPlannerColumn) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Task, TaskStore } from "@fusion/core";
|
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:
|
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
|
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.
|
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:
|
* FNXC:WorkflowReplan 2026-07-15-13:15:
|
||||||
* FN-7977: a planning/provider recovery may finish after another engine lane has
|
* 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
|
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
|
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
|
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
|
filter (intake column AND `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
|
parked in the intake lane with `needs-replan` permanently — "stuck in planning" on the board
|
||||||
triage-column workflow (builtin:coding, the default); plan-in-place Ideas cards escaped only
|
(FN-8594). It hit every workflow with a SEPARATE intake column (builtin:coding, the default at
|
||||||
because todo discovery admits `needs-replan` without consulting this guard.
|
the time); plan-in-place cards escaped only because hold-lane discovery admits `needs-replan`
|
||||||
This check covers BOTH planner lanes — the "triage" column and the plan-in-place "todo" lane.
|
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)) {
|
if (task.status != null && REPLAN_PARK_STATUSES.has(task.status)) {
|
||||||
return false;
|
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
|
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
|
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
|
(stamps read as advanced) AND `recoverAdvancedTriageTasks` — the designated owner of that
|
||||||
"stranded-advanced" class — also excluded it, because it bails on
|
"stranded-advanced" class — also excluded it, because it bails when the pinned column IS the
|
||||||
`workflowIrPinColumnId === "triage"` (it cannot resume a card into the column it already sits
|
card's own intake column (it cannot resume a card into the column it already sits in). Nobody
|
||||||
in). Nobody owned the card and it sat indefinitely.
|
owned the card and it sat indefinitely.
|
||||||
Arrival order alone is the honest signal: a stamp written BEFORE the card reached the planner
|
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
|
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
|
AFTER landing here has a stamp NEWER than its arrival, so it still reads advanced and stays with
|
||||||
the advanced-recovery sweep.
|
the advanced-recovery sweep.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ import type {
|
|||||||
AgentSession,
|
AgentSession,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js";
|
import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js";
|
||||||
import { hasAdvancedPastPlanning, isTaskStillInPlanningStage } from "./replan-target.js";
|
import { hasAdvancedPastPlanning, isTaskStillInPlanningStage, resolvePlannerLanes } from "./replan-target.js";
|
||||||
import {
|
import {
|
||||||
createResolvedAgentSession,
|
createResolvedAgentSession,
|
||||||
extractRuntimeHint,
|
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
|
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
|
column, and the release move becomes a no-op the guard already skips — which is
|
||||||
the intended end state, not a degenerate case.
|
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):
|
FNXC:WorkflowLifecycleColumns 2026-07-29-19:10 (U11 — STALL 3):
|
||||||
The pre-implementation column ids that shipped as the builtin lifecycle vocabulary,
|
The pre-implementation column ids that shipped as the builtin lifecycle vocabulary,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Task, TaskStore } from "@fusion/core";
|
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 {
|
import {
|
||||||
resolveExecutorSessionModel,
|
resolveExecutorSessionModel,
|
||||||
resolveMergerSessionModel,
|
resolveMergerSessionModel,
|
||||||
@@ -141,7 +143,7 @@ export class UsageLimitPauser {
|
|||||||
vocabulary conversion, not to this fix.
|
vocabulary conversion, not to this fix.
|
||||||
*/
|
*/
|
||||||
const isPreImplementation = preImplementationColumns?.has(task.column) === true;
|
const isPreImplementation = preImplementationColumns?.has(task.column) === true;
|
||||||
const providersByActiveLane = agentType === "triage"
|
const providersByActiveLane = agentType === PLANNER_AGENT_ROLE
|
||||||
? (isPreImplementation ? [
|
? (isPreImplementation ? [
|
||||||
resolvePlanningSessionModel(task.planningModelProvider, task.planningModelId, settings).provider,
|
resolvePlanningSessionModel(task.planningModelProvider, task.planningModelId, settings).provider,
|
||||||
resolveValidatorSessionModel(task.validatorModelProvider, task.validatorModelId, settings).provider,
|
resolveValidatorSessionModel(task.validatorModelProvider, task.validatorModelId, settings).provider,
|
||||||
@@ -204,7 +206,7 @@ export class UsageLimitPauser {
|
|||||||
*/
|
*/
|
||||||
const irCache = new Map<string, WorkflowIr>();
|
const irCache = new Map<string, WorkflowIr>();
|
||||||
const preImplementationByTask = new Map<string, ReadonlySet<string>>();
|
const preImplementationByTask = new Map<string, ReadonlySet<string>>();
|
||||||
if (agentType === "triage") {
|
if (agentType === PLANNER_AGENT_ROLE) {
|
||||||
await Promise.all(tasks.map(async (task) => {
|
await Promise.all(tasks.map(async (task) => {
|
||||||
const columns = await resolveTaskLifecycleColumns(this.store, task.id, irCache).catch(() => undefined);
|
const columns = await resolveTaskLifecycleColumns(this.store, task.id, irCache).catch(() => undefined);
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user