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

This commit is contained in:
gsxdsm
2026-04-18 23:22:26 -07:00
parent 49fea9ede7
commit cae24ce600
11 changed files with 756 additions and 74 deletions

View File

@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
import type { Task, TaskStore, CentralCore, AgentStore, Agent } from "@fusion/core";
import { InProcessRuntime } from "./in-process-runtime.js";
import type { ProjectRuntimeConfig } from "../project-runtime.js";
import { runtimeLog } from "../logger.js";
const {
mockSelfHealingStart,
@@ -642,6 +643,84 @@ describe("InProcessRuntime", () => {
}, 30000);
});
describe("agent cleanup failure diagnostics", () => {
it("logs warning when agent state update fails on task completion", async () => {
const warnSpy = vi.spyOn(runtimeLog, "warn");
await runtime.start();
const store = getAgentStore(runtime);
const updateStateSpy = vi.spyOn(store, "updateAgentState").mockImplementation(async (_agentId, state) => {
if (state === "terminated") {
throw new Error("state update failed");
}
return {} as Agent;
});
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
onComplete?: (task: Task) => void;
};
executorOptions.onStart?.({ id: "FN-DIAG-1" } as Task, join(testDir, "worktree-FN-DIAG-1"));
await vi.waitFor(async () => {
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((a: Agent) => a.name === "executor-FN-DIAG-1")).toBe(true);
});
updateStateSpy.mockClear();
executorOptions.onComplete?.({ id: "FN-DIAG-1" } as Task);
await Promise.resolve();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to update agent"),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("terminated (completion)"),
);
warnSpy.mockRestore();
}, 30000);
it("logs warning when agent deletion fails after task error", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(runtimeLog, "warn");
try {
await runtime.start();
const store = getAgentStore(runtime);
const deleteAgentSpy = vi.spyOn(store, "deleteAgent").mockRejectedValue(new Error("delete failed"));
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
onError?: (task: Task, error: Error) => void;
};
executorOptions.onStart?.({ id: "FN-DIAG-2" } as Task, join(testDir, "worktree-FN-DIAG-2"));
await vi.waitFor(async () => {
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((a: Agent) => a.name === "executor-FN-DIAG-2")).toBe(true);
});
deleteAgentSpy.mockClear();
executorOptions.onError?.({ id: "FN-DIAG-2" } as Task, new Error("Task failed"));
await vi.advanceTimersByTimeAsync(5000);
await Promise.resolve();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete agent"),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("after error"),
);
} finally {
warnSpy.mockRestore();
vi.useRealTimers();
}
}, 30000);
});
describe("configuration", () => {
it("should store projectId in config", () => {
// Access via the constructor params - runtime is created with testDir

View File

@@ -355,12 +355,18 @@ export class InProcessRuntime
// Update agent state to terminated (completed)
const agentId = this.taskAgentMap.get(task.id);
if (agentId && this.agentStore) {
void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {});
void this.agentStore.updateAgentState(agentId, "terminated").catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (completion): ${msg}`);
});
this.taskAgentMap.delete(task.id);
// Auto-delete the task-worker agent after a short delay so the UI
// can observe the terminal state before the agent is removed.
void setTimeout(() => {
this.agentStore?.deleteAgent(agentId).catch(() => {});
this.agentStore?.deleteAgent(agentId).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to delete agent ${agentId} after completion: ${msg}`);
});
}, 5000);
}
},
@@ -387,12 +393,18 @@ export class InProcessRuntime
// Update agent state to terminated (failed)
const agentId = this.taskAgentMap.get(task.id);
if (agentId && this.agentStore) {
void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {});
void this.agentStore.updateAgentState(agentId, "terminated").catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (error): ${msg}`);
});
this.taskAgentMap.delete(task.id);
// Auto-delete the task-worker agent after a short delay so the UI
// can observe the terminal state before the agent is removed.
void setTimeout(() => {
this.agentStore?.deleteAgent(agentId).catch(() => {});
this.agentStore?.deleteAgent(agentId).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to delete agent ${agentId} after error: ${msg}`);
});
}, 5000);
}
},
@@ -1021,7 +1033,9 @@ export class InProcessRuntime
try {
const state = await this.centralCore.getGlobalConcurrencyState();
return state.globalMaxConcurrent;
} catch {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to fetch global concurrency from CentralCore, falling back to default (4): ${msg}`);
// Fallback to default if CentralCore is unavailable
return 4;
}