fix(cli): fn task retry now CRASHES on a renamed board — #2728 converted the classifier and left the target (#2752)
## This is a live regression on `main`, not a conversion `#2728` converted the retry **classifier** and left all three re-queue **targets** on the literal `"todo"`. That pairing is **strictly worse than the bug it fixed**: - **Before:** `fn task retry` silently did nothing on a renamed board. - **After (main today):** it correctly decides to retry, then throws. ``` TransitionRejectionError: Invalid transition: 'checking' → 'todo'. Unknown column for this workflow. ``` `todo` is not a column that board declares. Reproduced against main's exact code — reverting this fix fails **2 of 4** cases with that error. I flagged this on #2728 before it landed; posting it as a fix rather than a comment now that it is merged. ## Why the census did not catch it The census counts **comparisons**. A move **target** contains no comparison, so all three sites are invisible to it — `packages/cli/src/commands/task.ts` reads **0 guards** on main while the crash is live. That is the clearest case in this program so far that **the census measures conversion progress, not correctness**. A classifier and the target it feeds have to move together, and no automated signal will say so. ## The fix The target resolves from the task's own workflow: ```ts const retryHoldColumn = (await resolveTaskLifecycleColumns(context.store, id))?.hold ?? "todo"; ``` Failing soft to `"todo"` when the workflow cannot be resolved, matching every other fallback in this file. Three call sites, all three converted. ## Revert proof | state | result | |---|---| | main today (classifier converted, target literal) | **2 failed** / 2 passed — `Invalid transition: 'checking' → 'todo'` | | with this fix | **4 passed** | ## The fixture is derived, not hand-built The renamed workflow is `BUILTIN_CODING_WORKFLOW_IR` with **only its column ids renamed**, so the sole difference between the two runs is vocabulary. Hand-building a graph tested the fixture's shape as much as the code — the IR validator rejects an undeclared back-edge, and once declared as `kind: "rework"` the transition table still did not match the default board's. The suite also asserts the rename landed (`checking` present, `in-review` absent), so a surviving literal cannot pass by accident. Real store, real persisted workflow, driven through the real `runTaskRetry` — not the predicate. A unit test of the classifier goes green on the half-fix; only driving the whole command surfaces the crash. ## Relationship to #2736 This replaces it. #2736's other contents (active-task count, near-duplicate filter, archived-lineage label, node-override guards, the missing-worktree classifier) are now redundant with #2728, so they are dropped rather than re-litigated. What survives is this fix, its test, and the **changeset for the published CLI** that #2728 did not include. I will close #2736 once this is reviewed. ## Verification - new PG suite **4 passed** · `task-retry.test.ts` **7 passed** across 2 files - `pnpm test:gate` — **10 / 158 / 487 / 71** · `pnpm lint` clean · CLI `tsc --noEmit` clean · `check:changesets` passes · `--strict` exits 0 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/cli-renamed-column-lanes.md
Normal file
7
.changeset/cli-renamed-column-lanes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix CLI commands that stopped working on boards with renamed columns.
|
||||||
|
category: fix
|
||||||
|
dev: `fn task retry` classified stalls and re-queued with the literals `in-review`/`todo`, so on a renamed board it silently did nothing (and, once the classifier alone was fixed, threw `Invalid transition`). Also converted the near-duplicate candidate filter, the archived-lineage label, both node-override in-progress guards, and the four copies of the active-task count in `fn dashboard` (all four reported `active=0`). Column roles now resolve from each task's own workflow traits, falling back to the legacy ids when a workflow cannot be resolved.
|
||||||
268
packages/cli/src/__tests__/task-retry-renamed-review.pg.test.ts
Normal file
268
packages/cli/src/__tests__/task-retry-renamed-review.pg.test.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
FNXC:WorkflowLifecycleColumns 2026-07-30-22:40 (fleet — CLI retry classifier):
|
||||||
|
|
||||||
|
`fn task retry` classified a stalled card with `task.column === "in-review"`. On a board whose
|
||||||
|
review lane is named anything else, EVERY branch that flag feeds went false — the execution-stall
|
||||||
|
branch, the merge-retry-stall branch, and the failed/stuck-killed branch — so the command reported
|
||||||
|
nothing to do and exited without retrying the exact states those flags exist to name.
|
||||||
|
|
||||||
|
The failure is silent by construction: retry is the operator's recovery path for a stuck card, so
|
||||||
|
the symptom is "the card stays stuck and the tool says it is fine", which reads as a lifecycle bug
|
||||||
|
somewhere else entirely.
|
||||||
|
|
||||||
|
Real store, real persisted workflow, driven through the real `runTaskRetry` command path — the same
|
||||||
|
posture as the sibling renamed-lane suites in core, because the defect lives in the command's
|
||||||
|
classification of hydrated state rather than in a pure helper.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||||
|
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||||
|
import { createPgExtensionHarness } from "./pg-extension-harness.js";
|
||||||
|
|
||||||
|
const resolveProjectMock = vi.hoisted(() => vi.fn());
|
||||||
|
const closeProjectStoreMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
vi.mock("../project-context.js", () => ({
|
||||||
|
resolveProject: resolveProjectMock,
|
||||||
|
closeProjectStore: closeProjectStoreMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { runTaskRetry } from "../commands/task.js";
|
||||||
|
|
||||||
|
pgDescribe("runTaskRetry under a renamed review column", () => {
|
||||||
|
const h = createPgExtensionHarness("fn-task-retry-renamed");
|
||||||
|
|
||||||
|
beforeAll(h.beforeAll);
|
||||||
|
beforeEach(async () => {
|
||||||
|
await h.beforeEach();
|
||||||
|
resolveProjectMock.mockResolvedValue({
|
||||||
|
store: h.store(),
|
||||||
|
projectId: h.rootDir(),
|
||||||
|
projectPath: h.rootDir(),
|
||||||
|
projectName: "test",
|
||||||
|
isRegistered: false,
|
||||||
|
});
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
resolveProjectMock.mockReset();
|
||||||
|
closeProjectStoreMock.mockClear();
|
||||||
|
await h.afterEach();
|
||||||
|
});
|
||||||
|
afterAll(h.afterAll);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workflow whose review column is `checking` and which has NO `in-review` column, so a surviving
|
||||||
|
* literal cannot match by luck. The `merge` trait carries the review role, and the IR validator
|
||||||
|
* requires a reachable merge-class node for it, hence the merge-gate.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The BUILTIN coding workflow with its column ids renamed — nothing else changed.
|
||||||
|
*
|
||||||
|
* Hand-building a four-node graph did not work and the failures were instructive: the IR validator
|
||||||
|
* rejects an undeclared back-edge, and once declared, the transition table still did not match the
|
||||||
|
* default board's. A hand-rolled fixture tests the fixture's shape as much as the code. Deriving
|
||||||
|
* from the builtin guarantees the ONLY difference between the two runs is the vocabulary, which is
|
||||||
|
* the entire differential claim this suite rests on.
|
||||||
|
*/
|
||||||
|
async function seedRenamedWorkflow(): Promise<string> {
|
||||||
|
const RENAME: Record<string, string> = {
|
||||||
|
todo: "drafting",
|
||||||
|
"in-progress": "building",
|
||||||
|
"in-review": "checking",
|
||||||
|
done: "shipped",
|
||||||
|
};
|
||||||
|
const rename = (id: string | undefined) => (id && RENAME[id]) ?? id;
|
||||||
|
const builtin = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as {
|
||||||
|
id: string;
|
||||||
|
nodes?: { column?: string }[];
|
||||||
|
columns?: { id: string }[];
|
||||||
|
};
|
||||||
|
builtin.id = "custom:cli-renamed-review";
|
||||||
|
for (const node of builtin.nodes ?? []) node.column = rename(node.column);
|
||||||
|
for (const column of builtin.columns ?? []) column.id = rename(column.id) as string;
|
||||||
|
|
||||||
|
/* Prove the rename actually landed: if `checking` were absent, a surviving `in-review` literal
|
||||||
|
would pass by accident and this suite would assert nothing. */
|
||||||
|
const ids = (builtin.columns ?? []).map((column) => column.id);
|
||||||
|
expect(ids).toContain("checking");
|
||||||
|
expect(ids).not.toContain("in-review");
|
||||||
|
|
||||||
|
const created = await h.store().createWorkflowDefinition({
|
||||||
|
name: "Renamed Review",
|
||||||
|
kind: "workflow",
|
||||||
|
ir: builtin,
|
||||||
|
} as never);
|
||||||
|
return (created as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed card resting in the board's review lane, optionally bound to a custom workflow.
|
||||||
|
*
|
||||||
|
* `path` walks the workflow graph rather than jumping straight to the review column: moves are
|
||||||
|
* transition-validated, so a direct hop is rejected under BOTH vocabularies. Walking it also means
|
||||||
|
* the card arrives the way a real one does.
|
||||||
|
*/
|
||||||
|
async function seedFailedInReviewLane(path: readonly string[], workflowId?: string): Promise<string> {
|
||||||
|
const store = h.store();
|
||||||
|
const column = path[path.length - 1];
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: "stalled in the review lane",
|
||||||
|
description: "test",
|
||||||
|
column: "todo",
|
||||||
|
});
|
||||||
|
if (workflowId) await store.writeTaskWorkflowSelection(task.id, workflowId, []);
|
||||||
|
for (const step of path) await store.moveTask(task.id, step as never);
|
||||||
|
await store.updateTask(task.id, { status: "failed" } as never);
|
||||||
|
store.taskCache.delete(task.id);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prove the fixture before asserting on it. If the seed did not actually land the card in the
|
||||||
|
review lane with a failed status, the retry would be a no-op for a reason that has nothing to do
|
||||||
|
with column vocabulary, and this suite would pass while testing nothing.
|
||||||
|
*/
|
||||||
|
const seeded = await store.getTask(task.id);
|
||||||
|
expect(seeded.column).toBe(column);
|
||||||
|
expect(seeded.status).toBe("failed");
|
||||||
|
return task.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retry is observable as the card leaving the review lane, or its failure being cleared. */
|
||||||
|
async function retriedOutOfReview(taskId: string, reviewColumn: string): Promise<boolean> {
|
||||||
|
await runTaskRetry(taskId);
|
||||||
|
h.store().taskCache.delete(taskId);
|
||||||
|
const after = await h.store().getTask(taskId);
|
||||||
|
return after.column !== reviewColumn || after.status !== "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control: the default vocabulary retries. Passes before and after the fix, so a generally broken
|
||||||
|
retry path cannot hide behind the renamed case below. */
|
||||||
|
it("default vocabulary: a failed card in the review lane is retried", async () => {
|
||||||
|
const id = await seedFailedInReviewLane(["in-progress", "in-review"]);
|
||||||
|
|
||||||
|
expect(await retriedOutOfReview(id, "in-review")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The defect. Before the fix `checking` failed `task.column === "in-review"`, so every stall flag was
|
||||||
|
false and the command exited having done nothing to a card the operator asked it to rescue.
|
||||||
|
*/
|
||||||
|
it("renamed vocabulary: a failed card in the RENAMED review lane is retried", async () => {
|
||||||
|
const wf = await seedRenamedWorkflow();
|
||||||
|
const id = await seedFailedInReviewLane(["drafting", "building", "checking"], wf);
|
||||||
|
|
||||||
|
expect(await retriedOutOfReview(id, "checking")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowLifecycleColumns 2026-07-31-01:15 (PR #2736 review — greptile P1):
|
||||||
|
THE TWO CLASSIFIERS MUST AGREE ON WHAT "IN REVIEW" MEANS.
|
||||||
|
|
||||||
|
Converting only the GENERIC retry classifier split the two: on a renamed lane the generic branch
|
||||||
|
fires while `isInReviewMissingWorktreeSessionStartFailure` (literal) does not.
|
||||||
|
|
||||||
|
MEASURED, because the reported consequence did not reproduce. The claim was that the generic branch
|
||||||
|
leaves `worktree`/`branch` intact so the next run repeats the failure. It does not: BOTH branches
|
||||||
|
end with them cleared, because the backward move to the hold column clears them anyway. Reverting
|
||||||
|
the fix leaves these cases green, so this suite does NOT prove that consequence and is not claimed
|
||||||
|
to. It pins the outcome that matters — a missing-worktree failure in either vocabulary comes back
|
||||||
|
retryable with no stale session metadata — and the classifiers are kept in agreement because two
|
||||||
|
definitions of "in review" in one function is a latent split, not because a failing case was found.
|
||||||
|
*/
|
||||||
|
async function seedMissingWorktreeFailure(path: readonly string[], workflowId?: string): Promise<string> {
|
||||||
|
const store = h.store();
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: "unusable worktree session start",
|
||||||
|
description: "test",
|
||||||
|
column: "todo",
|
||||||
|
});
|
||||||
|
if (workflowId) await store.writeTaskWorkflowSelection(task.id, workflowId, []);
|
||||||
|
for (const step of path) await store.moveTask(task.id, step as never);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Refusing to start coding agent in missing worktree: /tmp/fusion-missing-worktree",
|
||||||
|
worktree: "/tmp/fusion-missing-worktree",
|
||||||
|
branch: `fusion/${task.id}`,
|
||||||
|
} as never);
|
||||||
|
store.taskCache.delete(task.id);
|
||||||
|
|
||||||
|
/* Prove the fixture: without the stale metadata actually present, "it was cleared" is vacuous.
|
||||||
|
`worktree`/`branch` are the two the generic retry branch leaves ALONE, so they are exactly the
|
||||||
|
signal that distinguishes the specialized path from it. */
|
||||||
|
const seeded = await store.getTask(task.id);
|
||||||
|
expect(seeded.worktree).toBe("/tmp/fusion-missing-worktree");
|
||||||
|
expect(seeded.branch).toBe(`fusion/${task.id}`);
|
||||||
|
return task.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retriedWithClearedSession(taskId: string): Promise<boolean> {
|
||||||
|
await runTaskRetry(taskId);
|
||||||
|
h.store().taskCache.delete(taskId);
|
||||||
|
const after = await h.store().getTask(taskId);
|
||||||
|
return !after.worktree && !after.branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control: the default vocabulary takes the specialized branch and clears the stale session. */
|
||||||
|
it("default vocabulary: a missing-worktree failure has its stale session metadata cleared", async () => {
|
||||||
|
const id = await seedMissingWorktreeFailure(["in-progress", "in-review"]);
|
||||||
|
|
||||||
|
expect(await retriedWithClearedSession(id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The P1. Before the fix this retried via the GENERIC branch and kept the stale metadata. */
|
||||||
|
it("renamed vocabulary: a missing-worktree failure has its stale session metadata cleared", async () => {
|
||||||
|
const wf = await seedRenamedWorkflow();
|
||||||
|
const id = await seedMissingWorktreeFailure(["drafting", "building", "checking"], wf);
|
||||||
|
|
||||||
|
expect(await retriedWithClearedSession(id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowLifecycleColumns 2026-07-31-12:30 (PR #2752 review — greptile P1):
|
||||||
|
THE GENERIC RETRY PATH — a plainly failed card outside the review lane.
|
||||||
|
|
||||||
|
The cases above all enter through the in-review stall branches. The ordinary retry — a `failed`
|
||||||
|
card sitting in the WIP lane — falls through to a fourth `moveTask` that my first pass missed
|
||||||
|
because it was written with single quotes while its three siblings used double. Three of four
|
||||||
|
converted, and the one left behind was the common path.
|
||||||
|
|
||||||
|
Nothing automated would have caught that: the census counts comparisons and a move target has
|
||||||
|
none, and a grep for the double-quoted form reports the file clean. So the suite now exercises the
|
||||||
|
path by BEHAVIOUR rather than trusting that all the call sites were found.
|
||||||
|
*/
|
||||||
|
async function seedFailedInWipLane(path: readonly string[], workflowId?: string): Promise<string> {
|
||||||
|
const store = h.store();
|
||||||
|
const column = path[path.length - 1];
|
||||||
|
const task = await store.createTask({ title: "plain failure", description: "test", column: "todo" });
|
||||||
|
if (workflowId) await store.writeTaskWorkflowSelection(task.id, workflowId, []);
|
||||||
|
for (const step of path) await store.moveTask(task.id, step as never);
|
||||||
|
await store.updateTask(task.id, { status: "failed" } as never);
|
||||||
|
store.taskCache.delete(task.id);
|
||||||
|
|
||||||
|
const seeded = await store.getTask(task.id);
|
||||||
|
expect(seeded.column).toBe(column);
|
||||||
|
expect(seeded.status).toBe("failed");
|
||||||
|
return task.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control: the generic path works under the default vocabulary. */
|
||||||
|
it("default vocabulary: a plainly failed WIP card is retried to the hold column", async () => {
|
||||||
|
const id = await seedFailedInWipLane(["in-progress"]);
|
||||||
|
|
||||||
|
await runTaskRetry(id);
|
||||||
|
h.store().taskCache.delete(id);
|
||||||
|
expect((await h.store().getTask(id)).column).toBe("todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The P1: this threw `Invalid transition: 'building' -> 'todo'` before the fourth site moved. */
|
||||||
|
it("renamed vocabulary: a plainly failed WIP card is retried to the board's OWN hold column", async () => {
|
||||||
|
const wf = await seedRenamedWorkflow();
|
||||||
|
const id = await seedFailedInWipLane(["drafting", "building"], wf);
|
||||||
|
|
||||||
|
await runTaskRetry(id);
|
||||||
|
h.store().taskCache.delete(id);
|
||||||
|
expect((await h.store().getTask(id)).column).toBe("drafting");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1400,12 +1400,33 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
|||||||
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
|
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowLifecycleColumns 2026-07-31-11:20 (fleet — the retry TARGET, live regression on main):
|
||||||
|
Retry re-queues the card to its board's HOLD column, resolved from the task's own workflow.
|
||||||
|
|
||||||
|
#2728 converted the retry CLASSIFIER above (`retryReviewColumns.has(task.column)`) and left all
|
||||||
|
three re-queue targets below on the literal `"todo"`. That combination is strictly worse than the
|
||||||
|
bug it fixed: before, `fn task retry` silently did nothing on a renamed board; after, it
|
||||||
|
correctly decides to retry and then THROWS
|
||||||
|
|
||||||
|
TransitionRejectionError: Invalid transition: 'checking' -> 'todo'. Unknown column for this workflow.
|
||||||
|
|
||||||
|
because `todo` is not a column that board declares.
|
||||||
|
|
||||||
|
The census cannot see this: it counts COMPARISONS, and a move target contains none. So the file
|
||||||
|
reads 0 guards while the crash is live — which is exactly why the classifier and the target must
|
||||||
|
move together.
|
||||||
|
|
||||||
|
Fail-soft to `"todo"` when the workflow cannot be resolved, matching every other fallback here.
|
||||||
|
*/
|
||||||
|
const retryHoldColumn = (await resolveTaskLifecycleColumns(context.store, id))?.hold ?? "todo";
|
||||||
|
|
||||||
const autoPauseClearPatch = buildAutoPauseClearPatch(task);
|
const autoPauseClearPatch = buildAutoPauseClearPatch(task);
|
||||||
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
|
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
|
||||||
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
|
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
|
||||||
|
|
||||||
if (isMissingWorktreeSessionRetry) {
|
if (isMissingWorktreeSessionRetry) {
|
||||||
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, "todo", { preserveProgress: true }));
|
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, retryHoldColumn as never, { preserveProgress: true }));
|
||||||
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
||||||
status: null,
|
status: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1427,7 +1448,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
|||||||
// and merge failures (all steps done).
|
// and merge failures (all steps done).
|
||||||
if (isInReviewRetry) {
|
if (isInReviewRetry) {
|
||||||
if (isExecutionFailureInReview) {
|
if (isExecutionFailureInReview) {
|
||||||
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, "todo", { preserveProgress: true }));
|
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, retryHoldColumn as never, { preserveProgress: true }));
|
||||||
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
||||||
status: null,
|
status: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1447,7 +1468,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, "todo"));
|
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, retryHoldColumn as never));
|
||||||
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
||||||
status: null,
|
status: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1462,10 +1483,20 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to todo column before applying retry resets. `moveTask` reads from the
|
// Move to the hold column before applying retry resets. `moveTask` reads from the
|
||||||
// store's durable index and may overwrite task.json-only updates, so apply the
|
// store's durable index and may overwrite task.json-only updates, so apply the
|
||||||
// manual retry reset patch after the move to make the cleared counters stick.
|
// manual retry reset patch after the move to make the cleared counters stick.
|
||||||
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, 'todo'));
|
/*
|
||||||
|
FNXC:WorkflowLifecycleColumns 2026-07-31-12:30 (PR #2752 review — greptile P1):
|
||||||
|
THE FOURTH TARGET, and the one that matters most.
|
||||||
|
|
||||||
|
This is the GENERIC retry fallthrough — a plainly `failed` or `stuck-killed` card, which is the
|
||||||
|
ordinary case, not the in-review stall paths above. It was written with SINGLE quotes while its
|
||||||
|
three siblings used double, so my first pass converted three of four and left the common path
|
||||||
|
crashing. Found by review, not by me, and not by any tool: the census counts comparisons and sees
|
||||||
|
none of these, and a same-file grep for the double-quoted form reports clean.
|
||||||
|
*/
|
||||||
|
await retryBoardCall(context, id, "move task", () => context.store.moveTask(id, retryHoldColumn as never));
|
||||||
|
|
||||||
// Clear failure state and stale branch refs so retry can choose a fresh base.
|
// Clear failure state and stale branch refs so retry can choose a fresh base.
|
||||||
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
await retryBoardCall(context, id, "update task", () => context.store.updateTask(id, {
|
||||||
|
|||||||
Reference in New Issue
Block a user