From 2533ec38ff6d001315939b2f6b96ce812f837e0d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 22 Mar 2026 16:11:47 -0400 Subject: [PATCH 1/2] fix: refresh stale MCP daemon for session CLI --- src/session/commands.ts | 176 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 10 deletions(-) diff --git a/src/session/commands.ts b/src/session/commands.ts index 659a4aba..3022f8aa 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -11,7 +11,7 @@ import type { SessionNavigateCommandInput, SessionSelectorInput, } from "../core/types"; -import { isHunkDaemonHealthy, isLoopbackPortReachable } from "../mcp/daemonLauncher"; +import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "../mcp/daemonLauncher"; import { resolveHunkMcpConfig } from "../mcp/config"; import type { AppliedCommentResult, @@ -26,6 +26,7 @@ import type { interface HunkDaemonCliClient { connect(): Promise; close(): Promise; + listToolNames(): Promise>; listSessions(): Promise; getSession(selector: SessionSelectorInput): Promise; getSelectedContext(selector: SessionSelectorInput): Promise; @@ -36,17 +37,30 @@ interface HunkDaemonCliClient { clearComments(input: SessionCommentClearCommandInput): Promise; } +const REQUIRED_TOOLS_BY_ACTION: Partial> = { + context: ["get_selected_context"], + navigate: ["navigate_to_hunk"], + "comment-list": ["list_comments"], + "comment-rm": ["remove_comment"], + "comment-clear": ["clear_comments"], +}; + function extractToolValue( result: Awaited>, key: string, ): ResultType | undefined { + const content = (result.content ?? []) as Array<{ type?: string; text?: string }>; + const text = content.find((entry) => entry.type === "text")?.text; + + if (result.isError) { + throw new Error(text || "The Hunk daemon returned an MCP tool error."); + } + const structured = result.structuredContent as Record | undefined; if (structured && key in structured) { return structured[key]; } - const content = (result.content ?? []) as Array<{ type?: string; text?: string }>; - const text = content.find((entry) => entry.type === "text")?.text; if (!text) { return undefined; } @@ -75,6 +89,11 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient { await this.transport.close().catch(() => undefined); } + async listToolNames() { + const result = await this.client.listTools(); + return new Set(result.tools.map((tool) => tool.name)); + } + async listSessions() { const result = await this.client.callTool({ name: "list_sessions", @@ -202,6 +221,140 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient { } } +async function readDaemonHealth() { + const config = resolveHunkMcpConfig(); + + try { + const response = await fetch(`${config.httpOrigin}/health`); + if (!response.ok) { + return null; + } + + return (await response.json()) as { + ok: boolean; + pid?: number; + sessions?: number; + }; + } catch { + return null; + } +} + +async function waitForDaemonShutdown(timeoutMs = 3_000) { + const config = resolveHunkMcpConfig(); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (!(await isHunkDaemonHealthy(config))) { + return true; + } + + await Bun.sleep(100); + } + + return false; +} + +function sessionMatchesSelector(session: ListedSession, selector: SessionSelectorInput) { + if (selector.sessionId) { + return session.sessionId === selector.sessionId; + } + + if (selector.repoRoot) { + return session.repoRoot === selector.repoRoot; + } + + return true; +} + +async function waitForSessionRegistration(selector: SessionSelectorInput, timeoutMs = 8_000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const client = new McpHunkDaemonCliClient(); + await client.connect(); + + try { + const sessions = await client.listSessions(); + if (sessions.some((session) => sessionMatchesSelector(session, selector))) { + return true; + } + } finally { + await client.close(); + } + + await Bun.sleep(200); + } + + return false; +} + +async function restartDaemonForMissingTools(missingTools: string[], selector?: SessionSelectorInput) { + const health = await readDaemonHealth(); + const pid = health?.pid; + if (!pid || pid === process.pid) { + throw new Error( + `The running Hunk MCP daemon is missing required tools (${missingTools.join(", ")}). ` + + `Restart Hunk so it can launch a fresh daemon from the current source tree.`, + ); + } + + process.kill(pid, "SIGTERM"); + + const shutDown = await waitForDaemonShutdown(); + if (!shutDown) { + throw new Error( + `Stopped waiting for the old Hunk MCP daemon to exit after it was found missing required tools (${missingTools.join(", ")}).`, + ); + } + + launchHunkDaemon(); + + const config = resolveHunkMcpConfig(); + const ready = await waitForHunkDaemonHealth({ config, timeoutMs: 3_000 }); + if (!ready) { + throw new Error("Timed out waiting for the refreshed Hunk MCP daemon to start."); + } + + if (selector) { + const registered = await waitForSessionRegistration(selector); + if (!registered) { + throw new Error( + "Timed out waiting for the live Hunk session to reconnect after refreshing the MCP daemon.", + ); + } + } +} + +async function ensureRequiredTools(action: SessionCommandInput["action"], selector?: SessionSelectorInput) { + const requiredTools = REQUIRED_TOOLS_BY_ACTION[action] ?? []; + if (requiredTools.length === 0) { + return; + } + + const client = new McpHunkDaemonCliClient(); + await client.connect(); + + try { + const toolNames = await client.listToolNames(); + const missingTools = requiredTools.filter((tool) => !toolNames.has(tool)); + if (missingTools.length === 0) { + return; + } + + const looksLikeOlderHunkDaemon = toolNames.has("list_sessions") && toolNames.has("get_session"); + if (!looksLikeOlderHunkDaemon) { + throw new Error( + `The Hunk MCP daemon is missing required tools (${missingTools.join(", ")}). Available tools: ${[...toolNames].join(", ") || "(none)"}.`, + ); + } + } finally { + await client.close(); + } + + await restartDaemonForMissingTools(requiredTools, selector); +} + function stringifyJson(value: unknown) { return `${JSON.stringify(value, null, 2)}\n`; } @@ -352,6 +505,9 @@ export async function runSessionCommand(input: SessionCommandInput) { return renderOutput(input.output, { sessions: [] }, () => formatListOutput([])); } + const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null; + await ensureRequiredTools(input.action, normalizedSelector ?? undefined); + const client = new McpHunkDaemonCliClient(); await client.connect(); @@ -362,45 +518,45 @@ export async function runSessionCommand(input: SessionCommandInput) { return renderOutput(input.output, { sessions }, () => formatListOutput(sessions)); } case "get": { - const session = await client.getSession(normalizeRepoRoot(input.selector)); + const session = await client.getSession(normalizedSelector!); return renderOutput(input.output, { session }, () => formatSessionOutput(session)); } case "context": { - const context = await client.getSelectedContext(normalizeRepoRoot(input.selector)); + const context = await client.getSelectedContext(normalizedSelector!); return renderOutput(input.output, { context }, () => formatContextOutput(context)); } case "navigate": { const result = await client.navigateToHunk({ ...input, - selector: normalizeRepoRoot(input.selector), + selector: normalizedSelector!, }); return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result)); } case "comment-add": { const result = await client.addComment({ ...input, - selector: normalizeRepoRoot(input.selector), + selector: normalizedSelector!, }); return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result)); } case "comment-list": { const comments = await client.listComments({ ...input, - selector: normalizeRepoRoot(input.selector), + selector: normalizedSelector!, }); return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments)); } case "comment-rm": { const result = await client.removeComment({ ...input, - selector: normalizeRepoRoot(input.selector), + selector: normalizedSelector!, }); return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result)); } case "comment-clear": { const result = await client.clearComments({ ...input, - selector: normalizeRepoRoot(input.selector), + selector: normalizedSelector!, }); return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result)); } From 9a545481a907f15cdc74ec106f9650c5dfdeb439 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 22 Mar 2026 16:16:37 -0400 Subject: [PATCH 2/2] test: cover stale session CLI daemon refresh --- src/session/commands.ts | 29 ++++- test/session-commands.test.ts | 203 ++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 test/session-commands.test.ts diff --git a/src/session/commands.ts b/src/session/commands.ts index 3022f8aa..73191ae4 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -23,7 +23,7 @@ import type { SessionLiveCommentSummary, } from "../mcp/types"; -interface HunkDaemonCliClient { +export interface HunkDaemonCliClient { connect(): Promise; close(): Promise; listToolNames(): Promise>; @@ -45,6 +45,22 @@ const REQUIRED_TOOLS_BY_ACTION: Partial HunkDaemonCliClient; + resolveDaemonAvailability?: (action: SessionCommandInput["action"]) => Promise; + restartDaemonForMissingTools?: (missingTools: string[], selector?: SessionSelectorInput) => Promise; +} + +let sessionCommandTestHooks: SessionCommandTestHooks | null = null; + +export function setSessionCommandTestHooks(hooks: SessionCommandTestHooks | null) { + sessionCommandTestHooks = hooks; +} + +function createDaemonCliClient() { + return sessionCommandTestHooks?.createClient?.() ?? new McpHunkDaemonCliClient(); +} + function extractToolValue( result: Awaited>, key: string, @@ -271,7 +287,7 @@ async function waitForSessionRegistration(selector: SessionSelectorInput, timeou const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const client = new McpHunkDaemonCliClient(); + const client = createDaemonCliClient(); await client.connect(); try { @@ -332,7 +348,7 @@ async function ensureRequiredTools(action: SessionCommandInput["action"], select return; } - const client = new McpHunkDaemonCliClient(); + const client = createDaemonCliClient(); await client.connect(); try { @@ -352,7 +368,8 @@ async function ensureRequiredTools(action: SessionCommandInput["action"], select await client.close(); } - await restartDaemonForMissingTools(requiredTools, selector); + await (sessionCommandTestHooks?.restartDaemonForMissingTools?.(requiredTools, selector) + ?? restartDaemonForMissingTools(requiredTools, selector)); } function stringifyJson(value: unknown) { @@ -500,7 +517,7 @@ function renderOutput(output: SessionCommandOutput, value: unknown, formatText: } export async function runSessionCommand(input: SessionCommandInput) { - const daemonAvailable = await resolveDaemonAvailability(input.action); + const daemonAvailable = await (sessionCommandTestHooks?.resolveDaemonAvailability?.(input.action) ?? resolveDaemonAvailability(input.action)); if (!daemonAvailable && input.action === "list") { return renderOutput(input.output, { sessions: [] }, () => formatListOutput([])); } @@ -508,7 +525,7 @@ export async function runSessionCommand(input: SessionCommandInput) { const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null; await ensureRequiredTools(input.action, normalizedSelector ?? undefined); - const client = new McpHunkDaemonCliClient(); + const client = createDaemonCliClient(); await client.connect(); try { diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts new file mode 100644 index 00000000..572404ba --- /dev/null +++ b/test/session-commands.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { SessionCommandInput, SessionSelectorInput } from "../src/core/types"; +import { runSessionCommand, setSessionCommandTestHooks, type HunkDaemonCliClient } from "../src/session/commands"; + +function createListedSession(sessionId: string) { + return { + sessionId, + pid: 123, + cwd: "/repo", + repoRoot: "/repo", + inputKind: "diff" as const, + title: "repo diff", + sourceLabel: "/repo", + launchedAt: "2026-03-22T00:00:00.000Z", + fileCount: 1, + files: [ + { + id: "file-1", + path: "README.md", + additions: 1, + deletions: 0, + hunkCount: 1, + }, + ], + snapshot: { + selectedFileId: "file-1", + selectedFilePath: "README.md", + selectedHunkIndex: 0, + selectedHunkOldRange: [1, 1] as [number, number], + selectedHunkNewRange: [1, 2] as [number, number], + showAgentNotes: false, + liveCommentCount: 0, + liveComments: [], + updatedAt: "2026-03-22T00:00:00.000Z", + }, + }; +} + +function createClient(overrides: Partial): HunkDaemonCliClient { + return { + connect: async () => undefined, + close: async () => undefined, + listToolNames: async () => new Set(), + listSessions: async () => [], + getSession: async () => createListedSession("session-1"), + getSelectedContext: async () => ({ + sessionId: "session-1", + title: "repo diff", + sourceLabel: "/repo", + repoRoot: "/repo", + inputKind: "diff", + selectedFile: { + id: "file-1", + path: "README.md", + additions: 1, + deletions: 0, + hunkCount: 1, + }, + selectedHunk: { + index: 0, + oldRange: [1, 1], + newRange: [1, 2], + }, + showAgentNotes: false, + liveCommentCount: 0, + }), + navigateToHunk: async () => ({ + fileId: "file-1", + filePath: "README.md", + hunkIndex: 0, + }), + addComment: async () => ({ + commentId: "comment-1", + fileId: "file-1", + filePath: "README.md", + hunkIndex: 0, + side: "new", + line: 1, + }), + listComments: async () => [], + removeComment: async () => ({ + commentId: "comment-1", + removed: true, + remainingCommentCount: 0, + }), + clearComments: async () => ({ + removedCount: 0, + remainingCommentCount: 0, + }), + ...overrides, + }; +} + +afterEach(() => { + setSessionCommandTestHooks(null); +}); + +describe("session command compatibility checks", () => { + test("refreshes an older Hunk daemon before running a newer context command", async () => { + const selector: SessionSelectorInput = { sessionId: "session-1" }; + const restartCalls: Array<{ missingTools: string[]; selector?: SessionSelectorInput }> = []; + const createdClients: string[] = []; + + const clients = [ + createClient({ + listToolNames: async () => { + createdClients.push("stale-tools"); + return new Set(["list_sessions", "get_session", "comment"]); + }, + }), + createClient({ + getSelectedContext: async (receivedSelector) => { + createdClients.push("fresh-context"); + expect(receivedSelector).toEqual(selector); + return { + sessionId: "session-1", + title: "repo diff", + sourceLabel: "/repo", + repoRoot: "/repo", + inputKind: "diff", + selectedFile: { + id: "file-1", + path: "README.md", + additions: 1, + deletions: 0, + hunkCount: 1, + }, + selectedHunk: { + index: 0, + oldRange: [1, 1], + newRange: [1, 2], + }, + showAgentNotes: false, + liveCommentCount: 0, + }; + }, + }), + ]; + + setSessionCommandTestHooks({ + createClient: () => { + const client = clients.shift(); + if (!client) { + throw new Error("No fake session client remaining."); + } + + return client; + }, + resolveDaemonAvailability: async () => true, + restartDaemonForMissingTools: async (missingTools, receivedSelector) => { + restartCalls.push({ missingTools, selector: receivedSelector }); + }, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "context", + selector, + output: "json", + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toMatchObject({ + context: { + sessionId: "session-1", + selectedFile: { + path: "README.md", + }, + selectedHunk: { + index: 0, + }, + }, + }); + expect(restartCalls).toEqual([ + { + missingTools: ["get_selected_context"], + selector, + }, + ]); + expect(createdClients).toEqual(["stale-tools", "fresh-context"]); + }); + + test("throws a clear error when the daemon is missing tools but does not look like Hunk", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listToolNames: async () => new Set(["list_sessions", "strange_tool"]), + }), + resolveDaemonAvailability: async () => true, + restartDaemonForMissingTools: async () => { + throw new Error("should not restart"); + }, + }); + + await expect( + runSessionCommand({ + kind: "session", + action: "comment-list", + selector: { sessionId: "session-1" }, + output: "json", + } satisfies SessionCommandInput), + ).rejects.toThrow("missing required tools (list_comments)"); + }); +});