diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index dd39113e..b1ae41bb 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -68,13 +68,16 @@ export interface RuntimeBridgeDeps { credentialResolver: CredentialResolver; } -type RpcMethod = - | "persistMessage" - | "prepareRun" - | "beforeToolCall" - | "afterToolCall" - | "transformContext" - | "invokeInternalTool"; +// Derived from the zod schema in `worker/entry/types.ts` so there is +// exactly one source of truth for the RPC method name list. The C4→C5 +// hotfix (9d9f003) showed what happens when this union and the zod +// enum drift — worker emits a new method, handler accepts it, and the +// runtime gate silently rejects the envelope. `z.infer` plus a +// compile-time `satisfies` check below makes that category of drift +// a TypeScript error rather than a production ZodError. +import type { WorkerRpcMethod } from "@main/worker/entry/types"; + +type RpcMethod = WorkerRpcMethod; /** * Single owner of pi runtime side-effects in main. @@ -564,6 +567,15 @@ export class RuntimeBridge { return this.handleTransformContextRpc(request.runId, request.payload as TransformContextRpcPayload); case "invokeInternalTool": return this.handleInvokeInternalToolRpc(request.runId, request.payload as InvokeInternalToolRpcPayload); + default: { + // Exhaustiveness guard. If `WorkerRpcMethod` widens in + // `worker/entry/types.ts` without a corresponding case here, + // the assignment to `never` is a TypeScript error — which is + // exactly what we want so the C4→C5 drift category cannot + // silently recur at runtime. + const _exhaustive: never = request.method; + return { ok: false, error: `unknown RPC method: ${String(_exhaustive)}` }; + } } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; diff --git a/src/main/worker/entry/types.ts b/src/main/worker/entry/types.ts index c354b93a..8bda95d4 100644 --- a/src/main/worker/entry/types.ts +++ b/src/main/worker/entry/types.ts @@ -8,6 +8,28 @@ import type { RuntimeBridge, RuntimeMessageInjector } from "../../agent-runtime" const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]); +/** + * Single source of truth for worker → main RPC method names. + * + * This constant is consumed by both the zod validation schema in this + * file (the runtime gate at `handleWorkerMessage`) and the `RpcMethod` + * TypeScript union exported from `agent-runtime/runtime-bridge.ts` via + * `z.infer`. Adding a new internal RPC requires touching exactly one + * place — this literal list — which eliminates the C4→C5 hotfix class + * of bug where the two definitions silently drifted and the zod gate + * rejected a newly-minted method the handler had already registered. + */ +export const workerRpcMethodSchema = z.enum([ + "persistMessage", + "prepareRun", + "beforeToolCall", + "afterToolCall", + "transformContext", + "invokeInternalTool", +]); + +export type WorkerRpcMethod = z.infer; + export const workerMessageSchema = z.union([ z.object({ type: z.literal("run-log"), @@ -47,14 +69,7 @@ export const workerMessageSchema = z.union([ type: z.literal("rpc-request"), rpcId: z.string(), runId: z.string().uuid(), - method: z.enum([ - "persistMessage", - "prepareRun", - "beforeToolCall", - "afterToolCall", - "transformContext", - "invokeInternalTool", - ]), + method: workerRpcMethodSchema, payload: z.unknown(), }), z.object({