fix(FN-1572): stabilize fusion agent execution

This commit is contained in:
gsxdsm
2026-04-17 12:13:39 -07:00
parent 0515882da1
commit ef696f4564
29 changed files with 657 additions and 283 deletions

View File

@@ -4,7 +4,7 @@
### Minor Changes
- 2d13b82: Add pi extension. Installing `@gsxdsm/fusion` via `pi install` now provides native tools (`kb_task_create`, `kb_task_list`, `kb_task_show`, `kb_task_attach`, `kb_task_pause`, `kb_task_unpause`) and a `/kb` command to start the dashboard and AI engine from within a pi session.
- 2d13b82: Add pi extension. Installing `@gsxdsm/fusion` via `pi install` now provides native tools (`fn_task_create`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`) and a `/fn` command to start the dashboard and AI engine from within a pi session.
- 494de14: Changed `autoMerge` to default to `true` for new boards.
- 50821fc: Add global pause button to stop all automated agents and scheduling
- cac10af: Split engine control into Pause (soft) and Stop (hard). The dashboard Header now shows two buttons: "Pause AI engine" stops new work from being dispatched while letting in-flight agents finish gracefully, and "Stop AI engine" (previously the only Pause button) immediately kills all active agent sessions. A new `enginePaused` setting field controls the soft-pause state alongside the existing `globalPause` hard-stop.

View File

@@ -11,7 +11,7 @@ Create a new task on the Fusion board. Enters triage for AI specification.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `description` | string | ✓ | What needs to be done — be descriptive |
| `depends` | string[] | — | Task IDs this depends on (e.g., ["KB-001"]) |
| `depends` | string[] | — | Task IDs this depends on (e.g., ["FN-001"]) |
Returns: task ID, column, dependencies, path
@@ -21,7 +21,7 @@ Update fields on an existing task (title, description, dependencies).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID (e.g., KB-001) |
| `id` | string | ✓ | Task ID (e.g., FN-001) |
| `title` | string | — | New task title |
| `description` | string | — | New task description |
| `depends` | string[] | — | New dependency list — replaces existing |
@@ -45,7 +45,7 @@ Show full task details including steps, progress, prompt preview, and log.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID (e.g., KB-001) |
| `id` | string | ✓ | Task ID (e.g., FN-001) |
Returns: task details with steps, prompt preview (500 chars), last 5 log entries
@@ -244,7 +244,7 @@ Link a feature to a kb task. Updates feature status to triaged.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `featureId` | string | ✓ | Feature ID (e.g., F-001) |
| `taskId` | string | ✓ | Task ID (e.g., KB-001) |
| `taskId` | string | ✓ | Task ID (e.g., FN-001) |
### fn_agent_stop

View File

@@ -136,7 +136,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_create",
label: "KB: Create Task",
label: "fn: Create Task",
description:
"Create a new task on the Fusion task board. The task enters the triage column " +
"where the AI triage agent will specify it into a full prompt with steps, " +
@@ -150,7 +150,7 @@ export default function kbExtension(pi: ExtensionAPI) {
description: Type.String({ description: "What needs to be done — be descriptive" }),
depends: Type.Optional(
Type.Array(Type.String(), {
description: "Task IDs this depends on (e.g. ['KB-001', 'KB-002'])",
description: "Task IDs this depends on (e.g. ['FN-001', 'FN-002'])",
}),
),
agentId: Type.Optional(
@@ -203,7 +203,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_update",
label: "KB: Update Task",
label: "fn: Update Task",
description:
"Update fields on an existing task. Supports modifying the title, " +
"description, dependencies, and assigned agent after task creation.",
@@ -213,12 +213,12 @@ export default function kbExtension(pi: ExtensionAPI) {
"At least one field must be provided to update.",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
title: Type.Optional(Type.String({ description: "New task title" })),
description: Type.Optional(Type.String({ description: "New task description" })),
depends: Type.Optional(
Type.Array(Type.String(), {
description: "New dependency list — replaces existing dependencies (e.g. ['KB-001', 'KB-002'])",
description: "New dependency list — replaces existing dependencies (e.g. ['FN-001', 'FN-002'])",
}),
),
agentId: Type.Optional(
@@ -293,7 +293,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_list",
label: "KB: List Tasks",
label: "fn: List Tasks",
description: "List all tasks on the Fusion board, grouped by column.",
promptSnippet: "List all tasks on the Fusion board grouped by column",
parameters: Type.Object({
@@ -351,11 +351,11 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_show",
label: "KB: Show Task",
label: "fn: Show Task",
description: "Show full details for a task including steps, progress, and log entries.",
promptSnippet: "Show full details for a Fusion task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -430,13 +430,13 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_attach",
label: "KB: Attach File",
label: "fn: Attach File",
description:
"Attach a file to a task. Supports images (png, jpg, gif, webp) and " +
"text files (txt, log, json, yaml, yml, toml, csv, xml).",
promptSnippet: "Attach a file to a Fusion task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
path: Type.String({ description: "Path to the file to attach" }),
}),
@@ -481,12 +481,12 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_pause",
label: "KB: Pause Task",
label: "fn: Pause Task",
description:
"Pause a task — stops all automated agent and scheduler interaction for this task.",
promptSnippet: "Pause a Fusion task (stops automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -504,12 +504,12 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_unpause",
label: "KB: Unpause Task",
label: "fn: Unpause Task",
description:
"Unpause a task — resumes automated agent and scheduler interaction.",
promptSnippet: "Unpause a Fusion task (resumes automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -527,7 +527,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_retry",
label: "KB: Retry Task",
label: "fn: Retry Task",
description:
"Retry a failed task — clears the error state and moves it back to the todo column for re-execution.",
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo)",
@@ -537,7 +537,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"The task will be moved to the todo column with error state cleared",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to retry (e.g. KB-001). Must be in 'failed' state." }),
id: Type.String({ description: "Task ID to retry (e.g. FN-001). Must be in 'failed' state." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -584,7 +584,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_duplicate",
label: "KB: Duplicate Task",
label: "fn: Duplicate Task",
description:
"Duplicate an existing task, creating a fresh copy in triage. " +
"Copies the title and description but resets all execution state. " +
@@ -596,7 +596,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Dependencies, attachments, and execution state are NOT copied",
],
parameters: Type.Object({
id: Type.String({ description: "Source task ID to duplicate (e.g. KB-001)" }),
id: Type.String({ description: "Source task ID to duplicate (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -614,7 +614,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_refine",
label: "KB: Refine Task",
label: "fn: Refine Task",
description:
"Request a refinement of a completed or in-review task. " +
"Creates a new follow-up task in triage that references the original task as a dependency. " +
@@ -627,7 +627,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Provide clear feedback about what needs to be refined or improved",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to refine (e.g. KB-001). Must be in 'done' or 'in-review' column." }),
id: Type.String({ description: "Task ID to refine (e.g. FN-001). Must be in 'done' or 'in-review' column." }),
feedback: Type.String({
description: "Description of what needs to be refined or improved",
minLength: 1,
@@ -652,7 +652,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_archive",
label: "KB: Archive Task",
label: "fn: Archive Task",
description:
"Archive a done task (move from done → archived). " +
"Archived tasks are preserved for historical reference but moved out of the main board view.",
@@ -663,7 +663,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Archived tasks can be unarchived later if needed",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to archive (e.g. KB-001). Must be in 'done' column." }),
id: Type.String({ description: "Task ID to archive (e.g. FN-001). Must be in 'done' column." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -681,7 +681,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_unarchive",
label: "KB: Unarchive Task",
label: "fn: Unarchive Task",
description:
"Unarchive an archived task (move from archived → done). " +
"Restores the task to the done column.",
@@ -691,7 +691,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Only tasks in the 'archived' column can be unarchived",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to unarchive (e.g. KB-001). Must be in 'archived' column." }),
id: Type.String({ description: "Task ID to unarchive (e.g. FN-001). Must be in 'archived' column." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -709,7 +709,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_delete",
label: "KB: Delete Task",
label: "fn: Delete Task",
description:
"Permanently delete a task from the Fusion board. " +
"Tasks are deleted immediately and cannot be recovered.",
@@ -720,7 +720,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Consider archiving instead of deleting for completed work you may need to reference later",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to delete (e.g. KB-001)" }),
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -738,7 +738,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_import_github",
label: "KB: Import GitHub Issues",
label: "fn: Import GitHub Issues",
description:
"Import GitHub issues as Fusion tasks. Fetches open issues from a repository " +
"and creates tasks in the triage column. Each task includes the issue title " +
@@ -829,7 +829,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_import_github_issue",
label: "KB: Import GitHub Issue",
label: "fn: Import GitHub Issue",
description:
"Import a specific GitHub issue as a Fusion task. Fetches the issue by number " +
"and creates a single task in the triage column with the issue title and body.",
@@ -910,7 +910,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_browse_github_issues",
label: "KB: Browse GitHub Issues",
label: "fn: Browse GitHub Issues",
description:
"List open GitHub issues from a repository to browse before importing. " +
"Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue " +
@@ -1002,7 +1002,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_task_plan",
label: "KB: Plan Task",
label: "fn: Plan Task",
description:
"Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task.",
promptSnippet: "Create a task via AI-guided planning mode",
@@ -1050,8 +1050,8 @@ export default function kbExtension(pi: ExtensionAPI) {
}
// Parse created task ID from logs
const createdMatch = logs.find((l) => l.match(/Created (KB-\d+):/));
const taskId = createdMatch ? createdMatch.match(/Created (KB-\d+):/)?.[1] : undefined;
const createdMatch = logs.find((l) => l.match(/Created (FN-\d+):/));
const taskId = createdMatch ? createdMatch.match(/Created (FN-\d+):/)?.[1] : undefined;
// Get summary line
const summaryLine = logs.find((l) => l.includes("✓ Created")) || "Task created";
@@ -1075,7 +1075,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_mission_create",
label: "KB: Create Mission",
label: "fn: Create Mission",
description:
"Create a new mission — a high-level objective that can span multiple milestones. " +
"Missions contain milestones that break down work into phases.",
@@ -1131,7 +1131,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_mission_list",
label: "KB: List Missions",
label: "fn: List Missions",
description: "List all missions with their current status.",
promptSnippet: "List all missions",
promptGuidelines: [
@@ -1185,7 +1185,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_mission_show",
label: "KB: Show Mission",
label: "fn: Show Mission",
description: "Show mission details with full hierarchy: milestones → slices → features.",
promptSnippet: "Show mission details with hierarchy",
promptGuidelines: [
@@ -1250,7 +1250,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_mission_delete",
label: "KB: Delete Mission",
label: "fn: Delete Mission",
description: "Delete a mission and all its milestones, slices, and features. Cannot be undone.",
promptSnippet: "Delete a mission and all its contents",
promptGuidelines: [
@@ -1288,7 +1288,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_milestone_add",
label: "KB: Add Milestone",
label: "fn: Add Milestone",
description: "Add a milestone to a mission. Milestones represent phases of work.",
promptSnippet: "Add a milestone to a mission",
promptGuidelines: [
@@ -1332,7 +1332,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_slice_add",
label: "KB: Add Slice",
label: "fn: Add Slice",
description: "Add a slice to a milestone. Slices are work units that can be activated for implementation.",
promptSnippet: "Add a work slice to a milestone",
promptGuidelines: [
@@ -1377,7 +1377,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_feature_add",
label: "KB: Add Feature",
label: "fn: Add Feature",
description: "Add a feature to a slice. Features are deliverables that can be linked to tasks.",
promptSnippet: "Add a feature to a slice",
promptGuidelines: [
@@ -1426,7 +1426,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_slice_activate",
label: "KB: Activate Slice",
label: "fn: Activate Slice",
description:
"Activate a pending slice for implementation. " +
"Sets status to 'active' and enables task linking for its features.",
@@ -1479,7 +1479,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_feature_link_task",
label: "KB: Link Feature to Task",
label: "fn: Link Feature to Task",
description:
"Link a feature to a fn task for implementation. " +
"Updates the feature status to 'triaged' and associates it with the task.",
@@ -1492,7 +1492,7 @@ export default function kbExtension(pi: ExtensionAPI) {
],
parameters: Type.Object({
featureId: Type.String({ description: "Feature ID to link (e.g., F-001)" }),
taskId: Type.String({ description: "Task ID to link to (e.g., KB-001)" }),
taskId: Type.String({ description: "Task ID to link to (e.g., FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -1538,7 +1538,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_agent_stop",
label: "KB: Stop Agent",
label: "fn: Stop Agent",
description:
"Stop a running agent — pauses its execution. " +
"Transitions the agent from running/active to paused state.",
@@ -1601,7 +1601,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_agent_start",
label: "KB: Start Agent",
label: "fn: Start Agent",
description:
"Start a stopped agent — resumes its execution. " +
"Transitions the agent from paused to active state.",

View File

@@ -69,6 +69,8 @@ export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";
export { runCommandAsync } from "./run-command.js";
export type { RunCommandOptions, RunCommandResult } from "./run-command.js";
// ── Routine System ───────────────────────────────────────────────────
export {

View File

@@ -27,6 +27,7 @@ import {
ensureQmdInstalled,
qmdMemoryCollectionName,
QMD_REFRESH_INTERVAL_MS,
shouldSkipBackgroundQmdRefresh,
} from "./memory-backend.js";
import type { MemoryBackend } from "./memory-backend.js";
@@ -481,6 +482,10 @@ describe("memory-backend", () => {
}
});
it("skips background qmd refreshes under Vitest by default", () => {
expect(shouldSkipBackgroundQmdRefresh()).toBe(true);
});
it("uses the OpenClaw qmd package install command", () => {
expect(QMD_INSTALL_COMMAND).toBe("bun install -g @tobilu/qmd");
});

View File

@@ -36,6 +36,11 @@ type ExecFileAsync = (
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
let qmdInstallPromise: Promise<boolean> | null = null;
export function shouldSkipBackgroundQmdRefresh(): boolean {
return (process.env.VITEST === "true" || process.env.NODE_ENV === "test")
&& process.env.FUSION_ENABLE_QMD_REFRESH_IN_TESTS !== "1";
}
// ── Type Definitions ────────────────────────────────────────────────
/**
@@ -966,6 +971,10 @@ export async function refreshQmdProjectMemoryIndex(
}
export function scheduleQmdProjectMemoryRefresh(rootDir: string): void {
if (shouldSkipBackgroundQmdRefresh()) {
return;
}
void refreshQmdProjectMemoryIndex(rootDir).catch(() => {
// qmd is optional. Search falls back to local file scanning when refresh fails.
});
@@ -1029,6 +1038,10 @@ export async function ensureQmdInstalledAndRefresh(rootDir: string): Promise<voi
}
export function scheduleQmdInstallAndRefresh(rootDir: string): void {
if (shouldSkipBackgroundQmdRefresh()) {
return;
}
void ensureQmdInstalledAndRefresh(rootDir).catch(() => {
// qmd remains optional at runtime. Search falls back to local file scanning.
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { runCommandAsync } from "./run-command.js";
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("runCommandAsync", () => {
it("terminates background children left in the command process group", async () => {
if (process.platform === "win32") {
return;
}
const childScript = "setInterval(() => {}, 1000)";
const parentScript = [
"const { spawn } = require('node:child_process');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
"console.log(child.pid);",
"child.unref();",
].join(" ");
const result = await runCommandAsync(
`${process.execPath} -e ${JSON.stringify(parentScript)}`,
{ timeoutMs: 5_000 },
);
expect(result.exitCode).toBe(0);
const leakedPid = Number.parseInt(result.stdout.trim(), 10);
expect(Number.isFinite(leakedPid)).toBe(true);
for (let i = 0; i < 10 && isProcessAlive(leakedPid); i++) {
await sleep(100);
}
expect(isProcessAlive(leakedPid)).toBe(false);
});
});

View File

@@ -23,6 +23,8 @@ export interface RunCommandResult {
}
const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024;
const FORCE_KILL_DELAY_MS = 5_000;
const NORMAL_CLEANUP_FORCE_KILL_DELAY_MS = 500;
/**
* Run a shell command without blocking the Node.js event loop.
@@ -45,14 +47,38 @@ export function runCommandAsync(
let stderr = "";
let bufferExceeded = false;
let timedOut = false;
let forceKillTimer: NodeJS.Timeout | null = null;
const useProcessGroup = process.platform !== "win32";
const child = spawn(command, {
cwd: options.cwd,
env: options.env,
detached: useProcessGroup,
shell: true,
stdio: ["ignore", "pipe", "pipe"],
});
const signalProcessGroup = (signal: NodeJS.Signals): void => {
if (!child.pid) return;
try {
if (useProcessGroup) {
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
} catch {
// The command may already have exited and cleaned up its process group.
}
};
const scheduleForceKill = (delayMs: number): void => {
if (forceKillTimer) return;
forceKillTimer = setTimeout(() => {
signalProcessGroup("SIGKILL");
}, delayMs);
forceKillTimer.unref();
};
const append = (current: string, chunk: Buffer): string => {
const s = chunk.toString("utf-8");
if (current.length + s.length > maxBuffer) {
@@ -73,17 +99,17 @@ export function runCommandAsync(
const timer = options.timeoutMs
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5_000).unref();
signalProcessGroup("SIGTERM");
scheduleForceKill(FORCE_KILL_DELAY_MS);
}, options.timeoutMs)
: null;
child.on("error", (err) => {
if (timer) clearTimeout(timer);
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
resolve({
stdout,
stderr,
@@ -97,6 +123,16 @@ export function runCommandAsync(
child.on("close", (code, signal) => {
if (timer) clearTimeout(timer);
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
// A shell command can exit successfully while leaving background children
// in its process group (for example test runners, qmd indexers, or dev
// servers launched with `&`). Clean the group after every run so Fusion
// agents do not leak processes beyond the command lifecycle.
signalProcessGroup("SIGTERM");
scheduleForceKill(NORMAL_CLEANUP_FORCE_KILL_DELAY_MS);
resolve({
stdout,
stderr,

View File

@@ -237,26 +237,19 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
setSavingTarget(target);
try {
const updatedTask = await updateTask(requestTaskId, {
modelProvider: target === "executor"
? nextSelection.provider ?? null
: previousSavedExecutor.provider ?? null,
modelId: target === "executor"
? nextSelection.modelId ?? null
: previousSavedExecutor.modelId ?? null,
validatorModelProvider: target === "validator"
? nextSelection.provider ?? null
: previousSavedValidator.provider ?? null,
validatorModelId: target === "validator"
? nextSelection.modelId ?? null
: previousSavedValidator.modelId ?? null,
planningModelProvider: target === "planning"
? nextSelection.provider ?? null
: previousSavedPlanning.provider ?? null,
planningModelId: target === "planning"
? nextSelection.modelId ?? null
: previousSavedPlanning.modelId ?? null,
});
const updates: Parameters<typeof updateTask>[1] = {};
if (target === "executor") {
updates.modelProvider = nextSelection.provider ?? null;
updates.modelId = nextSelection.modelId ?? null;
} else if (target === "validator") {
updates.validatorModelProvider = nextSelection.provider ?? null;
updates.validatorModelId = nextSelection.modelId ?? null;
} else {
updates.planningModelProvider = nextSelection.provider ?? null;
updates.planningModelId = nextSelection.modelId ?? null;
}
const updatedTask = await updateTask(requestTaskId, updates);
if (activeTaskIdRef.current !== requestTaskId) {
return;

View File

@@ -193,6 +193,19 @@ function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
function sameStringArray(a: string[] = [], b: string[] = []): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function splitModelSelection(value: string): { provider: string; modelId: string } | null {
const slashIdx = value.indexOf("/");
if (!value || slashIdx === -1) return null;
return {
provider: value.slice(0, slashIdx),
modelId: value.slice(slashIdx + 1),
};
}
const DESCRIPTION_TRUNCATE_LENGTH = 200;
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
@@ -529,26 +542,54 @@ export function TaskDetailModal({
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
// Build update payload with all changed fields
const executorSlashIdx = editExecutorModel.indexOf("/");
const validatorSlashIdx = editValidatorModel.indexOf("/");
const planningSlashIdx = editPlanningModel.indexOf("/");
const updates: Parameters<typeof updateTask>[1] = {};
const trimmedTitle = editTitle.trim();
const trimmedDescription = editDescription.trim();
const updates: Parameters<typeof updateTask>[1] = {
title: editTitle.trim() || undefined,
description: editDescription.trim() || undefined,
dependencies: editDependencies,
enabledWorkflowSteps: editSelectedWorkflowSteps,
modelProvider: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(0, executorSlashIdx) : null,
modelId: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(executorSlashIdx + 1) : null,
validatorModelProvider: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(0, validatorSlashIdx) : null,
validatorModelId: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(validatorSlashIdx + 1) : null,
planningModelProvider: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(0, planningSlashIdx) : null,
planningModelId: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(planningSlashIdx + 1) : null,
thinkingLevel: editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null,
};
if (trimmedTitle && trimmedTitle !== (task.title ?? "")) {
updates.title = trimmedTitle;
}
if (trimmedDescription && trimmedDescription !== (task.description ?? "")) {
updates.description = trimmedDescription;
}
if (!sameStringArray(editDependencies, task.dependencies ?? [])) {
updates.dependencies = editDependencies;
}
if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) {
updates.enabledWorkflowSteps = editSelectedWorkflowSteps;
}
await updateTask(task.id, updates, projectId);
const executorSelection = splitModelSelection(editExecutorModel);
const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
if (editExecutorModel !== currentExecutorModel) {
updates.modelProvider = executorSelection?.provider ?? null;
updates.modelId = executorSelection?.modelId ?? null;
}
const validatorSelection = splitModelSelection(editValidatorModel);
const currentValidatorModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
if (editValidatorModel !== currentValidatorModel) {
updates.validatorModelProvider = validatorSelection?.provider ?? null;
updates.validatorModelId = validatorSelection?.modelId ?? null;
}
const planningSelection = splitModelSelection(editPlanningModel);
const currentPlanningModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
if (editPlanningModel !== currentPlanningModel) {
updates.planningModelProvider = planningSelection?.provider ?? null;
updates.planningModelId = planningSelection?.modelId ?? null;
}
const currentThinkingLevel = task.thinkingLevel ?? "";
if (editThinkingLevel !== currentThinkingLevel) {
updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
}
const hasTaskUpdates = Object.keys(updates).length > 0;
if (hasTaskUpdates) {
const updatedTask = await updateTask(task.id, updates, projectId);
onTaskUpdated?.(updatedTask);
}
// Upload pending images as attachments
if (editPendingImages.length > 0) {
@@ -578,7 +619,7 @@ export function TaskDetailModal({
setIsSaving(false);
}
}
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
const handleAutoSaveDescription = useCallback(async (description: string) => {
try {

View File

@@ -4134,6 +4134,9 @@ describe("TaskDetailModal", () => {
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
@@ -4163,6 +4166,9 @@ describe("TaskDetailModal", () => {
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
@@ -4196,6 +4202,9 @@ describe("TaskDetailModal", () => {
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
@@ -4276,7 +4285,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
});
it("save sends all changed fields via updateTask", async () => {
it("save sends only changed fields via updateTask", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
@@ -4296,16 +4305,16 @@ describe("TaskDetailModal", () => {
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "Updated desc" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
title: "Test",
description: "Desc",
dependencies: ["FN-002"],
enabledWorkflowSteps: [],
}), undefined);
expect(mockUpdate).toHaveBeenCalledWith("FN-001", {
description: "Updated desc",
}, undefined);
});
});
@@ -4408,12 +4417,10 @@ describe("TaskDetailModal", () => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
2,
"FN-001",
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
{
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}),
},
);
});

View File

@@ -303,6 +303,27 @@ describe("ChatManager.sendMessage", () => {
expect(assistantCall?.[1].content).toBe("Hello world!");
});
it("creates chat agents with the full coding toolset", async () => {
let createOptions: any;
__setCreateKbAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.tools).toBe("coding");
});
it("accumulates thinking output separately from text", async () => {
let onThinkingCb: ((delta: string) => void) | undefined;
let onTextCb: ((delta: string) => void) | undefined;

View File

@@ -537,7 +537,7 @@ export class ChatManager {
agentResult = await createKbAgent({
cwd: this.rootDir,
systemPrompt,
tools: "readonly",
tools: "coding",
...(effectiveModelProvider && effectiveModelId
? {
defaultProvider: effectiveModelProvider,

View File

@@ -2296,23 +2296,30 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: ["FN-002"],
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: null,
});
expect(res.body.dependencies).toEqual(["FN-002"]);
});
it("does not clear model or assignee fields when they are omitted", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ title: "New" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { title: "New" });
expect(store.updateTask).not.toHaveBeenCalledWith(
"KB-001",
expect.objectContaining({
modelProvider: null,
modelId: null,
assigneeUserId: null,
}),
);
});
it("forwards title and description without dependencies", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
@@ -2323,18 +2330,6 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: "New",
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: null,
});
});
@@ -2358,19 +2353,10 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: null,
});
});
@@ -2388,18 +2374,6 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: "requesting-user",
});
});
@@ -2442,19 +2416,8 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: null,
});
});
@@ -2476,19 +2439,8 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
thinkingLevel: undefined,
assigneeUserId: null,
});
});
@@ -2530,19 +2482,8 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: null,
});
});
@@ -2560,19 +2501,7 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: ["browser-verification"],
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: undefined,
assigneeUserId: null,
});
});
@@ -2601,19 +2530,7 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: "high",
assigneeUserId: null,
});
});
@@ -2632,19 +2549,7 @@ describe("PATCH /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
planningModelProvider: null,
planningModelId: null,
thinkingLevel: null,
assigneeUserId: null,
});
});

View File

@@ -4618,10 +4618,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
const validateModelField = (value: unknown, name: string): string | null | undefined => {
if (value === undefined || value === null) return null;
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== "string") {
throw new Error(`${name} must be a string`);
}
@@ -4648,21 +4650,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
const task = await scopedStore.updateTask(req.params.id, {
title,
description,
prompt,
dependencies,
enabledWorkflowSteps,
modelProvider: validatedModelProvider,
modelId: validatedModelId,
validatorModelProvider: validatedValidatorModelProvider,
validatorModelId: validatedValidatorModelId,
planningModelProvider: validatedPlanningModelProvider,
planningModelId: validatedPlanningModelId,
thinkingLevel: thinkingLevel === null ? null : thinkingLevel,
assigneeUserId: validatedAssigneeUserId,
});
const updates: Parameters<typeof scopedStore.updateTask>[1] = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (prompt !== undefined) updates.prompt = prompt;
if (dependencies !== undefined) updates.dependencies = dependencies;
if (enabledWorkflowSteps !== undefined) updates.enabledWorkflowSteps = enabledWorkflowSteps;
if (hasBodyField("modelProvider")) updates.modelProvider = validatedModelProvider;
if (hasBodyField("modelId")) updates.modelId = validatedModelId;
if (hasBodyField("validatorModelProvider")) updates.validatorModelProvider = validatedValidatorModelProvider;
if (hasBodyField("validatorModelId")) updates.validatorModelId = validatedValidatorModelId;
if (hasBodyField("planningModelProvider")) updates.planningModelProvider = validatedPlanningModelProvider;
if (hasBodyField("planningModelId")) updates.planningModelId = validatedPlanningModelId;
if (hasBodyField("thinkingLevel")) updates.thinkingLevel = thinkingLevel === null ? null : thinkingLevel;
if (hasBodyField("assigneeUserId")) updates.assigneeUserId = validatedAssigneeUserId;
const task = await scopedStore.updateTask(req.params.id, updates);
res.json(task);
} catch (err: unknown) {
if (err instanceof ApiError) {

View File

@@ -64,7 +64,35 @@ vi.mock("./worktree-names.js", async () => {
// promisify(exec) in executor.ts.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
@@ -95,7 +123,7 @@ vi.mock("node:child_process", async () => {
}
});
});
return { execSync: execSyncFn, exec: execFn };
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),

View File

@@ -6,7 +6,7 @@ import { isAbsolute, join } from "node:path";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext } from "@fusion/core";
import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentPrompt } from "@fusion/core";
import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentPrompt, runCommandAsync, type RunCommandResult } from "@fusion/core";
import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
@@ -78,6 +78,31 @@ function truncateWorkflowScriptOutput(output: string): string {
return `... output truncated to last ${WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS} characters ...\n${output.slice(-WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS)}`;
}
function configuredCommandErrorMessage(result: RunCommandResult): string {
if (result.spawnError) return result.spawnError.message;
const parts: string[] = [];
if (result.timedOut) parts.push("Timed out");
if (result.exitCode !== null) parts.push(`Exit code: ${result.exitCode}`);
if (result.signal) parts.push(`Signal: ${result.signal}`);
const stdout = result.stdout.trim();
const stderr = result.stderr.trim();
if (stdout) parts.push(`stdout: ${truncateWorkflowScriptOutput(stdout)}`);
if (stderr) parts.push(`stderr: ${truncateWorkflowScriptOutput(stderr)}`);
return parts.length ? parts.join("\n") : "Command failed";
}
async function runConfiguredCommand(
command: string,
cwd: string,
timeoutMs: number,
): Promise<RunCommandResult> {
return runCommandAsync(command, {
cwd,
timeoutMs,
maxBuffer: 10 * 1024 * 1024,
});
}
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
const taskUpdateParams = Type.Object({
@@ -1072,10 +1097,10 @@ export class TaskExecutor {
// while the user-configured command (e.g. `pnpm install`) executes.
if (settings.worktreeInitCommand) {
try {
await execAsync(settings.worktreeInitCommand, {
cwd: worktreePath,
timeout: 120_000,
});
const initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 120_000);
if (initResult.spawnError || initResult.timedOut || initResult.exitCode !== 0) {
throw new Error(configuredCommandErrorMessage(initResult));
}
await this.store.logEntry(task.id, "Worktree init command completed", settings.worktreeInitCommand, this.currentRunContext);
} catch (err: unknown) {
const execError = err instanceof Error ? err : new Error(String(err));
@@ -1091,10 +1116,10 @@ export class TaskExecutor {
const scriptCommand = settings.scripts?.[settings.setupScript];
if (scriptCommand) {
try {
await execAsync(scriptCommand, {
cwd: worktreePath,
timeout: 120_000,
});
const setupResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000);
if (setupResult.spawnError || setupResult.timedOut || setupResult.exitCode !== 0) {
throw new Error(configuredCommandErrorMessage(setupResult));
}
await this.store.logEntry(task.id, `Setup script '${settings.setupScript}' completed`, scriptCommand, this.currentRunContext);
} catch (err: unknown) {
const execError = err instanceof Error ? err : new Error(String(err));
@@ -3126,12 +3151,10 @@ ${failureFeedback}
await this.store.logEntry(task.id, `Workflow step '${workflowStep.name}' executing script '${scriptName}': ${scriptCommand}`);
try {
// Non-blocking: async exec so the executor event loop keeps running
// while the user-configured workflow script executes.
await execAsync(scriptCommand, {
cwd: worktreePath,
timeout: 120_000,
});
const scriptResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000);
if (scriptResult.spawnError || scriptResult.timedOut || scriptResult.exitCode !== 0) {
return { success: false, error: configuredCommandErrorMessage(scriptResult) };
}
return { success: true, output: `Script '${scriptName}' completed successfully` };
} catch (err: unknown) {
const execError = err instanceof Error ? err : new Error(String(err));

View File

@@ -19,7 +19,35 @@ vi.mock("./pi.js", () => ({
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
@@ -45,7 +73,7 @@ vi.mock("node:child_process", async () => {
}
});
});
return { execSync: execSyncFn, exec: execFn };
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({

View File

@@ -5,7 +5,7 @@ import { promisify } from "node:util";
const execAsync = promisify(exec);
import { existsSync } from "node:fs";
import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
import { getTaskMergeBlocker, runCommandAsync, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { createKbAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
@@ -570,20 +570,36 @@ async function runVerificationCommand(
};
try {
// Execute the command with timeout (non-blocking: uses async exec so the
// engine event loop keeps running while the child process executes)
const { stdout, stderr } = await execAsync(command, {
const commandResult = await runCommandAsync(command, {
cwd: rootDir,
encoding: "utf-8",
timeoutMs: 300_000,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
timeout: 300_000, // 5 minute timeout for verification commands
});
result.stdout = stdout;
result.stderr = stderr;
result.exitCode = 0;
result.success = true;
mergerLog.log(`${taskId}: ${type} command succeeded`);
await store.logEntry(taskId, `[verification] ${type} command succeeded (exit 0)`);
result.stdout = commandResult.stdout;
result.stderr = commandResult.stderr;
result.exitCode = commandResult.exitCode;
result.success = !commandResult.spawnError
&& !commandResult.timedOut
&& commandResult.exitCode === 0;
if (result.success) {
const bufferNote = commandResult.bufferExceeded ? ", output exceeded buffer" : "";
mergerLog.log(`${taskId}: ${type} command succeeded`);
await store.logEntry(taskId, `[verification] ${type} command succeeded (exit 0${bufferNote})`);
return result;
}
const failureText = commandResult.spawnError?.message
|| (commandResult.timedOut ? "Command timed out" : "")
|| commandResult.stderr
|| commandResult.stdout
|| `Command exited with ${commandResult.exitCode ?? commandResult.signal ?? "unknown status"}`;
throw Object.assign(new Error(failureText), {
stdout: commandResult.stdout,
stderr: commandResult.stderr,
status: commandResult.exitCode,
code: commandResult.timedOut ? "ETIMEDOUT" : undefined,
});
} catch (error: any) {
result.stdout = error.stdout?.toString() || "";
result.stderr = error.stderr?.toString() || "";

View File

@@ -111,6 +111,17 @@ describe("PeerExchangeService", () => {
expect(service).toBeDefined();
});
it("should use default sync interval of 120 seconds", () => {
mockListNodes.mockResolvedValue([]);
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
const service = new PeerExchangeService(mockCentralCore);
service.start();
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 120_000);
service.stop();
});
it("should default settingsSyncEnabled to false", async () => {
const service = new PeerExchangeService(mockCentralCore);
setupSuccessfulSync();

View File

@@ -3,7 +3,7 @@ import type { NodeConfig, PeerSyncRequest, PeerSyncResponse } from "@fusion/core
import { peerExchangeLog } from "./logger.js";
export interface PeerExchangeServiceOptions {
/** Interval between peer sync cycles in milliseconds. Default: 60000 (1 minute) */
/** Interval between peer sync cycles in milliseconds. Default: 120000 (2 minutes) */
syncIntervalMs?: number;
/** When true, include settings and model auth data in peer sync exchanges. Default: false. */
settingsSyncEnabled?: boolean;
@@ -70,7 +70,7 @@ export class PeerExchangeService {
*/
constructor(centralCore: CentralCore, options: PeerExchangeServiceOptions = {}) {
this.centralCore = centralCore;
this.syncIntervalMs = options.syncIntervalMs ?? 60_000; // 1 minute default
this.syncIntervalMs = options.syncIntervalMs ?? 120_000; // 2 minute default
this.settingsSyncEnabled = options.settingsSyncEnabled ?? false;
this.settingsSyncThrottleMs = options.settingsSyncThrottleMs ?? 300_000; // 5 minutes default
this.globalSettings = options.globalSettings;

View File

@@ -555,6 +555,53 @@ describe("createKbAgent", () => {
}));
});
it("falls back during prompt when the primary model rejects temperature settings", async () => {
const primaryPrompt = vi.fn().mockRejectedValue(
new Error("400 invalid temperature: only 0.6 is allowed for this model"),
);
const fallbackPrompt = vi.fn().mockResolvedValue(undefined);
const primaryDispose = vi.fn();
createAgentSessionMock
.mockResolvedValueOnce({
session: {
prompt: primaryPrompt,
subscribe: vi.fn(),
dispose: primaryDispose,
setThinkingLevel: vi.fn(),
},
})
.mockResolvedValueOnce({
session: {
prompt: fallbackPrompt,
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel: vi.fn(),
},
});
const { createKbAgent } = await import("./pi.js");
const { session } = await createKbAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "kimi-coding",
defaultModelId: "kimi-k2.6-preview",
fallbackProvider: "zai",
fallbackModelId: "glm-5.1",
});
await (session as any).promptWithFallback("review this spec");
expect(primaryPrompt).toHaveBeenCalledWith("review this spec");
expect(primaryDispose).toHaveBeenCalled();
expect(fallbackPrompt).toHaveBeenCalledWith("review this spec");
expect(createAgentSessionMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
model: { provider: "zai", id: "glm-5.1" },
}));
});
it("enables auto-compaction to prevent context-window overflow", async () => {
const { createKbAgent } = await import("./pi.js");

View File

@@ -381,7 +381,8 @@ function isRetryableModelSelectionError(message: string): boolean {
|| normalized.includes("overloaded")
|| normalized.includes("quota")
|| normalized.includes("capacity")
|| normalized.includes("temporarily unavailable");
|| normalized.includes("temporarily unavailable")
|| normalized.includes("invalid temperature");
}
interface PackageManagerSettingsView {

View File

@@ -1120,6 +1120,72 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("moves stale in-review tasks with incomplete steps back to todo for retry", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
taskStuckTimeoutMs: 1_000,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-1572",
column: "in-review",
paused: false,
status: null,
error: null,
worktree: "/tmp/test-project/.worktrees/fn-1572",
updatedAt: new Date(Date.now() - 5_000).toISOString(),
steps: [
{ name: "Preflight", status: "done" },
{ name: "Testing & Verification", status: "in-progress" },
],
workflowStepResults: [],
mergeDetails: undefined,
log: [],
},
]);
const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks();
expect(result).toBe(1);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1572",
expect.stringContaining("in-review task still had incomplete steps"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-1572", "todo");
managerWithRecovery.stop();
});
it("does not move fresh in-review tasks with incomplete steps", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
taskStuckTimeoutMs: 60_000,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-1573",
column: "in-review",
paused: false,
status: null,
updatedAt: new Date().toISOString(),
steps: [{ name: "Testing", status: "in-progress" }],
workflowStepResults: [],
log: [],
},
]);
const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks();
expect(result).toBe(0);
expect(store.moveTask).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("moves merged in-review tasks to done and clears transient merge state", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",

View File

@@ -64,6 +64,7 @@ export interface SelfHealingOptions {
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
const ORPHANED_EXECUTION_RECOVERY_GRACE_MS = 60_000;
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr"]);
const NON_TERMINAL_STEP_STATUSES = new Set(["pending", "in-progress"]);
/**
* Longer grace period for tasks that still have a worktree on disk.
* This avoids racing with `executor.resumeOrphaned()` which runs on
@@ -140,6 +141,7 @@ export class SelfHealingManager {
async runStartupRecovery(): Promise<void> {
await this.recoverNoProgressNoTaskDoneFailures();
await this.recoverCompletedTasks();
await this.recoverStaleIncompleteReviewTasks();
await this.recoverInterruptedMergingTasks();
await this.recoverMisclassifiedFailures();
await this.recoverOrphanedExecutions();
@@ -456,6 +458,7 @@ export class SelfHealingManager {
this.checkpointWal();
await this.enforceWorktreeCap();
await this.recoverCompletedTasks();
await this.recoverStaleIncompleteReviewTasks();
await this.recoverInterruptedMergingTasks();
await this.recoverMergeableReviewTasks();
await this.recoverMergedReviewTasks();
@@ -632,6 +635,58 @@ export class SelfHealingManager {
}
}
/**
* Recover tasks that reached `in-review` while a task step was still marked
* pending/in-progress. These tasks are not tracked by StuckTaskDetector
* anymore because the executor session is gone, and they are not mergeable
* because `getTaskMergeBlocker()` correctly blocks incomplete steps.
*
* Moving them back to `todo` lets the normal scheduler/executor resume the
* incomplete step instead of leaving the task stranded in review.
*/
async recoverStaleIncompleteReviewTasks(): Promise<number> {
try {
const settings = await this.store.getSettings();
const timeoutMs = settings.taskStuckTimeoutMs;
if (!timeoutMs || timeoutMs <= 0) return 0;
const now = Date.now();
const tasks = await this.store.listTasks({ column: "in-review" });
const staleIncomplete = tasks.filter((task) =>
task.column === "in-review" &&
!task.paused &&
!task.status &&
task.steps.length > 0 &&
task.steps.some((step) => NON_TERMINAL_STEP_STATUSES.has(step.status)) &&
now - new Date(task.updatedAt).getTime() >= timeoutMs
);
if (staleIncomplete.length === 0) return 0;
log.warn(`Found ${staleIncomplete.length} stale in-review task(s) with incomplete steps`);
let recovered = 0;
for (const task of staleIncomplete) {
try {
await this.store.logEntry(
task.id,
"Auto-recovered: in-review task still had incomplete steps — moved back to todo for retry",
);
await this.store.moveTask(task.id, "todo");
log.log(`Recovered stale incomplete review task ${task.id}: moved back to todo`);
recovered++;
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Failed to recover stale incomplete review task ${task.id}: ${errorMessage}`);
}
}
return recovered;
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Stale incomplete review recovery failed: ${errorMessage}`);
return 0;
}
}
/**
* Recover stale `in-review` tasks left in a transient merge status.
*

View File

@@ -1386,7 +1386,7 @@ describe("taskCreate tool model inheritance", () => {
});
describe("bounded recovery retries for triage", () => {
it("marks triage failed when the agent exits without calling review_spec", async () => {
it("requeues triage with backoff when the agent exits without calling review_spec", async () => {
const task = {
id: "FN-202",
description: "Test triage task",
@@ -1424,14 +1424,14 @@ describe("taskCreate tool model inheritance", () => {
await processor.specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith("FN-202", expect.objectContaining({
status: "failed",
error: expect.stringContaining("review_spec was never called"),
recoveryRetryCount: null,
nextRecoveryAt: null,
status: null,
error: null,
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-202",
expect.stringContaining("Specification failed: spec review not approved"),
expect.stringContaining("Spec review not approved (review_spec was never called)"),
);
});

View File

@@ -902,19 +902,41 @@ export class TriageProcessor {
}
// Post-session APPROVE gate: only advance to todo when the spec
// reviewer explicitly approved. Any other verdict (REVISE,
// RETHINK, UNAVAILABLE) or a missing review (null) keeps the task
// in triage so unreviewed / rejected specs never reach execution.
// reviewer explicitly approved. Any other verdict (REVISE,
// RETHINK, UNAVAILABLE) or a missing review (null) stays in triage
// and is retried with bounded backoff instead of immediately failing.
if (specReviewVerdictRef.current !== "APPROVE") {
const verdictDesc =
specReviewVerdictRef.current === null
? "review_spec was never called"
: `verdict was ${specReviewVerdictRef.current}`;
const decision = computeRecoveryDecision({
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAt: task.nextRecoveryAt,
});
if (decision.shouldRetry) {
const attempt = decision.nextState.recoveryRetryCount;
const delay = formatDelay(decision.delayMs);
const retryMessage =
`Spec review not approved (${verdictDesc}) — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}.`;
triageLog.warn(`${task.id} ${retryMessage}`);
await this.store.logEntry(task.id, retryMessage);
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : null;
await this.store.updateTask(task.id, {
status: restoreStatus,
error: null,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
});
return;
}
const failureMessage =
`Specification failed: spec review not approved (${verdictDesc}). ` +
`Specification failed after ${MAX_RECOVERY_RETRIES} unapproved spec reviews (${verdictDesc}). ` +
"Retry after adjusting the task prompt or model.";
triageLog.log(
`${task.id} spec review not approved (${verdictDesc}) — marking specification failed`,
`${task.id} spec review not approved (${verdictDesc}) — retry budget exhausted`,
);
await this.store.logEntry(
task.id,