Skip to content

Fleet Studio: in-window, multi-machine sessions & workspaces #1436

Closed
15 of 15 issues completed
Closed

Description

@jeonghun-jj-lee

Important

Problem — Working with a fleet today means switching between machines, not working across them. Clicking a device swaps the whole window at the target (thin-client re-attach + reload, or a new SSH window; ADR 0030), the app reads sessions from a single server, and a session carries no machine identity. On a real two-serving-machine fleet you can only see one machine's sessions at a time, and nothing tells you where a session runs — so you cannot, say, watch a solve on the Studio while chatting on the MacBook.
Approach — Make the fleet one studio. A session-aware multiplexing proxy + SSE relay in the local service routes each session's traffic (messages, tools, permissions, the engine file API, SSE) to the machine that owns it, so any number of sessions across machines are interactive simultaneously, in one window on one origin. Identity/knowledge stay local; only sessions and the files they touch are fleet-routed. Every session is badged by machine and shows its machine · workspace · branch provenance. Underneath, a per-peer, revocable trust credential replaces the fleet-wide shared secret, and servers move off full auth=open to an accept-set (staged, readiness-gated).
Approaches considered — (1) Keep per-device window swap (ADR 0030) — rejected, it is the pain being solved. (2) Native multi-origin switcher — rejected for the data plane (bypasses /amicode/* ownership, credential translation, single-origin CSP/auth). (3) Single-target seamless re-target (#1353) — rejected as the target (can't stream one machine's solve while working on another); kept only as the pre-multiplexer interim. (4) Full host-FS write plane over amico-host:// (Option B) — rejected; the Preview tab already gives in-window remote content editing on a governed plane. (5) Reuse the Fleet token for peer RPC / transport-layer revocation — rejected (no per-machine app-layer revocation; ADR 0027 §7 intent).
Scope — in: per-peer trust credential + accept-set; fleet-wide machine-badged session list; session multiplexer + SSE relay (incl. the engine file API); focused-machine sidebar + project-roots fan-out; fleet-wide amico-host://<machine> read/review workspace browser; session-header provenance + new-session machine picker; Preview-tab remote content editing; Remote-SSH reframed as escape-hatch/fallback. out: Horizon-2 compute-federation executor; host-FS write plane (Option B); moving a running session between machines; per-tab machine glyph; scheduled credential rotation.
Assumptions — Peers reachable over the existing fleet transport (SSH/tailscale/direct); the engine file API is the same write plane the agent already uses; the sticky session-title bar in the timeline is the "session header"; #1267 (host-FS write route) and #1353 (live re-target) remain unbuilt and are not prerequisites.

Acceptance Criteria

Trust & auth (slice 1)

  • A serving machine mints a per-peer token keyed by requesting machine_id, recorded in a revocable issued-token registry (0600); a joining machine bootstraps via a single-use, short-TTL enrollment nonce (not the standing Fleet token).
  • amico fleet revoke <machine_id> fans a drop out to every issuing peer's registry and bars that machine_id from re-minting; a request bearing a revoked token is refused (401), surfaced distinctly as revoked (NoPermissions), never conflated with an unreachable peer (HubDown).
  • Constant-time token compare (no early exit); a revocation observed by a concurrently-serving process takes effect on the immediately following request.
  • With the accept-set additive, a peer-token request AND an existing hub-credential request both succeed (backcompat holds mid-migration).
  • Readiness gate (single vocabulary): the gate resolves every roster row with capabilities ∋ serving ∧ health = reachable to its machine_id, and refuses close unless — for each — both this machine's issued-token registry and its reader peer-store hold a non-revoked entry. A serving row with no resolvable machine_id, or a serving peer not yet in the roster, refuses close (never satisfies it vacuously). Test: a 3-peer fixture missing one side → close 401; add it → close succeeds.
  • After a successful close, an unauthenticated request to each boundary (the service port AND the engine port) is refused (401); the local framed app still authenticates (local mint) and is unaffected.
  • After close the transitional hub credential is refused (withdrawn in the close step); only the local mint and non-revoked peer tokens are accepted (the mint endpoint's enrollment nonce excepted).
  • Rollback: marking a serving peer stranded post-close and invoking the documented rollback re-opens the additive posture — the stranded peer's request that 401'd post-close succeeds again (assert the 401→200 transition).
  • After close, a newly-enrolling machine still obtains its peer tokens via the enrollment-nonce mint endpoint.

Fleet-wide list & badges (slice 2)

  • The session fan-out is a generalization from the current 2-source (local|hub) union to an N-peer, machine-keyed fan-out across every reachable serving peer, each tagged { owner_machine_id, owner_name, device_type, directory, is_local } (name/device_type joined from the roster); an unreachable peer is a named absence, and reachable peers' sessions still render.
  • The titlebar Sessions dropdown and the Chats rail render the fleet-wide list; each row shows its machine badge; local sessions are unbadged.
  • The per-machine filter narrows the list to one machine and clears back to all. (The focus-decoupling assertion moves to slice 4 — it exercises the slice-4 focus selector and cannot be tested in slice 2 without a cycle.)

Interaction — as if local (slice 3)

  • Owner-routing key: a path-less engine file.write/file.read for a session carries its owner machine_id (from the Work Column's bound session); the service resolver routes on that key, and a request with no key resolves local (fail-safe), never to an arbitrary attached target. Test: two remote sessions (owners A, B) each write; each write lands on its own owner (not one shared target).
  • Opening a session owned by a different machine streams its messages and lets you submit a prompt, run tools, and answer permission prompts, with no window reload.
  • Concurrent + un-crossed: with a solve streaming SSE from machine B, a prompt submitted to a session on machine A returns its first streamed token without queuing behind B, and every SSE event carries the originating session_id matching its source (no A/B cross-attribution). Test drives both concurrently and asserts per-event session_id tagging.
  • The app holds a single origin throughout (no native multi-origin switch).
  • /amicode/fleet/* and posture requests are served locally and never routed to a peer (the honesty surface never proxies).

Sidebar focus & workspace browser (slice 4)

  • The fleet section is a single-select focus selector (default: home machine); selecting a machine scopes Research/Dev/Workspace to it; no roster row is written.
  • Project-roots fan-out: selecting a remote machine renders that machine's reported Research/Dev roots; an unreachable machine is an honest absence, not an empty-success.
  • Focus-decoupling (moved from slice 2): the fleet-wide session list is unaffected when the focused machine changes.
  • Focusing a remote session tab sets the focused machine to its owner and reveals that session's folder in the focused view.
  • The Workspace browser lists a remote machine's workspace files via amico-host://<machine> (recursive read, text; binary is a follow-up). Split by precondition: (a) reachable owner + human write attempt → an honest not-implemented / read-only outcome via the RouteAbsent → NoPermissions/Unavailable seam (write routes unbuilt, Fleet client: native Explorer shows host files via an amico-host:// FileSystemProvider (with honest capability label) #1267), no local write, no fabricated success; (b) unreachable owner + any browse → an honest HubDown / Unavailable outcome, no local read.

Provenance & editing (slice 5)

  • A remote session shows a computer icon left of its title (tooltip = machine name); a local session shows none.
  • The provenance caret opens a menu showing Machine · Workspace · Branch (+ Open folder / Copy path); the remote branch/worktree is read from the owner.
  • The titlebar is unchanged versus a captured pre-change baseline (no per-tab glyph; no session-scoped control migrated out) — assert against a snapshot of the current titlebar control set.
  • A new chat's composer shows Machine ▸ Project ▸ Worktree selectors with Machine first, defaulting to the focused machine and overridable; the new session runs on the chosen machine.
  • Editing and saving an existing file in the Preview tab of a remote session writes to the owner's filesystem via the engine file.write (multiplexed, owner-keyed), governed by the peer-trust credential, and the human write path is gated server-side to workspace-relative paths (an absolute path is rejected).

Testing Decisions

Reuse-first. Extend the merged-projection data-plane suite (fan-out over N peers, per-peer tag, revoked-peer named absence, currency). Extend the fleet-enroll suite (mutual peer-token exchange; readiness gate refuses an incomplete close). Extend the fleet beta smoke to a mint→read→revoke round trip against the real two-Mac fleet. Extend the app session-dropdown suite (merge + machine badge + filter). New surface only where genuinely new: the accept-set validator, the session→machine multiplexer routing, the SSE merge.

Key Decisions

  • Multi-target, both live via a session-aware multiplexer + SSE relay; single app origin; per-session routing retires the single attachment pointer (ADR 0031 §D1).
  • Home base local; only sessions + their files fleet-routed (ADR 0031 §D3).
  • Focused machine scopes the working sidebar surfaces only; sessions list stays fleet-wide (ADR 0031 §D4).
  • Provenance in the session header, titlebar untouched, machine picker first in the new-session cascade (ADR 0031 §D6).
  • Remote file mutation: agent in-place; Preview tab in-window content edit; native Explorer read/review; Remote-SSH for human structural/native + degraded fallback (ADR 0031 §D7–§D8).
  • Trust: per-peer revocable credential + accept-set replacing auth=open, staged additive→readiness-gated close (ADR 0032).

Data Contracts

  • Issued-token registry (minter side): { <machine_id>: <token record> }, 0600, revoke = delete entry; single-writer (the machine granting access to itself). Fleet-wide expulsion = a fan-out of per-registry drops + a mint-list bar (ADR 0032 §D4/§D5).
  • Reader peer-token store: a dedicated store (not attachment_credential.ts, whose header disclaims the peer-trust identity), keyed { <machine_id>: { base_url, token } }.
  • Accept-set predicate: accept ⟺ local mint ∨ non-revoked peer token ∨ (until close) transitional hub credential ∨ (mint endpoint only) enrollment nonce. Checked constant-time, per-request, at both the service and engine boundaries; the scope claim is recorded but inert in v1.
  • Session tag (overlay, not on Session.Info): { owner_machine_id, owner_name, device_type, directory, is_local }.
  • Session→machine map + owner-routing key: session-pathed requests resolve the owner from the id in the path (miss → on-demand fan-out refresh); path-less engine file requests carry the owner machine_id as a routing header from the Work Column's bound session (a miss on the map cannot fire for a path-less request — the header is mandatory; absent it, resolve local).
  • SSE relay cursor: N merged upstreams need a per-peer cursor set (or a service-side re-sequenced monotonic id space) — a single scalar lastEventID cannot address N independent peer positions on reconnect.

Constraints & Invariants

  • Single app origin (ADR 0027) — purchased by the multiplexer's per-session routing (ADR 0031 §D1a); one-parser fleet.json (ADR 0023); read-only roster (ADR 0026); local honesty surface never proxies (ADR 0027 §4).
  • No silent local fallback: an unreachable owner surfaces the honest degraded outcome, never a fabricated success or a local read.
  • The accept-set close is readiness-gated (single-vocabulary, machine_id-keyed, locally observable) and rollback-documented — a security-invariant change that must never lock out a half-migrated fleet.
  • The peer token's write reach equals the agent's (arbitrary-path on the owner — shell-equivalent if leaked); the human Preview write path is narrowed below it (workspace-relative, server-enforced). "No new blast radius" is not claimed for the raw token.

Prior Art

Read first: the merged-projection data plane and GET /amicode/fleet/sessions; the keyed attachment-credential store and the hub-credential mint; the fleet transport resolver and the heartbeat peer-push; the amico-host:// FileSystemProvider + host file client (vault-scoped, text-only read, write unbuilt — #1267); the app session dropdown / workspace session list / session-item row; the sticky session-title header in the message timeline; the new-session project/workspace selectors; the connect-to-device Quick Pick (being superseded) and the Remote-SSH resolver.

Review

Round 1 (2026-09-22, manual — amico spec review tooling absent on this machine, so this is a hand-run adversarial review, a weaker claim than a recorded amico spec review). Four independent critics, one lens each (security/trust, cross-artifact consistency, falsifiability, architecture/feasibility), each reading only the artifacts + the real code. Outcome: 1 blocking contradiction (ADR 0031 asserted "both server_mode: server" against the frozen "exactly one Canonical Server" / accepted ADR 0029) — resolved by rewording to ADR 0029's serving/keeper model. High-severity advisories addressed in-artifact: the engine-file-API owner-routing key (was unspecified — now ADR 0031 §D1a); the readiness gate's machine_id single-vocabulary predicate (was a roster-alias vs token-machine_id mismatch); the bootstrap that defeated revocation (now a single-use nonce + mint-list bar); the scope claim marked recorded-but-inert; the write blast-radius corrected + human path workspace-scoped. Cheap accuracy fixes: a mis-attributed ADR 0027 quote, the D10b label (spec, not ADR 0027 §7), the Preview _Avoid_ amendment. Tracked obligations (advisories carried to slices, not blocking start): SSE per-peer cursor set (slice 3); amico-host:// workspace-root + binary read split (slice 4); constant-time compare + per-request read (slice 1); owner-vs-local pane-disagreement UX note (slice 3/5). Re-verification: a focused re-review confirmed no residual contradiction.

Round 2 (2026-09-22, develop-mode kickoff — 3 independent critics: DAG integrity / falsifiability / feasibility-vs-code, each reading the sub-issues + the real code on feature/free-tier-fleet). Outcome: decomposition sound (AC coverage bijective 9/3/5/3/5; cross-slice contracts consistent; linear DAG 1438←1439←1440←1441←1442 correct) but not implement-as-is. Fixes applied to the sub-issues (authoritative at slice granularity) and mirrored here: (blocking) slice-2's focus-decoupling AC moved to slice 4 (it exercises slice-4's focus selector — un-wireable as a 2←4 edge without a cycle); slice 4 gains a project-roots fan-out AC (was a vacuous-pass gap). (testability) reframed untestable-as-written ACs — constant-time compare → structural (timingSafeEqual) + observable (wrong-length/equal-length-wrong both 401), no latency; revocation currency → per-request no-cache (mint→delete→401); "no head-of-line" → deterministic ordering (block B, drive A, assert A resolves first); titlebar-unchanged → the titlebar-layout.test.ts equality contract (no snapshot mechanism exists). (seam accuracy) slice 1's engine-port accept-set is a NEW opencode overlay + build:binary (base is stock ===), not an extension; slice 2 is a generalization of merged_projection.ts (2-source → N-peer + roster join + per-peer read), not a suite extension; slice 3's owner-routing substrate is net-new (no session→owner binding today — only the single global attachment pointer it retires); slice 5's Preview human-write gate must live on a separate service-side route, not the shared engine file.write handler (which intentionally permits absolute paths — the agent capability). Effort is front-loaded: slices 1 & 3 are the heavy ones. No blocking contradiction remained after the fixes.

Source

Design-of-record from the brainstorming → grill-with-docs session, 2026-09-22. ADRs: 0031 (Fleet Studio), 0032 (peer-trust credential + accept-set), committed under docs/adr/. CONTEXT deltas held in the design record, applied slice-by-slice as each lands (the ADRs are proposed, so the glossaries are not amended up-front). Decompose into the 5 slices via break-into-subissues.

Notes

Staging (Approach A — foundation-first, vertical thereafter): (1) peer-trust credential + accept-set → (2) fan-outs + fleet-wide list + badges → (3) multiplexer + SSE relay (incl. the owner-routing key) → (4) focused-machine sidebar + workspace browser → (5) provenance + new-session picker + Preview remote edit + Remote-SSH escape hatch. Each is a vertical, TDD-ready slice; the readiness-gated auth close and its rollback are explicit ACs of slice 1.

Deferred / superseded: #1353 (live re-target, no-reload single-target) — the pre-multiplexer interim only; #1267 (host-FS write route) — not a prerequisite; Option B (host-FS write plane) — out of scope; Horizon-2 executor — ADR 0027 non-goal. ADR 0030 §D2 (Quick Pick) is superseded and its §D3–§D5 pointer machinery retired (ADR 0031 §D8); ADR 0025 (Remote-SSH posture) is reframed; ADR 0026 fleet section is amended (focus selector, still no roster write); ADR 0027 §7 (spec D10b) is promoted to built.

Resolved during review (was an open question): the Fleet "exactly one Canonical Server" clause vs the two-machine fleet is not a conflict — the fleet is two serving-capable devices, exactly one keeper, precisely accepted ADR 0029's model; the earlier "both server_mode: server" wording was the error and is corrected.

Activity

  1. added a commit that references this issue on Sep 23, 2026
  2. jeonghun-jj-lee commented on Sep 23, 2026

    @jeonghun-jj-lee
    ContributorAuthor

    Graph reconciliation: original broad slices and #1455 wiring tickets are now closed as superseded partial foundations, with exact replacement links in their closure notes. #1475 is the live completion parent and its native children carry the corrected security, lifecycle, transport, UI, SSE, and two-peer E2E DAG. #1450 remains the SSE design gate.

  3. aarontrowbridge commented on Oct 29, 2026

    @aarontrowbridge
    Member

    Closing as done in the 0.4.0 backlog sweep (2026-10-29), checked against lane/0.4.0.\n\nEvidence: Fleet Studio slices landed (#1444,#1455,#1494 39b1030,#1535); 18 children closed; #1475 done; leftover is Armonia #1872\n\nReopen if this is wrong.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    hitlNeeds human review before merge

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions