diff --git a/.changeset/promote-force-unplanned.md b/.changeset/promote-force-unplanned.md index 7fb3fc7fa6..d35e2b5c49 100644 --- a/.changeset/promote-force-unplanned.md +++ b/.changeset/promote-force-unplanned.md @@ -4,4 +4,4 @@ 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. +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 }` and `fn_task_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 b25e1ee54e..e3475b715b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,7 +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. +- 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 opt-in per explicit promote request only (dashboard promote confirm dialog, `POST /tasks/:id/promote` with `{ force: true }`, or `fn_task_promote` with `force: true`) — the hold-release sweep and the webhook event release cannot 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/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 3e2108a194..436ea3aaf0 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -38,7 +38,7 @@ Archived publication is deliberately absent from every runtime tool schema: ther | `fn_workflow_delete` | executor, chat, planning | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) | | `fn_ask_question` | chat | Ask the user a structured question that renders as an interactive chat card; after calling it, end the turn and wait for the user's next message | `questions` (array of objects with `question`, optional `header`, optional `description`, optional `type`, optional `options`, optional `multiSelect`) | -| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) | +| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task); `force` starts execution even when planning/plan review is still outstanding, waiving only that gate and cancelling the pending replan | `task_id?` (string), `force?` (boolean) | | `fn_task_file_scope_add` | executor | Add one or more repo-relative files/globs to this task's declared `## File Scope` when you must edit beyond the initial scope, so edits are not stranded by the scope-aware squash merge (merge-time cross-task overlap blocking remains the backstop) | `files` (string[]) | | `fn_trait_list` | executor, chat, planning | List the registered column trait catalog (built-in and plugin traits) | none | | `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) | diff --git a/packages/engine/src/__tests__/promote-force-unplanned.test.ts b/packages/engine/src/__tests__/promote-force-unplanned.test.ts index 7612cd8dc7..06ea970b74 100644 --- a/packages/engine/src/__tests__/promote-force-unplanned.test.ts +++ b/packages/engine/src/__tests__/promote-force-unplanned.test.ts @@ -1,17 +1,19 @@ /* FNXC:WorkflowScheduling 2026-07-25-04:55: -Operator force-promote past the `unplanned-for-execution` gate (the rejection an +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). +under test is scoped: `force` waives the PLAN gate and nothing else, and only on +an explicit promote request. 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), the automatic event release (has no `force` +parameter at all, so FN-7648 still holds for automatic releases), and the +agent-native `fn_task_promote` tool (same two outcomes through the tool surface). */ import { describe, expect, it, vi } from "vitest"; import type { WorkflowIr } from "@fusion/core"; import { promoteHeldTask, releaseHeldTaskByEvent } from "../hold-release.js"; +import { createTaskPromoteTool } from "../agent-tools.js"; function workflow(): WorkflowIr { return { @@ -126,3 +128,33 @@ describe("force-promote past the unplanned-for-execution gate", () => { expect(store.moveTaskIf).not.toHaveBeenCalled(); }); }); + +describe("fn_task_promote force parity", () => { + it("rejects without force and names the flag so the caller can decide", async () => { + const store = makeStore(); + const tool = createTaskPromoteTool(store as never, "FN-1403"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (tool as any).execute("call-1", {}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("unplanned-for-execution"); + expect(result.content[0].text).toContain("force:true"); + expect(store.moveTaskIf).not.toHaveBeenCalled(); + }); + + it("releases with force and reports that the replan was waived", async () => { + const store = makeStore(); + const tool = createTaskPromoteTool(store as never, "FN-1403"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (tool as any).execute("call-2", { force: true }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("in-progress"); + expect(result.content[0].text).toContain("replan was cancelled"); + expect(result.details).toMatchObject({ released: true, forcedUnplanned: true }); + expect(store.moveTaskIf).toHaveBeenCalledTimes(1); + expect(store.task.status).toBeNull(); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index f1b573580c..b808f98cd7 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -253,6 +253,22 @@ export const taskPromoteParams = Type.Object({ task_id: Type.Optional( Type.String({ description: "Held task to promote. Defaults to the current task." }), ), + /* + FNXC:WorkflowScheduling 2026-07-25-05:40: + Agent-native parity with the dashboard's force-promote override: an agent that + has read the card and judged the pending replan / Plan Review not worth waiting + for can start execution anyway. Opt-in per call and never defaulted on — the + automatic surfaces (hold-release sweep, webhook release) still cannot force, so + FN-7648 holds for everything that is not an explicit promote request. + */ + force: Type.Optional( + Type.Boolean({ + description: + "Start execution even when the task is still waiting on planning or plan review " + + "(rejection 'unplanned-for-execution'). Waives ONLY that gate — hold membership and " + + "downstream capacity still apply — and cancels the pending replan. Default false.", + }), + ), }); export const workflowCreateParams = Type.Object({ @@ -2781,6 +2797,13 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string * Create a `fn_task_promote` tool that manually releases a held task out of its * hold column — the agent-native equivalent of the dashboard's "promote" action. * Defaults to the current task. Wraps {@link promoteHeldTask}. + * + * FNXC:WorkflowScheduling 2026-07-25-05:40: + * `force: true` mirrors the dashboard's confirm-dialog override for the + * `unplanned-for-execution` rejection. The rejection message names the flag so a + * caller that hit the gate can decide to waive it rather than guessing; the + * result text says so explicitly when a promote was forced, because "started + * without its plan review" is not a detail to bury. */ export function createTaskPromoteTool(store: TaskStore, currentTaskId: string): ToolDefinition { return { @@ -2790,25 +2813,39 @@ export function createTaskPromoteTool(store: TaskStore, currentTaskId: string): "Manually promote a held task out of its hold column, releasing it regardless of the " + "hold's release kind (the explicit operator action a 'manual' hold waits for). Defaults " + "to the current task. Returns the destination column, or a rejection reason when the task " + - "is not held or the destination is full.", + "is not held or the destination is full. Pass force:true to start execution even when " + + "planning or plan review is still outstanding (that waives the plan gate and cancels the " + + "pending replan; capacity still applies).", parameters: taskPromoteParams, execute: async (_id: string, params: Static) => { const taskId = params.task_id?.trim() || currentTaskId; + const force = params.force === true; try { - const outcome = await promoteHeldTask(store, taskId); + const outcome = await promoteHeldTask(store, taskId, {}, { force }); if (outcome.released) { + const forcedNote = outcome.forcedUnplanned + ? " Forced past the outstanding planning/plan review — the pending replan was cancelled." + : ""; return { content: [{ type: "text" as const, - text: `Promoted ${taskId} to column '${outcome.toColumn}'.`, + text: `Promoted ${taskId} to column '${outcome.toColumn}'.${forcedNote}`, }], - details: { taskId, released: true, toColumn: outcome.toColumn }, + details: { + taskId, + released: true, + toColumn: outcome.toColumn, + forcedUnplanned: outcome.forcedUnplanned === true, + }, }; } + const forceHint = outcome.rejection === "unplanned-for-execution" + ? " Pass force:true to start execution anyway." + : ""; return { content: [{ type: "text" as const, - text: `ERROR: Could not promote ${taskId}: ${outcome.rejection ?? "unknown"}.`, + text: `ERROR: Could not promote ${taskId}: ${outcome.rejection ?? "unknown"}.${forceHint}`, }], details: { taskId, released: false, rejection: outcome.rejection }, isError: true,