feat(remote-crew): run a local session on a connected crew - #7693
Conversation
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS The happy path is honest and legible, but every failure the feature routinely produces — create refused, crew unreachable, peer awaiting approval — is silent on screen. Watch
Suggestions
[UX-REVIEWED] c2526a3 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound relay architecture with fail-closed bindings, but a bound session silently hangs on the peer's first tool-approval — a mainstream path disclosed only in a code comment. Watch
Suggestions
[DESIGN-REVIEWED] c2526a3 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is gathered. Here is the review. First-Principles-Verdict: CONCERNS The binding, relay, and peer-roster shelf all earn their place; but 6 of the capabilities reply's 10 fields — workspaces, version banner, per-field failures — have zero frontend consumers. What this change shipsIntent: start a chat that runs on a connected crew without the session vanishing from the local list — an ADDITION (a new ownership split: local session, remote execution), honestly titled
(Screenshots and the capture script are the PR-template convention — 416 sibling Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c2526a3 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates fail falsification: Candidate 1 — The only in-memory route to a marker-only remote binding ( Candidate 2 — A No self-originated findings met the bar. No findings. [OPUS-REVIEWED] c2526a3 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
d4e8d1b to
8c8ce39
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
8c8ce39 to
d010314
Compare
|
Disposition — GPT 5.6 The finding was correct as written. Both gates now run before the peer is touched (
Six tests in |
|
Disposition — Opus 4.8, Confirmed by reading the four peer handlers rather than trusting the shapes I assumed, and the review was right — but the problem was one endpoint wider than the diagnosis. The peer endpoints do not agree on an envelope:
Rather than patch the one read,
|
d010314 to
3db3ceb
Compare
|
Posted as a plain comment, not an Disposition — GPT advisory,
The finding names two distinct gaps. The selections half was legitimate and is fixed. The approvals half is legitimate and is now a documented deferral, not silence. I did not take the prescribed fix (removing the controls and creation) for either — see the last paragraph. 1. Selections — fixed. The reviewer was right that a picker whose selection never leaves this gateway is display-only: the header would read
2. Approvals — confirmed, and deferred as a documented known gap. I went looking for the write to forward and found the gap is not a missing POST. The approval card is rendered from the slot projection ( On the prescribed fix. Removing the controls and the creation path would remove the feature, not the defect — the requirement this PR implements is specifically that a bound session's agent, workspace, context window, model and effort match what the crew offers, so a shelf with no controls does not satisfy it. The selections gap is closed instead, and the approvals gap is bounded and disclosed rather than shipped silently. Happy to reopen if a reviewer would rather see the approval mirroring land in this PR than the next one. Verified at |
3db3ceb to
f66ac7a
Compare
f66ac7a to
97f069a
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
The Description describes the peer's context reading as a relayed frame that populates the shelf's context meter and as a name the mirror denylist can safely exclude, but nothing on the local side ever turns that reading into a context_usage frame — it falls through the row handler into the transcript as a durable raw-JSON row.
1. The relayed context_usage reading is never re-emitted as a local frame, and lands in the transcript as a raw JSON row
The Description says —
ChatPage.tsxsubstitutes (never merges) the peer's agent and model rosters into the shelf's pickers — a union would let the user pick a local-only model and could not say which side it came from — supplies a context-window fallback until the first relayedcontext_usageframe arrives, and resolves the header's agent label through the peer'sdefault_agentfor a bound session.
and, on the mirror denylist:
That mirror is governed by a denylist of four names (
chat_message,chat_chunk,chat_thinking,context_usage— each either already drained by the SSE reader or pushed by the local side), not an allowlist, so a frame type added later is mirrored by default instead of silently dropped.
The code does — the peer's context reading does cross the wire in band, as an SSE row with type: "context_usage", so the denylist's stated reason for excluding it from the WS mirror is accurate. What is missing is the other half: _apply_row receives that payload and has no branch for it, so it is never translated into a local context_usage WS frame under the local slot key the way _replay_mirrored_frame rewrites the frames it does handle — src/kiro_crew/dashboard/remote_relay.py:306 (with the denylist at src/kiro_crew/dashboard/remote_mirror.py:50). Because context_usage is also absent from _SKIP_ROLES, the row does not merely get dropped: it takes the generic durable-row path. The frontend's fallback is therefore never superseded — website/src/pages/ChatPage.tsx:5547.
Risk — two effects, both permanent rather than transient.
The context meter never populates for a peer-bound session. contextTokens stays undefined, so contextPct and contextUsedTokens render empty and contextWindowTokens is pinned either to remoteContextWindow (the pre-turn capability fallback, which is 0 for auto) or to this machine's provider.getContextWindow — the local-knowledge answer the fallback exists specifically to avoid. The user gets no signal that auto-compaction pressure is building on the peer.
Separately, every context reading the peer takes during a relayed turn is appended to the local window as a durable row whose role is context_usage and whose content is the raw JSON payload. It is persisted into local history, counted in total_messages, and broadcast as a chat_message that the store pushes onto state.messages (website/src/store/chatSlice.ts:4785 — unknown roles fall through to the generic push). Of the two, this is the more serious: the empty meter is a missing signal, whereas this silently writes machine payloads into the user's transcript and inflates the message count on every relayed turn. Neither effect is covered by the two new test files, which assert only the roles _apply_row special-cases.
Required change — handle context_usage in _apply_row: decode the row's content and re-broadcast it as a local context_usage frame under the local slot key, the same rewrite _replay_mirrored_frame performs. That satisfies the claimed handoff and keeps the payload out of the transcript in one step. If the handoff is out of scope for this PR, then add context_usage to _SKIP_ROLES so it cannot land as a durable row, and correct both the Description and the comment at website/src/pages/ChatPage.tsx:5546-5548 to state that the peer's real context reading is not relayed yet.
97f069a to
5813184
Compare
|
Disposition — Opus 4.8
|
|
Disposition — GPT 5.6
|
|
Disposition — GPT 5.6
|
|
Disposition — Opus 4.8
|
|
Disposition — GPT 5.6
This span has now taken 3 rounds (send Every turn-starting entry point and how each now handles a bound slot:
Tests:
|
|
Disposition — GPT 5.6
|
|
Disposition — GPT 5.6
|
|
Disposition — GPT 5.6
|
|
Disposition — GPT 5.6
|
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
The rollback was gated on
|
Rehydrate restored the
|
The Remote-bound session row now lists the page owners this PR changed (
|
A session created from "New chat on crew" now lives in THIS machine's sidebar, transcript, history and search, carrying a chip that names the crew its turns run on. It previously POSTed straight to the peer and switched to that crew's iframe pane, because the local list had nowhere to show a peer-owned session. Binding: `_ChatSlot` gains executor / instance_id / remote_slot, projected on every slot so "runs locally" is a positive value rather than an absent key. An incomplete binding is never treated as local — that would run on this machine work the user asked a named crew to do — so `api_chat` refuses with 409 and the rehydrate path drops a truncated binding rather than resurrecting a session that cannot send. Relay: the peer runs the turn in SSE mode and `remote_relay` replays its stream locally — chunks as `chat_chunk`, transcript rows as local appends, so history comes for free. The SSE transport carries only transcript rows, so a relay reader opts in with `?relay=1` and the peer also queues its WebSocket frames in band (`remote_mirror`); a 4-name denylist keeps the already-in-band frames from doubling, so a frame type added later is mirrored by default. Capabilities: the shelf's agent, model, effort and context-window controls source from the BOUND CREW, via a new narrow `peer_capability` carrier with a closed path set. The generic proxy was not widened: its fence matches prefixes, so reaching `api/agents` would have granted the peer's mutating PUT in the same stroke. Version gate: remote execution requires string-equal gateway versions, read over the tunnel from a new authenticated `GET /api/version`. Deliberately NOT public — that would reverse the fingerprint-minimization on the probe boundary, and the existing session-search and session-import carriers prove an authenticated non-probe route traverses the tunnel. Resume-attach is out of scope: a local gateway restart leaves the peer running but loses this reader, and rejoining needs the peer's in-flight tail.
The
|
A stop pressed on the peer relays a
|
Problem / Motivation
"New chat on crew" did not create a local session. It reached into the connected crew, created a session there, and force-navigated the tab to that crew's pane. The session then lived only on the peer: absent from the local session list, absent from local history, absent from local search. Returning to it meant remembering which crew it was on and navigating there by hand.
Why it matters
The crew picker in the composer is a where does this run control, not a leave this app control. Every other choice on that shelf — agent, model, effort, project — changes how the next turn executes while the session stays where the user put it. Making one of them teleport the user breaks that expectation, and silently drops the session out of the two surfaces people use to find work again (the sidebar list and search).
What changed (motivation → approach → change)
Goal. A session that lives in the local list — searchable, resumable, badged with the crew's name — whose turns execute on that crew, and whose header offers only what that crew can actually do.
Approach, and the alternative rejected. Session ownership was a single thing: one gateway both listed a session and ran it. The only way to run on a peer was to let the peer own the session outright, which is what produced the symptom. The alternative — keep peer ownership and mirror the peer's rows into the local list — answers "which machine is it on" but leaves the session's real home elsewhere; it cannot be resumed, retitled, or folded locally. So ownership is split into two layers instead: the slot lives here, a binding says who executes it.
What was built.
The binding.
_ChatSlotgainsexecutor/instance_id/remote_slot(state.py).is_remoterequires the whole triple, so a half-binding is fail-closed —api_chatanswers 409 rather than silently running the turn locally on a session the user believes is remote (chat_handlers.py). The binding is set at birth inapi_chat_slot_create: the peer slot is opened first, so a failure over there leaves no orphan slot here. It is never patched onto an existing slot —nameaddressing any slot that already exists is refused 409remote_already_boundbefore the peer is contacted, whatever that slot is. An already-bound one would be pointed at a second peer session and orphan the first; an existing local one is the destructive half, since its transcript would stay here while its execution moved to an empty peer slot, so the next turn would run with none of the conversation on screen. Refusing there also closes an ownership hole: the slot's ownership check runs after the stamp, so a caller with no right to that slot would otherwise have created a peer session and rewritten somebody else's executor before seeing its 404. Persistence writes all three keys together or none, and rehydration drops an incomplete binding, returning the session as an ordinary local one (chat_persistence.py). Both save routes write them, including the empty-window metadata merge — a bound newborn has no messages until the first relayed row lands, so for the whole of that gap the merge is the only writer its binding ever sees; skipping it there would bring the session back local after a restart and run the next turn on this machine instead of the crew the user picked. All three are added toSLOT_OWNED_META_KEYS(history.py) — without that,carry_unowned_metadataresurrects a cleared binding from disk.slot_projection.pyshipsexecutorandinstance_idon every slot so the frontend branches on an always-present field rather than inferring from absence.remote_slotis deliberately not projected: it is the peer's own slot key, meaningful only inside a request routed back through that instance, and no browser code has any use for it.Relaying the turn.
POST /api/chatwithout?ws=1streams only appended transcript rows;tool_call,tool_result,chat_segment,chat_statusandchat_donenormally travel over the WebSocket.remote_relay.pydrives the peer turn and re-emits it locally: SSE record framing, row replay, chunk resequencing, trailing-chunk finalize, stop forwarding, and error rows that always terminate withchat_doneso the composer cannot hang.remote_mirror.pylets a reader opt in with?relay=1, after which the peer mirrors its WS frames in band. That mirror is governed by a denylist of five names (chat_message,chat_chunk,chat_thinking,context_usage,chat_done— each either already drained by the SSE reader or produced by the local side,chat_doneby the relay's ownfinally), not an allowlist, so a frame type added later is mirrored by default instead of silently dropped.Redacting what the peer sends. A relayed row lands on the same local surfaces a locally-run one does — this machine's transcript window, its WebSocket, its
ConversationLog— and a local turn never reaches them withoutredact_exfiltration_urlsthenredact_credentials(chat_runnerapplies the pair beforeslot.append("assistant", …)). So the relay applies it on this side of the wire rather than trusting the peer to have done it:_redact_relayedfor a row'scontent, and_redact_deepfor itsmetaand for every string in a mirrored frame — the frames carrying tool inputs and outputs, and the ones whose shape is open-ended because the mirror is a denylist. Both redactors return their input unchanged when nothing matches, so the healthy case (a peer that ran the same pass) is byte-identical and the cost is a scan.Reading the peer's capabilities. A bound session's shelf must offer the peer's agents, models, effort levels and workspaces; the local ones describe a machine that is not answering, so a wrong pick is accepted by the picker and refused on send.
GET /api/instances/{id}/capabilities(handlers_instances.py) fetches them over a newpeer_capabilitycarrier onSshTunnelManager. It is a separate carrier with a closed path set rather than a widening ofproxy_request's allowlist: that allowlist is("api","chat")/("api","stream")matched as a prefix, so admittingapi/agentswould also grant the peer's mutatingPUT /api/agents/{name}. The carrier accepts only the five paths in_PEER_CAPABILITY_PATHSand refuses everything else, with a byte cap enforced before JSON decode (constants.py). The four peer endpoints do not agree on reply shape —/api/agentsand/api/workspaceswrap their list in a dict while/api/modelsand/api/effort-levelsanswer bare lists — so_cap_listunwraps by key before clamping; without it a healthy peer's agent picker renders empty with no error. The peer's owndefault_agentis carried beside its roster, because a bound session deliberately stores no agent (below) and the header would otherwise fall back to a crew from this machine's roster.Applying a pick on the machine that runs the turn. A picker whose selection never leaves this gateway is a display-only control: the header would show
reviewerwhile the peer answered as its own default.forward_peer_selection(remote_relay.py) POSTs the pick to the peer slot, and_apply_remote_pick(chat_handlers.py) mirrors it locally only after the peer accepts — writing the local field first would leave the user looking at a pick the peer refused. A refusal surfaces as 502remote_pick_failedcarrying the peer's ownerrorstring (clamped to 200 chars; nothing else in a peer reply is trusted for display), never a silent success. The four routes are a closed map (_PEER_CONTROL_SEGMENTS) rather than an f-string over the caller's word, since the segment is interpolated into a proxied URL. Each branch sits after the existing validation, so bad input is still 400 locally, and version parity is re-checked before every forwarded write. An accepted pick is also persisted immediately viaconversation_log.update_metadata, exactly as the local agent switch does, rather than left to the periodic dirty-slot flush: the peer commits the value the moment it answers, so a restart inside the flush window would restore a local field the crew no longer agrees with — and the crew is the side that runs the next turn. (A local-only pick can only ever disagree with itself, which is why the local model/effort/workspace routes can leave it to the flush and this one cannot.) The workspace pick deliberately leavesslot.projectalone — that is a path on this machine. Agent and model picks ride the create rather than following it, so a second round-trip cannot fail after the peer session already exists and leave a bound session running a crew the user did not choose. For the same reason a bound create skips both this machine's default-agent stamping andresolve_agent_bindings: those answer from this machine's roster and bindings, so they would stamp an agent the peer may not have and resolve it to a local workspace.Version gate. Remote execution requires identical gateway versions.
/api/healthdeliberately withholdsversionfrom non-direct-local callers, so rather than reverse that decision with a public endpoint, this adds a cookie-authenticatedGET /api/version(handlers/core.py) — absent fromtoken_auth._BYPASS_EXACTandorigin.PROBE_PATHS, so it needs the dashboard credential and a servedHost.Frontend.
useRemoteCapabilitiesreads the binding once per session.ChatPage.tsxsubstitutes (never merges) the peer's agent and model rosters into the shelf's pickers — a union would let the user pick a local-only model and could not say which side it came from — supplies a context-window fallback until the first relayedcontext_usageframe arrives, and resolves the header's agent label through the peer'sdefault_agentfor a bound session.ReasoningEffortDropdowngainslevelsOverrideand skips its local/api/effort-levelsquery entirely for a bound session, since that endpoint only knows this gateway's models. While the capability read is in flight the lists are empty, not local: a brief empty picker is honest, whereas briefly showing this machine's models invites exactly the wrong pick.RemoteCrewChipis one shared component, now rendering on both the federated-search row and the new live session row. The sidebar's crew-create mutation deliberately sends noagent, unlike every sibling create entry:defaultAgentnames a crew from this machine's roster and the backend forwards any agent it is given straight to the peer, so sending it would either be refused over there or bind a different crew than the name implies. Omitting it lets the peer apply its own default, which is what the header then reads back from the peer'sdefault_agent.Restart and mode safety (this round, closing the GPT review blockers). Four narrowly-scoped guards, none of them resume-attach:
relay_remote_turnnow persists a_relay_in_flightmarker (a new_ChatSlotfield, written with the binding in both save routes) before the peer stream opens and clears it when the turn ends. On reload, a slot still carrying the marker gets an explicit "interrupted by a restart" row appended at the tail (chat_persistence.pyrehydrate), so a truncated conversation reads as interrupted rather than complete. The peer keeps running — that is still the split's upside — but the local side is honest about what it did not receive.peer_is_connected(remote_relay.py) reads the tunnel state defensively, andapi_chatanswers 409remote_not_connectedfor a bound send until the tunnel isCONNECTED, so a just-restarted gateway cannot fire a turn into a half-open tunnel and lose it.relay=1send to a busy slot. On the peer a relayed slot is an ordinary local slot (is_remoteis false there), so the busy branch's existing remote refusal did not cover it; it would have queued and drained without the mirror, losing the answer. The busy branch now refusesrelay=1too, which the owner's reader surfaces as a reconnect prompt.crew/orchestrator/design-critique) is consumed by an earlier dispatch branch inapi_chatthat runs its tools and filesystem work on this machine, not the peer.api_chat_slot_createrejects a non-plain mode wheninstance_idis set (before the peer write), andapi_chat_slot_moderefuses switching a bound slot into one — closing both the birth and the post-create path.docs/feature-map/README.mdgains the Remote-bound session row, per the map's maintenance contract for a feature-adding PR.Subtractions.
remote_mirror.is_mirroredand themirror_slotcontext manager are deleted — both had zero non-test callers (production attaches and detaches throughremote_mirror.attach/detach), and the tests that used them now assert the mirror's behaviour instead of its internal state.remote_slotis dropped from the wire projection and from theChatSlotTypeScript type for the reason above.Tests
test/test_remote_crew_execution.py(new, 167 tests) — locks in: a fresh slot runs locally; each of the three binding fields missing individually leavesis_remotefalse and makes dispatch refuse rather than fall back to local; the mirror emits nothing until a reader attaches, and handles overlapping readers; SSE record framing and chunk resequencing; the version-equality gate rejecting a mismatched peer; relay row replay; the binding surviving a persistence round-trip (plus being dropped when incomplete on disk); both directions of the empty-window merge; that an explicit pick rides the create while an unpicked one is omitted rather than sent as""; each of the four picks reaching its own peer route with the right body, an unlisted control raising beforeproxy_requestis touched, a version-skewed peer never being written to, the peer's own refusal being what the user reads (with unusable replies falling back to the status and a 900-char message truncated to exactly 200), and an unreachable peer leaking no port; that a bound create records no agent and forwards none while a local create still stamps this machine's default; and that an accepted pick is mirrored, a refused one is not, a model pick bumps_model_pick_gen, and a workspace pick leavesslot.projectuntouched. Plus, from this round: the peer's slot key is never projected; an existing local slot is not converted to remote (409, peer never called, transcript andexecutoruntouched); each of the four accepted picks is readable from theConversationLogimmediately while a refused one persists nothing; and a peer row carrying a credential and an exfiltration URL is redacted before it reachesslot.messages, thechat_chunk/chat_thinkingbroadcast, or a row'smeta, with a mirrored frame's nested strings redacted beforebroadcast_wsand clean prose passed through byte-identical. This round:peer_is_connectedreading the tunnel state defensively (connected → true; disconnected / no-status / a raising manager / no manager → false) and a bound send being refused 409remote_not_connectedwhile the tunnel is down with the peer never reached; a completed relay clearing the in-flight marker while a crash mid-turn comes back with an "interrupted" row at the tail (and the runtime marker left unset so a second restart cannot append it twice); arelay=1send to a busy local slot being refused 409remote_turn_busyrather than queued; and a bound slot refusing a switch to a non-plain mode.test/test_remote_crew_capabilities.py(new, 28 tests) — pins the two halves that make the peer's shelf honest: the reply shape each peer endpoint actually uses (the dict-wrapped agents case is the regression that would otherwise empty the picker silently on every healthy peer) and the clamping applied to a peer's reply on its way to the browser — unlisted fields dropped, strings truncated,Truerejected as a context window, row floods capped. Plus the peer'sdefault_agentbeing carried and reported as""when the roster read fails, per-control degradation, the owner/flag/origin gates, and the capability carrier's closed path set raising before any target is resolved.test/test_chat_slot_facade_contract.py— the pinned projection key tuple now assertsexecutorandinstance_idship on every slot;remote_slot's absence is asserted positively intest_remote_crew_execution.py.test/test_session_control.py— the empty-window merge's drift guard enumeratesSLOT_OWNED_META_KEYS, so the three new keys joinforked_from/linked_session_keyin itsexcludedset: like those, they are conditional-identity fields a plain local newborn does not carry. The bound case they do apply to is asserted positively intest_remote_crew_execution.pyrather than left uncovered.website/src/test/RemoteCrewChip.test.tsx(new, 5 tests) — the chip renders the crew name, carries its testid, and titles itself from the name when no explicit title is given.website/src/test/ChatSidebar.createMenu.test.tsx— rewritten to assert the new intent (create a local slot carryinginstanceId). It previously pinned the jump-to-pane behaviour this PR removes, so it had to change for the fix to be expressible. Two cases added this round make the no-agent omission load-bearing: with a default agent configured, the crew create still sends none, while the ordinary local create still stamps it. Without the configured default the earlier assertion passed either way.test_stop_addresses_linked_session.py,test_stop_handler_idempotent.py) gained the binding fields andis_remote, which the stop-forwarding branch now reads.Verified on this branch at
c0787bcd4(rebased onto currentorigin/main), targeted rather than full-suite runs. This round:pytest test/test_remote_crew_execution.py -n0— 184 passed (plus the_build_stream_chunkregression files);isort/flake8/black/mypyclean on the changed backend modules. This round resolved two relay data-integrity findings: a pre-stream refusal (version skew / non-2xx / connection error, before the peer received the turn) now rolls back the user row appended before dispatch, so a retry no longer duplicates local history the peer never saw — a mid-stream truncation still keeps the row (the peer is running it); and the relay SSE now carries a tool row's durablemeta(tool input/output, call id) via an opt-ininclude_row_metaon_build_stream_chunk, so a local refresh no longer loses tool correlation (the peer's per-gatewaymidis stripped so the local row mints its own). The ordinary / OpenAI-compat SSE keeps its no-row-meta contract. Earlier rounds resolved:session_sendrefusal + the_run_chatchokepoint guard; the effort-dropdown override; relay cancellation preserving the in-flight marker; Continue/Rewind refusal ordered after app-ownership;chat_doneon the mirror denylist; the marker being slot-owned; refuse-before-record on send; the four turn-restarting refusals; the pre-peermember-gate; and the mirror detach on aprepare()failure. Earlier rounds resolved: relay cancellation preserving the in-flight marker; Continue/Rewind refusal ordered after app-ownership;chat_doneon the mirror denylist; the marker being slot-owned and its drift-guard exclusion; refuse-before-record on send; the four turn-restarting refusals; the pre-peermember-gate; and the mirror detach on aprepare()failure. The earlier-round evidence below stands for the surfaces this round did not touch:pytest test/test_remote_crew_execution.py test/test_remote_crew_capabilities.py -n0— 137 passed.test_chat_turn_timeout_consistency.py,test_stop_addresses_linked_session.py,test_stop_handler_idempotent.py,test_chat_slot_facade_contract.py) — 53 passed.test_session_control.py— 140 passed, 2 pre-existing failures (test_the_created_agent_name_is_sanitized_before_storage,test_the_audit_write_does_not_run_on_the_event_loop). Both pass in isolation and fail only when the file runs whole: the session-creation rate limiter carries state between tests. Reproduced identically (2 failed / 140 passed) on a cleanorigin/mainworktree with this diff absent, so it is not caused by this PR — flagging rather than folding an unrelated fix in.remote_relay.pyline coverage 99% (210 statements, 1 miss).flake8clean on the changed backend modules;mypyclean; the repo's ownscripts/check_black_formatting.pygate passes with 44 files in scope.tsc --noEmitclean,eslint src/at 601 warnings (budget 603, 0 errors), 17 component tests pass.Manual verification
Not verified end-to-end, and the reason is structural: remote execution gates on identical gateway versions on both sides, and every peer reachable from this machine runs an older build. The relay has therefore been exercised only against the test doubles, not a live peer. A reviewer with two same-build gateways should confirm: create a session via "New chat on crew", verify it appears in the local list with the crew's chip, send a turn, confirm tool calls and streaming text arrive locally, and change the agent/model from the header and confirm the next turn runs under the new pick.
Screenshots / video
Captured against the real built SPA (
website/dist) by a scripted harness,website/scripts/capture-remote-crew-local-session.mjs, with every/api/**call answered from fixtures — no gateway, nokiro-cli, and deliberately no peer: the chip renders off theexecutor/instance_idfields of the session projection plus the crew roster, so the at-rest surface is reproducible on a machine with no reachable crew. The harness asserts what the frames claim before it writes them (chip present on exactly the 2 rows whoseexecutorisremote, label resolved from the roster rather than falling back to the raw id, chip inside its ownmax-widthcap with no row overflow, submenu offering every connected crew).1. A bound session in the local sidebar — two peer-bound rows carrying the info-tinted
Serverchip (nobita, andgian-eu-west-1truncating at the 7rem cap), beside three ordinary local rows including one whose strip already holds incognito + Autopilot, so the runs-elsewhere marker is seen competing for the same space rather than alone.2. The entry point that creates the binding — the create-caret menu with New chat on crew open and its submenu listing this machine's connected crews. Picking one creates a session that stays in this list instead of switching to that crew's iframe pane, which is the behaviour change the feature exists for.
What these frames do NOT show, and I would rather name it than let the images imply it: a relayed turn streaming back. That needs two hosts on the same gateway build — the same constraint described under Manual verification — so both frames are at-rest surfaces. The streaming path is covered by tests, not by a picture.
Known gaps
Both are deliberate scope calls, documented where a reader lands rather than left to be discovered:
remote_not_connected) until the tunnel is back rather than accepting a turn into a half-open one. Re-joining the peer's in-flight tail (true resume-attach) is the follow-up.pending_approval/approval_id), not from a streamed frame, so it is built from local slot state a relayed turn never populates: the card does not appear locally, andapi_chat_slot_approvewould find no local future to resolve. Closing it needs the peer's pending approval mirrored onto this slot's projection and the decision forwarded back — a second mechanism, deferred alongside resume-attach rather than half-built. Noted inrelay_remote_turn's docstring. Until then, point peer-bound sessions at a crew whose approval policy does not stop for the tools you expect to use.Related Issues
no linked issue: this came from directly reported behaviour on "New chat on crew" rather than a filed issue.
Two open PRs overlap this one — flagging both for a maintainer call:
feat(dashboard): merge remote instance sessions into the Sessions list) targets the same user-visible problem with a different design: it mirrors the peer's own sessions into the local list behind the default-offPREVIEW_INSTANCE_SESSIONSflag. The two look complementary rather than duplicative — feat(dashboard): merge remote instance sessions into the Sessions list #7444 makes a peer-owned session visible here, while this PR makes a locally-owned session execute there, which feat(dashboard): merge remote instance sessions into the Sessions list #7444 has no backend for — but both render an instance badge in the sidebar, so a maintainer should decide whether the row presentation converges.refactor(chat): split chat page controllers) rewrites most ofChatPage.tsx(+534/−7172). My footprint there is small and localised (roster substitutions, the agent-source lookup, and the peer default-agent fallback); whichever lands second needs a small manual re-apply.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)