FN-7488: allow source-free task artifacts to complete

Teach fn_task_done to honor explicit source-free task-artifact contracts without weakening ordinary commit requirements.

- Detect PROMPT-declared gitignored .fusion/tasks-only delivery contracts after completed steps.
- Keep zero-commit refusals for mixed tracked source, docs, config, test, or changeset scope.
- Document the completion contract in executor guidance and architecture notes.
- Add regression coverage for allowed source-free artifacts and refused mixed-scope deliveries.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7488-source-free-completion.md       |   7 ++
 docs/architecture.md                               |   2 +-
 packages/core/src/agent-prompts.ts                 |   6 ++
 .../__tests__/executor-task-done-invariant.test.ts | 120 +++++++++++++++++++++
 packages/engine/src/executor.ts                    |  61 ++++++++---
 5 files changed, 183 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-7488
Fusion-Task-Lineage: adbf1146-4513-4531-bdd8-ccecbeb42a63
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 23:24:17 -07:00
parent 821a63e447
commit 82493e0f62
5 changed files with 183 additions and 13 deletions

View File

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

View File

@@ -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 <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` 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/<id>` 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/<id>` 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/<id>` 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).

View File

@@ -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.
<!--
FNXC:TaskDoneCompletion 2026-07-03-00:00:
Some forensic/spec-compliance tasks intentionally deliver only gitignored task artifacts under .fusion/tasks/. Their PROMPT must say the delivery is source-free/gitignored task artifacts only and must forbid force-adding .fusion artifacts or fabricating empty commits; ordinary implementation, docs, config, and test work still needs commit evidence or an existing no-op sentinel.
-->
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

View File

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

View File

@@ -819,6 +819,11 @@ function extractPromptListEntries(section: string): string[] {
.filter(Boolean);
}
function isFusionTaskArtifactScopeEntry(entry: string): boolean {
const normalized = entry.trim().toLowerCase().replace(/^<rootdir>\//, "").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("<rootdir>/.fusion/tasks/")
isFusionTaskArtifactScopeEntry(normalized)
);
}
function hasSourceChangingScopeEntry(entry: string): boolean {
const normalized = entry.toLowerCase();
if (!normalized) return false;
if (normalized.startsWith(".fusion/tasks/") || normalized.startsWith("<rootdir>/.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")