feat(board): explain the unplanned promote rejection and let operators force past it
Promote on a held card printed the raw i18n key `board.rejection.unplannedForExecution`:
FN-8471 added the server-side code without a client case or catalog entry, so
translateRejection fell through to `t(messageKey, messageKey)`.
- Add the explicit rejection case (both translate helpers) plus the en catalog
entry and secondary-locale stubs.
- promoteHeldTask(..., { force }) waives ONLY the unplanned-for-execution gate;
hold membership, capacity and slot reservation still arbitrate. It clears a
needs-replan/plan-review-unavailable status so triage rediscovery cannot pull
the card back into the waived replan, and emits task:promote-forced-unplanned.
- POST /tasks/:id/promote accepts { force: true }; the board asks for explicit
confirmation first and only offers the override for this rejection.
Force stays operator-only — the sweep, the webhook release and fn_task_promote
never set it, so FN-7648 still holds for every automatic surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/promote-force-unplanned.md
Normal file
7
.changeset/promote-force-unplanned.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Promote on a held card now explains why it was refused and can force execution past a pending replan.
|
||||
category: feature
|
||||
dev: `promoteHeldTask(store, id, deps, { force })` waives only the `unplanned-for-execution` gate (capacity, hold membership and slot reservation still arbitrate), clears a `needs-replan`/`plan-review-unavailable` status, and emits `task:promote-forced-unplanned`. `POST /tasks/:id/promote` accepts `{ force: true }`; the board asks for confirmation first. Adds the missing `board.rejection.unplannedForExecution` catalog entry that made the raw i18n key render.
|
||||
@@ -266,6 +266,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
||||
- FN-7011/FN-7975: self-healing emits `task:reconcile-engine-downtime-active-timing` when startup recovery or a full Global/Engine unpause shifts active task segment anchors to exclude proven stopped-engine wall-clock, and `task:reconcile-engine-downtime-active-timing-no-action` when no active task qualifies.
|
||||
- FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes.
|
||||
- KB-002: divergent post-merge pushes emit `push:recovery-branch` for the remote `fusion/<task-id>-stranded` safety-ref lifecycle; metadata stays ids/outcomes-only (`taskId`, `remote`, `recoveryBranch`, `sha`, `outcome`). Aborted target pushes emit `push:origin` with `outcome:"aborted"` and remain non-fatal after task finalization.
|
||||
- Operator force-promote emits `task:promote-forced-unplanned` when `promoteHeldTask(..., { force: true })` waives the `unplanned-for-execution` gate (pending replan / pre-release Plan Review) and releases the card into a WIP column; metadata stays ids/outcomes-only (`fromColumn`, `toColumn`, `priorStatus`). Force is operator-only (dashboard promote confirm dialog / `POST /tasks/:id/promote` with `{ force: true }`) — the sweep, the webhook release, and `fn_task_promote` never set it, so FN-7648's "no unplanned card enters a processing column" invariant still holds for every automatic surface. Force waives ONLY the plan gate: hold membership, capacity, and slot reservation still arbitrate. It also clears a `needs-replan`/`plan-review-unavailable` status so triage rediscovery cannot pull the card back into the waived replan.
|
||||
- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move.
|
||||
- FN-6736: self-healing emits `task:reclaim-phantom-executor-binding` when it proves an in-memory executor-active binding is stale, clears the binding, and requeues the in-progress task with worktree/progress preserved.
|
||||
- FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs.
|
||||
|
||||
@@ -14,9 +14,20 @@ import { api } from "./client.js";
|
||||
import { withProjectId } from "./health.js";
|
||||
import type { DeleteTaskOptions, ArchiveTaskOptions } from "./tasks.js";
|
||||
|
||||
/** Manually promote a held card out of its hold column (U9). */
|
||||
export function promoteTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/promote`, projectId), { method: "POST" });
|
||||
/**
|
||||
* Manually promote a held card out of its hold column (U9).
|
||||
*
|
||||
* FNXC:WorkflowScheduling 2026-07-25-04:55:
|
||||
* `force` waives the `unplanned-for-execution` gate (pending replan / pre-release
|
||||
* Plan Review) and starts execution anyway. It is only ever sent after the
|
||||
* operator confirms the override dialog the plain promote's rejection raises;
|
||||
* capacity is still enforced server-side, so a forced promote can still reject.
|
||||
*/
|
||||
export function promoteTask(id: string, projectId?: string, options?: { force?: boolean }): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/promote`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ force: options?.force === true }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -407,8 +407,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
} = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache });
|
||||
const draggingTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
const handlePromote = useCallback(async (taskId: string) => {
|
||||
await promoteTask(taskId, projectId);
|
||||
const handlePromote = useCallback(async (taskId: string, options?: { force?: boolean }) => {
|
||||
// `force` only ever arrives from Column's confirmed unplanned-for-execution override.
|
||||
await promoteTask(taskId, projectId, options);
|
||||
}, [projectId]);
|
||||
|
||||
const handleToggleAutoMerge = useCallback(() => {
|
||||
|
||||
@@ -67,6 +67,19 @@ export function translateRejection(t: TFn, rejection: TransitionRejectionDetail)
|
||||
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
|
||||
case "merge-blocked":
|
||||
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
|
||||
/*
|
||||
FNXC:BoardRejections 2026-07-25-04:55:
|
||||
FN-8471 added this server-side code without a client case or catalog entry, so
|
||||
the default branch fell through to `t(messageKey, messageKey)` and the board
|
||||
printed the raw `board.rejection.unplannedForExecution` key at operators. The
|
||||
static literal here is also what the i18next extractor sees, so the key must
|
||||
be spelled out in the switch rather than resolved via the carried messageKey.
|
||||
*/
|
||||
case "unplanned-for-execution":
|
||||
return t(
|
||||
"board.rejection.unplannedForExecution",
|
||||
"This task isn't ready for execution yet — planning or plan review is still outstanding.",
|
||||
);
|
||||
default:
|
||||
return t(rejection.messageKey, rejection.messageKey);
|
||||
}
|
||||
@@ -86,6 +99,11 @@ export function translateRejectionKey(t: TFn, messageKey: string): string {
|
||||
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
|
||||
case "board.rejection.mergeBlocked":
|
||||
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
|
||||
case "board.rejection.unplannedForExecution":
|
||||
return t(
|
||||
"board.rejection.unplannedForExecution",
|
||||
"This task isn't ready for execution yet — planning or plan review is still outstanding.",
|
||||
);
|
||||
default:
|
||||
return t(messageKey, messageKey);
|
||||
}
|
||||
@@ -196,7 +214,8 @@ interface ColumnProps {
|
||||
/** Per-task workflow columns for aggregate Board cards whose tasks come from different workflows. */
|
||||
taskContextMenuColumnsByTaskId?: ReadonlyMap<string, readonly TaskContextMenuColumnMetadata[]>;
|
||||
/** Manually promote a held card out of this hold column (workflow mode). */
|
||||
onPromote?: (taskId: string) => Promise<void>;
|
||||
/** `force` waives the unplanned-for-execution gate after operator confirmation. */
|
||||
onPromote?: (taskId: string, options?: { force?: boolean }) => Promise<void>;
|
||||
/**
|
||||
* Pre-check whether a drop into THIS column is allowed for the dragged task.
|
||||
* Returns null for "allowed", or an i18n messageKey for a deterministic
|
||||
@@ -416,6 +435,16 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
}
|
||||
}, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]);
|
||||
|
||||
/*
|
||||
FNXC:BoardPromote 2026-07-25-04:55:
|
||||
Promote is a two-attempt flow for the `unplanned-for-execution` rejection only.
|
||||
The first attempt never forces; if the server refuses because a replan / plan
|
||||
review is still outstanding, the operator is asked whether to start execution
|
||||
anyway and the retry carries `{ force: true }`. Every other rejection (capacity,
|
||||
guard) stays a plain inline message with no override — those are not the
|
||||
operator's call to waive. Inline feedback rather than a toast, so several holds
|
||||
can promote concurrently without toast spam.
|
||||
*/
|
||||
const handlePromote = useCallback(async (taskId: string) => {
|
||||
if (!onPromote) return;
|
||||
setInlineFeedback(null);
|
||||
@@ -425,12 +454,32 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await onPromote(taskId);
|
||||
try {
|
||||
await onPromote(taskId);
|
||||
} catch (err) {
|
||||
const rejection = extractTransitionRejection(err);
|
||||
if (rejection?.code !== "unplanned-for-execution") throw err;
|
||||
|
||||
const forceConfirmed = await confirm({
|
||||
title: t("column.promoteUnplannedTitle", "Start execution anyway?"),
|
||||
message: t(
|
||||
"column.promoteUnplannedMessage",
|
||||
"{{taskId}} is still waiting on planning or plan review. Promoting now starts execution with the current plan and cancels the pending replan.",
|
||||
{ taskId },
|
||||
),
|
||||
confirmLabel: t("column.promoteUnplannedConfirm", "Start Anyway"),
|
||||
cancelLabel: t("column.promoteUnplannedCancel", "Keep Waiting"),
|
||||
danger: true,
|
||||
});
|
||||
if (!forceConfirmed) {
|
||||
setInlineFeedback(translateRejection(t, rejection));
|
||||
return;
|
||||
}
|
||||
await onPromote(taskId, { force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
const rejection = extractTransitionRejection(err);
|
||||
if (rejection) {
|
||||
// Capacity-exhausted (and any rejection) shows INLINE column feedback,
|
||||
// not a toast — so multiple holds can promote concurrently without spam.
|
||||
setInlineFeedback(translateRejection(t, rejection));
|
||||
} else {
|
||||
setInlineFeedback(getErrorMessage(err));
|
||||
@@ -442,7 +491,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [onPromote, t]);
|
||||
}, [confirm, onPromote, t]);
|
||||
|
||||
const worktreeGroups = useMemo(() => {
|
||||
if (!showWorktreeGroups) return [];
|
||||
|
||||
@@ -379,6 +379,86 @@ describe("Column workflow mode (U9)", () => {
|
||||
expect(screen.getByTestId("card-promote-FN-7")).toBeDefined();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:BoardPromote 2026-07-25-04:55:
|
||||
The unplanned-for-execution rejection must (a) render real copy rather than the
|
||||
raw `board.rejection.unplannedForExecution` key and (b) offer the operator an
|
||||
explicit force override. Declining leaves the card held; confirming re-issues
|
||||
the promote with `{ force: true }`.
|
||||
*/
|
||||
function renderHoldColumnWithPromote(onPromote: (taskId: string, options?: { force?: boolean }) => Promise<void>) {
|
||||
return render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column={"hold-col" as ColumnType}
|
||||
workflowMode
|
||||
columnDisplayName="Hold"
|
||||
columnFlags={{ hold: true }}
|
||||
onPromote={onPromote}
|
||||
tasks={[{ ...makeTask("FN-7"), column: "hold-col" as ColumnType }]}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const unplannedRejection = {
|
||||
details: {
|
||||
code: "unplanned-for-execution",
|
||||
messageKey: "board.rejection.unplannedForExecution",
|
||||
retryable: true,
|
||||
forceable: true,
|
||||
},
|
||||
};
|
||||
|
||||
it("offers an override on unplanned-for-execution and keeps the card held when declined", async () => {
|
||||
const onPromote = vi.fn().mockRejectedValue(unplannedRejection);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
renderHoldColumnWithPromote(onPromote);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-promote-FN-7"));
|
||||
|
||||
await waitFor(() => expect(mockConfirm).toHaveBeenCalled());
|
||||
expect(mockConfirm.mock.calls[0][0]).toMatchObject({
|
||||
title: "Start execution anyway?",
|
||||
confirmLabel: "Start Anyway",
|
||||
cancelLabel: "Keep Waiting",
|
||||
danger: true,
|
||||
});
|
||||
expect(mockConfirm.mock.calls[0][0].message).toContain("FN-7");
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined());
|
||||
// Real copy, never the raw i18n key (the FN-8471 regression).
|
||||
expect(screen.getByTestId("column-inline-feedback").textContent).not.toContain("board.rejection");
|
||||
expect(screen.getByTestId("column-inline-feedback").textContent).toContain("plan review");
|
||||
expect(onPromote).toHaveBeenCalledTimes(1);
|
||||
expect(onPromote).toHaveBeenCalledWith("FN-7");
|
||||
});
|
||||
|
||||
it("re-promotes with force once the operator confirms the override", async () => {
|
||||
const onPromote = vi.fn()
|
||||
.mockRejectedValueOnce(unplannedRejection)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
renderHoldColumnWithPromote(onPromote);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-promote-FN-7"));
|
||||
|
||||
await waitFor(() => expect(onPromote).toHaveBeenCalledTimes(2));
|
||||
expect(onPromote).toHaveBeenLastCalledWith("FN-7", { force: true });
|
||||
expect(screen.queryByTestId("column-inline-feedback")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not offer an override for a capacity rejection", async () => {
|
||||
const onPromote = vi.fn().mockRejectedValue({
|
||||
details: { code: "capacity-exhausted", messageKey: "board.rejection.capacityExhausted", retryable: true },
|
||||
});
|
||||
renderHoldColumnWithPromote(onPromote);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-promote-FN-7"));
|
||||
await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined());
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
expect(onPromote).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("#1410: clears the inline capacity banner when the task list changes via SSE", async () => {
|
||||
const onPromote = vi.fn().mockRejectedValue({
|
||||
details: { code: "capacity-exhausted", retryable: true },
|
||||
|
||||
@@ -1779,7 +1779,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
planTaskWorktreePath(task, rootDir, settings.worktreeNaming, reservedNames, settings)
|
||||
: undefined;
|
||||
|
||||
const result = await promoteHeldTask(scopedStore, req.params.id, { allocateWorktree });
|
||||
/*
|
||||
FNXC:WorkflowScheduling 2026-07-25-04:55:
|
||||
`{ force: true }` is the operator's explicit "start it anyway" override for
|
||||
the `unplanned-for-execution` rejection below — the board offers it in the
|
||||
confirm dialog that rejection raises. It waives ONLY the plan/replan gate;
|
||||
capacity and slot reservation still arbitrate the move.
|
||||
*/
|
||||
const force = (req.body as { force?: unknown } | undefined)?.force === true;
|
||||
|
||||
const result = await promoteHeldTask(scopedStore, req.params.id, { allocateWorktree }, { force });
|
||||
if (!result.released) {
|
||||
/*
|
||||
FNXC:WorkflowScheduling 2026-07-21-22:31:
|
||||
@@ -1792,6 +1801,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
code: "unplanned-for-execution",
|
||||
messageKey: "board.rejection.unplannedForExecution",
|
||||
retryable: true,
|
||||
// Tells the board this rejection has an operator override available.
|
||||
forceable: true,
|
||||
});
|
||||
}
|
||||
if (result.rejection === "capacity-exhausted-or-no-slot") {
|
||||
|
||||
128
packages/engine/src/__tests__/promote-force-unplanned.test.ts
Normal file
128
packages/engine/src/__tests__/promote-force-unplanned.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
FNXC:WorkflowScheduling 2026-07-25-04:55:
|
||||
Operator force-promote past the `unplanned-for-execution` gate (the rejection an
|
||||
FN-8471-style pending replan / pre-release Plan Review raises). The invariant
|
||||
under test is scoped: `force` waives the PLAN gate and nothing else, on the
|
||||
operator promote surface only. Surfaces enumerated here — unforced promote
|
||||
(still rejects), forced promote (releases + clears the durable replan signal),
|
||||
forced promote into a full column (still capacity-rejected), forced promote of a
|
||||
card that is not held (still rejected), and the automatic sweep/event release
|
||||
(never sees `force`, so FN-7648 still holds for non-operator releases).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { promoteHeldTask, releaseHeldTaskByEvent } from "../hold-release.js";
|
||||
|
||||
function workflow(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "force-promote",
|
||||
columns: [
|
||||
{ id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
}
|
||||
|
||||
/** A store whose task is parked in the durable replan signal — i.e. unplanned. */
|
||||
function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
const task = { id: "FN-1403", column: "todo", status: "needs-replan" } as Record<string, unknown>;
|
||||
const moveTaskIf = vi.fn(async () => ({ moved: true }));
|
||||
const updateTask = vi.fn(async (_id: string, updates: Record<string, unknown>) => {
|
||||
Object.assign(task, updates);
|
||||
return task;
|
||||
});
|
||||
const recordRunAuditEvent = vi.fn(async () => ({}));
|
||||
const store = {
|
||||
task,
|
||||
getTask: async () => task,
|
||||
updateTask,
|
||||
moveTaskIf,
|
||||
recordRunAuditEvent,
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "custom", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: workflow() }),
|
||||
...overrides,
|
||||
};
|
||||
return store as typeof store & Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("force-promote past the unplanned-for-execution gate", () => {
|
||||
it("rejects an unplanned card when force is not requested", async () => {
|
||||
const store = makeStore();
|
||||
const result = await promoteHeldTask(store as never, "FN-1403");
|
||||
|
||||
expect(result).toMatchObject({ released: false, rejection: "unplanned-for-execution", toColumn: "in-progress" });
|
||||
expect(store.moveTaskIf).not.toHaveBeenCalled();
|
||||
expect(store.task.status).toBe("needs-replan");
|
||||
});
|
||||
|
||||
it("releases into execution and clears the replan signal when forced", async () => {
|
||||
const store = makeStore();
|
||||
const result = await promoteHeldTask(store as never, "FN-1403", {}, { force: true });
|
||||
|
||||
expect(result).toMatchObject({ released: true, toColumn: "in-progress", forcedUnplanned: true });
|
||||
expect(store.moveTaskIf).toHaveBeenCalledTimes(1);
|
||||
// The replan signal must be gone, or triage rediscovery pulls the card back
|
||||
// into the very replan the operator just waived.
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1403", { status: null });
|
||||
expect(store.task.status).toBeNull();
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mutationType: "task:promote-forced-unplanned", taskId: "FN-1403" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still enforces capacity when forced — force waives only the plan gate", async () => {
|
||||
const store = makeStore();
|
||||
const reserveSlot = vi.fn(async () => null);
|
||||
|
||||
const result = await promoteHeldTask(store as never, "FN-1403", { reserveSlot }, { force: true });
|
||||
|
||||
expect(result).toMatchObject({ released: false, rejection: "capacity-exhausted-or-no-slot" });
|
||||
expect(reserveSlot).toHaveBeenCalledTimes(1);
|
||||
expect(store.moveTaskIf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still enforces hold membership when forced", async () => {
|
||||
const store = makeStore({
|
||||
getTask: async () => ({ id: "FN-1403", column: "in-progress", status: "needs-replan" }),
|
||||
});
|
||||
|
||||
const result = await promoteHeldTask(store as never, "FN-1403", {}, { force: true });
|
||||
|
||||
expect(result).toMatchObject({ released: false, rejection: "not-held" });
|
||||
expect(store.moveTaskIf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak the override to the automatic event-release surface", async () => {
|
||||
const store = makeStore({
|
||||
getTask: async () => ({
|
||||
id: "FN-1403",
|
||||
column: "todo",
|
||||
status: "needs-replan",
|
||||
// event releases only act on external-event holds; use one so the
|
||||
// release actually reaches the unplanned guard rather than short-circuiting.
|
||||
}),
|
||||
getWorkflowDefinition: async () => ({
|
||||
ir: {
|
||||
...workflow(),
|
||||
columns: [
|
||||
{ id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "external-event" } }] },
|
||||
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
} as WorkflowIr,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await releaseHeldTaskByEvent(store as never, "FN-1403", "webhook");
|
||||
|
||||
expect(result.released).toBe(false);
|
||||
expect(store.moveTaskIf).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -607,6 +607,7 @@ async function issueRelease(
|
||||
task: Task,
|
||||
target: string,
|
||||
ir: WorkflowIr,
|
||||
options: { allowUnplanned?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const targetColumn = findColumn(ir, target);
|
||||
const targetIsProcessing = targetColumn ? resolveColumnFlags(targetColumn).countsTowardWip === true : false;
|
||||
@@ -621,8 +622,14 @@ async function issueRelease(
|
||||
PROMPT.md, `status: "planning"`, or resident in an `intake`-trait column)
|
||||
must never be moved into a processing column, no matter which surface
|
||||
requested the release (FN-7648).
|
||||
|
||||
FNXC:WorkflowScheduling 2026-07-25-04:55:
|
||||
`allowUnplanned` is the ONLY way past this check, and it is set exclusively by
|
||||
an explicit operator force-promote (`promoteHeldTask({ force: true })`). The
|
||||
automatic surfaces — the sweep and the webhook release — never pass it, so
|
||||
FN-7648's invariant still holds for every non-operator release.
|
||||
*/
|
||||
if (targetIsProcessing && (await isUnplannedForExecution(store, task, ir))) {
|
||||
if (targetIsProcessing && !options.allowUnplanned && (await isUnplannedForExecution(store, task, ir))) {
|
||||
schedulerLog.log(`Hold release for ${task.id} blocked — card is unplanned and cannot enter processing column ${target}`);
|
||||
return false;
|
||||
}
|
||||
@@ -691,12 +698,27 @@ async function issueRelease(
|
||||
* and it is also accepted for other kinds as an operator override. The move
|
||||
* still serializes through the in-txn capacity check (KTD-10): a promote into a
|
||||
* full column rejects with `capacity-exhausted`, surfaced to the caller.
|
||||
*
|
||||
* FNXC:WorkflowScheduling 2026-07-25-04:55:
|
||||
* `options.force` is the operator's "start it anyway" override for the
|
||||
* `unplanned-for-execution` rejection: an operator who has read the card and
|
||||
* decided the pending replan / Plan Review is not worth waiting for can push it
|
||||
* straight into execution. Force ONLY relaxes the unplanned gate — the hold
|
||||
* membership, release target, capacity check, and slot reservation are all still
|
||||
* enforced, so a forced promote into a full column still rejects on capacity.
|
||||
* Force also CLEARS a durable replan signal (`needs-replan` /
|
||||
* `plan-review-unavailable`) before the move: those statuses are read by triage's
|
||||
* todo rediscovery and by this module's own gate, so leaving one in place would
|
||||
* let the card be pulled back for the very replan the operator just waived.
|
||||
* `status: "planning"` is deliberately NOT cleared — triage is mid-write on
|
||||
* PROMPT.md and clearing it would race the writer; the card still releases.
|
||||
*/
|
||||
export async function promoteHeldTask(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
deps: Pick<HoldReleaseDeps, "reserveSlot" | "allocateWorktree"> = {},
|
||||
): Promise<{ released: boolean; toColumn?: string; rejection?: string }> {
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<{ released: boolean; toColumn?: string; rejection?: string; forcedUnplanned?: boolean }> {
|
||||
const task = await store.getTask(taskId);
|
||||
if (!task) return { released: false, rejection: "task-not-found" };
|
||||
|
||||
@@ -717,18 +739,56 @@ export async function promoteHeldTask(
|
||||
const targetIsProcessing = targetColumn
|
||||
? resolveColumnFlags(targetColumn).countsTowardWip === true
|
||||
: false;
|
||||
if (targetIsProcessing && (await isUnplannedForExecution(store, task, ir))) {
|
||||
const unplanned = targetIsProcessing && (await isUnplannedForExecution(store, task, ir));
|
||||
if (unplanned && options.force !== true) {
|
||||
return { released: false, rejection: "unplanned-for-execution", toColumn: target };
|
||||
}
|
||||
|
||||
let promoted = task;
|
||||
if (unplanned) {
|
||||
// Clear the durable replan signal so triage's todo rediscovery and this
|
||||
// module's own gate do not pull the card back into the waived replan.
|
||||
if (task.status === "needs-replan" || task.status === "plan-review-unavailable") {
|
||||
try {
|
||||
await store.updateTask(task.id, { status: null });
|
||||
promoted = { ...task, status: undefined };
|
||||
} catch (error) {
|
||||
schedulerLog.warn(
|
||||
`Force-promote for ${task.id} could not clear replan status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// ids/outcomes-only audit of the override (no prompt or reason prose).
|
||||
if (typeof store.recordRunAuditEvent === "function") {
|
||||
try {
|
||||
await store.recordRunAuditEvent({
|
||||
domain: "database",
|
||||
mutationType: "task:promote-forced-unplanned",
|
||||
target: task.id,
|
||||
taskId: task.id,
|
||||
agentId: "system",
|
||||
runId: `promote-force-${task.id}`,
|
||||
metadata: { fromColumn: task.column, toColumn: target, priorStatus: task.status ?? null },
|
||||
});
|
||||
} catch {
|
||||
// Audit is best-effort — never block the operator's override on it.
|
||||
}
|
||||
}
|
||||
schedulerLog.log(`Force-promote for ${task.id} bypassing unplanned gate into ${target} (operator override)`);
|
||||
}
|
||||
|
||||
const released = await issueRelease(
|
||||
store,
|
||||
{ now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree },
|
||||
task,
|
||||
promoted,
|
||||
target,
|
||||
ir,
|
||||
{ allowUnplanned: unplanned },
|
||||
);
|
||||
return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" };
|
||||
if (!released) {
|
||||
return { released: false, rejection: "capacity-exhausted-or-no-slot" };
|
||||
}
|
||||
return { released: true, toColumn: target, forcedUnplanned: unplanned || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1205,6 +1205,7 @@
|
||||
"mergeBlocked": "This task is blocked from completing until its merge step finishes.",
|
||||
"promoteRejected": "This card could not be promoted.",
|
||||
"unknownColumn": "That column doesn't exist in this task's workflow.",
|
||||
"unplannedForExecution": "This task isn't ready for execution yet — planning or plan review is still outstanding.",
|
||||
"workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead."
|
||||
},
|
||||
"todo": "To Do",
|
||||
@@ -1515,6 +1516,10 @@
|
||||
"preserveProgressMessage": "This task has completed steps. Keep progress before moving?",
|
||||
"preserveProgressMoveTodoMessage": "Some tasks have completed steps. Keep progress before moving to Todo?",
|
||||
"preserveProgressTitle": "Preserve Progress?",
|
||||
"promoteUnplannedCancel": "Keep Waiting",
|
||||
"promoteUnplannedConfirm": "Start Anyway",
|
||||
"promoteUnplannedMessage": "{{taskId}} is still waiting on planning or plan review. Promoting now starts execution with the current plan and cancels the pending replan.",
|
||||
"promoteUnplannedTitle": "Start execution anyway?",
|
||||
"replanAll": "Replan All",
|
||||
"replanAllHint_one": "Move {{count}} task{{plural}} to Planning",
|
||||
"replanAllHint_other": "Move {{count}} task{{plural}} to Planning",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"guardRejected": "This move is not allowed by the workflow.",
|
||||
"mergeBlocked": "This task is blocked from completing until its merge step finishes.",
|
||||
"unknownColumn": "That column doesn't exist in this task's workflow.",
|
||||
"unplannedForExecution": "This task isn't ready for execution yet — planning or plan review is still outstanding.",
|
||||
"workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"mergeBlocked": "",
|
||||
"promoteRejected": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
},
|
||||
"todo": "Por hacer",
|
||||
@@ -1505,6 +1506,10 @@
|
||||
"preserveProgressMessage": "Esta tarea tiene pasos completados. ¿Preservar progreso antes de mover?",
|
||||
"preserveProgressMoveTodoMessage": "Algunas tareas tienen pasos completados. ¿Preservar progreso antes de mover a Tareas pendientes?",
|
||||
"preserveProgressTitle": "¿Preservar progreso?",
|
||||
"promoteUnplannedCancel": "",
|
||||
"promoteUnplannedConfirm": "",
|
||||
"promoteUnplannedMessage": "",
|
||||
"promoteUnplannedTitle": "",
|
||||
"replanAll": "Replanificar todo",
|
||||
"replanAllHint_one": "",
|
||||
"replanAllHint_other": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"guardRejected": "",
|
||||
"mergeBlocked": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"mergeBlocked": "",
|
||||
"promoteRejected": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
},
|
||||
"todo": "À faire",
|
||||
@@ -1505,6 +1506,10 @@
|
||||
"preserveProgressMessage": "Cette tâche a des étapes terminées. Conserver la progression avant de déplacer ?",
|
||||
"preserveProgressMoveTodoMessage": "Certaines tâches ont des étapes terminées. Conserver la progression avant de déplacer vers À faire ?",
|
||||
"preserveProgressTitle": "Conserver la progression ?",
|
||||
"promoteUnplannedCancel": "",
|
||||
"promoteUnplannedConfirm": "",
|
||||
"promoteUnplannedMessage": "",
|
||||
"promoteUnplannedTitle": "",
|
||||
"replanAll": "Replanifier tout",
|
||||
"replanAllHint_one": "",
|
||||
"replanAllHint_other": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"guardRejected": "",
|
||||
"mergeBlocked": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"mergeBlocked": "",
|
||||
"promoteRejected": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
},
|
||||
"todo": "할 일",
|
||||
@@ -1505,6 +1506,10 @@
|
||||
"preserveProgressMessage": "이 작업에는 완료된 단계가 있습니다. 이동하기 전에 진행 상황을 유지하시겠습니까?",
|
||||
"preserveProgressMoveTodoMessage": "일부 작업에 완료된 단계가 있습니다. 할 일로 이동하기 전에 진행 상황을 유지하시겠습니까?",
|
||||
"preserveProgressTitle": "진행 상황을 유지하시겠습니까?",
|
||||
"promoteUnplannedCancel": "",
|
||||
"promoteUnplannedConfirm": "",
|
||||
"promoteUnplannedMessage": "",
|
||||
"promoteUnplannedTitle": "",
|
||||
"replanAll": "모두 재계획",
|
||||
"replanAllHint_one": "",
|
||||
"replanAllHint_other": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"guardRejected": "",
|
||||
"mergeBlocked": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"mergeBlocked": "",
|
||||
"promoteRejected": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
},
|
||||
"todo": "待办",
|
||||
@@ -1505,6 +1506,10 @@
|
||||
"preserveProgressMessage": "此任务已完成步骤。在移动前保留进度?",
|
||||
"preserveProgressMoveTodoMessage": "某些任务已完成步骤。在移至待办前保留进度?",
|
||||
"preserveProgressTitle": "保留进度?",
|
||||
"promoteUnplannedCancel": "",
|
||||
"promoteUnplannedConfirm": "",
|
||||
"promoteUnplannedMessage": "",
|
||||
"promoteUnplannedTitle": "",
|
||||
"replanAll": "全部重新计划",
|
||||
"replanAllHint_one": "",
|
||||
"replanAllHint_other": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"guardRejected": "",
|
||||
"mergeBlocked": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"mergeBlocked": "",
|
||||
"promoteRejected": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
},
|
||||
"todo": "待辦",
|
||||
@@ -1505,6 +1506,10 @@
|
||||
"preserveProgressMessage": "此工作已完成步驟。在移動前保留進度?",
|
||||
"preserveProgressMoveTodoMessage": "某些工作已完成步驟。在移至待辦前保留進度?",
|
||||
"preserveProgressTitle": "保留進度?",
|
||||
"promoteUnplannedCancel": "",
|
||||
"promoteUnplannedConfirm": "",
|
||||
"promoteUnplannedMessage": "",
|
||||
"promoteUnplannedTitle": "",
|
||||
"replanAll": "全部重新規劃",
|
||||
"replanAllHint_one": "",
|
||||
"replanAllHint_other": "",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"guardRejected": "",
|
||||
"mergeBlocked": "",
|
||||
"unknownColumn": "",
|
||||
"unplannedForExecution": "",
|
||||
"workflowMismatch": ""
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user