fix(FN-1572): stabilize fusion agent execution
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
46
packages/core/src/run-command.test.ts
Normal file
46
packages/core/src/run-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -537,7 +537,7 @@ export class ChatManager {
|
||||
agentResult = await createKbAgent({
|
||||
cwd: this.rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
tools: "coding",
|
||||
...(effectiveModelProvider && effectiveModelId
|
||||
? {
|
||||
defaultProvider: effectiveModelProvider,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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() || "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user