feat(FN-3584): add memory file markdown preview to agent detail and log vie

This merge introduces a memory file markdown preview feature (FN-3584) with corresponding documentation, refines the AgentDetailView and AgentLogViewer components in the dashboard, and adds defensive collision handling for worktree operations during manual task moves (FN-3583).

Fusion-Task-Id: FN-3584
This commit is contained in:
Fusion
2026-05-06 07:45:56 -07:00
committed by gsxdsm
parent 9dd8445c96
commit 270823dcc9
9 changed files with 217 additions and 165 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix duplicate ntfy merge notifications by ensuring `ProjectEngine` uses a single `NotificationService` listener graph and passes that shared service into the `NtfyNotifier` compatibility shim.

View File

@@ -354,6 +354,8 @@ For long-form prompt authoring, **Soul**, **Agent Memory**, and **Inline Instruc
- Plain/edit mode and Markdown preview mode - Plain/edit mode and Markdown preview mode
- Fullscreen expand/collapse editing for long content (safe-area-aware on mobile) - Fullscreen expand/collapse editing for long content (safe-area-aware on mobile)
In Agent Detail → **Agent Memory****Memory Files**, selected file content now also supports the same **Edit/Preview** markdown toggle. Preview renders the current in-memory draft (including unsaved edits), while save/edit controls remain gated by agent read-only state.
These controls are also available on the editable review step, so prompt content can be reviewed and refined with the same markdown and fullscreen behavior before submit. These controls are also available on the editable review step, so prompt content can be reviewed and refined with the same markdown and fullscreen behavior before submit.
### Final review edits (step 2) ### Final review edits (step 2)

View File

@@ -438,6 +438,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem) - `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
- `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers - `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers
- Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:moved`, `task:updated`, `task:merged`) are emitted through a single path. - Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:moved`, `task:updated`, `task:merged`) are emitted through a single path.
- Merge dedup safety: `ProjectEngine.start()` is idempotent, so repeated start calls do not wire a second `NotificationService`/`NtfyNotifier` pair. A successful merge therefore emits exactly one canonical `merged` ntfy lifecycle notification per task.
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs. - Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears. - Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration - `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration

View File

@@ -1957,6 +1957,7 @@ function MemoryTab({
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [showFilePreview, setShowFilePreview] = useState(false);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]); const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false); const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
@@ -2027,6 +2028,7 @@ function MemoryTab({
setMemory(agent.memory ?? ""); setMemory(agent.memory ?? "");
setJustSaved(false); setJustSaved(false);
setShowPreview(false); setShowPreview(false);
setShowFilePreview(false);
setFileSwitchHint(""); setFileSwitchHint("");
setSelectedFileJustSaved(false); setSelectedFileJustSaved(false);
void loadMemoryFiles(); void loadMemoryFiles();
@@ -2220,8 +2222,46 @@ function MemoryTab({
</div> </div>
)} )}
<div className="agent-content-toolbar config-textarea-top-spacing">
<div className="agent-content-mode-toggle">
{!isReadOnly && (
<button
className={`btn btn-sm ${!showFilePreview ? "btn-primary" : ""}`}
onClick={() => setShowFilePreview(false)}
disabled={!showFilePreview}
aria-label="Memory file edit mode"
>
<FileEdit size={14} />
Edit
</button>
)}
<button
className={`btn btn-sm ${showFilePreview ? "btn-primary" : ""}`}
onClick={() => setShowFilePreview(true)}
disabled={showFilePreview}
aria-label="Memory file preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showFilePreview ? (
selectedFileContent.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedFileContent}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No memory file content yet. Switch to Edit mode to add content.
</div>
)
) : (
<textarea <textarea
className="input config-textarea-mono config-textarea-top-spacing" className="input config-textarea-mono"
rows={14} rows={14}
placeholder="Select a memory file to view and edit its content..." placeholder="Select a memory file to view and edit its content..."
value={selectedFileContent} value={selectedFileContent}
@@ -2233,6 +2273,7 @@ function MemoryTab({
setFileSwitchHint(""); setFileSwitchHint("");
}} }}
/> />
)}
{selectedFileLoading && ( {selectedFileLoading && (
<span className="config-hint config-hint--inline-loader"> <span className="config-hint config-hint--inline-loader">
@@ -2269,6 +2310,7 @@ function MemoryTab({
)} )}
</button> </button>
)} )}
{!showFilePreview && (
<button <button
className="btn" className="btn"
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly} disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
@@ -2286,6 +2328,7 @@ function MemoryTab({
</> </>
)} )}
</button> </button>
)}
{!hasInlineChanges && justSaved && ( {!hasInlineChanges && justSaved && (
<span className="config-saved-indicator"> <span className="config-saved-indicator">
<CheckCircle size={14} /> <CheckCircle size={14} />

View File

@@ -26,6 +26,9 @@ vi.mock("../../api", () => ({
updateAgentInstructions: vi.fn(), updateAgentInstructions: vi.fn(),
updateAgentSoul: vi.fn(), updateAgentSoul: vi.fn(),
updateAgentMemory: vi.fn(), updateAgentMemory: vi.fn(),
fetchAgentMemoryFiles: vi.fn(),
fetchAgentMemoryFile: vi.fn(),
saveAgentMemoryFile: vi.fn(),
fetchAgentTasks: vi.fn(), fetchAgentTasks: vi.fn(),
fetchChainOfCommand: vi.fn(), fetchChainOfCommand: vi.fn(),
fetchAgentBudgetStatus: vi.fn(), fetchAgentBudgetStatus: vi.fn(),
@@ -119,7 +122,7 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }), useConfirm: () => ({ confirm: mockConfirm }),
})); }));
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api"; import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
import { subscribeSse } from "../../sse-bus"; import { subscribeSse } from "../../sse-bus";
const mockFetchAgent = vi.mocked(fetchAgent); const mockFetchAgent = vi.mocked(fetchAgent);
@@ -138,6 +141,9 @@ const mockResetAgentBudget = vi.mocked(resetAgentBudget);
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions); const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
const mockUpdateAgentSoul = vi.mocked(updateAgentSoul); const mockUpdateAgentSoul = vi.mocked(updateAgentSoul);
const mockUpdateAgentMemory = vi.mocked(updateAgentMemory); const mockUpdateAgentMemory = vi.mocked(updateAgentMemory);
const mockFetchAgentMemoryFiles = vi.mocked(fetchAgentMemoryFiles);
const mockFetchAgentMemoryFile = vi.mocked(fetchAgentMemoryFile);
const mockSaveAgentMemoryFile = vi.mocked(saveAgentMemoryFile);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent); const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent); const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills); const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
@@ -231,6 +237,22 @@ describe("AgentDetailView", () => {
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 }); mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 }); mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockUpdateAgentInstructions.mockResolvedValue({} as any); mockUpdateAgentInstructions.mockResolvedValue({} as any);
mockFetchAgentMemoryFiles.mockResolvedValue({
files: [
{
path: ".fusion/agent-memory/agent-001/MEMORY.md",
label: "MEMORY.md",
layer: "long-term",
size: 12,
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as any);
mockFetchAgentMemoryFile.mockResolvedValue({
path: ".fusion/agent-memory/agent-001/MEMORY.md",
content: "",
} as any);
mockSaveAgentMemoryFile.mockResolvedValue({ success: true } as any);
// Default: return skills // Default: return skills
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS); mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] }); mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] });
@@ -4248,55 +4270,26 @@ describe("AgentDetailView", () => {
it("renders Memory tab with textarea by default", async () => { it("renders Memory tab with textarea by default", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument(); expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Edit mode" })).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
}); });
}); });
it("toggles between edit and preview mode", async () => { it("toggles between edit and preview mode", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ mockFetchAgent.mockResolvedValue(createMockAgent({ memory: "# Agent Memory\n\n- Item 1\n- Item 2" }));
memory: "# Agent Memory\n\n- Item 1\n- Item 2",
}));
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
// Default: edit mode
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
});
// Click Preview
await user.click(screen.getByText("Preview"));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
expect(document.querySelector(".markdown-body")).toBeInTheDocument(); expect(document.querySelector(".markdown-body")).toBeInTheDocument();
}); });
await user.click(screen.getByRole("button", { name: "Edit mode" }));
// Click Edit
await user.click(screen.getByText("Edit"));
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument(); expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
}); });
@@ -4304,18 +4297,9 @@ describe("AgentDetailView", () => {
it("shows placeholder when memory is empty in preview mode", async () => { it("shows placeholder when memory is empty in preview mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await user.click(screen.getByText("Preview"));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument(); expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
}); });
@@ -4323,126 +4307,83 @@ describe("AgentDetailView", () => {
it("hides save button when in preview mode", async () => { it("hides save button when in preview mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByText("Save Memory")).toBeInTheDocument(); expect(screen.getByText("Save Memory")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => expect(screen.queryByText("Save Memory")).not.toBeInTheDocument());
}); });
await user.click(screen.getByText("Preview")); it("hides inline Edit button when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "This agent has memory." }));
await waitFor(() => {
expect(screen.queryByText("Save Memory")).not.toBeInTheDocument();
});
});
it("hides Edit button when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
state: "running",
memory: "This agent has memory.",
}));
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
// Preview button should be visible, Edit button should be hidden
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Preview")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
// Edit button should not be in the DOM (not just disabled, hidden) expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
}); });
}); });
it("shows Preview button but not Edit when agent is running", async () => { it("can switch inline memory to preview mode when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "Agent memory content" }));
state: "running",
memory: "Agent memory content",
}));
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => { await waitFor(() => expect(document.querySelector(".markdown-body")).toBeInTheDocument());
// Preview button is visible
const previewBtn = screen.getByRole("button", { name: /Preview/i });
expect(previewBtn).toBeInTheDocument();
// Edit button should be hidden
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
// Since Edit is hidden and default is edit mode, the textarea should still be visible
// but user needs to click Preview to see the markdown render
});
}); });
it("can switch to preview mode when agent is running", async () => { it("renders memory file preview markdown and toggles back to edit", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "# Heading\n\n- entry" } as any);
state: "running",
memory: "Agent memory content",
}));
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
// Click Preview button
await user.click(screen.getByText("Preview"));
await waitFor(() => { await waitFor(() => {
expect(document.querySelector(".markdown-body")).toBeInTheDocument(); expect(screen.queryByPlaceholderText("Select a memory file to view and edit its content...")).not.toBeInTheDocument();
expect(screen.getByText("Heading")).toBeInTheDocument();
expect(screen.queryByText("Save Memory File")).not.toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Memory file edit mode" }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Select a memory file to view and edit its content...")).toBeInTheDocument();
});
});
it("shows memory file preview placeholder when selected file is empty", async () => {
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "" } as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
await waitFor(() => {
expect(screen.getByText("No memory file content yet. Switch to Edit mode to add content.")).toBeInTheDocument();
});
});
it("hides memory file edit button and disables save button for running agents", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Memory file preview mode" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Memory file edit mode" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Memory File" })).toBeDisabled();
}); });
}); });
it("calls updateAgentMemory when saving memory", async () => { it("calls updateAgentMemory when saving memory", async () => {
const addToast = vi.fn(); const addToast = vi.fn();
mockUpdateAgentMemory.mockResolvedValue({} as any); mockUpdateAgentMemory.mockResolvedValue({} as any);
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToMemory(user); await navigateToMemory(user);
const textarea = await screen.findByLabelText("Agent Memory"); const textarea = await screen.findByLabelText("Agent Memory");
await user.clear(textarea); await user.clear(textarea);
await user.type(textarea, "This is the agent's new memory"); await user.type(textarea, "This is the agent's new memory");
await user.click(screen.getByText("Save Memory")); await user.click(screen.getByText("Save Memory"));
await waitFor(() => { await waitFor(() => {
expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined); expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined);
expect(addToast).toHaveBeenCalledWith("Memory saved", "success"); expect(addToast).toHaveBeenCalledWith("Memory saved", "success");

View File

@@ -128,4 +128,36 @@ describe("NotificationService", () => {
expect(initSpy).not.toHaveBeenCalled(); expect(initSpy).not.toHaveBeenCalled();
initSpy.mockRestore(); initSpy.mockRestore();
}); });
it("duplicates merged dispatch when multiple NotificationService instances subscribe to the same store", async () => {
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = {
getProviderId: () => "mock",
isEventSupported: () => true,
sendNotification,
};
const first = new NotificationService(store as any);
const second = new NotificationService(store as any);
first.registerProvider(provider);
second.registerProvider(provider);
await first.start();
await second.start();
// Confirms duplication is from duplicate listener graphs, not duplicate task:merged payloads.
store.emit("task:merged", {
task: task(),
branch: "fusion/fn-1",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
});
await Promise.resolve();
expect(sendNotification).toHaveBeenCalledTimes(2);
await first.stop();
await second.stop();
});
}); });

View File

@@ -1093,7 +1093,7 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
}); });
it("emits a single merged notification when notifier shares an already-started NotificationService", async () => { it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
const sharedService = new NotificationService(store, { projectId: "proj-1" }); const sharedService = new NotificationService(store, { projectId: "proj-1" });
await sharedService.start(); await sharedService.start();

View File

@@ -288,6 +288,22 @@ describe("ProjectEngine notification ownership wiring", () => {
expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1); expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1);
expect(mocks.notifierStop).toHaveBeenCalledTimes(1); expect(mocks.notifierStop).toHaveBeenCalledTimes(1);
}); });
it("does not recreate notification listeners on repeated start calls, preventing duplicate merged delivery", async () => {
const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" });
await engine.start();
await engine.start();
// Root cause guard: if ProjectEngine.start is called more than once, it should not
// wire a second NotificationService/NtfyNotifier pair for the same store.
expect(NotificationService).toHaveBeenCalledTimes(1);
expect(NtfyNotifier).toHaveBeenCalledTimes(1);
expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1);
expect(mocks.notifierStart).toHaveBeenCalledTimes(1);
await engine.stop();
});
}); });
describe("ProjectEngine PR monitoring wiring", () => { describe("ProjectEngine PR monitoring wiring", () => {

View File

@@ -139,6 +139,7 @@ export interface ProjectEngineOptions {
*/ */
export class ProjectEngine { export class ProjectEngine {
private runtime: InProcessRuntime; private runtime: InProcessRuntime;
private started = false;
private prMonitor?: PrMonitor; private prMonitor?: PrMonitor;
private prCommentHandler?: PrCommentHandler; private prCommentHandler?: PrCommentHandler;
private notifier?: NtfyNotifier; private notifier?: NtfyNotifier;
@@ -243,6 +244,10 @@ export class ProjectEngine {
* Start the engine: initialize the runtime and all auxiliary subsystems. * Start the engine: initialize the runtime and all auxiliary subsystems.
*/ */
async start(): Promise<void> { async start(): Promise<void> {
if (this.started) {
return;
}
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.) // 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
await this.runtime.start(); await this.runtime.start();
@@ -422,6 +427,7 @@ export class ProjectEngine {
// 8. Start periodic merge retry sweep // 8. Start periodic merge retry sweep
this.scheduleMergeRetry(store); this.scheduleMergeRetry(store);
this.started = true;
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`); runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
} }
@@ -433,6 +439,10 @@ export class ProjectEngine {
* promptly without continuing git/verification work after shutdown starts. * promptly without continuing git/verification work after shutdown starts.
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
if (!this.started) {
return;
}
this.shuttingDown = true; this.shuttingDown = true;
// Stop merge retry timer // Stop merge retry timer
@@ -518,6 +528,8 @@ export class ProjectEngine {
// Stop the core runtime (Triage, Scheduler, Executor, etc.) // Stop the core runtime (Triage, Scheduler, Executor, etc.)
await this.runtime.stop(); await this.runtime.stop();
this.started = false;
this.shuttingDown = false;
runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`); runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`);
} }