feat(FN-2369): merge fusion/fn-2369
This commit is contained in:
@@ -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-"));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user