OpenProgram Docs

Turn occupancy — stop, queue, and the session slot#

A session admits one live turn. Typing while it runs is a queue, not a second turn. Stop is an interrupt: occupancy is released on cancel intent, not when the old turn thread dies.

Related: interaction-feedback.md (0ms UI), send-queue-reliability.html (queue mechanics). Code: use-chat-submit.ts (stopSession), server.py (_finish_owned_run / _try_reserve_run), execution/control.py (RuntimeControlService.request_cancel), run_control.py (CANCEL_GRACE_S), providers/utils/cancelable_stream.py.

Invariants (from Codex CLI and Claude Code)#

The reference implementations agree on five rules; OpenProgram follows them. Codex CLI: cancellation authority lives on the core session's running turn; UI running-state derives only from TurnStarted / TurnCompleted / TurnAborted; there is no protocol-level cancelling state. Claude Code: ESC flips a synchronous state machine (QueryGuard) to idle in the same frame, commits partial streamed text as a normal assistant message, then aborts; an interrupted turn has no intermediate status.

  1. One occupancy source of truth per side. Client: runningTasks in the session store, mirrored to the composer. Server: _running_tasks plus the canonical execution driver. Message status renders bubbles; it never gates the composer, and the composer state never gates bubble rendering.
  2. Terminal states are irreversible. Once a message is cancelled / completed / failed / error / interrupted / done, no later frame (execution.updated with cancelling, late running_task, late tree_update) may move it back to a running look. cancelling is a server-internal grace state; the client treats it as already-cancelled and never stores it on the running task or renders it as "thinking".
  3. Placeholders never overwrite identity. An empty { msg_id: "" } occupancy reservation may only be written into an empty slot. A slot holding msg_id / execution_id is what makes stop able to send execution.cancel.
  4. Only the turn's own terminal frame releases occupancy. A nested display:"runtime" result (inline Agent method, spawn attach) finalizes its card, not the turn. Late running_task frames for a turn the user already cancelled must not revive the slot.
  5. Stop never fails silently. If the server cannot resolve an execution to cancel it answers with an error frame (the HTTP route's 404 equivalent), so a dead stop is visible instead of a turn that keeps streaming while the client already dropped occupancy.

Queue vs interrupt (Claude Code)#

Claude Code: type+Enter while a turn is running = queue. Esc/stop interrupts the live turn and then immediately sends what was queued.

Vercel AI SDK: stop aborts the request now and forwards abort to the model. Do not wait for the next token.

OpenProgram copies both. The send queue already parks a typed message while runningTask is set. Stop must:

  1. Send execution.cancel for the canonical execution.
  2. Patch the live assistant to cancelled (keep streamed text).
  3. setRunningTaskFor(sessionId, null, "always") so the queue drains at 0ms.

Leaving cancelling: true on the running task, or drain: "never", locks the composer and waits for running_task_clear. That is the old bug.

Occupancy is released on cancel intent#

The session slot is _running_tasks, and the canonical driver owns the active attempt. The transport slot is not a second lifecycle authority.

When cancel_execution succeeds:

  • Match the admitted execution_id to session_id + msg_id.
  • Call _finish_owned_run(session_id, msg_id) to remove the transport slot.
  • Broadcast running_task_clear so every client matches. The clear names the finished turn (msg_id / execution_id).

A late clear or cancelled/result frame for the old turn must not idle a newer reservation, including the just-sent placeholder { msg_id: "" }. Honor the clear only when it matches the slot's execution. An unscoped clear is stale after stop-and-send.

_finish_owned_run no-ops if msg_id does not match — a newer reservation is safe. The cancelled turn's finally also calls _finish_owned_run; the second call is a no-op.

The canonical Agent driver owns the cooperative cancel event and exact attempt generation. Transport reservations do not claim a run_control token or derive an execution identifier from {msg_id}_reply.

Do not wait for the old driver thread to return before the next _try_reserve_run.

0ms UI#

The 0ms rule already says: Stop clears runningTask and patches the assistant at 0ms. Stop also drains the send queue at that same instant.

Late running_task / stream_event frames for the cancelled execution_id must not revive the task or append more tokens. The message status cancelled (or cancelling) is the guard.

Once runningTask is null, showStop / isCancelling are false and send is enabled. That is intended. Do not disable send for the old unwind.

HTTP abort — do not wait for the next token#

Anthropic and OpenAI Completions used to async for the SDK stream and only then check the cancel signal. Mid-reasoning that is seconds.

iter_until_cancelled polls the cancel signal every ≤250ms while one __anext__ stays outstanding. Do not wait_for the next chunk: a timeout cancels that read and kills httpx/OpenAI SSE iterators. Grok thinking often has no chunk for longer than 250ms; that finalized a completed assistant with empty content. On cancel it exits so the async with closes the HTTP stream.

finish_reason is the end of the turn. Do not wait forever for [DONE] or a trailing include_usage chunk — Grok/xAI often leave the SSE open after the last token, which looks like "still answering" with the text already on screen. Drain usage for a short window (USAGE_DRAIN_S), then emit EventDone.

4s grace is for real child processes#

CANCEL_GRACE_S = 4.0 stays. It applies when the owner has a subprocess or a terminate hook that kills a child (tools, process runners). Token-only chat owners — no process, no child-killing terminate — must not wait another 4s after cancel intent. Cooperative cancel + HTTP abort is enough; occupancy is already released.

Last updated · 2026-10-10