Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preview-latest-routing.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion docs/concepts/execution-model-and-durability.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.

Expand Down
4 changes: 3 additions & 1 deletion packages/eve/src/execution/session-timeout-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
96 changes: 92 additions & 4 deletions packages/eve/src/execution/workflow-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -36,6 +38,7 @@ vi.mock("#runtime/sessions/compiled-agent-cache.js", () => ({

afterEach(() => {
getHookByTokenMock.mockReset();
clearLatestDeploymentFallbackMemoForTest();
getRunMock.mockReset();
getWorldMock.mockReset();
resumeHookMock.mockReset();
Expand Down Expand Up @@ -464,6 +467,7 @@ describe("createWorkflowRuntime#createSession", () => {
"$eve.trigger": "subagent",
"$eve.type": "subagent",
},
deploymentId: "latest",
});
});

Expand Down Expand Up @@ -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;
Expand All @@ -532,6 +619,7 @@ describe("createWorkflowRuntime#createSession", () => {
"$eve.trigger": "http",
"$eve.type": "session",
},
deploymentId: "latest",
});
},
);
Expand Down
52 changes: 34 additions & 18 deletions packages/eve/src/execution/workflow-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -78,6 +78,7 @@ export const STABLE_WORKFLOW_NAMES: ReadonlySet<string> = new Set([
const STABLE_ID_BASE = EVE_PACKAGE_INFO.name;

const log = createLogger("execution.workflow-runtime");
let latestDeploymentFallbackMemoized = false;

interface WorkflowHookRecord {
readonly runId: string;
Expand Down Expand Up @@ -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<TArgs extends unknown[], TResult>(
workflow: WorkflowFunction<TArgs, TResult> | WorkflowMetadata,
args: TArgs,
options?: StartOptionsWithoutDeploymentId,
): Promise<Run<unknown> | Run<TResult>> {
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<TArgs extends unknown[], TResult>(
workflow: WorkflowFunction<TArgs, TResult> | WorkflowMetadata,
args: TArgs,
options?: StartOptionsWithoutDeploymentId,
): Promise<Run<unknown> | Run<TResult>> {
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 {
Expand Down
25 changes: 2 additions & 23 deletions packages/eve/src/execution/workflow-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
turnStep,
} from "#execution/workflow-steps.js";
import {
clearLatestDeploymentFallbackMemoForTest,
LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE,
turnWorkflowReference,
workflowEntryReference,
Expand Down Expand Up @@ -197,6 +198,7 @@ function createSerializedContext(): Record<string, unknown> {
}

afterEach(() => {
clearLatestDeploymentFallbackMemoForTest();
getRunMock.mockReset();
resumeHookMock.mockReset();
startMock.mockReset();
Expand Down Expand Up @@ -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();
Expand Down
Loading