feat(engine): add force to fn_task_promote for agent-native override parity

fn_task_promote can now pass force:true to start execution when a task is still
waiting on planning or plan review, matching the dashboard's promote override.
The rejection message names the flag so a caller that hits the gate can decide,
and a forced release says the pending replan was cancelled rather than burying it.

Force stays opt-in per explicit promote request: the hold-release sweep and the
webhook event release have no force parameter, 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 20:02:14 -07:00
parent 82e0ce3132
commit b5707318b5
5 changed files with 84 additions and 15 deletions

View File

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

View File

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

View File

@@ -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) |
<!-- FNXC:SkillSync 2026-06-17-23:05: Engine session-scoped `fn_*` tools registered in `packages/engine` must be mirrored in this reference because `packages/cli/src/__tests__/skill-sync.test.ts` treats the backticked tool names here as the documentation source of truth and fails the CLI + gate suites on drift. -->
| `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) |

View File

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

View File

@@ -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<typeof taskPromoteParams>) => {
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,