fix: prevent nested .fusion/.fusion dir from PluginStore path bug

PluginStore's constructor treats its rootDir arg as a project root and
internally appends `.fusion` before opening the SQLite DB. Several CLI
call sites were passing the already-resolved `.fusion` directory,
producing a doubled `.fusion/.fusion/fusion.db` that the dashboard
process kept recreating on every project load.

Pass the project root instead so the DB lands in the canonical
`.fusion/fusion.db` alongside the rest of the project's state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-23 15:55:04 -07:00
committed by gsxdsm
parent a1b2d48986
commit 51870ed27b
39 changed files with 1612 additions and 242 deletions

View File

@@ -1991,6 +1991,7 @@ describe("HeartbeatMonitor", () => {
createMessage({
id: "msg-1",
fromId: "agent-2",
fromType: "agent",
content: "Hello from agent-2",
createdAt: "2024-01-15T10:30:00.000Z",
}),
@@ -2031,8 +2032,8 @@ describe("HeartbeatMonitor", () => {
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Hello from agent-2");
expect(executionPrompt).toContain("Hello from user");
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
});
it("does not include message section when no unread messages", async () => {
@@ -2320,8 +2321,8 @@ describe("HeartbeatMonitor", () => {
expect(executionPrompt).toContain("Hello from agent-2");
});
describe("end-to-end agent-to-agent message flow", () => {
it("proves full message flow from send to wake to processing to reply", async () => {
describe("end-to-end message flow", () => {
it("proves wake-on-message can surface a user message and send a linked reply", async () => {
const messages: Map<string, Message[]> = new Map();
let messageCounter = 0;
@@ -2365,27 +2366,15 @@ describe("HeartbeatMonitor", () => {
}),
} as unknown as MessageStore;
// Agent A sends a message to Agent B
const sentMessage = fakeMessageStore.sendMessage({
fromId: "agent-alpha",
fromType: "agent",
const inboundFromUser = fakeMessageStore.sendMessage({
fromId: "dashboard",
fromType: "user",
toId: "agent-beta",
toType: "agent",
content: "Hello Agent Beta, please process task FN-001.",
type: "agent-to-agent",
metadata: { taskId: "FN-001" },
content: "Can you post a status update?",
type: "user-to-agent",
});
expect(sentMessage.id).toBeDefined();
expect(sentMessage.content).toContain("Hello Agent Beta");
// Verify message is stored in Agent B's inbox
const inbox = fakeMessageStore.getInbox("agent-beta", "agent", { read: false });
expect(inbox).toHaveLength(1);
expect(inbox[0].id).toBe(sentMessage.id);
expect(inbox[0].content).toBe(sentMessage.content);
// Create a monitor for Agent B with fake MessageStore
const store = createStoreWithAgentForExec({
id: "agent-beta",
name: "Agent Beta",
@@ -2402,25 +2391,40 @@ describe("HeartbeatMonitor", () => {
rootDir: "/tmp",
});
// Execute heartbeat to process the message (simulating wake-on-message trigger)
const result = await monitor.executeHeartbeat({
agentId: "agent-beta",
source: "on_demand",
triggerDetail: "wake-on-message",
});
// Verify heartbeat completed
expect(result.status).toBe("completed");
// Verify the execution prompt included the message
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Hello Agent Beta");
const executionPrompt = promptCalls[promptCalls.length - 1]?.[0] as string;
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain(`[id: ${inboundFromUser.id}]`);
expect(executionPrompt).toContain("dashboard");
// Verify messages were marked as read after successful processing
expect(fakeMessageStore.getMailbox("agent-beta", "agent").unreadCount).toBe(0);
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const sendMessageTool = callArgs.customTools?.find((tool: { name: string }) => tool.name === "send_message");
expect(sendMessageTool).toBeDefined();
await sendMessageTool!.execute(
"tool-call",
{
to_id: "dashboard",
content: "Status: I am on it.",
type: "agent-to-user",
reply_to_message_id: inboundFromUser.id,
},
undefined,
undefined,
{} as any,
);
const dashboardInbox = fakeMessageStore.getInbox("dashboard", "user");
const linkedReply = dashboardInbox.find((message) => message.content === "Status: I am on it.");
expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } });
await monitor.stop();
});
@@ -2639,6 +2643,11 @@ describe("HeartbeatMonitor", () => {
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("## Memory Boundaries");
});
it("both prompts instruct replies to include reply_to_message_id", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("reply_to_message_id");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("reply_to_message_id");
});
it("no-task system prompt processing messages section does not reference task_log", () => {
const processingMessagesSection = HEARTBEAT_NO_TASK_SYSTEM_PROMPT.split("## Processing Messages")[1] ?? "";
expect(processingMessagesSection).not.toContain("task_log");

View File

@@ -155,12 +155,14 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
1. Use read_messages to check your inbox for unread messages.
2. Review each message and determine the appropriate action:
- If the message requires a response, use send_message to reply.
- When replying, include 'reply_to_message_id' with the original message ID from read_messages output.
- If the message is informational, acknowledge it by logging with task_log.
- If the message requests work, create a follow-up task with task_create or handle it directly.
3. After processing messages, continue with your normal heartbeat duties.
When sending messages:
- Be concise and clear about what you need or what you've done.
- Use 'reply_to_message_id' when replying so threaded conversations stay linked.
- Include relevant context (task IDs, file paths) in metadata when applicable.
- Use agent-to-agent for inter-agent communication.`;
@@ -198,12 +200,14 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
1. If read_messages is available, use it to check your inbox for unread messages.
2. Review each message and determine the appropriate action:
- If the message requires a response and send_message is available, use send_message to reply.
- When replying, include 'reply_to_message_id' with the original message ID from read_messages output.
- If the message is informational, acknowledge it and respond via send_message when appropriate.
- If the message requests work, create a follow-up task with task_create.
3. After processing messages, continue with your ambient work.
When sending messages:
- Be concise and clear about what you need or what you've done.
- Use 'reply_to_message_id' when replying so threaded conversations stay linked.
- Include relevant context (task IDs, file paths) in metadata when applicable.
- Use agent-to-agent for inter-agent communication.`;
@@ -1202,7 +1206,7 @@ export class HeartbeatMonitor {
"Pending Messages:",
...pendingMessages.map((msg) => {
const timestamp = new Date(msg.createdAt).toLocaleString();
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
return `- [id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
}),
);
}
@@ -1217,7 +1221,7 @@ export class HeartbeatMonitor {
"useful ambient work. Here are some things you can do:",
"",
"1. **Check your messages** — Use read_messages to review any pending messages",
" and use send_message to respond or communicate with other agents.",
" and use send_message with reply_to_message_id when responding.",
"",
"2. **Create new tasks** — Use task_create to spawn follow-up work that needs",
" to be done. This is useful for surfacing issues or ideas you discover.",
@@ -1286,7 +1290,7 @@ export class HeartbeatMonitor {
"Pending Messages:",
...pendingMessages.map((msg) => {
const timestamp = new Date(msg.createdAt).toLocaleString();
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
return `- [id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
}),
);
}

View File

@@ -384,8 +384,9 @@ describe("createSendMessageTool", () => {
expect(tool.label).toBe("Send Message");
});
it("creates a tool with a description mentioning recipient waking", () => {
it("creates a tool with a description mentioning recipient waking and reply linking", () => {
expect(tool.description).toContain("messageResponseMode");
expect(tool.description).toContain("reply_to_message_id");
});
it("calls messageStore.sendMessage with correct parameters", async () => {
@@ -453,6 +454,32 @@ describe("createSendMessageTool", () => {
);
});
it("persists reply metadata when reply_to_message_id is provided", async () => {
const mockMessage = createMessage({ id: "msg-reply" });
vi.mocked(messageStore.sendMessage).mockReturnValue(mockMessage);
await executeTool(tool, {
to_id: "agent-2",
content: "Following up",
reply_to_message_id: "msg-original",
});
expect(messageStore.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { replyTo: { messageId: "msg-original" } } })
);
});
it("rejects blank reply_to_message_id", async () => {
const result = await executeTool(tool, {
to_id: "agent-2",
content: "Following up",
reply_to_message_id: " ",
});
expect(result.content[0]).toEqual({ type: "text", text: "ERROR: reply_to_message_id must be a non-empty string" });
expect(messageStore.sendMessage).not.toHaveBeenCalled();
});
it("returns error for empty content", async () => {
const result = await executeTool(tool, {
to_id: "agent-2",
@@ -570,11 +597,12 @@ describe("createReadMessagesTool", () => {
expect(result.content[0]).toEqual({ type: "text", text: "No messages" });
});
it("returns formatted message list with sender, content, and timestamp", async () => {
it("returns formatted message list with message IDs, sender, content, and timestamp", async () => {
const messages = [
createMessage({
id: "msg-1",
fromId: "agent-2",
fromType: "agent",
content: "Hello there",
createdAt: "2024-01-15T10:30:00.000Z",
read: false,
@@ -582,6 +610,7 @@ describe("createReadMessagesTool", () => {
createMessage({
id: "msg-2",
fromId: "user-1",
fromType: "user",
content: "Another message",
createdAt: "2024-01-15T11:00:00.000Z",
read: true,
@@ -594,8 +623,8 @@ describe("createReadMessagesTool", () => {
const text = result.content[0];
expect(text).toMatchObject({ type: "text" });
expect((text as { text: string }).text).toContain("Messages (2)");
expect((text as { text: string }).text).toContain("[unread] [from: agent-2] Hello there");
expect((text as { text: string }).text).toContain("[read] [from: user-1] Another message");
expect((text as { text: string }).text).toContain("[unread] [id: msg-1] [from: agent:agent-2] Hello there");
expect((text as { text: string }).text).toContain("[read] [id: msg-2] [from: user:user-1] Another message");
expect(result.details).toEqual({ messages });
});

View File

@@ -79,6 +79,9 @@ export const sendMessageParams = Type.Object({
Type.Literal("agent-to-agent"),
Type.Literal("agent-to-user"),
], { description: "Message type (defaults to 'agent-to-agent')" })),
reply_to_message_id: Type.Optional(
Type.String({ description: "Optional ID of the message you are replying to (use IDs from read_messages output)" }),
),
});
export const readMessagesParams = Type.Object({
@@ -928,7 +931,8 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
label: "Send Message",
description:
"Send a message to another agent or user. The recipient will be woken if they have " +
"`messageResponseMode: 'immediate'` configured.",
"`messageResponseMode: 'immediate'` configured. When replying to an existing message, " +
"include `reply_to_message_id` to preserve threading.",
parameters: sendMessageParams,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: async (_id: string, params: Static<typeof sendMessageParams>, _signal?: any, _onUpdate?: any, _ctx?: any) => {
@@ -950,6 +954,14 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
try {
const messageType = params.type ?? "agent-to-agent";
const recipientType = messageType === "agent-to-user" ? "user" : "agent";
const replyToMessageId = params.reply_to_message_id?.trim();
if (params.reply_to_message_id !== undefined && !replyToMessageId) {
return {
content: [{ type: "text" as const, text: "ERROR: reply_to_message_id must be a non-empty string" }],
details: {},
};
}
const message = messageStore.sendMessage({
fromId: fromAgentId,
@@ -958,6 +970,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
toType: recipientType,
content,
type: messageType,
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
});
return {
@@ -1014,7 +1027,7 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
const lines = messages.map((msg: Message) => {
const timestamp = new Date(msg.createdAt).toLocaleString();
const readStatus = msg.read ? "[read] " : "[unread] ";
return `${readStatus}[from: ${msg.fromId}] ${msg.content} (${timestamp})`;
return `${readStatus}[id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
});
return {

View File

@@ -182,6 +182,9 @@ import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
import { createFnAgent } from "./pi.js";
import { reviewStep as mockedReviewStepFn } from "./reviewer.js";
import { execSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findWorktreeUser, aiMergeTask } from "./merger.js";
import { WorktreePool } from "./worktree-pool.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
@@ -8119,6 +8122,119 @@ describe("Workflow Steps Execution", () => {
vi.useRealTimers();
});
it("routes exhausted prompt-mode workflow hard failures back to remediation and only reopens the last step", async () => {
const store = createMockStore();
const tempRoot = await mkdtemp(join(tmpdir(), "fn-2301-workflow-"));
const fusionDir = join(tempRoot, ".fusion");
const promptPath = join(fusionDir, "tasks", "FN-001", "PROMPT.md");
await mkdir(join(fusionDir, "tasks", "FN-001"), { recursive: true });
await writeFile(promptPath, "# Task\n\n## Steps\n\n- [x] Step 0\n- [x] Step 1\n", "utf-8");
store.getFusionDir.mockReturnValue(fusionDir);
const mutableTask = {
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress" as const,
dependencies: [] as string[],
steps: [
{ name: "Step 0", status: "done" as const },
{ name: "Step 1", status: "done" as const },
],
currentStep: 1,
log: [] as any[],
enabledWorkflowSteps: ["WS-001"],
workflowStepRetries: 3,
prompt: "# test\n## Steps\n### Step 0\n- [x] done\n### Step 1\n- [x] done",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockImplementation(async () => mutableTask);
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
if (mutableTask.steps[stepIndex]) {
mutableTask.steps[stepIndex].status = status as any;
}
return {};
});
store.getWorkflowStep.mockResolvedValue({
id: "WS-001",
name: "Frontend UX Design",
description: "Verify UX polish",
mode: "prompt",
prompt: "Review and report issues.",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
let callIdx = 0;
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
callIdx++;
if (callIdx === 1) {
const customTools = opts.customTools || [];
const session = {
prompt: vi.fn().mockImplementation(async () => {
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
};
return { session };
}
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("Quality gate hard failure: spacing regression in dashboard cards")),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
},
};
}) as any);
const onError = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test", { onError });
vi.useFakeTimers();
await executor.execute({ ...mutableTask });
expect(store.addTaskComment).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("Workflow step failed"),
"agent",
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
const updateStepCalls = store.updateStep.mock.calls
.filter((call: any[]) => call[0] === "FN-001" && call[2] === "pending")
.map((call: any[]) => call[1]);
expect(updateStepCalls).toContain(1);
expect(updateStepCalls).not.toContain(0);
vi.advanceTimersByTime(0);
await vi.runAllTimersAsync();
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
expect(onError).not.toHaveBeenCalled();
const promptContent = await readFile(promptPath, "utf-8");
expect(promptContent).toContain("## Workflow Step Failure");
expect(promptContent).toContain("Frontend UX Design");
expect(promptContent).toContain("Quality gate hard failure");
vi.useRealTimers();
});
it("skips script-mode step when scriptName is missing", async () => {
const store = createMockStore();