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:
gsxdsm
2026-07-25 19:56:54 -07:00
parent 1efff4e83c
commit 41d60f0355
21 changed files with 400 additions and 16 deletions

View 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.

View File

@@ -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.

View File

@@ -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 }),
});
}
/**

View File

@@ -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(() => {

View File

@@ -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 [];

View File

@@ -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 },

View File

@@ -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") {

View 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();
});
});

View File

@@ -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 };
}
/**

View File

@@ -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",

View File

@@ -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."
}
},

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"unplannedForExecution": "",
"workflowMismatch": ""
}
},

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"unplannedForExecution": "",
"workflowMismatch": ""
}
},

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"unplannedForExecution": "",
"workflowMismatch": ""
}
},

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"unplannedForExecution": "",
"workflowMismatch": ""
}
},

View File

@@ -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": "",

View File

@@ -38,6 +38,7 @@
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"unplannedForExecution": "",
"workflowMismatch": ""
}
},