fix(FN-000): harden project migration runtime

This commit is contained in:
gsxdsm
2026-04-02 17:55:25 -07:00
parent 05f2114743
commit c5913b951d
10 changed files with 305 additions and 56 deletions

View File

@@ -45,7 +45,11 @@ describe("CLI bundle output", () => {
if (platformDirs.length === 0) return;
const hasPlatform = platformDirs.some((platform) => existsSync(join(runtimeDir, platform, "pty.node")));
expect(hasPlatform).toBe(true);
const hasNativeAsset = platformDirs.some((platform) => {
const platformDir = join(runtimeDir, platform);
return readdirSync(platformDir).some((file) => file === "pty.node" || file === "spawn-helper");
});
expect(hasNativeAsset).toBe(true);
});
});

View File

@@ -2,12 +2,27 @@
* Tests for the init command
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInit } from "./init.js";
const mockCentralInit = vi.fn();
const mockCentralClose = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockRegisterProject = vi.fn();
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
getProjectByPath: mockGetProjectByPath,
registerProject: mockRegisterProject,
})),
resolveGlobalDir: vi.fn(),
}));
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
@@ -17,6 +32,15 @@ describe("init command", () => {
beforeEach(() => {
tempProjectDir = tempDir("fn-init-test-");
mockCentralInit.mockResolvedValue(undefined);
mockCentralClose.mockResolvedValue(undefined);
mockGetProjectByPath.mockResolvedValue(undefined);
mockRegisterProject.mockResolvedValue({
id: "proj_test",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
});
});
afterEach(() => {
@@ -46,6 +70,12 @@ describe("init command", () => {
it("should be idempotent - report already initialized", async () => {
// First init
await runInit({ path: tempProjectDir });
mockGetProjectByPath.mockResolvedValue({
id: "proj_test",
name: "registered-project",
path: tempProjectDir,
isolationMode: "in-process",
});
// Capture console output for second run
const originalLog = console.log;

View File

@@ -125,7 +125,7 @@ describe("TaskStore Backward Compatibility", () => {
createFakeFusionProject(projectDir);
process.chdir(projectDir);
const centralDb = join(tempDir, "kb-central.db");
const centralDb = join(tempDir, "fusion-central.db");
await centralCore.close();
rmSync(centralDb, { force: true });
centralCore = new CentralCore(tempDir);

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 5;
const SCHEMA_VERSION = 6;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -134,6 +134,7 @@ CREATE TABLE IF NOT EXISTS tasks (
blockedBy TEXT,
paused INTEGER DEFAULT 0,
baseBranch TEXT,
branch TEXT,
baseCommitSha TEXT,
modelPresetId TEXT,
modelProvider TEXT,

View File

@@ -113,10 +113,10 @@ export class FirstRunDetector {
const hasCentral = this.hasCentralDb();
if (!hasCentral) {
// No central DB - check for local .kb/ in cwd
// No central DB - check for local project in cwd or parent directories
const cwd = process.cwd();
const localKbExists = this.hasKbProject(cwd);
return localKbExists ? "needs-migration" : "fresh-install";
const detected = await this.detectExistingProjects(cwd);
return detected.length > 0 ? "needs-migration" : "fresh-install";
}
// Central DB exists - check if it has projects
@@ -129,7 +129,10 @@ export class FirstRunDetector {
await central.init();
shouldClose = true;
} catch {
return "setup-wizard";
// Central DB exists but is unreadable — fall back to local detection
const cwd = process.cwd();
const detected = await this.detectExistingProjects(cwd);
return detected.length > 0 ? "needs-migration" : "fresh-install";
}
}
@@ -150,7 +153,7 @@ export class FirstRunDetector {
* Check if the central database exists.
*/
hasCentralDb(): boolean {
const centralDbPath = join(this.globalDir, "kb-central.db");
const centralDbPath = join(this.globalDir, "fusion-central.db");
return existsSync(centralDbPath);
}
@@ -158,7 +161,7 @@ export class FirstRunDetector {
* Get the path to the central database.
*/
getCentralDbPath(): string {
return join(this.globalDir, "kb-central.db");
return join(this.globalDir, "fusion-central.db");
}
/**
@@ -270,10 +273,16 @@ export class FirstRunDetector {
* Check if a directory contains a valid kb project.
*/
private hasKbProject(dir: string): boolean {
const kbDir = join(dir, ".kb");
const dbPath = join(kbDir, "kb.db");
// Check for current .fusion/fusion.db or legacy .kb/kb.db
return this.hasProjectDbFile(dir, ".fusion", "fusion.db") ||
this.hasProjectDbFile(dir, ".kb", "kb.db");
}
if (!existsSync(kbDir)) return false;
private hasProjectDbFile(dir: string, folderName: string, dbName: string): boolean {
const projectDir = join(dir, folderName);
const dbPath = join(projectDir, dbName);
if (!existsSync(projectDir)) return false;
if (!existsSync(dbPath)) return false;
try {
@@ -343,7 +352,19 @@ export class MigrationCoordinator {
errors: [],
};
case "setup-wizard":
case "setup-wizard": {
// Central DB exists but no projects — check for local project to auto-register
const localProjects = await detector.detectExistingProjects(process.cwd());
if (localProjects.length > 0) {
return this.registerSingleProject(localProjects[0].path);
}
return {
success: true,
projectsRegistered: [],
errors: [],
};
}
case "normal-operation":
// No migration needed
return {
@@ -373,6 +394,13 @@ export class MigrationCoordinator {
return result;
}
// Validate it's an actual kb project
const detector = new FirstRunDetector(this.central.getGlobalDir());
if (!this.isValidKbProject(projectPath)) {
result.errors.push(`Path is not a valid kb project: ${projectPath}`);
return result;
}
// Check if already registered
try {
const existing = await this.central.getProjectByPath(projectPath);
@@ -387,8 +415,22 @@ export class MigrationCoordinator {
return result;
}
// Check for overlapping registered projects (nested inside or parent of existing)
try {
const allProjects = await this.central.listProjects();
const normalizedPath = resolve(projectPath);
for (const p of allProjects) {
const normalizedExisting = resolve(p.path);
if (normalizedPath.startsWith(normalizedExisting + "/") || normalizedExisting.startsWith(normalizedPath + "/")) {
result.errors.push(`Path "${projectPath}" overlaps an existing registered project at "${p.path}"`);
return result;
}
}
} catch {
// Non-fatal — continue with registration
}
// Generate unique name
const detector = new FirstRunDetector(this.central.getGlobalDir());
const baseName = await detector.generateProjectName(projectPath);
const uniqueName = await this.ensureUniqueName(baseName);
@@ -400,6 +442,9 @@ export class MigrationCoordinator {
isolationMode: "in-process",
});
// Activate the project after successful registration
await this.central.updateProject(project.id, { status: "active" });
result.success = true;
result.projectsRegistered.push(project.id);
} catch (err) {
@@ -424,6 +469,13 @@ export class MigrationCoordinator {
for (const input of projects) {
try {
// Validate it's a valid kb project
if (!this.isValidKbProject(input.path)) {
result.success = false;
result.errors.push(`Path is not a valid kb project: ${input.path}`);
continue;
}
// Check if already registered
const existing = await this.central.getProjectByPath(input.path);
if (existing) {
@@ -441,6 +493,9 @@ export class MigrationCoordinator {
isolationMode: input.isolationMode ?? "in-process",
});
// Activate after registration
await this.central.updateProject(project.id, { status: "active" });
result.projectsRegistered.push(project.id);
} catch (err) {
result.success = false;
@@ -472,6 +527,27 @@ export class MigrationCoordinator {
return candidate;
}
/**
* Check if a directory is a valid kb project (has .fusion/fusion.db or .kb/kb.db).
*/
private isValidKbProject(dir: string): boolean {
return this.hasProjectDbInDir(dir, ".fusion", "fusion.db") ||
this.hasProjectDbInDir(dir, ".kb", "kb.db");
}
private hasProjectDbInDir(dir: string, folderName: string, dbName: string): boolean {
const projectDir = join(dir, folderName);
const dbPath = join(projectDir, dbName);
if (!existsSync(projectDir)) return false;
if (!existsSync(dbPath)) return false;
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
}
// ── BackwardCompat ───────────────────────────────────────────────────
@@ -541,23 +617,6 @@ export class BackwardCompat {
const projects = await this.central.listProjects();
if (projects.length === 0) {
// No projects registered - check if cwd has a current .fusion project or legacy .kb project
if (this.hasProjectData(cwd)) {
// Auto-migrate this project
const coordinator = new MigrationCoordinator(this.central);
const result = await coordinator.registerSingleProject(cwd);
if (result.success && result.projectsRegistered.length > 0) {
const newProject = await this.central.getProject(result.projectsRegistered[0]);
if (newProject) {
return {
projectId: newProject.id,
workingDirectory: newProject.path,
isLegacy: false,
};
}
}
}
throw new ProjectRequiredError(
"No projects registered. Run 'fn init' or 'fn project add' to set up a project.",
[]
@@ -620,7 +679,8 @@ export class BackwardCompat {
private hasProjectDb(dir: string, folderName: ".fusion" | ".kb"): boolean {
const projectDir = join(dir, folderName);
const dbPath = join(projectDir, "kb.db");
const dbName = folderName === ".fusion" ? "fusion.db" : "kb.db";
const dbPath = join(projectDir, dbName);
if (!existsSync(projectDir)) return false;
if (!existsSync(dbPath)) return false;

View File

@@ -3474,14 +3474,14 @@ describe("TaskExecutor usage limit detection", () => {
expect(onError).toHaveBeenCalled();
});
it("does NOT trigger global pause for non-usage-limit errors", async () => {
it("does NOT trigger global pause for transient non-usage-limit errors", async () => {
const store = createMockStore();
const pauser = new UsageLimitPauser(store);
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
const onError = vi.fn();
mockedCreateHaiAgent.mockRejectedValue(new Error("connection refused"));
const onError = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test", {
onError,
usageLimitPauser: pauser,
@@ -3501,8 +3501,13 @@ describe("TaskExecutor usage limit detection", () => {
});
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
// Task should still be marked as failed
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: "connection refused" });
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Transient error (will retry): connection refused");
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ status: "failed" }),
);
expect(onError).not.toHaveBeenCalled();
});
it("works without usageLimitPauser (backward compatible)", async () => {

View File

@@ -52,12 +52,16 @@ describe("withRateLimitRetry", () => {
onRetry,
});
// Attach the rejection handler before advancing timers so the rejection
// is never unhandled when the final retry throws during timer advancement.
const assertion = expect(promise).rejects.toThrow("rate_limit exceeded");
// Advance enough to cover all backoff delays
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(500);
}
await expect(promise).rejects.toThrow("rate_limit exceeded");
await assertion;
expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
expect(onRetry).toHaveBeenCalledTimes(2);
});

View File

@@ -3,11 +3,13 @@ import type {
TaskStore,
Task,
CentralCore,
AgentStore,
} from "@fusion/core";
import { Scheduler } from "../scheduler.js";
import { TaskExecutor, type TaskExecutorOptions } from "../executor.js";
import { WorktreePool } from "../worktree-pool.js";
import { AgentSemaphore } from "../concurrency.js";
import { HeartbeatMonitor } from "../agent-heartbeat.js";
import type {
ProjectRuntime,
ProjectRuntimeConfig,
@@ -63,6 +65,10 @@ export class InProcessRuntime
private globalSemaphore?: AgentSemaphore;
private stuckTaskDetector?: StuckTaskDetector;
private usageLimitPauser?: UsageLimitPauser;
private agentStore?: AgentStore;
private heartbeatMonitor?: HeartbeatMonitor;
/** Maps task IDs to agent IDs for lifecycle tracking */
private taskAgentMap = new Map<string, string>();
private lastActivityAt: string = new Date().toISOString();
/**
@@ -153,17 +159,41 @@ export class InProcessRuntime
onStart: (task, worktreePath) => {
this.recordActivity();
runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`);
// Create agent in AgentStore for lifecycle tracking
if (this.agentStore) {
this.agentStore.createAgent({
name: `executor-${task.id}`,
role: "executor",
}).then(async (agent: { id: string }) => {
this.taskAgentMap.set(task.id, agent.id);
await this.agentStore!.assignTask(agent.id, task.id);
await this.agentStore!.updateAgentState(agent.id, "active");
}).catch((err: unknown) => {
runtimeLog.warn(`Failed to create agent for task ${task.id}:`, err);
});
}
},
onComplete: (task) => {
this.recordActivity();
runtimeLog.log(`Completed task ${task.id}`);
// Record task completion in CentralCore
this.recordTaskCompletion(task.id, true);
// Update agent state to terminated (completed)
const agentId = this.taskAgentMap.get(task.id);
if (agentId && this.agentStore) {
void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {});
this.taskAgentMap.delete(task.id);
}
},
onError: (task, error) => {
this.recordActivity();
runtimeLog.error(`Task ${task.id} failed:`, error.message);
this.recordTaskCompletion(task.id, false);
// Update agent state to terminated (failed)
const agentId = this.taskAgentMap.get(task.id);
if (agentId && this.agentStore) {
void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {});
this.taskAgentMap.delete(task.id);
}
},
};
@@ -173,13 +203,35 @@ export class InProcessRuntime
executorOptions
);
// 6. Set up event forwarding from TaskStore
// 6. Initialize AgentStore and HeartbeatMonitor
try {
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
this.agentStore = new AgentStoreClass({ rootDir: this.taskStore.getRootDir() });
await this.agentStore.init();
this.heartbeatMonitor = new HeartbeatMonitor({
store: this.agentStore,
onMissed: (agentId) => {
runtimeLog.warn(`Agent ${agentId} missed heartbeat`);
},
onTerminated: (agentId) => {
runtimeLog.warn(`Agent ${agentId} terminated (unresponsive)`);
},
});
this.heartbeatMonitor.start();
runtimeLog.log(`AgentStore and HeartbeatMonitor initialized`);
} catch (agentErr) {
// Non-fatal — agent monitoring is optional
runtimeLog.warn(`AgentStore initialization failed (continuing without agent monitoring):`, agentErr);
}
// 7. Set up event forwarding from TaskStore
this.setupEventForwarding();
// 7. Resume orphaned in-progress tasks
// 8. Resume orphaned in-progress tasks
await this.executor.resumeOrphaned();
// 8. Start scheduler
// 9. Start scheduler
this.scheduler.start();
this.setStatus("active");
@@ -214,7 +266,13 @@ export class InProcessRuntime
runtimeLog.log(`Stopping InProcessRuntime for project ${this.config.projectId}`);
try {
// 1. Stop scheduler (prevents new task scheduling)
// 1. Stop heartbeat monitor
if (this.heartbeatMonitor) {
this.heartbeatMonitor.stop();
runtimeLog.log("HeartbeatMonitor stopped");
}
// 2. Stop scheduler (prevents new task scheduling)
if (this.scheduler) {
this.scheduler.stop();
runtimeLog.log("Scheduler stopped");

View File

@@ -147,10 +147,19 @@ describe("Scheduler", () => {
});
it("triggers scheduling immediately when task:created event fires", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([
// Mock filesystem validation so schedule() can proceed
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
// First call (from start()) returns empty todo, second call (from event) returns the new task
const listTasksMock = vi.fn()
.mockResolvedValueOnce([]) // Initial schedule from start() sees no tasks
.mockResolvedValue([
createMockTask({ id: "FN-001", column: "todo", dependencies: [] }),
]),
]);
const store = createMockStore({
listTasks: listTasksMock,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
@@ -159,17 +168,23 @@ describe("Scheduler", () => {
const scheduler = new Scheduler(store);
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
// Find and call the task:created handler
const onCalls = (store.on as any).mock.calls;
const createdHandler = onCalls.find((call: any) => call[0] === "task:created")?.[1];
expect(createdHandler).toBeDefined();
// Simulate task:created event
const newTask = createMockTask({ id: "FN-002", column: "todo" });
// Simulate task:created event — triggers schedule() which now sees FN-001
const newTask = createMockTask({ id: "FN-001", column: "todo" });
await createdHandler(newTask);
// Wait for async schedule to complete
await new Promise((r) => setTimeout(r, 10));
// Verify schedule() was called (moveTask should be called since task can start)
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
});
it("registers task:moved event listener", () => {
@@ -180,11 +195,24 @@ describe("Scheduler", () => {
});
it("triggers scheduling immediately when task:moved to done event fires", async () => {
// Mock filesystem validation so schedule() can proceed
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
// Initially return only FN-001 in-progress so start() doesn't schedule FN-002
const listTasksMock = vi.fn()
.mockResolvedValueOnce([
createMockTask({ id: "FN-001", column: "in-progress", dependencies: [] }),
createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }),
])
// After event fires, FN-001 is done so FN-002's deps are satisfied
.mockResolvedValue([
createMockTask({ id: "FN-001", column: "done", dependencies: [] }),
createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }),
]);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([
createMockTask({ id: "FN-001", column: "done", dependencies: [] }), // Completed dep
createMockTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }), // Waiting on FN-001
]),
listTasks: listTasksMock,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
@@ -193,15 +221,21 @@ describe("Scheduler", () => {
const scheduler = new Scheduler(store);
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
// Find and call the task:moved handler
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
expect(movedHandler).toBeDefined();
// Simulate task:moved to done event
const doneTask = createMockTask({ id: "FN-001", column: "todo" });
const doneTask = createMockTask({ id: "FN-001", column: "in-progress" });
await movedHandler({ task: doneTask, from: "in-progress", to: "done" });
// Wait for async schedule to complete
await new Promise((r) => setTimeout(r, 10));
// Verify schedule() was called - FN-002 should now be able to start
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress");
});

View File

@@ -1,4 +1,4 @@
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type FeatureStatus } from "@fusion/core";
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -163,6 +163,11 @@ export class Scheduler {
void this.handleMissionTaskStart(task.id, task.sliceId);
}
// Mission progress tracking: when task with sliceId moves to done
if (task.sliceId && this.options.missionStore && to === "done") {
void this.handleMissionTaskCompletion(task.id, task.sliceId);
}
// Event-driven scheduling: when a dependency completes (task moves to "done"),
// trigger scheduling immediately so waiting tasks can start without waiting
// for the next poll interval (up to 15 seconds).
@@ -533,6 +538,13 @@ export class Scheduler {
return;
}
if (feature.sliceId !== sliceId) {
schedulerLog.warn(
`Task ${taskId} sliceId ${sliceId} does not match linked feature ${feature.id} sliceId ${feature.sliceId}; skipping mission start update`,
);
return;
}
// Only update if feature is still in "triaged" status
if (feature.status === "triaged") {
await missionStore.updateFeatureStatus(feature.id, "in-progress");
@@ -543,6 +555,47 @@ export class Scheduler {
}
}
/**
* Handle mission task completion.
* When a task moves to "done", update the linked feature status to "done".
* updateFeatureStatus cascades via recomputeSliceStatus — if all features
* in the slice are done the slice status becomes "complete" automatically.
* We then call onSliceComplete to trigger auto-advance to the next slice.
*/
private async handleMissionTaskCompletion(taskId: string, sliceId: string): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;
try {
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) return;
if (feature.sliceId !== sliceId) {
schedulerLog.warn(
`Task ${taskId} sliceId ${sliceId} does not match linked feature ${feature.id} sliceId ${feature.sliceId}; skipping mission completion update`,
);
return;
}
const sliceIdBeforeUpdate = feature.sliceId;
if (feature.status !== "done") {
missionStore.updateFeatureStatus(feature.id, "done");
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
}
// Check if the slice became complete after the feature update
const slice = missionStore.getSlice(sliceIdBeforeUpdate);
if (slice && slice.status === "complete") {
schedulerLog.log(`Slice ${slice.id} is complete — triggering auto-advance`);
await this.onSliceComplete(slice);
}
} catch (err) {
schedulerLog.error(`Error handling mission task completion for ${taskId}:`, err);
}
}
async onSliceComplete(slice: import("@fusion/core").Slice): Promise<void> {
if (!this.options.missionStore) return;