diff --git a/.changeset/descendant-cancel-delivery.md b/.changeset/descendant-cancel-delivery.md new file mode 100644 index 000000000..b9b75d4df --- /dev/null +++ b/.changeset/descendant-cancel-delivery.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Cancelling a parent turn now delivers descendant cancellations more reliably: the cancel request retries with exponential backoff (~8s budget instead of 3s), `no_active_turn` results carry the error class that marked the target inactive, and every previously silent drop path (missing pending batch, missing child session ids, exhausted retries) now logs a warning so an uncancelled child no longer runs to completion without a trace. diff --git a/.changeset/turn-cancel-race.md b/.changeset/turn-cancel-race.md new file mode 100644 index 000000000..b311f9e2e --- /dev/null +++ b/.changeset/turn-cancel-race.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +A turn cancellation observed while a durable turn step is returning now settles the turn as cancelled instead of losing the race to an ordinary completion. Previously the step could miss the abort signal and finish `done`, leaving the session in a completed state after the user had already cancelled. The cancel signal also now aborts in the same microtask that consumes the cancel payload, so a cancel replayed alongside a completed step can no longer lose the settle check by one task-queue hop. diff --git a/.changeset/workflow-beta-38.md b/.changeset/workflow-beta-38.md new file mode 100644 index 000000000..0d9365033 --- /dev/null +++ b/.changeset/workflow-beta-38.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Upgrade the vendored Workflow DevKit to `@workflow/core@5.0.0-beta.38`. This picks up the upstream fix for runs going dormant after an accepted hook resume (vercel/workflow#3183): a session cancelled while parked on subagents could previously hold the accepted cancel indefinitely — its turn only settling as cancelled when unrelated traffic happened to wake the run. beta.38 also restored runtime world selection in core's `createWorld()`, which statically imports both first-party worlds; eve stubs those imports at vendor time so hosted Vercel bundles keep excluding local-world infrastructure. diff --git a/packages/eve/package.json b/packages/eve/package.json index 366d31f21..b385c9344 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -328,13 +328,13 @@ "@vercel/otel": "catalog:", "@vercel/sandbox": "catalog:", "@vercel/sdk": "1.28.8", - "@workflow/core": "5.0.0-beta.37", - "@workflow/errors": "5.0.0-beta.13", + "@workflow/core": "5.0.0-beta.38", + "@workflow/errors": "5.0.0-beta.14", "@workflow/serde": "5.0.0-beta.2", - "@workflow/utils": "5.0.0-beta.7", + "@workflow/utils": "5.0.0-beta.8", "@workflow/world": "5.0.0-beta.23", - "@workflow/world-local": "5.0.0-beta.31", - "@workflow/world-vercel": "5.0.0-beta.33", + "@workflow/world-local": "5.0.0-beta.32", + "@workflow/world-vercel": "5.0.0-beta.34", "ai": "catalog:", "autoevals": "0.0.132", "chat": "4.34.0", diff --git a/packages/eve/scripts/vendor-compiled/@workflow/core.mjs b/packages/eve/scripts/vendor-compiled/@workflow/core.mjs index ecb4b1890..5b980369a 100644 --- a/packages/eve/scripts/vendor-compiled/@workflow/core.mjs +++ b/packages/eve/scripts/vendor-compiled/@workflow/core.mjs @@ -94,10 +94,41 @@ const copyDeclarations = createDeclarationCopier({ }, }); +// @workflow/core@5.0.0-beta.38 restored runtime world selection: its +// `createWorld()` statically imports both first-party world packages. eve +// never calls that factory — worlds are selected and wired explicitly at +// build time — so those imports would only drag the local world into hosted +// Vercel output (and the Vercel world into local-only builds). Stub them +// inside core; each world package keeps its own vendored entry. +const CORE_WORLD_FACTORY_STUB_ID = "\0eve-core-world-factory-stub"; + +function stubCoreWorldFactories() { + return { + name: "eve:stub-core-world-factories", + resolveId(source, importer) { + if (source !== "@workflow/world-local" && source !== "@workflow/world-vercel") return null; + if (typeof importer !== "string") return null; + if (!importer.replaceAll("\\", "/").includes("/@workflow/core/")) return null; + return CORE_WORLD_FACTORY_STUB_ID; + }, + load(id) { + if (id !== CORE_WORLD_FACTORY_STUB_ID) return null; + return [ + "export function createWorld() {", + ' throw new Error("Unsupported in eve: @workflow/core createWorld(). ' + + 'eve selects and wires the workflow world explicitly at build time.");', + "}", + "", + ].join("\n"); + }, + }; +} + export default { packageName: "@workflow/core", compiledPath: "@workflow/core", chunkGroup: "workflow", + plugins: [stubCoreWorldFactories()], entries: [ { outputPath: "index", diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index 564b47dfd..b21987e82 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -29,6 +29,12 @@ export interface CancelTurnInput { /** Result of requesting turn cancellation. Both statuses are successful. */ export interface CancelTurnResult { readonly status: CancelTurnStatus; + /** + * For `no_active_turn`: the error class that classified the target as + * inactive, distinguishing "already finished" (`HookNotFoundError`) from a + * transiently unreachable cancel hook (`EntityConflictError`). + */ + readonly reason?: string; } /** Identifies a session to transition permanently to a terminal state. */ diff --git a/packages/eve/src/execution/cancel-descendant-turns-step.test.ts b/packages/eve/src/execution/cancel-descendant-turns-step.test.ts index b4a740ec9..9dbe9ac62 100644 --- a/packages/eve/src/execution/cancel-descendant-turns-step.test.ts +++ b/packages/eve/src/execution/cancel-descendant-turns-step.test.ts @@ -140,11 +140,11 @@ describe("cancelDescendantTurnsStep", () => { serializedContext: {}, sessionState: createPendingState(), }); - await vi.advanceTimersByTimeAsync(3_000); + await vi.advanceTimersByTimeAsync(10_000); await expect(cancellation).resolves.toBeUndefined(); expect(requestWorkflowTurnCancellation).toHaveBeenCalledOnce(); - expect(cancelRemoteAgentTurn).toHaveBeenCalledTimes(12); + expect(cancelRemoteAgentTurn).toHaveBeenCalledTimes(8); expect(error).toHaveBeenCalledWith( "[eve:execution.cancel-descendant-turns] failed to cancel local descendant turn", expect.objectContaining({ callId: "call-local", childSessionId: "local-child" }), @@ -155,7 +155,34 @@ describe("cancelDescendantTurnsStep", () => { ); }); + it("warns loudly when a descendant cancel is never accepted", async () => { + vi.useFakeTimers(); + vi.mocked(requestWorkflowTurnCancellation).mockResolvedValue({ + reason: "EntityConflictError", + status: "no_active_turn", + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const cancellation = cancelDescendantTurnsStep({ + serializedContext: {}, + sessionState: createPendingState({ includeRemote: false }), + }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(cancellation).resolves.toBeUndefined(); + + expect(requestWorkflowTurnCancellation).toHaveBeenCalledTimes(8); + expect(warn).toHaveBeenCalledWith( + "[eve:execution.cancel-descendant-turns] descendant cancel was never accepted; the child may run to completion", + expect.objectContaining({ + childSessionId: "local-child", + finalStatus: "no_active_turn", + reason: "EntityConflictError", + }), + ); + }); + it("treats pending batches from older deployments as having no descendants", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const session = setPendingRuntimeActionBatch({ actions: [localAction], event: { sequence: 0, stepIndex: 0, turnId: "turn_0" }, @@ -171,6 +198,11 @@ describe("cancelDescendantTurnsStep", () => { expect(requestWorkflowTurnCancellation).not.toHaveBeenCalled(); expect(cancelRemoteAgentTurn).not.toHaveBeenCalled(); expect(deserializeContext).not.toHaveBeenCalled(); + // The skipped cancellation is observable, not silent. + expect(warn).toHaveBeenCalledWith( + "[eve:execution.cancel-descendant-turns] pending batch carries no child session ids; descendants cannot be cancelled", + expect.objectContaining({ sessionId: expect.any(String) }), + ); }); }); diff --git a/packages/eve/src/execution/cancel-descendant-turns-step.ts b/packages/eve/src/execution/cancel-descendant-turns-step.ts index 854e0cb89..9924b7173 100644 --- a/packages/eve/src/execution/cancel-descendant-turns-step.ts +++ b/packages/eve/src/execution/cancel-descendant-turns-step.ts @@ -16,8 +16,12 @@ import type { } from "#runtime/actions/types.js"; import type { RuntimeSubagentRegistry } from "#runtime/subagents/registry.js"; -const CANCEL_ATTEMPTS = 12; -const CANCEL_RETRY_DELAY_MS = 250; +// Retry through transient world contention (queue wakes, hook-claim +// conflicts), then log loudly: a silently dropped cancel leaves the child +// running to completion with no trace of why. +const CANCEL_ATTEMPTS = 8; +const CANCEL_RETRY_INITIAL_DELAY_MS = 250; +const CANCEL_RETRY_MAX_DELAY_MS = 1_500; const log = createLogger("execution.cancel-descendant-turns"); /** Cancels every successfully adopted child in the current pending batch. */ @@ -38,9 +42,20 @@ export async function cancelDescendantTurnsStep(input: { return; } - if (batch === undefined) return; + if (batch === undefined) { + log.warn("no pending batch found while cancelling descendants; nothing to cancel", { + sessionId: input.sessionState.sessionId, + }); + return; + } const childSessionIds = batch.childSessionIds; - if (childSessionIds === undefined) return; + if (childSessionIds === undefined) { + log.warn("pending batch carries no child session ids; descendants cannot be cancelled", { + actionCount: batch.actions.length, + sessionId: input.sessionState.sessionId, + }); + return; + } let remoteRegistry: Promise | undefined; const getRemoteRegistry = () => @@ -75,10 +90,19 @@ async function cancelLocalDescendant(input: { readonly childSessionId: string; }): Promise { try { - await requestCancellationWithRetry({ + const final = await requestCancellationWithRetry({ request: () => requestWorkflowTurnCancellation({ sessionId: input.childSessionId }), shouldRetryError: () => false, }); + if (final.status !== "accepted") { + log.warn("descendant cancel was never accepted; the child may run to completion", { + callId: input.action.callId, + childSessionId: input.childSessionId, + finalStatus: final.status, + reason: final.reason, + subagentName: input.action.subagentName, + }); + } } catch (error) { logError(log, "failed to cancel local descendant turn", error, { callId: input.action.callId, @@ -101,10 +125,19 @@ async function cancelRemoteDescendant(input: { registry, }); - await requestCancellationWithRetry({ + const final = await requestCancellationWithRetry({ request: () => cancelRemoteAgentTurn({ remote, sessionId: input.childSessionId }), shouldRetryError: isRetryableRemoteAgentCancelError, }); + if (final.status !== "accepted") { + log.warn("remote descendant cancel was never accepted; the child may run to completion", { + callId: input.action.callId, + childSessionId: input.childSessionId, + finalStatus: final.status, + reason: final.reason, + remoteAgentName: input.action.remoteAgentName, + }); + } } catch (error) { logError(log, "failed to cancel remote descendant turn", error, { callId: input.action.callId, @@ -117,15 +150,21 @@ async function cancelRemoteDescendant(input: { async function requestCancellationWithRetry(input: { readonly request: () => Promise; readonly shouldRetryError: (error: unknown) => boolean; -}): Promise { +}): Promise { + let delayMs = CANCEL_RETRY_INITIAL_DELAY_MS; + let lastResult: CancelTurnResult = { status: "no_active_turn" }; + for (let attempt = 1; attempt <= CANCEL_ATTEMPTS; attempt += 1) { try { - const result = await input.request(); - if (result.status === "accepted" || attempt === CANCEL_ATTEMPTS) return; + lastResult = await input.request(); + if (lastResult.status === "accepted" || attempt === CANCEL_ATTEMPTS) return lastResult; } catch (error) { if (!input.shouldRetryError(error) || attempt === CANCEL_ATTEMPTS) throw error; } - await new Promise((resolve) => setTimeout(resolve, CANCEL_RETRY_DELAY_MS)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * 2, CANCEL_RETRY_MAX_DELAY_MS); } + + return lastResult; } diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index 1a1f85928..38c23c880 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -6,6 +6,15 @@ import { SandboxTemplateNotProvisionedError } from "#public/definitions/sandbox- import { vercel } from "#public/sandbox/backends/vercel.js"; import { createVercelSandbox } from "#execution/sandbox/bindings/vercel.js"; +// The credential fallback consults the developer's Vercel CLI auth and the +// repo's `.vercel` project link; on a linked, logged-in machine it would +// inject real project credentials into the asserted SDK calls. +vi.mock("#compiled/@vercel/oidc/index.js", () => ({ + getVercelOidcToken: vi.fn(async () => { + throw new Error("No ambient Vercel OIDC token in unit tests."); + }), +})); + function createMockCommandResult() { return { exitCode: 0, diff --git a/packages/eve/src/execution/turn-cancellation-control.test.ts b/packages/eve/src/execution/turn-cancellation-control.test.ts index cda4eb17b..731b53fd1 100644 --- a/packages/eve/src/execution/turn-cancellation-control.test.ts +++ b/packages/eve/src/execution/turn-cancellation-control.test.ts @@ -113,6 +113,50 @@ describe("createTurnCancellationControl", () => { expect(control!.signal.aborted).toBe(false); }); + it("flips the signal in the same microtask that consumes the payload", async () => { + // A wake replays a journaled cancel and a completed step in one drain, + // payload first; the settle check reads `signal.aborted` one microtask + // later, so a `.then`-chained abort would lose to it. + let releasePayload!: (result: IteratorResult) => void; + const firstRead = new Promise>((resolve) => { + releasePayload = resolve; + }); + let delivered = false; + createHookMock.mockReturnValue({ + token: "session-1:cancel", + getConflict: vi.fn(async () => null), + dispose: vi.fn(), + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (delivered) return new Promise>(() => {}); + delivered = true; + return firstRead; + }, + return: vi.fn(async () => ({ done: true, value: undefined })), + }; + }, + }); + + const control = await createTurnCancellationControl({ + expectedTurnId: "turn_0", + sessionId: "session-1", + }); + + let releaseStep!: () => void; + const step = new Promise((resolve) => { + releaseStep = resolve; + }); + const abortedAtStepContinuation = step.then(() => control!.signal.aborted); + + // Journal order: the cancel payload resolves before the step result. + releasePayload({ done: false, value: {} }); + releaseStep(); + + await expect(abortedAtStepContinuation).resolves.toBe(true); + await expect(control!.requested).resolves.toBe("cancel"); + }); + it("disposes idempotently", async () => { const { dispose } = installCancelHook({}); diff --git a/packages/eve/src/execution/turn-cancellation-control.ts b/packages/eve/src/execution/turn-cancellation-control.ts index 1869ce4f7..2451e821e 100644 --- a/packages/eve/src/execution/turn-cancellation-control.ts +++ b/packages/eve/src/execution/turn-cancellation-control.ts @@ -51,12 +51,13 @@ export async function createTurnCancellationControl(input: { } const controller = new AbortController(); - // The durable abort fires in the read's continuation so its call site - // is reached deterministically on every replay. - const requested = consumeMatchingCancel(iterator, input.expectedTurnId).then(() => { + // The abort must fire inside the read continuation — not a chained + // `.then` — so the signal is already flipped when a same-drain + // continuation (the turn loop's settle check) reads it; one microtask + // later and an ordinary completion swallows the cancel. + const requested = consumeMatchingCancel(iterator, input.expectedTurnId, () => { controller.abort(new TurnCancelledError()); - return "cancel" as const; - }); + }).then(() => "cancel" as const); let disposed = false; return { @@ -73,15 +74,20 @@ export async function createTurnCancellationControl(input: { } // Mismatched turn guards are consumed as no-ops; each read is durable, -// so the skip sequence replays deterministically. +// so the skip sequence replays deterministically. `onCancel` fires inside +// the matching read's continuation (see the abort ordering note above). async function consumeMatchingCancel( iterator: AsyncIterator, expectedTurnId: string, + onCancel: () => void, ): Promise { while (true) { const next = await iterator.next(); if (next.done) return await new Promise(() => {}); - if (matchesActiveTurn(next.value, expectedTurnId)) return; + if (matchesActiveTurn(next.value, expectedTurnId)) { + onCancel(); + return; + } } } diff --git a/packages/eve/src/execution/turn-cancellation.integration.test.ts b/packages/eve/src/execution/turn-cancellation.integration.test.ts index 705bf08f7..857750b00 100644 --- a/packages/eve/src/execution/turn-cancellation.integration.test.ts +++ b/packages/eve/src/execution/turn-cancellation.integration.test.ts @@ -489,7 +489,10 @@ describe("turn cancellation integration", () => { // settled and its cancel hook swept, it reports the benign status. await waitForHookSweep(sessionCancelHookToken(run.runId)); const session = createSession(run.runId, rawToken, workflowRuntime); - await expect(session.cancel()).resolves.toEqual({ status: "no_active_turn" }); + await expect(session.cancel()).resolves.toEqual({ + reason: "HookNotFoundError", + status: "no_active_turn", + }); await waitForHook({ runId: run.runId }, { token: continuationToken }); await resumeHook(continuationToken, { diff --git a/packages/eve/src/execution/turn-workflow.test.ts b/packages/eve/src/execution/turn-workflow.test.ts index 227743039..9f352ebd0 100644 --- a/packages/eve/src/execution/turn-workflow.test.ts +++ b/packages/eve/src/execution/turn-workflow.test.ts @@ -296,6 +296,46 @@ describe("turnWorkflow", () => { expect(resumeHookMock.mock.calls.filter((call) => call[1]?.kind === "turn-error")).toEqual([]); }); + it("honors cancellation observed while a durable turn step returns", async () => { + const sessionState = createSessionState(); + installInbox([], { cancelPayloads: [{}] }); + vi.mocked(turnStep).mockImplementationOnce(async (stepInput) => { + await vi.waitFor(() => expect(stepInput.abortSignal?.aborted).toBe(true)); + return { + action: "done", + output: "must not complete", + serializedContext: { state: "done" }, + sessionState, + }; + }); + + const { input } = createInput({ + driverCapabilities: { cancelledTurnSettle: true, turnInbox: true }, + sessionState, + }); + await turnWorkflow(input); + + expect(cancelDescendantTurnsStep).toHaveBeenCalledWith({ + serializedContext: { state: "start" }, + sessionState, + }); + expect(resumeHookMock).toHaveBeenCalledWith("turn-token", { + action: { + cancelled: true, + kind: "park", + serializedContext: { state: "start" }, + sessionState, + }, + kind: "turn-result", + }); + expect(resumeHookMock).not.toHaveBeenCalledWith( + "turn-token", + expect.objectContaining({ + action: expect.objectContaining({ kind: "done" }), + }), + ); + }); + it("runs uncancellable when the session cancel token is claimed by another run", async () => { const sessionState = createSessionState(); installInbox([], { cancelConflict: { runId: "wrun_stale_prior_turn" } }); diff --git a/packages/eve/src/execution/turn-workflow.ts b/packages/eve/src/execution/turn-workflow.ts index 62ceaf9df..e1250ff19 100644 --- a/packages/eve/src/execution/turn-workflow.ts +++ b/packages/eve/src/execution/turn-workflow.ts @@ -106,8 +106,18 @@ async function runTurnOwnedWorkflow(input: TurnWorkflowInput): Promise { while (true) { const result = await turnStep(cursor.createStepInput(nextStepInput, cancellation?.signal)); + const pendingActionKeys = + result.action === "dispatch-workflow-runtime-actions" || result.action === "park" + ? result.pendingRuntimeActionKeys + : undefined; - if (result.action === "cancelled") { + // A cancel observed while the step was returning must still win: the + // step may have missed the abort and completed normally. Pending + // runtime-action batches are exempt — their wait observes the signal. + if ( + result.action === "cancelled" || + (cancellation?.signal.aborted === true && pendingActionKeys === undefined) + ) { // No `canPark` check here: that gate rejects model-authored waits // (`next: null`) in task mode, whereas a cancelled turn parks by // design and its parkability was already established when the @@ -137,11 +147,6 @@ async function runTurnOwnedWorkflow(input: TurnWorkflowInput): Promise { // A pending runtime-action batch (model-driven `park` or dynamic-workflow // interrupt) is resolved in-line so the turn stays alive across the wait; // the two arms differ only in their dispatch path. - const pendingActionKeys = - result.action === "dispatch-workflow-runtime-actions" || result.action === "park" - ? result.pendingRuntimeActionKeys - : undefined; - if (pendingActionKeys !== undefined) { await cursor.adopt(result); const dispatch = diff --git a/packages/eve/src/execution/workflow-runtime.test.ts b/packages/eve/src/execution/workflow-runtime.test.ts index 79346d5cc..207f90f10 100644 --- a/packages/eve/src/execution/workflow-runtime.test.ts +++ b/packages/eve/src/execution/workflow-runtime.test.ts @@ -169,19 +169,23 @@ describe("createWorkflowRuntime#cancelTurn", () => { expect(resumeHookMock).toHaveBeenCalledWith("session-1:cancel", {}); }); - it("maps missing and terminal targets to 'no_active_turn'", async () => { + it("maps missing and terminal targets to 'no_active_turn' with a classified reason", async () => { const { EntityConflictError, HookNotFoundError, RunExpiredError, WorkflowRunNotFoundError } = await import("#compiled/@workflow/errors/index.js"); const errors = [ - new HookNotFoundError("session-1:cancel"), - new WorkflowRunNotFoundError("turn-run"), - new RunExpiredError("turn already completed"), - new EntityConflictError("turn completed during cancellation"), + { error: new HookNotFoundError("session-1:cancel"), reason: "HookNotFoundError" }, + { error: new WorkflowRunNotFoundError("turn-run"), reason: "WorkflowRunNotFoundError" }, + { error: new RunExpiredError("turn already completed"), reason: "RunExpiredError" }, + { + error: new EntityConflictError("turn completed during cancellation"), + reason: "EntityConflictError", + }, ]; - for (const error of errors) { + for (const { error, reason } of errors) { resumeHookMock.mockRejectedValueOnce(error); await expect(buildRuntime().cancelTurn({ sessionId: "session-1" })).resolves.toEqual({ + reason, status: "no_active_turn", }); } diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index d63ab146e..9711a203a 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -269,20 +269,20 @@ export async function requestWorkflowTurnCancellation( await resumeHook(sessionCancelHookToken(input.sessionId), payload); return { status: "accepted" }; } catch (error) { - if (isInactiveCancelTarget(error)) { - return { status: "no_active_turn" }; + const reason = classifyInactiveCancelTarget(error); + if (reason !== undefined) { + return { reason, status: "no_active_turn" }; } throw error; } } -function isInactiveCancelTarget(error: unknown): boolean { - return ( - HookNotFoundError.is(error) || - WorkflowRunNotFoundError.is(error) || - RunExpiredError.is(error) || - EntityConflictError.is(error) - ); +function classifyInactiveCancelTarget(error: unknown): string | undefined { + if (HookNotFoundError.is(error)) return "HookNotFoundError"; + if (WorkflowRunNotFoundError.is(error)) return "WorkflowRunNotFoundError"; + if (RunExpiredError.is(error)) return "RunExpiredError"; + if (EntityConflictError.is(error)) return "EntityConflictError"; + return undefined; } function isAlreadyTerminalSessionError(error: unknown): boolean { diff --git a/packages/eve/src/internal/application/compiled-artifacts.ts b/packages/eve/src/internal/application/compiled-artifacts.ts index 81114d440..4ce9c7e30 100644 --- a/packages/eve/src/internal/application/compiled-artifacts.ts +++ b/packages/eve/src/internal/application/compiled-artifacts.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import type { CompileMetadata } from "#compiler/artifacts.js"; import type { CompileAgentResult } from "#compiler/compile-agent.js"; import { createCompiledModuleMapSource } from "#compiler/module-map.js"; -import { getWorldImport } from "@workflow/utils"; import { stringifyEsmImportSpecifier } from "#internal/application/import-specifier.js"; import { resolvePackageCompiledFilePath, @@ -15,6 +14,7 @@ import { buildPackageUserAgent } from "#internal/user-agent.js"; import type { AgentWorkflowWorldDefinition } from "#shared/agent-definition.js"; import { readMaterializedAuthoredModuleIndex } from "#internal/materialized-authored-modules.js"; import { usesParentDevelopmentWorkflowWorld } from "#internal/workflow/development-world-protocol.js"; +import { resolveWorkflowWorldImport } from "#internal/workflow/world-target.js"; export type BuiltInWorkflowWorldTarget = "local" | "vercel"; @@ -313,7 +313,7 @@ export function createWorkflowWorldPluginSource(input: { defaultWorld: BuiltInWorkflowWorldTarget; }): string { const targetWorld = input.configuredWorld ?? input.defaultWorld; - const packageName = getWorldImport({ WORKFLOW_TARGET_WORLD: targetWorld }); + const packageName = resolveWorkflowWorldImport(targetWorld); const wiring = resolveWorkflowWorldWiring(packageName); const workflowRuntimeImportSpecifier = resolvePackageCompiledFilePath( "src/compiled/@workflow/core/runtime.js", diff --git a/packages/eve/src/internal/workflow/development-world-protocol.ts b/packages/eve/src/internal/workflow/development-world-protocol.ts index 4756b84a6..8249cf56c 100644 --- a/packages/eve/src/internal/workflow/development-world-protocol.ts +++ b/packages/eve/src/internal/workflow/development-world-protocol.ts @@ -1,6 +1,5 @@ -import { getWorldImport } from "@workflow/utils"; - import type { AgentWorkflowWorldDefinition } from "#shared/agent-definition.js"; +import { resolveWorkflowWorldImport } from "#internal/workflow/world-target.js"; export const DEVELOPMENT_WORKFLOW_WORLD_ROUTE = "/eve/v1/dev/internal/workflow-world"; @@ -15,10 +14,7 @@ export const DEVELOPMENT_WORKFLOW_WORLD_ROUTE = "/eve/v1/dev/internal/workflow-w export function usesParentDevelopmentWorkflowWorld( configuredWorld: AgentWorkflowWorldDefinition | undefined, ): boolean { - return ( - getWorldImport({ WORKFLOW_TARGET_WORLD: configuredWorld ?? "local" }) === - "@workflow/world-local" - ); + return resolveWorkflowWorldImport(configuredWorld ?? "local") === "@workflow/world-local"; } export const DEVELOPMENT_WORKFLOW_SECRET_ENV = "EVE_DEV_WORKFLOW_TRANSPORT_SECRET"; export const DEVELOPMENT_WORKER_APP_ROOT_ENV = "EVE_DEV_WORKER_APP_ROOT"; diff --git a/packages/eve/src/internal/workflow/world-target.test.ts b/packages/eve/src/internal/workflow/world-target.test.ts new file mode 100644 index 000000000..c740fbdb3 --- /dev/null +++ b/packages/eve/src/internal/workflow/world-target.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { resolveWorkflowWorldImport } from "#internal/workflow/world-target.js"; + +describe("resolveWorkflowWorldImport", () => { + it("maps the built-in world shorthands to their packages", () => { + expect(resolveWorkflowWorldImport("local")).toBe("@workflow/world-local"); + expect(resolveWorkflowWorldImport("vercel")).toBe("@workflow/world-vercel"); + }); + + it("passes custom world specifiers through unchanged", () => { + expect(resolveWorkflowWorldImport("@acme/world-redis")).toBe("@acme/world-redis"); + expect(resolveWorkflowWorldImport("./relative/world.js")).toBe("./relative/world.js"); + }); +}); diff --git a/packages/eve/src/internal/workflow/world-target.ts b/packages/eve/src/internal/workflow/world-target.ts new file mode 100644 index 000000000..b9e297500 --- /dev/null +++ b/packages/eve/src/internal/workflow/world-target.ts @@ -0,0 +1,11 @@ +/** + * Maps a configured workflow world target to its package import specifier, + * mirroring the Workflow DevKit's `WORKFLOW_TARGET_WORLD` normalization: + * `"local"` and `"vercel"` are shorthands for the first-party world + * packages; anything else is already a specifier. + */ +export function resolveWorkflowWorldImport(targetWorld: string): string { + if (targetWorld === "local") return "@workflow/world-local"; + if (targetWorld === "vercel") return "@workflow/world-vercel"; + return targetWorld; +} diff --git a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts index 20be75bbe..8c8a032d2 100644 --- a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts +++ b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts @@ -847,11 +847,12 @@ describe("app runtime dependency tracing", () => { expect(vercelFunctionsSource).not.toContain("chokidar"); expect(vercelFunctionsSource).not.toContain("[eve:dev]"); expect(vercelFunctionsSource).not.toContain("rollup:reload"); - // The world-local canary is its config env var, not its error-class - // names: the semantic-error catalog legitimately embeds names like - // `DataDirAccessError` as matcher strings without bundling any - // world-local code. - expect(vercelFunctionsSource).not.toContain("WORKFLOW_LOCAL_DATA_DIR"); + // The world-local canary is its log prefix, not names other packages + // legitimately mention without bundling world-local code: the + // semantic-error catalog embeds error-class names like + // `DataDirAccessError`, and @workflow/core's runtime world factory + // (stubbed out at vendor time) names `WORKFLOW_LOCAL_DATA_DIR`. + expect(vercelFunctionsSource).not.toContain("[world-local]"); }, 30_000); it("loads instrumentation runtime dependencies from hosted Vercel output", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0a62e02..d1381c7fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1150,26 +1150,26 @@ importers: specifier: 1.28.8 version: 1.28.8 '@workflow/core': - specifier: 5.0.0-beta.37 - version: 5.0.0-beta.37(@opentelemetry/api@1.9.1)(supports-color@10.2.2)(ws@8.21.1(bufferutil@4.1.0)) + specifier: 5.0.0-beta.38 + version: 5.0.0-beta.38(@opentelemetry/api@1.9.1)(supports-color@10.2.2)(ws@8.21.1(bufferutil@4.1.0)) '@workflow/errors': - specifier: 5.0.0-beta.13 - version: 5.0.0-beta.13 + specifier: 5.0.0-beta.14 + version: 5.0.0-beta.14 '@workflow/serde': specifier: 5.0.0-beta.2 version: 5.0.0-beta.2 '@workflow/utils': - specifier: 5.0.0-beta.7 - version: 5.0.0-beta.7 + specifier: 5.0.0-beta.8 + version: 5.0.0-beta.8 '@workflow/world': specifier: 5.0.0-beta.23 version: 5.0.0-beta.23 '@workflow/world-local': - specifier: 5.0.0-beta.31 - version: 5.0.0-beta.31(@opentelemetry/api@1.9.1) + specifier: 5.0.0-beta.32 + version: 5.0.0-beta.32(@opentelemetry/api@1.9.1) '@workflow/world-vercel': - specifier: 5.0.0-beta.33 - version: 5.0.0-beta.33(@opentelemetry/api@1.9.1) + specifier: 5.0.0-beta.34 + version: 5.0.0-beta.34(@opentelemetry/api@1.9.1) ai: specifier: 'catalog:' version: 7.0.38(zod@4.4.3) @@ -7282,16 +7282,16 @@ packages: '@webgpu/types@0.1.71': resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==} - '@workflow/core@5.0.0-beta.37': - resolution: {integrity: sha512-QaBWdRXpQj2dSMkl7sjsTL3TNTfo2yDcXcLd8rFJ6u6By7xj5rC4nu4qsk8Cn6f13Dh9jrQTXGbDrDVZaeLgpQ==} + '@workflow/core@5.0.0-beta.38': + resolution: {integrity: sha512-jXQFXQ6o5Px0UNzQwH7eCnNeR9XeVIYIjI99PoMmgXcZZ+3828/ij/jvsMI+5xW7yo8IJMm24gl8rxsVI0tngQ==} peerDependencies: '@opentelemetry/api': '1' peerDependenciesMeta: '@opentelemetry/api': optional: true - '@workflow/errors@5.0.0-beta.13': - resolution: {integrity: sha512-xRiG9MEunLxkb7hw7n17+/cKKH4QfFvnQly4cAF2ImxPRDnL0HvWot9eHOPS3S41LEyZ/LSs/sYrIN07XUZbgw==} + '@workflow/errors@5.0.0-beta.14': + resolution: {integrity: sha512-1RwF7LG7JbIp78zD+eb1A8ifDacI12wzpdMAFbzMODc/Ok0u+1IXbT1DIVKLtBLnUOPrWLxnXBYDnkvkvG9tAw==} '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} @@ -7302,19 +7302,19 @@ packages: '@workflow/serde@5.0.0-beta.2': resolution: {integrity: sha512-INB+FcEKQkkFZa+s53sEMTNRFiBa2g9vzjsNuEDagvWxtI0t/cnCyoc57i7kS7nj+3/vGeRjO4Yn5ccbT0hyBQ==} - '@workflow/utils@5.0.0-beta.7': - resolution: {integrity: sha512-3vDiA8xea4USslIbz6m9HAhxDhvqLbETaXFxTNqVm/m8eji0rLKwnfF6JLaH0Qr1GsovysoOUlvyibokkNHZrg==} + '@workflow/utils@5.0.0-beta.8': + resolution: {integrity: sha512-dDiQ/LT05g9HyOicqWNNX/Gw/fmO7Ma2kZ+ddUXXpm/EFTMRS9eiI69suSe1+1NnhMzdV6ObI8wrO5lvQqPIbg==} - '@workflow/world-local@5.0.0-beta.31': - resolution: {integrity: sha512-Z9nq5lzjXnig4ONfLnVwTqx6EbxJjhTB5SBKT8ERcKe6/SFm30s1BIZ03bZBJox2Y4jJAgsNgvey29xWGnzMAg==} + '@workflow/world-local@5.0.0-beta.32': + resolution: {integrity: sha512-D8kb1IHHrkDbKnpUw3cqZnlkis3YCYPF2BoV6vWpGUGTeSGsnTZjMsrqsh5luKeZSUqK5BCO/YC27S9AdkBO0w==} peerDependencies: '@opentelemetry/api': '1' peerDependenciesMeta: '@opentelemetry/api': optional: true - '@workflow/world-vercel@5.0.0-beta.33': - resolution: {integrity: sha512-6MWaWw85145AInDBYql33XPETAjrOvu40wJYje/JwJwkFtOrGSx2B2vjbNxscafsN9TIZYUCRdK53spcqsMNzg==} + '@workflow/world-vercel@5.0.0-beta.34': + resolution: {integrity: sha512-bSBxUcs90wbhY8NlZYojoPAL7UwDuiTxr1R6eCb3ZCHCly5pTFXnkMIsM2hScPFM6ea/KImMl1KQVJc6OCPjIQ==} peerDependencies: '@opentelemetry/api': '1' peerDependenciesMeta: @@ -21035,19 +21035,19 @@ snapshots: '@webgpu/types@0.1.71': {} - '@workflow/core@5.0.0-beta.37(@opentelemetry/api@1.9.1)(supports-color@10.2.2)(ws@8.21.1(bufferutil@4.1.0))': + '@workflow/core@5.0.0-beta.38(@opentelemetry/api@1.9.1)(supports-color@10.2.2)(ws@8.21.1(bufferutil@4.1.0))': dependencies: '@aws-sdk/credential-provider-web-identity': 3.972.49 '@jridgewell/trace-mapping': 0.3.31 '@standard-schema/spec': 1.0.0 '@types/ms': 2.1.0 '@vercel/functions': 3.7.5(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.21.1(bufferutil@4.1.0)) - '@workflow/errors': 5.0.0-beta.13 + '@workflow/errors': 5.0.0-beta.14 '@workflow/serde': 5.0.0-beta.2 - '@workflow/utils': 5.0.0-beta.7 + '@workflow/utils': 5.0.0-beta.8 '@workflow/world': 5.0.0-beta.23 - '@workflow/world-local': 5.0.0-beta.31(@opentelemetry/api@1.9.1) - '@workflow/world-vercel': 5.0.0-beta.33(@opentelemetry/api@1.9.1) + '@workflow/world-local': 5.0.0-beta.32(@opentelemetry/api@1.9.1) + '@workflow/world-vercel': 5.0.0-beta.34(@opentelemetry/api@1.9.1) debug: 4.4.3(supports-color@10.2.2) devalue: 5.8.1 ms: 2.1.3 @@ -21062,9 +21062,9 @@ snapshots: - supports-color - ws - '@workflow/errors@5.0.0-beta.13': + '@workflow/errors@5.0.0-beta.14': dependencies: - '@workflow/utils': 5.0.0-beta.7 + '@workflow/utils': 5.0.0-beta.8 ms: 2.1.3 '@workflow/serde@4.1.0': {} @@ -21073,15 +21073,15 @@ snapshots: '@workflow/serde@5.0.0-beta.2': {} - '@workflow/utils@5.0.0-beta.7': + '@workflow/utils@5.0.0-beta.8': dependencies: ms: 2.1.3 - '@workflow/world-local@5.0.0-beta.31(@opentelemetry/api@1.9.1)': + '@workflow/world-local@5.0.0-beta.32(@opentelemetry/api@1.9.1)': dependencies: '@vercel/queue': 0.4.0(@opentelemetry/api@1.9.1) - '@workflow/errors': 5.0.0-beta.13 - '@workflow/utils': 5.0.0-beta.7 + '@workflow/errors': 5.0.0-beta.14 + '@workflow/utils': 5.0.0-beta.8 '@workflow/world': 5.0.0-beta.23 async-sema: 3.1.1 ulid: 3.0.2 @@ -21090,11 +21090,11 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - '@workflow/world-vercel@5.0.0-beta.33(@opentelemetry/api@1.9.1)': + '@workflow/world-vercel@5.0.0-beta.34(@opentelemetry/api@1.9.1)': dependencies: '@vercel/oidc': 3.2.0 '@vercel/queue': 0.4.0(@opentelemetry/api@1.9.1) - '@workflow/errors': 5.0.0-beta.13 + '@workflow/errors': 5.0.0-beta.14 '@workflow/world': 5.0.0-beta.23 cbor-x: 1.6.0 ulid: 3.0.2