feat(FN-2369): merge fusion/fn-2369

This commit is contained in:
Fusion
2026-04-23 20:01:04 -07:00
committed by gsxdsm
parent d62bf8e24c
commit 6ca8a04544
3 changed files with 274 additions and 21 deletions

View File

@@ -68,21 +68,25 @@ describe("test-project fixture", () => {
expect(existsSync(fixture.rootDir)).toBe(false);
});
it("supports multiple isolated projects without cross-interference", async () => {
const first = await createFixture({ seedTasks: 1 });
const second = await createFixture({ seedTasks: 2 });
it(
"supports multiple isolated projects without cross-interference",
async () => {
const first = await createFixture({ seedTasks: 1 });
const second = await createFixture({ seedTasks: 2 });
expect(first.rootDir).not.toBe(second.rootDir);
expect(first.globalDir).not.toBe(second.globalDir);
expect(first.rootDir).not.toBe(second.rootDir);
expect(first.globalDir).not.toBe(second.globalDir);
const firstTasks = await first.store.listTasks();
const secondTasks = await second.store.listTasks();
const firstTasks = await first.store.listTasks();
const secondTasks = await second.store.listTasks();
expect(firstTasks).toHaveLength(1);
expect(secondTasks).toHaveLength(2);
expect(firstTasks[0].id).toBe("FN-001");
expect(secondTasks[0].id).toBe("FN-001");
});
expect(firstTasks).toHaveLength(1);
expect(secondTasks).toHaveLength(2);
expect(firstTasks[0].id).toBe("FN-001");
expect(secondTasks[0].id).toBe("FN-001");
},
15000,
);
it("applies custom settings and honors a custom global settings directory", async () => {
const customGlobalDir = mkdtempSync(join(tmpdir(), "fusion-custom-global-"));

View File

@@ -28,6 +28,7 @@ import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "nod
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import * as projectMemory from "./project-memory.js";
import type { Task } from "./types.js";
function makeTmpDir(): string {
@@ -8300,6 +8301,220 @@ Task with acceptance criteria
});
});
describe("task-store diagnostics for best-effort catch paths", () => {
it("logs init config sync failures without blocking startup", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
let localStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal);
(localStore as any).configPath = join(localRoot, ".fusion", "missing-dir", "config.json");
await expect(localStore.init()).resolves.toBeUndefined();
await expect(localStore.createTask({ description: "still boots" })).resolves.toMatchObject({
id: "FN-001",
});
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed during init"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "init:config-sync",
configPath: join(localRoot, ".fusion", "missing-dir", "config.json"),
});
expect(typeof context.error).toBe("string");
} finally {
localStore?.close();
warnSpy.mockRestore();
await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it("logs writeConfig disk sync failures while preserving project settings updates", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalConfigPath = storeAny.configPath;
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
try {
const updated = await store.updateSettings({ mergeStrategy: "pull-request" });
expect(updated.mergeStrategy).toBe("pull-request");
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after config write"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "writeConfig:disk-sync",
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
});
expect(typeof context.error).toBe("string");
const settings = await store.getSettings();
expect(settings.mergeStrategy).toBe("pull-request");
} finally {
storeAny.configPath = originalConfigPath;
warnSpy.mockRestore();
}
});
it("logs allocateId disk sync failures while preserving task creation", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalConfigPath = storeAny.configPath;
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
try {
const task = await store.createTask({ description: "allocate despite sync failure" });
expect(task.id).toBe("FN-001");
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after ID allocation"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "allocateId:disk-sync",
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
taskId: task.id,
});
expect(typeof context.error).toBe("string");
} finally {
storeAny.configPath = originalConfigPath;
warnSpy.mockRestore();
}
});
it("logs init memory bootstrap failures without blocking startup", async () => {
const localRoot = makeTmpDir();
const localGlobal = makeTmpDir();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ensureSpy = vi
.spyOn(projectMemory, "ensureMemoryFileWithBackend")
.mockRejectedValueOnce(new Error("memory backend unavailable"));
let localStore: TaskStore | undefined;
try {
localStore = new TaskStore(localRoot, localGlobal);
await expect(localStore.init()).resolves.toBeUndefined();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed during init"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "init:memory-bootstrap",
rootDir: localRoot,
error: "memory backend unavailable",
});
expect(ensureSpy).toHaveBeenCalled();
} finally {
localStore?.close();
ensureSpy.mockRestore();
warnSpy.mockRestore();
await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it("logs memory toggle-on bootstrap failures without blocking settings updates", async () => {
await store.updateSettings({ memoryEnabled: false } as any);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ensureSpy = vi
.spyOn(projectMemory, "ensureMemoryFileWithBackend")
.mockRejectedValueOnce(new Error("memory toggle write failed"));
try {
const updated = await store.updateSettings({ memoryEnabled: true } as any);
expect(updated.memoryEnabled).toBe(true);
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed after memory toggle-on"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "updateSettings:memory-toggle-on",
rootDir,
error: "memory toggle write failed",
});
expect(ensureSpy).toHaveBeenCalled();
} finally {
ensureSpy.mockRestore();
warnSpy.mockRestore();
}
});
it("logs fs.watch setup failures and keeps polling active", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const storeAny = store as any;
const originalTasksDir = storeAny.tasksDir;
storeAny.tasksDir = join(rootDir, ".fusion", "missing-tasks-dir");
try {
await store.watch();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] fs.watch unavailable; falling back to polling-only updates"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "watch:fs-watch-setup",
tasksDir: join(rootDir, ".fusion", "missing-tasks-dir"),
});
expect(typeof context.error).toBe("string");
expect(storeAny.pollInterval).not.toBeNull();
await expect(storeAny.checkForChanges()).resolves.toBeUndefined();
} finally {
store.stopWatching();
storeAny.tasksDir = originalTasksDir;
warnSpy.mockRestore();
}
});
it("logs unreadable legacy agent.log files while keeping import non-fatal", async () => {
const task = await createTestTask();
const taskDir = join(rootDir, ".fusion", "tasks", task.id);
const logPath = join(taskDir, "agent.log");
await mkdir(logPath);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(store.importLegacyAgentLogs()).resolves.toBe(0);
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Skipping unreadable legacy agent.log file during import"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
phase: "importLegacyAgentLogs:read-file",
taskId: task.id,
logPath,
});
expect(typeof context.error).toBe("string");
} finally {
warnSpy.mockRestore();
}
});
});
describe("recovery metadata (recoveryRetryCount / nextRecoveryAt)", () => {
async function createTestTask(overrides: Partial<import("./types.js").TaskCreateInput> = {}) {
return store.createTask({ description: "recovery test task", ...overrides });

View File

@@ -406,8 +406,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const config = await this.readConfig();
try {
await writeFile(this.configPath, JSON.stringify(config, null, 2));
} catch {
// Non-fatal
} catch (err) {
storeLog.warn("Backward-compat config.json sync failed during init", {
phase: "init:config-sync",
configPath: this.configPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
@@ -421,8 +425,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Use backend-aware bootstrap to honor memoryBackendType setting
await ensureMemoryFileWithBackend(this.rootDir, mergedSettings);
}
} catch {
} catch (err) {
// Non-fatal — memory bootstrap failure should not block startup
storeLog.warn("Project-memory bootstrap failed during init", {
phase: "init:memory-bootstrap",
rootDir: this.rootDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
@@ -1401,8 +1410,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
try {
// Use backend-aware bootstrap to honor memoryBackendType setting
await ensureMemoryFileWithBackend(this.rootDir, updatedMerged);
} catch {
} catch (err) {
// Non-fatal — memory bootstrap failure should not block settings update
storeLog.warn("Project-memory bootstrap failed after memory toggle-on", {
phase: "updateSettings:memory-toggle-on",
rootDir: this.rootDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
@@ -1513,8 +1527,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const tmpPath = this.configPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(config, null, 2));
await rename(tmpPath, this.configPath);
} catch {
} catch (err) {
// Best-effort: SQLite is the primary store
storeLog.warn("Backward-compat config.json sync failed after config write", {
phase: "writeConfig:disk-sync",
configPath: this.configPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
@@ -1539,8 +1558,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const tmpPath = this.configPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(config, null, 2));
await rename(tmpPath, this.configPath);
} catch {
} catch (err) {
// Non-fatal: SQLite is the primary store
storeLog.warn("Backward-compat config.json sync failed after ID allocation", {
phase: "allocateId:disk-sync",
configPath: this.configPath,
taskId: id,
error: err instanceof Error ? err.message : String(err),
});
}
return id;
@@ -1736,7 +1761,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
} catch (err) {
// Non-fatal: default-on resolution is best-effort
storeLog.warn("Failed to auto-apply default workflow steps during task creation", {
storeLog.warn("Failed to auto-apply default workflow steps during task creation; auto-defaulting skipped", {
phase: "createTask:workflow-auto-default",
skippedAutoDefaulting: true,
error: err instanceof Error ? err.message : String(err),
descriptionLength: input.description.length,
});
@@ -3621,6 +3648,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
this.watcher.on("error", (err) => {
storeLog.warn("fs.watch emitted an error; polling will continue", {
phase: "watch:fs-watch-error",
error: err instanceof Error ? err.message : String(err),
tasksDir: this.tasksDir,
});
@@ -3628,6 +3656,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} catch (err) {
// fs.watch may not be available - that's fine
storeLog.warn("fs.watch unavailable; falling back to polling-only updates", {
phase: "watch:fs-watch-setup",
error: err instanceof Error ? err.message : String(err),
tasksDir: this.tasksDir,
});
@@ -4591,8 +4620,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Skip malformed JSONL lines.
}
}
} catch {
// Skip unreadable files.
} catch (err) {
storeLog.warn("Skipping unreadable legacy agent.log file during import", {
phase: "importLegacyAgentLogs:read-file",
taskId: entry.name,
logPath,
error: err instanceof Error ? err.message : String(err),
});
}
}