diff --git a/src/ida/IdaBusyHealth.test.ts b/src/ida/IdaBusyHealth.test.ts index 8b99bf9d55..e4671e1850 100644 --- a/src/ida/IdaBusyHealth.test.ts +++ b/src/ida/IdaBusyHealth.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { IdaMcpConnection } from "./IdaMcpConnection.js"; import { readSettledHealth } from "./IdaBusyHealth.js"; @@ -139,8 +140,13 @@ it("reports the busy tool of the last observation", async () => { clock, }).catch((cause: unknown) => cause); - expect(failure, "the operation names the tool seen last").toMatchObject({ - operation: "server_health: headless worker still busy in last_tool", + expect( + failure, + "the producer retains the tool seen last independently of attribution", + ).toMatchObject({ + operation: "server_health", + userMessage: expect.stringContaining("last_tool"), + diagnostics: { busy_tool: "last_tool" }, cause: busy("last_tool"), }); }); @@ -166,3 +172,34 @@ it("rejects without another read when the signal aborts after the pause has fulf }); expect(failure instanceof Error && failure.cause).toBe(reason); }); + +it("retains the last busy observation when a shortened health request times out", async () => { + const capturedOutput = { + stdout: "", + stderr: "transport closed", + truncated: false, + }; + const raw = busy("last_tool"); + let reads = 0; + const { connection } = scripted([raw]); + connection.call = async (_name, _args, options) => { + if (reads++ === 0) return raw; + throw new AnalysisTimeoutError("server_health", options?.timeoutMs ?? 0, { + capturedOutput, + }); + }; + const { clock } = virtualClock(); + const failure = await readSettledHealth(connection, { + database: "db", + timeoutMs: 350, + clock, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + operation: "server_health", + timeoutMs: 350, + cause: raw, + capturedOutput, + userMessage: expect.stringContaining("last_tool"), + diagnostics: { busy_tool: "last_tool", health_request_timeout_ms: 250 }, + }); +}); diff --git a/src/ida/IdaBusyHealth.ts b/src/ida/IdaBusyHealth.ts index c68f8d4b85..55ffc58958 100644 --- a/src/ida/IdaBusyHealth.ts +++ b/src/ida/IdaBusyHealth.ts @@ -35,7 +35,7 @@ const isBusyStatus = (raw: JsonValue): boolean => interface LastBusyHealth { readonly raw: JsonValue; - readonly operation: string; + readonly tool: string; } /** @@ -90,15 +90,7 @@ export const readSettledHealth = async ( cause.timeoutMs === requestTimeoutMs && requestTimeoutMs < timeoutMs ) { - throw new AnalysisTimeoutError(lastBusy.operation, timeoutMs, { - cause: lastBusy.raw, - capturedOutput: cause.capturedOutput, - ...idaRegistrationTimeoutGuidance(timeoutMs, "readiness"), - diagnostics: { - health_request_operation: cause.operation, - health_request_timeout_ms: cause.timeoutMs, - }, - }); + throw busyTimeout(lastBusy, timeoutMs, cause); } throw cause; } @@ -109,7 +101,7 @@ export const readSettledHealth = async ( } const observedBusy = { raw, - operation: `server_health: headless worker still busy in ${busyToolName(busy.data.busy_tool)}`, + tool: busyToolName(busy.data.busy_tool), }; lastBusy = observedBusy; const expired = (): AnalysisTimeoutError => @@ -139,8 +131,24 @@ export const readSettledHealth = async ( const busyTimeout = ( lastBusy: LastBusyHealth, timeoutMs: number, -): AnalysisTimeoutError => - new AnalysisTimeoutError(lastBusy.operation, timeoutMs, { + requestFailure?: AnalysisTimeoutError, +): AnalysisTimeoutError => { + const guidance = idaRegistrationTimeoutGuidance(timeoutMs, "readiness"); + return new AnalysisTimeoutError("server_health", timeoutMs, { cause: lastBusy.raw, - ...idaRegistrationTimeoutGuidance(timeoutMs, "readiness"), + ...guidance, + userMessage: `${guidance.userMessage} The last reported busy tool was ${lastBusy.tool}.`, + diagnostics: { + busy_tool: lastBusy.tool, + ...(requestFailure === undefined + ? {} + : { + health_request_operation: requestFailure.operation, + health_request_timeout_ms: requestFailure.timeoutMs, + }), + }, + ...(requestFailure === undefined + ? {} + : { capturedOutput: requestFailure.capturedOutput }), }); +}; diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 15afaa203a..a7d24f9ac6 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -771,22 +771,28 @@ describe("headless IDA transient busy health", () => { expect(projected.retryable, "the caller may retry").toBe(true); expect( projected.details, - "the observed busy tool is named exactly in the operation", + "the timeout is attributed to the requested operation", ).toMatchObject({ - operation: "server_health: headless worker still busy in config_json_get", + operation: "health", timeout_ms: 300, }); expect( - result.error.message, - "the error message names the busy tool", + projected.message, + "the public error message names the busy tool", ).toContain("config_json_get"); expect( `${result.error.message} ${projected.message}`, "no Hopper wording leaks in", ).not.toContain("Hopper"); - expect(result.error.cause, "the raw busy payload is the cause").toEqual( - busy, - ); + expect( + result.error.cause, + "reattribution preserves the producing timeout", + ).toMatchObject({ + _tag: "AnalysisTimeoutError", + operation: "server_health", + cause: busy, + diagnostics: { busy_tool: "config_json_get" }, + }); expect( elapsed, "the wait runs to the deadline, not less", @@ -808,10 +814,14 @@ describe("headless IDA transient busy health", () => { if (result.ok) throw new Error("expected a busy timeout"); expect( projectAnalysisError(result.error).details, - "an omitted busy_tool is reported as exactly unknown", + "the public operation stays independent of an omitted busy tool", ).toMatchObject({ - operation: "server_health: headless worker still busy in unknown", + operation: "health", }); + expect(result.error.diagnostics).toMatchObject({ busy_tool: "unknown" }); + expect(projectAnalysisError(result.error).message).toContain( + "busy tool was unknown", + ); await expectCleanClose(producer, client); }); @@ -832,11 +842,12 @@ describe("headless IDA transient busy health", () => { projectAnalysisError(result.error).details, "only a tool-name-shaped value is repeated", ).toMatchObject({ - operation: "server_health: headless worker still busy in unknown", + operation: "health", }); + expect(result.error.diagnostics).toMatchObject({ busy_tool: "unknown" }); expect( - result.error.message, - "the producer text never reaches the error message", + projectAnalysisError(result.error).message, + "the producer text never reaches the public error message", ).not.toContain(tool); await expectCleanClose(producer, client); },