Skip to content

Agent turns as normal chat replies (ELI-100, phase 1 design) ​

Status: implemented (ELI-100). This is the historical plan at 787a471: most symbols it names (_GapRepairSink, _heal_proactive_variants, PROACTIVE_DELIVERY_ENABLED and others) are gone. Current behavior: Agent turns and the code. Baseline: main 787a471. Every file:line below is in src/openclaw_pipe_pkg/ at that commit unless it names a dist file (OpenClaw 2026.9.3, node_modules/openclaw/dist/) or an Open WebUI file (0.12.0, container /app/backend/open_webui/ and /app/build/_app/immutable/chunks/). ELI-95 is folded in (section 5).

0. The design in one paragraph ​

A run on an OWUI chat's session key gets exactly one assistant message: the message whose id follows from the run id. For a run the pipe started from a user message, that id is already in the run id (msg-<chat>-<message>-<t>, pipe.py:1206). For a run the pipe did not start (cron, sessions_send, sub-agent completion, restart recovery), the id is uuid5(NS, "<session_key>:<run_id>"). The pipe learns that a run started from the Gateway's lifecycle push, not from a quiet period. It inserts the message at the chat's true tail inside Open WebUI's own row-locked chat transaction, so a second connection or deploy that tries the same run finds the message already there and backs off. It tells open tabs about the message with Open WebUI's own chat:messages event, which moves their view to it. It streams the same Responses items the inline path streams, and on the run's terminal event it finalizes from the run's own transcript by run id. There is no header, no debounce, no idle clock, no reload nudge, and no later rewrite. Whoever started the run, the same renderer, writer and finalizer handle it.

1. Inventory (main 787a471) ​

Verdicts: delete, fold (the behavior moves into the single path, the code goes), keep (with the reason given).

1.1 Code paths ​

#PathWhereVerdict
P1Post-idle proactive delivery, event-loop branchgateway.py:2630-2676delete
P2_maybe_deliver_proactive_after_debounce (120 s idle, 5 s poll, 600 s give-up)gateway.py:3036-3087delete
P3_deliver_proactive_owui_message (*↳ Proactive message* label, gateway.py:4299)gateway.py:4255-4320fold: text and cards come from the transcript by run id (P11, P12), and the write goes through the single writer (section 2.4)
P4Sub-agent delivery, event-loop branchgateway.py:2678-2705delete
P5_maybe_deliver_subagent_proactive (same 120/5/600 timers)gateway.py:4334-4382delete
P6_deliver_subagent_proactive_owui_message (*↳ Sub-agent finished*, taskId marker)gateway.py:4385-4429delete. The completion reaches the parent's session as its own run (section 3, case 8), which the single path shows. Posting the child's raw text as well shows the same result twice.
P7resolve_subagent_parent_task, is_subagent_session_keygateway.py:1912-1962delete (P6 is their only caller)
P8_append_proactive_message_to_chatgateway.py:3234-3334fold into the single writer's insert (section 2.3). Anchoring on the deepest leaf, writing the parent's childrenIds first, and inheriting the model are kept; the process lock becomes the DB transaction.
P9_emit_live_bootstrap_reload (location.reload() on the old leaf) + LIVE_STREAM_BOOTSTRAP_ENABLEDgateway.py:3345-3398delete. Replaced by Open WebUI's chat:messages event (section 2.3).
P10Live relay: LIVE_STREAM_RELAY_ENABLED, _RelayState, _relay_begin/_introduce/_feed_event/_finalize/_finalize_settled/_abort, event-loop branchesgateway.py:3425-3837, 2553-2590, 1540-1551, 1626-1629fold. This is the single path's streaming core; the gates (idle clock, _delivered_proactive) go.
P11_proactive_run_text (preview + clipped-preview completion + newest-reply fallback)gateway.py:2945-3008fold: the run's text comes only from _history_text_for_run by id. The preview/newest-reply fallbacks go, because they are what delivered a later run's text for an earlier run (2026-10-12).
P12_proactive_run_outputgateway.py:3011-3033fold (no label argument): it becomes "finished output of run R" from _history_run_messages + _history_renderer (gateway.py:2875-2942, keep).
P13_last_assistant_text_from_preview, PROACTIVE_HISTORY_LIMIT, _preview_clip_prefix, _history_text_completing_previewgateway.py:2763-2837delete (callers are P11 only)
P14_is_proactive_message, _relinearize_proactive_variants, _heal_proactive_variants, call in pipe.py:971-974gateway.py:3118-3231delete from the pipe. The logic moves to a one-off migration script outside the pipe (section 4).
P15_deepest_leaf_idgateway.py:3090-3115keep (the writer's anchor)
P16Parity finalize _finalize_inline_message, event-loop branchgateway.py:4170-4252, 2609-2628fold: "the inline reply ended before its run" is a handover to the single writer for the same message id (section 2.6). Its fallback that appends a new tail message goes.
P17register_run_target / unregister_run_target / _run_targets / _run_renderers / seed_run_renderergateway.py:1635-1655, 1496-1507, 2174-2193; pipe.py:1289, 1665, 2446, 2529fold: one run table keyed by run id that renders every run on an OWUI key (section 2.2)
P18mark_delivered_live / was_delivered_live / _delivered_livegateway.py:1821-1835, 1432; pipe.py:2445, gateway.py:2072, 2601delete. "Was this run shown" becomes "does the run's message exist", a DB fact.
P19has_any_consumer_for_session, session_idle_for, _touch_session_activity, _session_last_activitygateway.py:1847-1891, 1814-1819, 1453delete. has_any_consumer_for_session is used only by gates that go.
P20Orphan stub reconcile (ELI-90): _stub_turn, _orphan_stub_for_run, _reconcile_stub, _reconcile_orphan_stubs, _ensure_orphan_reconcile, notesgateway.py:3840-4167; pipe.py:369, 859keep, re-keyed: a stub is the message of a run by id. It runs on the same writer, and it also covers runs that ended while OWUI was down (section 3, case 4).
P21Gap repair (ELI-88/PR #17): _align_whole_text, _gap_repair_*, _GapRepairSink, _mark_event_gapgateway.py:629-836, 2250-2270keep. It is the inline path's repair. Agent turns get the same result by finalizing from the transcript.
P22Retire/reap of older deploys' connections (PR #14)gateway.py:2214-2230, 4445-4546keep. The DB claim (section 2.3) makes a leftover connection harmless even if one survives.
P23action.py switches PROACTIVE_DELIVERY_ENABLED = False, LIVE_STREAM_RELAY_ENABLED = False and their commentaction.py:36-52delete. The Action's connection must not run the writer; that becomes a constructor flag (_GatewayConnection(writer=False)), not a module constant.
P24Action: sub-agent task-id marker click redirectaction.py:1213-1225, _SUBAGENT_TASK_ID_MARKER_RE gateway.py:4331keep as the documented read-only compatibility shim for the 33 old ↳ Sub-agent messages (section 4); nothing new writes the marker
P25PROACTIVE_DELIVERY_ENABLED = True (gateway.py:1249) and its history commentgateway.py:1225-1249delete
P26_SILENT_SENTINELSgateway.py:2760keep (a run that says only NO_REPLY gets no message)
P27Inline idle close on sessions.describe statuspipe.py:2235-2269, gateway_run_status pipe.py:1422-1446delete. This is the suspect for "reply closed mid-run" (ELI-88 repro 1): describe.status reads done during a sub-agent yield gap (gateway.py comment at 1073-1079, memory gateway-active-run-signal). A reply ends only on its run's terminal (section 2.5).
P28Restart resume directive resume_after_restartpipe.py:1580-1675keep, re-keyed and re-triggered (M3 window 2026-10-12 23:54Z, manager decision 1). Key msg-<chat>-<M>-resume-<t> so the identity rule (2.2) puts it in M. It fires on the owner rule (2.7), not on _is_restart_signature (pipe.py:66-76, any process exited with code, connection closed) plus the 30 s idle probe and epoch bump (pipe.py:1696-1720, 1888-1891). It covers agent turns too (2.7).

1.2 Flags, shared maps, locks ​

ItemWhereVerdict
PROACTIVE_DELIVERY_ENABLEDgateway.py:1249, action.py:48delete
LIVE_STREAM_RELAY_ENABLEDgateway.py:3425, action.py:52delete
LIVE_STREAM_BOOTSTRAP_ENABLEDgateway.py:3345delete
shared delivered_proactivegateway.py:1292, 1418delete (DB claim)
shared delivered_livegateway.py:1291, 1432delete
shared session_last_activitygateway.py:1293, 1453delete
shared pending_proactive_debouncegateway.py:1294, 1466delete
shared proactive_runsgateway.py:1295, 1473delete
shared last_final_rungateway.py:1296, 1479, 2565-2566delete (fed only the newest-reply fallback)
shared chat_write_locks / _chat_write_lockgateway.py:1297, 1492, 1657-1664delete. Open WebUI's _chat_transaction (models/chats.py:400-407: BEGIN IMMEDIATE on SQLite plus with_for_update) serializes writers across connections, deploys and processes.
_relay_sessionsgateway.py:1516fold into the run table
_SHARED_STATE_ATTR / _shared_gateway_stategateway.py:1268-1298delete once the maps above are gone. Nothing left needs process-wide memory except the writer registry (section 2.3), which sits on the same open_webui.socket.main anchor.

1.3 Timers ​

See section 5 for every timer and its decision.

1.4 Tests (test_pipe_unit.py unless named) ​

Class (line)CoversVerdict, old -> new
ProactiveDeliveryFullTextTests (332)clipped preview completed from historyreplace: RunTextTests, the run's text by id only. The clipped-preview cases go with P13.
ProactiveDeliveryTests (539)label, dedup, adopt, tool cardsreplace: AgentTurnWriterTests (no header, cards from the transcript, one message per run id)
LiveBootstrapReloadTests (971)reload nudgereplace: IntroduceEventTests (chat:messages payload shape, room, currentId)
LiveRelayTests (1093)relay streaming/finalizekeep scenarios, retarget to the writer
RelayOutputEventTests (1822)_relay_output_eventskeep (renamed with the function)
EventConsumerMatchingTests (1973)consumer routing + proactive gateskeep routing cases, drop gate cases
ProactiveDebounceWatcherTests (2092)debouncereplace: AgentTurnStartTests, start keyed on sessions.changed phase start and on seq-gap recovery
ParkedCancelTests (2296), NoDroppedMessagesTests (2466)Send now / queuekeep unchanged (user-turn path)
SubagentSessionKeyTests (2985), ResolveSubagentParentTaskTests (3007), SubagentProactiveDeliveryTests (3100), MaybeDeliverSubagentProactiveTests (3220)P4-P7replace: SubagentCompletionTests, the announce run on the parent key renders as an agent turn, and the child's own final renders nothing
SubagentTaskIdMarkerRegexTests (3341)marker regexkeep (shim P24)
ParityFinalizeTests (3694)P16replace: HandoverTests, the inline reply ends early and the writer finishes the same message
RelinearizeProactiveVariantsTests (3804)P14move with the migration script (section 4)
OrphanStubReconcileTests (1590)P20keep, add the "ended while OWUI was down" cases
GatewayReloadReapTests (4121), SharedProactiveStateAcrossConnectionsTests (5955)redeploy/zombiereplace: TwoConnectionsOneMessageTests, two connections race one run id and produce one message
test_build.py:223 test_proactive_delivery_disabled_for_fallback_connectionaction flagreplace: the Action's connection is built with writer=False
test_status_action_unit.py:869 SubagentProactiveMessageClickTestsshimkeep
SessionToolEventTests (1382), SessionActiveSignalsTests (2959)session.tool, run statekeep

Docs: README.md (11 mentions) and docs/rehydration-persistence.md (1) get rewritten in phase 2.

2. The single path ​

2.1 Start signal (gateway, not timers) ​

Every agent run goes through one handler, createAgentEventHandler (dist server-chat-CbnqM5f6.mjs:238). On lifecycle start it pushes, to every connection that called sessions.subscribe:

sessions.changed {sessionKey, phase:"start", runId, clientRunId?, ts,
                  ...snapshot: hasActiveRun, activeRunIds, status}

(server-chat-CbnqM5f6.mjs:1015-1035; snapshot fields from session-event-payload-6u50DozL.mjs:150-160). The push does not depend on who started the run: chat.send, the agent method used by sub-agent announces (subagent-announce-delivery-CWxOCC67.mjs:672), cron, and restart recovery all reach it. Only runs flagged projectSessionLifecycle:false skip it, and those are internal runs that must not be shown anyway (memory flush agent-runner-memory-B2QTx17z.mjs:981, experience review experience-review-CWxdWP7a.mjs:108). chat.run.started (chat-send-handler-kZQfUabO.mjs:3070) is not the signal, because only chat.send emits it.

The pipe already subscribes (gateway.py:1530-1535) and already handles sessions.changed (gateway.py:2517, 1618). Phase 2 adds: on phase:"start" for an OWUI key (parse_owui_session_key, gateway.py:1893), open a run entry for clientRunId or runId. The start push alone misses runs that start before the pipe is subscribed, for example restart recovery at boot. The handshake below covers them.

A dropped push is detectable. sessions.changed is sent with dropIfSlow:true. The broadcaster still spends a sequence number on a dropped event (server-start-BB-IxTAg.mjs:3192-3207: clientSeq.set(c, nextSeq); continue), and every event frame carries it (frameWithSequence, :3060-3061). The pipe tracks the frame seq. A gap means at least one event was dropped, and the pipe re-reads sessions.list (activeRunIds) and opens entries for runs it does not know. A non-droppable event that overflows closes the socket with 1008 "slow consumer" (:3209-3214), which takes the reconnect path. Today the pipe ignores the frame seq entirely.

Every hello-ok subscribes and lists in one request (M3-crash). In the crash window (2026-10-13 00:33-00:37Z) the pipe never saw the recovery runs' phase:"start". The Gateway starts recovery at boot, before any client has connected and subscribed. The watcher saw only their chat final and phase:"end". So the start push is not enough on its own:

  1. On every hello-ok the pipe sends sessions.subscribe with list params. The handler registers the subscriber first (context.subscribeSessionEvents(connId), sessions-subscriptions-CeMquS--.mjs:21-22) and then runs sessionsListHandler in the same request (:27-36). The reply is the list: subscribe, then list, with no gap between the two calls.
  2. A run that starts around the subscribe is caught by one of the two.
    • The start push and activeRunIds both come from the same function, resolveVisibleActiveSessionRunState (session-active-runs-BURU1M8_.mjs:62). The push reaches it through resolveSessionActiveRunState (server-runtime-subscriptions-B1FHK14L.mjs:1401); sessions.list uses the projector built on it (session-active-runs-BURU1M8_.mjs:95, sessions-read-CtBfOtH4.mjs:576-597).
    • Every start push recorded carries hasActiveRun: true (2 of 2 in the crash window, runs/m3-watch-window2-crash.jsonl). So a run is visible in activeRunIds no later than its start push is built: t(visible) <= t(push).
    • Push after the registration: the pipe gets the push. Push before the registration: t(visible) <= t(push) < t(register) < t(list). The run is therefore in the list, unless it has already ended.
    • Measured live (2026-10-13 ~01:00Z): for claude-cli runs the list gives hasActiveRun: true but no activeRunIds. The function omits runIds whenever the session has an embedded run state (session-active-runs-BURU1M8_.mjs:90). So the guarantee is "the session has an active run", not its id. The pipe records the session as active-unknown and opens the run's message on the run's first event, which carries its runId: chat deltas and finals reach every connection (the M3 watcher saw other sessions' finals). Rule E then maps that id to M.
    • No inference from the transcript (manager note 1). A queued follow-up runs with no transcript turn at all: 04351f67 ran after recovery f2445aa8 and left nothing, checked live. "Run R still active" and "R failed unclosed, and a queued follow-up F is active" therefore produce the same transcript. The pipe does not guess, so nothing is opened before the first event (test_the_transcript_cannot_name_the_active_run).
  3. A run that ended before the list (for example a short recovery) is covered by the ended-while-away rule, case 4: the session's updatedAt is newer than the chat's updated_at, so chat.history is read, and every closed cli-assistant:<R> without a finished message gets one. A recovery run maps to its source message through rule E. The session row is bumped when a run ends: M2 measured this, and in the crash window the agent session's updatedAt 1791851832978 is at or after the recovery's closing message cli-assistant:8f27e4c8 at 1791851832894. So a recovery that ended before the pipe reconnected still gets its message, without a start push.

2.2 Identity: which message belongs to run R ​

message_id(R) = M                        if R == "msg-<chat36>-<M36>-<suffix>"  (pipe-started)
              = uuid5(NS, f"{key}:{R}")   otherwise                               (agent turn)
no message                                 if R starts with "owui-action-" (Status Action's /compact)

Every pipe-started run must carry its reply id. Today two do not: user-input-{chat}-{t} (pipe.py:1561) and resume-{chat}-{t} (pipe.py:1635). They become msg-<chat>-<M>-input-<t> and msg-<chat>-<M>-resume-<t>. The Action's compact-{chat}-{t} (action.py:1512) becomes owui-action-compact-..., a prefix that never gets a message; ELI-98 may later turn it into a separator. With this rule, any connection, deploy or restarted process can map a run to its message from the run id alone, without shared memory.

One run table per connection, runs[(session_key, R)], holds a _TurnRenderer fed by every event of R (as _run_renderers already does, gateway.py:2536-2549) and records who is writing that message:

  • inline: a live pipe() request in this process holds R's consumer and streams through Open WebUI's own request (unchanged user-turn path).
  • writer: this connection's writer task writes M (agent turns, and user turns whose request ended before the run).
  • none: not claimed here.

2.3 Claim and anchor: one message per run, at the tail ​

The claim lives in the DB, not in a process map. Inside Chats._chat_transaction(chat_id) (models/chats.py:400-407, row lock) the writer:

  1. If history.messages[M] exists, the run is already claimed, so it stops. The exception is a handover of its own reply (2.6), keyed by meta.openclaw_writer, below.
  2. Otherwise it takes leaf = _deepest_leaf_id(messages, currentId) (gateway.py:3090) and writes M = {role:"assistant", parentId:leaf, childrenIds:[], content:"", output:[], done:false, model:<leaf's model>, timestamp, meta:{task_id, openclaw_run:R, openclaw_writer:<conn id>}}, appends M to leaf.childrenIds, and sets currentId=M. It also mirrors the chat_message row (ChatMessages.upsert_message), as insert_chat_turn does (models/chats.py:1297-1320).

Two connections, an old deploy and a new one, can race the same R. The row lock serializes them, and the second finds M and backs off. Two different runs that start together each anchor on the tail they read under the lock, so they chain and never become siblings. That is the variant-group cause P14 was healing, removed at the source. openclaw_writer plus a process-wide registry of live connection ids (on open_webui.socket.main) lets a writer take over a message only when its recorded writer is no longer alive.

When (condition B). M is created on R's first showable item, not on phase:start. Showable means either of these, whichever comes first:

  • a tool call start (stream:"tool", phase:"start"), so an agent turn that runs tools for minutes shows its cards live from the first one;
  • assistant text that is not a silent sentinel or a prefix of one (_relay_content_is_showable, gateway.py:3496).

Exception: a run whose agent payload carries isHeartbeat:true (server-chat-CbnqM5f6.mjs:822) never gets a message. The Gateway hides heartbeat chat output itself (shouldHideHeartbeatChatOutput, :593), and heartbeat tool cards in a user chat would be noise.

A run that ends with only a sentinel (NO_REPLY, ANNOUNCE_SKIP) behaves as follows:

  • No tool calls: it never gets a message. That matches what today's path does ("no assistant text, skipping").
  • After tool calls: M already exists, so it is finalized with its tool cards and no text, done:true. The sentinel itself is never written. The cards stay, because the user already watched them stream and they are the record of what the run did. Deleting a message the user has seen would be a worse surprise than a card-only message.

Showing it in an open tab without a reload. Open WebUI 0.12 has a native multi-tab protocol. Tabs that have a chat open join room chat:{chat_id} (socket/main.py:715-742, events:chat join). An event {chat_id, message_id, user_id, shared:true, data:{type: "chat:messages", data:{messages, currentId}}} is merged by the page (rt() in chunk CoHf6c6A.js): unknown ids are added, childrenIds are unioned, and currentId moves to the new tail unless the tab is mid-generation of its own message. Open WebUI's backend emits exactly this for its own new turns (main.py:1577). Shared events from the viewer's own user_id are ignored except chat:messages, so this event type is the right one. After it, streaming events for M apply, because M is now a known id. This replaces the location.reload() nudge (P9) and the 1.5 s handshake sleep in owui_stream.py. Measurement M1 (section 7) proves it live before anything else is built on it.

Native Stop and "generating" state. The writer task is registered with Open WebUI's task registry, create_task(redis, coro, id=chat_id) (tasks.py:157-178), with the task id stored in M.meta.task_id. Open WebUI then lists it for the chat (main.py:2171, ownership by meta.task_id, models/chats.py:1947-1954), and the Stop button's POST /api/tasks/chat/{id}/stop (main.py:2192) cancels it (section 3, case 7). save_response_stream(... content, output) (tasks.py:199) on every event lets a tab that opens the chat mid-turn see the current content through overlay_response_streams (routers/chats.py:1431). That replaces the 1 s snapshot timer (section 5).

2.4 Streaming and tool cards ​

The writer feeds R's events into the renderer (_TurnRenderer.feed, gateway.py:1167). Tool events reach a non-initiator connection as session.tool, given a transcript subscription (server-chat-CbnqM5f6.mjs:930-941). The pipe opens that subscription on run start (gateway.py:1630-1633). The renderer's output diff becomes response:completion events (_relay_output_events, gateway.py:3460), the same item shapes Open WebUI folds for its own turns. They are emitted through get_event_emitter({chat_id, message_id:M, user_id}) (socket/main.py:1314), so they reach the user's room and, throttled by Open WebUI itself (0.15 s, :1366-1371), the shared chat room. The DB gets content/output once at a handover, after the request's Open WebUI task has ended (its end-of-stream save writes done: true), and at the end: every write rewrites the whole chat, so the live state is carried by the response stream (2.3) and the DB only by what a crash needs (ELI-115, Rehydration).

2.5 End: the run's own terminal, nothing else ​

The writer ends R on whichever comes first:

  • the chat terminal for R (emitChatTerminal, from finalizeLifecycleEvent, server-chat-CbnqM5f6.mjs:353-398), state final|error|aborted, carrying yielded:true when the run paused for sub-agents;
  • sessions.changed {phase:"end"|"error", runId:R}, pushed after the session row is persisted (:430-455);
  • after a seq gap or reconnect, sessions.list no longer listing R in activeRunIds.

Then it reads chat.history once and finalizes M from cli-assistant:<R> and R's tool messages (_history_run_messages + _history_renderer, gateway.py:2875-2942). If the transcript does not have R yet (claude-cli sometimes writes only the closing text), it keeps what streamed, which is complete unless a gap was recorded. It writes done:true, content, output under the transaction, emits a final chat:completion and chat:active {active:false}, and lets the Open WebUI task finish.

Yield (condition A). A yielded:true terminal ends this message too, and the parent's resume after its sub-agents is a new run with its own message. This is today's behavior on main, so it is no regression. Evidence: three runs of an inline user turn that calls sessions_spawn + sessions_yield and then resumes.

  • runs/deploy-main/yield-main.log: the 18:51 subagent-wait test, chat 0ee165a9, on main's code.
    • Reply 02eac227 closed done:true at the yield ("...and I've yielded. It is still running, so I'll reply with SUBAGENT-DONE... when its completion event arrives").
    • The resume came as a separate *↳ Proactive message* bubble, 2d65853b ("SUBAGENT-DONE-d6013359").
    • The child's own text came as a third bubble, ↳ Sub-agent, 31e95c10.
  • runs/accept-composer-2.log (20:20Z): same shape. Reply 6bfad4fd done at the yield, resume 77fc7ec8 separate, sub-agent bubble bf27c30c.
  • runs/accept-composer3-1.log (21:23Z): same shape. Reply 83ecadfb done at the yield, resume 326f0de9 separate, sub-agent bubble 75770bf8.

What changes:

  • The resume message loses its *↳ Proactive message* header.
  • The third ↳ Sub-agent bubble no longer appears. The child's result reaches the chat through the parent's resume, which quotes it. That is the duplicate P6 removes, and it was approved with the design.
  • Merging the resume into the yielded reply would be a behavior change. It is not proposed.

2.6 User turns use the same writer when their request ends early ​

A user turn starts as today: Open WebUI creates the stub M, pipe() sends msg-<chat>-<M>-<t>, and the reply streams through the request (inline). If the request ends before R does (Open WebUI cancel or close, a graceful Open WebUI stop, a reply closed by any other path), R's entry switches to writer for the same M, starting from the renderer it has been feeding all along. One mechanism replaces parity finalize (P16), relay adoption of an orphaned stub (gateway.py:3287-3299, 3622-3636) and the stub completion of PR #19. The orphan reconcile (P20) is the same handover done at startup, for runs whose request died with the process.

An interrupted run is one more such path. The inline turn never resumes a run itself. On an outside kill of the CLI (the 2.7 exit text), an abort after a shutdown notice, or a new bootId after a reconnect, it hands M to the writer (_hand_over_run) and ends its request. R's settled row then picks the owner by 2.7, read from the next push, or read at once when the handover comes after the post-hello resync. resume_after_restart, the epoch-bump probe and _is_restart_signature are gone.

2.7 Interrupted runs: one owner per run (M3 result, ELI-90) ​

M3 (window 2026-10-12 23:54Z, ha apps restart) showed that the Gateway does not recover every interrupted run. Both runs ended chat state:error + sessions.changed phase:error in the old boot, before the shutdown, so at boot neither session was running and the Gateway started no recovery run. Main's pipe resumed the user turn (resume-0df93712-..., reply c153f06b done, 7 cards). The agent turn stayed status: failed, lastRunError: "Claude Code process exited with code 143", with no continuation (its relay message 988dc34e has 1 card and no text). Case 5 as first written ("the pipe stops sending its own resume") is wrong for this path.

Dist facts the rule stands on.

  • F1. Recovery touches only rows whose persisted status is running. At boot, markStartupOrphanedMainSessionsForRecovery scans statuses:["running"] (main-session-restart-recovery-marking-CvQ2iWW3.mjs:222-262, statuses at :235, :240). Recovery dispatch needs isMainSessionRecoveryPending: status === "running" && abortedLastRun === true (main-session-recovery-state-CLSHE2xG.mjs:119-121).
  • F2. A run's terminal persists status from its outcome. The one exception is a cancel with stopReason:"restart", which keeps running and sets abortedLastRun (session-lifecycle-state-C7hBzJwC.mjs:89-90, 102). A user or RPC abort gives killed; an error gives failed, with lastRunError.
  • F3. The four ways a run is cut:
    • Restart mode. SIGUSR1, a restart intent on SIGTERM, or an in-process restart: server.close({restartExpectedMs:1500}) (run-CiAIvodT.mjs:1082-1086). markActiveRunsForRestartRecovery marks the live runs, then abortActiveRuns(restart) cancels them with stopReason:"restart" (server-close.runtime.js:148-186, 189-218, 222-250). The rows stay running, so the Gateway recovers.
    • Gateway stop mode. SIGTERM with no restart intent goes to request("stop") (run-CiAIvodT.mjs:1215-1225), and prepareGatewayRunShutdown with restart:false only aborts, stopReason:"rpc" (server-close.runtime.js:222-226). Nothing is marked, the rows go killed, and nobody recovers.
    • Container stop (ha apps restart). Every process gets SIGTERM. The claude-cli child exits 143 before the Gateway closes: the error push came 0.06 s after the signal and the 1012 close 0.26 s after (runs/m3-watch-window1-graceful.jsonl). Its error text is Claude Code process exited with code 143 (cli-transport-CXSP7FlN.mjs:96), so the row is failed and nobody recovers. That is what M3 saw.
    • Crash (SIGKILL, OOM). No terminal is persisted, so the row is still running at boot and the Gateway recovers (F1).
  • F4. Before any run is aborted, the Gateway broadcasts shutdown {reason, restartExpectedMs?} to every client (server-start-BB-IxTAg.mjs:1595-1603, first step of prepareClose, :1699-1700; server-shutdown-Dd8ozitC.mjs:21-26). restartExpectedMs is present only in restart mode.
  • F5. The terminal sessions.changed phase end|error push is built with the active-run overlay (buildSessionEventSnapshot(..., includeActiveRunState=true), server-chat-CbnqM5f6.mjs:311-327, 455). It still says status: running, hasActiveRun: true (measured in the crash dry run, 2026-10-13 00:14Z, runs/m3-watch.jsonl). The settled row comes in the next push for the key, which has hasActiveRun: false and carries status, abortedLastRun, lastRunId and lastRunError (session-event-payload-6u50DozL.mjs:81, 103-105). There are two sources, and both fire after the run's dispatch has finished:
    • reason: "chat.run.settled" for chat.send runs, from a finally (chat-send-handler-kZQfUabO.mjs:3214-3222). Measured: status: done, hasActiveRun: false, lastRunId: R.
    • reason: "agent.input.settled" for agent-method runs (principal-BVWseYi9.mjs:3007-3027).

The rule. The pipe reads the persisted row of R's session from one of two places:

  • the settled push after R's terminal (F5);
  • the first sessions.list after the reconnect, when no settled push reached it (crash, dropped frame).

The stop-mode notice in row 3 must come before R's terminal on the same connection.

R's rowOwnerPipe does
status: running, or no terminal at allGateway (F1-F3)keeps M open and links the recovery run by rule E (case 5)
failed and lastRunError matches process exited with (code 143|code 137|signal SIG*)piperesume directive msg-<chat>-<M>-resume-<t> into M
killed after a shutdown without restartExpectedMs on this connection (F4)pipethe same resume
killed with no such noticenobody (a Stop)finalizes M
failed with any other errornobody (a real error)shows the error in M, as today
lastRunId is a newer runthat runno resume of M: M ends (a cut-off note on an outside kill row) and that run's own writer decides; rule E links a recovery run whose events reached the pipe (ELI-159)

Why there is never a double continuation. The pipe resumes only rows the Gateway has already persisted as terminal, and the Gateway recovers only rows that are running. A row cannot be both. A resume run is an ordinary run, so if it is cut too, the same table applies to it. If a crash then cuts the resume run, the recovery's prior turn is msg-<chat>-<M>-resume-<t>, which rule E maps to M.

Never into a busy session (manager note 2). If the session has an active run (hasActiveRun: true), no resume is sent, whatever R's own error was. next_continuation returns wait, and the decision is taken again on that run's settle. A resume sent into a busy session is queued as a follow-up, and after a restart recovery such a follow-up fails ("Reply operation has no active tool authority snapshot", 04351f67). That is the failure main hit (test_no_resume_while_a_recovery_holds_the_session).

When the resume is sent. At once, on the classifying row. If chat.send is refused (the Gateway is closing, the socket is gone), the resume stays on R's entry and is sent on the next hello-ok. Both triggers are events: there is no idle probe and no wait. The bound is today's max_resume_attempts = 3 per M (pipe.py:1416), which is a count, not a timer.

Agent turns (manager decision 3). They follow the same rule as user turns: the same mechanism, the same directive, written into the agent turn's M. Today on main such a turn is lost (above). The suggested heartbeat/cron exception is already covered by the Gateway's own scope, and the pipe reuses it so that both sides agree on what is worth continuing:

  • Cron runs never reach an OWUI key as a session push (server-chat-CbnqM5f6.mjs:445 returns for cron-run-scoped keys).
  • Recovery skips sub-agent, cron and ACP keys (isMainRestartRecoveryCandidate, main-session-recovery-state-CLSHE2xG.mjs:114-118).

The pipe resumes only on keys that predicate accepts. Every OWUI key passes it, so no separate exception list exists.

The text match (approval condition). The dist has no structured exit field. The row schema has only lastRunError: string (sessions-row-BVZA0ilu.mjs:154). It is filled by resolveSessionRunError (session-run-error-BdVGP1JC.mjs:37-40), which passes outcome.error through renderUserFacingText and redaction (:9-12). The child's exit reaches it only as the Error text built at cli-transport-CXSP7FlN.mjs:96. So the pipe matches text, and option (b) guards the match:

  • a checked-in fixture holds that exact source line, extracted from the installed dist (version recorded), plus the row text seen live in window 1;
  • the unit test renders the fixture's templates for 143, 137, 1 and SIGKILL, and checks the classifier on each;
  • when the installed dist is present, the test extracts the line again and fails if it differs from the fixture. An OpenClaw upgrade that rewords the error therefore fails the suite instead of silently dropping resumes.

OOM loop. A claude child is the first OOM victim (oom_score_adj=1000, ELI-89). One that is OOM-killed with the Gateway alive leaves failed + exit 137, so the pipe resumes. If the resume is OOM-killed too, the same row comes back. max_resume_attempts = 3 per M is counted on M, not per run. After the third, M is finalized with what it has and a cut-off note, and no fourth run is sent. A unit test drives four consecutive 137 rows.

M3 results.

  • Window 1, 2026-10-12 23:54Z, container stop (ha apps restart): both rows were terminal before the shutdown, and no recovery ran.
    • Owner rule: pipe for both runs. Main resumed the user turn and lost the agent turn.
  • Window 2, 2026-10-13 00:33-00:37Z, crash (ELI-99 kill test, PASS, relaunch 50 s): boots 2, 1006 close, no shutdown notice. owner_rule_holds=true, E_rule_holds=true.
    • Agent turn: recovery 8f27e4c8 links to m3-agent-91c29446, and reply df1c154f is done with 6 cards and AGENT-DONE.
    • User turn: recovery f2445aa8 links to the source. On main, though, the user's content was lost:
      • Main's pipe also sent resume-0c5d682c-..., which is the double continuation. It arrived while the recovery was active, so the Gateway queued it as a follow-up: an immediate chat final (chat-send-handler-kZQfUabO.mjs:3206) and no transcript turn.
      • When the recovery ended (log 00:37:18.148 "restart recovery terminal ... f2445aa8"), the follow-up ran as 04351f67 and failed 67 ms later: "Embedded agent failed before reply: Reply operation has no active tool authority snapshot". It is now the session's lastRunId, with status: failed.
      • Main's reply 7371727a closed on the queued final, done:true, 1 card and "(This turn produced no reply text.)". The recovery's 6 cards and USER-DONE went nowhere.
    • M3_pass was false only because neither recovery's phase:"start" reached the watcher. That is the handshake gap closed in 2.1.

Fails on main (each is a test in section 6):

  • The container stop: the agent turn is lost.
  • A crash: main's idle probe sees the epoch bump and resumes while the Gateway also recovers, which is a double continuation. M3-crash measures this.
  • _is_restart_signature resumes a CLI that exited with code 1.

3. Cases ​

  1. Tab open on this chat. chat:messages adds M at the tail and moves the view to it. Responses events stream text and cards live. Native Stop works through the registered task. Reload mid-turn shows content from the response-stream overlay.

  2. Tab open on another chat. Events for other chats are dropped by the page (it checks event.chat_id === $chatId). M and its content are in the DB and the response stream. Opening the chat loads it mid-stream through the overlay, joins chat:{id}, and gets the throttled shared chat:completion updates from then on.

  3. No tab open. Same writes, nobody to emit to. Opening later shows the finished or in-progress message from the DB plus the overlay.

  4. OWUI down during the run (ELI-94). From the shutdown signal on, the dying process opens no writer: OWUI's stop saves what the reply showed (done), a kill leaves the stub (not done), and the next process finishes both (ELI-173). Run still going when OWUI is back: the startup reconcile (pipe.py:369 runs at the first model list after start) reads sessions.list and opens entries for active runs on OWUI keys. The handover rule (2.6) or the claim (2.3) finds or creates M and continues; the end rebuilds the whole run from the transcript. Run ended while OWUI was down: today nothing covers this. New rule: for each OWUI session whose sessions.list updatedAt is newer than its chat's updated_at, read chat.history and give every closed run (cli-assistant:<R>) that lacks a finished message_id(R) its message, from the transcript. This is a comparison of two recorded timestamps, not a wait. M2 verifies that updatedAt moves at run end.

  5. Gateway restart mid-run (ELI-90). Who continues R is decided by the owner rule (2.7), on R's persisted row:

    • Gateway. In a crash or a restart-mode shutdown the row is still running, and the Gateway's recovery run continues M. That run is a non-pipe run on the same key, linked by rule E below.
    • Pipe. After a container stop or a gateway stop-mode shutdown the row is terminal, and the pipe's resume run msg-<chat>-<M>-resume-<t> continues M.

    Either way it is one continuation, into the same message, for user and agent turns alike.

    Link rule (condition E). Recovery run R' belongs to the message of source run S when all three hold:

    1. The Gateway restarted. hello-ok.server.bootId differs from the one S was sent under (gateway.py:2271-2287).
    2. R' is a restart recovery. Its admitted user turn R':user in chat.history carries provenance:{kind:"internal_system", sourceTool: "main_session_restart_recovery"}. Dist: the recovery dispatch sets idempotencyKey: recoveryRunId and that inputProvenance (main-session-restart-recovery-CPJjd8da.mjs:441, :452-456). It is seen live in this session's own transcript: eaf12616-...:user and 42641a9c-...:user, both with that provenance.
    3. S is the run R' recovers. The Gateway recovers the session's restartRecoveryDeliverySourceRunId. Every admitted run overwrites that field with its own id: chat.send writes clientRunId (chat-send-handler-kZQfUabO.mjs:635-636) and the agent method writes sourceRunId (agent-command-C9QUtzmi.mjs:1019). So S is the newest admitted run on the key before R', and in the transcript S is the newest <id>:user before R':user that is not itself a recovery.

    M is then message_id(S) by the identity rule (2.2): the M inside msg-<chat>-<M>-... for a user turn, or uuid5(key:S) for an agent turn. Every input is a recorded field. The session row does not expose the source-run field (sessions.describe checked live; none of the restartRecovery* keys are in it), which is why the transcript order is used. M3 proves all three conditions on a real restart.

  6. WS reconnect, same bootId. Runs continue. Every open entry gets a gap mark (gateway.py:2250). _subscribe_all re-reads sessions.list; a run that ended during the drop is finalized from the transcript (2.5). Gap repair (P21) stays for inline replies.

  7. User sends while an agent turn streams (ELI-88). With the writer registered as an Open WebUI task, the page shows the chat as generating and queues the message in its own queue. M4 verifies this. If the page sends anyway, pipe() already waits for the session's run to settle on sessions.changed (pipe.py:1172-1198, no timer) and sends its own run after. It never steers into the agent run.

  8. Send now / Stop during an agent turn. Both reach the pipe as a cancel of the writer task. The writer installs the page intent hook (_INTENT_HOOK_JS, pipe.py:120) when it creates M, and asks the page which button was pressed (as _settle_cancel_intent, pipe.py:164, does). If the answer is Stop, it sends chat.abort and finalizes M. If the answer is Send now or unknown, it keeps writing M to the run's end, and the queued message runs after (case 7). One run keeps one message, and the agent turn needs no grace timer. Escape and the Stop button both report as Stop through the same hook. One Stop cancels every task of the chat at once (a reply queued behind the run and the writer holding it), and each asks: the query leaves the page's record in place for all of them, and a new message sent with nothing running clears it (ELI-198).

  9. Two agent turns back to back. Each run has its own id and its own M. The second anchors under the first (row lock, 2.3), so they chain in run-start order and are never variants.

  10. Sub-agent completion while the parent runs. If the parent is active, the completion is steered into the running parent run (subagent-announce-delivery-CWxOCC67.mjs:1205-1224, path:"steered"). That is part of R and appears in R's message. If the parent is idle, an agent run is dispatched on the parent's key (:672). That is a normal agent turn with its own message. The child's own session is never an OWUI key, so it renders nothing (P4-P7 deleted).

  11. Several pipe connections after a redeploy (PR #14). Every connection may see R's start. Only the one whose insert wins the row lock writes M, and a retired connection never claims (_retired, gateway.py:1380). Handover checks openclaw_writer against the live registry. A retired connection still serves the reply it streams; the new load routes nothing such a connection has a consumer for (_served_by_retired), and takes the run from the next event after that reply ends (PR #72). The result is one message, whatever the number of connections.

4. Existing chats ​

Read-only count on the live DB (2026-10-13): 325 chats; 214 *↳ Proactive message* and 33 *↳ Sub-agent messages in 44 chats; 87 variant groups that match the heal rule and are not yet chained.

  • Old messages keep their content and their header text. They are ordinary assistant messages to Open WebUI, and nothing in the new path reads or rewrites them. The Action's task-id click (P24) stays as a documented read-only shim for the 33 sub-agent messages.
  • Variant groups (condition G: a separate step after the merge, not in the phase 2 PR, with the copy diff going to the manager first): today they are healed only when the user next writes in that chat (pipe.py:974). After the delete, the 87 stay as 1/2 and 2/2 swipes unless migrated. Proposal: move _relinearize_proactive_variants into a workspace script, not the pipe.
    1. Copy the DB.
    2. Run the script on the copy and diff every changed chat (parent chain, currentId, no message lost, all done:true).
    3. Show the diff to the manager.
    4. Only then apply it chat by chat through ChatGuard with a backup. If the copy run shows any group it would break, the script skips that chat.
  • New agent turns cannot create variant groups (2.3), so the heal is never needed again.

5. Timers (ELI-95 folded in) ​

Turn path, pipe.py and gateway.py:

TimerWhereDecision
Proactive debounce 120 s idle / 5 s poll / 600 s give-upgateway.py:3036-3068remove (2.1 start push + DB claim)
Sub-agent debounce, same valuesgateway.py:4334-4375remove
_RELAY_MIN_IDLE_S = 120 via session_idle_forgateway.py:3432, 1866-1891, 2585remove
_RELAY_SNAPSHOT_MIN_INTERVAL_S = 1.0gateway.py:3439, 3704-3723remove. Keep the live state in the response stream and persist only the handover and the end (2.3, 2.4).
Inline idle close, idle_probe_s = 30 + describepipe.py:1407, 2235-2269remove (P27; end only on terminal, 2.5)
Restart resume trigger: the same 30 s idle probe plus the epoch bump, after a deferred _is_restart_signature errorpipe.py:1696-1720, 1885-1891remove. The resume fires on the owner rule (2.7): R's terminal push or the first sessions.list after hello-ok.
First-event wait 60 s + describe probepipe.py:1684-1719remove. A quiet queue before the first event is answered by run state (sessions.changed / sessions.list), which the queue wait already uses.
no_text_deadman_s = 180, describe_check_interval = 45, max_events_without_textpipe.py:1408-1411remove. A live run is waited on, and a dead one has a terminal or a settle.
Auto-title: sleep(3) x 7 preview pollpipe.py:531-545remove. Register a consumer for the title run and wait for its chat terminal, which is broadcast like any run's.
Event-loop recv timeout 90 sgateway.py:2479keep, given a meaning. The Gateway ticks every 30 s (server-constants-BrVEC7RW.mjs:9, TICK_INTERVAL_MS = 3e4), so 90 s without a frame is three missed ticks, which means a dead socket: reconnect. Today it just loops. A half-open TCP socket has no event of its own, and the tick contract is the Gateway's own liveness signal.
Reconnect backoff 1..30 sgateway.py:2738-2751keep. With no socket there is no push to wait for; trying is the only signal.
RPC response timeouts (connect 10 s, send_request, ORPHAN_HISTORY_TIMEOUT_S = 60)gateway.py:21, 1706, 3994keep. A response frame is the only signal for an RPC. These bound a hung server and do not decide anything when the server answers.
Cancel grace INTERRUPT_GRACE_SECONDS, _SEND_NOW_HOLD_S = 120, _INTENT_QUERY_TIMEOUT_S = 3, claim wait 3 spipe.py:274, 160-161; gateway.py:2095-2114, 2145unchanged by ELI-100. This is timer (a), under its own review. Agent turns do not use it (case 8).

Not on the turn path, unchanged by ELI-100, listed for completeness: the ask-user card re-show and poll (askuser.py:605-648, 796, 887), the composer watcher tick (composer_watch.py:30), and the Action's sub-agent poll and compact wait (action.py:1105-1106, 1533-1536).

6. Regression plan and rollback ​

Tests first (deterministic, no sleeps). Each of these must fail on 787a471 and pass on the branch:

  • AgentTurnRendersNormallyTest: a sessions.changed start, deltas, two tool calls and a final for a foreign run on an OWUI key produce one message at the tail with id uuid5(key:R), no Proactive message text, 2 function_call items, and done:true, with no time advance. On main this needs 120 s of idle.

  • TwoConnectionsOneMessageTest: two connections share one fake chat store with a real lock, receive the same run, and the result is exactly one message.

  • ToolCardsFromTranscriptTest: a run whose events were partly lost is finalized from cli-assistant:<R> plus tool messages, and the card count equals the transcript's.

  • SeqGapRecoveryTest: a missing frame seq triggers sessions.list, and a run started during the gap gets its message.

  • NoMidRunCloseTest: describe.status = "done" while hasActiveRun is true does not end an inline reply. On main it does after 30 s idle.

  • OrphanStubAdoptedWithoutHistoryTest (the 2026-10-12 23:29Z incident, this manager chat 0abe9bde, Open WebUI OOM-restarted mid-turn).

    • On main: the startup reconcile marked stub 8acdce95 "its run is still going". When the run settled, _orphan_stub_for_run failed with chat.history failed: (an exception with an empty message, 4 times, at gateway.py:3976-3980) and returned None. The relay then introduced a new message 32e095ae (5872 chars, 7 cards) under an empty done:false stub.
    • Test: a chat holds stub M with done:false, run msg-<chat>-<M>-<t> arrives on a connection with no consumer, and session_history raises TimeoutError(). Expected: the run writes into M (identity from the run id, 2.2, no transcript read needed) and M ends done:true with the run's text and cards. No second message.
  • UserTurnNoTabTest (side finding of the M3 dry run, 2026-10-12, main, no tab open): a user turn sent through the API with no page left the stub done:false and empty, and the answer arrived as a second bubble with 1 of 5 cards. The agent turn got 2 of 5 cards. Expected: one message M, done:true, 5 cards.

  • OwnerRuleTest (2.7), one case per row of the table. Each is fed the dist-shaped event sequence: the terminal push with the running overlay, then the settled push.

    • container stop (failed, exit 143) -> resume into M;
    • restart mode (running + abortedLastRun) -> no resume, recovery linked;
    • crash (no terminal, row running after hello) -> no resume;
    • gateway stop (killed after shutdown without restartExpectedMs) -> resume;
    • user Stop (killed, no notice) -> no resume;
    • real error, and CLI exit 1 -> no resume.

    On main, the crash case resumes (a double continuation), the agent-turn container stop is lost, and exit 1 resumes.

  • CrashDoubleContinuationTest (M3-crash, user turn): a crash, then recovery f2445aa8 starts at boot. On main the pipe also sends resume-..., which the Gateway queues behind the recovery. The reply closes on the queued run's immediate final, with no text and 1 of 6 cards. Expected: no pipe resume (the row is running), and M gets the recovery's 6 cards and text.

  • RecoveryStartedBeforeSubscribeTest: the recovery's start push never arrives. The run is in the sessions.subscribe list reply (still active), or it has already ended and is found by the case 4 rule. Either way it writes into M.

  • ExitTextFixtureTest and OomResumeLoopTest (2.7).

  • ResumeQueuedUntilHelloTest: chat.send for the resume is refused while the Gateway closes, and it is sent on the next hello-ok, with no time advance.

  • Plus the old -> new list in 1.4, which goes in the PR body.

Grep gate on the merged tree: zero Proactive message, zero PROACTIVE_, zero _proactive, except the shim in action.py and its test, named in the PR.

Live gate (scratch OWUI chat, real pipe, DB plus a reloaded page each time), per the ticket. Cases:

  • cron wake into the chat
  • sessions_send into the chat
  • sub-agent completion announce
  • tab closed then opened mid-turn
  • user message while an agent turn streams
  • gateway reconnect mid agent turn
  • Stop during an agent turn (condition C): the run is aborted and M is finalized with what it had
  • Send now during an agent turn (C): M keeps the whole run, and the queued message gets its own reply after
  • two agent turns back to back (C): two messages chained, no variant group
  • OWUI down during the run (case 4) and gateway restart mid-run (case 5), for both an agent turn and a user turn: these need a restart, which the manager runs in a window. The worker prepares the scenario and the checks and hands over with NEEDS-MANAGER.

Each case is checked for:

  • one assistant message per run
  • a linear parent chain
  • text equal to the transcript
  • card count equal to the transcript's
  • done:true
  • no header

The integration suite and test_history_guard.py --live 16/16 must stay unchanged. The manager re-runs at least three cases.

Rollout: one PR, deployed after the measurements in section 7 pass.

Rollback:

  1. Revert the merge commit and redeploy main's previous artifact (install.py backs up the live function before every deploy: backup_function, install.py:377).
  2. Messages written by the new path are plain assistant messages with extra meta keys, which the old code reads as normal messages. No DB change needs undoing.
  3. The variant migration (section 4) is separate, applied only with its own backup, and reversible from that backup.

7. Measurements phase 2 must pass before building on them ​

These come from reading the Open WebUI bundle and the dist, not from a live run. Each one gets a live proof in a scratch chat first. If one fails, the design changes at that point and comes back for approval.

  • M1 chat:messages (shared, room chat:{id}) adds a REST-created assistant message to an open tab and moves currentId, and the next user message parents to it. Proof: DB parentId plus the page.

  • M2 sessions.list updatedAt for an OWUI session moves when a run ends (case 4, ended during downtime).

  • M3 (needs a Gateway restart; the manager runs it) After a real Gateway restart with one user turn and one agent turn in flight, each of these must hold:

    • the recovery run's sessions.changed start reaches the pipe after its reconnect, or sessions.list lists it in activeRunIds;
    • R':user carries the main_session_restart_recovery provenance;
    • the newest non-recovery <id>:user before it is the interrupted run.

    If the first does not hold, the pipe directive (P28) stays and is re-keyed to msg-<chat>-<M>-resume-<t>.

    Result, window 2026-10-12 23:54Z (container stop): FAIL for "the Gateway recovers every interrupted run". No recovery run at all. Both rows were terminal before the shutdown (2.7). Two boots, 0 seq gaps, smoke keys 0. P28 stays, re-keyed, and is triggered by the owner rule.

    M3-crash (the ELI-99 kill test as the restart, m3_orchestrate.sh crash, prepared, not yet run). The rule says the Gateway owns both runs. Passes if, for both turns:

    • a recovery run exists;
    • rule E links it to the source run;
    • its start push reaches a reconnected client.

    On main, a pipe resume next to the recovery is expected for the user turn (a double continuation). That is recorded as a fails-on-main datum, not as an M3 failure.

  • M4 A chat whose only active Open WebUI task is the writer's shows as generating, and the composer queues instead of sending. If not, case 7 still holds through pipe()'s existing wait, and this becomes a nicety.

  • M5 create_task(..., id=chat_id) from inside the Function is stopped by the page's Stop button, and the cancel reaches the writer.

Released under the MIT License.