Transport wire contract
This document is the single owner of the framing and connection semantics that every transport
carrying the frontend protocol must reproduce. It exists because the browser port (#808) adds a
second transport, a WebSocket gateway (#811), beside the existing Unix domain socket
(src/server/transport/unix_jsonl.py), and the two are built by different people in different
languages. Anything a transport can get wrong independently is written down here so the two ends
cannot silently disagree.
Scope
This contract is transport level, not payload level. It specifies how protocol messages are framed,
which connection carries which role, how errors and disconnects are expressed, and how liveness and
flow control behave. It does not specify the schema of the messages themselves: those are the
Pydantic models in src/server/api/protocol.py (built on the light control envelope in
src/server/control_envelope.py), generated into
clients/libs/contracts/src/generated/. A transport relays whole protocol messages opaquely. If the
payload schema changes (for example a version bump), this layer does not.
One payload invariant is relevant to framing: a recorded RunEvent is bounded by
src/server/events.py:MAX_SERIALIZED_RUN_EVENT_BYTES before it reaches either transport.
src/server/events.py:EventStore.append
keeps an ordinary event byte-for-byte unchanged. If an event is larger, it retains prefixes of its
variable-size payload values on Unicode code-point and collection-entry boundaries and carries
truncated: true; web and TUI clients render that marker. The durable log holds the bounded event,
so live delivery and every later replay observe the same fact. The WebSocket batch chunker still
handles only whole events and has no payload-specific branch.
It also does not specify how the WebSocket gateway serves the browser bundle over plain HTTP. The
response headers, the Content-Security-Policy, and the token-free /assets/* route are gateway
serving decisions with no counterpart on the Unix transport, so they are owned by
web-development.md rather than being WP-* decision tokens here.
The authoritative message taxonomy is three unions in src/server/api/protocol.py:
| Direction | Union | Members |
|---|---|---|
| client to server | ProtocolRequest | command.*, query.*, subscribe (17 types) |
| server to client, control | Response | one per request, correlated by request_id |
| server to client, stream | ServerMessage | subscribed, event, event_batch, active_executions_chunk, protocol_error |
PROTOCOL_VERSION is 1. Every model sets extra="forbid", which is load bearing: a client probes
for an optional field by sending it and treating rejection as "this server does not have the field"
(see WP-CAPABILITY-PROBE).
query.iterations is an additive control request in that generated payload
schema. A response returns one bounded page of roots or direct children, or a
typed unavailable result. Its opaque cursor binds projection revision, parent,
and offset; clients must replay an identical page idempotently and reject a
different page at an already applied cursor. Transports do not decode the cursor
or interpret iteration kinds. Older servers reject the request under the same
strict request parsing, and clients retain event-derived round navigation as the
compatibility path.
Run trace events
This is payload level, recorded here because every client folds it the same way. The run trace
(see "The run trace" in flows.md) reaches clients as three generic run events,
span_started, span_updated and span_ended. Each event carries the whole span in
data.span, plus the event's own sequence and timestamp, never a field delta. The runtime
records the spans. After each durable core commit, the run's commit observer
(vibesys.run.integration) diffs the committed trace against the one before
(vs_core.span_changes) and emits one event per span that started, changed or ended, in the
trace's order. A span that starts and ends within one commit gets a span_started and then a
span_ended. No search code is involved.
- Folding. Replace by
span.id, insequenceorder. A span keeps the position it first started at, so each span comes after its parent.RunSnapshot.spansis that fold at the snapshot'ssequence. The server folds the same events as a journal listener (src/server/trace.py), live and on replay. A client starts from the snapshot and folds the events after it (@vibesys/run-state/trace:traceFromSnapshot,foldTrace). Folding every span event gives the snapshot's spans; folding a prefix gives the trace at that prefix's last sequence. - Generic. A span's
label.kindnames a kind its search declared at@search. A client that does not know the kind renders it fromname,label.title,status,label.attrs_jsonandlabel.links, so a new kind needs no protocol change. A kind's attribute schema is in theProtocolDocumentunderspan_attributes.<orchestration>.<kind>, soattrs_jsondecodes to a generated TypeScript type. An orchestration declares its kinds once, at@search(spans=...). Its core policy carries the same declaration (CorePolicy.span_kinds), andpython -m entrypoints.protocol_schemaexports the built-in ones (tests/server/test_protocol_schema.pyholds the committed schema to it). Every search labels the same kinds (vibesys.search_spans):planning_call,workstream(verdictsaccepted,rejected; linkbuilds_on),agent_turn,gate(verdictspassed,failed_check,slower) andadoption(verdictsadopted,not_adopted; attributescandidate,metric_value,vs_baseline). The adoption span is the operation that adopts the run's winner; a run that has not adopted yet has none, andcandidateis null when the input itself stands. Clients read the run's candidates from these spans alone (@vibesys/run-state/candidates, folded linearly in events): aworkstreamis one attempt; attempts sharinghypothesis_idare one candidate;ideais what it tries;probemarks one that only looks; an accepted one names therevisionit kept, which the adoption'scandidatenames; itsbuilds_onlink names the workstream it built on; and a gate'sfailednames the trusted check that rejected it with its progress (93 of 100 trials). A search that plays one path links each round to the round that kept its base, so its candidates form a chain. Anagent_turnnames itsagent(its session's id, stable across replay and restart),role,modelandstarted_by: the agents whose work started it, none when the search did, several for fan-in. Clients read the run's agents and who started whom from these turns alone (@vibesys/run-state/agents, constant work per event): an agent's status and activity are its latest turn's, which runs within its nearest labelled ancestor. A gate span covers one measurement; each of its stages (build,accuracy,benchmark) has its owngate_started/gate_finishedevents (GateKind) and its own log stream, whoseGateStageowner names the span. The kinds are additive withinPROTOCOL_VERSION1: a client that does not know one renders it from its name and label. - Ids.
flow:<path>#<n>for a flow task,op:<decision id>for an operation, both plain strings. A span id is stable across replay and restart. - Contract choices.
waiting_on[].kindisoperation,slot,timeorstop. There is nofirst: a flow waiting onctx.first(a, b)has one entry per source.failure.kindisoutcome(for afailedspan) orinterruption(for astoppedspan). There is nodefect: a defect crashes the step that raised it, and that step is never committed, so no span event reports it. The run's failure event carries it instead. The run'sFinishhas no span: issuing it ends every open span, and the root span endsdone,failedorstoppedby the proposed result. - Agent turns. An agent-turn span carries
invocation, the invocation id the turn ran as. The runtime owns the mapping from that id to the span: the server stamps the envelope'sspan_idof every event whoseexecution_idran as a recorded agent-turn span (the turn's output chunks, tool calls and results, activity, usage) andturn_usage_recorded.data.span_idwith it. An event recorded before its span, or one outside a turn, carries none; events recorded before the field existed carry none. Clients never join the two, so a transcript reaches a span byspan_idand never by invocation, name or time. A log stream's owner names its span inspan_idwhen the writer knows it, andlogs.listfilters by it (see desktop-connection.md). - Typed replies. A structured turn ends with the agent's raw JSON text, which stays in
agent_execution_finished.data.result. When that text is a valid reply of the turn's role model,data.replycarries it as data:kind(the reply model's name; a role that may wait reports the result or the wait the envelope holds),json_schema(that model's JSON Schema) andvalue(the validated reply as JSON). A client learns which fields are text, choices or lists fromjson_schemaand keeps no field list of its own; a reply that fails validation has noreply.replyis optional and additive withinPROTOCOL_VERSION1. - Evolution. The three events,
RunSnapshot.spans,RunEvent.span_id,TurnUsageData.span_idandLogOwner.span_idare additive withinPROTOCOL_VERSION1. A client that predates them counts the events as unknown kinds and ignores the new optional fields.
Run overview
The run's header facts are one contract, RunOverview (overview_version: 1, defined once in
vibesys.events and used by both the core event and the wire). The run's run_started event
carries it, built from the task's input bundle (vibesys.run.overview):
objective.textis the task'sOBJECTIVE.md(Markdown) andobjective.metricthe task's headline metric (itsmetric, else the scalar benchmark result's metric).objective.correctness_checkis the argv of the accuracy check a candidate must pass.benchmarks.evaluatoris the evaluator package the manifest pins (name,version). It is null for a source-tree evaluator, which has no version.
RunSnapshot.overview is that declaration (the server keeps it from the journal, live and on
replay), with objective.unit and objective.direction filled in from the run's own
measurements and objectives once it has them. It is null before the run started. Trajectories and
key numbers are not part of it. They are queries over the run trace's gate and workstream spans:
@vibesys/run-state/overview provides gateTrajectory, bestVsBaseline and workstreamCounts
beside overviewFromSnapshot and foldOverview. query.performance and PerformanceContext
stay unchanged for the frozen web run page. Gate tools' own versions are not measured today, so
the overview does not carry them.
Waiting for capacity to return
A run paused on a provider capacity limit (quota_paused) resumes when the operator sends
command.resume. The request's when (now, the default, or capacity_returns) says how:
nowresumes at once. A wait for capacity in progress ends withquota_wait_cancelled {provider, reason: resumed}, thenquota_resumed {reason: operator}.capacity_returnskeeps the run paused until capacity should be back, then resumes it by itself withquota_resumed {reason: wait_elapsed}. The run server arms the same wait thewaitpolicy uses (vs_runtime'scapacity_wait): the provider's reset time plus a five-second margin, or, when the provider gave none, the quota policy'sretry_seconds; a turn that stops on the limit again after such a wait waits again by itself, so the run polls until the turn goes through. Each wait it arms emitsquota_waiting {provider, resumes_at}, whereresumes_atis the epoch second it resumes,nullwhen it is polling without a reset time. The command acks{action: resume, status: pending}; a run not paused on a capacity limit answers the protocol errorcapacity_wait_unavailableand nothing changes.fallback: truecannot be combined with it (invalid_request).command.pauseduring the wait cancels it (quota_wait_cancelled {reason: paused}): the run stays paused for the operator. A secondcapacity_returnsreplaces the first, emitting a newquota_waiting.
The wait is held by the live run server: if the server ends, the run ends like any other. when
is a new request field, so a client discovers support for it by WP-CAPABILITY-PROBE: an older
server rejects it as an unknown field.
Framing and connection decisions
Each decision below carries a stable token. The conformance corpus (tests/conformance/, see
tests/conformance/README.md) tags every scenario with the tokens it exercises, and the corpus gate
(clients/tools/scripts/check_conformance_corpus.mjs) fails if any token here is never exercised or a
scenario tags a token that does not appear here. So this document cannot drift from the wire without
a test failing.
WP-GRANULARITY: one frame carries exactly one protocol message
One transport frame is exactly one serialized protocol message. On the Unix transport a frame is one
JSONL line; _write_message serializes one model and appends \n
(unix_jsonl.py:_write_message). On the WebSocket transport a frame is one WebSocket message.
Batching is not framing. event_batch is one protocol message that carries many events in its
events list. The batch is a payload construct (EventBatchMessage in protocol.py), produced by
the server coalescing a watermark-consistent snapshot (unix_jsonl.py:_stream), and it crosses the
wire as a single frame on either transport. A transport never splits or merges frames. The server
may send one batch as several whole event_batch messages, one frame each, so no frame exceeds the
shared client cap (protocol.py:EventBatchMessage.stream_messages_within); folding them in order is
folding the batch. Both Unix JSONL and WebSocket use that projection, so transport choice cannot
change which checkpoint is deliverable.
An active-execution checkpoint can consume most or all of the cap even before its batch adds events:
assignments are user-authored and the list describes every concurrent execution. Whenever the
checkpoint and any one event cannot share a bounded batch, the event frames carry
active_executions: null, meaning "preserve the last complete checkpoint while this replay updates
it", plus active_executions_checkpoint_id, followed by ordered active_executions_chunk messages
with that identity. Their payloads concatenate to the JSON encoding of the generated
ActiveExecutions type. The persistent client stream owns reassembly for one connection generation.
Each deferred event frame folds exactly once into a private, serializable execution projection while
the public state retains its last complete execution checkpoint and statuses. After the complete
payload validates and its SHA-256 matches checkpoint_id, the stream emits one synthetic empty,
non-rebootstrap event_batch; folding it atomically publishes the staged execution projection and
the authoritative checkpoint without retaining or replaying the event frames. An ordinary live
transfer needs only the completed checkpoint. A rebootstrap transfer rebuilds the same state as the
unsplit authoritative batch inside that private transaction instead of reconciling into the
pre-replay fold. A checkpoint prefix is never passed to the run-state fold. Fragments require the
matching deferred batch immediately before their transfer; a new deferred or ordinary authoritative
batch supersedes an interrupted transfer and fences its late fragments. A gap, duplicate,
interleaved unrelated message, changed identity, changed cursor metadata, or content digest mismatch
is a protocol fault. A disconnect discards staging, so fragments from two connections cannot
combine. The assembled UTF-8 payload is capped at 16 MiB and a fragment at 64 Ki Unicode code points;
the 256-fragment generated-schema bound is the matching worst-case count. The server validates the
aggregate and its complete fragment projection before sending any event frame; an oversize snapshot
therefore produces a typed stream_failed error without advancing the peer's cursor. Every
serialized fragment still has to fit the transport byte budget.
WP-FRAME-TYPE: text frames, UTF-8
Frames are UTF-8 text. On the Unix transport the bytes are UTF-8 encoded JSON. On the WebSocket transport, frames are WebSocket text frames (opcode 0x1), not binary frames. Binary framing is reserved for a future payload change and is out of scope here.
WP-NEWLINE: the trailing newline is transport local
The \n the Unix transport appends is a framing delimiter for a byte stream, not part of the
message. A message-framed transport does not carry it. One WebSocket text frame is one message with
no trailing newline. A reader that strips a trailing newline before parsing is correct on both
transports; a reader that requires one is Unix specific and wrong.
A Unix request line is bounded like a WebSocket frame: the server buffers at most
MAX_REQUEST_LINE_BYTES (1 MiB, the gateway's inbound frame cap) of one line. A longer line draws one
failed response with diagnostic code request_too_large, its remaining bytes are dropped up to the
next newline, and the session reads on.
WP-FRAMER: NewlineFramer is Unix only
NewlineFramer (clients/libs/transport/src/newline-framer.ts) reassembles messages from a byte
stream that may split a message across reads or pack several into one read. That problem exists only
on a stream transport. WebSocket delivers whole messages, so the framer does not run on the
WebSocket path. Framing lives in the transport adapter, below the shared client, so each transport
brings its own.
WP-ROLES: one connection per role
The client opens three kinds of connection, and each maps to its own transport connection. This mirrors the three sockets the Unix client opens today and needs no multiplexing.
| Role | Purpose | Lifetime |
|---|---|---|
| control | requests and their responses (command.*, most query.*) | long lived; carries many sequential request/response pairs |
| subscribe | one subscribe then the event stream | one subscription; server streams until the peer goes away |
| chat | one query.chat on its own connection | one long-running request and its single response |
On the Unix transport the control connection reads requests in a loop and writes one response per
request (unix_jsonl.py:_ClientSession.handle); a subscribe request takes over its connection
and never returns to that loop (unix_jsonl.py:_stream). A transport must preserve this: a
subscription owns its connection for the connection's life, and chat is isolated on its own
connection so a long agent turn does not block control traffic behind it.
WP-SUBSCRIBE-ACK: subscribe is acknowledged before any batch, even on failure
The server answers an accepted subscribe with a subscribed message carrying run_id and
latest_sequence before it sends any event_batch (unix_jsonl.py:_stream). If the bootstrap
replay then fails, the server has already sent subscribed and reports the failure as a
protocol_error frame (unix_jsonl.py:_write_stream_error). So a client always sees subscribed
first on a dial the server accepted, and a bootstrap failure is a stream error after the ack, never
a rejected dial. A transport must not reorder or drop the ack.
The acknowledged identity and the run_id stamped on snapshots and events name the same run.
Projection state latches its first non-empty stamped identity and rejects later data for another
run. Empty or absent ids are legacy unknown values, not a request to replace an established owner.
WP-PROTOCOL-ERROR: a protocol error is an in-band frame then close
ProtocolErrorMessage is a normal framed message (type: "protocol_error"). The server writes it in
band and then closes the connection (unix_jsonl.py:_write_stream_error). It is not expressed as a
transport close code. This is not cosmetic: the client suppresses its own disconnect callback after
it reads a protocol_error, because the drop that follows is the expected consequence of the error,
not a separate outage. A WebSocket transport that mapped a protocol error onto a close code instead
of an in-band text frame would change error semantics with no compile-time signal, so it must send
the frame and then close.
WP-DISCONNECT: disconnect detection is transport specific and needs a liveness signal
The Unix server notices a gone client through the connection's non-blocking peer_closed check
(vs_sim/network.py:Connection.peer_closed; on a real socket a recv(1, MSG_PEEK | MSG_DONTWAIT)
that returns empty on FIN), made only while the stream is idle (unix_jsonl.py:_stream). A response
written after the client hung up ends that client's session and nothing else
(unix_jsonl.py:UnixJsonlServer._serve). That probe has no WebSocket equivalent, and a WebSocket that dies without
a close (a slept laptop, a killed tab) leaves the server with no FIN to read. The disposition:
- A clean browser close (tab closed, navigation) sends a WebSocket close frame, which the gateway treats exactly as the Unix FIN: the subscriber count falls and teardown proceeds.
- A hard-dead peer (network partition, power loss) is reaped by a server-initiated WebSocket ping whose pong never arrives. Browsers answer a ping automatically below the JavaScript layer, so this needs no application protocol.
- A frozen or throttled tab still answers pings while reading nothing, so ping liveness cannot see
it. That case is handled by the send-side write deadline below, and by the optional client
heartbeat (
WP-HEARTBEAT).
The bounds are stated, not inherited. The src/server/transport/websocket.py:WebSocketLimits
dataclass names every one of them. The gateway owns the keepalive schedule and deadlines; its
src/server/transport/websocket.py:WebSocketGateway._serve_until_stopped disables the dependency's
automatic keepalive and passes the remaining library bounds explicitly to serve(), so a
websockets upgrade cannot move a bound a subscriber depends on. Three gateway-owned bounds
compose into the keepalive ceiling:
| Bound | Value | Role |
|---|---|---|
ping_interval_seconds | 20s | Idle period before the server probes the peer |
write_deadline_seconds | 40s | Maximum time for the Ping write to pass flow control |
ping_timeout_seconds | 20s | How long a pong may be outstanding before the peer fails |
close_timeout_seconds | 10s | Library bound for an ordinary closing handshake after handler teardown |
A peer that stops answering is aborted after at most 80s: 20s before the probe, at most 40s for its
Ping write, then at most 20s for the Pong. When the Ping write drains promptly, the nominal bound is
40s. The abort moves the connection to CLOSED, which is what the stream loop polls for, so the
subscription is released one _DISCONNECT_POLL_SECONDS later and a non-detached run may then finish
after RECONNECT_SETTLE_SECONDS. A peer that sends a FIN needs none of this. The separate close
timeout still governs graceful handler teardown; the keepalive deadline aborts directly because a
close frame is another write and cannot safely be the escape hatch for a blocked Ping write.
Flow control has two separate bounds in opposite directions, and conflating them is the easy
mistake. max_queue, (32, 8), bounds frames arriving from the peer. write_limit, 32 KiB, is
the send-side high-water mark, and it is the one that carries the contract below.
The send-side overflow policy is to stall the producer, not to drop events and not to disconnect
on the first slow read. Past the high-water mark the producing coroutine suspends in the library's
drain() until the peer catches up. This is deliberately the same shape as the Unix path, where
src/server/transport/unix_jsonl.py:_ClientSession._write_message does a blocking send of the
whole line, and it is what preserves burst batching on both transports: a stalled stream loop is not
reading the journal, so the next subscription_checkpoint coalesces the whole backlog into one
event_batch instead of one frame per event. Slow consumers get fewer, larger batches rather than
lost events.
The stall is bounded. A single frame may stall for write_deadline_seconds, the nominal keepalive
response window (40s) by default; past that the peer is treated as gone and its socket is aborted.
That bound is not optional decoration. websockets ends every write in drain(). The gateway
therefore applies the same deadline to application frames, its owned Ping, and its final close. A
peer that neither drains its socket nor answers pings cannot block the very keepalive that reaps it.
Without the Ping-write deadline such a peer was never reaped at all, its subscription was never
released, and a non-detached run never exited. The socket is aborted rather than closed on deadline
because a close frame is itself a write and would re-enter the same stalled drain().
The Unix path has no equivalent deadline: its blocking write stalls the handler thread indefinitely, which is pre-existing behavior this contract records rather than changes. Its gone-client probe fires only while the stream is idle. So the two transports agree on the stall and differ on the ceiling. A remote client that reaches the Unix transport through the relay gets a ceiling at the relay instead (see The relay).
WP-HEARTBEAT: the application heartbeat is optional and probe advertised
Because browsers do not expose WebSocket ping and pong to JavaScript, a client that wants to detect a
dead server (as opposed to the server detecting a dead client) needs an application-level signal. The
frame shape is reserved here so both ends agree before either implements it: an optional
heartbeat_ms field on SubscribeRequest, capability probed like tail and store_id
(WP-CAPABILITY-PROBE). A server that has the field emits a periodic keepalive frame the client can
time out against; a server that predates it rejects the field, and the client falls back to no
application heartbeat. This reservation only fixes where the frame rides so it is additive.
No server-side heartbeat frame exists, and the server does not need one. The direction this contract
governs, the server detecting a dead client, is served entirely by protocol-level pings, because a
browser's own WebSocket implementation answers them below the JavaScript API (WP-DISCONNECT). Only
the opposite direction, a client detecting a dead server, would use the reserved field, so it stays
reserved and unimplemented on both transports. tests/conformance/scenarios/heartbeat-probe.json
records the reservation: the field is rejected today, and that rejection is the capability probe
working, not a gap. It records rather than pins, because no runner executes it yet
(tests/conformance/README.md).
WP-CAPABILITY-PROBE: capabilities are probed by rejection, not advertised
There is no capability advertisement. A client discovers whether the server supports an optional
request field by sending it and reading the result: acceptance means the field works, a
extra="forbid" rejection means the server predates it. The subscribe fields tail and
store_id are governed this way (SubscribeRequest in protocol.py; the client's
probe-and-fallback in clients/libs/transport/src/persistent-event-stream.ts), and so is
command.resume's when (see Waiting for capacity to return).
The recorded consequence, accepted deliberately: a browser resume that carries store_id against a
server that lacks it costs one extra speculative dial (the rejected probe, then the cursor-only
resume). Probe-by-rejection is kept rather than adding an advertisement frame, because the number of
optional fields is small and an advertisement is a new frame both transports would have to reproduce.
Revisit if the option set grows.
WP-REBOOTSTRAP: a store swap or tail overflow restarts the replay with a non-contiguous sequence
Two conditions make the server abandon a client's cursor and send a fresh bootstrap batch instead of a continuation:
- The run attached its durable event store after the client subscribed, so the batch's
store_iddiffers from what the client folded (unix_jsonl.py:_stream). Sequences are only comparable within one store. - More live output landed in one wait than the
tailbound was willing to replay (unix_jsonl.py:_stream).
In both cases the next event_batch sets rebootstrap: true, supersedes the client's fold rather
than extending it, and its through_sequence is not the client's cursor plus one. The marker means
this is a second bootstrap on an already-open subscription. A rebootstrap that the WebSocket gateway
splits to fit its send cap carries the marker on its first frame only. The first batch of a reconnect
remains rebootstrap: false, even when a changed store_id makes that new connection's first batch a fresh
fold. A client keys continuation on the dial context, the marker, and store_id, not sequence
contiguity. A transport carries these batches unchanged; the rebootstrap decision is server logic,
not framing. An in-loop rebootstrap restarts from what the stream itself delivered (its cursor and
the store that cursor numbers), never from the subscribe request's cursor, so a swapped store is
replayed from its start whether or not the request named a store.
Rebootstrap is the sole projection transition allowed to adopt a different run_id. Within any
ordinary batch, event identity also guards the batch-level active_executions,
through_sequence, and history_after_sequence: if an event is rejected as foreign, those
metadata do not apply. A same-run rebootstrap may retain snapshot state that the replay tail cannot
reconstruct; a changed or unknown identity must start without state owned by the prior run.
WP-IDENTITY: the issuing connection is identified by a stable client id
Two clients on one run is the configuration the browser port creates, and the server must be able to
say which connection issued a request (for #840's acknowledgment attribution, and for the two-client
conformance scenario). This is the optional protocol field client_id. A frontend transport
generates one stable id for its client instance and carries it on control requests, subscriptions,
and dedicated chat requests, including requests sent after a reconnect. Response,
SubscribedMessage, and ProtocolErrorMessage reflect it. An empty id is the backward-compatible
default for a peer that predates attribution.
The field is defined in src/server/api/protocol.py and generated into the TypeScript bindings. The
corpus reserves a two-client scenario that asserts independent attribution with one Unix and one
WebSocket subscriber live in one process.
The relay
A remote client (the desktop over SSH) reaches a run's Unix transport through the relay, the
relay core src/server/relay.py that a host helper run channel becomes after its
run.connect (see desktop-connection.md). It is not a third transport.
The client opens one run channel per protocol connection over its own ssh exec channel, so
WP-ROLES holds unchanged: a control, subscribe, or chat connection is one helper process. The
relay connects to the socket and copies bytes in both
directions between its stdin/stdout and the socket. It does not parse or re-frame: every Unix
transport decision above (WP-GRANULARITY, WP-NEWLINE, WP-PROTOCOL-ERROR, and the rest) holds
through it, and the client keeps its NewlineFramer (WP-FRAMER) because its side is still a byte
stream. The conformance runner tests/conformance/runners/relay.json replays the Unix
scenarios through a real helper's run channel to hold it to that.
Ends. A relayed channel ends with one exit status per outcome, and, whenever the status is not
0, one JSON line on stderr: {"outcome": ..., "exit_status": ..., "detail": ...}
(src/server/host_api.py:ChannelEnd, in the helper's generated schema). The outcomes are
src/server/host_api.py:ChannelEndOutcome:
| Status | outcome | Meaning |
|---|---|---|
| 0 | client_closed | stdin reached end of stream, or stdout was closed: the client went away. No stderr line. |
| 3 | server_closed | The server closed the connection. Everything it sent was relayed first. |
| 4 | run_gone | No socket file, or nothing listening on it: the run is not there. |
| 5 | connect_denied | The socket exists but this user may not connect to it. |
| 6 | connect_failed | Connecting failed another way, including the connect timeout. |
| 7 | server_stalled | A write toward the server made no progress within the write deadline. |
| 8 | client_stalled | A write toward stdout made no progress within the write deadline. |
| 9 | server_failed | A socket read or write failed with an error other than a close. |
| 10 | client_failed | A stdin read or stdout write failed with an error other than a close. |
| 11 | server_unresponsive | The socket sent nothing within the read deadline: the server is wedged. |
Status 1 stays an uncaught error and 2 a usage error. So a client can tell "the run is gone" (4) from "the transport is broken" (5, 6, and the stalls) from "the server ended this connection" (3) without parsing text.
On stdin end of stream the relay closes the socket, which the server reads as the client's FIN
(WP-DISCONNECT), so a closed ssh channel releases a subscription exactly as a closed Unix client
does. Replies still in flight toward a client that closed its stdin are dropped; a client keeps
stdin open for a connection's whole life. In the other direction, a write toward the server that
finds it closed does not end the relay by itself: the server may have written a final
protocol_error frame before its close (WP-PROTOCOL-ERROR), so the relay ends only when its
socket read reaches end of stream, after relaying that frame.
Deadlines. src/server/relay.py:BridgeLimits names every bound:
| Bound | Value | Role |
|---|---|---|
write_deadline_seconds | 40s | How long one write toward either the server or stdout may make no progress |
connect_timeout_seconds | 5s | How long connecting to the socket may take |
read_deadline_seconds | 7d | How long the socket may send nothing before the relay ends |
The write deadline is the hop's answer to the Unix path having none. A half-open ssh hop (a slept
laptop, a partition) stops draining the channel; once ssh's window and the pipe fill, the relay's
stdout write stops progressing, and after 40s the relay closes the socket and exits with
client_stalled. The server then sees an ordinary client FIN, so a dead remote client pins server
resources for no longer than the WebSocket gateway's write_deadline_seconds, which has the same
value. The client-facing hop is idle-reaped by ssh's own liveness instead: when sshd closes the
channel the relay reads end of stream on stdin.
The server-facing hop (the Unix socket) has no such liveness: AF_UNIX has no keepalive, so a
wedged server that neither sends nor closes would hang the relay forever without its own bound. The
read deadline is that bound, checked only against the socket's read (not stdin's, which ssh already
reaps): after read_deadline_seconds of nothing read from the socket, the relay exits with
server_unresponsive. A provider capacity pause can leave the socket legitimately silent for
unbounded time: [agent.quota]'s shipped default policy (pause) waits for the operator with no
bound at all, and the self-resuming policies wait without a bound too unless wait_seconds is set
(vibesys.config.QuotaCfg). No finite deadline is "longer than every legitimate wait" the way the
write deadline is, so this one instead draws the line at a week: long enough that an operator who
left a run paused past it is unlikely to still be waiting on this particular connection, short
enough to eventually recover a genuinely wedged server. Tearing down a relay that outlives it only
ends one viewer's connection, not the run itself.
The conformance corpus
The framing decisions above are enforced by one shared fixture corpus at tests/conformance/, read
by both conformance suites: the client-side fold suite (#812) and the server-side scenario suite
(#811). Neither side keeps a private copy. The corpus has two parts:
tests/conformance/events/holds one fixture perEventType(the event-kind enum in the generated schema). The corpus gate enumerates the enum fromclients/libs/contracts/src/generated/protocol.schema.jsonand fails if a kind has no fixture, so a new event kind cannot land without a fixture.tests/conformance/scenarios/holds the connection scenarios (bootstrap, resume, rebootstrap, capability probes, protocol error, and so on), each tagging the framing decisions it exercises.
See tests/conformance/README.md for the layout and how to run the gate, and
tests/conformance/FORMAT.md for
the fixture and scenario formats. The replay runners that execute the scenarios against a live
transport land with the transports themselves (888b for the client fold runner over the Unix
transport, #811 for the two-transport scenario); this document and the corpus define what they must
reproduce.