Skip to content
Merged
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/descendant-cancel-delivery.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/turn-cancel-race.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/workflow-beta-38.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions packages/eve/scripts/vendor-compiled/@workflow/core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
36 changes: 34 additions & 2 deletions packages/eve/src/execution/cancel-descendant-turns-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand All @@ -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" },
Expand All @@ -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) }),
);
});
});

Expand Down
59 changes: 49 additions & 10 deletions packages/eve/src/execution/cancel-descendant-turns-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<RuntimeSubagentRegistry["subagentsByNodeId"]> | undefined;
const getRemoteRegistry = () =>
Expand Down Expand Up @@ -75,10 +90,19 @@ async function cancelLocalDescendant(input: {
readonly childSessionId: string;
}): Promise<void> {
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,
Expand All @@ -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,
Expand All @@ -117,15 +150,21 @@ async function cancelRemoteDescendant(input: {
async function requestCancellationWithRetry(input: {
readonly request: () => Promise<CancelTurnResult>;
readonly shouldRetryError: (error: unknown) => boolean;
}): Promise<void> {
}): Promise<CancelTurnResult> {
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;
}
9 changes: 9 additions & 0 deletions packages/eve/src/execution/sandbox/bindings/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/eve/src/execution/turn-cancellation-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) => void;
const firstRead = new Promise<IteratorResult<unknown>>((resolve) => {
releasePayload = resolve;
});
let delivered = false;
createHookMock.mockReturnValue({
token: "session-1:cancel",
getConflict: vi.fn(async () => null),
dispose: vi.fn(),
[Symbol.asyncIterator](): AsyncIterator<unknown> {
return {
next: () => {
if (delivered) return new Promise<IteratorResult<unknown>>(() => {});
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<void>((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({});

Expand Down
20 changes: 13 additions & 7 deletions packages/eve/src/execution/turn-cancellation-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<TurnCancelPayload>,
expectedTurnId: string,
onCancel: () => void,
): Promise<void> {
while (true) {
const next = await iterator.next();
if (next.done) return await new Promise<never>(() => {});
if (matchesActiveTurn(next.value, expectedTurnId)) return;
if (matchesActiveTurn(next.value, expectedTurnId)) {
onCancel();
return;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Loading
Loading