diff --git a/.changeset/fn-343-merge-worktree-cleanup.md b/.changeset/fn-343-merge-worktree-cleanup.md index 2b6d3982fc..d5386b6638 100644 --- a/.changeset/fn-343-merge-worktree-cleanup.md +++ b/.changeset/fn-343-merge-worktree-cleanup.md @@ -1,5 +1,5 @@ --- -"@fusion/engine": patch +"@runfusion/fusion": patch --- Classify harmless temporary merge worktree cleanup failures after `git worktree prune`/porcelain inspection while keeping still-registered worktree leaks visible in merger diagnostics. diff --git a/.changeset/fn-352-no-commit-coordination.md b/.changeset/fn-352-no-commit-coordination.md new file mode 100644 index 0000000000..3b216bd917 --- /dev/null +++ b/.changeset/fn-352-no-commit-coordination.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Allow narrowly-scoped Review Level 1 coordination tasks with board-only file scope and explicit no-source intent to complete without commits while preserving the missing-commit guard for implementation tasks. diff --git a/docs/task-management.md b/docs/task-management.md index 0292f7423d..849f2641c4 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -876,7 +876,10 @@ Use `noCommitsExpected: true` for tasks where the deliverable is a decision/repo - Meaning: executor allows `fn_task_done` with zero commits for that task. - Triage auto-sets it only when the task is clearly decision-shaped (e.g. "Decide whether...", "Evaluate...", "Verify...", "Audit...") with explicitly observational acceptance criteria and explicit no-code language. +- Review Level 1 coordination/routing tasks that are board-only, explicitly say not to change source, and scope only task documents/metadata can also complete without commits even if older prompts omitted the explicit flag. This fallback is intentionally narrow and exists to recover plan-only coordination work; it does not bypass wrong-worktree or wrong-branch checks. - Ambiguous/forked tasks (e.g. "Investigate..." or "Investigate and fix if needed") leave it unset by default. +- Implementation, feature, bug-fix, source-docs, test, config, or broad investigation tasks still require commits unless they have an explicit and valid no-commit contract. +- If a legacy coordination task is stuck with `fn_task_done refused: no_commits`, prefer setting/verifying `noCommitsExpected` and re-running normal no-op finalization rather than editing `.fusion/fusion.db` directly. - You can manually set/clear it in Task Detail via **No commits expected (decision-only task)**. - Task cards show a **decision-only** badge when enabled. - Finalization still uses the existing no-op review/merge path (`mergeDetails.noOpMerge: true`, `mergeConfirmed: true`); no synthetic merge strategy values are introduced. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b88366993b..8aaef1f081 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2375,6 +2375,8 @@ export interface Task { sourceMessageId?: string; sourceParentTaskId?: string; sourceMetadata?: Record; + /** Reconstructed task prompt content when available on in-memory execution tasks. */ + prompt?: string; /** Explicitly assigned user ID for task-user linking. Used during review handoff to indicate * which user should review the task. The sentinel value "requesting-user" indicates the * user who created or steered the task. */ diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 6358adfad0..2d5edddff2 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -226,7 +226,7 @@ function foreachConfigOf(node: WorkflowIrNode): WorkflowForeachConfig | undefine } function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined { - if (node.kind !== "loop") return undefined; + if (node.kind !== "loop" && node.kind !== "retry-backoff") return undefined; const cfg = node.config as Partial | undefined; if (!cfg || !cfg.template) return undefined; return cfg as WorkflowLoopConfig; @@ -375,7 +375,11 @@ export function irToFlow(def: WorkflowDefinition): { function nodeConfig(node: FlowNode): Record | undefined { const data = node.data; const config: Record = { ...(data.config ?? {}) }; - const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; + const fallbackLabel = data.kind === "merge" + ? "Merge boundary" + : node.parentId + ? templateNodeIdFromChild(node.parentId, node.id) + : node.id; if (data.kind !== "start" && data.kind !== "end" && data.label && data.label !== fallbackLabel) { config.name = data.label; } else { 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 a939af9e56..3c1db1f844 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,164 @@ describe("FN-4114 fn_task_done invariants", () => { 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 + +**Created:** 2026-06-12 +**Size:** S + +## Review Level: 1 (Plan Only) + +**Assessment:** This is a coordination/routing task that should not change product source, but it can affect execution ordering and owner assignment for active Swift implementation work. Risk is low if the executor follows the existing coordinator handoff policy, routes at most one existing ready task, and records clear evidence instead of creating duplicate implementation work. + +## Mission + +Route exactly one existing ready Swift implementation task to the durable executor owner, or record the intentional block if no safe candidate exists. Do not change product source. + +## File Scope + +Atlas Notes task-board artifacts only: + +- FN-350 task document \`docs\` via \`fn_task_document_write\` +- Board task metadata and logs via Fusion task tools + +## Steps + +### Step 0: Preflight +- [x] Required board records exist. + +### Step 1: Re-check live candidate readiness +- [x] Candidate readiness inspected. + +### Step 2: Select exactly one routing action +- [x] One routing action selected. + +### Step 3: Perform safe routing or record intentional block +- [x] Routing evidence recorded. + +### Step 4: Testing & Verification +- [x] Board-only verification recorded. + +### Step 5: Documentation & Delivery +- [x] Final documentation saved. + +## Do NOT + +- Do not edit product source. +- Do not create duplicate implementation tasks. +`; + const { store, tool } = await setup({ + id: "FN-350", + title: "Route Ready Swift Tasks to Executor Owner", + description: "Coordination/routing task with task-document evidence only.", + prompt: fn350Prompt, + branch: "fusion/fn-350", + noCommitsExpected: undefined, + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Re-check live candidate readiness", status: "done" as const }, + { name: "Select exactly one routing action", status: "done" as const }, + { name: "Perform safe routing or record intentional block", status: "done" as const }, + { name: "Testing & Verification", status: "done" as const }, + { name: "Documentation & Delivery", status: "in-progress" as const }, + ], + currentStep: 5, + }); + 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-350\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: "Recorded routing evidence in task documents and logs." }); + + 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.mock.calls).toEqual([["FN-350", "in-progress"]]); + expect(store.handoffToReview).not.toHaveBeenCalled(); + }); + + it("FN-350 refuses contradictory implementation plus coordination fallback prompts", async () => { + const prompt = `# Task: FN-350 - Route Ready Swift Tasks to Executor Owner + +## Review Level: 1 (Plan Only) + +**Assessment:** This is a coordination/routing task that should not change product source. + +## Mission +Implement the source fix if possible, or record the intentional block if no safe candidate exists. Do not change product source. + +## File Scope + +- FN-350 task document \`docs\` via \`fn_task_document_write\` + +## Steps + +### Step 1: Decide +- [x] Decision recorded. +`; + const { store, tool } = await setup({ + id: "FN-350", + title: "Route Ready Swift Tasks to Executor Owner", + description: "Coordination/routing task with task-document evidence only.", + prompt, + branch: "fusion/fn-350", + noCommitsExpected: undefined, + steps: [{ name: "Decide", 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-350\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", {}); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-350", "todo", { preserveProgress: true }); + }); + + it("FN-4114 still refuses source-changing implementation tasks with zero commits and no explicit no-commit contract", async () => { + const implementationPrompt = `# Task: FN-4114 - Implement source change + +**Size:** M + +## Review Level: 2 (Plan and Code) + +## Mission + +Implement a bug fix in the engine. + +## File Scope + +- packages/engine/src/executor.ts +- packages/engine/src/__tests__/executor-task-done-invariant.test.ts + +## Steps + +### Step 1: Implement +- [ ] Change source code and tests. +`; + const { store, tool } = await setup({ prompt: implementationPrompt, noCommitsExpected: undefined }); + 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", {}); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); + it("FN-4114 allows no-commit completion when noCommitsExpected is true", async () => { const { store, tool } = await setup({ noCommitsExpected: true }); mockedExecSync.mockImplementation((cmd: string) => { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 115594cdc7..65fa5df763 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -231,6 +231,67 @@ export { const yieldEventLoop = (): Promise => new Promise((resolve) => setImmediateCb(resolve)); +function getPromptSection(prompt: string, heading: string): string { + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = prompt.match(new RegExp(`^##\\s+${escapedHeading}\\s*$([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "im")); + return match?.[1]?.trim() ?? ""; +} + +function promptDeclaresReviewLevelOnePlanOnly(prompt: string): boolean { + return /^##\s+Review Level:\s*1\b[^\n]*\bPlan Only\b/im.test(prompt); +} + +function promptDeclaresNoSourceChangeIntent(prompt: string): boolean { + const normalized = prompt.toLowerCase(); + return [ + /should\s+not\s+change\s+(?:product\s+)?source/, + /do\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?source/, + /no\s+(?:source|code)\s+changes?\s+(?:are\s+)?(?:expected|required|needed|allowed)/, + /must\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?(?:source|code)/, + ].some((pattern) => pattern.test(normalized)); +} + +function promptLooksCoordinationOnly(prompt: string): boolean { + const titleMatch = prompt.match(/^#\s+Task:\s+[^\n]+/im)?.[0] ?? ""; + const mission = getPromptSection(prompt, "Mission"); + const assessment = prompt.match(/^\*\*Assessment:\*\*\s*([^\n]+)/im)?.[1] ?? ""; + const coordinationText = `${titleMatch}\n${mission}\n${assessment}`.toLowerCase(); + const hasCoordinationIntent = /\b(coordination|routing|route|handoff|assign(?:ment)?|owner|triage|select exactly one|record (?:the )?intentional block)\b/.test(coordinationText); + const missionLower = mission.toLowerCase() + .replace(/do\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?source/g, "") + .replace(/should\s+not\s+change\s+(?:product\s+)?source/g, "") + .replace(/must\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?(?:source|code)/g, ""); + const hasImplementationDirective = /\b(implement|fix|add|change|modify|refactor|build|create|delete|remove)\b/.test(missionLower); + return hasCoordinationIntent && !hasImplementationDirective; +} + +function promptFileScopeIsBoardOnly(prompt: string): boolean { + const fileScope = getPromptSection(prompt, "File Scope"); + if (!fileScope.trim()) return false; + const normalized = fileScope.toLowerCase(); + const sourcePathPattern = /(?:^|[\s`'"(])(?:packages|src|source|sources|app|apps|lib|libs|components|scripts|docs|\.github|config|test|tests|__tests__)\//m; + const sourceExtensionPattern = /\.(?:ts|tsx|js|jsx|mjs|cjs|swift|kt|java|py|go|rs|rb|php|cs|cpp|c|h|hpp|json|ya?ml|toml|mdx?|css|scss|html|sql|sh)\b/m; + if (sourcePathPattern.test(normalized) || sourceExtensionPattern.test(normalized)) return false; + const allowedBoardOnlyPattern = /(?:^|[^\w/])(?:task[- ]?board|board task|task document|task documents|task metadata|task logs|fusion task tools|fn_task_[\w-]*|\.fusion\/tasks|attachments?)(?=$|[^\w/-])/; + return allowedBoardOnlyPattern.test(normalized); +} + +function getNoCommitEligibilityReason(task: Task): "explicit noCommitsExpected=true" | "prompt-derived coordination-only no-source scope" | null { + if (task.noCommitsExpected === true) return "explicit noCommitsExpected=true"; + const rawPrompt = task.prompt; + const prompt = typeof rawPrompt === "string" ? rawPrompt : ""; + if (!prompt.trim()) return null; + if ( + promptDeclaresReviewLevelOnePlanOnly(prompt) && + promptLooksCoordinationOnly(prompt) && + promptDeclaresNoSourceChangeIntent(prompt) && + promptFileScopeIsBoardOnly(prompt) + ) { + return "prompt-derived coordination-only no-source scope"; + } + return null; +} + /** * How long to wait after engine startup before spawning AI agent sessions for * orphaned in-progress tasks. The work itself (worktree setup, pi-coding-agent @@ -9582,15 +9643,21 @@ export class TaskExecutor { } const promptContent = (task as Task & { prompt?: unknown }).prompt; - const noCommitEligibility = task.noCommitsExpected === true - ? { eligible: true, reason: "noCommitsExpected=true" } - : evaluatePromptDerivedNoCommitEligibility(task, typeof promptContent === "string" ? promptContent : ""); - if (noCommitEligibility.eligible) { - executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (${noCommitEligibility.reason})`); + const promptDerivedEligibility = evaluatePromptDerivedNoCommitEligibility( + task, + typeof promptContent === "string" ? promptContent : "", + ); + const noCommitEligibilityReason = + getNoCommitEligibilityReason(task) ?? + (promptDerivedEligibility.eligible + ? promptDerivedEligibility.reason ?? "prompt-derived no-commit eligibility" + : null); + if (noCommitEligibilityReason) { + executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (${noCommitEligibilityReason})`); try { await this.store.logEntry( task.id, - `fn_task_done no_commits guard skipped (${noCommitEligibility.reason})`, + `fn_task_done no_commits guard skipped (${noCommitEligibilityReason})`, undefined, this.getRunContextFor(task.id), );