fix(engine): add honest blocked exit to fn_task_done so impossible tasks park failed instead of laundering to done (#2256)

## What & why

FN-8141 ("Update pi SDK to latest and verify Kimi K3 end to end") was
impossible as specced — pi 0.80.x removed `AuthStorage`/`ModelRegistry`
APIs, so every SDK bump broke the build. The executor correctly reverted
its work and filed follow-up FN-8145 — but had **no sanctioned way to
end the task in a blocked state**. `fn_task_done` only expressed
success: the bulk-completion gate refused it, the requeue budget re-ran
the doomed task 5 times, and the only remaining affordance (mark every
step `skipped`, then complete) made `isTaskComplete()` return true.
Self-healing then promoted the "complete" todo to in-review and the AI
merger finalized the empty diff as `done`. **The honest path must be
cheaper than the laundering path.**

This adds a first-class **blocked** outcome to the executor's
`fn_task_done` tool.

## Change

- `fn_task_done` gains `outcome: "completed" | "blocked"` (default
`"completed"`), optional `blockedBy: string[]`, and `reason` (required
when blocked).
- `outcome="blocked"` runs **before** every completion gate (completion
blocker, verdict providers, worktree invariants, bulk-completion
refusal) — blocked is not a completion claim, so none of those gates
apply.
- Parks the task `failed` with `error = "BLOCKED: <reason>"`, following
the FN-7863 `EXECUTION_DISPATCH_LOOP_EXHAUSTED` park convention: **steps
keep their true statuses** (no auto-done, no auto-skip), worktree/branch
preserved. It does **not** call `onDone()`, so the executor's existing
`status === "failed"` post-loop branch honors the park instead of
handing off to review.
- `blockedBy` is recorded as real `task.dependencies` edges (unioned
with existing) so the task requeues behind the blocker.
- Emits run-audit `task:execution-blocked-parked` with ids/outcomes-only
metadata (`taskId`, `blockedBy` ids, `hasReason` boolean — **never** the
reason prose).
- Executor + core prompt guidance and the
`bulk-step-completion-without-review` refusal message now name the
blocked exit as **the** correct action when work cannot proceed,
replacing skip-and-done. `PREMISE STALE:` skip guidance is preserved for
genuinely-stale premises.

## Surface enumeration

- **fn_task_done tool schema + handler**
(`packages/engine/src/executor.ts`): blocked branch added at the top of
`execute`, before all gates.
- **Refusal/requeue machinery**: `formatTaskDoneRefusal` for
`bulk-step-completion-without-review` now points at the blocked exit;
the requeue-budget path is untouched (blocked never enters it).
- **Executor prompt text**: turn-ending rules, the "Cannot proceed"
section, the preflight/stale-premise escape hatch (now explicitly
distinguishes stale-premise skip from blocked).
- **Core prompt mirror** (`packages/core/src/agent-prompts.ts`): same
turn-ending + cannot-proceed guidance.
- **Tool reference doc**
(`packages/cli/skill/fusion/references/engine-tools.md`): `fn_task_done`
params updated. (grep for `fn_task_done` confirmed the only executable
tool schema is in executor.ts; CLI/pi surfaces re-export it, no separate
schema copy.)
- **Self-healing**: verified a blocked-parked row is NOT auto-recovered
by `recoverStrandedCompletedTodoTasks` — its steps are not all
done/skipped and `task.error` is set (both are hard filters in the
sweep).
- **Run Audit inventory** (`AGENTS.md`): documented the new event.

## Test evidence

New `packages/engine/src/__tests__/executor-task-done-blocked.test.ts`
(8 tests) asserts the invariant across surfaces:

```
pnpm --filter @fusion/engine exec vitest run \
  src/__tests__/executor-task-done-blocked.test.ts \
  src/__tests__/executor-task-done-invariant.test.ts \
  src/__tests__/gating-classifications.test.ts \
  src/__tests__/reliability-interactions/execute-requeue-loop-guard.test.ts --reporter=dot
→ Test Files 3 passed | Tests 138 passed (0 failed)
```

Coverage: blocked parks failed with `BLOCKED:` error and does **not**
trip the bulk-completion refusal or requeue to todo; `blockedBy` unioned
into `dependencies`; `task:execution-blocked-parked` emitted with
metadata that excludes the reason prose; steps left untouched; empty
`reason` rejected without parking; `completed` outcome unchanged (still
marks steps done, no blocked audit); and
`recoverStrandedCompletedTodoTasks` never promotes a blocked-parked row.

### Note on `pnpm verify:fast`

`verify:fast` currently fails at the workspace build step due to
**pre-existing** type errors in `packages/engine/src/auth-storage.ts`,
`pi.ts`, and `provider-registration.ts` — the exact FN-8142 pi SDK API
break that FN-8145 will fix. These are present on the base branch and
untouched by this PR. Verified instead that this change introduces
**zero** new type errors (`tsc` diff before/after, engine and core both
clean) and that all scoped tests are green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 19:35:37 -07:00
committed by GitHub
parent 779cd2e030
commit 9a37415887
6 changed files with 380 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Executors can end a genuinely-impossible task as "blocked" instead of laundering it into done.
category: fix
dev: fn_task_done gains outcome="blocked" (plus reason + optional blockedBy). Blocked parks the task failed (error "BLOCKED: <reason>"), bypasses the completion/bulk-completion gates, keeps steps in their true statuses, records blockedBy as task.dependencies, and emits run-audit task:execution-blocked-parked. Prompt guidance now names the blocked exit as the correct escape hatch, replacing skip-then-complete. Motivating incident: FN-8141.

View File

@@ -279,6 +279,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- FN-7996: executor emits `task:execution-tool-failure-retry` for a claimed same-model consecutive-tool-failure retry and `task:execution-tool-failure-retry-exhausted` when the matching run budget is spent. Metadata is ids/counts/outcomes-only; the exhausted event is emitted once through a project-scoped compare-and-set while terminal parking remains idempotent.
- FN-7998: executor emits `task:execution-escalation-retry` when its opt-in, single alternate model/node attempt is persisted after FN-7996 exhaustion, and `task:execution-escalation-exhausted` when that attempt also reaches the terminal park. Metadata remains ids/counts/outcomes-only (`taskId`, graph node id, target booleans, and prior retry count); no model identifiers or prose are persisted in run-audit.
- FN-8004: `agent:heartbeat-move-skipped-soft-delete` records a heartbeat move that races a soft-deleted task without parking the durable agent. Metadata remains ids/timestamps/source only (`agentId`, optional `taskId`/`deletedAt`, `moveAttemptedAt`, optional `source`); it never stores error prose.
- FN-8141: the executor's `fn_task_done(outcome="blocked", reason=..., blockedBy?=[...])` honest-blocked exit emits `task:execution-blocked-parked` when an executor parks a genuinely-impossible task `failed` (`error = "BLOCKED: <reason>"`) instead of laundering it to `done` by skipping steps. It bypasses the completion/verdict/bulk-completion gates (blocked is not a completion claim), leaves steps in their true statuses, preserves worktree/branch, records `blockedBy` as real `task.dependencies` edges so the task requeues behind the blocker, and does NOT hand off to review — the parked row is honored by the executor's `status === "failed"` post-loop branch and is not auto-recovered into in-review by `recoverStrandedCompletedTodoTasks` (steps are not all done/skipped and `task.error` is set). Metadata stays ids/outcomes-only (`taskId`, `blockedBy` ids, `hasReason` boolean — never the reason prose).
## Reference docs (deeper detail)

View File

@@ -78,7 +78,7 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi
|---|---|---|
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 0-indexed; matches `### Step N:` in PROMPT.md, Step 0 = Preflight), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) |
| `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) |
| `fn_task_done` | Mark task complete and optionally store summary | `summary?` (string) |
| `fn_task_done` | End the task: `outcome="completed"` (default) marks it complete; `outcome="blocked"` honestly parks it failed (`BLOCKED: <reason>`) with no completion claim, preserving steps/worktree and recording `blockedBy` as dependencies | `summary?` (string), `outcome?` (`completed` \| `blocked`), `blockedBy?` (string[]), `reason?` (string, required when blocked) |
| `fn_review_step` | Spawn step plan/code reviewer | `step` (number, 0-indexed; matches `### Step N:` in PROMPT.md), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) |
| `fn_spawn_agent` | Spawn child agent in separate worktree | `name` (string), `role` (enum), `task` (string) |
| `fn_acquire_repo_worktree` | Acquire an isolated git worktree for a sub-repo in a workspace task (workspace mode only) | `repo` (string — must be one of the workspace's configured repos) |

View File

@@ -67,7 +67,7 @@ You are working in a git worktree isolated from the main branch. Your job is to
You MUST end every turn by either:
- (a) calling another tool to make progress, OR
- (b) calling \`fn_task_done\` if the entire task is complete, OR
- (c) calling \`fn_task_done\` with a summary explaining what is blocked, if you cannot make progress for any reason
- (c) calling \`fn_task_done(outcome="blocked", reason="...")\` if the work genuinely cannot proceed (see below)
You MUST NOT end a turn by writing prose that asks the user a question, summarizes progress, or requests permission to continue. The following are FORBIDDEN turn-endings:
- "If you want, I can continue with..."
@@ -82,7 +82,7 @@ If you have just finished a step's work, immediately call \`fn_task_update\` to
The user is not watching this conversation in real-time. They will read the final result. Asking permission wastes a full retry cycle and may orphan committed work.
If you genuinely cannot proceed (blocked on a dependency, missing information, or an unresolvable error), call \`fn_task_done\` with a clear explanation of what is blocked and what is needed to unblock it. Never write the question as plain prose.
If the work genuinely cannot proceed (an upstream API break, a missing prerequisite task, an unresolvable external error), call \`fn_task_done(outcome="blocked", reason="<concrete blocker + what would unblock it>", blockedBy=["FN-XXXX"])\`. This parks the task as failed with no completion claim, leaves your steps in their true statuses, preserves your worktree/branch, and records \`blockedBy\` as dependencies so the task requeues once the blocker completes. Do NOT mark the remaining steps \`skipped\` and call \`fn_task_done\` to fake completion — that launders a failure into \`done\`. Never write the blocker as plain prose.
## How to work
1. Read the PROMPT.md carefully — it contains your mission, steps, file scope, and acceptance criteria

View File

@@ -0,0 +1,256 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import * as worktreePool from "../worktree-pool.js";
import { SelfHealingManager } from "../self-healing.js";
import { evaluateNoCommitsNoOpFinalize } from "@fusion/core";
import {
createMockStore,
mockedCreateFnAgent,
mockedExecSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
/*
FNXC:Lifecycle 2026-07-16-10:20:
FN-8141 — regression coverage for the honest blocked exit. Asserts the INVARIANT across surfaces:
(1) fn_task_done(outcome="blocked") parks failed with a BLOCKED: error + audit event and NEVER trips the
completion/bulk-completion gates or auto-completes/auto-skips steps;
(2) blockedBy becomes real task.dependencies so the task requeues behind the blocker;
(3) the completed-work recovery sweep never promotes a blocked-parked row to in-review;
(4) the ordinary completed outcome is unchanged.
*/
function baseTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-8141",
title: "Blocked exit test",
description: "",
column: "in-progress",
worktree: "/repo/.worktrees/swift-falcon",
branch: "fusion/fn-8141",
baseCommitSha: "abc123",
taskDoneRetryCount: 0,
// Two unreviewed pending steps: the exact shape that trips bulk-step-completion-without-review.
steps: [
{ name: "Implement", status: "in-progress" as const },
{ name: "Testing & Verification", status: "pending" as const },
],
currentStep: 0,
dependencies: [],
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
async function setup(overrides: Record<string, unknown> = {}) {
const store = createMockStore();
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
store.getAgentLogCount = vi.fn().mockResolvedValue(0);
let task: any = baseTask(overrides);
let tool: any;
store.getTask.mockImplementation(async () => ({ ...task, steps: task.steps.map((s: any) => ({ ...s })) }));
store.updateTask.mockImplementation(async (_id: string, updates: any) => {
task = { ...task, ...updates };
return task;
});
store.moveTask.mockImplementation(async (id: string, column: string) => {
task = { ...task, id, column };
});
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => {
tool = customTools.find((t: any) => t.name === "fn_task_done");
return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any;
});
const executor = new TaskExecutor(store as any, "/repo");
await executor.execute(task as any);
// execute() runs a mock session that never calls fn_task_done, so its own
// "finished without fn_task_done" recovery touches these mocks. Clear that
// history so assertions capture ONLY the direct tool.execute() call below.
store.updateTask.mockClear();
store.moveTask.mockClear();
store.updateStep.mockClear();
store.logEntry.mockClear();
store.recordRunAuditEvent.mockClear();
return { store, tool, getTask: () => task };
}
describe("FN-8141 fn_task_done honest blocked exit", () => {
beforeEach(() => {
resetExecutorMocks();
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-8141\n");
if (cmd.includes("rev-list --count")) return Buffer.from("0\n");
if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n");
return Buffer.from("");
});
});
it("parks failed with a BLOCKED: error and does NOT trip the bulk-completion refusal", async () => {
const { store, tool } = await setup();
const result = await tool.execute("id", {
outcome: "blocked",
reason: "pi 0.80.10 removed AuthStorage; SDK bump cannot pass verify:fast",
blockedBy: ["FN-8145"],
});
// Not a refusal — the blocked exit bypasses the completion gates.
expect(result.content[0].text).not.toContain("fn_task_done refused");
expect(result.content[0].text).toContain("parked as blocked");
// Parked failed with the BLOCKED: convention; requeue budget untouched.
const parkCall = store.updateTask.mock.calls.find(
(c: any[]) => c[1]?.status === "failed" && typeof c[1]?.error === "string" && c[1].error.startsWith("BLOCKED:"),
);
expect(parkCall).toBeTruthy();
expect(parkCall![1].error).toBe("BLOCKED: pi 0.80.10 removed AuthStorage; SDK bump cannot pass verify:fast");
// The bulk-completion refusal path requeues to todo — blocked must not.
expect(store.moveTask).not.toHaveBeenCalled();
});
it("records blockedBy as real dependency edges, unioned with existing, so the task requeues behind the blocker", async () => {
const { store, tool } = await setup({ dependencies: ["FN-0001"] });
await tool.execute("id", {
outcome: "blocked",
reason: "upstream break",
blockedBy: ["FN-8145", "FN-8145", " FN-8146 "],
});
const depCall = store.updateTask.mock.calls.find((c: any[]) => Array.isArray(c[1]?.dependencies));
expect(depCall).toBeTruthy();
expect(depCall![1].dependencies).toEqual(["FN-0001", "FN-8145", "FN-8146"]);
});
it("emits task:execution-blocked-parked with ids/outcomes-only metadata (no reason prose)", async () => {
const { store, tool } = await setup();
await tool.execute("id", {
outcome: "blocked",
reason: "secret blocker prose that must never land in run-audit metadata",
blockedBy: ["FN-8145"],
});
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
mutationType: "task:execution-blocked-parked",
target: "FN-8141",
metadata: { taskId: "FN-8141", blockedBy: ["FN-8145"], hasReason: true },
}),
);
const auditCall = store.recordRunAuditEvent.mock.calls[0][0];
expect(JSON.stringify(auditCall.metadata)).not.toContain("secret blocker prose");
});
it("leaves steps in their true statuses (no auto-done, no auto-skip)", async () => {
const { store, tool } = await setup();
await tool.execute("id", { outcome: "blocked", reason: "cannot proceed" });
expect(store.updateStep).not.toHaveBeenCalled();
});
it("requires a non-empty reason before parking", async () => {
const { store, tool } = await setup();
const result = await tool.execute("id", { outcome: "blocked", reason: " " });
expect(result.content[0].text).toContain("requires a non-empty `reason`");
// No park write, and no blocked audit event (execute() emits unrelated audit events, so scope the check).
const parkCall = store.updateTask.mock.calls.find(
(c: any[]) => typeof c[1]?.error === "string" && c[1].error.startsWith("BLOCKED:"),
);
expect(parkCall).toBeUndefined();
const blockedAudit = store.recordRunAuditEvent.mock.calls.find(
(c: any[]) => c[0]?.mutationType === "task:execution-blocked-parked",
);
expect(blockedAudit).toBeUndefined();
});
it("completed outcome (default) is unchanged — still marks steps done and hands off", async () => {
const { store, tool } = await setup({
steps: [{ name: "Implement", status: "in-progress" as const }],
});
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-8141\n");
if (cmd.includes("rev-list --count")) return Buffer.from("1\n");
if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n");
return Buffer.from("");
});
const result = await tool.execute("id", { summary: "Implemented the fix and verified." });
expect(result.content[0].text).toContain("Task marked complete");
expect(store.updateStep).toHaveBeenCalledWith("FN-8141", 0, "done");
expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "task:execution-blocked-parked" }),
);
});
});
describe("FN-8141 blocked-parked task is not auto-recovered by the completed-todo sweep", () => {
function blockedParkedTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-8141",
title: "Blocked exit test",
column: "in-progress",
status: "failed",
error: "BLOCKED: upstream pi SDK break",
dependencies: ["FN-8145"],
paused: false,
// Steps stay in their true statuses — NOT all done/skipped.
steps: [
{ name: "Implement", status: "in-progress" as const },
{ name: "Testing & Verification", status: "pending" as const },
],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
it("recoverStrandedCompletedTodoTasks never promotes a blocked-parked row", async () => {
const store = createMockStore();
const recoverCompletedTask = vi.fn().mockResolvedValue(true);
// A blocked-parked task, even if it somehow surfaces in a todo listing, is filtered out
// because task.error is set AND its steps are not all done/skipped.
store.listTasks = vi.fn().mockResolvedValue([blockedParkedTask({ column: "todo" })]);
const manager = new SelfHealingManager(store as any, {
rootDir: "/tmp/test",
recoverCompletedTask: recoverCompletedTask as any,
getExecutingTaskIds: () => new Set<string>(),
});
const recovered = await manager.recoverStrandedCompletedTodoTasks();
expect(recovered).toBe(0);
expect(recoverCompletedTask).not.toHaveBeenCalled();
});
it("guard sanity: the blocked-parked shape has non-complete steps so the sweep's completion predicate is false", () => {
const t = blockedParkedTask({ column: "todo" });
const allDoneOrSkipped = t.steps.every((s) => {
const status = s.status as string;
return status === "done" || status === "skipped";
});
expect(allDoneOrSkipped).toBe(false);
// And the FN-6461 no-op finalize guard is not what stops it — task.error is the primary gate.
expect(Boolean((t as any).error)).toBe(true);
// evaluateNoCommitsNoOpFinalize is import-checked to keep the guard reference honest.
expect(typeof evaluateNoCommitsNoOpFinalize).toBe("function");
});
});

View File

@@ -596,7 +596,16 @@ function detectPendingReviewBlock(
}
function formatTaskDoneRefusal(refusalClass: TaskDoneRefusalClass, reason: string): string {
return `fn_task_done refused (${refusalClass}): ${reason}. ${TASK_DONE_REFUSAL_SUFFIX}`;
/*
FNXC:Lifecycle 2026-07-16-10:20:
FN-8141 — when the bulk-completion gate refuses (steps lack APPROVE verdicts), the agent must NOT reach for
skip-every-step-then-complete as the escape hatch (that is exactly how FN-8141 laundered a failure into `done`).
Name the honest blocked exit in the refusal so the sanctioned path is the advertised one.
*/
const blockedHint = refusalClass === "bulk-step-completion-without-review"
? " If the work genuinely cannot proceed, do NOT skip the remaining steps to force completion — call fn_task_done(outcome=\"blocked\", reason=\"...\") instead."
: "";
return `fn_task_done refused (${refusalClass}): ${reason}. ${TASK_DONE_REFUSAL_SUFFIX}${blockedHint}`;
}
export function evaluateTaskDoneRefusal(
@@ -1324,7 +1333,7 @@ You execute task specs in isolated worktrees, produce production-quality changes
You MUST end every turn by either:
- (a) calling another tool to make progress, OR
- (b) calling \`fn_task_done\` if the entire task is complete, OR
- (c) calling \`fn_task_done\` with a summary explaining what is blocked, if you cannot make progress for any reason
- (c) calling \`fn_task_done(outcome="blocked", reason="...")\` if the work genuinely cannot proceed (see "Cannot proceed" below)
You MUST NOT end a turn by writing prose that asks the user a question, summarizes progress, or requests permission to continue. The following are FORBIDDEN turn-endings:
- "If you want, I can continue with..."
@@ -1339,7 +1348,8 @@ If you have just finished a step's work, immediately call \`fn_task_update\` to
The user is not watching this conversation in real-time. They will read the final result. Asking permission wastes a full retry cycle and may orphan committed work.
If you genuinely cannot proceed (blocked on a dependency, missing information, or an unresolvable error), call \`fn_task_done\` with a clear explanation of what is blocked and what is needed to unblock it. Never write the question as plain prose.
**Cannot proceed — the honest blocked exit.** If the work genuinely cannot be finished (an upstream API break, a missing prerequisite task, an unresolvable external error), call \`fn_task_done(outcome="blocked", reason="<concrete blocker + what would unblock it>", blockedBy=["FN-XXXX"])\`. This parks the task as failed WITHOUT any completion claim, leaves your steps in their true statuses, preserves your worktree/branch, and records \`blockedBy\` task IDs as dependencies so the task requeues once the blocker completes.
This is THE correct action when you are stuck — do NOT instead mark the remaining steps \`skipped\` and call \`fn_task_done\` to make the task look finished. Skipping steps to escape a blocker launders a failure into \`done\` and is never the right move. (\`skipped\` remains valid only for the stale-premise path below, when the requested work is already present on HEAD.) Never write the blocker as plain prose.
## How to work
1. Read the PROMPT.md carefully — it contains your mission, steps, file scope, acceptance criteria, and Do NOT constraints
@@ -1370,6 +1380,8 @@ PROMPT.md is captured at task-creation time; HEAD may have moved on since then.
This path exists specifically to prevent the executor from looping when PROMPT.md is out of sync with HEAD. Use it only after running the actual reproduction — do not invoke it to dodge real work. If a task is verified as a no-op, duplicate, or redundant for the same reason (the requested behavior is already present on HEAD), \`fn_task_done\` may also use a leading sentinel summary of \`NO-OP:\`, \`NOOP:\`, \`DUPLICATE: FN-NNNN ...\`, or \`REDUNDANT:\`. These sentinels are audit-logged and allow a verified zero-commit completion; ordinary zero-commit implementation completions without a recognized leading sentinel are still refused.
**Stale premise vs. blocked — do not confuse them.** Skipping remaining steps is ONLY for the stale-premise case above, where the requested work is already present on HEAD so there is nothing left to do. If the work is real but you CANNOT do it (upstream broke, a prerequisite task is missing, an external error is unresolvable), that is NOT a stale premise — do NOT skip steps to fake completion. Use \`fn_task_done(outcome="blocked", reason="...", blockedBy=[...])\` instead (see "Cannot proceed" above).
**Logging important actions:** \`fn_task_log(message="what happened")\`
**Out-of-scope work found during execution:** \`fn_task_create(description="what needs doing")\`
@@ -14431,16 +14443,108 @@ export class TaskExecutor {
name: "fn_task_done",
label: "Mark Task Done",
description:
"Signal that all steps are complete, tests pass, and documentation is updated. " +
"Call this as the final action after finishing all work. " +
"Automatically marks all remaining steps as done. " +
"Optionally provide a summary of what was changed/fixed.",
"End the task. With outcome=\"completed\" (default): signal that all steps are complete, tests pass, and " +
"documentation is updated — call as the final action after finishing all work; automatically marks all " +
"remaining steps as done; optionally provide a summary of what was changed/fixed. " +
"With outcome=\"blocked\": honestly park the task when the work genuinely cannot proceed (upstream API break, " +
"missing dependency task, unresolvable external blocker). Blocked is NOT a completion claim — it does not " +
"trip the review/completion gates, does not auto-complete or auto-skip steps, and preserves your worktree/" +
"branch/step progress so the task can be requeued once the blocker clears. Prefer blocked over marking steps " +
"skipped when the task cannot be finished.",
parameters: Type.Object({
summary: Type.Optional(Type.String({
description: "Optional summary of what was changed/fixed and what was verified (2-4 sentences)",
description: "Optional summary of what was changed/fixed and what was verified (2-4 sentences). Used when outcome=\"completed\".",
})),
/*
FNXC:Lifecycle 2026-07-16-10:20:
FN-8141 laundered a genuinely-impossible task into `done`: fn_task_done only expressed success, the bulk-completion
gate refused it, the requeue budget re-ran the doomed task 5 times, and the only remaining affordance (skip every
step) made `isTaskComplete()` return true so self-healing + the AI merger finalized an empty diff as done. The
`blocked` outcome is the sanctioned honest exit: it parks the task `failed` (error `BLOCKED: <reason>`) without any
completion claim, so laundering is never the cheapest path.
*/
outcome: Type.Optional(Type.Union(
[Type.Literal("completed"), Type.Literal("blocked")],
{ description: "\"completed\" (default) finishes the task; \"blocked\" honestly parks it as failed because the work cannot proceed. Use \"blocked\" instead of skipping steps + completing when you are stuck." },
)),
blockedBy: Type.Optional(Type.Array(Type.String(), {
description: "When outcome=\"blocked\": task IDs (e.g. [\"FN-8145\"]) that must complete before this task can proceed. Recorded as real dependency edges so the task requeues behind the blocker.",
})),
reason: Type.Optional(Type.String({
description: "Required when outcome=\"blocked\": concrete explanation of what is blocking the work and what is needed to unblock it.",
})),
}),
execute: async (_id: string, params: { summary?: string }) => {
execute: async (_id: string, params: { summary?: string; outcome?: "completed" | "blocked"; blockedBy?: string[]; reason?: string }) => {
/*
FNXC:Lifecycle 2026-07-16-10:20:
FN-8141 — the blocked exit runs BEFORE every completion gate (completion blocker, verdict providers, worktree
invariants, bulk-completion refusal). Blocked is not a completion claim, so none of those gates apply; parking
`failed` with a `BLOCKED:` error + real dependency edges is the whole action. Steps keep their true statuses
(no auto-done, no auto-skip) so a laundered "all steps skipped ⇒ complete" state can never form.
*/
if (params.outcome === "blocked") {
const reason = params.reason?.trim();
if (!reason) {
const message = "fn_task_done(outcome=\"blocked\") requires a non-empty `reason` describing what is blocking the work. Provide `reason` (and optional `blockedBy` task IDs) and call again.";
return {
content: [{ type: "text" as const, text: message }],
details: { error: message },
};
}
const blockedTask = await store.getTask(taskId);
const blockedByIds = Array.from(
new Set((params.blockedBy ?? []).map((id) => id.trim()).filter((id) => id.length > 0)),
);
const parkError = `BLOCKED: ${reason}`;
// Record blockedBy as real dependency edges (union with existing) so the task requeues
// behind the blocker rather than re-running the doomed work. Preserve worktree/branch/
// step progress (FN-7863 EXECUTION_DISPATCH_LOOP_EXHAUSTED park convention) — do NOT
// call onDone() so the outer loop's `liveTask.status === "failed"` honor-park branch keeps
// the row parked for the blocker/operator instead of handing off to review.
const mergedDependencies = blockedByIds.length > 0
? Array.from(new Set([...(blockedTask.dependencies ?? []), ...blockedByIds]))
: undefined;
await store.updateTask(taskId, {
status: "failed",
error: parkError,
paused: false,
pausedByAgentId: null,
...(mergedDependencies ? { dependencies: mergedDependencies } : {}),
}, this.getRunContextFor(taskId));
await store.logEntry(
taskId,
`${parkError}${blockedByIds.length > 0 ? ` — recorded dependencies: ${blockedByIds.join(", ")}` : ""} — parked failed (honest blocked exit; steps preserved)`,
undefined,
this.getRunContextFor(taskId),
);
await this.store.recordRunAuditEvent?.({
taskId,
agentId: "executor",
runId: generateSyntheticRunId("execution-blocked", taskId),
domain: "database",
mutationType: "task:execution-blocked-parked",
target: taskId,
metadata: {
taskId,
blockedBy: blockedByIds,
hasReason: true,
},
});
await this.persistTokenUsage(taskId);
executorLog.log(`⛔ ${taskId} parked failed via blocked exit${blockedByIds.length > 0 ? ` (blockedBy: ${blockedByIds.join(", ")})` : ""}`);
return {
content: [{
type: "text" as const,
text: `Task parked as blocked (failed). ${blockedByIds.length > 0 ? `Recorded ${blockedByIds.length} blocking dependency(ies); it will requeue once they complete. ` : ""}Steps left in their true statuses; no completion recorded.`,
}],
details: {},
};
}
const task = await store.getTask(taskId);
const completionBlocker = await this.getTaskCompletionBlocker(task);
if (completionBlocker) {