From 4e6df035c1d6b19b24e7102e81d5a5b96ceaf853 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 02:54:57 -0700 Subject: [PATCH] FN-6275: allow verified no-op completions Allow executors to finish already-satisfied tasks without fabricating commits while preserving existing completion guards. - Add leading sentinel parsing for premise-stale, no-op, duplicate, and redundant completion summaries. - Permit zero-commit fn_task_done only for recognized sentinels or existing no-commit contracts, with audit log/activity details. - Document the verified no-op completion contract and add regression coverage for accepted and refused paths. Files changed: .changeset/fn-6275-no-op-completion.md | 5 ++ docs/architecture.md | 1 + .../src/__tests__/no-op-completion-marker.test.ts | 55 ++++++++++++ packages/core/src/agent-prompts.ts | 4 + packages/core/src/index.ts | 5 ++ packages/core/src/no-op-completion-marker.ts | 48 ++++++++++ .../__tests__/executor-task-done-invariant.test.ts | 100 +++++++++++++++++++++ .../engine/src/__tests__/executor-test-helpers.ts | 1 + packages/engine/src/executor.ts | 64 ++++++++++++- 9 files changed, 279 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-6275 Fusion-Task-Lineage: 0a2ec21b-415f-411c-bfcd-4f1c19a6a136 --- .changeset/fn-6275-no-op-completion.md | 5 + docs/architecture.md | 1 + .../__tests__/no-op-completion-marker.test.ts | 55 ++++++++++ packages/core/src/agent-prompts.ts | 4 + packages/core/src/index.ts | 5 + packages/core/src/no-op-completion-marker.ts | 48 +++++++++ .../executor-task-done-invariant.test.ts | 100 ++++++++++++++++++ .../src/__tests__/executor-test-helpers.ts | 1 + packages/engine/src/executor.ts | 64 ++++++++++- 9 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-6275-no-op-completion.md create mode 100644 packages/core/src/__tests__/no-op-completion-marker.test.ts create mode 100644 packages/core/src/no-op-completion-marker.ts diff --git a/.changeset/fn-6275-no-op-completion.md b/.changeset/fn-6275-no-op-completion.md new file mode 100644 index 0000000000..1d77b3b0e7 --- /dev/null +++ b/.changeset/fn-6275-no-op-completion.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a verified no-op/duplicate task completion path so executors can close already-satisfied tasks without fabricating commits by using an audited `fn_task_done` sentinel summary. diff --git a/docs/architecture.md b/docs/architecture.md index 4af1b5dcf3..d55093c32c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1791,6 +1791,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. - **In-review branch-binding self-heal (FN-5083)**: `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. 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. 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. 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/__tests__/no-op-completion-marker.test.ts b/packages/core/src/__tests__/no-op-completion-marker.test.ts new file mode 100644 index 0000000000..61f61d60e4 --- /dev/null +++ b/packages/core/src/__tests__/no-op-completion-marker.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseNoOpCompletionMarker } from "../no-op-completion-marker.js"; + +describe("parseNoOpCompletionMarker", () => { + it.each([ + ["PREMISE STALE: already implemented on HEAD", "premise-stale"], + ["NO-OP: existing behavior already satisfies the request", "no-op"], + ["NOOP: no code changes are needed", "no-op"], + ["DUPLICATE: FN-6239 covers the same requested behavior", "duplicate"], + ["REDUNDANT: FN-6239 already landed this", "redundant"], + ] as const)("recognizes leading prefix %s", (summary, kind) => { + const marker = parseNoOpCompletionMarker(summary); + + expect(marker).toMatchObject({ kind }); + expect(marker?.reason.length).toBeGreaterThan(0); + }); + + it("matches prefixes case-insensitively", () => { + expect(parseNoOpCompletionMarker("no-op: verified unchanged")?.kind).toBe("no-op"); + expect(parseNoOpCompletionMarker("duplicate: fn-6239 already covers it")).toMatchObject({ + kind: "duplicate", + canonicalId: "FN-6239", + }); + }); + + it("requires the marker at the start of the summary", () => { + expect(parseNoOpCompletionMarker("Verified existing behavior; NO-OP: no changes needed")).toBeNull(); + expect(parseNoOpCompletionMarker("The task is DUPLICATE: FN-6239")).toBeNull(); + }); + + it("returns null for empty, undefined, and ordinary prose", () => { + expect(parseNoOpCompletionMarker(undefined)).toBeNull(); + expect(parseNoOpCompletionMarker("")).toBeNull(); + expect(parseNoOpCompletionMarker("Implemented the requested behavior and verified tests.")).toBeNull(); + }); + + it("captures duplicate and redundant canonical task ids", () => { + expect(parseNoOpCompletionMarker("DUPLICATE: FN-6239 existing QuickChatFAB tests cover this")).toMatchObject({ + kind: "duplicate", + canonicalId: "FN-6239", + reason: "FN-6239 existing QuickChatFAB tests cover this", + }); + expect(parseNoOpCompletionMarker("REDUNDANT: covered by fn-42 after rebase")).toMatchObject({ + kind: "redundant", + canonicalId: "FN-42", + }); + }); + + it("does not require a canonical id for duplicate and redundant summaries", () => { + expect(parseNoOpCompletionMarker("DUPLICATE: same request already exists on HEAD")).toEqual({ + kind: "duplicate", + reason: "same request already exists on HEAD", + }); + }); +}); diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 18069407c5..65bb97aed4 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -387,6 +387,8 @@ Anti-heuristics (bias to false-negative when ambiguous): - LEAVE UNSET: Investigate FN-XYZ and fix if needed - LEAVE UNSET: Investigate and fix routing if needed +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. + ## Guidelines - Read relevant source files before writing the spec - Be specific: reference concrete files, modules, and commands from this repo @@ -670,6 +672,8 @@ Anti-heuristics (bias to false-negative when ambiguous): - LEAVE UNSET: Investigate FN-XYZ and fix if needed - LEAVE UNSET: Investigate and fix routing if needed +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. + ## 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/core/src/index.ts b/packages/core/src/index.ts index 56f050a569..9f76948fea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -479,6 +479,11 @@ export { parseExplicitDuplicateMarker, type ExplicitDuplicateMarker, } from "./explicit-duplicate-marker.js"; +export { + parseNoOpCompletionMarker, + type NoOpCompletionMarker, + type NoOpCompletionMarkerKind, +} from "./no-op-completion-marker.js"; export { __getDeterministicGuardMutexSize, deterministicGuardLocks, diff --git a/packages/core/src/no-op-completion-marker.ts b/packages/core/src/no-op-completion-marker.ts new file mode 100644 index 0000000000..2337c02871 --- /dev/null +++ b/packages/core/src/no-op-completion-marker.ts @@ -0,0 +1,48 @@ +export type NoOpCompletionMarkerKind = "premise-stale" | "no-op" | "duplicate" | "redundant"; + +export interface NoOpCompletionMarker { + kind: NoOpCompletionMarkerKind; + reason: string; + canonicalId?: string; +} + +const PREFIXES: Array<{ pattern: RegExp; kind: NoOpCompletionMarkerKind }> = [ + { pattern: /^PREMISE STALE:\s*/i, kind: "premise-stale" }, + { pattern: /^NO-OP:\s*/i, kind: "no-op" }, + { pattern: /^NOOP:\s*/i, kind: "no-op" }, + { pattern: /^DUPLICATE:\s*/i, kind: "duplicate" }, + { pattern: /^REDUNDANT:\s*/i, kind: "redundant" }, +]; + +/** + * Detects explicit executor completion summaries that mean the task was + * verified as already satisfied on HEAD (no source commit is appropriate). + * + * The marker must be a leading, case-insensitive prefix. Mid-summary mentions + * intentionally do not match so ordinary prose cannot accidentally bypass the + * no-commits invariant. + */ +export function parseNoOpCompletionMarker(summary: string | undefined): NoOpCompletionMarker | null { + const trimmed = summary?.trim() ?? ""; + if (!trimmed) { + return null; + } + + for (const { pattern, kind } of PREFIXES) { + const match = trimmed.match(pattern); + if (!match) continue; + + const reason = trimmed.slice(match[0].length).trim(); + const idMatch = kind === "duplicate" || kind === "redundant" + ? reason.match(/\b(FN-\d+)\b/i) + : null; + + return { + kind, + reason, + ...(idMatch ? { canonicalId: idMatch[1].toUpperCase() } : {}), + }; + } + + return null; +} 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 3c1db1f844..b6c8578c59 100644 --- a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts +++ b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts @@ -156,6 +156,106 @@ describe("FN-4114 fn_task_done invariants", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); }); + it.each([ + "NO-OP: existing tests already cover this", + "PREMISE STALE: targeted reproduction already passes unchanged on HEAD", + "DUPLICATE: FN-6239 existing QuickChatFAB tests already cover this", + ])("FN-6275 allows verified no-op zero-commit completion with sentinel %s", async (summary) => { + const { store, tool } = await setup({ + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Implement", status: "skipped" as const }, + { name: "Testing & Verification", status: "done" as const }, + ], + currentStep: 2, + }); + 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-4114\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 }); + + 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-4114", "todo", { preserveProgress: true }); + expect(store.updateTask).toHaveBeenCalledWith("FN-4114", { noCommitsExpected: true }); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-4114", + expect.stringContaining("completion sentinel accepted"), + expect.stringContaining(summary), + undefined, + ); + expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ + type: "task:updated", + taskId: "FN-4114", + metadata: expect.objectContaining({ summary }), + })); + }); + + it("FN-6275 still refuses ordinary zero-commit completion summaries", async () => { + const { store, tool } = await setup({ + steps: [{ name: "Implement", 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-4114\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: "Verified existing behavior with targeted tests." }); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-4114", { noCommitsExpected: true }); + }); + + it.each([ + ["wrong_toplevel", "/repo\n", "fusion/fn-4114\n"], + ["wrong_branch", "/repo/.worktrees/swift-falcon\n", "main\n"], + ] as const)("FN-6275 does not relax %s for sentinel summaries", async (reason, toplevel, branch) => { + const { store, tool } = await setup({ steps: [{ name: "Implement", status: "done" as const }] }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from(toplevel); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from(branch); + 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: "NO-OP: already covered" }); + + expect(result.content[0].text).toContain(`fn_task_done refused: ${reason}`); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-4114", { noCommitsExpected: true }); + }); + + it("FN-6275 sentinel summaries do not auto-complete multiple pending unreviewed steps", async () => { + const { store, tool } = await setup({ + steps: [ + { name: "Implement", status: "in-progress" as const }, + { name: "Testing", status: "pending" 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-4114\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: "NO-OP: already covered" }); + + expect(result.content[0].text).toContain("fn_task_done refused (bulk-step-completion-without-review)"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); + it("FN-350 allows Review Level 1 coordination completion with zero commits when no source files are scoped", async () => { const fn350Prompt = `# Task: FN-350 - Route Ready Swift Tasks to Executor Owner diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 74a6d33642..7b815e6052 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -345,6 +345,7 @@ export function createMockStore() { updatedAt: new Date().toISOString(), }), updateTask: vi.fn().mockResolvedValue({}), + recordActivity: vi.fn().mockResolvedValue({}), moveTask: vi.fn().mockResolvedValue({}), handoffToReview: vi.fn().mockImplementation(async (id: string) => store.moveTask(id, "in-review")), mergeTask: vi.fn().mockResolvedValue({}), diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 65fa5df763..9cc8b50021 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -9,7 +9,7 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n import { existsSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow } from "@fusion/core"; +import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker } from "@fusion/core"; import { mergeEffectiveSettings } from "./effective-settings.js"; import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core"; import { @@ -1109,7 +1109,7 @@ PROMPT.md is captured at task-creation time; HEAD may have moved on since then. 3. Mark every remaining step skipped with a one-line reason: \`fn_task_update(step=N, status="skipped")\`. 4. Call \`fn_task_done\` with a summary that begins \`PREMISE STALE:\` followed by the concrete reason (e.g. \`PREMISE STALE: targeted reproduction passes unchanged on HEAD; PROMPT claimed MOBILE_MEDIA_QUERY had been expanded but useViewportMode.ts:9 still exports the legacy value\`). -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. +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. **Logging important actions:** \`fn_task_log(message="what happened")\` @@ -9509,6 +9509,7 @@ export class TaskExecutor { task: Task, worktreePathOverride?: string, allowReanchor = true, + options?: { noOpCompletion?: boolean; noOpCompletionReason?: string }, ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> { const settings = await this.store.getSettings(); const branchName = resolveTaskWorkingBranch(task); @@ -9572,7 +9573,7 @@ export class TaskExecutor { executorLog.log(`${task.id}: re-anchored nested task.worktree ${worktreePath} -> ${reanchor.root}`); await this.store.logEntry(task.id, `Re-anchored nested task.worktree from ${worktreePath} to ${reanchor.root}`, undefined, this.getRunContextFor(task.id)); await this.emitWorktreeReanchoredAudit(task.id, worktreePath, reanchor.root, "verify-worktree-invariants"); - return this.verifyWorktreeInvariants(task, reanchor.root, false); + return this.verifyWorktreeInvariants(task, reanchor.root, false, options); } } return { @@ -9649,6 +9650,9 @@ export class TaskExecutor { ); const noCommitEligibilityReason = getNoCommitEligibilityReason(task) ?? + (options?.noOpCompletion + ? options.noOpCompletionReason ?? "verified no-op/duplicate completion sentinel" + : null) ?? (promptDerivedEligibility.eligible ? promptDerivedEligibility.reason ?? "prompt-derived no-commit eligibility" : null); @@ -9878,7 +9882,13 @@ export class TaskExecutor { }; } - const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath); + const noOpMarker = parseNoOpCompletionMarker(params.summary); + const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath, true, { + noOpCompletion: Boolean(noOpMarker), + noOpCompletionReason: noOpMarker + ? `verified ${noOpMarker.kind} completion sentinel${noOpMarker.canonicalId ? ` (${noOpMarker.canonicalId})` : ""}` + : undefined, + }); if (!invariantCheck.ok) { const refusalMessage = `fn_task_done refused: ${invariantCheck.reason} — observed=${invariantCheck.observed}, expected=${invariantCheck.expected}`; await store.logEntry(taskId, refusalMessage, undefined, this.getRunContextFor(task.id)); @@ -10015,6 +10025,52 @@ export class TaskExecutor { }; } + if (noOpMarker) { + const runContext = this.getRunContextFor(taskId); + await store.updateTask(taskId, { noCommitsExpected: true }); + await store.logEntry( + taskId, + `Verified ${noOpMarker.kind} completion sentinel accepted; no commits expected for terminal handoff`, + JSON.stringify({ + kind: noOpMarker.kind, + reason: noOpMarker.reason, + canonicalId: noOpMarker.canonicalId, + summary: params.summary, + runId: runContext?.runId, + agentId: runContext?.agentId, + }), + runContext, + ); + const recordActivity = (store as typeof store & { + recordActivity?: (entry: { + type: "task:updated"; + taskId: string; + taskTitle?: string; + details: string; + metadata?: Record; + }) => Promise; + }).recordActivity; + if (recordActivity) { + await recordActivity.call(store, { + type: "task:updated", + taskId, + taskTitle: task.title, + details: `Task marked as verified ${noOpMarker.kind}; no commits expected`, + metadata: { + taskId, + kind: noOpMarker.kind, + reason: noOpMarker.reason, + canonicalId: noOpMarker.canonicalId, + summary: params.summary, + runId: runContext?.runId, + agentId: runContext?.agentId, + }, + }).catch((error: unknown) => { + executorLog.warn(`${taskId}: failed to record no-op completion activity: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + onDone(); // Mark all pending/in-progress steps as done