Files
fusion/packages/engine/src/__tests__/executor-prompt.test.ts
gsxdsm b007de5f94 fix(ci,tests): repair binary release pipeline and re-green the full suite
Binary Release (v0.73.0-beta.5 was fully red):
- bun compile: mark chromium-bidi external — playwright-core@1.60 (feature-video)
  optionally requires it and bun fails closed on unresolvable requires.
- Windows desktop EXE: quote -c.publish.channel=beta in release.yml; PowerShell
  tokenizes the bare flag into `-c` + a path and electron-builder ENOENTs on it.

Full suite (all 4 shards red from stale-test drift, no product bugs found):
- engine: align mock stores/assertions with atomic store.moveTaskIf dispatch
  (#2371), the fail-closed non-empty PROMPT.md artifact gate (#2390), oldest-
  first admission (FN-8453), alreadyClaimed graph routing (#2393), startStep
  step projection (#2403/FN-8464), structured retry presentation (FN-8503),
  provider-lane pause reasons (#2339), typed column-boundary entry (#2378),
  Type.Integer in CAS document schemas (#2375), bounded model-registry refresh.
- engine-no-blocking-shellout: re-pin 17 drifted allowlist line numbers and drop
  the stale REBASE_HEAD entry whose execSync was removed.
- core: schema-applier expectations track migrations 0033-0035 (96 tables) and
  the synthetic 0000 fixture gains workflow_work_items/mission_contract_assertions;
  work-item terminal state is "succeeded" post-#2378.

Known follow-up (not addressed here): self-healing starved-refinement escalation
bumps task.priority, which FN-8453 oldest-first admission no longer consults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:43:00 -07:00

2963 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -nocheck
/* eslint-disable -eslint/no-unused-vars */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import "./executor-test-helpers.js";
import { AgentSemaphore } from "../concurrency.js";
import { detectReviewHandoffIntent, determineRevisionResetStart } from "../executor.js";
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
import { createFnAgent } from "../pi.js";
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
import { execSync } from "node:child_process";
import { writeFile, rm } from "node:fs/promises";
import { findWorktreeUser, aiMergeTask } from "../merger.js";
import { WorktreePool } from "../worktree-pool.js";
import { generateWorktreeName, slugify } from "../worktree-names.js";
import type { Task, TaskDetail } from "@fusion/core";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { StepSessionExecutor } from "../step-session-executor.js";
import { executingTaskLock } from "../active-session-registry.js";
import { executorLog } from "../logger.js";
import { withRateLimitRetry } from "../rate-limit-retry.js";
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
import {
createMockStore,
mockedCreateFnAgent,
mockedSessionManager,
mockedGenerateWorktreeName,
mockedFindWorktreeUser,
mockedStepSessionExecutor,
mockedWithRateLimitRetry,
mockedExecSync,
mockedExistsSync,
mockExecuteAll,
mockTerminateAllSessions,
mockCleanup,
resetExecutorMocks,
} from "./executor-test-helpers.js";
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-001",
title: "Test Task",
description: "A test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("buildExecutionPrompt", () => {
it("includes attachment section with absolute paths for image attachments", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "abc123-screenshot.png", originalName: "screenshot.png", mimeType: "image/png", size: 2048, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain("## Attachments");
expect(result).toContain("**screenshot.png** (screenshot)");
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
});
it("includes attachment section with absolute paths for text attachments", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "def456-error.log", originalName: "error.log", mimeType: "text/plain", size: 512, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain("## Attachments");
expect(result).toContain("**error.log** (text/plain)");
expect(result).toContain("read for context");
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/def456-error.log");
});
it("includes both image and text attachments", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "abc-shot.png", originalName: "shot.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
{ filename: "def-config.json", originalName: "config.json", mimeType: "application/json", size: 256, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain("**shot.png** (screenshot)");
expect(result).toContain("**config.json** (application/json)");
});
it("rewrites project-root absolute paths to the active worktree", () => {
const task = createMockTaskDetail({
prompt: [
"# test",
"## Context to Read First",
"- `/home/user/project/web/app/page.tsx`",
"- `/home/user/project/.fusion/memory/`",
"## Steps",
"### Step 0: Preflight",
"- [ ] inspect `/home/user/project/web/app/layout.tsx`",
].join("\n"),
});
const result = buildExecutionPrompt(
task,
"/home/user/project",
undefined,
"/home/user/project/.worktrees/happy-robin",
);
expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/page.tsx");
expect(result).toContain("/home/user/project/.worktrees/happy-robin/web/app/layout.tsx");
expect(result).toContain("/home/user/project/.fusion/memory/");
expect(result).not.toContain("/home/user/project/.worktrees/happy-robin/.fusion/memory/");
});
it("omits attachment section when no attachments", () => {
const task = createMockTaskDetail({ attachments: [] });
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).not.toContain("## Attachments");
});
it("omits attachment section when attachments is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Attachments");
});
it("omits attachment section when rootDir is not provided", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "abc.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Attachments");
});
it("does not instruct graph-owned execution sessions to request per-step reviews", () => {
const task = createMockTaskDetail({
prompt: [
"# test",
"**Review Level:** 2",
"## Steps",
"### Step 0: Preflight",
"- [ ] check",
"### Step 1: Implement",
"- [ ] change code",
"### Step 2: Delivery",
"- [ ] summarize",
].join("\n"),
});
const result = buildExecutionPrompt(
task,
"/home/user/project",
undefined,
undefined,
undefined,
undefined,
undefined,
{ workflowReviewGatesOwnedByGraph: true },
);
expect(result).toContain("Workflow review gates are handled by the workflow graph");
expect(result).not.toContain("Before implementing each step");
expect(result).not.toContain("After implementing + committing each step");
expect(result).not.toContain("fn_review_step");
});
it("includes Custom fields section listing id/name/type, enum options, required, and current value", () => {
const task = createMockTaskDetail({ customFields: { severity: "high" } });
const result = buildExecutionPrompt(task, "/home/user/project", undefined, undefined, undefined, [
{ id: "severity", name: "Severity", type: "enum", required: true, options: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
] },
{ id: "notes", name: "Notes", type: "text" },
] as any);
expect(result).toContain("## Custom fields");
expect(result).toContain("`severity` (Severity) — type: enum");
expect(result).toContain("options: [low (Low), high (High)]");
expect(result).toContain("required");
expect(result).toContain('current: "high"');
// The unset field reports "unset".
expect(result).toContain("`notes` (Notes) — type: text; current: unset");
});
it("omits Custom fields section when no field defs are provided", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).not.toContain("## Custom fields");
const resultEmpty = buildExecutionPrompt(task, "/home/user/project", undefined, undefined, undefined, []);
expect(resultEmpty).not.toContain("## Custom fields");
});
it("includes Project Commands section with test command when settings.testCommand is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
testCommand: "pnpm test",
} as any);
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Test:** `pnpm test`");
expect(result).not.toContain("- **Build:**");
});
it("includes Project Commands section with build command when settings.buildCommand is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
buildCommand: "pnpm build",
} as any);
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Build:** `pnpm build`");
expect(result).not.toContain("- **Test:**");
});
it("includes both commands when both are set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
testCommand: "pnpm test",
buildCommand: "pnpm build",
} as any);
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Test:** `pnpm test`");
expect(result).toContain("- **Build:** `pnpm build`");
});
it("tells executors to split unrelated broad-suite failures into follow-up work", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
testCommand: "pnpm test",
buildCommand: "pnpm build",
} as any);
expect(result).toContain("caused-by-this-task failures are blocking");
expect(result).toContain("unrelated or pre-existing failures should be logged and split into a follow-up");
expect(result).toContain("If the repo has a typecheck command, run it before `fn_task_done()`");
expect(result).toContain("including unrelated/pre-existing broad-suite failures");
});
it("warns against repeated broad workspace verification loops", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
testCommand: "pnpm test",
buildCommand: "pnpm build",
} as any);
expect(result).toContain("Do not repeatedly rerun a broad failing or hanging workspace command");
expect(result).toContain("without a new hypothesis and a narrower confirming command");
expect(result).toContain("unrelated or pre-existing failures should be logged and split into a follow-up");
expect(result).not.toContain("Resolve ALL test failures");
});
it("includes source issue reference in commit instruction when task has github sourceIssue", () => {
const task = createMockTaskDetail({
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "2915",
issueNumber: 2915,
},
} as any);
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — <short summary>" -m "Ref: runfusion/fusion#2915"');
});
it("falls back to externalIssueId for commit source issue reference when issueNumber is missing", () => {
const task = createMockTaskDetail({
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "2915",
},
} as any);
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — <short summary>" -m "Ref: runfusion/fusion#2915"');
});
it("omits source issue reference from commit instruction when sourceIssue is missing", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — <short summary>"');
expect(result).not.toContain(' -m "Ref:');
});
it("requires a short summary in the execution prompt begin block", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — <short summary>"');
expect(result).toContain("The `<short summary>` is required");
expect(result).toContain("concrete 5–10 word description of what the step changed");
});
it("keeps the executor source prompt wording and examples for commit summaries", async () => {
const { readFileSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
const executorSource = readFileSync(new URL("../executor.ts", import.meta.url), "utf8");
expect(executorSource).toContain("Always include a short, specific summary after the em dash (5–10 words)");
expect(executorSource).toContain("Do NOT commit just \\`complete Step N\\`");
expect(executorSource).toContain("\\`feat(FN-1234): complete Step 4 — tighten prompt examples for commit summaries\\`");
expect(executorSource).toContain("\\`feat(FN-1234): complete Step 2\\`");
});
it("omits Project Commands section when neither command is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {} as any);
expect(result).not.toContain("## Project Commands");
});
it("omits Project Commands section when settings is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Project Commands");
});
it("includes Steering Comments section when steeringComments has entries", () => {
const task = createMockTaskDetail({
steeringComments: [
{
id: "1",
text: "Please handle the edge case",
createdAt: new Date().toISOString(),
author: "user" as const,
},
],
});
const result = buildExecutionPrompt(task);
expect(result).toContain("## Steering Comments");
expect(result).toContain("**user**");
expect(result).toContain("> Please handle the edge case");
expect(result).toContain("The following comments were added by the user during execution");
});
it("formats multiple steering comments correctly", () => {
const now = new Date();
const task = createMockTaskDetail({
steeringComments: [
{
id: "1",
text: "First comment",
createdAt: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
author: "user" as const,
},
{
id: "2",
text: "Second comment",
createdAt: now.toISOString(),
author: "agent" as const,
},
],
});
const result = buildExecutionPrompt(task);
expect(result).toContain("**user**");
expect(result).toContain("**agent**");
expect(result).toContain("> First comment");
expect(result).toContain("> Second comment");
});
it("omits Steering Comments section when steeringComments is empty", () => {
const task = createMockTaskDetail({ steeringComments: [] });
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Steering Comments");
});
it("omits Steering Comments section when steeringComments is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Steering Comments");
});
it("includes only the 10 most recent steering comments", () => {
const steeringComments = Array.from({ length: 15 }, (_, i) => ({
id: `${i}`,
text: `Comment ${i}`,
createdAt: new Date().toISOString(),
author: "user" as const,
}));
const task = createMockTaskDetail({ steeringComments });
const result = buildExecutionPrompt(task);
// Should include comments 5-14 (the 10 most recent), not 0-4
expect(result).toContain("> Comment 5");
expect(result).toContain("> Comment 14");
expect(result).not.toContain("> Comment 0");
expect(result).not.toContain("> Comment 4");
});
it("end-to-end: steering comments are fully injected into execution prompt with correct format", () => {
const now = new Date();
const task = createMockTaskDetail({
id: "FN-123",
title: "Verify Steering Feature",
steeringComments: [
{
id: "sc-001",
text: "Please ensure all edge cases are handled in the validation logic",
createdAt: new Date(now.getTime() - 120000).toISOString(),
author: "user" as const,
},
{
id: "sc-002",
text: "Consider adding unit tests for the new utility function",
createdAt: new Date(now.getTime() - 60000).toISOString(),
author: "agent" as const,
},
{
id: "sc-003",
text: "Don't forget to update the documentation before completing",
createdAt: now.toISOString(),
author: "user" as const,
},
],
});
const result = buildExecutionPrompt(task, "/project", { testCommand: "pnpm test" } as any);
// Verify section header exists
expect(result).toContain("## Steering Comments");
// Verify explanatory header text
expect(result).toContain("The following comments were added by the user during execution");
expect(result).toContain("Consider adjusting your approach or replanning remaining steps based on this feedback");
// Verify all three comments appear with correct author badges
expect(result).toContain("**user**");
expect(result).toContain("**agent**");
// Verify quoted text format
expect(result).toContain("> Please ensure all edge cases are handled in the validation logic");
expect(result).toContain("> Consider adding unit tests for the new utility function");
expect(result).toContain("> Don't forget to update the documentation before completing");
// Verify timestamp formatting appears (either relative like "2m ago" or absolute)
// The formatTimestamp function returns relative times for recent comments
expect(result).toMatch(/\*\*user\*\* — \d+m? ago/);
// Verify the section appears in the expected location (after progress section, before review level)
const steeringSectionIndex = result.indexOf("## Steering Comments");
const reviewLevelIndex = result.indexOf("## Review level");
expect(steeringSectionIndex).toBeGreaterThan(0);
expect(reviewLevelIndex).toBeGreaterThan(steeringSectionIndex);
});
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
testCommand: "npm test",
buildCommand: "npm run build",
});
const mockPrompt = vi.fn().mockResolvedValue(undefined);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: vi.fn(),
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Called four times: initial execution + 3 retries when agent finishes without fn_task_done
expect(mockPrompt).toHaveBeenCalledTimes(4);
const agentPrompt = mockPrompt.mock.calls[0][0];
expect(agentPrompt).toContain("## Project Commands");
expect(agentPrompt).toContain("- **Test:** `npm test`");
expect(agentPrompt).toContain("- **Build:** `npm run build`");
});
describe("memoryEnabled setting", () => {
it("includes memory instructions when memoryEnabled: true", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
} as any);
expect(result).toContain("Execute this task.");
expect(result).toContain("## Project Memory");
expect(result).toContain(".fusion/memory/");
});
it("excludes memory instructions when memoryEnabled: false", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: false,
} as any);
expect(result).toContain("Execute this task.");
expect(result).not.toContain("## Project Memory");
});
it("includes memory instructions when memoryEnabled is undefined (default enabled)", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {} as any);
expect(result).toContain("Execute this task.");
expect(result).toContain("## Project Memory");
expect(result).toContain(".fusion/memory/");
});
it("includes selective memory write instruction for durable learnings at end of execution", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
} as any);
// Should instruct selective writes, not unconditional appends
expect(result).toMatch(/skip.*memory.*update|selectively|durable.*learnings/i);
expect(result).toMatch(/end of execution|before calling.*fn_task_done/i);
// Should distinguish agent-private vs project-shared memory scope
expect(result).toContain('fn_memory_append(scope="agent")');
expect(result).toContain('fn_memory_append(scope="project")');
// Should forbid task-specific trivia
expect(result).toMatch(/avoid.*trivia|task-specific.*trivia|per-task.*log/i);
// Should allow consolidation/editing
expect(result).toMatch(/consolidate|update.*refine.*existing|edit.*existing/i);
});
it("uses project-root memory path not worktree-local path", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
} as any);
expect(result).toContain("`.fusion/memory/`");
});
});
describe("memoryBackendType setting", () => {
it("includes .fusion/memory/ for file backend", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
memoryBackendType: "file",
} as any);
expect(result).toContain("## Project Memory");
// Check that the Project Memory section contains .fusion/memory/
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
expect(memorySectionMatch).toBeTruthy();
expect(memorySectionMatch![1]).toContain(".fusion/memory/");
});
it("includes read-only wording for readonly backend without write directives in memory section", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
memoryBackendType: "readonly",
} as any);
expect(result).toContain("## Project Memory");
// Extract the Project Memory section
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
expect(memorySectionMatch).toBeTruthy();
const memorySection = memorySectionMatch![1];
// Should NOT contain write/update directives in the memory section
expect(memorySection).not.toMatch(/write.*memory|update.*memory/i);
// Should NOT contain the specific file path in the memory section
expect(memorySection).not.toContain(".fusion/memory/");
});
it("does not include .fusion/memory/ in Project Memory section for qmd backend", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
memoryBackendType: "qmd",
} as any);
expect(result).toContain("## Project Memory");
// Extract the Project Memory section
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
expect(memorySectionMatch).toBeTruthy();
const memorySection = memorySectionMatch![1];
// QMD should NOT unconditionally reference .fusion/memory/ in the memory section
expect(memorySection).not.toContain(".fusion/memory/");
expect(memorySection).toContain("fn_memory_search");
expect(memorySection).toContain("fn_memory_get");
});
it("QMD prompt has actionable memory instructions", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
memoryBackendType: "qmd",
} as any);
expect(result).toContain("## Project Memory");
// Extract the Project Memory section
const memorySectionMatch = result.match(/## Project Memory\n([\s\S]*?)(?=\n## [^#]|$)/);
expect(memorySectionMatch).toBeTruthy();
const memorySection = memorySectionMatch![1];
// QMD should NOT contain .fusion/memory/
expect(memorySection).not.toContain(".fusion/memory/");
expect(memorySection).toContain("fn_memory_search");
// Contains "end of execution" write guidance
expect(memorySection).toMatch(/end of execution/i);
});
it("excludes memory section when memoryEnabled: false regardless of backend", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: false,
memoryBackendType: "file",
} as any);
expect(result).toContain("Execute this task.");
expect(result).not.toContain("## Project Memory");
});
});
describe("commit co-author attribution", () => {
it("includes default co-author trailer in commit instruction when commitAuthorEnabled is true", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
commitAuthorEnabled: true,
} as any);
expect(result).toContain('-m "Co-authored-by: Fusion <noreply@runfusion.ai>"');
expect(result).not.toContain("--author=");
});
it("includes custom co-author name and email in commit instruction", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
commitAuthorEnabled: true,
commitAuthorName: "CustomBot",
commitAuthorEmail: "bot@example.com",
} as any);
expect(result).toContain('-m "Co-authored-by: CustomBot <bot@example.com>"');
});
it("omits co-author trailer from commit instruction when commitAuthorEnabled is false", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
commitAuthorEnabled: false,
} as any);
expect(result).not.toContain("Co-authored-by");
expect(result).not.toContain("--author");
// Should still contain commit instruction without co-author
expect(result).toContain("git commit -m");
});
it("uses default co-author when commitAuthorEnabled is true but name/email are undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
commitAuthorEnabled: true,
commitAuthorName: undefined,
commitAuthorEmail: undefined,
} as any);
expect(result).toContain('-m "Co-authored-by: Fusion <noreply@runfusion.ai>"');
});
it("uses default co-author when settings is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project");
expect(result).toContain('-m "Co-authored-by: Fusion <noreply@runfusion.ai>"');
});
it("uses default co-author when settings is empty object", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {} as any);
expect(result).toContain('-m "Co-authored-by: Fusion <noreply@runfusion.ai>"');
});
});
});
// Import the summarizeToolArgs helper directly (not affected by mocks above)
describe("summarizeToolArgs", () => {
// Dynamic import to avoid mock interference
let summarizeToolArgs: (name: string, args?: Record<string, unknown>) => string | undefined;
beforeEach(async () => {
const mod = await vi.importActual<typeof import("../executor.js")>("../executor.js");
summarizeToolArgs = mod.summarizeToolArgs;
});
it("returns command for bash tool", () => {
expect(summarizeToolArgs("Bash", { command: "ls -la" })).toBe("ls -la");
expect(summarizeToolArgs("bash", { command: "echo hello" })).toBe("echo hello");
});
it("returns long bash commands in full without truncation", () => {
const longCmd = "a".repeat(100);
const result = summarizeToolArgs("Bash", { command: longCmd });
expect(result).toBe(longCmd);
});
it("returns path for read/edit/write tools", () => {
expect(summarizeToolArgs("Read", { path: "src/types.ts" })).toBe("src/types.ts");
expect(summarizeToolArgs("edit", { path: "src/store.ts" })).toBe("src/store.ts");
expect(summarizeToolArgs("Write", { path: "out.txt", content: "data" })).toBe("out.txt");
});
it("returns first string arg for unknown tools", () => {
expect(summarizeToolArgs("fn_task_update", { step: 1, status: "done" })).toBe("done");
});
it("returns undefined when no args provided", () => {
expect(summarizeToolArgs("Bash")).toBeUndefined();
expect(summarizeToolArgs("Bash", {})).toBeUndefined();
});
it("returns compact JSON when only non-string args are present", () => {
// FNXC:StuckDetector 2026-07-22-20:20: structured custom-tool args need a distinct summary.
expect(summarizeToolArgs("unknown", { count: 42, flag: true })).toBe('{"count":42,"flag":true}');
});
});
/*
FNXC:EngineTests 2026-07-19-03:50 (U10b):
Execution ownership (`executingTaskLock`, graph routing) is deliberately PROCESS-WIDE in production: a duplicate dispatch of the same card from ANY TaskExecutor instance must be dropped while a run owns it.
Tests that fire a resume without awaiting it (`resumeOrphaned`, `task:updated` triggers) leave a real graph run in flight past their own end, so under graph-owned execution the next same-id test is dropped as a duplicate dispatch or collides on the shared worktree.
Drain those in-flight runs between tests, then clear the registries, so each case measures its own dispatch instead of the previous test's leftovers.
*/
const processWideGraphRouting = () =>
(TaskExecutor as unknown as { processWideGraphRouting: Set<string> }).processWideGraphRouting;
async function settleLeakedBackgroundRuns(): Promise<void> {
const deadline = Date.now() + 3000;
while (processWideGraphRouting().size > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10));
}
processWideGraphRouting().clear();
executingTaskLock._clearForTest();
}
describe("TaskExecutor pause behavior", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExistsSync.mockReturnValue(true);
});
afterEach(settleLeakedBackgroundRuns);
it("terminates agent and moves task to todo when paused during execution", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
mockedCreateFnAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate pause happening during agent execution
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
// Simulate the dispose causing an error (session terminated)
throw new Error("Session terminated");
}),
dispose: disposeFn,
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Should move to todo, NOT mark as failed.
// FNXC:ExecutorMoveTaskOptions 2026-07-12: executor.ts:11622-11625 now always passes a moveTask options object built from conditional spreads.
/*
FNXC:EngineTests 2026-07-23-21:40 (FN-8464 / #2403):
A pause-abort bounce to todo preserves resume state ONLY when the run recorded resumable
progress (currentStep > 0 or a step marked done/in-progress). A FRESH task's first
implementation pass now OWNS the step projection: `runProjectedGraphTaskStep` defers the
atomic `startStep` in-progress write until the task has a real worktree (FN-8464 baseline
cwd gating) and #2403 routed step starts through the dependency-gated `store.startStep`.
A pause landing during that first session therefore finds every step still `pending`,
so the bounce carries no `preserveResumeState` — the conditional spreads collapse to `{}`.
The protective intent is unchanged: pause parks in todo and never marks the task failed.
*/
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {});
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
});
it("does not move to in-review when paused during execution (graceful session end)", async () => {
const store = createMockStore();
mockedCreateFnAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate pause — session ends gracefully (no throw)
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
}),
dispose: vi.fn(),
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Should NOT move to in-review (paused tasks skip that logic)
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
// Should move to todo instead (regression: was stranding in in-progress).
// Pause-graceful path flags preserveResumeState so the bounce keeps
// the worktree and accumulated step progress.
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true });
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
});
it("moves paused task to todo when session ends gracefully (regression for FN-827)", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
mockedCreateFnAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate pause during execution — session ends gracefully (no throw)
store._trigger("task:updated", { id: "FN-805", paused: true, column: "in-progress" });
// No error thrown — this is the "graceful exit" path
}),
dispose: disposeFn,
},
} as any;
});
const stuckTaskDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordActivity: vi.fn() } as any;
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector });
await executor.execute({
id: "FN-805",
title: "Stranded task",
description: "A task that was paused and stranded",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// The critical fix: task must end in todo, not stranded in in-progress.
// The pause path must also flag preserveResumeState so the move does not
// wipe accumulated step progress and the worktree pointer.
expect(store.moveTask).toHaveBeenCalledWith("FN-805", "todo", { preserveResumeState: true });
// Should NOT be marked as failed
expect(store.updateTask).not.toHaveBeenCalledWith("FN-805", expect.objectContaining({ status: "failed" }));
// Should log the pause event
expect(store.logEntry).toHaveBeenCalledWith("FN-805", expect.stringContaining("Execution paused"));
// Session should be disposed
expect(disposeFn).toHaveBeenCalled();
// Stuck detector should have untracked the task
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-805");
});
it("handles rapid pause→unpause without duplicate executor runs", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
let promptCallCount = 0;
mockedCreateFnAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
promptCallCount++;
// Simulate pause during execution
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
// Simulate rapid unpause while executor is still handling the pause
store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress" });
// Session ends gracefully (no throw)
}),
dispose: disposeFn,
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Rapid pause/unpause",
description: "Test rapid pause then unpause",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// The task should still be moved to todo exactly once (the pause took effect)
// Even if unpause happened rapidly, the session was already disposed
const todoCalls = store.moveTask.mock.calls.filter(
(call: any[]) => call[0] === "FN-001" && call[1] === "todo",
);
expect(todoCalls.length).toBe(1);
// Should NOT have duplicate in-review calls
const inReviewCalls = store.moveTask.mock.calls.filter(
(call: any[]) => call[0] === "FN-001" && call[1] === "in-review",
);
expect(inReviewCalls.length).toBe(0);
// Agent should only have been prompted once
expect(promptCallCount).toBe(1);
});
it("skips paused tasks during resumeOrphaned", async () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([
{ id: "FN-001", column: "in-progress", paused: true, title: "Paused task", steps: [], description: "", dependencies: [] },
{ id: "FN-002", column: "in-progress", paused: false, title: "Active task", steps: [], description: "", dependencies: [] },
]);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
// Only KB-002 should be resumed (KB-001 is paused)
expect(store.logEntry).toHaveBeenCalledWith("FN-002", "Resumed after engine restart");
expect(store.logEntry).not.toHaveBeenCalledWith("FN-001", expect.anything());
});
it("skips resumeOrphaned entirely while enginePaused is active", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
enginePaused: true,
globalPause: false,
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
expect(store.listTasks).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("resumes unpaused in-progress task with no active session", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeFn,
},
}) as any);
const _executor = new TaskExecutor(store, "/tmp/test");
// Simulate unpause of an in-progress task that has no active session
// (e.g., engine restarted while task was paused in-progress)
store._trigger("task:updated", {
id: "FN-001",
paused: undefined,
column: "in-progress",
description: "Test task",
title: "Resumed task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Wait for async execution to start
await new Promise((r) => setTimeout(r, 50));
// Agent created at least twice: initial resume + retry when agent finishes without fn_task_done
// (async worktree validation may allow additional retry cycles within the timeout)
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined);
});
it("does not resume unpaused in-progress task while global pause is active", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause: true,
enginePaused: false,
});
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
store._trigger("task:updated", {
id: "FN-001",
paused: undefined,
column: "in-progress",
description: "Test task",
title: "Paused runtime task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await new Promise((r) => setTimeout(r, 20));
expect(executor).toBeTruthy();
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined);
});
it("does not recursively resume when resume logging emits task updated", async () => {
const store = createMockStore();
const task = {
id: "FN-001",
paused: undefined,
column: "in-progress",
description: "Test task",
title: "Resumed task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
}) as any);
let emittedUpdateFromLog = false;
store.logEntry.mockImplementation(async (_id: string, action: string) => {
if (action === "Resuming execution after unpause" && !emittedUpdateFromLog) {
emittedUpdateFromLog = true;
store._trigger("task:updated", { ...task, updatedAt: new Date().toISOString() });
}
});
new TaskExecutor(store, "/tmp/test");
store._trigger("task:updated", task);
await new Promise((r) => setTimeout(r, 50));
const resumeLogCalls = store.logEntry.mock.calls.filter(
([id, action]: [string, string]) => id === "FN-001" && action === "Resuming execution after unpause",
);
expect(resumeLogCalls).toHaveLength(1);
});
it("does not resurrect a failed in-progress task when an unrelated update is emitted", async () => {
const store = createMockStore();
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
}) as any);
const _executor = new TaskExecutor(store, "/tmp/test");
store._trigger("task:updated", {
id: "FN-001",
paused: undefined,
column: "in-progress",
status: "failed",
error: "Request was aborted.",
description: "Test task",
title: "Resumed task",
dependencies: [],
steps: [],
currentStep: 0,
comments: [{ id: "oversight-1", text: "[planner-oversight] inject guidance", author: "agent" }],
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await new Promise((r) => setTimeout(r, 30));
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: null, error: null });
expect(store.logEntry).not.toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined);
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("clears stale failed state before resuming orphaned in-progress task", async () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([
{
id: "FN-001",
column: "in-progress",
paused: false,
status: "failed",
error: "Request was aborted.",
title: "Active task",
steps: [],
description: "",
dependencies: [],
},
]);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resumed after engine restart");
});
it("does not duplicate execution when unpausing already-executing task", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate rapid unpause during execution — should NOT start a second run
store._trigger("task:updated", {
id: "FN-001",
paused: undefined,
column: "in-progress",
});
// Wait a bit to let the unpause handler run
await new Promise((r) => setTimeout(r, 10));
}),
dispose: disposeFn,
},
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Already executing",
description: "Test no duplicate",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// At least two agent creations (initial + retry without fn_task_done), but no duplicate from the unpause event
// (async worktree validation may allow additional retry cycles)
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("does not resume unpaused task that is not in-progress", async () => {
const store = createMockStore();
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
}) as any);
const _executor = new TaskExecutor(store, "/tmp/test");
// Unpause a todo task — executor should NOT try to execute it
store._trigger("task:updated", {
id: "FN-001",
paused: undefined,
column: "todo",
});
await new Promise((r) => setTimeout(r, 20));
// No agent should have been created
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("does not resume unpaused task that still has an active session", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
let promptResolve: () => void;
const promptPromise = new Promise<void>((r) => { promptResolve = r; });
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate unpause while session is still active (should be a no-op)
store._trigger("task:updated", {
id: "FN-001",
paused: undefined,
column: "in-progress",
});
await new Promise((r) => setTimeout(r, 10));
}),
dispose: disposeFn,
},
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
// Start execution — session will be active
const executePromise = executor.execute({
id: "FN-001",
title: "Active session",
description: "Test active session unpause",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await executePromise;
// Four agent sessions (initial + 3 retries without fn_task_done) — the unpause during active session was a no-op
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
});
it("uses SessionManager.create for fresh execution and persists sessionFile", async () => {
const store = createMockStore();
const sessionFilePath = "/tmp/sessions/session_123.jsonl";
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
sessionFile: sessionFilePath,
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Fresh task",
description: "Test fresh session",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Should use SessionManager.create for fresh execution
expect(mockedSessionManager.create).toHaveBeenCalledWith(
expect.stringContaining(".worktrees"),
);
expect(mockedSessionManager.open).not.toHaveBeenCalled();
// Should persist the session file path on the task
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { sessionFile: sessionFilePath });
});
it("uses SessionManager.open to resume session when task has sessionFile", async () => {
const store = createMockStore();
const sessionFilePath = "/tmp/sessions/session_123.jsonl";
const resumePromptFn = vi.fn().mockResolvedValue(undefined);
// existsSync must return true for the session file
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: resumePromptFn,
dispose: vi.fn(),
},
sessionFile: sessionFilePath,
} as any);
/*
FNXC:EngineTests 2026-07-19-04:05 (U10b):
Resume state is read from the STORE row, not from the Task literal handed to execute(): the graph re-reads the card at its execute node.
Seed the persisted sessionFile + worktree so the run is a genuine resume; without them the row looks worktree-less and the executor correctly recovers by minting a fresh worktree and a fresh session.
*/
store._setRow("FN-001", {
sessionFile: sessionFilePath,
worktree: "/tmp/test/.worktrees/fn-001",
branch: "fusion/fn-001",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Resumed task",
description: "Test session resume",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
sessionFile: sessionFilePath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Should use SessionManager.open for the initial resumed execution
expect(mockedSessionManager.open).toHaveBeenCalledWith(sessionFilePath);
// The first createFnAgent call should use the opened session manager
const firstCall = mockedCreateFnAgent.mock.calls[0][0] as any;
expect(firstCall.sessionManager).toBeDefined();
// The log should indicate resume
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("Resumed agent session after unpause"),
undefined,
expect.objectContaining({ agentId: "executor" }),
);
});
it("preserves sessionFile when task is paused (graceful exit)", async () => {
const store = createMockStore();
const sessionFilePath = "/tmp/sessions/session_456.jsonl";
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate pause — session ends gracefully
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
}),
dispose: vi.fn(),
},
sessionFile: sessionFilePath,
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Pauseable task",
description: "Test session file preserved on pause",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Session file should NOT be cleared when paused
const clearCalls = store.updateTask.mock.calls.filter(
(call: any[]) => call[0] === "FN-001" && call[1]?.sessionFile === null,
);
expect(clearCalls.length).toBe(0);
// Task should be moved to todo (ready for resume) with preserveResumeState
// so step progress and the worktree survive the pause→unpause hop.
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true });
});
it("falls back to fresh session when sessionFile no longer exists on disk", async () => {
const store = createMockStore();
const staleSessionFile = "/tmp/sessions/deleted_session.jsonl";
// Session file does NOT exist on disk
mockedExistsSync.mockImplementation(
(p) => p !== staleSessionFile,
);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
sessionFile: "/tmp/sessions/new_session.jsonl",
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Stale session",
description: "Test stale session file fallback",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
sessionFile: staleSessionFile,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Should fall back to SessionManager.create (not open)
expect(mockedSessionManager.create).toHaveBeenCalled();
expect(mockedSessionManager.open).not.toHaveBeenCalled();
});
it("does not resume stale sessionFile when persisted worktree path mismatches live task worktree", async () => {
const store = createMockStore();
const sessionFilePath = "/tmp/fn-4031-stale-session.jsonl";
await writeFile(sessionFilePath, JSON.stringify({ cwd: "/tmp/test/.worktrees/bright-wren" }), "utf-8");
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
sessionFile: "/tmp/sessions/new_session.jsonl",
} as any);
/*
FNXC:EngineTests 2026-07-19-04:12 (U10b):
The invariant is "a persisted session whose recorded cwd is NOT the task's live worktree must never be resumed" — the mismatch, not a missing worktree, is what must reject the resume.
The graph re-reads the card from the store, so the live worktree/sessionFile are seeded on the row; the worktree exists on disk (existsSync true) so the only reason to refuse the resume is the cwd mismatch under test.
*/
store._setRow("FN-001", {
sessionFile: sessionFilePath,
worktree: "/tmp/test/.worktrees/fn-001",
branch: "fusion/fn-001",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Stale resumed session",
description: "Test stale worktree session mismatch fallback",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
worktree: "/tmp/test/.worktrees/fn-001",
sessionFile: sessionFilePath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(mockedSessionManager.open).not.toHaveBeenCalled();
expect(mockedSessionManager.create).toHaveBeenCalledWith("/tmp/test/.worktrees/fn-001");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { sessionFile: null });
await rm(sessionFilePath, { force: true });
});
});
describe("swallowed async store failure observability", () => {
beforeEach(() => {
resetExecutorMocks();
mockedWithRateLimitRetry.mockImplementation((fn: () => Promise<unknown>) => fn());
});
/*
* FNXC:StepLifecycle 2026-07-22-10:30:
* A legacy inversion leaves the target in-progress even when the predecessor guard rejects
* its restart. The executor must consume the atomic verdict instead of inferring acceptance
* from that unchanged target status.
*/
it("turns a blocked corrupted in-progress start into a failed step-session result", async () => {
const store = createMockStore();
const task = {
id: "FN-8490",
title: "Ordered step start",
description: "Do not execute a rejected later step",
column: "in-progress" as const,
dependencies: [] as string[],
steps: [
{ name: "Step 0", status: "in-progress" as const },
{ name: "Step 1", status: "in-progress" as const },
],
currentStep: 0,
log: [] as any[],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n### Step 1: Implement\n- [ ] build",
worktree: "/tmp/test/.worktrees/fn-8490",
baseCommitSha: "abc123",
enabledWorkflowSteps: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15_000,
groupOverlappingFiles: false,
autoMerge: false,
runStepsInNewSessions: true,
maxParallelSteps: 1,
});
store.getTask.mockResolvedValue(task);
store.startStep
.mockResolvedValueOnce({
task,
accepted: true,
disposition: "resumed",
})
.mockResolvedValueOnce({
task,
accepted: false,
disposition: "blocked",
blockingStepIndex: 0,
});
mockExecuteAll.mockImplementation(async () => {
const options = mockedStepSessionExecutor.mock.calls.at(-1)?.[0] as {
onStepStart?: (stepIndex: number) => Promise<void | boolean>;
};
const accepted = await options.onStepStart?.(1);
return accepted === false
? [{ stepIndex: 1, success: false, error: "start rejected", retries: 0 }]
: [{ stepIndex: 1, success: true, retries: 0 }];
});
const onError = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test", { onError });
await executor.execute(task);
expect(store.startStep).toHaveBeenLastCalledWith("FN-8490", 1, undefined);
expect(
store.updateStep.mock.calls.some(
([taskId, stepIndex, status]) => taskId === "FN-8490" && stepIndex === 0 && status === "done",
),
).toBe(false);
expect(store.moveTask).toHaveBeenCalledWith(
"FN-8490",
"todo",
expect.objectContaining({ preserveProgress: true, recoveryRehome: true }),
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ id: "FN-8490" }),
expect.objectContaining({ message: "Step 1: start rejected" }),
);
});
it("logs warning when rate-limit retry logEntry fails in step-session mode", async () => {
const warnSpy = vi.spyOn(executorLog, "warn");
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
runStepsInNewSessions: true,
maxParallelSteps: 2,
});
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Rate-limit step-session task",
description: "Rate-limit diagnostics",
column: "in-progress",
dependencies: [],
steps: [{ name: "Step 0", status: "pending" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
baseCommitSha: "abc123",
enabledWorkflowSteps: [],
});
/*
FNXC:EngineTests 2026-07-19-04:25 (U10b):
A stubbed step-session run must record the step state a real one records: the graph's `steps#N:step-execute` node re-reads the projection and refuses to advance a step left `pending`.
Marking the step done keeps this test about the swallowed rate-limit log failure instead of silently terminating the run one node earlier.
*/
mockExecuteAll.mockImplementation(async () => {
await store.updateStep("FN-001", 0, "done");
return [{ stepIndex: 0, success: true, retries: 0 }];
});
store.logEntry.mockImplementation(async (_taskId: string, message: string) => {
if (message.includes("Rate limited — retry")) {
throw new Error("step-session retry log failure");
}
return undefined;
});
mockedWithRateLimitRetry.mockImplementationOnce((async (
fn: () => Promise<unknown>,
options?: { onRetry?: (attempt: number, delayMs: number, error: Error) => void },
) => {
options?.onRetry?.(2, 4_000, new Error("rate limit"));
return fn();
}) as typeof withRateLimitRetry);
const executor = new TaskExecutor(store, "/tmp/test");
await expect(executor.execute({
id: "FN-001",
title: "Rate-limit step-session task",
description: "Rate-limit diagnostics",
column: "in-progress",
dependencies: [],
steps: [{ name: "Step 0", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})).resolves.toBeUndefined();
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("FN-001 failed to log rate-limit retry: step-session retry log failure"),
);
warnSpy.mockRestore();
});
it("logs warning when rate-limit retry logEntry fails in main-agent mode", async () => {
const warnSpy = vi.spyOn(executorLog, "warn");
const store = createMockStore();
let capturedCustomTools: Array<{ name: string; execute: (callId: string, args: Record<string, unknown>) => Promise<unknown> }> = [];
store.logEntry.mockImplementation(async (_taskId: string, message: string) => {
if (message.includes("Rate limited — retry")) {
throw new Error("main-agent retry log failure");
}
return undefined;
});
mockedCreateFnAgent.mockImplementation((async (opts: { customTools?: typeof capturedCustomTools }) => {
capturedCustomTools = opts.customTools ?? [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool) => tool.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
sessionFile: "/tmp/sessions/main-agent-rate-limit.jsonl",
};
}) as any);
mockedWithRateLimitRetry.mockImplementationOnce((async (
fn: () => Promise<unknown>,
options?: { onRetry?: (attempt: number, delayMs: number, error: Error) => void },
) => {
options?.onRetry?.(1, 3_000, new Error("rate limit"));
return fn();
}) as typeof withRateLimitRetry);
const executor = new TaskExecutor(store, "/tmp/test");
await expect(executor.execute({
id: "FN-001",
title: "Rate-limit main-agent task",
description: "Rate-limit diagnostics",
column: "in-progress",
dependencies: [],
steps: [{ name: "Step 0", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})).resolves.toBeUndefined();
/*
FNXC:EngineTests 2026-07-19-03:18 (U10b):
A swallowed rate-limit logEntry failure must be observable in the log AND must not derail the run's handoff to review.
The handoff is now the graph's merge boundary, so the move carries the workflow-graph provenance instead of being a bare completion-path move.
*/
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("FN-001 failed to log rate-limit retry: main-agent retry log failure"),
);
warnSpy.mockRestore();
});
it("logs warning when sessionFile update fails during retry", async () => {
const warnSpy = vi.spyOn(executorLog, "warn");
const store = createMockStore();
const retrySessionFilePath = "/tmp/sessions/retry-failed.jsonl";
/*
FNXC:EngineTests 2026-07-19-04:45 (U10b):
Only the retry sessionFile write may fail; every other write must still land on the row.
The graph re-reads the card between nodes, so a blanket `updateTask` stub that swallows all writes leaves the row without the worktree/step state the run just persisted and the graph terminates before the retry under test ever happens.
Delegate to the harness's write-through implementation for everything except the write being sabotaged.
*/
const passThroughUpdateTask = store.updateTask.getMockImplementation()!;
store.updateTask.mockImplementation(async (taskId: string, patch: Record<string, unknown>) => {
if (patch?.sessionFile === retrySessionFilePath) {
throw new Error("retry sessionFile write failed");
}
return passThroughUpdateTask(taskId, patch);
});
// First implementation session persists cleanly; every retry session carries the sabotaged path.
let agentCall = 0;
mockedCreateFnAgent.mockImplementation((async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
sessionFile: agentCall++ === 0 ? "/tmp/sessions/initial.jsonl" : retrySessionFilePath,
})) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await expect(executor.execute({
id: "FN-001",
title: "Retry session task",
description: "Session retry diagnostics",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})).resolves.toBeUndefined();
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("FN-001 failed to persist retry sessionFile: retry sessionFile write failed"),
);
warnSpy.mockRestore();
});
it("logs warning when sessionFile clear fails on completion", async () => {
const warnSpy = vi.spyOn(executorLog, "warn");
const store = createMockStore();
let capturedCustomTools: any[] = [];
/*
FNXC:EngineTests 2026-07-19-04:47 (U10b):
Only the sessionFile CLEAR may fail; the graph re-reads the card between nodes, so every other write must still land on the row or the run never reaches the completion that triggers the clear.
*/
const passThroughUpdateTask = store.updateTask.getMockImplementation()!;
store.updateTask.mockImplementation(async (taskId: string, patch: Record<string, unknown>) => {
if (patch?.sessionFile === null) {
throw new Error("session clear failed");
}
return passThroughUpdateTask(taskId, patch);
});
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
sessionFile: "/tmp/sessions/clear-test.jsonl",
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await expect(executor.execute({
id: "FN-001",
title: "Session clear task",
description: "Session clear diagnostics",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})).resolves.toBeUndefined();
/*
FNXC:EngineTests 2026-07-19-03:19 (U10b):
A failed sessionFile clear must warn but must not block the run's handoff to review; that handoff is now the graph's merge boundary, so the move carries workflow-graph provenance.
*/
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("FN-001 failed to clear sessionFile: session clear failed"),
);
warnSpy.mockRestore();
});
it("logs warning when child agent deletion fails during cleanup", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(executorLog, "warn");
try {
const store = createMockStore();
const agentStore = {
updateAgentState: vi.fn().mockResolvedValue(undefined),
deleteAgent: vi.fn().mockRejectedValue(new Error("delete failed")),
};
const executor = new TaskExecutor(store, "/tmp/test", {
agentStore: agentStore as any,
});
(executor as any).childSessions.set("child-007", {
dispose: vi.fn(),
});
await (executor as any).terminateChildAgent("child-007");
await Promise.resolve();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete spawned agent child-007: delete failed"),
);
} finally {
warnSpy.mockRestore();
vi.useRealTimers();
}
});
});
describe("TaskExecutor executor model hot-swap", () => {
const buildUpdatedTask = (overrides: Partial<Task> = {}): Task => ({
id: "FN-001",
title: "Model task",
description: "Test model updates",
column: "in-progress",
paused: false,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
const flushTaskUpdated = async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
};
beforeEach(() => {
resetExecutorMocks();
});
it("hot-swaps executor model on active session when modelProvider/modelId change", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
const findModel = vi.fn().mockReturnValue({
provider: { name: "openai" },
id: "gpt-4o",
name: "GPT-4o",
});
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
modelProvider: "openai",
modelId: "gpt-4o",
}));
await flushTaskUpdated();
expect(setModel).toHaveBeenCalledTimes(1);
expect(setModel).toHaveBeenCalledWith(expect.objectContaining({
provider: expect.objectContaining({ name: "openai" }),
id: "gpt-4o",
}));
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Model changed to openai/gpt-4o", undefined, undefined);
});
it("does not attempt hot-swap when no active session exists", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
new TaskExecutor(store, "/tmp/test");
store._trigger("task:updated", buildUpdatedTask({
modelProvider: "openai",
modelId: "gpt-4o",
}));
await flushTaskUpdated();
expect(setModel).not.toHaveBeenCalled();
});
it("does not hot-swap when model fields are unchanged", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
const findModel = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}));
await flushTaskUpdated();
expect(findModel).not.toHaveBeenCalled();
expect(setModel).not.toHaveBeenCalled();
});
it("hot-swaps to project default override when task override is cleared", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
const findModel = vi.fn().mockReturnValue({
provider: { name: "openai" },
id: "gpt-4o",
name: "GPT-4o",
});
store.getSettings.mockResolvedValue({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
modelProvider: undefined,
modelId: undefined,
}));
await flushTaskUpdated();
expect(findModel).toHaveBeenCalledWith("openai", "gpt-4o");
expect(setModel).toHaveBeenCalledTimes(1);
});
it("falls back to global default when project default override pair is incomplete", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
const findModel = vi.fn().mockReturnValue({
provider: { name: "anthropic" },
id: "claude-sonnet-4-5",
name: "Claude Sonnet",
});
store.getSettings.mockResolvedValue({
executionProvider: undefined,
executionModelId: undefined,
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
defaultProviderOverride: "openai",
defaultModelIdOverride: undefined,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastResolvedModelProvider: "openai",
lastResolvedModelId: "gpt-4o",
lastTaskModelProvider: "openai",
lastTaskModelId: "gpt-4o",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
modelProvider: undefined,
modelId: undefined,
}));
await flushTaskUpdated();
expect(findModel).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-5");
expect(setModel).toHaveBeenCalledTimes(1);
});
it("logs error and continues when setModel fails", async () => {
const store = createMockStore();
const setModel = vi.fn().mockRejectedValue(new Error("API key not found"));
const findModel = vi.fn().mockReturnValue({
provider: { name: "openai" },
id: "gpt-4o",
name: "GPT-4o",
});
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set(),
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
modelProvider: "openai",
modelId: "gpt-4o",
}));
await flushTaskUpdated();
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Model change failed: API key not found", undefined, undefined);
expect((executor as any).activeSessions.has("FN-001")).toBe(true);
});
it("does not attempt hot-swap on paused task", async () => {
const store = createMockStore();
const setModel = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
const findModel = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any)._modelRegistry = { find: findModel };
(executor as any).activeSessions.set("FN-001", {
session: { setModel, dispose },
seenSteeringIds: new Set(),
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-sonnet-4-5",
lastTaskModelProvider: "anthropic",
lastTaskModelId: "claude-sonnet-4-5",
lastAssignedAgentId: null,
});
store._trigger("task:updated", buildUpdatedTask({
paused: true,
modelProvider: "openai",
modelId: "gpt-4o",
}));
await flushTaskUpdated();
expect(dispose).toHaveBeenCalledTimes(1);
expect(findModel).not.toHaveBeenCalled();
expect(setModel).not.toHaveBeenCalled();
});
});
describe("TaskExecutor task:updated listener guards", () => {
it("catches and logs errors from async task:updated operations", async () => {
const store = createMockStore();
const terminateError = new Error("terminate failed");
const terminateAllSessions = vi.fn().mockRejectedValue(terminateError);
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).activeStepExecutors.set("FN-001", {
terminateAllSessions,
});
const taskUpdatedHandler = (store.on as unknown as ReturnType<typeof vi.fn>).mock.calls
.find((call: any[]) => call[0] === "task:updated")?.[1];
expect(taskUpdatedHandler).toBeTypeOf("function");
await expect(taskUpdatedHandler({
id: "FN-001",
title: "Guard test",
description: "Guard test",
column: "in-progress",
paused: true,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} satisfies Task)).resolves.toBeUndefined();
expect(terminateAllSessions).toHaveBeenCalledTimes(1);
// FN-5256: the pause handler now routes through awaitAbortInFlightTaskWork,
// which internally catches/logs the per-surface failure. The error still hits
// executorLog.error but via the granular message path.
expect(executorLog.error).toHaveBeenCalledWith("Failed to terminate step sessions for FN-001:", terminateError);
});
});
/*
FNXC:EngineTests 2026-07-19-03:24 (U10b):
Completion handoff to `in-review` is the workflow graph's merge boundary, not a completion-path move the executor makes on its own.
Every handoff assertion in this block therefore asserts the graph's provenance on the move (`workflowMoveSource: "workflow-graph"`), so a regression that re-introduces a second, out-of-graph move-to-review authority fails here instead of passing silently.
*/
describe("TaskExecutor global pause behavior", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExistsSync.mockReturnValue(true);
});
it("disposes all active sessions when settings:updated fires with globalPause: true", async () => {
const store = createMockStore();
const disposeFn1 = vi.fn();
const disposeFn2 = vi.fn();
let callCount = 0;
/*
FNXC:WorkflowLifecycle 2026-07-01-20:35:
With workflowGraphExecutor default-on, each task's thrown session error is classified for
pause-provenance at handleGraphFailure time: a throw that lands BEFORE globalPause registers is a
genuine execution failure (parked `failed`), while a throw AFTER the pause is a benign global-pause
abort (moved to todo, progress preserved). The legacy harness fired the pause only inside the
second-created task's prompt, so the first task raced ahead and threw before the pause registered,
landing it `failed` and defeating the disposal invariant under the graph. Gate both prompts on a
two-party barrier so BOTH sessions are genuinely in-flight when the single globalPause fires, then let
both throw — faithfully modeling "global pause aborts every in-flight task to todo without failing it".
*/
// Each session's first prompt blocks on the barrier so BOTH tasks are genuinely in-flight at their
// first graph node (createFnAgent invoked) before the pause fires. The test body detects both
// in-flight, fires the single globalPause, then releases the barrier so both sessions throw AFTER the
// pause is registered — a graph-node count is unreliable because a coding task now traverses several
// agent nodes (planning/plan-review/execute/code-review), so we gate on distinct in-flight task ids.
let releaseBarrier: () => void = () => {};
const barrier = new Promise<void>((resolve) => { releaseBarrier = resolve; });
mockedCreateFnAgent.mockImplementation(async () => {
callCount++;
const dispose = callCount === 1 ? disposeFn1 : disposeFn2;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
await barrier;
throw new Error("Session terminated");
}),
dispose,
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
// Execute two tasks concurrently (do NOT await yet — the prompts block on the barrier).
// Distinct worktrees per task: the active-session registry now rejects two tasks claiming the same
// checkout path, so without unique worktrees FN-002 would fail on a path-collision guard rather than
// exercise the pause-disposal invariant. existsSync is stubbed true (resume) so each stored path is
// reused verbatim instead of regenerating a shared name.
/*
FNXC:EngineTests 2026-07-19-04:58 (U10b):
The per-task worktree must live on the STORE ROW, not only on the literal handed to execute(): the graph re-reads the card, and a row with no worktree looks like drift, so both tasks regenerate the SAME deterministic worktree name and the second one dies on the foreign-path guard instead of exercising pause disposal.
*/
store._setRow("FN-001", { worktree: "/tmp/test/.worktrees/wt-001", branch: "fusion/fn-001" });
store._setRow("FN-002", { worktree: "/tmp/test/.worktrees/wt-002", branch: "fusion/fn-002" });
const run = Promise.all([
executor.execute({
id: "FN-001", title: "T1", description: "T", column: "in-progress",
worktree: "/tmp/test/.worktrees/wt-001", branch: "fusion/fn-001",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}),
executor.execute({
id: "FN-002", title: "T2", description: "T", column: "in-progress",
worktree: "/tmp/test/.worktrees/wt-002", branch: "fusion/fn-002",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}),
]);
// Wait until BOTH tasks have an active in-flight session (registered by execute()), then fire the
// single global pause and release the sessions so their terminations classify as pause aborts.
await vi.waitFor(() => {
if (callCount < 2) throw new Error("waiting for both sessions in-flight");
}, { timeout: 5000 });
store._trigger("settings:updated", {
settings: { globalPause: true },
previous: { globalPause: false },
});
releaseBarrier();
await run;
// Global pause should move both tasks out of in-progress without marking failed.
const moveCalls = store.moveTask.mock.calls;
expect(moveCalls.some(([id, column]) => id === "FN-002" && /^(todo|in-review)$/.test(String(column)))).toBe(true);
expect(moveCalls.some(([id, column]) => id === "FN-001" && /^(todo|in-review)$/.test(String(column)))).toBe(true);
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", { status: "failed" });
});
it("moves paused tasks to todo (not marked as failed)", async () => {
const store = createMockStore();
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockImplementation(async () => {
store._trigger("settings:updated", {
settings: { globalPause: true },
previous: { globalPause: false },
});
throw new Error("Session terminated");
}),
dispose: vi.fn(),
},
} as any));
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001", title: "Test", description: "T", column: "in-progress",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
});
// FNXC:ExecutorMoveTaskOptions 2026-07-12: executor.ts:11622-11625 now always passes a moveTask options object (conditional spreads collapse to {} when nothing to preserve); previously undefined. Intent (not marked failed) unchanged.
/*
FNXC:EngineTests 2026-07-23-21:40 (FN-8464 / #2403):
A global-pause abort must park the task in todo without failing it. Resume state is
preserved only when the run recorded resumable progress; a fresh task's first
implementation pass owns the step projection (startStep is deferred until a real
worktree exists), so a pause during that first session leaves all steps `pending`
and the bounce options collapse to `{}`.
*/
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {});
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
});
it("defers completion handoff when global pause hits after fn_task_done", async () => {
const store = createMockStore();
let globalPause = false;
store.getSettings.mockImplementation(async () => ({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause,
enginePaused: false,
}));
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const customTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("tool-1", {});
}
globalPause = true;
store._trigger("settings:updated", {
settings: { globalPause: true },
previous: { globalPause: false },
});
throw new Error("Session terminated");
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001", title: "Test", description: "T", column: "in-progress",
dependencies: [], steps: [{ name: "Step 1", status: "pending" }], currentStep: 0, log: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
});
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
),
).toBe(true);
});
it("parks todo tasks in in-progress when fn_task_done is called during global pause", async () => {
const store = createMockStore();
let capturedCustomTools: any[] = [];
let taskDoneResult: any;
const todoTask = {
id: "FN-001",
title: "Test",
description: "T",
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
column: "todo",
paused: true,
dependencies: [],
steps: [{ name: "Step 1", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockResolvedValue(todoTask);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause: true,
enginePaused: false,
});
store.moveTask.mockImplementation(async (_id: string, to: string) => ({ ...todoTask, column: to, paused: undefined }));
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
taskDoneResult = await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(todoTask as any);
/*
FNXC:EngineTests 2026-07-23-21:40 (#2371):
User-paused dispatch stops: a paused todo task is no longer dispatched at all —
execute() ends the graph run benignly with the row still parked and paused, so no
agent session exists and `fn_task_done` is unreachable from this shape. The
protective intent survives on the surfaces that remain: the card is never handed to
`in-review` under global pause, no completion watchdog is armed, the pause is never
cleared by the refused dispatch, and the run narrates the benign paused park.
*/
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(taskDoneResult).toBeUndefined();
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.anything(),
);
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("parked in todo — benign, paused awaiting explicit unpause"),
),
).toBe(true);
});
describe("fn_task_done with paused state (FN-3964 / FN-4167 regression)", () => {
it("advances todo + paused tasks through normal completion handoff", async () => {
const store = createMockStore();
let capturedCustomTools: any[] = [];
let taskDoneResult: any;
const todoTask = {
id: "FN-001",
title: "Paused todo task",
description: "T",
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
column: "todo",
paused: true,
pausedByAgentId: "agent-123",
dependencies: [],
steps: [{ name: "Step 1", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockResolvedValue(todoTask);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause: false,
enginePaused: false,
});
store.moveTask.mockImplementation(async (_id: string, to: string) => ({ ...todoTask, column: to, paused: false }));
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
taskDoneResult = await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(todoTask as any);
/*
FNXC:EngineTests 2026-07-23-21:40 (#2371):
User-paused dispatch stops supersede the FN-3964/FN-4167 shape for ALREADY-paused
todo rows: execute() no longer dispatches a paused task, so no agent session is
created and `fn_task_done` cannot fire from this shape. Explicit-completion pause
clearing (FN-4145) still holds for a pause that lands MID-session — covered by
"completes in-progress + paused tasks after clearing task-level pause state".
Here the row must stay parked and paused: no in-review handoff, no watchdog, no
pause clear, and the run narrates the benign paused park.
*/
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(taskDoneResult).toBeUndefined();
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("parked in todo — benign, paused awaiting explicit unpause"),
),
).toBe(true);
// globalPause:true refused-dispatch behavior is intentionally covered by the test above.
});
it("completes in-progress + paused tasks after clearing task-level pause state", async () => {
const store = createMockStore();
let capturedCustomTools: any[] = [];
let taskDoneResult: any;
const inProgressTask = {
id: "FN-001",
title: "Paused in-progress task",
description: "T",
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
column: "in-progress",
paused: true,
pausedByAgentId: "agent-123",
dependencies: [],
steps: [{ name: "Step 1", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockResolvedValue(inProgressTask);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause: false,
enginePaused: false,
});
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
taskDoneResult = await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(inProgressTask as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
paused: false,
pausedByAgentId: null,
status: null,
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion.
bulkCompletionRefusalAt: null,
});
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
),
).toBe(false);
expect(taskDoneResult.content[0].text).toBe(
"Task marked complete with summary. All steps done. Moving to in-review.",
);
// globalPause:true deferred behavior is intentionally covered by
// "parks todo tasks in in-progress when fn_task_done is called during global pause".
});
});
describe("fn_task_done with paused state (FN-3964 / FN-4167 regression)", () => {
it("advances todo + paused tasks through normal completion handoff", async () => {
const store = createMockStore();
let capturedCustomTools: any[] = [];
const todoTask = {
id: "FN-001",
title: "Paused todo task",
description: "T",
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
column: "todo",
paused: true,
pausedByAgentId: "agent-123",
dependencies: [],
steps: [{ name: "Step 1", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockResolvedValue(todoTask);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause: false,
enginePaused: false,
});
store.moveTask.mockImplementation(async (_id: string, to: string) => ({ ...todoTask, column: to, paused: false }));
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(todoTask as any);
/*
FNXC:EngineTests 2026-07-23-21:40 (#2371):
Same paused-dispatch-stop contract as the sibling describe: an already-paused todo
row is never dispatched, `fn_task_done` is unreachable, the pause is preserved, and
the run parks benignly in todo.
*/
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("parked in todo — benign, paused awaiting explicit unpause"),
),
).toBe(true);
// globalPause:true refused-dispatch behavior is intentionally covered by the test above.
});
it("completes in-progress + paused tasks after clearing task-level pause state", async () => {
const store = createMockStore();
let capturedCustomTools: any[] = [];
const inProgressTask = {
id: "FN-001",
title: "Paused in-progress task",
description: "T",
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
column: "in-progress",
paused: true,
pausedByAgentId: "agent-123",
dependencies: [],
steps: [{ name: "Step 1", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockResolvedValue(inProgressTask);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
autoMerge: false,
globalPause: false,
enginePaused: false,
});
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(inProgressTask as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
paused: false,
pausedByAgentId: null,
status: null,
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion.
bulkCompletionRefusalAt: null,
});
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
),
).toBe(false);
// globalPause:true deferred behavior is intentionally covered by
// "parks todo tasks in in-progress when fn_task_done is called during global pause".
});
});
it("takes no action when globalPause remains false", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
let capturedCustomTools: any[] = [];
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
store._trigger("settings:updated", {
settings: { globalPause: false },
previous: { globalPause: false },
});
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: disposeFn,
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001", title: "Test", description: "T", column: "in-progress",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
});
// Should move to in-review (normal completion), not todo
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
});
it("takes no action when globalPause transitions from true to true", async () => {
const store = createMockStore();
let capturedCustomTools: any[] = [];
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
store._trigger("settings:updated", {
settings: { globalPause: true },
previous: { globalPause: true },
});
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
if (taskDoneTool) {
await taskDoneTool.execute("call-1", { summary: "done" });
}
}),
dispose: vi.fn(),
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001", title: "Test", description: "T", column: "in-progress",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
});
// Should move to in-review (normal completion), not todo
expect(store.moveTask).toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
});
});
describe("fn_task_update bare-call guard (P1 api-contract)", () => {
// createTaskUpdateTool is a private executor method; the bare-call guard runs
// before any store access, so we reach it via the lowest-cost seam: construct
// a TaskExecutor over a mock store and invoke the private method with `as any`.
function makeTool(store = createMockStore()) {
const executor = new TaskExecutor(store, "/tmp/test");
return { store, tool: (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }) };
}
it("returns isError with a self-describing message when no fields are supplied", async () => {
const { tool } = makeTool();
const result = await tool.execute("call-1", {});
expect(result.isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("fn_task_update requires at least one of");
// The legacy no-op text is preserved as the detail.
expect(text).toContain("No-op: provide a step+status, dependencies, or custom_fields to update.");
});
it("does not trigger the guard when a dependencies-only patch is supplied", async () => {
const { tool } = makeTool();
const result = await tool.execute("call-1", { dependencies: [] });
// Reaches the dependencies path, not the bare-call guard.
expect(result.isError).not.toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).not.toContain("fn_task_update requires at least one of");
});
it("accepts a store-accepted skipped transition without tool-side agent-log narration", async () => {
/*
FNXC:ProactiveChatStatus 2026-07-18-12:40:
FN-8064 moved step start/success/skip narration into TaskStore.updateStep (merge-queue-ops)
so workflow projection, review auto-approval, and self-healing share the same chat rows.
fn_task_update only reports progress text; appendAgentLog is store-owned when
proactiveTaskChatEnabled is true. Covered by packages/core proactive-step-status.pg.test.ts.
*/
const { store, tool } = makeTool();
store.updateStep.mockResolvedValue(createMockTaskDetail({
steps: [{ name: "No code change needed", status: "skipped", dependsOn: [] }],
}));
const result = await tool.execute("call-1", { step: 0, status: "skipped" });
expect(result.isError).not.toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Step 0");
expect(text).toContain("skipped");
expect(store.appendAgentLog).not.toHaveBeenCalled();
});
// FNXC:StepLifecycle 2026-07-22-09:50: Rejected starts must clearly preserve
// lifecycle invariants so agents do not execute work for a pending step.
it("explains that a rejected out-of-order start preserves lifecycle invariants", async () => {
const { store, tool } = makeTool();
store.getTask.mockResolvedValue(createMockTaskDetail({
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "pending" },
],
}));
store.updateStep.mockResolvedValue(createMockTaskDetail({
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "pending" },
],
}));
const result = await tool.execute("call-1", { step: 1, status: "in-progress" });
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("remains pending");
expect(text).toContain("ignored to preserve step lifecycle invariants");
});
});
// ---------------------------------------------------------------------------
// Runtime self-awareness preamble (FN-7675)
// ---------------------------------------------------------------------------
describe("executor base prompt runtime self-awareness", () => {
it("prepends the shared FUSION_RUNTIME_SELF_AWARENESS preamble to the executor base prompt", async () => {
const { FUSION_RUNTIME_SELF_AWARENESS } = await import("@fusion/core");
const { getExecutorSystemPrompt } = await import("../executor.js");
const settings = { agentPrompts: undefined } as any;
const prompt = getExecutorSystemPrompt(settings);
expect(prompt.startsWith(FUSION_RUNTIME_SELF_AWARENESS)).toBe(true);
});
it("carries the shutdown-boundary clauses", async () => {
const { getExecutorSystemPrompt } = await import("../executor.js");
const settings = { agentPrompts: undefined } as any;
const lower = getExecutorSystemPrompt(settings).toLowerCase();
expect(lower).toContain("cannot** perform any action after fusion is shut down".toLowerCase());
expect(lower).toContain("standalone artifact the user runs themselves");
});
it("stays byte-identical with the core EXECUTOR_PROMPT_TEXT mirror at the shared preamble", async () => {
const { FUSION_RUNTIME_SELF_AWARENESS } = await import("@fusion/core");
const { readFileSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
const executorSource = readFileSync(new URL("../executor.ts", import.meta.url), "utf8");
expect(executorSource).toContain("const EXECUTOR_SYSTEM_PROMPT = `${FUSION_RUNTIME_SELF_AWARENESS}");
expect(FUSION_RUNTIME_SELF_AWARENESS.length).toBeGreaterThan(0);
});
it("lands the preamble in the stable (cacheable) layer via buildPromptLayers", async () => {
const { FUSION_RUNTIME_SELF_AWARENESS } = await import("@fusion/core");
const { getExecutorSystemPrompt } = await import("../executor.js");
const { buildPromptLayers } = await import("../prompt-layers.js");
const settings = { agentPrompts: undefined } as any;
const basePrompt = getExecutorSystemPrompt(settings);
const layers = buildPromptLayers({
basePrompt,
agentInstructions: "per-session instructions that must not affect the stable prefix",
});
expect(layers.stable).toBe(basePrompt);
expect(layers.stable.startsWith(FUSION_RUNTIME_SELF_AWARENESS)).toBe(true);
expect(layers.dynamic).not.toContain(FUSION_RUNTIME_SELF_AWARENESS);
});
});