From 43337e535bf93876589815f080a4fec898f1dc08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 14:52:08 -0400 Subject: [PATCH] C5 follow-up: derive RpcMethod from zod enum (single source of truth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C4→C5 drift bug (hotfix 12556de) was caused by the same RPC method list being declared twice — once as a zod enum in worker/entry/types.ts for runtime validation, and once as a TS union type in runtime-bridge.ts for compile-time handler wiring. The two drifted when C4 added `invokeInternalTool`: the TS union grew, the handler switch added a case, but the zod enum was forgotten, so the runtime gate rejected envelopes the handler had already registered. This commit collapses the two declarations into one: - Extract `workerRpcMethodSchema = z.enum([...])` in worker/entry/types.ts as the canonical list. - Export `WorkerRpcMethod = z.infer`. - Replace the hand-written `RpcMethod` union in runtime-bridge.ts with `type RpcMethod = WorkerRpcMethod` plus an explicit import. - Reuse the schema inside the `rpc-request` variant of `workerMessageSchema` so adding a method requires exactly one edit instead of three. - Add an exhaustiveness `default: { const _exhaustive: never = ... }` guard to `handleRpc`'s switch. If the zod enum widens without a matching case, TypeScript fails the build at the `never` assignment — drift becomes a compile-time error, not a runtime ZodError fired at the user. typecheck clean, 529 unit tests green, 8/8 eval green. --- src/main/agent-runtime/runtime-bridge.ts | 26 ++++++++++++++------ src/main/worker/entry/types.ts | 31 ++++++++++++++++++------ 2 files changed, 42 insertions(+), 15 deletions(-) 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({