Skip to content

Commit ac84eb4

Browse files
authored
fix: refresh stale MCP daemon for session CLI (#55)
* fix: refresh stale MCP daemon for session CLI * test: cover stale session CLI daemon refresh
1 parent d6df232 commit ac84eb4

2 files changed

Lines changed: 389 additions & 13 deletions

File tree

src/session/commands.ts

Lines changed: 186 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
SessionNavigateCommandInput,
1212
SessionSelectorInput,
1313
} from "../core/types";
14-
import { isHunkDaemonHealthy, isLoopbackPortReachable } from "../mcp/daemonLauncher";
14+
import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "../mcp/daemonLauncher";
1515
import { resolveHunkMcpConfig } from "../mcp/config";
1616
import type {
1717
AppliedCommentResult,
@@ -23,9 +23,10 @@ import type {
2323
SessionLiveCommentSummary,
2424
} from "../mcp/types";
2525

26-
interface HunkDaemonCliClient {
26+
export interface HunkDaemonCliClient {
2727
connect(): Promise<void>;
2828
close(): Promise<void>;
29+
listToolNames(): Promise<Set<string>>;
2930
listSessions(): Promise<ListedSession[]>;
3031
getSession(selector: SessionSelectorInput): Promise<ListedSession>;
3132
getSelectedContext(selector: SessionSelectorInput): Promise<SelectedSessionContext>;
@@ -36,17 +37,46 @@ interface HunkDaemonCliClient {
3637
clearComments(input: SessionCommentClearCommandInput): Promise<ClearedCommentsResult>;
3738
}
3839

40+
const REQUIRED_TOOLS_BY_ACTION: Partial<Record<SessionCommandInput["action"], string[]>> = {
41+
context: ["get_selected_context"],
42+
navigate: ["navigate_to_hunk"],
43+
"comment-list": ["list_comments"],
44+
"comment-rm": ["remove_comment"],
45+
"comment-clear": ["clear_comments"],
46+
};
47+
48+
interface SessionCommandTestHooks {
49+
createClient?: () => HunkDaemonCliClient;
50+
resolveDaemonAvailability?: (action: SessionCommandInput["action"]) => Promise<boolean>;
51+
restartDaemonForMissingTools?: (missingTools: string[], selector?: SessionSelectorInput) => Promise<void>;
52+
}
53+
54+
let sessionCommandTestHooks: SessionCommandTestHooks | null = null;
55+
56+
export function setSessionCommandTestHooks(hooks: SessionCommandTestHooks | null) {
57+
sessionCommandTestHooks = hooks;
58+
}
59+
60+
function createDaemonCliClient() {
61+
return sessionCommandTestHooks?.createClient?.() ?? new McpHunkDaemonCliClient();
62+
}
63+
3964
function extractToolValue<ResultType>(
4065
result: Awaited<ReturnType<Client["callTool"]>>,
4166
key: string,
4267
): ResultType | undefined {
68+
const content = (result.content ?? []) as Array<{ type?: string; text?: string }>;
69+
const text = content.find((entry) => entry.type === "text")?.text;
70+
71+
if (result.isError) {
72+
throw new Error(text || "The Hunk daemon returned an MCP tool error.");
73+
}
74+
4375
const structured = result.structuredContent as Record<string, ResultType> | undefined;
4476
if (structured && key in structured) {
4577
return structured[key];
4678
}
4779

48-
const content = (result.content ?? []) as Array<{ type?: string; text?: string }>;
49-
const text = content.find((entry) => entry.type === "text")?.text;
5080
if (!text) {
5181
return undefined;
5282
}
@@ -75,6 +105,11 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient {
75105
await this.transport.close().catch(() => undefined);
76106
}
77107

108+
async listToolNames() {
109+
const result = await this.client.listTools();
110+
return new Set(result.tools.map((tool) => tool.name));
111+
}
112+
78113
async listSessions() {
79114
const result = await this.client.callTool({
80115
name: "list_sessions",
@@ -202,6 +237,141 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient {
202237
}
203238
}
204239

240+
async function readDaemonHealth() {
241+
const config = resolveHunkMcpConfig();
242+
243+
try {
244+
const response = await fetch(`${config.httpOrigin}/health`);
245+
if (!response.ok) {
246+
return null;
247+
}
248+
249+
return (await response.json()) as {
250+
ok: boolean;
251+
pid?: number;
252+
sessions?: number;
253+
};
254+
} catch {
255+
return null;
256+
}
257+
}
258+
259+
async function waitForDaemonShutdown(timeoutMs = 3_000) {
260+
const config = resolveHunkMcpConfig();
261+
const deadline = Date.now() + timeoutMs;
262+
263+
while (Date.now() < deadline) {
264+
if (!(await isHunkDaemonHealthy(config))) {
265+
return true;
266+
}
267+
268+
await Bun.sleep(100);
269+
}
270+
271+
return false;
272+
}
273+
274+
function sessionMatchesSelector(session: ListedSession, selector: SessionSelectorInput) {
275+
if (selector.sessionId) {
276+
return session.sessionId === selector.sessionId;
277+
}
278+
279+
if (selector.repoRoot) {
280+
return session.repoRoot === selector.repoRoot;
281+
}
282+
283+
return true;
284+
}
285+
286+
async function waitForSessionRegistration(selector: SessionSelectorInput, timeoutMs = 8_000) {
287+
const deadline = Date.now() + timeoutMs;
288+
289+
while (Date.now() < deadline) {
290+
const client = createDaemonCliClient();
291+
await client.connect();
292+
293+
try {
294+
const sessions = await client.listSessions();
295+
if (sessions.some((session) => sessionMatchesSelector(session, selector))) {
296+
return true;
297+
}
298+
} finally {
299+
await client.close();
300+
}
301+
302+
await Bun.sleep(200);
303+
}
304+
305+
return false;
306+
}
307+
308+
async function restartDaemonForMissingTools(missingTools: string[], selector?: SessionSelectorInput) {
309+
const health = await readDaemonHealth();
310+
const pid = health?.pid;
311+
if (!pid || pid === process.pid) {
312+
throw new Error(
313+
`The running Hunk MCP daemon is missing required tools (${missingTools.join(", ")}). ` +
314+
`Restart Hunk so it can launch a fresh daemon from the current source tree.`,
315+
);
316+
}
317+
318+
process.kill(pid, "SIGTERM");
319+
320+
const shutDown = await waitForDaemonShutdown();
321+
if (!shutDown) {
322+
throw new Error(
323+
`Stopped waiting for the old Hunk MCP daemon to exit after it was found missing required tools (${missingTools.join(", ")}).`,
324+
);
325+
}
326+
327+
launchHunkDaemon();
328+
329+
const config = resolveHunkMcpConfig();
330+
const ready = await waitForHunkDaemonHealth({ config, timeoutMs: 3_000 });
331+
if (!ready) {
332+
throw new Error("Timed out waiting for the refreshed Hunk MCP daemon to start.");
333+
}
334+
335+
if (selector) {
336+
const registered = await waitForSessionRegistration(selector);
337+
if (!registered) {
338+
throw new Error(
339+
"Timed out waiting for the live Hunk session to reconnect after refreshing the MCP daemon.",
340+
);
341+
}
342+
}
343+
}
344+
345+
async function ensureRequiredTools(action: SessionCommandInput["action"], selector?: SessionSelectorInput) {
346+
const requiredTools = REQUIRED_TOOLS_BY_ACTION[action] ?? [];
347+
if (requiredTools.length === 0) {
348+
return;
349+
}
350+
351+
const client = createDaemonCliClient();
352+
await client.connect();
353+
354+
try {
355+
const toolNames = await client.listToolNames();
356+
const missingTools = requiredTools.filter((tool) => !toolNames.has(tool));
357+
if (missingTools.length === 0) {
358+
return;
359+
}
360+
361+
const looksLikeOlderHunkDaemon = toolNames.has("list_sessions") && toolNames.has("get_session");
362+
if (!looksLikeOlderHunkDaemon) {
363+
throw new Error(
364+
`The Hunk MCP daemon is missing required tools (${missingTools.join(", ")}). Available tools: ${[...toolNames].join(", ") || "(none)"}.`,
365+
);
366+
}
367+
} finally {
368+
await client.close();
369+
}
370+
371+
await (sessionCommandTestHooks?.restartDaemonForMissingTools?.(requiredTools, selector)
372+
?? restartDaemonForMissingTools(requiredTools, selector));
373+
}
374+
205375
function stringifyJson(value: unknown) {
206376
return `${JSON.stringify(value, null, 2)}\n`;
207377
}
@@ -347,12 +517,15 @@ function renderOutput(output: SessionCommandOutput, value: unknown, formatText:
347517
}
348518

349519
export async function runSessionCommand(input: SessionCommandInput) {
350-
const daemonAvailable = await resolveDaemonAvailability(input.action);
520+
const daemonAvailable = await (sessionCommandTestHooks?.resolveDaemonAvailability?.(input.action) ?? resolveDaemonAvailability(input.action));
351521
if (!daemonAvailable && input.action === "list") {
352522
return renderOutput(input.output, { sessions: [] }, () => formatListOutput([]));
353523
}
354524

355-
const client = new McpHunkDaemonCliClient();
525+
const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null;
526+
await ensureRequiredTools(input.action, normalizedSelector ?? undefined);
527+
528+
const client = createDaemonCliClient();
356529
await client.connect();
357530

358531
try {
@@ -362,45 +535,45 @@ export async function runSessionCommand(input: SessionCommandInput) {
362535
return renderOutput(input.output, { sessions }, () => formatListOutput(sessions));
363536
}
364537
case "get": {
365-
const session = await client.getSession(normalizeRepoRoot(input.selector));
538+
const session = await client.getSession(normalizedSelector!);
366539
return renderOutput(input.output, { session }, () => formatSessionOutput(session));
367540
}
368541
case "context": {
369-
const context = await client.getSelectedContext(normalizeRepoRoot(input.selector));
542+
const context = await client.getSelectedContext(normalizedSelector!);
370543
return renderOutput(input.output, { context }, () => formatContextOutput(context));
371544
}
372545
case "navigate": {
373546
const result = await client.navigateToHunk({
374547
...input,
375-
selector: normalizeRepoRoot(input.selector),
548+
selector: normalizedSelector!,
376549
});
377550
return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result));
378551
}
379552
case "comment-add": {
380553
const result = await client.addComment({
381554
...input,
382-
selector: normalizeRepoRoot(input.selector),
555+
selector: normalizedSelector!,
383556
});
384557
return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result));
385558
}
386559
case "comment-list": {
387560
const comments = await client.listComments({
388561
...input,
389-
selector: normalizeRepoRoot(input.selector),
562+
selector: normalizedSelector!,
390563
});
391564
return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments));
392565
}
393566
case "comment-rm": {
394567
const result = await client.removeComment({
395568
...input,
396-
selector: normalizeRepoRoot(input.selector),
569+
selector: normalizedSelector!,
397570
});
398571
return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result));
399572
}
400573
case "comment-clear": {
401574
const result = await client.clearComments({
402575
...input,
403-
selector: normalizeRepoRoot(input.selector),
576+
selector: normalizedSelector!,
404577
});
405578
return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result));
406579
}

0 commit comments

Comments
 (0)