Skip to content

CF Durable Object stream reader and client (WE MR B) ​

This guide covers the release in which every Worker-side reader of a Cloudflare Durable Object's /sse goes through one decoder and one reader, every DO handle reports a failed stream the same way, and DOWorkflowExecutor is removed. It is a major release of @helix-agents/runtime-cloudflare and a minor (0.x) release of @helix-agents/core. Nothing is deprecated: the removed API is deleted.

Design: 2026-10-10-do-sse-unified-reader-design.md. Runtime page: Cloudflare Durable Objects.

DOWorkflowExecutor is removed ​

DOWorkflowExecutor and DOWorkflowExecutorConfig are deleted (the user's ruling, 2026-10-11: "delete it if we don't need it. don't worry about backwards compatibility"). It never reached a Durable Object: every request used a relative URL (workerd refuses it: TypeError: Fetch API cannot load: /start), none sent the x-partykit-room header partyserver needs, and it addressed idFromName('session:' + sessionId), an instance no other client of the session used. Its /start and /retry bodies also lacked the agentType the DO requires.

RemovedUse instead
new DOWorkflowExecutor({ agentNamespace, createLLMAdapter })new DOFrontendExecutor({ namespace, agentName }) (or the chat route, createCloudflareChatHandler, which builds one)
execute / getHandle / resume / submitToolResultthe same methods on DOFrontendExecutor (resume takes DoResumeOptions: continue, with_message, from_checkpoint)
retry(agent, sessionId, options)the DO's POST /retry route, or a new turn through the chat route
handle result() / getUsage() / getUsageRollup()the DO's GET /status and GET /usage?mode=self|rollup|entries routes (see Usage tracking)

The structural binding types DurableObjectNamespace, DurableObjectId and DurableObjectStub are unchanged and still exported (they moved to do/durable-object-types.ts). There is no data to migrate: nothing could ever have been written under the session: ids.

A failed stream on the DO handle is an error chunk, not a throw (DI-43) ​

DOFrontendExecutor's handle stream() used to reject the iteration with Error('Stream failed: …') (and drop the failure's errorDetail) when the run failed, both for a stream that had already failed and for one that failed while read. It now ends like every other runtime's handle (the user's decision, 2026-10-09): ONE error chunk carrying the run's ErrorDetail byte for byte (recoverable: false; not repeated when the run already wrote that failure in-band), then the iteration ends.

typescript
// Before (CF DO only)
try {
  for await (const chunk of (await handle.stream()) ?? []) render(chunk);
} catch (error) {
  if (error instanceof Error && error.message.startsWith('Stream failed:'))
    showFailure(error.message);
}

// After (every runtime)
for await (const chunk of (await handle.stream()) ?? []) {
  if (chunk.type === 'error' && !chunk.recoverable)
    showFailure(chunk.errorDetail ?? { message: chunk.error });
  else render(chunk);
}

DOStreamManagerClient reads like every other stream manager (DI-41) ​

  • createResumableReader returns null for a stream that has already failed (unless waitForLive). On createCloudflareChatHandler a resume of a failed run therefore answers the typed 409 stream_failed (as the JS host does) instead of a 200 that ended in finish.
  • A stream that fails while it is read ends with one error chunk carrying the frame's detail, then done (it used to end cleanly, dropping the error). A live attach whose run fails now ends the chat response with an error event.
  • A TRUNCATED frame (a cursor below the DO's retention floor) throws StreamTruncatedError instead of ending cleanly: refetch the snapshot and resume from its streamSequence.

/sse and entry-route failures are typed (DI-47) ​

CaseBeforeAfter
/sse answers 404nullnull
/sse answers another non-2xxnull / a clean enda typed error: the body's code when typed, else transport_error (retryable on a 5xx)
the /sse request fails, or a frame is malformeda clean end / the frame skippeda retryable transport_error
/start, /resume, /submit-tool-result untyped answera plain Errortransport_error, <route> answered <status> with an untyped body, the body (≤ 512) its cause

Match on code, never on the message. A body the DO sends typed ({ error, code }) keeps its code as before.

waitForLive reaches the DO (RM-73) ​

DOStreamManagerClient.createResumableReader(streamId, { fromSequence, waitForLive: true }) sends /sse?…&waitForLive=1, and the DO parks the request until the stream is live (the in-DO DOStreamManager's own rule), writing a : waiting comment first. A chat-route resume in a new run's start window now waits for that run (or answers 503 run_not_live after liveWaitTimeoutMs), as on the JS host, instead of replaying the previous turn's tail and end. No frame changes; : waiting is an SSE comment, which an EventSource ignores.

DOStreamManagerClient.getStreamInfo now reports the stream's own status. It used to report the DO /snapshot's run-derived status, under which a live run reads active even while its stream still shows the previous turn's ended / failed status. The chat route took that for a live stream and attached without waiting, so the wait above was never reached on the DO while a run was live. /snapshot carries the stream's own status as streamStatus ('active' | 'paused' | 'ended' | 'failed' | 'none', read in the same transaction as the view), and getStreamInfo answers null for 'none', as every other stream manager does for a stream that does not exist. So a reload in a brand-new session's start window (the run committed, its stream not created yet) now waits for the run too, where it used to attach to a stream that did not exist. If you read getStreamInfo().status to decide whether a run is live, read the run instead (stateStore.getCurrentRun, or the snapshot's status / run).

Deploy the Worker and the DO together. An older DO ignores waitForLive=1 (the old behaviour) and sends no streamStatus, which a newer Worker's client refuses; a newer DO with an older Worker is unaffected (the parameter is never sent, and the extra field is ignored).

Released under the MIT License.