From 3b21308f95f26367ae7d0ef55db51b76bdb0aa74 Mon Sep 17 00:00:00 2001 From: seal Date: Fri, 7 Aug 2026 20:23:42 -0400 Subject: [PATCH 1/2] docs(product): port spawn-control design record from orion (SEA-1361) Ports the frozen orion Bridge-UI spawn-control record (orion #884) into compass as `docs/designs/product/compass-spawn-control/design.md`, with Matt-ruled amendments: - Stop premise updated: compass ships a real `stopAgent()` (store.ts:1816-1849, awaits `stopAgentSession`); all stop-is-a-stub statements rewritten and stop-side tasks reframed as hardening. - Posture amended (DL-165): live-wired against the shipped `AppStoreOptions` seam; orion's walking-skeleton lane + stacked T6 dissolved into T2/T4. - DL renumber: orion DL-053..060 re-expressed as DL-164..DL-171; rows appended to DECISIONS.md. - Distinctness note vs the agent-facing `compass-agent-spawn-despawn` record. - All code citations re-grounded against current compass main (SEA-1527 repo-less provisioning already shipped; the orion T0 self-clone reconciliation dropped from scope). One port-level Open Question parked for review (OQ-P1, non-blocking): the DL-165 posture amendment supersedes orion's walking-skeleton ruling because its premises no longer hold in compass; recommendation is to ratify DL-165, with the fixture-first fallback noted if preferred. Ledger-impact: adds DL-164..DL-171 (net-new, no supersessions). Spec-impact: none. The wire/spec reconciliation (SpawnAgent RPC + agent_account_id field + one-container SHALL) lands in the T0 impl PR, per the record's Spec impact section. Refs SEA-1361 Co-authored-by: Matt Wilkinson --- docs/designs/product/DECISIONS.md | 13 + .../product/compass-spawn-control/design.md | 1045 +++++++++++++++++ 2 files changed, 1058 insertions(+) create mode 100644 docs/designs/product/compass-spawn-control/design.md diff --git a/docs/designs/product/DECISIONS.md b/docs/designs/product/DECISIONS.md index 3279ff8c..9b556a2a 100644 --- a/docs/designs/product/DECISIONS.md +++ b/docs/designs/product/DECISIONS.md @@ -236,3 +236,16 @@ check enforces the mechanical half. Full rationale: | DL-158 | The agent workspace simplifies to the agent's home channel + its session trace (two fixed panes, no arbitrary split tree); no terminal pane and no file-viewer pane in dogfood (isolated containers; an operator dev-server-view affordance is deferred to backlog, PR review lives on the user's forge); the terminal `PaneKind` arm + `newTerminalPane` retire at the workspace flip (D6/D10) | Active (Matt, 2026-08-05) | [ux foundation §D6](compass-ux-foundation/design.md#d6--rendering-the-frozen-ia-surface-by-surface-composition) | | DL-159 | One UI codebase renders in two hosts — the Wails v3 desktop app (primary) and the browser (the managed/hosted product at `compass.rigel.build`) — over the same transport-agnostic UI above the `connection.ts` provider seam; the layout is fluid within its window/viewport (a dense supervision surface that reflows, not a fixed-pixel canvas and not a mobile redesign) (D6/§Global Constraints 2) | Active (Matt, 2026-08-05) | [ux foundation §D6](compass-ux-foundation/design.md#d6--rendering-the-frozen-ia-surface-by-surface-composition) | | DL-160 | The desktop app is first-class multi-window: every top-level surface (Bridge, a channel, an agent workspace, Backlog/Done, Settings) is an independently mountable window-scoped view (own DL-127 route, own focus zones + command scope, no sibling region required); compass-native spawns/manages OS windows, this record owns the decomposition; in-window tabs (Linear-style) and split views are deferred to the Beta milestone (SEA-1808), admitted by the same decomposition without rework (D6.1) | Active (Matt, 2026-08-05) | [ux foundation §D6](compass-ux-foundation/design.md#d6--rendering-the-frozen-ia-surface-by-surface-composition) | + +## Bridge spawn control + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-164 | Adding a workstream and starting an agent are two separate operations: `addWorkstream` is a board-only mutation (no lifecycle RPC), `startAgent` is the lifecycle call, and `SpawnAgent` rejects when the target agent already holds a live session; agents own multiple workstreams (binding map keyed by workstream, not agent) inside one container/one live session, and the add flow can create agents via the existing `CommsService.CreateAgent` | Active (Matt, 2026-07-24) | [spawn control §Control flow](compass-spawn-control/design.md#control-flow-add-a-workstream-no-rpc-vs-start-an-agent-spawnagent) | +| DL-165 | The Bridge spawn-control lane builds live-wired against the shipped `AppStoreOptions` client seam (real RPC awaits when clients are present, surfaced offline refusals when not — the shipped `stopAgent` shape), superseding the orion walking-skeleton posture whose premises (no injection seam, no live streams, stub stop) no longer hold in compass | Active (Matt, 2026-08-07) | [spawn control §Posture](compass-spawn-control/design.md#posture-live-wired-from-the-start-amends-orions-walking-skeleton--dl-165) | +| DL-166 | Spawn is one server-side composite `CompassService.SpawnAgent` RPC orchestrating Provision→Start under a single `client_request_id` with server-owned end-to-end dedup (retry returns the same `session_id`, no second container) and a pre-Provision reject-on-live short-circuit ordered after the dedup-join lookup; client-orchestrated two-call flow rejected | Active (Matt, 2026-07-24) | [spawn control §DL-166](compass-spawn-control/design.md#dl-166-orion-oq-d--dl-055--server-side-composite-spawnagent) | +| DL-167 | `AgentSessionStatus` gains `agent_account_id`, populated at every construction site (board projection, SubscribeEvents push synthesis, Runner-side Status) — a join onto the live hub binding Server-side and new Runner-local account carriage (AgentSpec→handle→liveSession), with the terminal STOPPED status resolving its account before `unbindSession`; makes the UI reconcile full-fleet within the binding's live scope and is the prerequisite of the reject-on-live scan | Active (Matt, 2026-07-24) | [spawn control §DL-167](compass-spawn-control/design.md#dl-167-orion-oq-g--dl-056--agent_account_id-on-agentsessionstatus) | +| DL-168 | A stopped card stays restartable: the card's start affordance shows on no-binding OR a `stopped` binding (agent not live), the exact predicate the `startAgent` guard reads, and restart mints a fresh binding — a no-binding-only affordance would strand every stopped card since `applyStopped` keeps the binding | Active (Matt, 2026-07-26) | [spawn control §DL-168](compass-spawn-control/design.md#dl-168-orion-oq-h--dl-057--a-stopped-card-stays-restartable) | +| DL-169 | Retry renders on `spawn-failed` only; a `stop-failed` card recovers by re-issuing the idempotent stop (`beginStop` accepts `running` \| `stop-failed`, and stop enablement covers `stop-failed`), because the session is still held and re-sending `SpawnAgent` would be bounced by reject-on-live | Active (Matt, 2026-07-26) | [spawn control §DL-169](compass-spawn-control/design.md#dl-169-orion-oq-i--dl-058--retry-is-spawn-failed-only) | +| DL-170 | The spec gains a SHALL binding one container per agent account, so the agent-scoped reject-on-live rule is contract-backed rather than resting on the Runner's incidental container-name derivation (`NamePrefix + accountID`); lands with the T0 wire changes | Active (Matt, 2026-07-26) | [spawn control §DL-170](compass-spawn-control/design.md#dl-170-orion-oq-j--dl-059--one-container-per-agent-account-shall) | +| DL-171 | `SpawnAgent` classifies `adminOnly` on the network door, alongside the sibling lifecycle RPCs (it provisions AND starts — strictly more powerful); the Bridge control surface is bootstrap-admin-only for MVP, and any future non-admin opening is `authenticatedOpen` + per-account authorization in the handler, not a bare reclassification | Active (Matt, 2026-07-26) | [spawn control §DL-171](compass-spawn-control/design.md#dl-171-orion-oq-k--dl-060--spawnagent-ships-adminonly) | diff --git a/docs/designs/product/compass-spawn-control/design.md b/docs/designs/product/compass-spawn-control/design.md new file mode 100644 index 00000000..37fd2033 --- /dev/null +++ b/docs/designs/product/compass-spawn-control/design.md @@ -0,0 +1,1045 @@ +# Compass spawn control — start/stop a workstream agent from the Bridge board + +Status: Active + +This record freezes on merge; later changes supersede by citation, never +rewrite. + +Ported from the frozen orion record (RigelBuild/orion #884, SEA-1361) with +Matt-ruled amendments, re-grounded against the current compass tree +(`main@origin`, 2026-08-07). Orion's DL-053..060 are re-expressed here as +DL-164..DL-171. + +## Problem / Intent + +The Bridge board can observe and stop, but not start: the server's +agent-lifecycle RPCs (`ProvisionAgentWorkspace`, `StartAgentSession`, +`StopAgentSession`, `ReloadAgentSession` — +`proto/compass/v1/compass.proto:54-79`) exist and are implemented, and the UI +ships a **real, wired stop** — `stopAgent` (`apps/ui/src/store.ts:1816-1849`) +awaits `client.stopAgentSession({ sessionId })` on the live `CompassClient`, +refusing fixture-sourced sessions and offline stores with a surfaced reason, +triggered by the LogPanel "■ stop" button (`components/LogPanel.tsx:73-98`). +But there is **no start/provision trigger anywhere in `apps/ui/src`**: nothing +in the UI calls `ProvisionAgentWorkspace` or `StartAgentSession`, and no board +surface adds a card and names its agent. This record designs the human-facing +control that starts a workstream agent (provision + start, with an optional +initial prompt) from the board, and hardens the existing stop into the same +control model. + +*(Port note — superseding the orion premise: the source record described stop +as a no-op stub, `const stopAgent = () => {};`. That was true of the orion +snapshot; compass has since shipped the real `stopAgent`, so every stop-side +task below hardens or extends shipped behavior rather than replacing a stub.)* + +## Spec impact + +Spec-impact: `docs/specs/product/compass.md` — reconciliations land in the +**T0** proto+server PR. Two are additions to the compass.v1 contract, both +Matt-authorized on the orion record (DL-166, DL-167): a new `SpawnAgent` RPC +(+ `SpawnAgentRequest`/`SpawnAgentResponse` messages) on `CompassService`, and +an `agent_account_id` field on `AgentSessionStatus`. The third is a **new +SHALL binding one container per agent account** (DL-170, Matt ruled +2026-07-26), which makes the reject-on-live rule agent-scoped *by contract* +rather than by an incidental Runner naming property; §DL-170 carries the full +argument. The spec doc is reconciled in T0 (where the wire change actually +lands), per the "spec updated as the last step of implementation" convention — +not this design-only PR, which has no code. The add-agent path needs no spec +change: it reuses the existing `CommsService.CreateAgent` +(`proto/compass/v1/comms.proto:41-42`), and the reject-on-live rule **agrees +with** the container-scoped SHALL at `compass.md:368-370`. + +*(Port note: the orion record carried a fourth reconciliation — relaxing +`ProvisionAgentWorkspace`'s repo requirement for self-clone. That already +shipped in compass under SEA-1527: `ProvisionAgentWorkspaceRequest` no longer +carries a repo (`compass.proto:506-509` — "Repo carriage removed … the agent +self-clones"), so it drops from T0's scope entirely, along with the orion +record's clone-target-validation and agent-credential prerequisites, which +were consequences of the now-shipped change.)* + +## Approach + +### Posture: live-wired from the start (amends orion's walking skeleton — DL-165) + +The orion record froze a walking-skeleton posture (its OQ-A) on three facts of +that tree: `createAppStore` took no arguments, `SubscribeEvents` had zero UI +consumers, and no live client seam existed. **All three are false in compass +today**: `createAppStore(options: AppStoreOptions)` already takes an options +bag carrying `comms` and `compass` clients (`store.ts:551-589`, `:667`), the +store already runs the live comms stream and the SubscribeEvents-backed board +read when clients are present (`store.ts:897-912`, `:922-932`), and the +shipped `stopAgent` already dials `stopAgentSession` on that seam +(`store.ts:1835-1848`). The rationale for fixture-first is gone, so this port +amends the posture: **the control lane builds against the live seam +directly** — store actions await the real RPCs when `options.compass` is +present and degrade to the documented offline refusal (the shipped `stopAgent` +precedent: refuse with a surfaced reason, never a silent no-op) when it is +not. The UI tasks still gate on T0 (the `SpawnAgent` RPC must exist to be +called), but there is no separate stacked "wiring lane": orion's T6 folds into +T2/T4. Recorded as DL-165, superseding orion's walking-skeleton ruling whose +premises no longer hold. + +### The wire contract (two changes, Matt-authorized on orion; re-grounded here) + +Baseline RPCs from `proto/compass/v1/compass.proto`, verified this run: + +**Baseline (unchanged):** + +- `rpc ProvisionAgentWorkspace(...)` (`compass.proto:54`): + `agent_account_id` + `client_request_id` → `container_name`. Repo-less + since SEA-1527 — the container is provisioned with a git credential + + workspace and the agent self-clones (`compass.proto:506-509`). *(An + internal server step of `SpawnAgent`, not a client call — see Control + flow.)* +- `rpc StartAgentSession(...)` (`compass.proto:59`): `container_name` + + `initial_prompt` → `session_id`. *(Likewise internal to `SpawnAgent`.)* +- `rpc StopAgentSession(...)` (`compass.proto:64`): "Idempotent — stopping an + unknown/already-stopped session succeeds" (`compass.proto:61-63`; served at + `go/server/service.go:252-256`). **Already consumed by the shipped + `stopAgent`** (`store.ts:1845`). +- `rpc ReloadAgentSession(...)` (`compass.proto:79`): restart-in-place. +- `rpc GetAgentStatus(...)` (`compass.proto:82`): snapshot; "Empty = every + live session" (`compass.proto:592-593`); served from the Bridge board + projection (`go/internal/board/projection.go:114-122`), not a Runner relay. +- `rpc SubscribeEvents(...)` (`compass.proto:33`): the sole push path from + the server to the UI; `AgentSessionStatus` (`compass.proto:324-327`) pushed + on every transition. **Already consumed by the store's live board read** + (`store.ts:926-932`, `runEventStream`). +- Agent-account creation: `rpc CreateAgent(...)` on **CommsService** + (`comms.proto:41-42` — owner = the authenticated caller). Already exists — + no proto change; impl at `go/internal/comms/comms.go:120`, store + `go/internal/store/accounts.go:131` (handle required, `:132-134`; mints the + agent + home channel in one tx). The add-agent flow consumes it. + +**Change 1 (DL-166) — new `rpc SpawnAgent` on CompassService** (after +`StartAgentSession`). Two new messages; the request carries the agent +account, an initial prompt, and the idempotency key — no `repo`/`ref` +(self-clone is already the shipped provision contract): + +```proto +rpc SpawnAgent(SpawnAgentRequest) returns (SpawnAgentResponse); + +message SpawnAgentRequest { + string agent_account_id = 1; + string initial_prompt = 2; // empty = start idle + string client_request_id = 3; // end-to-end idempotency key +} +message SpawnAgentResponse { + string session_id = 1; + string container_name = 2; // intermediate handle, surfaced for lifecycle parity +} +``` + +The response keeps `container_name` alongside `session_id` for +lifecycle/debug parity with the two-call path; the UI consumes only +`session_id`. + +**New behavior the composite must add (contract-load-bearing, T0 +acceptance):** a `SpawnAgent` retried with the same `client_request_id` MUST +return the same `session_id` and provision no second container. The two +individual RPCs don't compose idempotency for free — Start mints a fresh id +(`orNewRequestID`, `go/internal/runnerhub/commands.go:219-224`, used at +`:70`) — so T0 threads the one id through `provisionDedupID` +(`commands.go:229-244`, used at `:50`) AND makes the orchestrated Start +idempotent on an already-live container. **Build-order:** this end-to-end +idempotency composes three primitives already built. `provisionDedupID` +binds a non-empty `client_request_id` to the agent account through a +domain-separated hash, so a retry of the same provision dedups (since +SEA-1527 removed repo carriage, the key derives from account + +`client_request_id` alone — correct: `client_request_id` is the explicit +idempotency key, though two concurrent repo-less spawns for one account are +distinguished only by that id). The command router joins a retry whose id is +already in flight to the existing call (`runnerhub/router.go:117-132`; +`waitCall` deliberately leaves a timed-out call joinable, `:222-228`). And +the Runner's dispatcher returns the recorded result for an id it has already +handled (`runner/dispatch.go:223-236`, the `handled` map at `:79-88`). What +is missing is the **composite span**: `orNewRequestID` mints a fresh id per +relayed command, so Provision and Start dedup independently today. +`SpawnAgent` threads the one id across both steps; T0 lands as a small +standalone compass PR — the freeze does not wait on the build. + +**Change 2 (DL-167) — `agent_account_id` on `AgentSessionStatus`.** Append +`string agent_account_id = 3;` to `AgentSessionStatus` +(`compass.proto:324-327`, today `session_id` + `state` only) and populate it +at every construction site — the `GetAgentStatus` projection, the +SubscribeEvents push, and the Runner-side `Status` answer — so a status +reattaches to an agent for **any** session whose account the server can +still resolve: post-refresh, other-client, another agent's. +Backward-compatible append (buf: non-breaking). **This is a join, not new +tracking** — the session→account binding already lives in the Server's hub +state: `containerAccounts` (the Provision..Start window), `sessionAccounts` +(the live-session map), and the reverse `accountSessions` map +(`go/internal/runnerhub/hub.go:287-305`), promoted Provision→Start at +`relay_comms.go:53-65`. It is **in-memory and live-scoped** — Start adds an +entry; Stop removes it (`relay_comms.go:112-122`) and a Runner re-enroll +clears all three maps (`hub.go:707-711`). That scoping bounds the claim: the +reconcile is full-fleet **across clients while the server stays up and the +binding stands**; a status emitted after a Runner reconnect has cleared the +maps carries no account and reconciles nothing — the UI holds its optimistic +phase in that case. The terminal STOPPED status is the case this bites +hardest, since `Hub.Stop` unbinds as soon as the Runner answers +(`commands.go:91-104`, `unbindSession` at `:102`), so T0 resolves the +account **before** the binding drops — capture it in `Hub.Stop` prior to +`unbindSession`, or keep the binding alive until the terminal status has +published. (Compass already fires a terminal presence edge at exactly this +seam — `unbindSession`'s DISCONNECTED presence publish, +`relay_comms.go:95-124` — so T0 has an in-tree precedent for +resolve-before-unbind.) T0 carries the account into the status +`GetAgentStatus` emits (`board/projection.go:143-145`, `statusOf` — the sole +Server-side builder, reached from `Snapshot` at `:114`/`:122`) and into the +sole push-path synthesis site, `Hub.deliverSession` (`hub.go:533-546`), +which today builds +`&compassv1.AgentSessionStatus{SessionId: sessionID, State: state}` +(`:543-546`) from a frame carrying only a session id. + +A server built with no Runner door answers `Unavailable` on every lifecycle +RPC (`go/server/service.go:51-53`, checked per-handler — e.g. `:131-132`, +`:188-189`, `:252-253`), so the error surface renders a transport-level +failure, not only per-session errors — the shipped `stopAgent` already routes +exactly this refusal (`store.ts:1808-1815`). + +### Control flow: add-a-workstream (no RPC) vs start-an-agent (`SpawnAgent`) + +Two distinct client operations (DL-164). Adding a workstream to an agent is a +**board** operation and makes **no** lifecycle RPC at all — it creates the +card and names its agent, nothing more. Starting an agent is the lifecycle +operation, one RPC — the server orchestrates Provision → Start (DL-166): + +1. Start = mint one `client_request_id` (UUID); call + `SpawnAgent(agent_account_id, initial_prompt, client_request_id)` → + `session_id`. The server runs `ProvisionAgentWorkspace` then + `StartAgentSession` internally (the existing RunnerHub Provision+Start + path, `runnerhub/commands.go`) and owns dedup across both steps under the + one id. **`SpawnAgent` rejects when that agent already holds a live + session**, and the rejection must be a **pre-Provision short-circuit**. + The check runs **after** the `client_request_id` dedup-join lookup and + only on a cache miss — a retry of an in-flight or completed spawn joins + the original and returns its `session_id`, so reject-on-live never fires + for it (without this ordering the short-circuit would swallow the retry + case and return `AlreadyExists`, breaking the T0 idempotency criterion). + On a miss: consult **the Runner**, which is authoritative for + live-session truth, via `Hub.Status` (`commands.go:141-151`) with an + empty session id — "Empty = every live session" (`compass.proto:592-593`), + answered by the Runner's all-sessions arm (`runner/host.go:487-501`) — + and scan the returned statuses for one whose `agent_account_id` matches + the request's, returning `connect.CodeAlreadyExists` on a hit. The + **mechanism is a full-fleet scan** because the request shape admits + nothing else: `GetAgentStatusRequest` carries only + `string session_id = 1` (`compass.proto:591-594`). This makes **Change 2 + a prerequisite of the reject rule**, not merely of the UI: today's + `AgentSessionStatus` carries only `session_id` + `state`, so the scan has + no field to match on. The account must reach the *Runner-side* + construction too (`host.go:495`, `:499`) — the hub binding is + `package runnerhub`, unreachable from the Runner — and the Runner keeps + no account of its own today (`liveSession` holds + sessionID/containerName/containerID/… with no account field, + `runner/host.go:84-88`), so Change 2's T0 brief carries it onto + Runner-local state (`AgentSpec` → the registered handle → `liveSession`) + rather than parsing it out of the container name (`NamePrefix` is + Runner-owned config, `runner/spec.go:31-33`; the name derivation + `name := d.NamePrefix + accountID` at `spec.go:85` is exactly the + incidental property DL-170 exists to stop leaning on). + + The check **must not** be sourced from Server-side in-memory state. + Compass now carries the reverse index orion lacked — + `Hub.SessionForAccount` (`relay_comms.go:157-171`, over + `accountSessions`) — but it is still **live-scoped and cleared on every + Runner re-enroll** (`hub.go:707-711`, pinned by + `TestEnrollClearsReverseAccountSessions`, + `relay_comms_test.go:430-458`). That clear is a deliberate fail-**closed** + for the comms relay, but the same absence read as "nothing is live" makes + a reject-on-live check fail **open**: after a reconnect the containers + still run, the check passes, and the spawn collides on the container name + during Provision — `Launch` is create-first, so the collision surfaces as + an internal error, not `AlreadyExists`. The existing `errAlreadyRunning` + guard (`runner/host.go:224-238`) cannot carry the rule either: it sits + inside `StartAgentSession`, and a composite Provision→Start against a + live agent never reaches it. Starting a second workstream on a live agent + is therefore not a start at all: the card is added to the board and the + running agent picks it up. +2. Stop = `StopAgentSession(session_id)` — **already shipped** + (`store.ts:1816-1849`): stops the observed session, refuses fixture + sessions and offline stores with a surfaced reason routed to the error + surface, idempotent server-side so a double-click or a stop racing a + server-side death is safe. This record's stop-side work is confinement + into the same control model (two-step confirm + binding-aware enablement, + T4), not a new action. +3. Retry a failed start = re-send `SpawnAgent` with the **same** + `client_request_id` — the server's composite dedup decides + join-vs-reattempt internally, so the client never distinguishes + provision-failed from start-failed and juggles no retry id. + +### Board state model: optimistic spawn, then server-confirmed lifecycle (DL-167) + +Two pieces of state with an explicit precedence rule: + +1. **`SessionBinding.phase` (`SpawnPhase`)** — the *spawn-window* optimistic + state: `spawning` on click, `running` once `SpawnAgent` resolves with a + `session_id`, plus `spawn-failed`, `stopping`, `stop-failed`, `stopped`. + Spawn is one composite call, so the phase machine has no + provisioning-vs-starting split — those are live states the server + streams, not client await points. +2. **The live `AgentSessionState`**, rendered through `agentDotState` + (`apps/ui/src/agent-state.ts:53-56` — the compass signature takes + `(sessionState, refinement)`; the binding side feeds only the session + state). STARTING / READY / WORKING / ERRORED / DISCONNECTED live here, + pushed on SubscribeEvents. + +**Precedence:** the binding phase is the card's state until a live +`AgentSessionStatus` **attributed to that agent** arrives (Change 2); from +then on the live state wins and the binding phase holds at `running`. A +precedence *switch*, not an overwrite — `SpawnPhase` does not grow the +live-session variants. A card with no binding (added but never started) +shows no pill. Because the live event stream is already wired in compass +(`store.ts:926-932`), the switch is **attribution-gated, not lane-gated** +(an amendment to orion's "T6-gated" phrasing): it happens the moment the +first attributed status for that agent lands, which requires T0's Change 2 +on the wire — until T0 ships, statuses carry no account and the binding dot +holds. + +The store gains one new piece of state — a `SessionBinding` map keyed by +`workstreamId` (per DL-164: the board is workstream-keyed and an agent owns +multiple cards — Matt confirmed agents need multiple workstreams — so an +agent-keyed map would bleed one card's pill onto siblings). The binding +carries its `agentAccountId` for the RPC. Bindings are store-internal +wire-lifecycle bookkeeping, not a fixture-shape change: the compass board's +`Issue` (`stub-data.ts:202-240`) and `Agent` shapes stay frozen. +*(Terminology port note: orion's UI had a `Workstream` type; the compass +board renders `Issue` cards (`board.ts:39-56`, `components/IssueCard.tsx`) +and has no `Workstream` symbol. This record keeps "workstream" for the +concept — an issue promoted to a unit of work — and `workstreamId` binds to +the compass `Issue.id` (`stub-data.ts:203-205`).)* + +### Surfaces + +- **"+ New workstream" button** in the Bridge toolbar (`components/ + Bridge.tsx`, beside the board-mode segment) opening a + **NewWorkstreamDialog** with: agent picker over existing accounts **plus a + "+ new agent" path** (DL-164: the flow must be able to create agents) — + the new-agent path calls `CommsService.CreateAgent` (`comms.proto:41-42`) + and uses the returned account id as the card's assignee (`Issue.assignee`, + `stub-data.ts:231`) — plus board fields (title, priority). Submit = the + store's `addWorkstream`. **This surface starts nothing**: no `SpawnAgent`, + no prompt field, and it works whether the agent is stopped, already live, + or brand new. +- **Start** is its own affordance on the card (T4) for a card whose agent is + not live: it opens a **StartAgentDialog** whose only field is the optional + initial prompt, and submits the store's `startAgent`. It is absent when + the agent already holds a live session, because `SpawnAgent` would reject + (`CodeAlreadyExists`) — the card is simply added to the running agent's + board instead. +- **Stop** stays where it is: the LogPanel "■ stop" button + (`LogPanel.tsx:73-98`), already wired to the real `store.stopAgent()` + (`:85`) and already disabled for fixture sessions (`:79`), gains a + two-step inline confirm (DL-168: the wire has a single "deliberately kill" + semantic — `compass.proto:61-63` — so "graceful" is a UI confirm, not a + wire flag) and binding-aware enablement (T4). `ReloadAgentSession` is + surfaced later as a distinct "restart" action, out of this record's build + scope. +- A failed start surfaces on the board card (state pill + retry affordance) + and, for transport-level failures (`Unavailable`), as a dismissible banner + hosted by `Bridge` — board-level, so no card owns it. Retry re-sends + `SpawnAgent` (the server owns join-vs-reattempt). The compass card renders + as a single `