Skip to content

Commit 8c04b8a

Browse files
author
Matt Carey
committed
feat(codemode): add durable execution retries
1 parent c476265 commit 8c04b8a

15 files changed

Lines changed: 666 additions & 86 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/codemode": patch
3+
---
4+
5+
Add durable execution retries to `runtime.tool()`. Connectors can throw `RetryableError` with an optional delay; by default the runtime makes three total attempts, honors that delay or uses bounded exponential backoff, and can be customized or disabled with `retry`. Failed passes restart under the same execution id, replaying applied calls from the log and re-executing only the failure boundary. Dynamic-worker timeouts are surfaced as structured failures but are not retried by default, so applications can conservatively decide which executions are safe to retry. Attempt fencing prevents calls or results from a superseded timed-out sandbox from mutating the replay log.

docs/codemode/runtime.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const runtime = createCodemodeRuntime({
2323

2424
| Handle method | Purpose |
2525
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
26-
| `runtime.tool(options?)` | The single model-facing AI SDK tool, `codemode({ code })` |
26+
| `runtime.tool(options?)` | The framework-independent model-facing tool, `codemode({ code })` |
2727
| `runtime.pending(executionId?)` | Actions awaiting approval — drives approval UIs; no id aggregates all paused runs |
2828
| `runtime.approve({ executionId })` | Approve the pending action and continue via replay |
2929
| `runtime.reject({ seq, executionId })` | Reject a pending action; ends the execution. Returns `false` if it was a no-op (action no longer pending — approved or rejected elsewhere) |
@@ -149,6 +149,32 @@ A tool can opt out of result recording with [`replay: "reexecute"`](./connectors
149149

150150
Any single recorded value (a call's arguments, a recorded result, the final result) is capped at 1 MB serialized (`MAX_DURABLE_VALUE_BYTES`). Truncating a logged value is never an option — replay would feed resumed code corrupted data — so an oversized argument or call result **fails the run** with a model-actionable error suggesting the data be written to a file/workspace and passed by reference. An oversized **final** result does not fail the run (replay never needs it): the run completes, the model receives the real value, and the audit trail stores a placeholder note.
151151

152+
## Retrying failed passes
153+
154+
A connector requests a retry by throwing `RetryableError`. By default the runtime makes three total attempts, honoring `retryAfterMs` when present and otherwise using bounded exponential backoff (500ms, 1s, up to 10s). A retry keeps the same execution id and re-runs the stored code: applied calls replay from the durable log, while the call left `executing` at the failure boundary executes again. No configuration is needed for this default.
155+
156+
```ts
157+
const runtime = createCodemodeRuntime({ ctx, executor, connectors });
158+
```
159+
160+
Customize the policy when needed, or pass `retry: false` to disable automatic retries:
161+
162+
```ts
163+
const runtime = createCodemodeRuntime({
164+
ctx,
165+
executor,
166+
connectors,
167+
retry: {
168+
maxAttempts: 4,
169+
shouldRetry: ({ failure }) => failure.kind === "retryable"
170+
}
171+
});
172+
```
173+
174+
The error message and optional `retryAfterMs` cross the connector RPC and sandbox boundaries as structured metadata. Dynamic-worker timeouts also arrive as `failure.kind === "timeout"`, but are not retried by default: an operation may have succeeded remotely before its response was lost, so the application must decide whether that boundary is safe to repeat.
175+
176+
Before retry policy or delay callbacks run, the runtime advances a durable attempt fence. Late calls and results from the superseded sandbox are inert and cannot overwrite the newer pass.
177+
152178
## Rollback
153179

154180
Rollback walks the log backward and calls the `revert` of **every** applied action that has one — independent of `requiresApproval`. A non-approval write with a `revert` is still undone; an approval-gated action without a `revert` is not. `revert` (via `revertAction`) returns whether it actually reverted, and the runtime marks only those entries `reverted`:

packages/codemode/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,49 @@ The `tool(name, t)` decoration hook adjusts tools you didn't author inline (used
380380

381381
The agent drives approvals through the runtime: `runtime.pending()`, `runtime.approve({ executionId })`, `runtime.reject({ seq, executionId })`, `runtime.rollback({ executionId })` (see [docs/codemode/approvals.md](../../docs/codemode/approvals.md)).
382382

383+
### Durable retries
384+
385+
A runtime automatically retries `RetryableError` under the same execution id, up to three total attempts. Applied connector calls replay from the durable log; the call at the failure boundary executes again. `retryAfterMs` is honored when present, otherwise retries use bounded exponential backoff (500ms, 1s, up to 10s).
386+
387+
```ts
388+
import { RetryableError } from "@cloudflare/codemode";
389+
390+
// Connector code can signal a transient, safe-to-repeat failure.
391+
throw new RetryableError("Rate limited", { retryAfterMs: 2_000 });
392+
393+
const runtime = createCodemodeRuntime({
394+
ctx,
395+
executor,
396+
connectors
397+
// No retry configuration needed for RetryableError.
398+
});
399+
400+
// Customize the policy when needed — for example, to opt safe reads into
401+
// timeout retries. Timeout retries are off by default because a timed-out
402+
// mutation may already have succeeded remotely.
403+
const customized = createCodemodeRuntime({
404+
ctx,
405+
executor,
406+
connectors,
407+
retry: {
408+
maxAttempts: 4,
409+
shouldRetry: ({ failure, execution }) =>
410+
failure.kind === "retryable" ||
411+
(failure.kind === "timeout" && timeoutIsSafe(execution))
412+
}
413+
});
414+
415+
// Or disable automatic retries entirely.
416+
const noRetries = createCodemodeRuntime({
417+
ctx,
418+
executor,
419+
connectors,
420+
retry: false
421+
});
422+
```
423+
424+
Each pass has a durable attempt fence. If a timed-out old sandbox finishes after its retry started, its later calls and results are ignored rather than corrupting the replay log.
425+
383426
### Snippets
384427

385428
Snippets are durable, addressable saved scripts. The model writes and runs scripts; the developer promotes the ones worth keeping (`runtime.saveSnippet`), and the model reuses them (`codemode.run`). No authoring step, no skill-source interface.

packages/codemode/src/connectors/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export type ExecutionEndStatus =
5757
* resources (an open socket, a lease) should be released even though
5858
* per-execution resources (a session) must survive.
5959
*/
60-
export type PassEndStatus = ExecutionEndStatus | "paused";
60+
export type PassEndStatus = ExecutionEndStatus | "paused" | "retrying";
6161

6262
// ---------------------------------------------------------------------------
6363
// Connector description — returned by describe() RPC.

packages/codemode/src/executor-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
* code in a sandbox (DynamicWorkerExecutor, IframeSandboxExecutor, ...).
44
*/
55

6+
import type { ExecuteFailure } from "./retry";
7+
68
export interface ExecuteResult {
79
result: unknown;
810
error?: string;
11+
/** Structured failure metadata. Executors should set this when available. */
12+
failure?: ExecuteFailure;
913
logs?: string[];
1014
}
1115

packages/codemode/src/executor.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@ export class DynamicWorkerExecutor implements Executor {
372372
` if (__r && typeof __r === "object") {\n` +
373373
` if (__r.${CONNECTOR_CONTROL_KEY} === "pause") throw new Error("${PAUSE_SENTINEL_LITERAL}");\n` +
374374
` if (__r.${CONNECTOR_CONTROL_KEY} === "error") throw new Error(String(__r.message));\n` +
375+
` if (__r.${CONNECTOR_CONTROL_KEY} === "retryable") {\n` +
376+
` const __e = new Error(String(__r.message));\n` +
377+
` __e.__codemode_failure__ = { kind: "retryable", message: String(__r.message), retryAfterMs: __r.retryAfterMs };\n` +
378+
` throw __e;\n` +
379+
` }\n` +
375380
` }\n` +
376381
` return __r;\n` +
377382
` };\n` +
@@ -400,13 +405,13 @@ export class DynamicWorkerExecutor implements Executor {
400405
.concat([normalized])
401406
.concat([
402407
")(),",
403-
' new Promise((_, reject) => setTimeout(() => reject(new Error("Execution timed out")), ' +
408+
' new Promise((_, reject) => setTimeout(() => { const e = new Error("Execution timed out"); e.__codemode_failure__ = { kind: "timeout", message: "Execution timed out" }; reject(e); }, ' +
404409
timeoutMs +
405410
"))",
406411
" ]);",
407412
" return { result, logs: __logs };",
408413
" } catch (err) {",
409-
" return { result: undefined, error: err.message, logs: __logs };",
414+
" return { result: undefined, error: err.message, failure: err.__codemode_failure__, logs: __logs };",
410415
" }",
411416
" }",
412417
"}"
@@ -472,13 +477,19 @@ export class DynamicWorkerExecutor implements Executor {
472477
): Promise<{
473478
result: unknown;
474479
error?: string;
480+
failure?: import("./retry").ExecuteFailure;
475481
logs?: string[];
476482
}>;
477483
};
478484
const response = await entrypoint.evaluate(dispatchers, connectorBindings);
479485

480-
if (response.error) {
481-
return { result: undefined, error: response.error, logs: response.logs };
486+
if (response.error || response.failure) {
487+
return {
488+
result: undefined,
489+
error: response.error,
490+
failure: response.failure,
491+
logs: response.logs
492+
};
482493
}
483494

484495
return { result: response.result, logs: response.logs };

packages/codemode/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ export {
3737
type PendingAction
3838
} from "./runtime";
3939
export { type Snippet, type SaveSnippetOptions } from "./snippet";
40+
export {
41+
RetryableError,
42+
DEFAULT_RETRY_MAX_ATTEMPTS,
43+
DEFAULT_RETRY_BASE_DELAY_MS,
44+
DEFAULT_RETRY_MAX_DELAY_MS,
45+
defaultRetryDelayMs,
46+
type ExecuteFailure,
47+
type CodemodeRetryContext,
48+
type CodemodeRetryOptions,
49+
type CodemodeRetryPolicy
50+
} from "./retry";
4051
export {
4152
createCodemodeRuntime,
4253
type CreateCodemodeRuntimeOptions,

0 commit comments

Comments
 (0)