ARES

Ares Run Adapter Contract and Migration Gates

Credit and Scope

This RFC codifies the direction discussed in #1925. It does not introduce an implementation. The central guardrail comes from Michael Lam’s review framing:

the adapter should be a protocol translator, not a runtime surrogate.

The product boundary from #1925 is:

WebUI should be thin in execution ownership, not thin in product scope.

That means WebUI remains the full browser workbench for sessions, workspace files, chat rendering, tool cards, approvals, status, diagnostics, and controls. The change is that long-lived execution ownership should move behind an explicit runtime boundary instead of remaining scattered through the main WebUI request process.

This document is intentionally a reviewable spec and migration gate. It should be accepted before any implementation PR for this adapter direction changes the streaming hot path, introduces a runner process, or moves a new approval / clarify / queue / goal control path. Narrow current-path bug fixes that do not introduce a new runtime boundary can still proceed under the WebUI run-state consistency contract and the relevant issue scope.

Problem

Browser-originated chat turns are still executed inside the WebUI server process. The current path creates process-local stream state, starts background agent threads, constructs or reuses AIAgent, and owns callback state for token, tool, reasoning, approval, clarify, cancellation, and terminal events.

That shape works, but it makes the WebUI process the owner of active runtime truth. Consequences include:

The immediate goal is not to build a sidecar. The immediate goal is to define the browser contract, classify current runtime state, and gate the first reversible journal slice.

Current Gate State — 2026-05-23

Slice 1 is now past the first active validation gate:

This evidence did not prove the future runner/sidecar path. It did unblock the adapter-seam work:

The next gate is runner-backend plumbing, not queue implementation by default. Queue / continue routing should only move before Slice 4 if a future maintainer decision identifies an existing server-side legacy entry point and pins its response shape, ordering, and idempotency contract. Otherwise, keeping queue_message(...) staged is the honest boundary while execution ownership moves out of the main WebUI request process.

Goals

Non-goals

Artifact 1: Browser Event and Control Contract

This is the compatibility contract the browser depends on, regardless of whether the backend is today’s in-process streaming path, an in-process journaled path, a future WebUI-managed runner, or a future Ares /v1/runs backend.

The current inventory should be derived from static/messages.js consumers and SSE/event production in api/streaming.py. Future edits to those files should update this RFC or the implementation contract that replaces it.

Event Envelope

Every replayable runtime event should be representable with:

{
  "event_id": "run_123:42",
  "seq": 42,
  "run_id": "run_123",
  "session_id": "20260514_...",
  "type": "tool.updated",
  "created_at": 1778750000.0,
  "terminal": false,
  "payload": {}
}

Required semantics:

Event Families

Event family Required payload Browser responsibility Runtime source of truth
run.started / status lifecycle state, controls available, session id, workspace/profile/model/toolset summary render active state and controls runtime run state
token.delta assistant message id or segment id, delta text, content type append visible assistant text runtime model output stream
reasoning.delta / reasoning.done reasoning block id, delta/final text, visibility metadata render thinking/progress UI runtime reasoning events
progress concise phase/status text, optional tool context render activity/progress text runtime progress callbacks
tool.started tool call id, name, sanitized arguments, start time open/update tool card runtime tool lifecycle
tool.updated stdout/stderr/structured partial data, progress metadata update tool card runtime tool lifecycle
tool.done result, status/exit code, duration, error flag finalize tool card runtime tool lifecycle
approval.requested approval id, action summary, risk metadata, available choices show approval widget runtime approval state
approval.resolved approval id, choice, resulting status close/update approval widget runtime approval state
clarify.requested clarify id, question, choices/input mode show clarify widget runtime clarify state
clarify.resolved clarify id, answer metadata/status close/update clarify widget runtime clarify state
title.updated title text, source/confidence update title surfaces session/title subsystem
usage.updated / usage.final tokens, cost, model/provider, duration where available update usage surfaces runtime usage accounting
error stable error code, safe message, redacted diagnostics, terminal flag render error and final state runtime terminal/error state
done final lifecycle state, usage, terminal result/error summary, last seq finalize run UI runtime terminal state

Reconnect Metadata

Every active or terminal run must expose:

Controls

Control Required semantics Target owner
observe attach to live events and replay from cursor adapter API surface backed by runtime/journal
status poll lifecycle state when SSE/WebSocket is unavailable adapter API surface backed by runtime/journal
cancel request graceful cancellation; terminal event follows runner/runtime control plane
queue / continue append follow-up work according to Ares semantics runner/runtime control plane
approval resolve pending approval by id with supported choices runner/runtime control plane
clarify answer pending clarify request by id runner/runtime control plane
goal set/status/pause/resume/clear goal where capability exists runtime command/capability plane

WebUI may keep presentation state such as expanded rows, selected tabs, and local scroll position. WebUI must not privately mutate runtime truth for these controls.

Artifact 2: Runtime State Inventory and Classifier

Classifications:

Current primitive Current legacy source of truth Target classification Future backend mapping Slice 1 handling Notes / gap
STREAMS / STREAMS_LOCK api.state_sync process memory adapter API surface + presentation fan-out WebUI runner or future Ares run observation API keep live path; mirror events into journal Must stop being authoritative for active run existence.
CANCEL_FLAGS api.state_sync process memory runner process cancel/interrupt endpoint or runner control no control-flow change Final cancel state must return as a replayable event.
cached AIAgent objects / AGENT_INSTANCES api/config.py process memory runner process runner-owned Ares integration unchanged Moving this is deferred until after journal proof.
background thread lifecycle _run_agent_streaming in api/streaming.py runner process runner-owned execution lifecycle unchanged Slice 1 must not rewrite thread/control flow.
token / partial text buffers streaming callbacks and browser SSE state journal + presentation cache replayable runtime events append emitted events Browser can cache rendered state, but replay must rebuild it.
reasoning buffers streaming callbacks and UI rendering state journal + presentation cache replayable reasoning events append emitted events Thinking cards must survive reconnect.
tool buffers / live tool calls WebUI streaming callbacks journal + presentation cache replayable tool lifecycle events append emitted events WebUI owns rendering, not tool execution state.
approval callbacks / queues live Python callbacks runner process + adapter API surface + journal approval state/control endpoint journal request/resolution events only Pending approval must eventually survive WebUI restart.
clarify callbacks / queues live Python callbacks runner process + adapter API surface + journal clarify state/control endpoint journal request/resolution events only Pending clarify must eventually survive WebUI restart.
per-request ARES_HOME env mutation lock api/streaming.py / config helpers runner process runner/profile execution context unchanged Long-term runner must isolate profile env without process-global mutation.
session-to-active-run mapping session JSON + active stream ids + memory journal + adapter API surface runtime run registry/session mapping journal run metadata Reopen session must discover active/completed run.
title generation state WebUI callbacks/session saves journal + presentation cache runtime/session title event append title events WebUI may display title updates after event receipt.
usage accounting state WebUI callbacks/session saves journal + presentation cache runtime usage event/source of truth append usage events Avoid divergent WebUI-only accounting.
command capability metadata WebUI command registry + Ares command assumptions adapter API surface runtime command/capability metadata unchanged Unknown command support should not be guessed by WebUI.
voice mode state browser/UI + streaming path presentation cache + adapter API surface runtime input/control capability unchanged Acceptance tests must pin voice behavior before migration.
project/workspace context WebUI session/workspace state + env mutation adapter API surface + runner process runtime run context unchanged Must preserve workspace-aware chat and project context.

Unclassified state is a design blocker. If an implementation slice discovers a runtime primitive that does not fit this table, update the RFC before landing code.

Artifact 3: Acceptance Test Catalog

These are the user-observable behaviors that must survive the migration. The catalog should become automated tests where practical. Where full automation is not feasible in the first slice, the PR must include the strongest practical diagnostic or manual validation plan.

Behavior Acceptance criterion Why it matters First slice that must prove it
Journal replay after refresh/reconnect reconnect or restart after events have been journaled can replay from cursor without duplicate transcript/tool/reasoning state proves the browser contract is replayable and duplicate-safe journal/replay slice
Terminal replay completed/failed/cancelled runs replay terminal state and do not duplicate transcript content prevents stale spinner and duplicate-message regressions journal/replay slice
Interrupted/stale run diagnostics if WebUI restarts while execution is still owned by the WebUI process, replay shows the last journaled state and a clear interrupted/stale diagnostic instead of pretending the run kept executing keeps slice 1 honest before a runner exists journal/replay slice
Execution survives WebUI restart active execution outlives the main WebUI process, reconnect discovers the active run, ordered replay catches up, and controls such as cancel still work proves execution ownership actually moved out of the request process runner/sidecar or external-runtime slice
Cancel during tool call cancel emits one terminal cancelled state and no stale writeback catches historical stream ownership races control migration slice
Cancel during reasoning partial/reasoning content is preserved cleanly and final state is not provider-error catches cancellation classification regressions control migration slice
Approval request/response approval survives observation, browser response reaches runtime, result is replayable approval callbacks are cross-cutting and easy to orphan approval migration slice
Clarify request/response clarify survives observation, browser response reaches runtime, result is replayable same risk as approval, different UI/control path clarify migration slice
Slash commands /compress, /branch, /retry, and other supported commands keep current semantics command behavior should not be reimplemented ad hoc command capability slice
Model switch mid-session provider/model changes route through the correct runtime context prevents provider/source-of-truth drift adapter control slice
Workspace context run receives the session workspace and attachments context preserves workbench value adapter control slice
Multi-profile isolation profile-specific runs write/read the correct Ares home and memory protects #2134-family isolation concerns runner/profile slice
Queue/continue follow-up input during live/resumable work obeys Ares semantics prevents parallel continuation model control migration slice
Goal continuation goal status/control survives the adapter boundary goal logic is lifecycle-sensitive goal capability slice
Voice mode voice-originated input uses the same run/event/control contract prevents alternate input path drift adapter parity slice
Projects context project metadata remains visible and correct across run replay preserves session/workbench organization adapter parity slice

Artifact 4: Slicing Plan and Reversibility

Slice 0: Spec PR

Scope:

Revert path: revert the docs PR.

Slice 1: Append-only journal/replay beside the legacy path

Pre-authorized only after this spec is reviewed and accepted in #1925.

Scope:

Non-goals:

Revert path:

Success criterion:

  1. Start a non-trivial WebUI run.
  2. Refresh/reconnect the browser, or restart WebUI after events have already been journaled.
  3. Rediscover the run from journal metadata.
  4. Replay from cursor without duplicate visible transcript content.
  5. Render the same already-journaled token/reasoning/tool/status/terminal state the workbench would have rendered without the reconnect.
  6. If WebUI restarted while execution was still owned by the WebUI process, show an explicit interrupted/stale diagnostic rather than claiming the active run kept executing.

Slice 2: Adapter interface over the journaled legacy path

Status as of 2026-05-17: shipped. PR #2416 defined the adapter-seam contract, PR #2424 added the default-off LegacyJournalRuntimeAdapter seam, and PR #2438 kept /api/chat/start response shape identical between legacy-direct and the flagged legacy-journal path. Slice 2 remains a reversible boundary change, not a sidecar or execution-ownership move.

Scope:

Revert path: switch the feature flag back to direct legacy path.

Slice 2 interface contract

The Slice 2 seam should introduce a deliberately small RuntimeAdapter boundary without changing the execution backend. The first implementation is a LegacyJournalRuntimeAdapter that delegates to the existing WebUI-owned streaming path and reads the Slice 1 journal for status/replay. That makes the adapter a protocol translator over the current backend, not a new runtime owner.

Minimal interface shape:

class RuntimeAdapter:
    def start_run(self, request: StartRunRequest) -> RunStartResult: ...
    def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream: ...
    def get_run(self, run_id: str) -> RunStatus: ...
    def cancel_run(self, run_id: str) -> ControlResult: ...
    def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ...
    def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ...
    def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ...
    def update_goal(
        self,
        session_id: str,
        action: Literal["set", "pause", "resume", "clear", "status", "edit"],
        text: str | None = None,
    ) -> ControlResult: ...

queue_message is named for the legacy queued-message payload shape: it accepts follow-up chat text rather than arbitrary runtime input. The method name does not require a new HTTP route. Today /queue is primarily browser-side queue/drain behavior; the adapter method enters the protocol so a later queue/continue slice has a typed control surface, but route wiring remains deliberately staged until the exact legacy entry point and ordering/idempotency contract are explicit.

For update_goal, the action argument is the bounded adapter capability label. During the legacy-journal slice, the legacy goal parser still receives the full text payload and remains authoritative for details such as the body of set <goal text>. Future slices must not route goal semantics from action alone; doing so would drop the goal body and change /api/goal behavior.

Required data classes / payload fields:

Type Required fields Notes
StartRunRequest session_id, message, attachments, workspace, profile, provider, model, toolsets, source, metadata Mirrors current /api/chat/start inputs without introducing new behavior.
RunStartResult run_id, session_id, stream_id, status, started_at, cursor, active_controls stream_id may remain the legacy stream id during Slice 2.
RunStatus run_id, session_id, status, last_event_id, terminal_state, active_controls, pending_approval_id, pending_clarify_id Backed by live legacy state plus journal/session metadata.
RunEventStream ordered events matching Artifact 1, resumable from cursor Can be implemented by existing SSE + journal replay at first.
ControlResult accepted, status, event_id, safe_message, optional internal payload Controls may still call existing handlers in Slice 2. Public HTTP responses must not leak adapter-only fields unless a later RFC expands them.

The interface is intentionally narrower than a runner. It does not own AIAgent, tool execution, callback queues, cancellation flags, approval callbacks, or clarify callbacks in Slice 2. Those remain in the legacy path until their individual migration slices.

Slice 2 feature flag and revert contract

Slice 2 should be guarded by one WebUI-local setting/environment flag, for example ARES_WEBUI_RUNTIME_ADAPTER=legacy-journal with default legacy-direct until the seam is proven. The flag selects only the route/adapter entry point:

legacy-direct   -> current /api/chat/start and /api/chat/stream path
legacy-journal  -> RuntimeAdapter facade over the same legacy execution path + journal

Reverting must be operationally boring:

  1. set the flag back to legacy-direct,
  2. restart WebUI if needed,
  3. existing session transcripts and journal files remain readable,
  4. no migration or data deletion is required.

The PR that introduces the seam should include a source-level regression that the default path remains legacy-direct and that the adapter flag is the only way the new entry point is selected.

Slice 2 backend mapping

Adapter method Slice 2 backend Explicit non-goal
start_run call the existing chat-start preparation and legacy _run_agent_streaming path do not move AIAgent construction or thread ownership
observe_run combine existing live SSE fan-out with journal replay cursor semantics do not build a second renderer or event protocol
get_run derive status from session metadata, live stream presence, and journal terminal state do not make STREAMS authoritative for durable run existence
cancel_run delegate to existing cancel handler/control path do not redesign cancellation semantics yet
respond_approval delegate to existing approval response path do not persist approval callbacks in the main server as a new adapter-owned queue
respond_clarify delegate to existing clarify response path do not persist clarify callbacks in the main server as a new adapter-owned queue
queue_message delegate to existing queue/continue path when that slice is accepted do not invent a parallel continuation buffer or run scheduler
update_goal delegate to existing goal command/control path when that slice is accepted do not move goal evaluation or continuation ownership into the adapter

Any implementation that needs a new long-lived queue, agent cache, cancellation registry, or callback registry inside the main WebUI process is out of scope for Slice 2 and should become a spec amendment before code lands.

Slice 2 acceptance tests

Before the seam is enabled by default, tests should prove at least:

These tests are adapter-seam tests, not runner-survives-restart tests. The execution-survives-WebUI-restart gate remains deferred to Slice 4.

Slice 3: Control migration

Status as of 2026-05-18: Slice 3a cancel routing shipped in v0.51.86 via #2479, Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507, and the Slice 3c queue/continue + goal gate shipped in v0.51.90 via #2509. Cancel was the smallest control-plane migration because it already had one clear browser affordance, one active-run target, and an existing legacy handler to delegate to. Approval and clarify then proved the same protocol-translator shape for user-mediated callback controls. Queue/continue and goal are the final pre-runner control migration because they can change run lifecycle semantics rather than just resolve an already-pending control.

Scope:

Revert path: per-control feature flags or route-level fallback to legacy control handlers.

Slice 3a: Cancel control gate

The first control migration should route Stop Generation through the RuntimeAdapter.cancel_run(...) seam while preserving the current legacy cancel semantics. It should not introduce a new cancellation registry, worker-owned signal table, or sidecar boundary. During this slice, cancel_run is still a protocol translator over the existing cancellation path.

Acceptance properties:

  1. Same visible result as legacy Stop Generation. A cancelled turn still emits one terminal cancelled/interrupted state and preserves any already streamed partial assistant content according to the existing cancellation contract.
  2. Adapter flag is behavior-preserving. With ARES_WEBUI_RUNTIME_ADAPTER=legacy-journal, Stop Generation uses RuntimeAdapter.cancel_run(...); with the default legacy-direct path, the current route remains available as fallback.
  3. No new runtime-surrogate state. The implementation must not add a second CANCEL_FLAGS-like map, cached AIAgent table, long-lived queue, or local callback registry inside the main WebUI process.
  4. Journal/status coherence. After cancellation, replay and session reload classify the turn as cancelled/interrupted rather than stale/unknown, and the terminal state is visible through the same journal/session diagnostic surface used by Slice 1.
  5. Idempotent duplicate cancel. Repeating cancel for the same run should be safe: one terminal result is recorded, later attempts return a bounded ControlResult such as not-active rather than creating extra terminal events or resurrecting stale stream state.

Suggested regression coverage:

Non-goals for Slice 3a:

Slice 3b: Approval and clarify control gate

The next control migration should cover approval and clarify together as one gate, but not necessarily one implementation commit. They are distinct browser widgets, but architecturally they share the same high-risk shape: the agent loop pauses on a live callback, the browser presents a user-mediated decision, and the runtime must resume from a bounded response without orphaning callback state.

During Slice 3b, RuntimeAdapter.respond_approval(...) and RuntimeAdapter.respond_clarify(...) remain protocol translators over the existing legacy callback paths. They must not create a second approval queue, clarify queue, callback registry, pending-prompt table, or runner-owned wait loop inside the main WebUI process.

Acceptance properties:

  1. Same visible result as legacy approval / clarify. Existing approval cards, clarify prompts, choices, denial paths, and resumed-agent behavior remain unchanged for users. The adapter flag changes only the route/control entry point.
  2. Stable response contracts. Existing approval and clarify HTTP endpoints keep their current browser-facing response shapes. Adapter-only fields such as internal status strings, callback ids, or active-control metadata must not leak into public responses unless a later RFC explicitly expands the contract.
  3. Bounded missing-prompt behavior. Responding to a non-existent, already resolved, stale, or expired approval/clarify id returns a bounded ControlResult such as not-active / expired / unsupported; it must not block the request, recreate a callback, or synthesize a success path.
  4. Replayable request and resolution events. Approval/clarify request and resolution events remain journal-visible so reload/reconnect can show the last safe state. Slice 3b does not have to make pending approvals survive a WebUI process restart while execution is still in-process; that property belongs to the runner/sidecar gate.
  5. No new runtime-surrogate state. The implementation must not add new process-local global maps, long-lived queues, or callback registries under adapter-specific names. If the existing legacy callback path needs more state to satisfy the route, stop and amend this RFC before landing code.
  6. Idempotent duplicate responses. Repeating the same approve/deny/clarify response is safe: the runtime accepts at most one response for the pending request, records one resolution event, and later attempts return bounded not-active/expired status without resuming the run twice.

Suggested regression coverage:

Non-goals for Slice 3b:

Slice 3c: Queue/continue and goal control gate

The next control migration should specify queue/continue and goal before any code routes those actions through RuntimeAdapter. They may ship as separate implementation PRs, but they should share one gate because both affect what the agent does after the current user turn instead of merely resolving a pending prompt. Queue/continue controls append or schedule follow-up input against live or resumable work; goal controls set, pause, resume, clear, or inspect a standing cross-turn objective. Both can accidentally create a second continuation model if WebUI buffers or evaluates them independently.

During Slice 3c, RuntimeAdapter.queue_message(...) and RuntimeAdapter.update_goal(...) should remain protocol translators over the existing legacy queue/goal paths. They must not create a WebUI-owned run queue, goal evaluator, continuation scheduler, agent loop, or sidecar substitute inside the main WebUI process.

RuntimeAdapter.update_goal(...) controls goal state mutations only. Post-turn goal evaluation and the decision to continue remain in the existing agent conversation loop until the later runner/sidecar slice moves execution ownership; Slice 3c must not move that evaluator into WebUI or the adapter.

Acceptance properties:

  1. Same visible result as legacy queue/continue and goal. Existing /queue and /goal semantics, browser status affordances, paused/resumed states, and post-turn continuation behavior remain unchanged for users. The adapter flag changes only the route/control entry point.
  2. Stable response contracts. Existing queue/continue and goal HTTP or command responses keep their current browser-facing shapes. Adapter-only run metadata, internal status, or capability details must not leak into public responses unless a later RFC explicitly expands the contract.
  3. Bounded unavailable-control behavior. Requests for a missing run, unsupported profile, inactive session, paused/cleared goal, or stale queued continuation return bounded ControlResult states such as not-active, unsupported, or conflict; they must not create a phantom run, resurrect a dead stream, or silently enqueue work against the wrong session.
  4. Replayable lifecycle/status evidence. Queue/continue submission, goal status changes, and resulting post-turn continuation decisions remain visible through the journal/session diagnostic surface where the legacy path already emits equivalent state. Slice 3c does not have to make queued follow-ups or goals survive a WebUI process restart while execution is still in-process; that stronger property belongs to the runner/sidecar gate.
  5. No new runtime-surrogate state. The implementation must not add a second process-local queue, goal table, scheduler, cached-agent registry, or continuation loop under adapter-specific names. If the existing legacy path cannot support the route without new ownership state, stop and amend this RFC before landing code.
  6. Ordering and idempotency are explicit. Repeating the same queue/continue request should not duplicate follow-up work unless the legacy path already defines that behavior. Goal pause/resume/clear/status operations should be safe to repeat and should report one coherent state.

Suggested regression coverage:

Non-goals for Slice 3c:

Slice 4: Runner process / sidecar boundary

Slice 4 is the first gate that may move active execution ownership out of the main WebUI request process. It should start as a docs/test contract PR before any runner code lands. Slice 1’s journal/replay layer has shipped and passed active validation, Slice 2’s default-off adapter seam has shipped, and Slice 3’s cancel/approval/clarify/goal control routing has proven the protocol-translator pattern. Queue remains staged unless maintainers explicitly ask for a separate pre-runner queue route.

The Slice 4 implementation must not make the adapter a new runtime surrogate. The runner boundary may own active execution, process supervision, run lifecycle, and callback state, but those responsibilities must be centralized behind the adapter/runner contract rather than recreated as scattered globals in the main WebUI server.

Scope:

Revert path: disable runner backend and fall back to journaled legacy backend.

Slice 4a: Runner contract gate

Before runner code lands, define a narrow contract that covers:

  1. Backend selection and rollback. The existing legacy-direct and legacy-journal paths remain available. Any new runner backend is feature-flagged, default-off, and revertible by switching the adapter mode back to legacy-journal without deleting sessions or journal files.
  2. Process ownership. The runner, not the main WebUI request process, owns AIAgent construction/reuse, active run execution, cancellation flags, approval/clarify callback wait state, and post-turn continuation evaluation for runs assigned to that backend.
  3. Durable observation. The main WebUI server observes through RuntimeAdapter.observe_run(...), get_run(...), and the journal cursor. A WebUI restart must not be required for the runner to finish writing ordered events and terminal state.
  4. Restart/reattach success criterion. Start a long-running run, restart only ares-webui.service, reload the session, rediscover the active or terminal runner-owned run, replay/catch up from cursor without duplicate transcript / tool / reasoning state, and preserve cancel if the run is still active.
  5. Control parity. Cancel, approval, clarify, goal status/control, and any accepted queue/continue behavior route through adapter methods with stable browser response shapes. Unsupported controls return bounded ControlResult states instead of silently falling back to stale in-process state.
  6. Profile/workspace isolation. Runner startup receives explicit profile, workspace, attachments, model/provider, toolset, and source metadata rather than relying on process-global environment mutation in the WebUI server.

Suggested contract tests before implementation:

Non-goals for Slice 4a:

Slice 4b: Runner adapter client facade

Status as of 2026-05-20: shipped in v0.51.94 via #2599.

The first code slice after the Slice 4a contract should be a small RunnerRuntimeAdapter facade that delegates to an injected runner client. This is still not the runner process itself. Its job is to pin the adapter-facing normalization rules before route wiring or process supervision lands:

The implementation remains default-off until a later slice adds an actual runner client/backend and explicit route selection.

Slice 4c: Feature-flagged runner backend and restart/reattach harness

Status as of 2026-05-21: shipped in v0.51.105 via #2696. The code adds a default-off runner-local adapter selection point plus factory wiring for an injected runner client, while keeping live browser chat routes on the legacy backend. The restart/reattach harness remains synthetic/fake-runner based until a later slice introduces a supervised runner process.

After the facade exists, the next narrow implementation slice should add a real runner-client/backend selection point and a synthetic restart/reattach harness, without routing normal browser chat to that backend yet.

Scope:

Acceptance tests for Slice 4c:

  1. Default-off selection. legacy-direct remains the default; runner-local or any later runner mode is selected only by an explicit feature flag.
  2. No route-shape drift. Adding the runner backend does not expand public /api/chat/start, cancel, approval, clarify, goal, or status response shapes while the route remains legacy-backed.
  3. Restart/reattach harness. A fake or local runner fixture can start a run, discard the first WebUI adapter instance, recreate the adapter, and still observe ordered events plus terminal/live status from durable runner-owned state.
  4. Control bounds. Cancel / approval / clarify / queue / goal controls route through the runner client only when the runner backend is selected, and unsupported controls return bounded ControlResult values without consulting legacy process-local state.
  5. No runtime-surrogate globals. The main WebUI server must not gain new module-level maps for runner-owned streams, cancellation flags, pending approval/clarify callbacks, cached agents, or goal/queue schedulers.

Non-goals for Slice 4c:

Slice 4d: Supervised runner backend route gate

Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a docs/test contract: it defines the default-off route-selection requirements but does not itself route live chat to a runner backend.

After runner-local selection exists, the next reviewable gate should define the first supervised/local runner backend and the route-selection harness before live browser chat can use it. This is still a contract/test slice first: no default-on runner mode, no removal of legacy-direct or legacy-journal, and no claim that production WebUI turns survive restart until the harness demonstrates it.

Scope:

Acceptance tests for Slice 4d:

  1. Route remains default-off. Unset ARES_WEBUI_RUNTIME_ADAPTER and legacy-direct keep /api/chat/start on the existing path; runner-local is the only mode allowed to select the runner route.
  2. Restart/reattach harness proves ownership moved. A fake or local runner starts a run, the first WebUI server/adapter instance is discarded, a new adapter instance discovers the same active or terminal run, replay catches up from cursor, and cancel remains available if the run is still active.
  3. No public response-shape drift. Chat start and control responses remain stable while adapter-only fields stay internal or explicitly documented as a new contract revision.
  4. No runtime-surrogate globals. The main WebUI server does not gain new module-level maps for runner-owned streams, cancel flags, approval/clarify callbacks, cached agents, goal state, or queue schedulers.
  5. Explicit context payloads. Runner startup carries session, profile, workspace, attachments, provider/model, toolsets, source, and metadata as payload fields rather than depending on process-global environment mutation in the WebUI server.

Non-goals for Slice 4d:

Slice 4e: Default-off runner chat-start route-selection harness

Status as of 2026-05-24: shipped in v0.51.129 via #2794. The route-selection harness now makes adapter mode selection explicit: legacy-direct remains the default, legacy-journal still delegates to the existing journaled legacy path, and runner-local returns a bounded not-configured response instead of silently starting an in-process legacy run.

The first implementation after the Slice 4d gate should wire the /api/chat/start selection point to the existing RuntimeAdapter factory without adding a supervised runner process yet. The harness must make the selection behavior explicit: legacy-direct stays default, legacy-journal continues to delegate to the legacy in-process stream path, and runner-local does not silently fall back to legacy when no runner client is configured.

Scope:

Acceptance tests for Slice 4e:

  1. Default remains legacy-direct. With no adapter env var, /api/chat/start keeps using _start_chat_stream_for_session(...) directly.
  2. Legacy-journal remains behavior-preserving. The flagged legacy adapter still delegates to the same stream-start helper and preserves the public response shape.
  3. Runner-local does not fallback silently. If runner-local is selected but no runner client exists, the route returns a bounded error instead of starting a WebUI-owned legacy run behind the operator’s back.
  4. No adapter-internal response drift. run_id, status, and active_controls remain internal until a later contract explicitly exposes them.
  5. No runtime-surrogate globals. The harness does not add runner-owned stream, cancel, approval, clarify, cached-agent, goal, or queue maps to the main WebUI process.

Non-goals for Slice 4e:

Slice 4f: Supervised local runner client backend gate

Status as of 2026-05-31: shipped in v0.51.188 via #3073 / #3274. The client transport is now implemented behind ARES_WEBUI_RUNNER_BASE_URL and remains default-off. With no endpoint configured, runner-local still returns the bounded not-configured path and the live in-process _run_agent_streaming path is unchanged. When configured, WebUI uses a JSON HTTP client boundary for start / observe / status / controls and bridges observed runner events through the existing SSE stream route rather than adding main-process runner-owned maps.

The release added two security hardening checks while absorbing #3073: HttpRunnerClient rejects non-http(s) base URL schemes and uses an opener that does not follow redirects, so a misconfigured or compromised runner cannot leak a Bearer token to a redirected host. The release gate reported full pytest passing and independent default-off/inert-path review.

This bridge is intentionally a WebUI consumer transport seam: the configured runner must emit events that are already compatible with the browser SSE event names/payloads, or a later runner-owned normalization layer must translate Ares runtime families such as token.delta, tool.started, and done before they reach this route.

After the configured runner-client boundary ships, the next reviewable step is not to make runner-local the default. It is to define the first supervised runner process harness that can actually own AIAgent execution behind that client boundary and prove restart/reattach with a real local runner, not just a configured external endpoint or fake-runner fixture.

This slice was the client-boundary implementation gate. The goal was to pin the minimum runner client behavior so the implementation could not become a renamed STREAMS / CANCEL_FLAGS / cached AIAgent surrogate inside api/routes.py.

Scope:

Acceptance tests for Slice 4f:

  1. 501 path replaced only when configured. Unset adapter mode and legacy-journal behavior stay unchanged; runner-local uses the supervised runner client only when the backend is explicitly configured.
  2. Restart/reattach proves ownership moved. Start a runner-owned run, discard/restart the WebUI server process, rediscover the active or terminal run from durable runner/journal state, replay from cursor without duplicates, and preserve cancel if the run is still active.
  3. No runtime-surrogate globals. The main WebUI server does not gain new module-level maps for runner-owned streams, cancel flags, approval/clarify callbacks, cached agents, goal state, queue schedulers, or child-process run registries. Supervision state belongs to the runner client/backend boundary.
  4. Stable browser contracts. Successful chat-start responses remain limited to the legacy-compatible field whitelist unless a later contract revision explicitly exposes run_id, status, or active_controls.
  5. Bounded control gaps. Unsupported runner controls return safe unsupported, not-active, or conflict results; they must not fall back to legacy callback queues for a runner-owned run.

Non-goals for Slice 4f:

Slice 4g: Supervised local runner process harness gate

After #3073 / #3274, WebUI has an explicit configured-runner HTTP client and SSE consumer bridge, but it still does not ship the supervised runner process itself. The next gate should define the smallest local runner harness that can own AIAgent execution outside the main WebUI request process while being consumed through the already-shipped runner-local client boundary.

Scope:

Acceptance tests for Slice 4g:

  1. Process ownership moved. A local runner process, not ares-webui, owns AIAgent construction/reuse and active run execution for runner-local runs.
  2. Restart/reattach with a real runner. Start a non-trivial runner-local run, restart only ares-webui, reload the session, rediscover the active or terminal runner-owned run, replay/catch up from cursor without duplicate transcript/tool/reasoning state, and preserve cancel if still active.
  3. No runtime-surrogate globals in WebUI. The main WebUI server still does not gain new module-level maps for runner-owned streams, cancel flags, approval/clarify callbacks, cached agents, child process run registries, goal state, or queue schedulers.
  4. Default-off and reversible. Unset ARES_WEBUI_RUNNER_BASE_URL or switch the adapter mode back to legacy and the existing in-process path remains available without session or journal migration.
  5. Runner health and failure are observable. A missing, unhealthy, or crashed runner returns bounded diagnostics and terminal/interrupted state rather than silently falling back to WebUI-owned execution for a runner-selected run.

Non-goals for Slice 4g:

First Meaningful Success Criteria

The first meaningful milestones are deliberately split.

Journal / Replay Gate

This gate belongs to Slice 1. It does not prove active execution survives a WebUI process restart, because execution is still owned by the WebUI process in this slice.

It proves:

  1. A WebUI run emits append-only journal events with stable cursors.
  2. Browser refresh/reconnect can replay already-journaled events from cursor.
  3. Terminal done, error, or cancelled state replays without duplicate transcript content.
  4. Tool/reasoning/status state can be reconstructed from replayed journal events.
  5. If WebUI restarts before execution ownership has moved out of process, the UI can show a clear interrupted/stale diagnostic for the last journaled run state.

Execution-Survives-WebUI-Restart Gate

This stronger gate belongs to the runner/sidecar or external-runtime slice, not Slice 1. It proves execution ownership has actually moved out of the main WebUI request process:

  1. Start a long-running run from WebUI.
  2. Restart only ares-webui.
  3. Keep the active run executing outside the restarted WebUI process.
  4. Reload the browser/session.
  5. Rediscover the active run and replay/catch up from cursor.
  6. Preserve the rendered workbench state without duplicate transcript content.
  7. If the run is still active, cancellation still works.

If this works without moving runtime ownership into a new pile of process-local globals, the architecture is moving in the right direction.

Open Questions