From 41d60f0355ea346929c7bb7550b900b35032a121 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 19:56:54 -0700 Subject: [PATCH] feat(board): explain the unplanned promote rejection and let operators force past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/promote-force-unplanned.md | 7 + AGENTS.md | 1 + packages/dashboard/app/api/tasks-lifecycle.ts | 17 ++- packages/dashboard/app/components/Board.tsx | 5 +- packages/dashboard/app/components/Column.tsx | 59 +++++++- .../app/components/__tests__/Column.test.tsx | 80 +++++++++++ .../routes/register-task-workflow-routes.ts | 13 +- .../__tests__/promote-force-unplanned.test.ts | 128 ++++++++++++++++++ packages/engine/src/hold-release.ts | 70 +++++++++- packages/i18n/locales/en/app.json | 5 + packages/i18n/locales/en/common.json | 1 + packages/i18n/locales/es/app.json | 5 + packages/i18n/locales/es/common.json | 1 + packages/i18n/locales/fr/app.json | 5 + packages/i18n/locales/fr/common.json | 1 + packages/i18n/locales/ko/app.json | 5 + packages/i18n/locales/ko/common.json | 1 + packages/i18n/locales/zh-CN/app.json | 5 + packages/i18n/locales/zh-CN/common.json | 1 + packages/i18n/locales/zh-TW/app.json | 5 + packages/i18n/locales/zh-TW/common.json | 1 + 21 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 .changeset/promote-force-unplanned.md create mode 100644 packages/engine/src/__tests__/promote-force-unplanned.test.ts diff --git a/.changeset/promote-force-unplanned.md b/.changeset/promote-force-unplanned.md new file mode 100644 index 0000000000..7fb3fc7fa6 --- /dev/null +++ b/.changeset/promote-force-unplanned.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 5e3e9ed94b..b25e1ee54e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/-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. diff --git a/packages/dashboard/app/api/tasks-lifecycle.ts b/packages/dashboard/app/api/tasks-lifecycle.ts index 33b3c1ae0f..8a658faff4 100644 --- a/packages/dashboard/app/api/tasks-lifecycle.ts +++ b/packages/dashboard/app/api/tasks-lifecycle.ts @@ -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 { - return api(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 { + return api(withProjectId(`/tasks/${id}/promote`, projectId), { + method: "POST", + body: JSON.stringify({ force: options?.force === true }), + }); } /** diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 33933e0ab1..3c2bca1522 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -407,8 +407,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o } = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache }); const draggingTaskIdRef = useRef(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(() => { diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 101f3fcbe8..44df968358 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -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; /** Manually promote a held card out of this hold column (workflow mode). */ - onPromote?: (taskId: string) => Promise; + /** `force` waives the unplanned-for-execution gate after operator confirmation. */ + onPromote?: (taskId: string, options?: { force?: boolean }) => Promise; /** * 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 []; diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 3f24b6ec02..56910c933b 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -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) { + return render( + , + ); + } + + 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 }, diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 7b733767c2..54d4605b71 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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") { diff --git a/packages/engine/src/__tests__/promote-force-unplanned.test.ts b/packages/engine/src/__tests__/promote-force-unplanned.test.ts new file mode 100644 index 0000000000..7612cd8dc7 --- /dev/null +++ b/packages/engine/src/__tests__/promote-force-unplanned.test.ts @@ -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 = {}) { + const task = { id: "FN-1403", column: "todo", status: "needs-replan" } as Record; + const moveTaskIf = vi.fn(async () => ({ moved: true })); + const updateTask = vi.fn(async (_id: string, updates: Record) => { + 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; +} + +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(); + }); +}); diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts index 7ebb928e9d..fb585a8f8e 100644 --- a/packages/engine/src/hold-release.ts +++ b/packages/engine/src/hold-release.ts @@ -607,6 +607,7 @@ async function issueRelease( task: Task, target: string, ir: WorkflowIr, + options: { allowUnplanned?: boolean } = {}, ): Promise { 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 = {}, -): 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 }; } /** diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index a2ed6bf770..2d9ecce093 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 7d42a3a64a..39bc16f5a5 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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." } }, diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index c9d2950996..c6bd181c2b 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -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": "", diff --git a/packages/i18n/locales/es/common.json b/packages/i18n/locales/es/common.json index 28dbaca35c..3753662f96 100644 --- a/packages/i18n/locales/es/common.json +++ b/packages/i18n/locales/es/common.json @@ -38,6 +38,7 @@ "guardRejected": "", "mergeBlocked": "", "unknownColumn": "", + "unplannedForExecution": "", "workflowMismatch": "" } }, diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 08574358b4..41c05aa7dc 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -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": "", diff --git a/packages/i18n/locales/fr/common.json b/packages/i18n/locales/fr/common.json index 28dbaca35c..3753662f96 100644 --- a/packages/i18n/locales/fr/common.json +++ b/packages/i18n/locales/fr/common.json @@ -38,6 +38,7 @@ "guardRejected": "", "mergeBlocked": "", "unknownColumn": "", + "unplannedForExecution": "", "workflowMismatch": "" } }, diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index a964df5dca..7f14a88f3a 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -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": "", diff --git a/packages/i18n/locales/ko/common.json b/packages/i18n/locales/ko/common.json index 28dbaca35c..3753662f96 100644 --- a/packages/i18n/locales/ko/common.json +++ b/packages/i18n/locales/ko/common.json @@ -38,6 +38,7 @@ "guardRejected": "", "mergeBlocked": "", "unknownColumn": "", + "unplannedForExecution": "", "workflowMismatch": "" } }, diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 8168d49818..0197574a9a 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -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": "", diff --git a/packages/i18n/locales/zh-CN/common.json b/packages/i18n/locales/zh-CN/common.json index 28dbaca35c..3753662f96 100644 --- a/packages/i18n/locales/zh-CN/common.json +++ b/packages/i18n/locales/zh-CN/common.json @@ -38,6 +38,7 @@ "guardRejected": "", "mergeBlocked": "", "unknownColumn": "", + "unplannedForExecution": "", "workflowMismatch": "" } }, diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 1ae5a4f780..8bb4a8baf5 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -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": "", diff --git a/packages/i18n/locales/zh-TW/common.json b/packages/i18n/locales/zh-TW/common.json index 28dbaca35c..3753662f96 100644 --- a/packages/i18n/locales/zh-TW/common.json +++ b/packages/i18n/locales/zh-TW/common.json @@ -38,6 +38,7 @@ "guardRejected": "", "mergeBlocked": "", "unknownColumn": "", + "unplannedForExecution": "", "workflowMismatch": "" } },