Skip to content

Commit 0f80fac

Browse files
authored
fix: make agent runs durable across worker restarts (#176)
## Summary - Move the semantic agent loop out of a Worker-isolate promise and into one deterministic Cloudflare Workflow per run. - Checkpoint every model turn, tool invocation, transcript publication, terminal transition, and cleanup so isolate eviction or deployment resumes completed work. - Reacquire provider credentials and sandbox capabilities inside each active step, keeping plaintext secrets out of durable state. - Reconcile the web client with the persisted terminal transcript when a browser misses the end of a stream. ## What's Included ### Durable execution - Add a model-only Mastra turn that exposes tool schemas without executing them. - Reconstruct each selected tool through Mastra's cross-process execution API in a separate Workflow step. - Replace lease epochs and the volatile stream driver with validated JSON Workflow state. ### Idempotent publication and lifecycle - Give Workflow events deterministic keys and atomically receipt transcript fragments in Durable Object SQLite. - Recover ambiguous admission, reconcile retained Workflow status, and convert exhausted or externally terminated runs into explicit retryable failures. - Terminate the owning Workflow before cancellation or deletion and keep cleanup independently retryable. ### Client convergence - Poll the lightweight thread record only while its run is active and the tab is visible. - Stop a stale stream and refresh persisted messages and chat lists when the authoritative run pointer clears. ## Architecture ```mermaid sequenceDiagram participant G as Gateway participant D as AgentRun Durable Object participant W as Cloudflare Workflow participant M as Mastra participant S as Daytona / external tools G->>D: admit immutable run payload D->>W: create deterministic run instance W->>M: checkpoint one model-only turn M-->>W: text and tool calls W->>D: publish turn with idempotency key loop each tool call W->>M: reconstruct registered tool M->>S: execute with step-scoped credentials S-->>M: serializable result W->>D: publish result with idempotency key end W->>D: commit terminal status and transcript W->>S: retryable cleanup ``` ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | Execution owner | Cloudflare Workflow | In-isolate promise; Mastra in-process durable agent | The existing promise disappears with isolate eviction. Mastra's built-in durable agent is documented for in-process/simple deployments and defaults to a hard step ceiling, while Cloudflare already owns durable execution here. | | Tool boundary | Model-only turn plus one Workflow step per tool | Stream the whole Mastra loop in one step | Completed tool calls become durable checkpoints and tools are reconstructed through Mastra's intended cross-process API. | | Transcript delivery | Deterministic event keys plus atomic SQLite receipts | Best-effort append | A retried publication cannot duplicate visible assistant or tool parts. | | Failed retained Workflow | Visible retryable terminal failure | Restart from the beginning | Blind restart can repeat external side effects. | | Secrets | Resolve inside each step | Serialize request context | Plaintext BYOK credentials and capabilities never enter Workflow storage. | ## Edge Cases Handled | Scenario | Handling | |---|---| | Worker or Durable Object eviction | Workflow replays completed steps and continues from the next checkpoint. | | Ambiguous Workflow creation response | Alarm recovery reuses the deterministic instance and validates the immutable input hash. | | Repeated Workflow callback | Durable Object verifies instance identity; transcript receipts suppress duplicates. | | Transient tool failure | Tool error is returned to the model so it can recover semantically. | | Workflow API temporarily unavailable | Reconciliation is re-armed without prematurely failing the run. | | Workflow reports unknown | Three spaced observations are required before declaring interruption. | | User cancellation or deletion | Active queued/running/waiting/paused Workflow is terminated before terminal state is committed. | | Browser misses terminal stream chunks | Active-run polling stops the stale stream and replaces transient state with the persisted transcript. | ## How to Review 1. Start with `agent-run-workflow.ts` and `agent-run-workflow-runtime.ts` for the checkpointed loop. 2. Review `durable-agent-step.ts` for the Mastra model/tool separation. 3. Review `agent-run-workflow-controller.ts` and `agent-run-output.ts` for admission, reconciliation, and idempotency. 4. Finish with the web lifecycle hooks that converge a disconnected client. ## Verification - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` - [x] Full documented Docker stack starts under Node 24.18.0 - [x] Gateway liveness returns HTTP 200 - [x] Web root returns HTTP 200 after cold compilation - [x] Startup logs contain no runtime errors Production browser acceptance will run after deployment so it exercises the exact merged Worker and web release.
1 parent b33340f commit 0f80fac

36 files changed

Lines changed: 1923 additions & 2295 deletions

apps/agent-worker/README.md

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ exact object before failing. A committed replay under a still-active run atomica
2424
retention before the output can be exposed through a fresh download capability. A terminal replay
2525
can acknowledge only the same unexpired output and never renews it; every committed replay verifies
2626
the exact R2 object again before returning. Terminal run
27-
persistence records upload quiescence only after its execution promise has settled, while deletion
28-
RPCs abort and join the same promise before returning.
27+
persistence records upload quiescence only after the Workflow-owned tool steps have settled, while
28+
deletion RPCs terminate the run's Workflow before removing its durable state.
2929

3030
Artifact messages persist only the output UUID and presentation metadata. The authenticated
3131
`POST /v1/outputs/:outputId/download-url` path rechecks tenant ownership, retention, and R2
@@ -74,25 +74,25 @@ and clears the matching thread pointer in one transaction. Transport or reconnec
7474
leaves the pointer intact for the next idempotent replay. Active-run conflicts use the same
7575
reconciliation path instead of blindly returning a conflict.
7676

77-
Each admitted semantic run has a deterministic chain of Cloudflare Workflow generations. A
78-
generation keeps the Durable Object execution request attached in four-minute ownership epochs,
79-
then checkpoints and renews the same in-memory coroutine. At 20,000 epochs (about 55 days), it
80-
atomically reserves `generation + 1` in the run-keyed Durable Object and creates the deterministic
81-
successor, leaving almost 5,000 steps below Cloudflare's configured 25,000-step platform ceiling.
82-
The successor's first execution atomically promotes its exact generation, input hash, and instance
83-
ID; any late callback from the predecessor becomes a no-op. A pending successor is recovered by
84-
the existing admission alarm if creation was ambiguous. `draining` permits that continuation,
85-
while `closed` fences it. Generations are an operational rollover mechanism, not a run-duration,
86-
step, token, or cost limit.
87-
88-
A persisted execution-start fence makes retries at-most-once: a warm retry joins the exact
89-
promise, while a restart that lost that promise terminalizes the run instead of replaying model
90-
calls, tools, or other non-idempotent external side effects. An alarm-backed lease also
91-
terminalizes an admission or execution whose current Workflow owner stops renewing it.
92-
Before a Worker release, the closed gateway plus draining agent gate requires every retained
93-
`cheatcode-agent-runs` instance to be complete. Errored and terminated instances
94-
remain restartable on their pinned Worker version, so they block deployment until
95-
the exact retained instance has expired or been purged.
77+
Each admitted semantic run has one deterministic Cloudflare Workflow instance. The Workflow owns
78+
the agent loop and checkpoints preparation, every model turn, every tool invocation, transcript
79+
publication, completion, and cleanup as separate steps. Its state contains only validated JSON;
80+
provider keys and sandbox capabilities are reacquired inside the active step and never enter
81+
Workflow storage. A Worker isolate or Durable Object eviction therefore resumes from the last
82+
completed step instead of losing an in-memory coroutine. Transcript publication uses deterministic
83+
event keys and an atomic SQLite receipt, so Workflow step replay cannot duplicate visible parts.
84+
There is no application step, token, duration, or cost ceiling; semantic completion ends the loop,
85+
while per-operation timeouts and the platform Workflow limit remain operational safeguards.
86+
87+
The run-keyed Durable Object is the authoritative status, cancellation, transcript, and stream
88+
store. It validates every Workflow callback against the stored input hash and deterministic
89+
instance ID, and late callbacks become terminal no-ops. Admission ambiguity is recovered by the
90+
existing alarm. Cancellation terminates the Workflow before committing terminal state. Before a
91+
Worker release, the closed gateway plus draining agent gate requires every retained
92+
`cheatcode-agent-runs` instance to be complete. Workflow retries resume individual model and tool
93+
steps from their durable checkpoints. An exhausted or externally terminated instance is never
94+
blindly restarted from the beginning because doing so could repeat an external tool side effect;
95+
the run object reconciles that terminal mismatch into a visible, retryable failure instead.
9696

9797
Normal chat runs resolve provider credentials from Supabase Vault through `packages/byok`,
9898
pass only the request-scoped transport credential to Mastra, and execute tools against the
@@ -110,13 +110,12 @@ record with the canonical UI-message schema, and converts it with AI SDK
110110
`convertToModelMessages`. The current run's user message must be last and carry that run ID.
111111
Ephemeral app-builder context is appended only to that current model turn and is never stored.
112112

113-
`AgentRun` is the Durable Object coordination shell rather than the implementation home for
114-
every concern. Its HTTP adapter owns bounded request parsing and route dispatch; the run
115-
lifecycle module owns progress, terminal persistence, and sandbox-lease cleanup; the run-path
116-
module selects general or app-builder execution; and the output component owns replay and
117-
answer segmentation. The Workflow controller owns admission, execution identity, the
118-
at-most-once fence, and ownership leases. The shell retains only Durable Object identity,
119-
cancellation, status, and dependency wiring.
113+
`AgentRun` is the Durable Object coordination shell rather than the implementation home for every
114+
concern. Its HTTP adapter owns bounded request parsing and route dispatch; the Workflow runtime
115+
owns model/tool step preparation and sandbox-lease cleanup; the app-builder path owns scaffold and
116+
preview setup; and the output component owns idempotent transcript publication plus resumable
117+
streams. The Workflow controller owns admission, callback identity, and cancellation. The shell
118+
retains only durable run identity, status, transcript, cancellation, and dependency wiring.
120119

121120
An explicit app-builder mode remains authoritative. In a projectless chat, a narrowly
122121
matched imperative such as “build a website” or “create a mobile app” also enters the matching
@@ -240,9 +239,8 @@ as every segment's logical timestamp. PostgreSQL publishes a run only when its u
240239
segment exists; retries compare each segment's JSONB, final marker, timestamp, and tenant
241240
identity. Oversized structured parts use lossless bounded fragment envelopes rather than
242241
truncation, and there is no transcript-length, step, token, or cost ceiling.
243-
Mastra tool-call chunks also emit `step_started`, `step_completed`,
244-
`tool_invoked`, and `skill_invoked` events when those chunks are present in the
245-
live stream. If the last stream subscriber disconnects while a run is still
242+
Checkpointed tool steps emit `step_started`, `step_completed`, `tool_invoked`, and
243+
`skill_invoked` events independently of the live stream. If the last stream subscriber disconnects while a run is still
246244
running, AgentRun emits `run_abandoned` for the funnel trail.
247245

248246
Project deletion first fences project/thread mutations, refuses an active run, records a

apps/agent-worker/src/durable-objects/abort-timeout.ts

Lines changed: 0 additions & 48 deletions
This file was deleted.

apps/agent-worker/src/durable-objects/agent-run-alarm.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { pendingStatusRetryAt } from "./agent-run-status-persistence";
44
import { getRunStateTimestamp, getRunStateValue } from "./agent-run-storage";
55
import {
66
AGENT_RUN_WORKFLOW_ADMITTED_KEY,
7-
AGENT_RUN_WORKFLOW_LEASE_EXPIRES_AT_KEY,
7+
AGENT_RUN_WORKFLOW_RECONCILE_AT_KEY,
88
AGENT_RUN_WORKFLOW_RETRY_AT_KEY,
99
} from "./agent-run-workflow-protocol";
1010
import { hasActiveRun } from "./run-state";
@@ -16,14 +16,14 @@ export async function armAgentRunAlarm(ctx: DurableObjectState): Promise<void> {
1616
return;
1717
}
1818
const isRunActive = hasActiveRun(getRunStateValue(ctx, "status"));
19-
const executionLeaseAlarm = isRunActive
20-
? (getRunStateTimestamp(ctx, AGENT_RUN_WORKFLOW_LEASE_EXPIRES_AT_KEY) ??
21-
Number.POSITIVE_INFINITY)
22-
: Number.POSITIVE_INFINITY;
2319
const admissionRetryAlarm =
2420
isRunActive && getRunStateValue(ctx, AGENT_RUN_WORKFLOW_ADMITTED_KEY) !== "true"
2521
? (getRunStateTimestamp(ctx, AGENT_RUN_WORKFLOW_RETRY_AT_KEY) ?? Number.POSITIVE_INFINITY)
2622
: Number.POSITIVE_INFINITY;
23+
const workflowReconcileAlarm =
24+
isRunActive && getRunStateValue(ctx, AGENT_RUN_WORKFLOW_ADMITTED_KEY) === "true"
25+
? (getRunStateTimestamp(ctx, AGENT_RUN_WORKFLOW_RECONCILE_AT_KEY) ?? Date.now())
26+
: Number.POSITIVE_INFINITY;
2727
const assistantMessageRetryAlarm = pendingAssistantMessageRetryAt(ctx);
2828
const statusRetryAlarm =
2929
assistantMessageRetryAlarm === Number.POSITIVE_INFINITY
@@ -32,7 +32,7 @@ export async function armAgentRunAlarm(ctx: DurableObjectState): Promise<void> {
3232
await ctx.storage.setAlarm(
3333
Math.min(
3434
admissionRetryAlarm,
35-
executionLeaseAlarm,
35+
workflowReconcileAlarm,
3636
assistantMessageRetryAlarm,
3737
statusRetryAlarm,
3838
nextAgentRunAlarm(Date.now()),

apps/agent-worker/src/durable-objects/agent-run-app-builder.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ interface RunAppBuilderOptions {
145145
// Harness workspace setup runs before the model streams; its progress is status
146146
// chrome (run stage / Computer panel), never visible answer prose. Anything the
147147
// model needs to know is handed to it as agentContextNote.
148-
setRunStage: (stage: string) => void;
148+
setRunStage: (stage: string) => Promise<void>;
149149
}
150150

151151
interface AppBuilderSetup {
@@ -219,10 +219,10 @@ async function prepareTemplateWorkspace(
219219
): Promise<void> {
220220
const { input, logger, sandbox, setRunStage, shouldBootstrap, workspace } = options;
221221
const mobile = workspace.mobile;
222-
setRunStage(mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace.");
222+
await setRunStage(mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace.");
223223
await ensureAppBuilderRuntime(sandbox, mobile);
224224
if (!shouldBootstrap) {
225-
setRunStage("Restoring the app workspace.");
225+
await setRunStage("Restoring the app workspace.");
226226
if (!(await hasInstalledAppBuilderDependencies(sandbox, workspace))) {
227227
await installAppBuilderDependencies(sandbox, logger, workspace.dir, mobile);
228228
}
@@ -243,14 +243,14 @@ async function prepareTemplateWorkspace(
243243
if (mobile) {
244244
await ensureExpoWebSupport(sandbox, workspace.dir);
245245
} else {
246-
setRunStage("Seeding the starter files.");
246+
await setRunStage("Seeding the starter files.");
247247
await writeAppBuilderFiles(input, sandbox, workspace.dir);
248248
}
249249
}
250250

251251
async function startTemplatePreview(options: WorkspaceOptions): Promise<void> {
252252
const { append, logger, sandbox, setRunStage, workspace } = options;
253-
setRunStage("Starting the dev server.");
253+
await setRunStage("Starting the dev server.");
254254
if (workspace.mobile) {
255255
await startExpoDevServer(sandbox, logger, workspace);
256256
} else {
@@ -274,7 +274,7 @@ export async function restartMobilePreview(
274274
options: Pick<RunAppBuilderOptions, "append" | "input" | "logger" | "sandbox" | "setRunStage">,
275275
): Promise<void> {
276276
const { append, input, logger, sandbox, setRunStage } = options;
277-
setRunStage("Reloading the preview.");
277+
await setRunStage("Reloading the preview.");
278278
const workspace = await resolveAppWorkspace(sandbox, input, logger);
279279
await startExpoDevServer(sandbox, logger, workspace);
280280
await append({
@@ -299,7 +299,7 @@ async function importRepoWorkspace(
299299
throw repoImportError("The import URL must be a public https github.com repository.");
300300
}
301301
logger.info("repo_import_started", { repoHost: repoRef.host, repoPath: repoRef.path });
302-
setRunStage(`Cloning ${repoRef.path}.`);
302+
await setRunStage(`Cloning ${repoRef.path}.`);
303303
await resetTemplateAppBuilderDirectory(sandbox, workspace.dir);
304304
throwIfRunCanceled(options.abortSignal);
305305
const cloneDir = `${workspace.dir}/.cheatcode-import-${input.runId ?? crypto.randomUUID()}`;
@@ -350,7 +350,7 @@ async function restoreImportedWorkspace(
350350
options: WorkspaceOptions,
351351
): Promise<{ agentContextNote: string }> {
352352
const { append, input, logger, sandbox, setRunStage, workspace } = options;
353-
setRunStage("Restoring the imported workspace.");
353+
await setRunStage("Restoring the imported workspace.");
354354
await installImportedDependencies(sandbox, logger, workspace.dir);
355355
throwIfRunCanceled(options.abortSignal);
356356
await clearBuildCache(sandbox, workspace.dir, workspace.mobile);

0 commit comments

Comments
 (0)