diff --git a/.changeset/fn-7488-source-free-completion.md b/.changeset/fn-7488-source-free-completion.md new file mode 100644 index 0000000000..7a47fa068c --- /dev/null +++ b/.changeset/fn-7488-source-free-completion.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow documented source-free task-artifact deliveries to finish without commits. +category: fix +dev: fn_task_done now recognizes explicit gitignored .fusion/tasks artifact contracts while preserving source-change no-commit guards. diff --git a/docs/architecture.md b/docs/architecture.md index 0efd85934b..7487e0e3ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1832,7 +1832,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight. - **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat. - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. -- **Verified no-op/duplicate executor completion (FN-6275)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`) or the task already carries a no-commit contract. The sentinel only relaxes the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Ordinary zero-commit implementation completions without a leading sentinel are still refused. +- **Verified no-op/duplicate executor completion (FN-6275/FN-7488)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`), the task already carries a no-commit contract, or the PROMPT declares a source-free gitignored task-artifact delivery. The source-free path is intentionally narrow: File Scope must be populated and limited to board/task artifacts such as `.fusion/tasks/...`, task documents/logs, or attachments; the prompt must forbid force-adding ignored `.fusion/` artifacts and fabricating empty commits or equivalently state that source-free/gitignored task artifacts are the only deliverables; and any tracked source/docs/config/test/changeset scope keeps the `no_commits` refusal active (even if `.fusion/` artifacts are also listed). These exemptions only relax the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Prompt-derived source-free completions log `prompt-derived source-free task-artifact contract` for operator audit. Ordinary zero-commit implementation completions without one of these contracts are still refused. - **In-review branch-binding self-heal (FN-5083/FN-6695)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Unsafe metadata repair is also skipped with `task:auto-rebind-skipped`: `userPaused` preserves authoritative user intent, and `checkedOutBy` preserves live agent checkout ownership. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. - **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. FN-7182 applies the same human-gated treatment to an open `PrInfo.manual` PR created or linked from the dashboard **Create PR** action: automatic merge queues and self-healing stand down until the PR is closed/merged or handled manually, while pipeline-created PRs without `manual` remain auto-merge eligible. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode). diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index a4cc5031be..fd7106a201 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -523,6 +523,12 @@ Anti-heuristics (bias to false-negative when ambiguous): If an executor later proves an ordinary implementation task is already satisfied on HEAD, it may close without fabricating a commit by calling \`fn_task_done\` with a leading verified no-op/duplicate sentinel summary: \`PREMISE STALE:\`, \`NO-OP:\`, \`NOOP:\`, \`DUPLICATE: FN-NNNN ...\`, or \`REDUNDANT:\`. This does not weaken ordinary tasks: zero-commit completions without one of these leading sentinels still fail the no-commits invariant. + +For source-free forensic or spec-compliance tasks whose only deliverables are gitignored \`.fusion/tasks/...\` artifacts (for example sibling \`PROMPT.md\`, \`task.json\`, or \`attachments/*\` evidence), document that contract explicitly in the PROMPT: File Scope must be limited to task artifacts, Do NOT must forbid force-adding \`.fusion/\` files and creating empty/fabricated commits, and acceptance criteria must not require tracked source/docs/config/test changes. Executors may then complete without force-adding ignored artifacts or fabricating empty commits; mixed prompts that also scope tracked files still require real commits. + ## Guidelines - Read the project structure and relevant source files to understand context BEFORE writing - Check package.json/scripts and explicit project commands to align real lint/test/build/typecheck commands diff --git a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts index 08ec63c2be..bfd369ccee 100644 --- a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts +++ b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts @@ -336,6 +336,126 @@ Atlas Notes task-board artifacts only: expect(store.handoffToReview).not.toHaveBeenCalled(); }); + it("FN-7487 allows source-free gitignored task-artifact delivery with zero commits", async () => { + const fn7487Prompt = `# Task: FN-7487 - Audit FN-6902 spec compliance + +**Created:** 2026-07-03 +**Size:** S + +## Review Level: 0 (None) + +## Mission + +Deliver the requested source-free task-artifact audit. The deliverables are gitignored task artifacts only, not product source changes. + +## File Scope + +- .fusion/tasks/FN-6902/PROMPT.md +- .fusion/tasks/FN-7487/attachments/fn-6902-spec-compliance.test.mjs + +## Steps + +### Step 0: Preflight +- [x] Read the source task prompt. + +### Step 1: Write source-free task artifact evidence +- [x] Save the audit artifact under .fusion/tasks/. + +## Do NOT + +- Do not force-add gitignored .fusion/ artifacts. +- Do not create empty commits or fabricate commits for this source-free delivery. +`; + const { store, tool } = await setup({ + id: "FN-7487", + title: "Audit FN-6902 spec compliance", + description: "Source-free task-artifact delivery only.", + prompt: fn7487Prompt, + branch: "fusion/fn-7487", + noCommitsExpected: undefined, + reviewLevel: 0, + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Write source-free task artifact evidence", status: "done" as const }, + ], + currentStep: 1, + }); + 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-7487\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(""); + }); + + store.moveTask.mockClear(); + const result = await tool.execute("id", { summary: "Saved the source-free task-artifact audit and verified the artifact path." }); + + expect(result.content[0].text).toContain("Task marked complete"); + expect(result.content[0].text).not.toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-7487", "todo", { preserveProgress: true }); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-7487", + expect.stringContaining("prompt-derived source-free task-artifact contract"), + undefined, + undefined, + ); + const revListCalled = mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("rev-list --count")); + expect(revListCalled).toBe(false); + }); + + it("FN-7487 refuses mixed task artifacts and tracked source scope with zero commits", async () => { + const mixedPrompt = `# Task: FN-7487 - Audit and update executor + +## Review Level: 0 (None) + +## Mission +Deliver source-free task artifacts if possible, but also update tracked documentation. + +## File Scope + +- .fusion/tasks/FN-6902/PROMPT.md +- .fusion/tasks/FN-7487/attachments/fn-6902-spec-compliance.test.mjs +- docs/testing.md + +## Steps + +### Step 1: Deliver +- [x] Work completed. + +## Do NOT + +- Do not force-add gitignored .fusion/ artifacts. +- Do not create empty commits or fabricate commits for this source-free delivery. +`; + const { store, tool } = await setup({ + id: "FN-7487", + prompt: mixedPrompt, + branch: "fusion/fn-7487", + noCommitsExpected: undefined, + reviewLevel: 0, + steps: [{ name: "Deliver", status: "done" 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-7487\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(""); + }); + + const result = await tool.execute("id", { summary: "Saved artifacts and documentation." }); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-7487", "todo", { preserveProgress: true }); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-7487", + expect.stringContaining("prompt-derived source-free task-artifact contract"), + undefined, + undefined, + ); + }); + it("FN-350 refuses contradictory implementation plus coordination fallback prompts", async () => { const prompt = `# Task: FN-350 - Route Ready Swift Tasks to Executor Owner diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6b372a9368..9b2163348c 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -819,6 +819,11 @@ function extractPromptListEntries(section: string): string[] { .filter(Boolean); } +function isFusionTaskArtifactScopeEntry(entry: string): boolean { + const normalized = entry.trim().toLowerCase().replace(/^\//, "").replace(/^\.\//, ""); + return normalized.startsWith(".fusion/tasks/"); +} + function isNoSourceScopeEntry(entry: string): boolean { const normalized = entry.toLowerCase(); return ( @@ -827,24 +832,43 @@ function isNoSourceScopeEntry(entry: string): boolean { normalized.includes("no code") || normalized.includes("no file mutations") || normalized.includes("task document") || + normalized.includes("task documents") || + normalized.includes("task metadata") || normalized.includes("task log") || normalized.includes("agent log") || + normalized.includes("task artifacts") || normalized.includes("read-only evidence") || - normalized.startsWith(".fusion/tasks/") || - normalized.startsWith("/.fusion/tasks/") + isFusionTaskArtifactScopeEntry(normalized) ); } function hasSourceChangingScopeEntry(entry: string): boolean { const normalized = entry.toLowerCase(); if (!normalized) return false; - if (normalized.startsWith(".fusion/tasks/") || normalized.startsWith("/.fusion/tasks/")) return false; - if (/\b(source|sources|packages|tests|src|app|scripts|\.changeset)\b/.test(normalized)) return true; + if (isFusionTaskArtifactScopeEntry(normalized)) return false; + const sourcePathPattern = /(?:^|[\s`'"(])(?:packages|src|source|sources|app|apps|lib|libs|components|scripts|docs|\.github|config|test|tests|__tests__|\.changeset)\//m; + if (sourcePathPattern.test(normalized)) return true; if (/\.(ts|tsx|js|jsx|mjs|cjs|swift|kt|java|py|rs|go|rb|md|json|ya?ml|toml|css|scss|html)\b/.test(normalized)) return true; if (normalized.includes("read-only") || isNoSourceScopeEntry(normalized)) return false; return false; } +function promptDeclaresSourceFreeTaskArtifactContract(combinedText: string): boolean { + const forbidsForceAddingFusionArtifacts = /(?:do not|don't|never|must not)\s+(?:force[- ]?add|git add -f)[^\n]*(?:\.fusion|gitignored)/.test(combinedText) + || /(?:\.fusion|gitignored)[^\n]*(?:do not|don't|never|must not)\s+(?:force[- ]?add|git add -f)/.test(combinedText); + const forbidsFabricatedCommits = /(?:do not|don't|never|must not)\s+(?:create|make|fabricate|manufacture)[^\n]*(?:empty|fabricated|zero[- ]diff)[^\n]*commits?/.test(combinedText) + || /(?:empty|fabricated|zero[- ]diff)[^\n]*commits?[^\n]*(?:do not|don't|never|must not|forbidden)/.test(combinedText); + const declaresOnlySourceFreeArtifacts = /(?:source[- ]free|gitignored)[^\n]*(?:task[- ]artifact|task artifact|\.fusion\/tasks|deliver(?:y|able)|artifact)/.test(combinedText) + || /(?:only|limited to)[^\n]*(?:source[- ]free|gitignored)[^\n]*(?:task[- ]artifact|task artifact|\.fusion\/tasks)/.test(combinedText); + return (forbidsForceAddingFusionArtifacts && forbidsFabricatedCommits) || declaresOnlySourceFreeArtifacts; +} + +function promptScopeIsSourceFreeTaskArtifacts(promptScopeEntries: string[], declaredScope: string[]): boolean { + if (promptScopeEntries.length === 0 || declaredScope.length === 0) return false; + if (declaredScope.some(hasSourceChangingScopeEntry)) return false; + return declaredScope.every((entry) => isFusionTaskArtifactScopeEntry(entry) || isNoSourceScopeEntry(entry)); +} + function getTaskTextForNoCommitEligibility(task: Task, promptContent: string): string { const logText = (task.log ?? []) .map((entry) => `${entry.action ?? ""}\n${entry.outcome ?? ""}`) @@ -857,6 +881,27 @@ function getTaskTextForNoCommitEligibility(task: Task, promptContent: string): s function evaluatePromptDerivedNoCommitEligibility(task: Task, promptContent: string): { eligible: boolean; reason?: string } { const combined = getTaskTextForNoCommitEligibility(task, promptContent).toLowerCase(); + const promptScopeEntries = extractPromptListEntries(extractPromptSection(promptContent, "File Scope")); + const metadataScope = Array.isArray(task.sourceMetadata?.fileScope) + ? task.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string") + : []; + const declaredScope = [...promptScopeEntries, ...metadataScope]; + const stepsComplete = Array.isArray(task.steps) && task.steps.length > 0 + ? task.steps.every((step) => step.status === "done" || step.status === "skipped") + : false; + + /* + FNXC:TaskDoneCompletion 2026-07-03-00:00: + Source-free deliveries that only write gitignored `.fusion/tasks/...` task artifacts must not fabricate empty commits or force-add ignored evidence just to satisfy fn_task_done. This exemption is intentionally narrower than Review Level 0/1: the PROMPT must declare a source-free task-artifact contract, every declared scope entry must be board/task artifact only, and any tracked source/docs/config/test/changeset path keeps the no_commits refusal intact. + */ + if ( + stepsComplete && + promptDeclaresSourceFreeTaskArtifactContract(combined) && + promptScopeIsSourceFreeTaskArtifacts(promptScopeEntries, declaredScope) + ) { + return { eligible: true, reason: "prompt-derived source-free task-artifact contract" }; + } + const reviewLevel = typeof task.reviewLevel === "number" ? task.reviewLevel : parseReviewLevelFromPrompt(promptContent); const isPlanOnly = reviewLevel === 1 && (/plan\s*only/.test(combined) || combined.includes("plan-only")); if (!isPlanOnly) return { eligible: false }; @@ -877,18 +922,10 @@ function evaluatePromptDerivedNoCommitEligibility(task: Task, promptContent: str const operationalIntent = /\b(operational|routing|route|assign|assignment|owner|handoff|coordination|coordinate|no-route|triage)\b/.test(combined); if (!operationalIntent || excludedImplementationIntent) return { eligible: false }; - const promptScopeEntries = extractPromptListEntries(extractPromptSection(promptContent, "File Scope")); - const metadataScope = Array.isArray(task.sourceMetadata?.fileScope) - ? task.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string") - : []; - const declaredScope = [...promptScopeEntries, ...metadataScope]; if (declaredScope.length === 0) return { eligible: false }; if (declaredScope.some(hasSourceChangingScopeEntry)) return { eligible: false }; if (!declaredScope.every(isNoSourceScopeEntry)) return { eligible: false }; - const stepsComplete = Array.isArray(task.steps) && task.steps.length > 0 - ? task.steps.every((step) => step.status === "done" || step.status === "skipped") - : false; const logText = (task.log ?? []) .map((entry) => `${entry.action ?? ""}\n${entry.outcome ?? ""}`) .join("\n")