A DBOS persistent turn is a turn (CP-69, CP-124, CP-149)
This guide covers the release in which a DBOS persistent session reads each turn's outcome, instead of reading active until its receive loop idles out. It is for people who run persistent agents or persistent companions on DBOS.
Versions: @helix-agents/runtime-dbos takes a major. Every change below is a behaviour you can observe. Each one brings DBOS into line with the other runtimes and the documented companion contract. If your code relied on the old DBOS answers, it needs the changes below.
What changed
Each persistent turn now writes its terminal outcome to the session: completed, failed, or interrupted after a stop. The receive loop itself stays alive until its persistentIdleTimeoutMs, exactly as before. Previously the session stayed active until the loop idled out, which is 24 hours by default.
| Before | Now |
|---|---|
A persistent session reads active until its loop exits | It reads its last turn's outcome; the loop's liveness is recorded separately |
A persistent root's handle and execute() result wait for the loop to exit | They read the last turn's outcome. A follow-up execute() still goes to the live loop |
A blocking companion__spawnAgent returns after the child's idle timeout | It returns when the child's turn ends, with that turn's outcome |
A stopped companion turn is answered terminated | It is answered interrupted, which is resumable |
companion__waitForResult on a stopped turn times out | It answers { status: 'interrupted', message } at once |
companion__sendMessage to a companion between turns may answer child_exited | It runs as a new turn on the live loop. A companion whose loop has exited is continued on a restart workflow, as before |
A blocking turn leaves the parent's ref running | The ref records the turn's outcome, so getChildStatus and a re-consult by name read it |
A re-spawn of a failed / terminated companion reuses its session; no turn runs | It runs fresh, on a new session (no history, no state) and a workflow of its own, after the previous loop has exited |
abort() / interrupt() of a loop idling between turns leaves the session as is | The loop is stopped, its exit is recorded, and the session reads failed / interrupted, replacing its last turn's outcome |
What to change
- Don't read a persistent session's status as "the loop is alive". A session that reads
completedmay still be served by a live loop: a follow-upexecute()goes to that loop. If you usedactiveto mean "still serving", stop. The runtime records the loop's exit itself (in the session'sdbosLoopFinalizedmetadata) and routes on it; treat that field as internal. - Expect a persistent root's handle to settle per turn.
handle.result()and anexecute()result now report the turn that just ended. Code that awaited a persistent root's result as "the conversation is over" must use its own end condition, or callabort()/interrupt(). - Read companion tool results per turn. A blocking spawn's result arrives at the turn's end. A stopped turn is
interrupted, neverterminated. Prompts, parsers or tests that expectedterminatedfor a stop, or that relied on the long wait, need updating. terminateChildafter a blocking turn completed is a no-op. The companion has already completed, so the answer is core's{ terminated: false }and the ref keepscompleted. Terminating a companion whose turn is still running is unchanged.- A re-spawn of a failed or terminated companion starts from scratch. It sees only its new message and none of the old session's state. If its previous loop does not exit in time, the spawn answers a retryable
not_spawned(child_busy); retry it. - An idle-loop stop can now fail retryably. If neither the shutdown signal nor the cancel reaches the loop,
abort()/interrupt()reject with a retryabletransport_errorand change nothing; retry the call. A turn that starts during the stop is stopped as a live run. In the rare case a second turn starts while that turn is being settled, the call rejects with a retryablestate_concurrency_conflict. - Custom forks only: the internal
skipSessionStatusUpdate/keepSessionStatusoptions are removed.
Still open
A parent's stop does not yet reach a blocking companion's live turn on DBOS (CP-23's DBOS half). The companion's ref and session keep their previous outcome after the parent's interrupt().