fix(FN-352): allow no-commit coordination completion
This commit is contained in:
5
.changeset/fn-352-no-commit-coordination.md
Normal file
5
.changeset/fn-352-no-commit-coordination.md
Normal file
@@ -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.
|
||||
@@ -875,7 +875,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.
|
||||
|
||||
@@ -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<WorkflowLoopConfig> | undefined;
|
||||
if (!cfg || !cfg.template) return undefined;
|
||||
return cfg as WorkflowLoopConfig;
|
||||
@@ -375,7 +375,11 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
function nodeConfig(node: FlowNode<WorkflowFlowNodeData>): Record<string, unknown> | undefined {
|
||||
const data = node.data;
|
||||
const config: Record<string, unknown> = { ...(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 {
|
||||
@@ -436,9 +440,6 @@ export function flowToIr(
|
||||
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
|
||||
}
|
||||
if (data.kind === "foreach" || data.kind === "loop") {
|
||||
if (originalKind && originalKind !== "foreach" && originalKind !== "loop") {
|
||||
return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined };
|
||||
}
|
||||
// Reassemble the template from this group's children.
|
||||
const children = childrenByGroup.get(node.id) ?? [];
|
||||
const templateNodes: WorkflowIrNode[] = children.map((c) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ async function setup(overrides: Record<string, unknown> = {}) {
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store as any, "/repo");
|
||||
await executor.execute(baseTask() as any);
|
||||
await executor.execute(task as any);
|
||||
|
||||
return { store, tool, setTask: (next: any) => (task = { ...task, ...next }) };
|
||||
}
|
||||
@@ -111,6 +111,122 @@ 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-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) => {
|
||||
|
||||
@@ -231,6 +231,63 @@ export {
|
||||
|
||||
const yieldEventLoop = (): Promise<void> => 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 hasImplementationDirective = /\b(implement|fix|add|change|modify|refactor|build|create|delete|remove)\b/.test(mission.toLowerCase()) && !/record (?:the )?intentional block/.test(mission.toLowerCase());
|
||||
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 = /\b(task[- ]?board|board task|task document|task documents|task metadata|task logs|fusion task tools|fn_task_|\.fusion\/tasks|attachments?)\b/;
|
||||
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 as { prompt?: unknown }).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
|
||||
@@ -9419,8 +9476,9 @@ export class TaskExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
if (task.noCommitsExpected === true) {
|
||||
executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (noCommitsExpected=true)`);
|
||||
const noCommitEligibilityReason = getNoCommitEligibilityReason(task);
|
||||
if (noCommitEligibilityReason) {
|
||||
executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (${noCommitEligibilityReason})`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user