diff --git a/packages/loopover-contract/src/shared.ts b/packages/loopover-contract/src/shared.ts index b04b5e42a..bb1dc97ed 100644 --- a/packages/loopover-contract/src/shared.ts +++ b/packages/loopover-contract/src/shared.ts @@ -5,6 +5,7 @@ // second. Two tools that happen to take the same fields today are usually a coincidence, and // prematurely coupling them means a later divergence has to be un-shared under pressure. import { z } from "zod"; +import { MCP_TELEMETRY_ERROR_CODES } from "./telemetry.js"; /** The owner/repo pair virtually every repo-scoped tool takes. */ export const ownerRepoInput = z.object({ @@ -38,7 +39,10 @@ export const freshnessFields = { export const toolErrorFields = { error: z .object({ - code: z.string().min(1), + // #9659: the closed set, not free text. The doc above always said "drawn from a closed, + // developer-defined set so telemetry can break failures down by cause" -- while the type said + // `z.string()`, which is what let a server return a code telemetry then re-guessed differently. + code: z.enum(MCP_TELEMETRY_ERROR_CODES), message: z.string().min(1), }) .optional(), diff --git a/packages/loopover-contract/src/telemetry.ts b/packages/loopover-contract/src/telemetry.ts index 2cdc13745..fade74d71 100644 --- a/packages/loopover-contract/src/telemetry.ts +++ b/packages/loopover-contract/src/telemetry.ts @@ -35,6 +35,10 @@ export const MCP_TELEMETRY_ERROR_CODES = [ "upstream_error", "timeout", "elicitation_declined", + // #9659: the miner's own envelope has always returned this to callers -- a local SQLite store that + // will not open. It belongs in the closed set so ONE code can serve both the envelope and the + // telemetry event, rather than the event re-deriving a different one from the message. + "store_unavailable", "unknown_error", ] as const; export type McpTelemetryErrorCode = (typeof MCP_TELEMETRY_ERROR_CODES)[number]; @@ -264,6 +268,22 @@ export function mcpToolSpanName(tool: string): string { * Matches on shape and on the small set of messages the servers actually produce; everything else * is `unknown_error` rather than a guess. Never reads a caller-supplied string into the code. */ +/** + * The error envelope a tool's `structuredContent` carries, if it carries one (#9659). + * + * Every server reports failure the same way -- `isError: true` plus `{ error: { code, message } }` -- but + * each was reading that result differently, or not at all: the remote emitted a hardcoded + * `"unknown_error"`, the stdio one passed nothing to the classifier, and the miner passed the raw thrown + * error so the code was re-derived from message regexes and disagreed with the code the caller was given. + * Feed the result of this straight to `resolveErrorCode`, which validates the declared code against the + * closed set and falls back for anything else. + */ +export function toolErrorEnvelope(structuredContent: unknown): { code?: unknown; message?: unknown } | undefined { + if (typeof structuredContent !== "object" || structuredContent === null) return undefined; + const envelope = (structuredContent as { error?: unknown }).error; + return typeof envelope === "object" && envelope !== null ? (envelope as { code?: unknown; message?: unknown }) : undefined; +} + export function resolveErrorCode(error: unknown): McpTelemetryErrorCode { const envelope = error as { code?: unknown } | null | undefined; if (envelope && typeof envelope.code === "string") { diff --git a/packages/loopover-contract/src/tools/miner-ops.ts b/packages/loopover-contract/src/tools/miner-ops.ts index 3d8194b94..c824facf0 100644 --- a/packages/loopover-contract/src/tools/miner-ops.ts +++ b/packages/loopover-contract/src/tools/miner-ops.ts @@ -20,6 +20,7 @@ // agent-safe control and is here instead. import { z } from "zod"; import { defineTool } from "../tool-definition.js"; +import { toolErrorFields } from "../shared.js"; import { INSTANCE_CHECK_STATUSES } from "../enums.js"; const RepoFullName = z.string().min(3).max(200).describe("owner/repo."); @@ -32,6 +33,11 @@ export const MinerDoctorInput = z.object({}); export const MinerDoctorOutput = z.looseObject({ ok: z.boolean().describe("True when no check reported fail. Warnings do not clear it to false."), checks: z.array(z.looseObject({ name: z.string(), status: z.enum(INSTANCE_CHECK_STATUSES), detail: z.string().optional() })), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerDoctorTool = defineTool({ @@ -59,6 +65,11 @@ export const MinerMetricsSnapshotOutput = z.looseObject({ samples: z.array(z.looseObject({ value: z.number(), labels: z.record(z.string(), z.string()).optional() })), }), ), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerMetricsSnapshotTool = defineTool({ @@ -86,7 +97,11 @@ export const MinerGovernorActionOutput = z.looseObject({ blocked: z.boolean().optional().describe("True when the governor chokepoint refused the action."), reason: z.string().optional(), result: z.unknown().optional(), - error: z.string().optional(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const MinerGovernorPauseInput = z.object({ @@ -216,7 +231,11 @@ export const MinerRunMigrationsOutput = z.looseObject({ result: z.unknown().optional(), blocked: z.boolean().optional(), reason: z.string().optional(), - error: z.string().optional(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerRunMigrationsTool = defineTool({ @@ -248,7 +267,11 @@ export const MinerPurgeRepoOutput = z.looseObject({ result: z.unknown().optional(), blocked: z.boolean().optional(), reason: z.string().optional(), - error: z.string().optional(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerPurgeRepoTool = defineTool({ diff --git a/packages/loopover-contract/src/tools/miner.ts b/packages/loopover-contract/src/tools/miner.ts index fe9013575..bad607d96 100644 --- a/packages/loopover-contract/src/tools/miner.ts +++ b/packages/loopover-contract/src/tools/miner.ts @@ -11,6 +11,7 @@ // gains is a description of what it already returns, not a new shape. import { z } from "zod"; import { defineTool } from "../tool-definition.js"; +import { toolErrorFields } from "../shared.js"; import { PLAN_STEP_STATUSES } from "../enums.js"; /** Statuses a portfolio-queue entry can hold. */ @@ -35,6 +36,11 @@ export const MinerPingInput = z.object({}); export const MinerPingOutput = z.looseObject({ status: z.literal("ok"), tool: z.literal("loopover_miner_ping"), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerPingTool = defineTool({ @@ -69,6 +75,11 @@ export const MinerPortfolioDashboardOutput = z.looseObject({ }), ), oldestQueuedAgeMs: z.number().nullable(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerPortfolioDashboardTool = defineTool({ @@ -114,6 +125,11 @@ export const MinerManageStatusOutput = z.looseObject({ prs: z.array(manageStatusRowSchema), }), ), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerManageStatusTool = defineTool({ @@ -149,6 +165,11 @@ export const MinerListClaimsOutput = z.looseObject({ note: z.string().nullish(), }), ), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerListClaimsTool = defineTool({ @@ -187,6 +208,11 @@ export const MinerAuditFeedOutput = z.looseObject({ createdAt: z.string(), }), ), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerAuditFeedTool = defineTool({ @@ -219,6 +245,11 @@ export const MinerGetRunStateOutput = z.looseObject({ repoFullName: z.string().optional(), state: z.enum(MINER_RUN_STATES).nullable().optional(), states: z.array(z.looseObject({ repoFullName: z.string(), state: z.enum(MINER_RUN_STATES).nullable() })).optional(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerGetRunStateTool = defineTool({ @@ -275,6 +306,11 @@ export const MinerListPlansInput = z.object({ export const MinerListPlansOutput = z.looseObject({ plans: z.array(minerPlanRecordSchema), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerListPlansTool = defineTool({ @@ -300,6 +336,11 @@ export const MinerGetPlanOutput = z.looseObject({ planId: z.string().optional(), found: z.boolean(), plan: minerPlanRecordSchema.optional(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerGetPlanTool = defineTool({ @@ -335,6 +376,11 @@ export const MinerGovernorDecisionsOutput = z.looseObject({ reason: z.string(), }), ), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerGovernorDecisionsTool = defineTool({ @@ -371,6 +417,11 @@ export const MinerStatusOutput = z.looseObject({ }), }), doctor: z.array(z.looseObject({ name: z.string(), ok: z.boolean(), detail: z.string() })), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerStatusTool = defineTool({ @@ -410,6 +461,11 @@ export const MinerCalibrationReportOutput = z.looseObject({ }), ), hasSignal: z.boolean(), + // #9659: every miner tool answers a store failure with the shared error envelope + // (`withMinerToolErrorHandling`), so the advertised schema declares it rather than describing only + // the success shape. `error.code` is the closed telemetry set, which is what lets the code the caller + // is told and the code telemetry records be the same one. + ...toolErrorFields, }); export const minerCalibrationReportTool = defineTool({ diff --git a/packages/loopover-mcp/lib/telemetry.ts b/packages/loopover-mcp/lib/telemetry.ts index 2a56445bc..dcf5a3c88 100644 --- a/packages/loopover-mcp/lib/telemetry.ts +++ b/packages/loopover-mcp/lib/telemetry.ts @@ -7,6 +7,7 @@ import { MCP_TOOL_CALL_EVENT, MCP_USAGE_EVENT, resolveErrorCode, + toolErrorEnvelope, toolExcludesPayloads, UNKNOWN_TOOL_CATEGORY, type McpTelemetryTransport, @@ -131,6 +132,9 @@ export function wrapStdioToolHandler( transport, args: args[0], result: result?.structuredContent, + // #9659: on the failure path this used to pass no error at all, so `resolveErrorCode(undefined)` + // classified every returned failure as `unknown_error` regardless of what the tool told the caller. + ...(ok ? {} : { error: toolErrorEnvelope(result?.structuredContent) }), }); return result; } catch (error) { diff --git a/packages/loopover-miner/bin/loopover-miner-mcp.ts b/packages/loopover-miner/bin/loopover-miner-mcp.ts index 2a5b5c604..7b42373bb 100755 --- a/packages/loopover-miner/bin/loopover-miner-mcp.ts +++ b/packages/loopover-miner/bin/loopover-miner-mcp.ts @@ -2,6 +2,7 @@ import { readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { McpServer, type ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpTelemetryErrorCode } from "@loopover/contract"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // #9536: every tool's schemas come from the shared contract instead of being declared here. The // remote and stdio servers already register from the same package (#9517/#9518) -- this closes the @@ -53,7 +54,7 @@ import { initGovernorLedger } from "../lib/governor-ledger.js"; import { collectStatus, runDoctorChecks } from "../lib/status.js"; import { collectMinerPredictionMetrics } from "@loopover/engine"; import { collectPredictionMetricRows } from "../lib/metrics-cli.js"; -import { dispatchChatAction } from "../lib/chat-action-dispatch.js"; +import { dispatchChatAction, type ChatActionRefusalStatus } from "../lib/chat-action-dispatch.js"; import { MINER_CLAIM_RELEASE_ACTION, MINER_DENY_HOOKS_DECIDE_ACTION, @@ -86,9 +87,21 @@ import { captureMinerPostHogErrorAndFlush, initMinerPostHog } from "../lib/posth * thrown values to this set rather than passing a caller/store-derived string through, matching the * `code`/`message` shape @loopover/contract's shared `toolErrorFields` describes. */ -type MinerToolErrorCode = "store_unavailable" | "unknown_error"; - -function toolErrorCode(error: unknown): MinerToolErrorCode { +/** + * Each way a dispatch can refuse, as the closed telemetry code for it (#9659). + * + * A `Record` over the status union rather than a lookup with a fallback: adding a dispatch status without + * deciding what it means to a caller then fails the build, which is the only reason a mapping between two + * closed vocabularies is safe to write down at all. + */ +const DISPATCH_REFUSAL_CODES: Record = { + disabled: "not_configured", + unknown_action: "not_found", + invalid_params: "invalid_input", + handler_error: "upstream_error", +}; + +function toolErrorCode(error: unknown): McpTelemetryErrorCode { // A local SQLite store failing to open (missing file, corrupted file, permissions) is the one // failure mode every store-backed tool below can actually hit; anything else is unclassified. return error instanceof Error && /not found|not a database|permission|ENOENT/i.test(error.message) ? "store_unavailable" : "unknown_error"; @@ -173,7 +186,10 @@ async function withMinerToolErrorHandling( return payload; } catch (error) { const data = { error: { code: toolErrorCode(error), message: error instanceof Error ? error.message : String(error) } }; - recordMinerDispatchTelemetry({ tool: toolName, ok: false, durationMs: Date.now() - startedAt, error }); + // #9659: the ENVELOPE, not the raw error. Passing the raw one made telemetry re-derive a code from + // the message regexes, so a store that would not open told the caller `store_unavailable` while the + // event recorded `not_found` -- two classifications of one failure, from adjacent lines. + recordMinerDispatchTelemetry({ tool: toolName, ok: false, durationMs: Date.now() - startedAt, error, errorEnvelope: data.error }); return { ...minerToolResult(data), isError: true }; } } @@ -274,7 +290,11 @@ export interface MinerMcpServerOptions { export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { const server = new McpServer({ name: "loopover-miner", version: ownPackageJson.version }); - registerMinerTool(server, minerPingTool, async () => minerToolResult(MINER_PING_STATUS), + // #9658: through the wrapper, like the other twenty. Ping is the health check an operator's monitoring + // hits on a loop -- the cheapest signal that this server is alive and being used -- and it was the one + // registration that never reached the dispatch-telemetry chokepoint, so a `usage_event` breakdown by + // tool reported zero pings forever. + registerMinerTool(server, minerPingTool, () => withMinerToolErrorHandling(() => MINER_PING_STATUS, minerPingTool.name), ); registerMinerTool(server, minerPortfolioDashboardTool, () => @@ -436,7 +456,16 @@ export function createMinerMcpServer(options: MinerMcpServerOptions = {}) { if (result.ok) return { ok: true, action, result: (result as { result?: unknown }).result }; // A refusal is an ANSWER, not a transport failure: the caller needs to know the governor said no and // why, which a thrown error would flatten into a generic tool error. - return { ok: false, action, blocked: true, reason: result.status, ...(result.error ? { error: result.error } : {}) }; + return { + ok: false, + action, + blocked: true, + reason: result.status, + // #9659: the shared envelope, so one `error` field means one thing everywhere. `blocked` and + // `reason` still carry the refusal's own vocabulary; what used to be a bare string detail is now + // the envelope's `message`, under a code drawn from the same closed set telemetry records. + error: { code: DISPATCH_REFUSAL_CODES[result.status] ?? "unknown_error", message: typeof result.error === "string" ? result.error : result.status }, + }; }; registerMinerTool(server, minerGovernorPauseTool, (input) => withMinerToolErrorHandling(() => dispatchResult(GOVERNOR_PAUSE_CHAT_ACTION, input.reason ? { reason: input.reason } : {}), minerGovernorPauseTool.name), diff --git a/packages/loopover-miner/lib/chat-action-dispatch.ts b/packages/loopover-miner/lib/chat-action-dispatch.ts index cc630919d..21764d180 100644 --- a/packages/loopover-miner/lib/chat-action-dispatch.ts +++ b/packages/loopover-miner/lib/chat-action-dispatch.ts @@ -30,12 +30,26 @@ export function isChatActionDispatchEnabled(env: Record; + +/** + * DISCRIMINATED on `ok` (#9659), so a consumer that has ruled out success knows which statuses remain. + * The previous `{ ok: boolean; status: string }` told a caller nothing: the miner MCP server maps a + * refusal onto the shared error envelope's closed code set, and with a bare `string` that mapping could + * not be checked by anything. + */ +export type ChatActionDispatchResult = + | ({ ok: true; status: "dispatched"; action: string | null } & Record) + | ({ ok: false; status: ChatActionRefusalStatus; action: string | null } & Record); /** * The single entry point every chat-issued action goes through. In order: diff --git a/packages/loopover-miner/lib/mcp-dispatch-telemetry.ts b/packages/loopover-miner/lib/mcp-dispatch-telemetry.ts index fe6c87dfa..91b28dcf5 100644 --- a/packages/loopover-miner/lib/mcp-dispatch-telemetry.ts +++ b/packages/loopover-miner/lib/mcp-dispatch-telemetry.ts @@ -27,7 +27,11 @@ export type MinerDispatchCall = { durationMs: number; args?: unknown; result?: unknown; + /** The raw thrown value, kept for the exception capture -- an envelope has no stack. */ error?: unknown; + /** The envelope the tool returned to its caller (#9659). Preferred for classification, so the code the + * caller was told and the code recorded here are the same one rather than two guesses at one failure. */ + errorEnvelope?: { code: string; message: string }; }; /** Emit both usage events, plus an exception capture on the failure path. Never throws. */ @@ -40,7 +44,7 @@ export function recordMinerDispatchTelemetry(call: MinerDispatchCall): void { surface: "miner", ok: call.ok, durationMs: call.durationMs, - ...(call.ok ? {} : { errorCode: resolveErrorCode(call.error) }), + ...(call.ok ? {} : { errorCode: resolveErrorCode(call.errorEnvelope ?? call.error) }), }; captureMinerPostHogEvent(MCP_USAGE_EVENT, buildUsageEventProperties(telemetry)); captureMinerPostHogEvent( diff --git a/src/mcp/dispatch-telemetry-sink.ts b/src/mcp/dispatch-telemetry-sink.ts index b6e30e744..cff0c2bb7 100644 --- a/src/mcp/dispatch-telemetry-sink.ts +++ b/src/mcp/dispatch-telemetry-sink.ts @@ -15,7 +15,6 @@ // schema is strict", and this Worker already bundles posthog-node for #6235, so there is no bundle // argument for avoiding it here. (#9525's issue text assumed metagraphed's situation, where the // bundle cost was real.) -import type { } from "@loopover/contract"; import { capturePostHogWorkerError, isWorkerPostHogConfigured, type WorkerPostHogEnv } from "../api/worker-posthog"; import { MCP_TOOL_CALL_EVENT, MCP_USAGE_EVENT } from "@loopover/contract"; import type { DispatchTelemetrySink } from "./dispatch-telemetry"; diff --git a/src/mcp/dispatch-telemetry.ts b/src/mcp/dispatch-telemetry.ts index ac88b6904..039aafedc 100644 --- a/src/mcp/dispatch-telemetry.ts +++ b/src/mcp/dispatch-telemetry.ts @@ -24,6 +24,7 @@ import { getToolContract, mcpToolSpanName, resolveErrorCode, + toolErrorEnvelope, toolExcludesPayloads, UNKNOWN_TOOL_CATEGORY, type McpToolCallTelemetry, @@ -103,7 +104,11 @@ export function instrumentToolDispatch { const { sink: spy, calls, exceptions } = sink(); const wrapped = instrumentToolDispatch("loopover_get_repo_context", spy, async (_args: unknown) => ({ isError: true, structuredContent: {} })); await wrapped({}); + // No envelope on the result: `unknown_error` is the honest answer, and stays the answer (#9659). expect(calls[0]).toMatchObject({ ok: false, errorCode: "unknown_error" }); expect(exceptions).toEqual([]); expect(warn).toHaveBeenCalledWith(expect.stringContaining("mcp_tool_call_failed")); warn.mockRestore(); }); + // #9659: the code the caller is told is the code the event records. Before this the remote emitted a + // hardcoded `"unknown_error"` for every returned failure, so the dimension the closed set exists to + // populate was dead on the only failure path that does not throw. + it("resolves the error code from the result's own envelope", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { sink: spy, calls } = sink(); + const wrapped = instrumentToolDispatch("loopover_get_repo_context", spy, async (_args: unknown) => ({ + isError: true, + structuredContent: { error: { code: "not_configured", message: "no token" } }, + })); + await wrapped({}); + expect(calls[0]).toMatchObject({ ok: false, errorCode: "not_configured" }); + warn.mockRestore(); + }); + + it("falls back to unknown_error for an envelope whose code is not in the closed set", async () => { + // A tool cannot widen the dimension by inventing a code: `resolveErrorCode` validates membership. + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { sink: spy, calls } = sink(); + const wrapped = instrumentToolDispatch("loopover_get_repo_context", spy, async (_args: unknown) => ({ + isError: true, + structuredContent: { error: { code: "made_up", message: "nope" } }, + })); + await wrapped({}); + expect(calls[0]).toMatchObject({ ok: false, errorCode: "unknown_error" }); + warn.mockRestore(); + }); + it("captures a genuine throw, rethrows it, and logs at error", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); const { sink: spy, calls, exceptions } = sink(); diff --git a/test/unit/mcp-local-telemetry.test.ts b/test/unit/mcp-local-telemetry.test.ts index 7e8fa12a6..dcf190eec 100644 --- a/test/unit/mcp-local-telemetry.test.ts +++ b/test/unit/mcp-local-telemetry.test.ts @@ -372,6 +372,29 @@ describe("recordStdioToolTelemetry / wrapStdioToolHandler (#8690)", () => { expect(usage.properties).toMatchObject({ surface: "stdio", transport: "proxied" }); }); + // #9659: the stdio wrapper passed NO error on the returned-failure path, so `resolveErrorCode(undefined)` + // classified every one of them as `unknown_error` no matter what the tool told its caller. + it("wrapStdioToolHandler resolves the error code from the result's own envelope", async () => { + vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test"); + const wrapped = wrapStdioToolHandler("loopover_lint_pr_text", () => true, async () => ({ + isError: true, + structuredContent: { error: { code: "rate_limited", message: "slow down" } }, + })); + await wrapped({}); + + const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!; + expect(usage.properties).toMatchObject({ ok: false, error_code: "rate_limited" }); + }); + + it("wrapStdioToolHandler keeps unknown_error for a failure carrying no envelope", async () => { + vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test"); + const wrapped = wrapStdioToolHandler("loopover_lint_pr_text", () => true, async () => ({ isError: true, structuredContent: { ok: false } })); + await wrapped({}); + + const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!; + expect(usage.properties).toMatchObject({ ok: false, error_code: "unknown_error" }); + }); + it("wrapStdioToolHandler tags a local call `local` without the caller saying so", async () => { vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test"); const wrapped = wrapStdioToolHandler("loopover_lint_pr_text", () => true, async () => ({ structuredContent: { ok: true } })); diff --git a/test/unit/miner-mcp-contract.test.ts b/test/unit/miner-mcp-contract.test.ts index e8cf67bc0..54bf52b3e 100644 --- a/test/unit/miner-mcp-contract.test.ts +++ b/test/unit/miner-mcp-contract.test.ts @@ -9,7 +9,8 @@ import { type MinerMcpServerOptions, } from "../../packages/loopover-miner/bin/loopover-miner-mcp"; import { initGovernorLedger } from "../../packages/loopover-miner/lib/governor-ledger"; -import { getToolDefinition } from "@loopover/contract/tools"; +import { getToolContract, getToolDefinition, listToolDefinitions } from "@loopover/contract/tools"; +import { MCP_TELEMETRY_ERROR_CODES } from "@loopover/contract"; // The SAME secret-shape matcher the miner pack validator uses — imported from its single source of truth (rather // than hand-duplicated here) so the two stay byte-for-byte in sync instead of relying on manual vigilance. import { FORBIDDEN_CONTENT } from "../../scripts/forbidden-content"; @@ -335,3 +336,30 @@ describe("the miner server advertises the registry's projection (#9655)", () => } }); }); + +// #9659: `toolErrorFields` declared an envelope with zero consumers -- no output schema spread it -- while +// every miner tool answered a store failure with exactly that shape. So the advertised schema described only +// the success arm, and the field a caller has to read to know WHY a call failed appeared in no artifact. +describe("every miner output declares the shared error envelope (#9659)", () => { + const miner = listToolDefinitions({ locality: ["miner"] }); + + it("advertises `error` with the closed code set, on every tool", () => { + expect(miner.length).toBeGreaterThan(15); + for (const tool of miner) { + const error = (tool.outputSchema.properties as Record }> | undefined)?.error; + expect(error, `${tool.name} advertises no error envelope`).toBeTruthy(); + // The code is the closed telemetry set rather than free text, which is what lets the code the caller + // is told and the code telemetry records be the same one. + expect(error!.properties?.code?.enum, `${tool.name}'s error code is not the closed set`).toEqual([...MCP_TELEMETRY_ERROR_CODES]); + } + }); + + it("accepts a payload carrying the envelope", () => { + // The envelope rides ALONGSIDE the success fields rather than replacing them: the MCP SDK exempts an + // `isError` result from output validation (mcp.js `validateToolOutput`), so making every success field + // optional to let a bare `{ error }` validate would weaken the success contract for no gain. + const ping = getToolContract("loopover_miner_ping")!; + expect(ping.output.safeParse({ status: "ok", tool: "loopover_miner_ping", error: { code: "store_unavailable", message: "queue.db is not a database" } }).success).toBe(true); + expect(ping.output.safeParse({ status: "ok", tool: "loopover_miner_ping", error: { code: "invented", message: "x" } }).success, "an invented code is refused").toBe(false); + }); +}); diff --git a/test/unit/miner-mcp-dispatch-telemetry.test.ts b/test/unit/miner-mcp-dispatch-telemetry.test.ts index bc69e279f..e65240a64 100644 --- a/test/unit/miner-mcp-dispatch-telemetry.test.ts +++ b/test/unit/miner-mcp-dispatch-telemetry.test.ts @@ -3,6 +3,7 @@ // Its sink is this package's own opt-in PostHog client, so both sides of that gate are driven here // via the module's exported reset helper rather than by mocking the SDK. import { afterEach, describe, expect, it } from "vitest"; +import { resolveErrorCode } from "@loopover/contract"; import { recordMinerDispatchTelemetry } from "../../packages/loopover-miner/lib/mcp-dispatch-telemetry"; import { resetMinerPostHogForTesting } from "../../packages/loopover-miner/lib/posthog"; @@ -23,6 +24,20 @@ describe("miner dispatch telemetry (#9525)", () => { expect(() => recordMinerDispatchTelemetry({ tool: "loopover_miner_ping", ok: false, durationMs: 3 })).not.toThrow(); }); + // #9659: the envelope the tool returned to its caller classifies the failure. The raw error used to be + // passed instead, so `resolveErrorCode`'s message regexes re-derived a code -- and an ENOENT from a store + // that would not open matches /not found|no such/, so the caller was told `store_unavailable` while the + // event recorded `not_found`. Two classifications of one failure, produced by adjacent lines. + it("classifies by the envelope's declared code, not by re-reading the error's message", () => { + const enoent = new Error("ENOENT: no such file or directory, open 'queue.db'"); + expect(resolveErrorCode(enoent), "the message alone reads as not_found").toBe("not_found"); + expect(resolveErrorCode({ code: "store_unavailable", message: enoent.message })).toBe("store_unavailable"); + }); + + it("still classifies from the raw error when no envelope is supplied", () => { + expect(resolveErrorCode(new Error("request timed out"))).toBe("timeout"); + }); + it("tolerates a tool with no contract entry rather than throwing on the path it instruments", () => { // The contract validator (#9520) makes this unreachable in practice; telemetry still must not be // the thing that breaks a tool call if it ever happens. diff --git a/test/unit/miner-mcp-ops-tools.test.ts b/test/unit/miner-mcp-ops-tools.test.ts index 15d2db9e3..956176d74 100644 --- a/test/unit/miner-mcp-ops-tools.test.ts +++ b/test/unit/miner-mcp-ops-tools.test.ts @@ -190,11 +190,16 @@ describe("the mutating tools shape their dispatch result (#9523)", () => { it("REPORTS a governor refusal as a blocked result rather than throwing", async () => { // A refusal is an ANSWER the caller needs to see; a thrown error would flatten it into a generic // tool failure with no reason attached. - const dispatchAction = (async () => ({ ok: false, status: "blocked_by_governor", action: "miner_purge_repo" })) as never; + // #9659: a REAL refusal status. The dispatcher's outcomes are a closed union now, and this fixture used + // to invent one ("blocked_by_governor") that `dispatchChatAction` cannot return -- so the case proved + // the shaping worked for a status that does not exist. + const dispatchAction = (async () => ({ ok: false, status: "handler_error", action: "miner_purge_repo" })) as never; const client = await connect({ dispatchAction }); const result = (await client.callTool({ name: "loopover_miner_purge_repo", arguments: { repoFullName: "owner/repo", confirm: true } })) as ToolResult; expect(result.isError, "a refusal is not a transport failure").toBeFalsy(); - expect(structured(result)).toMatchObject({ ok: false, blocked: true, reason: "blocked_by_governor" }); + expect(structured(result)).toMatchObject({ ok: false, blocked: true, reason: "handler_error" }); + // The refusal carries the shared envelope, under the code its status maps to. + expect(structured(result).error).toEqual({ code: "upstream_error", message: "handler_error" }); }); it("carries the dispatcher's own error text through when it supplies one", async () => { @@ -203,7 +208,21 @@ describe("the mutating tools shape their dispatch result (#9523)", () => { const result = structured( (await client.callTool({ name: "loopover_miner_queue_release", arguments: { repoFullName: "owner/repo", issueNumber: 1 } })) as ToolResult, ); - expect(result).toMatchObject({ blocked: true, reason: "invalid_params", error: "issueNumber must be positive" }); + // #9659: the detail now travels inside the shared envelope rather than as a bare `error` string, so + // one field name means one thing on every LoopOver server. + expect(result).toMatchObject({ blocked: true, reason: "invalid_params", error: { code: "invalid_input", message: "issueNumber must be positive" } }); + }); + + it("falls back to unknown_error for a status outside the dispatcher's closed set", async () => { + // `dispatchAction` is an injection seam and the chat-action registry is populated at runtime, so a + // status the union does not name is reachable even though it is not writable in typed code. It must + // still produce a valid envelope rather than an unparseable code. + const dispatchAction = (async () => ({ ok: false, status: "something_new", action: "miner_queue_release", error: "detail" })) as never; + const client = await connect({ dispatchAction }); + const result = structured( + (await client.callTool({ name: "loopover_miner_queue_release", arguments: { repoFullName: "owner/repo", issueNumber: 1 } })) as ToolResult, + ); + expect(result.error).toEqual({ code: "unknown_error", message: "detail" }); }); it("REGRESSION: rejects a purge whose confirm is absent, before any dispatch happens", async () => { diff --git a/test/unit/miner-mcp-telemetry-chokepoint.test.ts b/test/unit/miner-mcp-telemetry-chokepoint.test.ts new file mode 100644 index 000000000..723e2e0b1 --- /dev/null +++ b/test/unit/miner-mcp-telemetry-chokepoint.test.ts @@ -0,0 +1,120 @@ +// Every miner tool call reaches the dispatch-telemetry chokepoint (#9658). +// +// `withMinerToolErrorHandling`'s own doc calls the tool name REQUIRED "so this same wrapper doubles as the +// miner's dispatch-telemetry chokepoint... leaving it optional would have made 'instrumented' a property of +// each call site rather than of the wrapper". Twenty registrations honoured that; `loopover_miner_ping` did +// not, and nothing noticed -- so the health check an operator's monitoring hits on a loop, the cheapest +// signal that this server is alive at all, reported zero calls forever. +// +// Two checks, because either alone can go quiet: a structural one over the source (no registration can skip +// the wrapper) and a behavioural one that actually calls every tool the registry projects for this server. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; +import { listToolDefinitions } from "@loopover/contract/tools"; +import { resolveErrorCode } from "@loopover/contract"; + +const recorded: Array<{ tool: string; ok: boolean; errorEnvelope?: { code: string; message: string } }> = []; + +vi.mock("../../packages/loopover-miner/lib/mcp-dispatch-telemetry", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordMinerDispatchTelemetry: (call: { tool: string; ok: boolean; errorEnvelope?: { code: string; message: string } }) => { + recorded.push({ tool: call.tool, ok: call.ok, ...(call.errorEnvelope ? { errorEnvelope: call.errorEnvelope } : {}) }); + }, + }; +}); + +const SOURCE = readFileSync(join(process.cwd(), "packages/loopover-miner/bin/loopover-miner-mcp.ts"), "utf8"); + +describe("the miner's registrations are instrumented by construction (#9658)", () => { + it("routes every registration through withMinerToolErrorHandling", () => { + // Structural, and deliberately not a list of tool names: a rule that has to be extended per tool is a + // rule someone forgets. Each `registerMinerTool(server, xTool, HANDLER)` is matched to the end of its + // handler argument, and the handler must mention the wrapper. + const registrations = [...SOURCE.matchAll(/registerMinerTool\(server,\s*(\w+),([\s\S]*?)\n\s*\);/g)]; + expect(registrations.length).toBeGreaterThan(15); + + const uninstrumented = registrations + .filter(([, , handler]) => !handler!.includes("withMinerToolErrorHandling")) + .map(([, tool]) => tool!); + expect(uninstrumented, "these registrations bypass the dispatch-telemetry chokepoint").toEqual([]); + }); + + it("would catch a registration that bypassed it", () => { + // The rule's own regex, against the exact shape it exists to reject -- otherwise a refactor could make + // the pattern unmatchable and this suite would go quiet while reporting success. + const bypassing = `registerMinerTool(server, minerPingTool, async () => minerToolResult(MINER_PING_STATUS),\n );`; + const [match] = [...bypassing.matchAll(/registerMinerTool\(server,\s*(\w+),([\s\S]*?)\n\s*\);/g)]; + expect(match, "the pattern still matches a real registration").toBeTruthy(); + expect(match![2]).not.toContain("withMinerToolErrorHandling"); + }); + + it("records exactly one telemetry event per tool, for every tool the registry projects", async () => { + const { createMinerMcpServer } = await import("../../packages/loopover-miner/bin/loopover-miner-mcp"); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-chokepoint", version: "0.0.0" }); + await Promise.all([ + // Every store-backed tool is left to fail against an absent store: this asserts INSTRUMENTATION, and a + // failed call must be recorded exactly like a successful one. + createMinerMcpServer({ dispatchAction: (async () => ({ ok: false, status: "handler_error", action: null })) as never }).connect(serverTransport), + client.connect(clientTransport), + ]); + try { + // Derived from the registry, never a literal array -- a new miner tool joins this test by existing. + const names = listToolDefinitions({ locality: ["miner"] }).map((tool) => tool.name); + expect(names.length).toBeGreaterThan(15); + + for (const name of names) { + recorded.length = 0; + await client.callTool({ name, arguments: SMOKE_ARGUMENTS[name] ?? {} }).catch(() => undefined); + expect(recorded.map((call) => call.tool), `${name} produced no dispatch-telemetry record`).toEqual([name]); + } + } finally { + await client.close().catch(() => undefined); + } + }); + + // #9659: the classification the caller is given is the one the event carries. The raw error used to be + // handed to telemetry, where an ENOENT message matches /not found|no such/ -- so a store that would not + // open told the caller `store_unavailable` and recorded `not_found`. + it("records a store failure under the code the caller was given, not one re-read from the message", async () => { + const { createMinerMcpServer } = await import("../../packages/loopover-miner/bin/loopover-miner-mcp"); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-store-failure", version: "0.0.0" }); + const initPortfolioQueue = () => { + throw new Error("ENOENT: no such file or directory, open 'portfolio-queue.db'"); + }; + await Promise.all([ + createMinerMcpServer({ initPortfolioQueue: initPortfolioQueue as never }).connect(serverTransport), + client.connect(clientTransport), + ]); + try { + recorded.length = 0; + const result = (await client.callTool({ name: "loopover_miner_get_portfolio_dashboard", arguments: {} })) as { + isError?: boolean; + structuredContent?: { error?: { code?: string } }; + }; + expect(result.isError).toBe(true); + expect(result.structuredContent?.error?.code, "what the caller is told").toBe("store_unavailable"); + expect(recorded[0]?.errorEnvelope?.code, "what telemetry records").toBe("store_unavailable"); + // And the message on its own would have been classified differently, which is the whole point. + expect(resolveErrorCode(new Error("ENOENT: no such file or directory, open 'portfolio-queue.db'"))).toBe("not_found"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); + +/** The few tools with required inputs. Anything absent here takes `{}`, which is most of them. */ +const SMOKE_ARGUMENTS: Record> = { + loopover_miner_purge_repo: { repoFullName: "owner/repo", confirm: true }, + loopover_miner_queue_release: { repoFullName: "owner/repo", issueNumber: 1 }, + loopover_miner_queue_requeue: { repoFullName: "owner/repo", issueNumber: 1 }, + loopover_miner_claim_release: { repoFullName: "owner/repo", issueNumber: 1 }, + loopover_miner_deny_hooks_decide: { repoFullName: "owner/repo", hookId: "proposal-1", decision: "approve" }, + loopover_miner_get_plan: { planId: "plan-1" }, +};