diff --git a/.changeset/restore-executor-peel-regressions.md b/.changeset/restore-executor-peel-regressions.md new file mode 100644 index 0000000000..3a128b320b --- /dev/null +++ b/.changeset/restore-executor-peel-regressions.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore completion-recommendation capture and the worktree base-refresh guard dropped by a refactor. +category: fix +dev: PR #3317 (U4 executor peel) rewrote executor.ts from a pre-change base, silently deleting two landed fixes. FN-8850's `getCompletionRecommendationGuidance` and its call site are restored in `executor/system-prompt.ts` (the `fn_task_done` validator survived, so recommendations were validated but never requested). The `WorktreeBaseRefreshError` guard is restored in `executor/run-implementation.ts` so a pre-session checkout refusal is left queued instead of terminally parking the task. New `executor-prompt-completion-recommendations.test.ts` pins the prompt wiring that had no coverage. diff --git a/packages/engine/src/__tests__/executor-prompt-completion-recommendations.test.ts b/packages/engine/src/__tests__/executor-prompt-completion-recommendations.test.ts new file mode 100644 index 0000000000..2d94a547de --- /dev/null +++ b/packages/engine/src/__tests__/executor-prompt-completion-recommendations.test.ts @@ -0,0 +1,38 @@ +/* +FNXC:TaskRecommendations 2026-08-10-01:15 (the producer must stay wired to the validator — regression): + +FN-8850 added BOTH a `fn_task_done` validator for completion recommendations AND the engine-appended prompt +section that asks the executor to produce them. The U4 executor peel (#3317) rewrote `executor.ts` from a +pre-FN-8850 base and dropped the prompt half while keeping the validator. Nothing failed: `fn_task_done` kept +accepting `recommendations`, no test covered the prompt wiring, and recommendation capture simply stopped. + +A refactor rebased off a stale base does not conflict — it deletes. These assertions pin the wiring so the +producer cannot be removed while the validator that consumes it stays in place. +*/ +import { describe, expect, it } from "vitest"; +import { getExecutorSystemPrompt } from "../executor/system-prompt.js"; + +const prompt = (settings: Record = {}) => getExecutorSystemPrompt(settings as never); + +describe("executor prompt: completion recommendations", () => { + it("asks the executor to produce recommendations at the accepted completion checkpoint", () => { + const text = prompt(); + expect(text).toContain("## Completion recommendations"); + expect(text).toContain("recommendations: []"); + }); + + it("states the cap from settings so the prompt matches what the validator accepts", () => { + // A prompt promising a different maximum than the validator enforces produces rejected completions. + expect(prompt({ maxRecommendationsPerTask: 5 })).toContain("at most 5"); + // Default cap when unset. + expect(prompt()).toContain("at most 3"); + }); + + it("tells the executor to send nothing when capture is disabled", () => { + const text = prompt({ maxRecommendationsPerTask: 0 }); + expect(text).toContain("Recommendation capture is disabled for this project"); + // A zero cap must not invite writes the store would reject. + expect(text).not.toContain("at most 0"); + }); + +}); diff --git a/packages/engine/src/executor/run-implementation.ts b/packages/engine/src/executor/run-implementation.ts index 4f9907454b..ef82131fbb 100644 --- a/packages/engine/src/executor/run-implementation.ts +++ b/packages/engine/src/executor/run-implementation.ts @@ -188,7 +188,7 @@ import { resolveDedicatedPlannerColumnsForTask } from "../planner-lane-resolutio import { mergeEffectiveSettings } from "../project/effective-settings.js"; import { buildStepFailureMessage, emitProactiveStatus, sanitizeFailureReason } from "../project/proactive-status.js"; import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "../util/run-audit.js"; -import { acquireTaskWorktree } from "../worktree/worktree-acquisition.js"; +import { acquireTaskWorktree, WorktreeBaseRefreshError } from "../worktree/worktree-acquisition.js"; import { resolveWorktreesDir } from "../worktree/worktree-paths.js"; import { RemovalReason, @@ -2884,6 +2884,26 @@ export async function runImplementation( // Dependency added mid-execution — discard worktree and move to triage deps.depAborted.delete(task.id); await deps.handleDepAbortCleanup(task.id, worktreePath); + } else if (err instanceof WorktreeBaseRefreshError) { + /* + FNXC:WorktreeBaseRefresh 2026-08-10-01:15: + Classified FIRST among error types, and re-applied after the U4 executor peel (#3317) rewrote + executor.ts from a pre-change base and dropped it. Acquisition throws this BEFORE any session starts, + so the generic sink below would park the task `failed` and page the operator for a pre-session + checkout state — that path parked 99 tasks and produced 47 operator alerts over 2026-08-01..09. + Post-fix only an UNPROVEN tree (failed compensation) still throws, and a later acquisition can repair + that once git state changes, so it stays a wait: leave the row cleanly dispatchable and let ordinary + scheduling retry it rather than terminalizing recoverable work. + */ + executorLog.warn(`${task.id}: worktree base refresh blocked execution (${err.refresh.kind}) — leaving the task queued for re-dispatch (not a failure)`); + await deps.store.logEntry( + task.id, + `Worktree base refresh blocked execution (${err.refresh.kind}) — task left queued for a later clean acquisition`, + err.refresh.detail, + deps.getRunContextFor(task.id), + ).catch(() => undefined); + await deps.persistTokenUsage(task.id); + return; } else if (isInvalidAssistantContinuationErrorMessage(errorMessage)) { /* FNXC:PostDoneContinuation 2026-07-16-11:57: diff --git a/packages/engine/src/executor/system-prompt.ts b/packages/engine/src/executor/system-prompt.ts index e9d7a03e94..7da5eb1048 100644 --- a/packages/engine/src/executor/system-prompt.ts +++ b/packages/engine/src/executor/system-prompt.ts @@ -262,6 +262,26 @@ policy rather than malfunction. It is appended last so it wins over the base tex applies to a custom operator prompt too (an operator who overrode the prompt still gets a truthful statement of what this session may do). */ +function getCompletionRecommendationGuidance(maximum: number): string { + /* + FNXC:TaskRecommendations 2026-08-09-04:06: + Engine-appended guidance preserves the accepted-completion recommendation contract even when an + operator customizes the executor prompt. A disabled cap must not invite unavailable writes. + + FNXC:TaskRecommendations 2026-08-10-01:15: + Restored verbatim after the U4 executor peel (#3317) rewrote executor.ts from a pre-FN-8850 base and + dropped both this function and its call site, leaving the validator wired with no producer. + */ + if (maximum === 0) { + return `## Completion recommendations + +Recommendation capture is disabled for this project (maxRecommendationsPerTask is 0). Ignore any earlier generic recommendation guidance: do not send recommendations, including \`recommendations: []\`; use an honest summary or task log for non-blocking context, and do not fabricate a finding.`; + } + return `## Completion recommendations + +At the final accepted \`fn_task_done(outcome="completed")\` checkpoint, evaluate optional, non-blocking work discovered outside this task. Send at most ${maximum} task-ready recommendations, each with a stable unique \`id\`, \`title\`, \`description\`, and \`category\`, or explicitly send \`recommendations: []\` when none genuinely qualify. Example populated payload: \`recommendations: [{ id: "follow-up-export", title: "Add task export", description: "Provide a CSV export for completed tasks.", category: "feature" }]\`. Do not fabricate filler or include required current-task work, blockers, secrets, executable commands, reasoning, or duplicate ids. Recommendations are only for completed outcomes; never send them with \`outcome="blocked"\`. Use immediate task creation/delegation only for an explicit task requirement, necessary dependency coordination, or operator direction.`; +} + function getWithheldTaskCreationGuidance(taskCreateWithheld: boolean, delegateWithheld: boolean): string { if (!taskCreateWithheld && !delegateWithheld) return ""; const withheld = [ @@ -286,9 +306,17 @@ export function getExecutorSystemPrompt( ): string { const customPrompt = resolveAgentPrompt("executor", settings.agentPrompts); const basePrompt = customPrompt || EXECUTOR_SYSTEM_PROMPT; + /* + FNXC:TaskRecommendations 2026-08-10-01:15: + Re-applied after the U4 executor peel (#3317) dropped it: `fn_task_done` kept VALIDATING recommendations + while nothing asked the executor to produce any, so capture silently stopped. Keep the guidance adjacent to + the validator it pairs with — the two must be added or removed together. + */ + const maximumRecommendations = settings.maxRecommendationsPerTask ?? 3; const sections = [ basePrompt, isResearchToolSurfaceEnabled(settings) ? getResearchGuidanceForSurface("executor") : "", + getCompletionRecommendationGuidance(maximumRecommendations), getWithheldTaskCreationGuidance( toolAvailability?.taskCreateWithheld === true, toolAvailability?.delegateWithheld === true,