fix(FN-000): harden project migration runtime
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user