Harden architecture hot paths
This commit is contained in:
@@ -116,7 +116,7 @@ async function getTaskCounts(projectPath: string): Promise<Record<string, number
|
||||
try {
|
||||
const store = new TaskStore(projectPath);
|
||||
await store.init();
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const col of COLUMNS) {
|
||||
|
||||
@@ -108,6 +108,18 @@ export async function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDet
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizePullRequestMerge(
|
||||
store: TaskStore,
|
||||
cwd: string,
|
||||
task: TaskDetail,
|
||||
prInfo: PrInfo,
|
||||
): Promise<void> {
|
||||
await cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing a PR merge task.
|
||||
* - "waiting": PR exists but not ready to merge (checks pending, reviews needed)
|
||||
@@ -188,10 +200,7 @@ export async function processPullRequestMergeTask(
|
||||
await store.updatePrInfo(task.id, refreshedPrInfo);
|
||||
|
||||
if (mergeStatus.prInfo.status === "merged") {
|
||||
await cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`);
|
||||
await finalizePullRequestMerge(store, cwd, task, prInfo);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
@@ -207,9 +216,6 @@ export async function processPullRequestMergeTask(
|
||||
await store.updateTask(task.id, { status: "merging-pr" });
|
||||
const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
|
||||
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
|
||||
await cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${mergedPr.number}: ${mergedPr.url}`);
|
||||
await finalizePullRequestMerge(store, cwd, task, mergedPr);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
export async function runTaskList(projectName?: string) {
|
||||
const projectContext = await getProjectContext(projectName);
|
||||
const store = projectContext?.store ?? await getStore(projectName);
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log("\n No tasks yet. Create one with: fn task create\n");
|
||||
@@ -711,7 +711,7 @@ export async function runTaskImportGitHubInteractive(
|
||||
console.log(`\n Fetching issues from ${owner}/${repo}...\n`);
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const existingTasks = await store.listTasks();
|
||||
const existingTasks = await store.listTasks({ slim: true });
|
||||
|
||||
// Build a set of already-imported issue URLs
|
||||
const importedUrls = new Map<string, string>();
|
||||
@@ -899,7 +899,7 @@ export async function runTaskImportFromGitHub(
|
||||
console.log(`\n Importing issues from ${owner}/${repo}...\n`);
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const existingTasks = await store.listTasks();
|
||||
const existingTasks = await store.listTasks({ slim: true });
|
||||
|
||||
// Build a set of already-imported issue URLs
|
||||
const importedUrls = new Map<string, string>();
|
||||
|
||||
@@ -290,7 +290,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
@@ -763,7 +763,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
const store = await getStore(ctx.cwd);
|
||||
const existingTasks = await store.listTasks();
|
||||
const existingTasks = await store.listTasks({ slim: true });
|
||||
const createdTasks: Array<{ id: string; title: string }> = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
@@ -841,7 +841,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
// Check if already imported
|
||||
const store = await getStore(ctx.cwd);
|
||||
const existingTasks = await store.listTasks();
|
||||
const existingTasks = await store.listTasks({ slim: true });
|
||||
const sourceUrl = issue.html_url;
|
||||
|
||||
for (const task of existingTasks) {
|
||||
@@ -936,7 +936,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
// Check which issues are already imported
|
||||
const store = await getStore(ctx.cwd);
|
||||
const existingTasks = await store.listTasks();
|
||||
const existingTasks = await store.listTasks({ slim: true });
|
||||
const importedUrls = new Set<string>();
|
||||
|
||||
for (const task of existingTasks) {
|
||||
|
||||
@@ -685,7 +685,7 @@ export async function getProjectInfo(name?: string): Promise<{
|
||||
const health = await central.getProjectHealth(project.projectId);
|
||||
|
||||
// Get task counts by column
|
||||
const tasks = await project.store.listTasks();
|
||||
const tasks = await project.store.listTasks({ slim: true });
|
||||
const taskCounts: Record<string, number> = {};
|
||||
for (const task of tasks) {
|
||||
taskCounts[task.column] = (taskCounts[task.column] || 0) + 1;
|
||||
@@ -791,7 +791,7 @@ export async function getProjectsWithStatus(): Promise<
|
||||
try {
|
||||
const store = new (await import("@fusion/core")).TaskStore(project.path);
|
||||
await store.init();
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
taskCount = tasks.length;
|
||||
} catch {
|
||||
// If we can't read tasks, just report 0
|
||||
@@ -848,7 +848,7 @@ export async function getProjectTaskCounts(
|
||||
|
||||
if (!taskStore) return {};
|
||||
|
||||
const tasks = await taskStore.listTasks();
|
||||
const tasks = await taskStore.listTasks({ slim: true });
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
|
||||
58
packages/core/src/__tests__/architecture-hot-paths.test.ts
Normal file
58
packages/core/src/__tests__/architecture-hot-paths.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
function listSourceFiles(dir: string): string[] {
|
||||
const entries = readdirSync(dir);
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "dist" || entry === "node_modules") {
|
||||
continue;
|
||||
}
|
||||
files.push(...listSourceFiles(path));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!/\.(ts|tsx)$/.test(entry) || /\.test\.(ts|tsx)$/.test(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(path);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("architecture hot-path contracts", () => {
|
||||
it("keeps production listTasks() callers explicit about payload shape", () => {
|
||||
const sourceRoots = [
|
||||
"packages/cli/src",
|
||||
"packages/dashboard/app",
|
||||
"packages/dashboard/src",
|
||||
"packages/engine/src",
|
||||
];
|
||||
const bareListTaskCalls: string[] = [];
|
||||
|
||||
for (const root of sourceRoots) {
|
||||
for (const file of listSourceFiles(join(workspaceRoot, root))) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (/\.\s*listTasks\(\)/.test(line)) {
|
||||
bareListTaskCalls.push(`${relative(workspaceRoot, file)}:${index + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(bareListTaskCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4,104 +4,9 @@ import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
GLOBAL_SETTINGS_KEYS,
|
||||
PROJECT_SETTINGS_KEYS,
|
||||
isGlobalSettingsKey,
|
||||
isProjectSettingsKey,
|
||||
} from "../types.js";
|
||||
import type { GlobalSettings, ProjectSettings } from "../types.js";
|
||||
|
||||
const GLOBAL_KEYS: (keyof GlobalSettings)[] = [
|
||||
"themeMode",
|
||||
"colorTheme",
|
||||
"defaultProvider",
|
||||
"defaultModelId",
|
||||
"fallbackProvider",
|
||||
"fallbackModelId",
|
||||
"defaultThinkingLevel",
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
"ntfyEvents",
|
||||
"ntfyDashboardHost",
|
||||
"defaultProjectId",
|
||||
"setupComplete",
|
||||
"favoriteProviders",
|
||||
"favoriteModels",
|
||||
"openrouterModelSync",
|
||||
"modelOnboardingComplete",
|
||||
];
|
||||
|
||||
const PROJECT_KEYS: (keyof ProjectSettings)[] = [
|
||||
"globalPause",
|
||||
"enginePaused",
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
"pollIntervalMs",
|
||||
"groupOverlappingFiles",
|
||||
"autoMerge",
|
||||
"mergeStrategy",
|
||||
"worktreeInitCommand",
|
||||
"testCommand",
|
||||
"buildCommand",
|
||||
"recycleWorktrees",
|
||||
"worktreeNaming",
|
||||
"taskPrefix",
|
||||
"includeTaskIdInCommit",
|
||||
"planningProvider",
|
||||
"planningModelId",
|
||||
"planningFallbackProvider",
|
||||
"planningFallbackModelId",
|
||||
"validatorProvider",
|
||||
"validatorModelId",
|
||||
"validatorFallbackProvider",
|
||||
"validatorFallbackModelId",
|
||||
"modelPresets",
|
||||
"autoSelectModelPreset",
|
||||
"defaultPresetBySize",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"strictScopeEnforcement",
|
||||
"buildRetryCount",
|
||||
"buildTimeoutMs",
|
||||
"requirePlanApproval",
|
||||
"taskStuckTimeoutMs",
|
||||
"aiSessionTtlMs",
|
||||
"aiSessionCleanupIntervalMs",
|
||||
"autoUnpauseEnabled",
|
||||
"autoUnpauseBaseDelayMs",
|
||||
"autoUnpauseMaxDelayMs",
|
||||
"maxStuckKills",
|
||||
"maxSpawnedAgentsPerParent",
|
||||
"maxSpawnedAgentsGlobal",
|
||||
"maintenanceIntervalMs",
|
||||
"autoUpdatePrStatus",
|
||||
"autoCreatePr",
|
||||
"autoBackupEnabled",
|
||||
"autoBackupSchedule",
|
||||
"autoBackupRetention",
|
||||
"autoBackupDir",
|
||||
"autoSummarizeTitles",
|
||||
"titleSummarizerProvider",
|
||||
"titleSummarizerModelId",
|
||||
"titleSummarizerFallbackProvider",
|
||||
"titleSummarizerFallbackModelId",
|
||||
"scripts",
|
||||
"setupScript",
|
||||
"insightExtractionEnabled",
|
||||
"insightExtractionSchedule",
|
||||
"insightExtractionMinIntervalMs",
|
||||
"memoryEnabled",
|
||||
"memoryBackendType",
|
||||
"tokenCap",
|
||||
"runStepsInNewSessions",
|
||||
"maxParallelSteps",
|
||||
"missionStaleThresholdMs",
|
||||
"missionMaxTaskRetries",
|
||||
"missionHealthCheckIntervalMs",
|
||||
"agentPrompts",
|
||||
"promptOverrides",
|
||||
"reflectionEnabled",
|
||||
"reflectionIntervalMs",
|
||||
"reflectionAfterTask",
|
||||
"reviewHandoffPolicy",
|
||||
"showQuickChatFAB",
|
||||
];
|
||||
|
||||
function assertExactKeyCoverage(scopeName: string, actual: readonly string[], expected: readonly string[]): void {
|
||||
const uniqueActual = [...new Set(actual)];
|
||||
@@ -124,30 +29,29 @@ function assertExactKeyCoverage(scopeName: string, actual: readonly string[], ex
|
||||
}
|
||||
|
||||
describe("settings key parity", () => {
|
||||
it("GLOBAL_SETTINGS_KEYS covers all GlobalSettings keys", () => {
|
||||
assertExactKeyCoverage("GLOBAL_SETTINGS_KEYS", GLOBAL_SETTINGS_KEYS as readonly string[], GLOBAL_KEYS as string[]);
|
||||
});
|
||||
|
||||
it("PROJECT_SETTINGS_KEYS covers all ProjectSettings keys", () => {
|
||||
assertExactKeyCoverage("PROJECT_SETTINGS_KEYS", PROJECT_SETTINGS_KEYS as readonly string[], PROJECT_KEYS as string[]);
|
||||
});
|
||||
|
||||
it("DEFAULT_GLOBAL_SETTINGS covers all GlobalSettings keys", () => {
|
||||
it("GLOBAL_SETTINGS_KEYS is derived from the global settings defaults", () => {
|
||||
assertExactKeyCoverage(
|
||||
"DEFAULT_GLOBAL_SETTINGS",
|
||||
"GLOBAL_SETTINGS_KEYS",
|
||||
GLOBAL_SETTINGS_KEYS as readonly string[],
|
||||
Object.keys(DEFAULT_GLOBAL_SETTINGS),
|
||||
GLOBAL_KEYS as string[],
|
||||
);
|
||||
});
|
||||
|
||||
it("DEFAULT_PROJECT_SETTINGS covers all ProjectSettings keys", () => {
|
||||
it("PROJECT_SETTINGS_KEYS is derived from the project settings defaults", () => {
|
||||
assertExactKeyCoverage(
|
||||
"DEFAULT_PROJECT_SETTINGS",
|
||||
"PROJECT_SETTINGS_KEYS",
|
||||
PROJECT_SETTINGS_KEYS as readonly string[],
|
||||
Object.keys(DEFAULT_PROJECT_SETTINGS),
|
||||
PROJECT_KEYS as string[],
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies settings scopes", () => {
|
||||
expect(isGlobalSettingsKey("themeMode")).toBe(true);
|
||||
expect(isGlobalSettingsKey("maxConcurrent")).toBe(false);
|
||||
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
||||
expect(isProjectSettingsKey("themeMode")).toBe(false);
|
||||
});
|
||||
|
||||
it("No key appears in both GLOBAL_SETTINGS_KEYS and PROJECT_SETTINGS_KEYS", () => {
|
||||
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
@@ -47,7 +47,7 @@ export type { Statement } from "./db.js";
|
||||
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
||||
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
export { getTaskMergeBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
export {
|
||||
isGhAvailable,
|
||||
isGhAuthenticated,
|
||||
|
||||
137
packages/core/src/settings-schema.ts
Normal file
137
packages/core/src/settings-schema.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
||||
|
||||
type CompleteSettings<T> = { [K in keyof Required<T>]: Required<T>[K] | undefined };
|
||||
|
||||
/**
|
||||
* Settings schema source of truth.
|
||||
*
|
||||
* The default objects intentionally include optional keys with `undefined`
|
||||
* values so `Object.keys()` can derive complete scope key lists. This keeps
|
||||
* persistence filters, UI save splitting, and parity tests aligned.
|
||||
*/
|
||||
|
||||
/** Default values for global (user-level) settings. */
|
||||
export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
themeMode: "dark",
|
||||
colorTheme: "default",
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
fallbackProvider: undefined,
|
||||
fallbackModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||||
ntfyDashboardHost: undefined,
|
||||
defaultProjectId: undefined,
|
||||
setupComplete: undefined,
|
||||
favoriteProviders: undefined,
|
||||
favoriteModels: undefined,
|
||||
openrouterModelSync: true,
|
||||
modelOnboardingComplete: undefined,
|
||||
} satisfies CompleteSettings<GlobalSettings>;
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
export const DEFAULT_PROJECT_SETTINGS = {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
worktreeInitCommand: undefined,
|
||||
testCommand: undefined,
|
||||
buildCommand: undefined,
|
||||
recycleWorktrees: false,
|
||||
worktreeNaming: "random",
|
||||
taskPrefix: "FN",
|
||||
includeTaskIdInCommit: true,
|
||||
planningProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
planningFallbackProvider: undefined,
|
||||
planningFallbackModelId: undefined,
|
||||
validatorProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
validatorFallbackProvider: undefined,
|
||||
validatorFallbackModelId: undefined,
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
strictScopeEnforcement: false,
|
||||
buildRetryCount: 0,
|
||||
buildTimeoutMs: 300_000,
|
||||
requirePlanApproval: false,
|
||||
taskStuckTimeoutMs: undefined,
|
||||
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
||||
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
|
||||
autoUnpauseEnabled: true,
|
||||
autoUnpauseBaseDelayMs: 300_000,
|
||||
autoUnpauseMaxDelayMs: 3_600_000,
|
||||
maxStuckKills: 6,
|
||||
maxSpawnedAgentsPerParent: 5,
|
||||
maxSpawnedAgentsGlobal: 20,
|
||||
maintenanceIntervalMs: 900_000,
|
||||
autoUpdatePrStatus: false,
|
||||
autoCreatePr: false,
|
||||
autoBackupEnabled: false,
|
||||
autoBackupSchedule: "0 2 * * *",
|
||||
autoBackupRetention: 7,
|
||||
autoBackupDir: ".fusion/backups",
|
||||
autoSummarizeTitles: false,
|
||||
titleSummarizerProvider: undefined,
|
||||
titleSummarizerModelId: undefined,
|
||||
titleSummarizerFallbackProvider: undefined,
|
||||
titleSummarizerFallbackModelId: undefined,
|
||||
scripts: undefined,
|
||||
setupScript: undefined,
|
||||
insightExtractionEnabled: false,
|
||||
insightExtractionSchedule: "0 2 * * *",
|
||||
insightExtractionMinIntervalMs: 86_400_000,
|
||||
memoryEnabled: true,
|
||||
memoryBackendType: "file",
|
||||
tokenCap: undefined,
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: 2,
|
||||
missionStaleThresholdMs: 600_000,
|
||||
missionMaxTaskRetries: 3,
|
||||
missionHealthCheckIntervalMs: 300_000,
|
||||
agentPrompts: undefined,
|
||||
promptOverrides: undefined,
|
||||
reflectionEnabled: false,
|
||||
reflectionIntervalMs: 3_600_000,
|
||||
reflectionAfterTask: true,
|
||||
reviewHandoffPolicy: "disabled",
|
||||
showQuickChatFAB: false,
|
||||
} satisfies CompleteSettings<ProjectSettings>;
|
||||
|
||||
/**
|
||||
* Merged default settings (backward compatible).
|
||||
* This combines global and project defaults into a single object
|
||||
* that matches the legacy `DEFAULT_SETTINGS` shape.
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
};
|
||||
|
||||
/** Keys that belong to the global settings scope. */
|
||||
export const GLOBAL_SETTINGS_KEYS = Object.freeze(
|
||||
Object.keys(DEFAULT_GLOBAL_SETTINGS) as Array<keyof GlobalSettings>,
|
||||
);
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
export const PROJECT_SETTINGS_KEYS = Object.freeze(
|
||||
Object.keys(DEFAULT_PROJECT_SETTINGS) as Array<keyof ProjectSettings>,
|
||||
);
|
||||
|
||||
export function isGlobalSettingsKey(key: string): key is keyof GlobalSettings {
|
||||
return (GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function isProjectSettingsKey(key: string): key is keyof ProjectSettings {
|
||||
return (PROJECT_SETTINGS_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { runCommandAsync } from "./run-command.js";
|
||||
const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
||||
|
||||
import { TaskStore } from "./store.js";
|
||||
import { readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -2186,6 +2186,46 @@ Task with acceptance criteria
|
||||
expect(logs[4].text).toBe("chunk 4");
|
||||
});
|
||||
|
||||
it("can return only the most recent agent log entries while skipping malformed lines", async () => {
|
||||
const task = await createTestTask();
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
const logPath = join(dir, "agent.log");
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i === 2) {
|
||||
await appendFile(logPath, "{not valid json}\n");
|
||||
}
|
||||
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
|
||||
}
|
||||
|
||||
const logs = await store.getAgentLogs(task.id, { limit: 2 });
|
||||
|
||||
expect(logs.map((entry) => entry.text)).toEqual(["chunk 3", "chunk 4"]);
|
||||
});
|
||||
|
||||
it("preserves long entry fields when returning a bounded tail", async () => {
|
||||
const task = await createTestTask();
|
||||
const longText = [
|
||||
"## Long Tail Entry",
|
||||
"",
|
||||
"This entry should survive a bounded tail read in full.",
|
||||
"Z".repeat(800),
|
||||
].join("\n");
|
||||
const longDetail = "detail/".repeat(120) + "AgentLogViewer.tsx";
|
||||
|
||||
await store.appendAgentLog(task.id, "older entry", "text");
|
||||
await store.appendAgentLog(task.id, longText, "tool", longDetail, "executor");
|
||||
await store.appendAgentLog(task.id, "newest entry", "text");
|
||||
|
||||
const logs = await store.getAgentLogs(task.id, { limit: 2 });
|
||||
|
||||
expect(logs.map((entry) => entry.text)).toEqual([longText, "newest entry"]);
|
||||
expect(logs[0].detail).toBe(longDetail);
|
||||
expect(logs[0].agent).toBe("executor");
|
||||
expect(logs[0].text.length).toBe(longText.length);
|
||||
expect(logs[0].detail!.length).toBe(longDetail.length);
|
||||
});
|
||||
|
||||
it("appendAgentLog persists and reads back the agent field", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
@@ -3387,6 +3427,45 @@ Task with acceptance criteria
|
||||
expect(moved.nextRecoveryAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats repeated done finalization as an idempotent no-op", async () => {
|
||||
const task = await store.createTask({ description: "test repeated done finalization" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
const done = await store.moveTask(task.id, "done");
|
||||
|
||||
const repeated = await store.moveTask(task.id, "done");
|
||||
|
||||
expect(repeated.column).toBe("done");
|
||||
expect(repeated.updatedAt).toBe(done.updatedAt);
|
||||
});
|
||||
|
||||
it("normalizes stale completion fields on repeated done finalization", async () => {
|
||||
const task = await store.createTask({ description: "test repeated dirty done finalization" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, {
|
||||
status: "failed",
|
||||
error: "stale failure",
|
||||
blockedBy: "FN-000",
|
||||
worktree: "/tmp/fusion-stale-worktree",
|
||||
recoveryRetryCount: 2,
|
||||
nextRecoveryAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
});
|
||||
|
||||
const repeated = await store.moveTask(task.id, "done");
|
||||
|
||||
expect(repeated.column).toBe("done");
|
||||
expect(repeated.status).toBeUndefined();
|
||||
expect(repeated.error).toBeUndefined();
|
||||
expect(repeated.blockedBy).toBeUndefined();
|
||||
expect(repeated.worktree).toBeUndefined();
|
||||
expect(repeated.recoveryRetryCount).toBeUndefined();
|
||||
expect(repeated.nextRecoveryAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks moving failed in-review tasks to done", async () => {
|
||||
const task = await store.createTask({ description: "test block failed review task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
||||
import { appendFile, mkdir, open, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
|
||||
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
|
||||
@@ -673,9 +673,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// Extract only project-level keys from config.settings
|
||||
const projectSettings: Partial<ProjectSettings> = {};
|
||||
if (config.settings) {
|
||||
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||
for (const key of Object.keys(config.settings)) {
|
||||
if (!globalKeySet.has(key)) {
|
||||
if (!isGlobalSettingsKey(key)) {
|
||||
(projectSettings as any)[key] = (config.settings as any)[key];
|
||||
}
|
||||
}
|
||||
@@ -695,7 +694,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// Filter out global-only fields — they should go through updateGlobalSettings()
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!(GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key)) {
|
||||
if (!isGlobalSettingsKey(key)) {
|
||||
(projectPatch as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1466,7 +1465,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> {
|
||||
const tasks = await this.listTasks();
|
||||
const tasks = await this.listTasks({ slim: true });
|
||||
if (tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -1547,6 +1546,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
if (task.column === "done" && toColumn === "done") {
|
||||
if (this.clearDoneTransientFields(task)) {
|
||||
task.updatedAt = new Date().toISOString();
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
const validTargets = VALID_TRANSITIONS[task.column];
|
||||
if (!validTargets.includes(toColumn)) {
|
||||
throw new Error(
|
||||
@@ -1568,12 +1577,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
// Clear transient fields when moving to done (matches moveToDone behavior)
|
||||
if (toColumn === "done") {
|
||||
task.status = undefined;
|
||||
task.error = undefined;
|
||||
task.worktree = undefined;
|
||||
task.blockedBy = undefined;
|
||||
task.recoveryRetryCount = undefined;
|
||||
task.nextRecoveryAt = undefined;
|
||||
this.clearDoneTransientFields(task);
|
||||
}
|
||||
|
||||
// Clear transient fields when reopening/resetting a task into todo/triage.
|
||||
@@ -2017,11 +2021,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* Scans all tasks' logs for entries whose runContext.runId matches.
|
||||
*/
|
||||
async getMutationsForRun(runId: string): Promise<TaskLogEntry[]> {
|
||||
const allTasks = await this.listTasks();
|
||||
const rows = this.db.prepare("SELECT log FROM tasks").all() as Array<{ log: string | null }>;
|
||||
const mutations: TaskLogEntry[] = [];
|
||||
for (const task of allTasks) {
|
||||
if (!task.log) continue;
|
||||
for (const entry of task.log) {
|
||||
for (const row of rows) {
|
||||
const logEntries = fromJson<TaskLogEntry[]>(row.log) || [];
|
||||
for (const entry of logEntries) {
|
||||
if (entry.runContext?.runId === runId) {
|
||||
mutations.push(entry);
|
||||
}
|
||||
@@ -2388,13 +2392,53 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const branch = `fusion/${id.toLowerCase()}`;
|
||||
|
||||
if (task.column === "done") {
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
branch,
|
||||
merged: false,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
};
|
||||
|
||||
const worktreePath = task.worktree;
|
||||
const changed = this.clearDoneTransientFields(task);
|
||||
|
||||
if (worktreePath && existsSync(worktreePath)) {
|
||||
const removeWorktree = await this.runGitCommand(`git worktree remove "${worktreePath}" --force`, 120_000);
|
||||
if (removeWorktree.exitCode === 0) {
|
||||
result.worktreeRemoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBranch = await this.runGitCommand(`git branch -d "${branch}"`);
|
||||
if (deleteBranch.exitCode === 0) {
|
||||
result.branchDeleted = true;
|
||||
} else {
|
||||
const forceDeleteBranch = await this.runGitCommand(`git branch -D "${branch}"`);
|
||||
if (forceDeleteBranch.exitCode === 0) {
|
||||
result.branchDeleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
task.updatedAt = new Date().toISOString();
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
result.task = task;
|
||||
return result;
|
||||
}
|
||||
|
||||
const mergeBlocker = getTaskMergeBlocker(task);
|
||||
if (mergeBlocker) {
|
||||
throw new Error(`Cannot merge ${id}: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
const branch = `fusion/${id.toLowerCase()}`;
|
||||
const worktreePath = task.worktree;
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
@@ -2477,8 +2521,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* Returns an array of archived tasks.
|
||||
*/
|
||||
async archiveAllDone(): Promise<Task[]> {
|
||||
const tasks = await this.listTasks();
|
||||
const doneTasks = tasks.filter((t) => t.column === "done");
|
||||
const doneTasks = await this.listTasks({ slim: true, column: "done" });
|
||||
|
||||
if (doneTasks.length === 0) {
|
||||
return [];
|
||||
@@ -2680,18 +2723,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
private async moveToDone(task: Task, dir: string): Promise<void> {
|
||||
if (task.column === "done") {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromColumn = task.column;
|
||||
const mergeBlocker = getTaskMergeBlocker(task);
|
||||
if (mergeBlocker) {
|
||||
throw new Error(`Cannot move ${task.id} to done: ${mergeBlocker}`);
|
||||
}
|
||||
|
||||
task.column = "done";
|
||||
task.worktree = undefined;
|
||||
task.status = undefined;
|
||||
task.error = undefined;
|
||||
task.blockedBy = undefined;
|
||||
task.recoveryRetryCount = undefined;
|
||||
task.nextRecoveryAt = undefined;
|
||||
this.clearDoneTransientFields(task);
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
|
||||
@@ -2700,7 +2743,25 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// Update cache if watcher is active
|
||||
if (this.isWatching) this.taskCache.set(task.id, { ...task });
|
||||
|
||||
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
|
||||
this.emit("task:moved", { task, from: fromColumn, to: "done" as Column });
|
||||
}
|
||||
|
||||
private clearDoneTransientFields(task: Task): boolean {
|
||||
const changed = task.status !== undefined
|
||||
|| task.error !== undefined
|
||||
|| task.worktree !== undefined
|
||||
|| task.blockedBy !== undefined
|
||||
|| task.recoveryRetryCount !== undefined
|
||||
|| task.nextRecoveryAt !== undefined;
|
||||
|
||||
task.status = undefined;
|
||||
task.error = undefined;
|
||||
task.worktree = undefined;
|
||||
task.blockedBy = undefined;
|
||||
task.recoveryRetryCount = undefined;
|
||||
task.nextRecoveryAt = undefined;
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ── File-system watcher ───────────────────────────────────────────
|
||||
@@ -2713,8 +2774,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
async watch(): Promise<void> {
|
||||
if (this.watcher || this.pollInterval) return; // already watching
|
||||
|
||||
// Populate cache with current state
|
||||
const tasks = await this.listTasks();
|
||||
// Populate cache with current state. The watcher only needs metadata to
|
||||
// detect created/updated/moved/deleted events; full task logs stay on the
|
||||
// detail path.
|
||||
const tasks = await this.listTasks({ slim: true });
|
||||
this.taskCache.clear();
|
||||
for (const task of tasks) {
|
||||
this.taskCache.set(task.id, { ...task });
|
||||
@@ -2769,9 +2832,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
// Only load tasks modified since our last known timestamp.
|
||||
// Use lastKnownPollTime (ISO string) to filter — much cheaper than full scan.
|
||||
const selectClause = this.getTaskSelectClause(true);
|
||||
const changedRows = this.lastPollTime
|
||||
? this.db.prepare('SELECT * FROM tasks WHERE updatedAt > ? OR columnMovedAt > ?').all(this.lastPollTime, this.lastPollTime) as any[]
|
||||
: this.db.prepare('SELECT * FROM tasks').all() as any[];
|
||||
? this.db.prepare(`SELECT ${selectClause} FROM tasks WHERE updatedAt > ? OR columnMovedAt > ?`).all(this.lastPollTime, this.lastPollTime) as any[]
|
||||
: this.db.prepare(`SELECT ${selectClause} FROM tasks`).all() as any[];
|
||||
this.lastPollTime = new Date().toISOString();
|
||||
|
||||
for (const row of changedRows) {
|
||||
@@ -2976,6 +3040,75 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.emit("agent:log", entry);
|
||||
}
|
||||
|
||||
private parseAgentLogLine(line: string): AgentLogEntry | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as AgentLogEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseAgentLogContent(content: string): AgentLogEntry[] {
|
||||
const entries: AgentLogEntry[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const entry = this.parseAgentLogLine(line);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async readAgentLogTail(logPath: string, limit: number): Promise<AgentLogEntry[]> {
|
||||
const handle = await open(logPath, "r");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
if (size === 0) return [];
|
||||
|
||||
const chunkSize = 64 * 1024;
|
||||
let position = size;
|
||||
let buffer = Buffer.alloc(0);
|
||||
const entriesNewestFirst: AgentLogEntry[] = [];
|
||||
|
||||
while (position > 0 && entriesNewestFirst.length < limit) {
|
||||
const readSize = Math.min(chunkSize, position);
|
||||
position -= readSize;
|
||||
|
||||
const chunk = Buffer.allocUnsafe(readSize);
|
||||
const { bytesRead } = await handle.read(chunk, 0, readSize, position);
|
||||
if (bytesRead <= 0) break;
|
||||
|
||||
buffer = Buffer.concat([chunk.subarray(0, bytesRead), buffer]);
|
||||
|
||||
while (entriesNewestFirst.length < limit) {
|
||||
const newlineIndex = buffer.lastIndexOf(10);
|
||||
if (newlineIndex === -1) break;
|
||||
|
||||
const lineBuffer = buffer.subarray(newlineIndex + 1);
|
||||
buffer = buffer.subarray(0, newlineIndex);
|
||||
|
||||
if (lineBuffer.length === 0) continue;
|
||||
|
||||
const entry = this.parseAgentLogLine(lineBuffer.toString("utf-8"));
|
||||
if (entry) {
|
||||
entriesNewestFirst.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entriesNewestFirst.length < limit && buffer.length > 0) {
|
||||
const entry = this.parseAgentLogLine(buffer.toString("utf-8"));
|
||||
if (entry) {
|
||||
entriesNewestFirst.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entriesNewestFirst.reverse();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async addTaskComment(id: string, text: string, author: string): Promise<Task> {
|
||||
// Delegate to unified addComment method
|
||||
return this.addComment(id, text, author);
|
||||
@@ -3494,21 +3627,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* @param taskId - The task ID (e.g. "KB-001")
|
||||
* @returns Array of agent log entries, empty if no log file exists
|
||||
*/
|
||||
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
async getAgentLogs(taskId: string, options?: { limit?: number }): Promise<AgentLogEntry[]> {
|
||||
const dir = this.taskDir(taskId);
|
||||
const logPath = join(dir, "agent.log");
|
||||
if (!existsSync(logPath)) return [];
|
||||
const content = await readFile(logPath, "utf-8");
|
||||
const entries: AgentLogEntry[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(line) as AgentLogEntry);
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
if (options?.limit !== undefined) {
|
||||
const limit = Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0;
|
||||
if (limit === 0) return [];
|
||||
return this.readAgentLogTail(logPath, limit);
|
||||
}
|
||||
return entries;
|
||||
|
||||
const content = await readFile(logPath, "utf-8");
|
||||
return this.parseAgentLogContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3559,9 +3689,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* and removing task directories. Also removes from tasks table.
|
||||
*/
|
||||
async cleanupArchivedTasks(): Promise<string[]> {
|
||||
const archivedTasks = await this.listTasks().then((tasks) =>
|
||||
tasks.filter((t) => t.column === "archived"),
|
||||
);
|
||||
const archivedTasks = await this.listTasks({ column: "archived" });
|
||||
|
||||
const cleanedUpIds: string[] = [];
|
||||
|
||||
@@ -4021,7 +4149,7 @@ ${stepsSection}`;
|
||||
|
||||
// Clean up references from existing tasks (best-effort, outside config lock)
|
||||
try {
|
||||
const tasks = await this.listTasks();
|
||||
const tasks = await this.listTasks({ slim: true });
|
||||
for (const task of tasks) {
|
||||
if (task.enabledWorkflowSteps?.includes(id)) {
|
||||
const updated = task.enabledWorkflowSteps.filter((wsId) => wsId !== id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { StepStatus } from "./types.js";
|
||||
import { getTaskMergeBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
import { getTaskCompletionBlocker, getTaskMergeBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
|
||||
const baseTask = {
|
||||
column: "in-review" as const,
|
||||
@@ -11,6 +11,11 @@ const baseTask = {
|
||||
workflowStepResults: undefined as any,
|
||||
};
|
||||
|
||||
const baseCompletionTask = {
|
||||
dependencies: [] as string[],
|
||||
blockedBy: undefined as string | undefined,
|
||||
};
|
||||
|
||||
describe("getTaskMergeBlocker", () => {
|
||||
it("returns undefined for a clean task in review", () => {
|
||||
expect(getTaskMergeBlocker(baseTask)).toBeUndefined();
|
||||
@@ -191,3 +196,42 @@ describe("isTaskReadyForMerge", () => {
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTaskCompletionBlocker", () => {
|
||||
it("returns undefined for a task with no blockers", async () => {
|
||||
await expect(getTaskCompletionBlocker(baseCompletionTask)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a reason when task has blockedBy", async () => {
|
||||
await expect(getTaskCompletionBlocker({ ...baseCompletionTask, blockedBy: "FN-123" }))
|
||||
.resolves.toBe("task is blocked by FN-123");
|
||||
});
|
||||
|
||||
it("returns a reason when a dependency is unresolved", async () => {
|
||||
const resolveTask = async (taskId: string) => {
|
||||
if (taskId === "FN-001") {
|
||||
return { id: "FN-001", column: "done" as const };
|
||||
}
|
||||
if (taskId === "FN-002") {
|
||||
return { id: "FN-002", column: "in-progress" as const };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
await expect(getTaskCompletionBlocker({
|
||||
...baseCompletionTask,
|
||||
dependencies: ["FN-001", "FN-002"],
|
||||
}, { resolveTask }))
|
||||
.resolves.toBe("task has unresolved dependencies: FN-002");
|
||||
});
|
||||
|
||||
it("returns undefined when all dependencies are resolved", async () => {
|
||||
const resolveTask = async (taskId: string) => ({ id: taskId, column: "done" as const });
|
||||
|
||||
await expect(getTaskCompletionBlocker({
|
||||
...baseCompletionTask,
|
||||
dependencies: ["FN-001", "FN-002"],
|
||||
}, { resolveTask }))
|
||||
.resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,3 +68,44 @@ export function isTaskReadyForMerge(
|
||||
): boolean {
|
||||
return getTaskMergeBlocker(task) === undefined;
|
||||
}
|
||||
|
||||
export interface TaskCompletionBlockerOptions {
|
||||
resolveTask?: (taskId: string) => Promise<Pick<Task, "id" | "column"> | null | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable reason when a task should not be treated as
|
||||
* successfully complete yet. Undefined means the task can be finalized.
|
||||
*
|
||||
* This is intentionally conservative: if dependency state cannot be resolved,
|
||||
* the helper only blocks when the task itself carries enough state to prove
|
||||
* completion is unsafe (`blockedBy`).
|
||||
*/
|
||||
export async function getTaskCompletionBlocker(
|
||||
task: Pick<Task, "blockedBy" | "dependencies">,
|
||||
options: TaskCompletionBlockerOptions = {},
|
||||
): Promise<string | undefined> {
|
||||
if (task.blockedBy?.trim()) {
|
||||
return `task is blocked by ${task.blockedBy.trim()}`;
|
||||
}
|
||||
|
||||
const dependencies = task.dependencies ?? [];
|
||||
if (dependencies.length === 0 || !options.resolveTask) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unresolvedDependencies: string[] = [];
|
||||
|
||||
for (const dependencyId of dependencies) {
|
||||
const dependency = await options.resolveTask(dependencyId);
|
||||
if (!dependency || (dependency.column !== "done" && dependency.column !== "archived")) {
|
||||
unresolvedDependencies.push(dependencyId);
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedDependencies.length > 0) {
|
||||
return `task has unresolved dependencies: ${unresolvedDependencies.join(", ")}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1156,7 +1156,7 @@ export interface ProjectSettings {
|
||||
reviewHandoffPolicy?: "disabled" | "comment-triggered" | "always";
|
||||
/** When true, show the quick-chat floating action button (FAB) in the dashboard.
|
||||
* When false, the FAB is hidden but chat remains accessible via the More menu.
|
||||
* Default: true. */
|
||||
* Default: false. */
|
||||
showQuickChatFAB?: boolean;
|
||||
}
|
||||
|
||||
@@ -1177,229 +1177,15 @@ export interface Settings extends GlobalSettings, ProjectSettings {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Default values for global (user-level) settings. */
|
||||
export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode" | "colorTheme">> & GlobalSettings = {
|
||||
themeMode: "dark",
|
||||
colorTheme: "default",
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
fallbackProvider: undefined,
|
||||
fallbackModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||||
ntfyDashboardHost: undefined,
|
||||
defaultProjectId: undefined,
|
||||
setupComplete: undefined,
|
||||
favoriteProviders: undefined,
|
||||
favoriteModels: undefined,
|
||||
openrouterModelSync: true,
|
||||
modelOnboardingComplete: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
worktreeInitCommand: undefined,
|
||||
testCommand: undefined,
|
||||
buildCommand: undefined,
|
||||
recycleWorktrees: false,
|
||||
worktreeNaming: "random",
|
||||
taskPrefix: "FN",
|
||||
includeTaskIdInCommit: true,
|
||||
planningProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
planningFallbackProvider: undefined,
|
||||
planningFallbackModelId: undefined,
|
||||
validatorProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
validatorFallbackProvider: undefined,
|
||||
validatorFallbackModelId: undefined,
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
strictScopeEnforcement: false,
|
||||
buildRetryCount: 0,
|
||||
buildTimeoutMs: 300_000,
|
||||
requirePlanApproval: false,
|
||||
taskStuckTimeoutMs: undefined,
|
||||
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
||||
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
|
||||
autoUnpauseEnabled: true,
|
||||
autoUnpauseBaseDelayMs: 300_000,
|
||||
autoUnpauseMaxDelayMs: 3_600_000,
|
||||
maxStuckKills: 6,
|
||||
maxSpawnedAgentsPerParent: 5,
|
||||
maxSpawnedAgentsGlobal: 20,
|
||||
maintenanceIntervalMs: 900_000,
|
||||
autoUpdatePrStatus: false,
|
||||
autoCreatePr: false,
|
||||
autoBackupEnabled: false,
|
||||
autoBackupSchedule: "0 2 * * *",
|
||||
autoBackupRetention: 7,
|
||||
autoBackupDir: ".fusion/backups",
|
||||
autoSummarizeTitles: false,
|
||||
titleSummarizerProvider: undefined,
|
||||
titleSummarizerModelId: undefined,
|
||||
titleSummarizerFallbackProvider: undefined,
|
||||
titleSummarizerFallbackModelId: undefined,
|
||||
scripts: undefined,
|
||||
setupScript: undefined,
|
||||
insightExtractionEnabled: false,
|
||||
insightExtractionSchedule: "0 2 * * *",
|
||||
insightExtractionMinIntervalMs: 86_400_000,
|
||||
memoryEnabled: true,
|
||||
memoryBackendType: "file",
|
||||
tokenCap: undefined,
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: 2,
|
||||
missionStaleThresholdMs: 600_000,
|
||||
missionMaxTaskRetries: 3,
|
||||
missionHealthCheckIntervalMs: 300_000,
|
||||
agentPrompts: undefined,
|
||||
promptOverrides: undefined,
|
||||
reflectionEnabled: false,
|
||||
reflectionIntervalMs: 3_600_000,
|
||||
reflectionAfterTask: true,
|
||||
reviewHandoffPolicy: "disabled",
|
||||
showQuickChatFAB: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Merged default settings (backward compatible).
|
||||
* This combines global and project defaults into a single object
|
||||
* that matches the legacy `DEFAULT_SETTINGS` shape.
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
};
|
||||
|
||||
/** Keys that belong to the global settings scope. */
|
||||
export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"themeMode",
|
||||
"colorTheme",
|
||||
"defaultProvider",
|
||||
"defaultModelId",
|
||||
"fallbackProvider",
|
||||
"fallbackModelId",
|
||||
"defaultThinkingLevel",
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
"ntfyEvents",
|
||||
"ntfyDashboardHost",
|
||||
"defaultProjectId",
|
||||
"setupComplete",
|
||||
"favoriteProviders",
|
||||
"favoriteModels",
|
||||
"openrouterModelSync",
|
||||
"modelOnboardingComplete",
|
||||
] as const;
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
"globalPause",
|
||||
"enginePaused",
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
"pollIntervalMs",
|
||||
"groupOverlappingFiles",
|
||||
"autoMerge",
|
||||
"mergeStrategy",
|
||||
"worktreeInitCommand",
|
||||
"testCommand",
|
||||
"buildCommand",
|
||||
"recycleWorktrees",
|
||||
"worktreeNaming",
|
||||
"taskPrefix",
|
||||
"includeTaskIdInCommit",
|
||||
"planningProvider",
|
||||
"planningModelId",
|
||||
"planningFallbackProvider",
|
||||
"planningFallbackModelId",
|
||||
"validatorProvider",
|
||||
"validatorModelId",
|
||||
"validatorFallbackProvider",
|
||||
"validatorFallbackModelId",
|
||||
"modelPresets",
|
||||
"autoSelectModelPreset",
|
||||
"defaultPresetBySize",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"strictScopeEnforcement",
|
||||
"buildRetryCount",
|
||||
"buildTimeoutMs",
|
||||
"requirePlanApproval",
|
||||
"taskStuckTimeoutMs",
|
||||
"autoUnpauseEnabled",
|
||||
"autoUnpauseBaseDelayMs",
|
||||
"autoUnpauseMaxDelayMs",
|
||||
"aiSessionTtlMs",
|
||||
"aiSessionCleanupIntervalMs",
|
||||
"maxStuckKills",
|
||||
"autoUpdatePrStatus",
|
||||
"autoCreatePr",
|
||||
"autoBackupEnabled",
|
||||
"autoBackupSchedule",
|
||||
"autoBackupRetention",
|
||||
"autoBackupDir",
|
||||
"autoSummarizeTitles",
|
||||
"titleSummarizerProvider",
|
||||
"titleSummarizerModelId",
|
||||
"titleSummarizerFallbackProvider",
|
||||
"titleSummarizerFallbackModelId",
|
||||
"scripts",
|
||||
"setupScript",
|
||||
"tokenCap",
|
||||
"insightExtractionEnabled",
|
||||
"insightExtractionSchedule",
|
||||
"insightExtractionMinIntervalMs",
|
||||
"memoryEnabled",
|
||||
"memoryBackendType",
|
||||
"maxSpawnedAgentsPerParent",
|
||||
"maxSpawnedAgentsGlobal",
|
||||
"maintenanceIntervalMs",
|
||||
"runStepsInNewSessions",
|
||||
"maxParallelSteps",
|
||||
"missionStaleThresholdMs",
|
||||
"missionMaxTaskRetries",
|
||||
"missionHealthCheckIntervalMs",
|
||||
"agentPrompts",
|
||||
"promptOverrides",
|
||||
"reflectionEnabled",
|
||||
"reflectionIntervalMs",
|
||||
"reflectionAfterTask",
|
||||
"reviewHandoffPolicy",
|
||||
"showQuickChatFAB",
|
||||
] as const;
|
||||
|
||||
// ── Compile-time parity: ensures every interface key is listed exactly once ──
|
||||
// If either assertion fails with "Type 'X' is not assignable to type 'Y'",
|
||||
// a key was added to the interface without updating the corresponding array
|
||||
// (or vice versa). Add or remove the key to fix.
|
||||
type _GlobalKeysCheck = typeof GLOBAL_SETTINGS_KEYS[number] extends keyof GlobalSettings
|
||||
? keyof GlobalSettings extends typeof GLOBAL_SETTINGS_KEYS[number]
|
||||
? true
|
||||
: never
|
||||
: never;
|
||||
const _globalParity: _GlobalKeysCheck = true as _GlobalKeysCheck;
|
||||
|
||||
type _ProjectKeysCheck = typeof PROJECT_SETTINGS_KEYS[number] extends keyof ProjectSettings
|
||||
? keyof ProjectSettings extends typeof PROJECT_SETTINGS_KEYS[number]
|
||||
? true
|
||||
: never
|
||||
: never;
|
||||
const _projectParity: _ProjectKeysCheck = true as _ProjectKeysCheck;
|
||||
export {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
DEFAULT_SETTINGS,
|
||||
GLOBAL_SETTINGS_KEYS,
|
||||
PROJECT_SETTINGS_KEYS,
|
||||
isGlobalSettingsKey,
|
||||
isProjectSettingsKey,
|
||||
} from "./settings-schema.js";
|
||||
|
||||
export interface BoardConfig {
|
||||
nextId: number;
|
||||
@@ -2730,4 +2516,3 @@ export interface Mailbox {
|
||||
|
||||
// Re-export PROMPT_KEY_CATALOG for backward compatibility with vite alias
|
||||
export { PROMPT_KEY_CATALOG } from "./prompt-overrides.js";
|
||||
|
||||
|
||||
@@ -343,8 +343,13 @@ export async function deleteAttachment(id: string, filename: string, projectId?:
|
||||
return api<Task>(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchAgentLogs(taskId: string, projectId?: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs`, projectId));
|
||||
export function fetchAgentLogs(taskId: string, projectId?: string, options?: { limit?: number }): Promise<AgentLogEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) {
|
||||
params.set("limit", String(options.limit));
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId));
|
||||
}
|
||||
|
||||
export function fetchSessionFiles(taskId: string, projectId?: string): Promise<string[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Globe, Folder } from "lucide-react";
|
||||
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, PROMPT_KEY_CATALOG } from "@fusion/core";
|
||||
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
||||
@@ -497,19 +497,17 @@ export function SettingsModal({
|
||||
// updateGlobalSettings ignores project keys). This ensures fields in sections
|
||||
// are persisted correctly based on their scope.
|
||||
|
||||
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||
const globalPatch: Partial<GlobalSettings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (globalKeySet.has(key)) {
|
||||
if (isGlobalSettingsKey(key)) {
|
||||
(globalPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const projectKeySet = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "githubTokenConfigured") continue; // server-only field
|
||||
if (projectKeySet.has(key)) {
|
||||
if (isProjectSettingsKey(key)) {
|
||||
(projectPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries).toEqual(historicalLogs);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined);
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 });
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
|
||||
@@ -98,8 +98,8 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["FN-002"].entries).toEqual(logs2);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-002");
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 });
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-002", undefined, { limit: 500 });
|
||||
});
|
||||
|
||||
it("opens SSE EventSource for each task ID", async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
* Hook that manages agent log fetching and live SSE streaming for a task.
|
||||
*
|
||||
* When `enabled` is true:
|
||||
* 1. Fetches historical logs via GET /api/tasks/:id/logs
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
@@ -53,7 +53,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(currentTaskId, projectId);
|
||||
const historical = await fetchAgentLogs(currentTaskId, projectId, { limit: MAX_LOG_ENTRIES });
|
||||
if (cancelled) return;
|
||||
setEntries(capLogEntries(historical));
|
||||
} catch {
|
||||
|
||||
@@ -35,7 +35,7 @@ interface InitState {
|
||||
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
|
||||
*
|
||||
* For each task ID in the provided array:
|
||||
* 1. Fetches historical logs via GET /api/tasks/:id/logs
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
@@ -195,7 +195,7 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
es.addEventListener("error", handleError);
|
||||
|
||||
// Fetch historical logs
|
||||
void fetchAgentLogs(taskId)
|
||||
void fetchAgentLogs(taskId, undefined, { limit: MAX_LOG_ENTRIES })
|
||||
.then((historical) => {
|
||||
if (cancelled[taskId]) return;
|
||||
|
||||
|
||||
@@ -2970,7 +2970,7 @@ describe("Attachment routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(fakeLogs);
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001", undefined);
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
||||
|
||||
@@ -2829,7 +2829,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.get("/tasks/:id/logs", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const logs = await scopedStore.getAgentLogs(req.params.id);
|
||||
const limit = typeof req.query.limit === "string"
|
||||
? Number.parseInt(req.query.limit, 10)
|
||||
: undefined;
|
||||
const logs = await scopedStore.getAgentLogs(req.params.id, limit !== undefined && Number.isFinite(limit)
|
||||
? { limit }
|
||||
: undefined);
|
||||
res.json(logs);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -5904,6 +5904,77 @@ describe("TaskExecutor task_done with summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor task_done blockers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("rejects task_done when the task is explicitly blocked", async () => {
|
||||
const store = createMockStore();
|
||||
let capturedTool: any = null;
|
||||
|
||||
store.getTask.mockImplementation(async (taskId: string) => {
|
||||
if (taskId === "FN-001") {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Blocked task",
|
||||
description: "Blocked task",
|
||||
column: "in-progress",
|
||||
blockedBy: "FN-DEP-1",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Step 1", status: "in-progress" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: taskId,
|
||||
column: "done",
|
||||
};
|
||||
});
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async ({ customTools }: any) => {
|
||||
capturedTool = customTools?.find((t: any) => t.name === "task_done");
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Blocked task",
|
||||
description: "Blocked task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Step 1", status: "in-progress" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(capturedTool).toBeDefined();
|
||||
|
||||
store.updateStep.mockClear();
|
||||
store.updateTask.mockClear();
|
||||
|
||||
const result = await capturedTool.execute("tool-1", {});
|
||||
|
||||
expect(result.content[0].text).toContain("Cannot mark task done yet");
|
||||
expect(store.updateStep).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow Steps Execution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
taskCreateParams,
|
||||
taskLogParams,
|
||||
} from "./agent-tools.js";
|
||||
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
|
||||
// Re-export for backward compatibility (tests import from executor.ts)
|
||||
export { summarizeToolArgs } from "./agent-logger.js";
|
||||
@@ -619,11 +620,20 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
private async shouldFinalizeCompletedTask(taskId: string, taskDone: boolean): Promise<boolean> {
|
||||
if (taskDone) return true;
|
||||
const task = await this.store.getTask(taskId);
|
||||
const completionBlocker = await this.getTaskCompletionBlocker(task);
|
||||
if (completionBlocker) {
|
||||
executorLog.log(`${taskId} completion blocked — ${completionBlocker}`);
|
||||
return false;
|
||||
}
|
||||
if (taskDone) return true;
|
||||
return this.isTaskWorkComplete(task);
|
||||
}
|
||||
|
||||
private async getTaskCompletionBlocker(task: Task): Promise<string | undefined> {
|
||||
return getTaskCompletionBlockerForStore(this.store, task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a review handoff: move the task to in-review column with
|
||||
* awaiting-user-review status, assign the requesting user, and dispose
|
||||
@@ -2136,9 +2146,21 @@ export class TaskExecutor {
|
||||
})),
|
||||
}),
|
||||
execute: async (_id: string, params: { summary?: string }) => {
|
||||
onDone();
|
||||
// Mark all pending/in-progress steps as done
|
||||
const task = await store.getTask(taskId);
|
||||
const completionBlocker = await this.getTaskCompletionBlocker(task);
|
||||
if (completionBlocker) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Cannot mark task done yet — ${completionBlocker}. Resolve the blocker before calling task_done().`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
onDone();
|
||||
|
||||
// Mark all pending/in-progress steps as done
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
if (task.steps[i].status !== "done" && task.steps[i].status !== "skipped") {
|
||||
await store.updateStep(taskId, i, "done");
|
||||
|
||||
@@ -632,6 +632,40 @@ describe("MissionAutopilot", () => {
|
||||
autopilot.stop();
|
||||
});
|
||||
|
||||
it("does not promote a feature to done when the linked task has unresolved dependencies", async () => {
|
||||
autopilot.start();
|
||||
autopilot.watchMission("M-TEST1");
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
...createMockMission(),
|
||||
milestones: [{
|
||||
...createMockMilestone(),
|
||||
slices: [{
|
||||
...createMockSlice({ status: "active" }),
|
||||
features: [createMockFeature({ id: "F-001", status: "triaged", taskId: "FN-001" })],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
taskStore.getTask.mockImplementation(async (taskId: string) => {
|
||||
if (taskId === "FN-001") {
|
||||
return {
|
||||
id: "FN-001",
|
||||
column: "done",
|
||||
dependencies: ["FN-DEP-1"],
|
||||
blockedBy: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "FN-DEP-1",
|
||||
column: "in-progress",
|
||||
};
|
||||
});
|
||||
|
||||
await autopilot.recoverMissions(missionStore as any);
|
||||
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
|
||||
autopilot.stop();
|
||||
});
|
||||
|
||||
it("fixes feature status when task is in-progress but feature is triaged", async () => {
|
||||
autopilot.start();
|
||||
autopilot.watchMission("M-TEST1");
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
MissionEventType,
|
||||
} from "@fusion/core";
|
||||
import { autopilotLog } from "./logger.js";
|
||||
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
|
||||
|
||||
/** Maximum retry attempts for slice activation failures. */
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
@@ -762,32 +763,21 @@ export class MissionAutopilot {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.status === "failed" && feature.status === "in-progress") {
|
||||
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature);
|
||||
|
||||
if (reconciliation.kind === "failure") {
|
||||
await this.handleTaskFailure(feature.taskId);
|
||||
fixedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.column === "done" && feature.status !== "done") {
|
||||
this.missionStore.updateFeatureStatus(feature.id, "done");
|
||||
fixedCount++;
|
||||
if (reconciliation.kind === "blocked") {
|
||||
autopilotLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
task.column === "in-progress"
|
||||
&& (feature.status === "triaged" || feature.status === "defined")
|
||||
) {
|
||||
this.missionStore.updateFeatureStatus(feature.id, "in-progress");
|
||||
fixedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(task.column === "triage" || task.column === "todo")
|
||||
&& feature.status === "in-progress"
|
||||
) {
|
||||
this.missionStore.updateFeatureStatus(feature.id, "triaged");
|
||||
if (reconciliation.kind === "update") {
|
||||
this.missionStore.updateFeatureStatus(feature.id, reconciliation.status);
|
||||
fixedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
64
packages/engine/src/mission-feature-sync.ts
Normal file
64
packages/engine/src/mission-feature-sync.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { MissionFeature, Task, TaskStore } from "@fusion/core";
|
||||
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
|
||||
|
||||
export type MissionFeatureSyncTargetStatus = "done" | "in-progress" | "triaged";
|
||||
|
||||
export type MissionFeatureSyncDecision =
|
||||
| { kind: "failure"; reason: string }
|
||||
| { kind: "blocked"; reason: string }
|
||||
| { kind: "update"; status: MissionFeatureSyncTargetStatus; reason: string }
|
||||
| { kind: "noop" };
|
||||
|
||||
export async function reconcileMissionFeatureState(
|
||||
taskStore: Pick<TaskStore, "getTask">,
|
||||
task: Task,
|
||||
feature: Pick<MissionFeature, "id" | "status">,
|
||||
): Promise<MissionFeatureSyncDecision> {
|
||||
if (task.status === "failed" && feature.status === "in-progress") {
|
||||
return {
|
||||
kind: "failure",
|
||||
reason: `task ${task.id} failed while feature ${feature.id} is in-progress`,
|
||||
};
|
||||
}
|
||||
|
||||
if (task.column === "done") {
|
||||
const blocker = await getTaskCompletionBlockerForStore(taskStore, task);
|
||||
if (blocker) {
|
||||
return { kind: "blocked", reason: blocker };
|
||||
}
|
||||
|
||||
if (feature.status !== "done") {
|
||||
return {
|
||||
kind: "update",
|
||||
status: "done",
|
||||
reason: `task ${task.id} completed`,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "noop" };
|
||||
}
|
||||
|
||||
if (
|
||||
task.column === "in-progress"
|
||||
&& (feature.status === "triaged" || feature.status === "defined")
|
||||
) {
|
||||
return {
|
||||
kind: "update",
|
||||
status: "in-progress",
|
||||
reason: `task ${task.id} started`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(task.column === "triage" || task.column === "todo")
|
||||
&& feature.status === "in-progress"
|
||||
) {
|
||||
return {
|
||||
kind: "update",
|
||||
status: "triaged",
|
||||
reason: `task ${task.id} returned to triage`,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "noop" };
|
||||
}
|
||||
@@ -45,6 +45,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask()),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
@@ -1867,6 +1868,42 @@ describe("Scheduler", () => {
|
||||
// Delegates to autopilot, which internally checks autoAdvance
|
||||
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("does not mark a feature done when the completed task is blocked", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask({
|
||||
id: "FN-001",
|
||||
blockedBy: "FN-000",
|
||||
column: "done",
|
||||
})),
|
||||
});
|
||||
const mockAutopilot = {
|
||||
setScheduler: vi.fn(),
|
||||
watchMission: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
isWatching: vi.fn(() => true),
|
||||
};
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
status: "in-progress",
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
missionAutopilot: mockAutopilot as any,
|
||||
});
|
||||
|
||||
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(mockAutopilot.handleTaskCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileAllMissionFeatures", () => {
|
||||
@@ -1972,6 +2009,95 @@ describe("Scheduler", () => {
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("does not reconcile feature to done when the linked task has unresolved dependencies", async () => {
|
||||
const getTask = vi.fn(async (id: string) => {
|
||||
if (id === "FN-001") {
|
||||
return createMockTask({
|
||||
id: "FN-001",
|
||||
column: "done",
|
||||
dependencies: ["FN-000"],
|
||||
});
|
||||
}
|
||||
return createMockTask({ id, column: "in-progress" });
|
||||
});
|
||||
const store = createMockStore({ getTask: getTask as any });
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "in-progress",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("routes failed linked tasks through onTaskFailed during reconciliation", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockReturnValue(createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress",
|
||||
status: "failed",
|
||||
})),
|
||||
});
|
||||
const onTaskFailed = vi.fn().mockResolvedValue(undefined);
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "in-progress",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
onTaskFailed,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(onTaskFailed).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("updates feature to triaged when task moves back to todo and feature is in-progress", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "todo" })),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AgentSemaphore } from "./concurrency.js";
|
||||
import { generateReservedWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { schedulerLog } from "./logger.js";
|
||||
import { type PrMonitor, type PrComment } from "./pr-monitor.js";
|
||||
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
|
||||
|
||||
/**
|
||||
* Check whether two sets of file scope paths overlap.
|
||||
@@ -746,6 +747,10 @@ export class Scheduler {
|
||||
const missionStore = this.options.missionStore;
|
||||
|
||||
try {
|
||||
const task = await this.store.getTask(taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
const feature = missionStore.getFeatureByTaskId(taskId);
|
||||
if (!feature) return;
|
||||
|
||||
@@ -756,9 +761,24 @@ export class Scheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
const reconciliation = await reconcileMissionFeatureState(
|
||||
this.store,
|
||||
{ ...task, column: "done" },
|
||||
feature,
|
||||
);
|
||||
if (reconciliation.kind === "blocked") {
|
||||
schedulerLog.warn(`Task ${taskId} mission completion blocked — ${reconciliation.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconciliation.kind === "failure") {
|
||||
schedulerLog.warn(`Task ${taskId} mission completion reported failure — ${reconciliation.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sliceIdBeforeUpdate = feature.sliceId;
|
||||
|
||||
if (feature.status !== "done") {
|
||||
if (reconciliation.kind === "update" && reconciliation.status === "done") {
|
||||
missionStore.updateFeatureStatus(feature.id, "done");
|
||||
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
|
||||
}
|
||||
@@ -930,29 +950,25 @@ export class Scheduler {
|
||||
const task = await this.store.getTask(feature.taskId);
|
||||
if (!task) continue;
|
||||
|
||||
// Task done but feature not done -> update feature to done
|
||||
if (task.column === "done" && feature.status !== "done") {
|
||||
missionStore.updateFeatureStatus(feature.id, "done");
|
||||
totalFixed++;
|
||||
const reconciliation = await reconcileMissionFeatureState(this.store, task, feature);
|
||||
|
||||
if (reconciliation.kind === "failure") {
|
||||
if (this.options.onTaskFailed) {
|
||||
await this.options.onTaskFailed(task.id);
|
||||
totalFixed++;
|
||||
} else {
|
||||
schedulerLog.warn(`Skipping failed feature reconciliation for ${feature.id} — ${reconciliation.reason}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task in-progress and feature triaged/defined -> update to in-progress
|
||||
if (
|
||||
task.column === "in-progress"
|
||||
&& (feature.status === "triaged" || feature.status === "defined")
|
||||
) {
|
||||
missionStore.updateFeatureStatus(feature.id, "in-progress");
|
||||
totalFixed++;
|
||||
if (reconciliation.kind === "blocked") {
|
||||
schedulerLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task in triage/todo and feature in-progress -> update to triaged
|
||||
if (
|
||||
(task.column === "triage" || task.column === "todo")
|
||||
&& feature.status === "in-progress"
|
||||
) {
|
||||
missionStore.updateFeatureStatus(feature.id, "triaged");
|
||||
if (reconciliation.kind === "update") {
|
||||
missionStore.updateFeatureStatus(feature.id, reconciliation.status);
|
||||
totalFixed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverNoProgressNoTaskDoneFailures();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress" });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1473", {
|
||||
status: "stuck-killed",
|
||||
worktree: null,
|
||||
@@ -586,6 +587,7 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverCompletedTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress" });
|
||||
expect(recoverFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "FN-001" }),
|
||||
);
|
||||
|
||||
@@ -426,7 +426,7 @@ export class SelfHealingManager {
|
||||
if (!recoverFn) return 0;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
|
||||
const tasks = await this.store.listTasks({ column: "in-progress" });
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
|
||||
const stuckCompleted = tasks.filter((t) =>
|
||||
@@ -469,7 +469,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverMergeableReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
|
||||
const tasks = await this.store.listTasks({ column: "in-review" });
|
||||
|
||||
const mergeable = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
@@ -520,7 +520,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverMergedReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
|
||||
const tasks = await this.store.listTasks({ column: "in-review" });
|
||||
|
||||
const mergedButNotDone = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
@@ -573,7 +573,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverMisclassifiedFailures(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
|
||||
const tasks = await this.store.listTasks({ column: "in-review" });
|
||||
|
||||
const misclassified = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
@@ -622,7 +622,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverOrphanedExecutions(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
|
||||
const tasks = await this.store.listTasks({ column: "in-progress" });
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -690,7 +690,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverNoProgressNoTaskDoneFailures(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
|
||||
const tasks = await this.store.listTasks({ column: "in-progress" });
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
|
||||
const candidates = tasks.filter((task) =>
|
||||
@@ -789,7 +789,7 @@ export class SelfHealingManager {
|
||||
if (!recoverFn) return 0;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "triage" });
|
||||
const tasks = await this.store.listTasks({ column: "triage" });
|
||||
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
16
packages/engine/src/task-completion.ts
Normal file
16
packages/engine/src/task-completion.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getTaskCompletionBlocker, type Task, type TaskStore } from "@fusion/core";
|
||||
|
||||
export async function getTaskCompletionBlockerForStore(
|
||||
store: Pick<TaskStore, "getTask">,
|
||||
task: Task,
|
||||
): Promise<string | undefined> {
|
||||
return getTaskCompletionBlocker(task, {
|
||||
resolveTask: async (dependencyId) => {
|
||||
try {
|
||||
return await store.getTask(dependencyId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
readAttachmentContents,
|
||||
computeUserCommentFingerprint,
|
||||
} from "./triage.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, writeFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const { mockReviewStep, mockCreateKbAgent } = vi.hoisted(() => ({
|
||||
mockReviewStep: vi.fn(),
|
||||
@@ -34,7 +35,28 @@ vi.mock("@fusion/core", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
async function createTriageFixtureRoot(prefix: string): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function cleanupTriageFixtureRoot(rootDir: string | undefined): Promise<void> {
|
||||
if (!rootDir) return;
|
||||
|
||||
const retryableCodes = new Set(["ENOTEMPTY", "EBUSY", "EPERM"]);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error: any) {
|
||||
if (!retryableCodes.has(error?.code) || attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await delay(25 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
@@ -417,17 +439,20 @@ describe("TRIAGE_SYSTEM_PROMPT", () => {
|
||||
});
|
||||
|
||||
describe("readAttachmentContents", () => {
|
||||
const testDir = join(__dirname, "test-attachments");
|
||||
let testDir = "";
|
||||
const taskId = "FN-TEST";
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up and create test directory
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
testDir = await createTriageFixtureRoot("fusion-triage-attachments-");
|
||||
await mkdir(join(testDir, ".fusion", "tasks", taskId, "attachments"), {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTriageFixtureRoot(testDir);
|
||||
});
|
||||
|
||||
it("returns empty arrays when no attachments provided", async () => {
|
||||
const result = await readAttachmentContents(testDir, taskId, undefined);
|
||||
|
||||
@@ -578,75 +603,77 @@ describe("TriageProcessor", () => {
|
||||
|
||||
it("re-reads settings when review_spec runs so reviewer uses the latest validator model", async () => {
|
||||
const taskId = "FN-001";
|
||||
const testRootDir = join(__dirname, "__test_triage_review_spec__");
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
const taskDir = join(testRootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
|
||||
const testRootDir = await createTriageFixtureRoot("fusion-triage-review-spec-");
|
||||
try {
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
const taskDir = join(testRootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
|
||||
|
||||
const freshSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
validatorProvider: "zai",
|
||||
validatorModelId: "glm-5.1",
|
||||
};
|
||||
|
||||
store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue(freshSettings),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...mockTaskDetail,
|
||||
id: taskId,
|
||||
comments: [],
|
||||
}),
|
||||
});
|
||||
processor = new TriageProcessor(store, testRootDir);
|
||||
|
||||
mockReviewStep.mockResolvedValue({
|
||||
verdict: "APPROVE",
|
||||
review: "Looks good.",
|
||||
summary: "approved",
|
||||
});
|
||||
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-opus-4-6",
|
||||
validatorProvider: "anthropic",
|
||||
validatorModelId: "claude-opus-4-6",
|
||||
},
|
||||
);
|
||||
|
||||
await tool.execute({});
|
||||
|
||||
expect(store.getSettings).toHaveBeenCalled();
|
||||
expect(mockReviewStep).toHaveBeenCalledWith(
|
||||
testRootDir,
|
||||
taskId,
|
||||
0,
|
||||
"Specification",
|
||||
"spec",
|
||||
"# Spec\n\nCurrent prompt",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
const freshSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
validatorModelProvider: "zai",
|
||||
validatorProvider: "zai",
|
||||
validatorModelId: "glm-5.1",
|
||||
userComments: undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await rm(testRootDir, { recursive: true, force: true });
|
||||
store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue(freshSettings),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...mockTaskDetail,
|
||||
id: taskId,
|
||||
comments: [],
|
||||
}),
|
||||
});
|
||||
processor = new TriageProcessor(store, testRootDir);
|
||||
|
||||
mockReviewStep.mockResolvedValue({
|
||||
verdict: "APPROVE",
|
||||
review: "Looks good.",
|
||||
summary: "approved",
|
||||
});
|
||||
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-opus-4-6",
|
||||
validatorProvider: "anthropic",
|
||||
validatorModelId: "claude-opus-4-6",
|
||||
},
|
||||
);
|
||||
|
||||
await tool.execute({});
|
||||
|
||||
expect(store.getSettings).toHaveBeenCalled();
|
||||
expect(mockReviewStep).toHaveBeenCalledWith(
|
||||
testRootDir,
|
||||
taskId,
|
||||
0,
|
||||
"Specification",
|
||||
"spec",
|
||||
"# Spec\n\nCurrent prompt",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
validatorModelProvider: "zai",
|
||||
validatorModelId: "glm-5.1",
|
||||
userComments: undefined,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await cleanupTriageFixtureRoot(testRootDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -713,10 +740,14 @@ describe("Re-specification flow", () => {
|
||||
});
|
||||
|
||||
describe("requirePlanApproval setting", () => {
|
||||
const rootDir = join(__dirname, "__test_triage_approval__");
|
||||
let rootDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(rootDir, { recursive: true });
|
||||
rootDir = await createTriageFixtureRoot("fusion-triage-approval-");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
});
|
||||
|
||||
it("sets awaiting-approval status instead of moving to todo when requirePlanApproval is true", async () => {
|
||||
@@ -776,8 +807,6 @@ describe("requirePlanApproval setting", () => {
|
||||
// We can't easily run the full specifyTask without mocking the AI,
|
||||
// but we can verify the store setup is correct
|
||||
expect(await store.getSettings()).toHaveProperty("requirePlanApproval", true);
|
||||
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("auto-moves to todo when requirePlanApproval is false", async () => {
|
||||
@@ -813,9 +842,10 @@ describe("requirePlanApproval setting", () => {
|
||||
});
|
||||
|
||||
describe("approved triage recovery", () => {
|
||||
const rootDir = join(__dirname, "__test_triage_recovery__");
|
||||
let rootDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await createTriageFixtureRoot("fusion-triage-recovery-");
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
|
||||
await writeFile(
|
||||
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
||||
@@ -824,7 +854,7 @@ describe("approved triage recovery", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
});
|
||||
|
||||
it("moves approved specifying task to todo during recovery", async () => {
|
||||
@@ -1742,6 +1772,16 @@ describe("awaiting-approval poll exclusion", () => {
|
||||
});
|
||||
|
||||
describe("stale approval detection", () => {
|
||||
let rootDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await createTriageFixtureRoot("fusion-triage-stale-approval-");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
});
|
||||
|
||||
it("computeUserCommentFingerprint detects added user comment", () => {
|
||||
const before = [
|
||||
{ id: "c1", text: "First", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||
@@ -1771,7 +1811,6 @@ describe("stale approval detection", () => {
|
||||
});
|
||||
|
||||
it("captures fingerprint on review_spec APPROVE", async () => {
|
||||
const rootDir = join(__dirname, "__test_stale_approval_capture__");
|
||||
const taskId = "FN-CAP";
|
||||
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
@@ -1820,12 +1859,9 @@ describe("stale approval detection", () => {
|
||||
|
||||
// Verify fingerprint was captured from the user comments at approval time
|
||||
expect(approvedCommentFingerprintRef.current).toBe("c1");
|
||||
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fingerprint is empty string when review_spec returns REVISE (no capture)", async () => {
|
||||
const rootDir = join(__dirname, "__test_stale_approval_revise__");
|
||||
const taskId = "FN-REV";
|
||||
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
@@ -1869,8 +1905,6 @@ describe("stale approval detection", () => {
|
||||
|
||||
// Fingerprint should NOT be captured on REVISE
|
||||
expect(approvedCommentFingerprintRef.current).toBe("");
|
||||
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user