Harden architecture hot paths

This commit is contained in:
gsxdsm
2026-04-12 15:13:27 -07:00
parent 7b78963a4c
commit a34ba41ad1
38 changed files with 1212 additions and 561 deletions

View 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([]);
});
});

View File

@@ -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));

View File

@@ -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,

View 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);
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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";