diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index a0db0c06e..a00435b2f 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -124,6 +124,7 @@ export class AnalysisOutputError extends AnalysisError { export class AnalysisCapabilityUnavailableError extends AnalysisError { readonly _tag = "AnalysisCapabilityUnavailableError"; override readonly userMessage: string | undefined; + readonly remediationAction: string | undefined; /** Incomplete cleanup must settle before the operation can be retried. */ readonly retryable: boolean; @@ -133,6 +134,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { readonly reason: string, options?: AnalysisErrorOptions & { readonly userMessage?: string; + readonly remediationAction?: string; readonly retryable?: boolean; }, ) { @@ -141,6 +143,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { options, ); this.userMessage = options?.userMessage; + this.remediationAction = options?.remediationAction; this.retryable = options?.retryable === true && !this.cleanupIncomplete; } } diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 75a2d0c28..ec5efa50d 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -43,7 +43,8 @@ export const analysisErrorRemediationAction = ( ): string => { error = resolvedAnalysisError(error); if ( - error instanceof ProviderAdapterError && + (error instanceof ProviderAdapterError || + error instanceof AnalysisCapabilityUnavailableError) && error.remediationAction !== undefined ) return error.remediationAction; diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index de8c326af..7b18fe4dd 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -866,3 +866,30 @@ it("retains the primary failure and both cleanup reports after a secondary failu }, }); }); + +it.each([false, true])( + "preserves producer capability recovery with retryable=%s", + (retryable) => { + const recovery = + "Choose an explicit disposition, then retry the same session."; + const projected = projectAnalysisError( + new AnalysisCapabilityUnavailableError("fixture", "close", "live state", { + retryable, + remediationAction: recovery, + }), + ); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable, + remediation: { action: recovery }, + details: { + provider_id: "fixture", + operation: "close", + reason: "live state", + }, + }); + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe( + true, + ); + }, +); diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index ad4c5e21b..25d21a392 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -1316,14 +1316,18 @@ export class GdbSessionManager { ); if (groupCount > 0 && !session.inferiorTtyVerified) return err( - new AnalysisInputError("close_gdb_session", undefined, [ + new AnalysisCapabilityUnavailableError( + GDB_PROVIDER_IDENTITY.id, + "close_gdb_session", + "Live GDB inferiors require an explicit disposition because no durable inferior TTY is verified.", { - path: ["session_id"], - reason: "invalid_value", - message: - "Live GDB thread groups remain and no verified durable inferior TTY is configured. Select a safe disposition through the GDB console or restart with inferior_tty before closing.", + retryable: true, + userMessage: + "Close requires an explicit inferior disposition.", + remediationAction: + "Use gdb_console with this session_id to explicitly detach or terminate each remaining inferior, then retry close_gdb_session.", }, - ]), + ), ); if (groupCount === 0) { session.noInferiorVerified = true; diff --git a/tests/boundary/mcp/gdbCloseDisposition.test.ts b/tests/boundary/mcp/gdbCloseDisposition.test.ts new file mode 100644 index 000000000..f49455760 --- /dev/null +++ b/tests/boundary/mcp/gdbCloseDisposition.test.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { expect, it } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; +import { silentLogger } from "../../../src/logger.js"; +import { registerGdbTools } from "../../../src/server/registerGdbTools.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; + +it.skipIf(process.platform === "win32").each(["kill", "detach"])( + "retains a valid GDB session through disposition refusal and explicit %s recovery", + async (disposition) => { + const directory = await mkdtemp(join(tmpdir(), "rea-gdb-disposition-")); + const provider = join(directory, "fake-gdb.mjs"); + await writeFile( + provider, + `#!${process.execPath} +import { createInterface } from 'node:readline'; +let live = false; +process.stdout.write('(gdb)\\n'); +createInterface({ input: process.stdin }).on('line', line => { + const token = /^(\\d+)/.exec(line)?.[1]; + if (!token) return; + const command = line.slice(token.length); + if (command === '-interpreter-exec console "run"') { + live = true; + process.stdout.write(token + '^running\\n*stopped,reason="breakpoint-hit",thread-id="1"\\n(gdb)\\n'); + return; + } + const disposition = command === '-interpreter-exec console "kill"' || command === '-interpreter-exec console "detach"'; + if (disposition) live = false; + const result = command === '-list-thread-groups --recurse 1' + ? (live ? 'done,groups=[{id="i1",type="process",pid="4242"}]' : 'done,groups=[]') + : disposition || command === '-gdb-set auto-load off' || command === '-gdb-exit' + ? 'done' : 'error,msg="Unmodeled fixture command"'; + process.stdout.write(token + '^' + result + '\\n(gdb)\\n', () => { + if (line.includes('-gdb-exit')) process.exit(0); + }); +}); +`, + ); + await chmod(provider, 0o700); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: provider }, + }); + const server = new McpServer({ + name: "gdb-disposition-test", + version: "1", + }); + registerGdbTools( + server, + manager, + silentLogger, + createToolResultDelivery(undefined), + ); + const client = new Client({ name: "gdb-disposition-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + let sessionId: string | undefined; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_gdb_session", + arguments: {}, + }); + sessionId = toolContract("start_gdb_session").outputSchema.parse( + started.structuredContent, + ).session_id; + const running = await client.callTool({ + name: "gdb_console", + arguments: { session_id: sessionId, command: "run" }, + }); + expect(running.isError).not.toBe(true); + for (let attempt = 0; attempt < 2; attempt += 1) { + const refusal = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: sessionId }, + }); + expect(refusal.isError).toBe(true); + expect(refusal.structuredContent).toMatchObject({ + error: { + code: "capability_unavailable", + category: "unavailable", + retryable: true, + remediation: { action: expect.stringContaining("gdb_console") }, + details: { + provider_id: "gnu.gdb", + operation: "close_gdb_session", + reason: expect.stringContaining("inferior"), + }, + }, + }); + expect(JSON.stringify(refusal.structuredContent)).not.toContain( + "invalid_value", + ); + expect(manager.status(sessionId)?.state).toBe("ready"); + } + const disposed = await client.callTool({ + name: "gdb_console", + arguments: { session_id: sessionId, command: disposition }, + }); + expect(disposed.isError).not.toBe(true); + const closed = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: sessionId }, + }); + expect(closed.isError).not.toBe(true); + expect(closed.structuredContent).toMatchObject({ + target_state: "none_observed", + }); + expect(manager.status(sessionId)).toBeUndefined(); + const unknown = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: randomUUID() }, + }); + expect(unknown.structuredContent).toMatchObject({ + error: { code: "invalid_request", category: "invalid_input" }, + }); + } finally { + if (sessionId !== undefined && manager.status(sessionId) !== undefined) { + const disposed = await manager.execute(sessionId, "kill"); + if (!disposed.ok) throw disposed.error; + const closed = await manager.close(sessionId); + if (!closed.ok) throw closed.error; + } + await client.close(); + await server.close(); + await manager.closeAll(); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 5c85ccb25..04bd74184 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -1752,25 +1752,6 @@ it("correlates command output records and reports invalid or cancelled requests" expect(manager.status(started.value.session_id)).toBeDefined(); }); -it("refuses to close a session while GDB reports a live target", async () => { - const manager = await managerFor( - responsiveProvider(` - if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); - else if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^done,groups=[{id="i1",pid="123"}]\\n'); - else process.stdout.write(match[1] + '^done\\n'); - `), - ); - const started = await expectGdbSessionStarted(manager); - - const closed = await manager.close(started.value.session_id); - - expect(closed.ok).toBe(false); - if (!closed.ok) - expect(JSON.stringify(closed.error)).toContain( - "Live GDB thread groups remain", - ); -}); - it.each([ ["malformed recursive list", "groups=[}]"], ["malformed result suffix", "groups=[],extra=}"],