diff --git a/.changeset/preview-latest-routing.md b/.changeset/preview-latest-routing.md new file mode 100644 index 000000000..a61de8281 --- /dev/null +++ b/.changeset/preview-latest-routing.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Try latest-deployment routing for Vercel preview workflow dispatches and fall back when the active workflow world cannot resolve `latest`. diff --git a/docs/concepts/execution-model-and-durability.mdx b/docs/concepts/execution-model-and-durability.mdx index d2eb75c5e..9dd1a0643 100644 --- a/docs/concepts/execution-model-and-durability.mdx +++ b/docs/concepts/execution-model-and-durability.mdx @@ -17,7 +17,7 @@ Every turn runs as a durable workflow, built on the open-source [Workflow SDK](h The Workflow SDK is not inherently tied to Vercel. In local development and in a self-deployed `eve start` process, eve uses the SDK's local world by default; that world persists workflow runs on disk under `.eve/.workflow-data` and dispatches through the same Nitro-hosted workflow routes. On Vercel, the same workflow code runs against Vercel Workflow instead, which adds platform features such as latest production deployment routing and dashboard run metadata. -When a Vercel production deployment changes, the next model turn in an existing session uses that deployment's current instructions, model, and tools. The durable session keeps its conversation history and authored state, so identity-based channels such as Telegram private chats and Twilio phone-number conversations adopt agent updates without requiring a new session. +When a Git-connected Vercel deployment changes, the next model turn in an existing session attempts to use the latest deployment for that branch, so the session can pick up current instructions, model, and tools. The durable session keeps its conversation history and authored state, so identity-based channels such as Telegram private chats and Twilio phone-number conversations adopt agent updates without requiring a new session. If Vercel cannot resolve a latest deployment for that runtime, for example in a branchless CLI deployment, eve falls back to the current immutable deployment. Nitro hosts the HTTP routes and workflow entrypoints. It does not supply the workflow state store or the sandbox runtime. Those are separate adapters: Workflow uses the active world implementation, and Sandbox uses the backend from `agent/sandbox` or `defaultBackend()`. diff --git a/packages/eve/src/execution/session-timeout-steps.test.ts b/packages/eve/src/execution/session-timeout-steps.test.ts index 854d6aeb7..116974059 100644 --- a/packages/eve/src/execution/session-timeout-steps.test.ts +++ b/packages/eve/src/execution/session-timeout-steps.test.ts @@ -37,7 +37,9 @@ describe("session timeout steps", () => { }; await expect(startSessionTimeoutStep(input)).resolves.toEqual({ runId: "timer-run" }); - expect(startMock).toHaveBeenCalledWith(sessionTimeoutWorkflowReference, [input]); + expect(startMock).toHaveBeenCalledWith(sessionTimeoutWorkflowReference, [input], { + deploymentId: "latest", + }); }); it("signals the owning session hook", async () => { diff --git a/packages/eve/src/execution/workflow-runtime.test.ts b/packages/eve/src/execution/workflow-runtime.test.ts index d4dbc5455..4f399bf1e 100644 --- a/packages/eve/src/execution/workflow-runtime.test.ts +++ b/packages/eve/src/execution/workflow-runtime.test.ts @@ -4,7 +4,9 @@ import type { ChannelAdapter } from "#channel/adapter.js"; import { ChannelRequestIdKey } from "#context/keys.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; import { + clearLatestDeploymentFallbackMemoForTest, createWorkflowRuntime, + LATEST_DEPLOYMENT_NO_GIT_BRANCH_MESSAGE, LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE, sessionTimeoutWorkflowReference, turnWorkflowReference, @@ -36,6 +38,7 @@ vi.mock("#runtime/sessions/compiled-agent-cache.js", () => ({ afterEach(() => { getHookByTokenMock.mockReset(); + clearLatestDeploymentFallbackMemoForTest(); getRunMock.mockReset(); getWorldMock.mockReset(); resumeHookMock.mockReset(); @@ -464,6 +467,7 @@ describe("createWorkflowRuntime#createSession", () => { "$eve.trigger": "subagent", "$eve.type": "subagent", }, + deploymentId: "latest", }); }); @@ -501,12 +505,95 @@ describe("createWorkflowRuntime#createSession", () => { }); }); + it("falls back to the current deployment when the no-git-branch error is wrapped", async () => { + vi.stubEnv("VERCEL_ENV", "preview"); + const compiledArtifactsSource = {} as RuntimeCompiledArtifactsSource; + mockBundleAndRun(compiledArtifactsSource); + const branchlessDeploymentError = new Error("Failed to resolve latest deployment", { + cause: new Error(LATEST_DEPLOYMENT_NO_GIT_BRANCH_MESSAGE), + }); + startMock + .mockRejectedValueOnce(branchlessDeploymentError) + .mockResolvedValueOnce({ runId: "driver-run" }); + + await buildRuntime(compiledArtifactsSource).createSession({ + adapter, + auth: null, + input: { message: "hello" }, + mode: "task", + }); + + expect(startMock).toHaveBeenNthCalledWith(1, workflowEntryReference, expect.any(Array), { + allowReservedAttributes: true, + attributes: { + "$eve.title": "hello", + "$eve.trigger": "http", + "$eve.type": "session", + }, + deploymentId: "latest", + }); + expect(startMock).toHaveBeenNthCalledWith(2, workflowEntryReference, expect.any(Array), { + allowReservedAttributes: true, + attributes: { + "$eve.title": "hello", + "$eve.trigger": "http", + "$eve.type": "session", + }, + }); + }); + + it("memoizes latest-deployment fallback after a pinned dispatch succeeds", async () => { + vi.stubEnv("VERCEL_ENV", "preview"); + const compiledArtifactsSource = {} as RuntimeCompiledArtifactsSource; + mockBundleAndRun(compiledArtifactsSource); + startMock + .mockRejectedValueOnce(new Error(LATEST_DEPLOYMENT_NO_GIT_BRANCH_MESSAGE)) + .mockResolvedValueOnce({ runId: "driver-run" }) + .mockResolvedValueOnce({ runId: "driver-run" }); + + await buildRuntime(compiledArtifactsSource).createSession({ + adapter, + auth: null, + input: { message: "first" }, + mode: "task", + }); + await buildRuntime(compiledArtifactsSource).createSession({ + adapter, + auth: null, + input: { message: "second" }, + mode: "task", + }); + + expect(startMock).toHaveBeenNthCalledWith(1, workflowEntryReference, expect.any(Array), { + allowReservedAttributes: true, + attributes: { + "$eve.title": "first", + "$eve.trigger": "http", + "$eve.type": "session", + }, + deploymentId: "latest", + }); + expect(startMock).toHaveBeenNthCalledWith(2, workflowEntryReference, expect.any(Array), { + allowReservedAttributes: true, + attributes: { + "$eve.title": "first", + "$eve.trigger": "http", + "$eve.type": "session", + }, + }); + expect(startMock).toHaveBeenNthCalledWith(3, workflowEntryReference, expect.any(Array), { + allowReservedAttributes: true, + attributes: { + "$eve.title": "second", + "$eve.trigger": "http", + "$eve.type": "session", + }, + }); + }); + it.each(["preview", "development", undefined])( - "pins workflowEntry to the current deployment when VERCEL_ENV is %s", + "starts workflowEntry on the latest deployment when VERCEL_ENV is %s", async (vercelEnv) => { - // Preview and CLI deployments carry no git branch reference, so the - // platform cannot resolve "latest" for them (HTTP 400). They must pin - // to their own immutable deployment. if (vercelEnv === undefined) { vi.stubEnv("VERCEL_ENV", ""); delete process.env.VERCEL_ENV; @@ -532,6 +619,7 @@ describe("createWorkflowRuntime#createSession", () => { "$eve.trigger": "http", "$eve.type": "session", }, + deploymentId: "latest", }); }, ); diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index a4aea51a6..49497c9dc 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -24,7 +24,6 @@ import { readParentLineage, } from "#execution/eve-workflow-attributes.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; -import { isEveDevEnvironment } from "#internal/application/dev-environment.js"; import { createLogger, logError } from "#internal/logging.js"; import { getHookByToken, @@ -58,6 +57,7 @@ const COMMAND_HOOK_READY_TIMEOUT_MS = 30_000; export const LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE = "deploymentId 'latest' requires a World that implements resolveLatestDeploymentId()"; +export const LATEST_DEPLOYMENT_NO_GIT_BRANCH_MESSAGE = "Source deployment has no git branch"; /** * Workflow function names whose bundled id is stable across deployments @@ -78,6 +78,7 @@ export const STABLE_WORKFLOW_NAMES: ReadonlySet = new Set([ const STABLE_ID_BASE = EVE_PACKAGE_INFO.name; const log = createLogger("execution.workflow-runtime"); +let latestDeploymentFallbackMemoized = false; interface WorkflowHookRecord { readonly runId: string; @@ -364,44 +365,59 @@ async function waitForCommandHookRelease(token: string, sessionId: string): Prom } /** - * Starts a workflow on the latest deployment when latest routing applies, - * while preserving local/dev worlds that do not implement latest routing. + * Starts a workflow on the latest deployment when the active workflow world can + * resolve it, while preserving worlds and deployments that cannot. */ export async function startWorkflowPreferLatest( workflow: WorkflowFunction | WorkflowMetadata, args: TArgs, options?: StartOptionsWithoutDeploymentId, ): Promise | Run> { - if (!shouldRouteToLatestDeployment()) { - return options === undefined - ? await start(workflow, args) - : await start(workflow, args, options); + if (latestDeploymentFallbackMemoized) { + return await startWorkflowOnCurrentDeployment(workflow, args, options); } try { return await start(workflow, args, { ...options, deploymentId: "latest" }); } catch (error) { - if (!isLatestDeploymentUnsupportedError(error)) { + if (!isLatestDeploymentFallbackError(error)) { throw error; } - return options === undefined - ? await start(workflow, args) - : await start(workflow, args, options); + const run = await startWorkflowOnCurrentDeployment(workflow, args, options); + latestDeploymentFallbackMemoized = true; + return run; } } /** - * Local development resolves "latest" to the active promoted generation. - * Vercel resolves it only for production deployments; previews and CLI - * deployments have no branch reference and remain pinned to themselves. + * Clears the latest-routing fallback memo between tests. Production code keeps + * the memo for the life of the process because unsupported worlds and + * branchless deployments cannot start resolving "latest" mid-process. */ -function shouldRouteToLatestDeployment(): boolean { - return process.env.VERCEL_ENV === "production" || isEveDevEnvironment(); +export function clearLatestDeploymentFallbackMemoForTest(): void { + latestDeploymentFallbackMemoized = false; } -function isLatestDeploymentUnsupportedError(error: unknown): boolean { - return error instanceof Error && error.message.includes(LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE); +async function startWorkflowOnCurrentDeployment( + workflow: WorkflowFunction | WorkflowMetadata, + args: TArgs, + options?: StartOptionsWithoutDeploymentId, +): Promise | Run> { + return options === undefined ? await start(workflow, args) : await start(workflow, args, options); +} + +function isLatestDeploymentFallbackError(error: unknown): boolean { + return ( + errorMessageIncludes(error, LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE) || + errorMessageIncludes(error, LATEST_DEPLOYMENT_NO_GIT_BRANCH_MESSAGE) + ); +} + +function errorMessageIncludes(error: unknown, message: string): boolean { + if (!(error instanceof Error)) return false; + if (error.message.includes(message)) return true; + return error.cause !== undefined && errorMessageIncludes(error.cause, message); } function normalizeWorkflowHook(value: unknown): WorkflowHookRecord { diff --git a/packages/eve/src/execution/workflow-steps.test.ts b/packages/eve/src/execution/workflow-steps.test.ts index e4c9b992a..9147cf898 100644 --- a/packages/eve/src/execution/workflow-steps.test.ts +++ b/packages/eve/src/execution/workflow-steps.test.ts @@ -46,6 +46,7 @@ import { turnStep, } from "#execution/workflow-steps.js"; import { + clearLatestDeploymentFallbackMemoForTest, LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE, turnWorkflowReference, workflowEntryReference, @@ -197,6 +198,7 @@ function createSerializedContext(): Record { } afterEach(() => { + clearLatestDeploymentFallbackMemoForTest(); getRunMock.mockReset(); resumeHookMock.mockReset(); startMock.mockReset(); @@ -306,29 +308,6 @@ describe("dispatchTurnStep", () => { ); }); - it("pins turn workflows to the current deployment off production", async () => { - vi.stubEnv("VERCEL_ENV", "preview"); - const input = createTurnInput(); - startMock.mockResolvedValue({ runId: "turn-run" }); - - await expect(dispatchTurnStep(input)).resolves.toEqual({ runId: "turn-run" }); - - expect(startMock).toHaveBeenCalledTimes(1); - expect(startMock).toHaveBeenCalledWith( - turnWorkflowReference, - [createTurnWorkflowInput(input)], - { - allowReservedAttributes: true, - attributes: { - "$eve.channel_request_id": "req_turn", - "$eve.parent": "sess-test", - "$eve.root": "sess-test", - "$eve.type": "turn", - }, - }, - ); - }); - it("falls back to the current deployment when latest is unsupported", async () => { vi.stubEnv("VERCEL_ENV", "production"); const input = createTurnInput();