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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user