U11 PR2: entry contract under the merged column + a real intake-column bug the audit surfaced (#2503)
Second small PR for **U11**. Two commits: a tests-only entry-contract pin, then a **real present-day bug fix** the audit surfaced. ## The audit you asked for, finished — no design fork You named four surfaces as the remaining risk. All four can take a combined `intake` + `hold` column. One needed a code change; here it is. | Surface | Verdict | Evidence | |---|---|---| | `isUnplannedForExecution` | Safe | PR1 (#2495) — passed unmodified; a mutation now fails exactly the merged-column test | | Capacity hold / release | Safe | PR1 — `hold-release.ts:260` already accepts intake **or** hold | | `start`'s column / entry contract | Safe | commit 1 — all 6 assertions passed unmodified | | `createTask` intake wiring | **Broken today** | commit 2 — fixed, revert-proven | | *(also found)* triage auto-discovery | Needs conversion | `triage.ts:1382` — deferred to PR3, see below | ## Commit 1 — entry contract under the merged column (tests only) All 6 new assertions passed on the first run. **Regression floor, not evidence of a fix** — I could not make them fail and am not claiming otherwise. They pin one real behavioral **difference** rather than asserting sameness everywhere: the merged shape answers `start` where the split shape answers `plan`, because `start` becomes the first node in that column once the columns collapse. That is equivalent *only* because `start` reaches the specification node by a single unconditional success edge — asserted, so if a node is ever inserted between them this fails instead of silently admitting an unspecified card into implementation. Also pinned: past planning both shapes agree exactly; a card past the merged column still never resumes at a planning node (the backward drag that fires `abort-on-exit`); and a row persisted in the **deleted** `triage` column resolves to `undefined`, safe only while the executor's start-node fallback exists. ## Commit 2 — a real bug, found by the audit The intake column was resolved **only** as a by-product of materializing workflow steps. A create supplying `enabledWorkflowSteps` without an explicit `workflowId` takes **neither** materialization branch, so `resolvedEntryColumn` stays `undefined` and `column:` falls through to the hard-coded `|| "triage"`. Today, on Coding (Ideas), that lands the card in `triage` — **a column that workflow does not declare.** Created straight into a phantom lane. Measured: the new test fails `expected 'triage' to be 'ideas'` against unmodified sources. **Why it blocks U11.** Once `triage` leaves the coding IRs this stops being an Ideas edge case and becomes the default workflow's behavior for every create down this path: the card lands in an undeclared column **and** — because `isIntakeColumn` keys on the same `"triage"` literal — gets `generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for planning only when its `PROMPT.md` reads as a seed, so a placeholder spec is classified "already planned" and never planned. The card sits in Planning forever with no log line in any lane — **FN-8587's exact failure mode, promoted from one edge case to every new card.** The fix resolves the intake column **side-effect-free** (read the IR, ask which column carries `intake`). It deliberately does *not* call `materializeDefaultWorkflowSteps`, which would persist step rows the caller explicitly opted out of by supplying its own toggles. Unresolvable workflow returns `undefined` and each call site keeps its legacy fallback, so no path loses behavior when the IR cannot be read. Applied to both create paths. Branch ordering preserved in both — the explicit empty-toggle case (`length === 0` hydrating back as `[]`) still runs, now nested rather than sequential. **Revert check:** with `task-creation.ts` reverted, *"lands a Coding (Ideas) task in ideas even when enabledWorkflowSteps is supplied"* fails `expected 'triage' to be 'ideas'`. The companion bootstrap-`PROMPT.md` assertion passes either way today — it is correct **by accident of the `"triage"` literal** — and is kept precisely because that accident disappears with U11. ## Verification 37 tests green across the three intake/create suites; 119 across the entry-contract, merged-column and lifecycle suites; `pnpm test:gate` green (307 + 10 + 71); lint and core typecheck clean. Changeset added. ## Deferred to PR3, with the line numbers `discoverReadyPlanningTasks` has two hardcoded branches: ```ts (t) => t.column === "triage" && isTaskStillInPlanningStage(t) // triage.ts:1382 (t) => t.column === "todo" && !this.processing.has(t.id) … // triage.ts:1389 ``` Delete `triage` and branch 1 matches nothing for coding cards; branch 2 then does all the work and is **narrower** (it admits only `needs-replan` or bootstrap-stub cards). Commit 2 is what makes branch 2 sufficient — every new card now gets a real bootstrap seed. They cannot double-fire: a card is in `todo` xor `triage`. Two adjacent sites are already merged-shape-ready: `triage.ts:3899` skips the redundant same-column move for a plan-in-place card, and `triage.ts:753`'s stale-status sweep already scans both columns. Then the ~10-line IR change, then the migration proof. 🤖 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/u11-intake-column-resolution.md
Normal file
7
.changeset/u11-intake-column-resolution.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Tasks created with custom workflow-step toggles now land in their workflow's own intake column.
|
||||
category: fix
|
||||
dev: `resolveDefaultWorkflowIntakeColumn` resolves the intake column side-effect-free (IR + `intake` trait) when a create supplies `enabledWorkflowSteps` without an explicit `workflowId`, so neither `materializeWorkflowSteps` branch runs. Previously `resolvedEntryColumn` stayed undefined and the card fell through to the hard-coded `|| "triage"`.
|
||||
@@ -44,6 +44,41 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => {
|
||||
expect(task.column).toBe("ideas");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MergedPlanningColumn 2026-07-28-12:40 (U11 precondition):
|
||||
The intake column is resolved ONLY inside the two `materializeWorkflowSteps` branches. A create
|
||||
that supplies `enabledWorkflowSteps` without an explicit `workflowId` takes NEITHER branch, so
|
||||
`resolvedEntryColumn` stays undefined and `column:` falls through to the hard-coded `|| "triage"`.
|
||||
|
||||
Today that lands a Coding (Ideas) card in `triage` — a column that workflow does not declare —
|
||||
so the card is created straight into a phantom lane. U11 makes this the DEFAULT workflow's
|
||||
problem too: once `triage` is deleted, every create down this path lands in an undeclared column
|
||||
and, because `isIntakeColumn` keys on the same literal, also gets `generateSpecifiedPrompt`
|
||||
instead of the bootstrap seed. Triage's discovery admits a card only when its PROMPT.md reads as
|
||||
a seed, so the card would sit in Planning forever with no log line in any lane — FN-8587's exact
|
||||
failure mode, for every new card rather than one edge case.
|
||||
*/
|
||||
it("lands a Coding (Ideas) task in ideas even when enabledWorkflowSteps is supplied", async () => {
|
||||
const store = h.store();
|
||||
await store.setDefaultWorkflowId("builtin:coding-ideas");
|
||||
const task = await store.createTask({
|
||||
description: "ideas task created with explicit optional-group toggles",
|
||||
enabledWorkflowSteps: [],
|
||||
});
|
||||
expect(task.column).toBe("ideas");
|
||||
});
|
||||
|
||||
it("writes a bootstrap PROMPT.md for that same create (so triage can still discover it)", async () => {
|
||||
const store = h.store();
|
||||
await store.setDefaultWorkflowId("builtin:coding-ideas");
|
||||
const task = await store.createTask({
|
||||
description: "ideas task created with explicit optional-group toggles",
|
||||
enabledWorkflowSteps: [],
|
||||
});
|
||||
const prompt = await readFile(join(store.getTasksDir(), task.id, "PROMPT.md"), "utf-8");
|
||||
expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description));
|
||||
});
|
||||
|
||||
it("lands a Coding (Ideas) task in ideas when it is the project default workflow", async () => {
|
||||
const store = h.store();
|
||||
await store.setDefaultWorkflowId("builtin:coding-ideas");
|
||||
|
||||
@@ -24,6 +24,8 @@ import {getErrorMessage} from "../error-message.js";
|
||||
import {generateTaskLineageId} from "../task-lineage.js";
|
||||
import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicate-intake.js";
|
||||
import {buildBootstrapPrompt} from "../mesh-task-replication.js";
|
||||
import {resolveWorkflowIrById} from "../workflow-ir-resolver.js";
|
||||
import {columnsWithFlag} from "../workflow-lifecycle-traits.js";
|
||||
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
|
||||
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
|
||||
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
|
||||
@@ -59,6 +61,36 @@ function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
FNXC:MergedPlanningColumn 2026-07-28-12:55 (U11 precondition):
|
||||
The intake column used to be resolved ONLY as a by-product of materializing workflow steps, so a
|
||||
create that supplied `enabledWorkflowSteps` without an explicit `workflowId` took neither
|
||||
materialization branch and fell through to the hard-coded `|| "triage"`. On Coding (Ideas) that
|
||||
lands the card in a column the workflow does not declare — a phantom lane, today.
|
||||
|
||||
U11 makes it the DEFAULT workflow's problem: once `triage` is deleted, every create down this path
|
||||
lands in an undeclared column AND (because `isIntakeColumn` keys on the same literal) gets
|
||||
`generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for planning only
|
||||
when its PROMPT.md reads as a seed, so the card would sit in Planning forever with no log line in
|
||||
any lane — FN-8587's failure mode, for every new card.
|
||||
|
||||
Resolution is deliberately SIDE-EFFECT-FREE: it reads the default workflow's IR and asks which
|
||||
column carries `intake`. It must not call `materializeDefaultWorkflowSteps`, which persists step
|
||||
rows the caller explicitly opted out of by supplying its own `enabledWorkflowSteps`.
|
||||
Unresolvable workflow returns undefined and the caller keeps its existing legacy fallback.
|
||||
*/
|
||||
async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise<string | undefined> {
|
||||
try {
|
||||
const workflowId = await store.getDefaultWorkflowId();
|
||||
if (!workflowId) return undefined;
|
||||
const ir = await resolveWorkflowIrById(store, workflowId);
|
||||
return columnsWithFlag(ir, "intake")[0];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
|
||||
// U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins).
|
||||
input = applyReviewLevelPreset(input);
|
||||
@@ -194,10 +226,17 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (input.enabledWorkflowSteps.length === 0) {
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
|
||||
// optional-step selection must hydrate back as [], not undefined.
|
||||
resolvedWorkflowSteps = [];
|
||||
} else {
|
||||
// Caller supplied its own optional-step toggles, so no materialization branch ran and
|
||||
// `resolvedEntryColumn` was left undefined — the gap that dropped the card into the
|
||||
// hard-coded `|| "triage"`. The toggles are the caller's; the INTAKE COLUMN is still the
|
||||
// project default workflow's, so resolve it without materializing steps.
|
||||
resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store);
|
||||
if (input.enabledWorkflowSteps.length === 0) {
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
|
||||
// optional-step selection must hydrate back as [], not undefined.
|
||||
resolvedWorkflowSteps = [];
|
||||
}
|
||||
}
|
||||
|
||||
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:20:
|
||||
@@ -671,10 +710,16 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(input.enabledWorkflowSteps) && input.enabledWorkflowSteps.length === 0) {
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
|
||||
// optional-step selection must hydrate back as [], not undefined.
|
||||
resolvedWorkflowSteps = [];
|
||||
} else if (input.enabledWorkflowSteps !== undefined) {
|
||||
// Mirror of the backend path: the caller owns the optional-step toggles, so no
|
||||
// materialization branch ran and `resolvedEntryColumn` was left undefined. The INTAKE
|
||||
// COLUMN is still the project default workflow's — resolve it without materializing steps.
|
||||
resolvedEntryColumn = await resolveDefaultWorkflowIntakeColumn(store);
|
||||
if (input.enabledWorkflowSteps.length === 0) {
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-29-02:55: an explicit empty
|
||||
// optional-step selection must hydrate back as [], not undefined.
|
||||
resolvedWorkflowSteps = [];
|
||||
}
|
||||
}
|
||||
|
||||
let createdTask: Task;
|
||||
|
||||
@@ -170,3 +170,222 @@ describe("workflow graph entry contract — the executor honors it", () => {
|
||||
expect(calls).not.toContain("planning-session");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MergedPlanningColumn 2026-07-28-14:05 (U11, PR #2503 review — greptile + coderabbit):
|
||||
|
||||
The entry contract under the MERGED planning column — one column carrying `intake` + `hold`,
|
||||
which is what U11 leaves behind once `triage` is deleted and `todo` becomes "Planning".
|
||||
|
||||
REWRITTEN after review. The first cut of this block did two things wrong, and both are the
|
||||
difference between proving something and appearing to:
|
||||
|
||||
1. It asserted against a hand-written synthetic graph. A toy graph proves the RESOLVER's
|
||||
arithmetic, not that the operator's board survives — production planning/review topology could
|
||||
drift away from the shape under test and nothing here would notice. `mergeTodoIntoPlanning`
|
||||
below is instead the literal U11 edit expressed as a transformation, applied to the REAL
|
||||
production IR, so these assertions track production topology by construction.
|
||||
|
||||
2. It asserted that `resolveColumnResumeNode` returns `undefined` for a card stranded in the
|
||||
deleted `triage` column, and that a start node exists. Both can hold while cards strand: the
|
||||
thing that actually rescues such a card is the `?? ir.nodes.find(kind === "start")` fallback in
|
||||
`run()`, and neither assertion touches it. Deleting that fallback left the old test GREEN. The
|
||||
stranded case now drives `executor.run()` and asserts the card really traverses — and going red
|
||||
when the fallback is removed is verified, not assumed.
|
||||
*/
|
||||
describe("workflow graph entry contract — merged intake+hold planning column (U11)", () => {
|
||||
/**
|
||||
* The U11 IR edit, as a transformation of a real workflow: `triage`'s traits merge into `todo`,
|
||||
* `todo` becomes "Planning", `triage` is deleted, and every node that named `triage` is repointed.
|
||||
* Applying this to the production IR is what makes the assertions below track production.
|
||||
*/
|
||||
function mergeTodoIntoPlanning(source: WorkflowIr): WorkflowIr {
|
||||
const ir = structuredClone(source) as WorkflowIr & {
|
||||
columns: Array<{ id: string; name?: string; traits?: unknown[] }>;
|
||||
nodes: Array<{ id: string; column?: string }>;
|
||||
};
|
||||
const triage = ir.columns.find((column) => column.id === "triage");
|
||||
const todo = ir.columns.find((column) => column.id === "todo");
|
||||
if (!triage || !todo) throw new Error("source IR is not the split-column shape this merge transforms");
|
||||
|
||||
todo.name = "Planning";
|
||||
// intake first, then the existing hold/reset-on-entry — the union U11 declares.
|
||||
todo.traits = [...(triage.traits ?? []), ...(todo.traits ?? [])];
|
||||
ir.columns = ir.columns.filter((column) => column.id !== "triage");
|
||||
for (const node of ir.nodes) {
|
||||
if (node.column === "triage") node.column = "todo";
|
||||
}
|
||||
return ir as WorkflowIr;
|
||||
}
|
||||
|
||||
const mergedCodingIr = mergeTodoIntoPlanning(codingIr);
|
||||
|
||||
it("is a faithful merge of the production IR (guards the transformation itself)", () => {
|
||||
// If this drifts, every assertion below is measuring the wrong thing.
|
||||
expect(mergedCodingIr.columns.map((column) => column.id)).not.toContain("triage");
|
||||
expect(mergedCodingIr.nodes.every((node) => node.column !== "triage")).toBe(true);
|
||||
const planning = mergedCodingIr.columns.find((column) => column.id === "todo")!;
|
||||
const traits = (planning.traits ?? []).map((trait) => (trait as { trait: string }).trait);
|
||||
expect(traits).toContain("intake");
|
||||
expect(traits).toContain("hold");
|
||||
// Node COUNT is unchanged: this is a column merge, not a graph edit.
|
||||
expect(mergedCodingIr.nodes.length).toBe(codingIr.nodes.length);
|
||||
});
|
||||
|
||||
it("enters the specification phase for a card in the merged planning column", () => {
|
||||
const resumed = resolveColumnResumeNode(mergedCodingIr, "todo");
|
||||
|
||||
expect(resumed?.column).toBe("todo");
|
||||
// The failure this guards: entering at an implementation node would put an unspecified card
|
||||
// into implementation with no plan.
|
||||
expect(resumed?.id).not.toBe("parse");
|
||||
});
|
||||
|
||||
it("reaches the production specification node from the merged entry point in one hop", () => {
|
||||
/*
|
||||
The merged shape answers `start` where the split shape answers the planning node, because
|
||||
`start` becomes the first node in that column once the columns collapse. That is equivalent
|
||||
ONLY because `start` reaches the specification node by a single unconditional success edge.
|
||||
Asserted against the PRODUCTION graph, so inserting a node between them fails here rather than
|
||||
silently admitting an unspecified card into implementation.
|
||||
*/
|
||||
const entry = resolveColumnResumeNode(mergedCodingIr, "todo")!;
|
||||
const splitAnswer = resolveColumnResumeNode(codingIr, "todo")!;
|
||||
|
||||
if (entry.id === splitAnswer.id) return; // no collapse happened; nothing to bridge.
|
||||
|
||||
const successors = mergedCodingIr.edges.filter(
|
||||
(edge) => edge.from === entry.id && (edge.condition === undefined || edge.condition === "success") && edge.kind !== "rework",
|
||||
);
|
||||
expect(successors).toHaveLength(1);
|
||||
expect(successors[0]!.to).toBe(splitAnswer.id);
|
||||
});
|
||||
|
||||
it("NEVER re-plans a card past the merged column — the drag that aborts a live session", () => {
|
||||
const resumed = resolveColumnResumeNode(mergedCodingIr, "in-progress");
|
||||
|
||||
expect(resumed?.id).toBe("parse");
|
||||
expect(resumed?.column).toBe("in-progress");
|
||||
expect(["start", "plan", "planning", "plan-review", "plan-replan"]).not.toContain(resumed?.id);
|
||||
});
|
||||
|
||||
it("re-enters a review-column card at the production first review node, not the merge region", () => {
|
||||
const resumed = resolveColumnResumeNode(mergedCodingIr, "in-review");
|
||||
|
||||
/*
|
||||
ABSOLUTE, not merely equal-to-production. A differential assertion alone is tautological here:
|
||||
if the production review lane were reordered, both sides of the comparison would move together
|
||||
and the test would stay green while the merged board silently skipped a gate. The literal is
|
||||
the same one the production block above pins, so a real topology change fails in both places.
|
||||
*/
|
||||
expect(resumed?.id).toBe("browser-verification");
|
||||
expect(resumed?.column).toBe("in-review");
|
||||
// …and it still agrees with production, which is the property the merge must preserve.
|
||||
expect(resumed?.id).toBe(resolveColumnResumeNode(codingIr, "in-review")?.id);
|
||||
});
|
||||
|
||||
it("produces the same answer as the production IR at every position past planning", () => {
|
||||
// Absolutes first, so production drift cannot move both sides of the differential in step.
|
||||
expect(resolveColumnResumeNode(mergedCodingIr, "in-progress")?.id).toBe("parse");
|
||||
expect(resolveColumnResumeNode(mergedCodingIr, "in-review")?.id).toBe("browser-verification");
|
||||
|
||||
for (const column of ["in-progress", "in-review", "done"]) {
|
||||
expect(resolveColumnResumeNode(mergedCodingIr, column)?.id)
|
||||
.toBe(resolveColumnResumeNode(codingIr, column)?.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MergedPlanningColumn 2026-07-28-14:20 (U11, PR #2503 review — coderabbit):
|
||||
The stranded-card case, driven through `executor.run()` rather than through the resolver.
|
||||
|
||||
Rows persisted in `triage` outlive the column U11 deletes. `resolveColumnResumeNode` returns
|
||||
undefined for a column the IR does not declare, and the ONLY thing that then rescues the card is
|
||||
`run()`'s `?? ir.nodes.find(kind === "start")` fallback. Asserting "the resolver returns undefined"
|
||||
and "a start node exists" is compatible with the card stranding, which is why this drives the real
|
||||
traversal and asserts the card moves.
|
||||
|
||||
Verified red: deleting the `?? ir.nodes.find(...)` fallback makes `run()` throw
|
||||
`WorkflowIrError: Workflow IR missing start node` and this test fails.
|
||||
*/
|
||||
describe("workflow graph entry contract — a card stranded in a deleted column still runs (U11)", () => {
|
||||
const promptWithOneStep = "# Task\n\n## Steps\n\n### Step 0: Implement\n- [ ] do it\n";
|
||||
|
||||
function silentPrimitives(): WorkflowRuntimePrimitives {
|
||||
const ok = { outcome: "success" as const };
|
||||
return {
|
||||
prepareWorktree: async () => ({ outcome: "success", data: { worktreePath: "/memory/worktree" } }),
|
||||
readArtifact: async (_c: unknown, _t: unknown, key: string) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
|
||||
writeArtifact: async (_c: unknown, _t: unknown, key: string) => ({ outcome: "success", data: { key } }),
|
||||
runPlanningSession: async () => ({ outcome: "success", data: { approved: true, artifactKeys: ["PROMPT.md"] } }),
|
||||
runCodingSession: async () => ({ outcome: "success", data: { taskDone: true, modifiedFiles: [] } }),
|
||||
runTaskStep: async () => ({ outcome: "success", baselineSha: "b", checkpointId: "c" }),
|
||||
resetTaskStep: async () => ({ ok: true }),
|
||||
runReview: async () => ({ outcome: "success", data: { verdict: "APPROVE" } }),
|
||||
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
|
||||
updateSteps: async (_c: unknown, target: TaskDetail, steps: TaskStep[]) => {
|
||||
target.steps = steps;
|
||||
return { outcome: "success", data: { count: steps.length } };
|
||||
},
|
||||
transitionTask: async () => ok,
|
||||
requestMerge: async () => ({ outcome: "success", value: "merged", data: { status: "merged" } }),
|
||||
abortRun: async () => ok,
|
||||
audit: () => undefined,
|
||||
} as unknown as WorkflowRuntimePrimitives;
|
||||
}
|
||||
|
||||
function mergedIrWithoutTriage(): WorkflowIr {
|
||||
const ir = structuredClone(codingIr) as WorkflowIr & {
|
||||
columns: Array<{ id: string; name?: string; traits?: unknown[] }>;
|
||||
nodes: Array<{ id: string; column?: string }>;
|
||||
};
|
||||
const triage = ir.columns.find((column) => column.id === "triage")!;
|
||||
const todo = ir.columns.find((column) => column.id === "todo")!;
|
||||
todo.name = "Planning";
|
||||
todo.traits = [...(triage.traits ?? []), ...(todo.traits ?? [])];
|
||||
ir.columns = ir.columns.filter((column) => column.id !== "triage");
|
||||
for (const node of ir.nodes) if (node.column === "triage") node.column = "todo";
|
||||
return ir as WorkflowIr;
|
||||
}
|
||||
|
||||
it("runs a card still stored in the DELETED triage column instead of stranding it", async () => {
|
||||
const task = {
|
||||
id: "FN-STRANDED",
|
||||
title: "Left behind in a deleted column",
|
||||
description: "",
|
||||
// The migration case: the row outlived the column its workflow declared.
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: promptWithOneStep,
|
||||
workflowStepResults: [],
|
||||
createdAt: "2026-07-28T00:00:00.000Z",
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
} as unknown as TaskDetail;
|
||||
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
primitives: silentPrimitives(),
|
||||
parseStepsDeps: {
|
||||
readArtifact: async (_target: unknown, key: string) => (key === "PROMPT.md" ? promptWithOneStep : undefined),
|
||||
writeSteps: async (target: TaskDetail, steps: TaskStep[]) => {
|
||||
target.steps = steps;
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const merged = mergedIrWithoutTriage();
|
||||
expect(merged.columns.map((column) => column.id)).not.toContain("triage");
|
||||
|
||||
// No continuation node id — the "replay from start" path the fallback governs.
|
||||
const result = await executor.run(task, { experimentalFeatures: {} } as never, merged);
|
||||
|
||||
// The card must actually traverse. An empty trace IS the stranding this guards.
|
||||
expect(result.visitedNodeIds.length).toBeGreaterThan(0);
|
||||
// …and it re-enters at the top of the pipeline, which is the only safe answer for a column
|
||||
// the workflow no longer declares: it cannot be placed relative to any node.
|
||||
expect(result.visitedNodeIds[0]).toBe(merged.nodes.find((node) => node.kind === "start")?.id);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user