Skip to content

A stream end always records the run's status (CP-87) ​

This release makes the end status of every agent stream explicit. Before it, StreamManager.endStream's terminal argument was optional, and every reader that found no recorded status guessed completed. A run that was interrupted (or, on DBOS, a cancelled workflow's failed run) could therefore reach a cross-service parent as end{completed} with null output, and the parent folded it as a successful child.

It is a minor release (a 0.x package: minor marks an observable behaviour change) of @helix-agents/core, @helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-cloudflare, @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos and @helix-agents/agent-server.

endStream requires the run's status ​

typescript
// Before
endStream(streamId: string, finalOutput?: unknown, terminal?: {...}, fence?: StreamRunFence): Promise<void>;

// After
endStream(
  streamId: string,
  finalOutput: unknown,
  terminal: StreamEndTerminal, // { status: 'completed' | 'interrupted'; state?; usage? }
  fence?: StreamRunFence
): Promise<void>;
  • If you call endStream yourself, pass the ended run's own status: endStream(streamId, output, { status: 'completed' }), or { status: 'interrupted' } for a stopped run. A run that failed is never ended: call failStream with its error detail.
  • If you implement a custom StreamManager, refuse an end with no valid status (assertStreamEndTerminal from @helix-agents/core throws a typed validation_error), persist terminal.status as StreamMetadata.endStatus on every end, and return it from getStreamInfo. The store contract (@helix-agents/core/testing) checks both (ES.end-status-required, ES.end-status-recorded). An untyped caller that omits the status now gets that validation_error from every built-in manager instead of a stream with no recorded status.
  • state and usage stay optional: a caller attaches them only when its own terminal persist committed.

Readers never guess completed ​

Core's resumable stream handler (the wire v2 terminal frame), AgentServer.streamAgent, the Cloudflare Durable Object's own /sse frame and @helix-agents/store-cloudflare's stream Durable Object read the recorded status. A stream that ended with no recorded status is a contract violation. It is answered as a retryable transport_stream_drop (core.streamDropTerminal) and logged at error as core.stream.end_status_missing ({ streamId }), so a StreamManager that drops the status shows up in your logs instead of reporting success. createResumableStreamHandler takes an optional logger for that line.

Behaviour changes by runtime ​

WhereBeforeAfter
Temporal, CF Workflows: an interrupted run's streamno status (read as completed)interrupted
DBOS: an interrupted run's stream (settle, entry restore, workflow)no status (read as completed)interrupted
DBOS settleCancelledRun(…, 'failed')ended cleanly (read as completed)failed with a framework_cancelled error detail
runtime-js: a completion whose terminal persist did not commitno status (read as completed by luck)completed, with no state / usage
Core stop / reconcile / revived-stream ends (every runtime)no statusthe run's own status
CF DO /sse late joiner, store-cloudflare stream DO /end, /infoa missing status was written / read as completed/end without a valid status is refused 400 validation_error; a missing one reads as none

The Cloudflare DO's reconcile wrapper keeps the caller's status and still adds the DO's committed state and usage to a status-only end, so a wake-finished run's end frame carries the same payload as a driven run's.

Released under the MIT License.