Skip to content

Fleet thin client: #792 slices + #1260 + #1258 (→ feature/free-tier-fleet) - #1266

Merged
jeonghun-jj-lee merged 23 commits into
feature/free-tier-fleetfrom
amico/fleet-thin-client-792
Sep 19, 2026
Merged

jeonghun-jj-lee merged 23 commits into
feature/free-tier-fleetfrom
amico/fleet-thin-client-792

Conversation

@jeonghun-jj-lee

@jeonghun-jj-lee jeonghun-jj-lee commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Single integration PR for the fleet thin-client campaign, targeting feature/free-tier-fleet (NOT main). Every slice was implemented TDD (RED→GREEN) and gate-verified by the director (typecheck + full suite) before banking; slices merged sequentially on this one branch.

Design of record: ADR 0024 (docs/adr/0024-fleet-thin-client-pluggable-transport.md, already on base).

Slices — all green (director-verified)

Test posture

Known integration seam — the #792 world-merge (flagged by #1261 AND #1265) — NOT in this PR

  • extension.ts client-activation boot still frames the tunnel engine origin directly (the fleet-client branch boots no relay; base startAmicodeService boots the relay with no fleet option). The relay data plane + posture feed + transport are built, typed, tested, and wired at the correct seams, but no running process co-locates them for a client yet. The last-mile "boot + frame the local relay for a client" wiring is the remaining integration step. Deliberately not faked — a synthesized live relay poll would read the hub's posture, not the client's (a dishonest posture), which is exactly what this work refuses to do. Recommend a dedicated follow-up issue for the activation-boot world-merge.

Other documented follow-ups (additive — not regressions)

  • fleet_health.ts checkFleetTunnel still self-skips Linux; systemdTunnelUnit/fleet_hub_service renderers exist and (for the hub service) are installer-wired, but the tunnel's Linux health-check arm isn't wired.
  • The live checkFleet poll still uses the old inline probe; rewiring it to transport.health() is a clean shared follow-up (posture already works via the hub proxy's data-plane outcomes).

Part of #792. Do not merge to main — target is feature/free-tier-fleet, merge is the maintainer's call.

The client-role never-fork decision was mac-only: isFleetClientGuard
early-returned on process.platform !== "darwin", so a linux/WSL client
silently cold-spawned a local engine (the ADR-0005 split-brain, #1227).

Extract the platform-agnostic decision divertToFleetRelay(binary, state)
into fleet_topology.ts (the role read is already OS-neutral) and drop the
darwin early-return. Never-fork now holds on mac, linux, and WSL alike,
asserted by a non-darwin fixture + a source-guard against the return.
…ck (AC1/AC2/AC6)

A fleet client runs the amicode_service as a relay with NO local engine
(never-fork). Thread a `client` flag through createAmicodeService → the
FleetPlane:

- AC6: suppress the standalone→engine mode flip for a client (getMode stays
  fleet) and emit the relay's OWN named hub-down 503 ("fleet-hub-down" +
  pointer) instead of "engine upstream not available" — there is no local
  engine to be unavailable, and no silent local fall-through. The engine-armed
  machine keeps its existing behavior.
- AC1/AC2 (already-built, now pinned for the client config): UI served locally
  (zero assets cross the WAN), host sessions via the proxied data plane, and
  the credential translated (client mint stripped, hub mint attached).
The client relay refuses to START when its pinned version and the host's
version (GET /global/health → version) disagree beyond a defined tolerance —
an ACTIONABLE message naming both versions and the fix, never a confusing
generic downstream timeout. Protects the host-API-shape assumptions #1262 /
#1264 build on (moved here from #1265 — it is a relay-start gate).

- fleet_version_skew.ts: pure versionSkewVerdict (tolerance exact|minor|major,
  default minor = major.minor must agree), relayVersionGate (start|refuse),
  hostVersionProbe (bounded, honest-null on failure).
- Wired into startAmicodeService: a skewed host returns undefined (no boot);
  matching — or an unreadable host version (deferred to hub-down) — boots.
…n (AC5)

#792 Amendment B's churn objection: an extension-host relay accumulating SSE
upstreams on every window reload (a hub connection storm). Answer it with the
lazy 1:1 pass-through it always was — plus the missing teardown: when the
CLIENT disconnects (a reloaded window closing its stream), destroy the host
upstream. So N reloads re-join each endpoint ONCE, never N× concurrent.

- HubProxy + EngineProxy: res.on("close") → upstream.destroy() (skipping a
  normal writableFinished completion).
- A client-initiated abort sets clientAborted so it never records a
  no-response outcome (a reload must not falsely drive the hub-down posture).

Asserted by a concurrency-counting stub host: N reloads → N joins,
maxConcurrent === 1; the posture stays fleet, no_response_streak 0.
The guard backstop was darwin-only in two places. Make it cross-platform:

- install.sh: enrollment writes the platform-correct VS Code settings path —
  macOS Application Support; linux/WSL ~/.config/Code/User, plus the Remote-WSL/
  SSH server-side ~/.vscode-server/data/Machine/settings.json when present — so
  amicode.opencodeBinary/opencodePort are set on a client of any OS. The guard
  --check host verification runs on every OS now (no non-darwin skip); the
  launchd TUNNEL stays darwin-only (the linux tunnel is #1260). Packaged copy
  kept byte-identical.
- fleet_health.ts: checkFleetGuard/Settings/Role RUN on linux (only
  checkFleetTunnel stays darwin-specific); the aggregate standalone-floor is
  cross-platform.
- extension.ts: the activation-time drift warning fires on linux too (the
  plist read stays darwin-only; the tunnel check self-skips).
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: c7f48d0c-f138-4802-82d4-1236d42ecbc9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…cal dispatch (AC1/AC2/AC5)

In fleet CLIENT mode the host owns all /amicode/* state. dispatch() now
bypasses the ENTIRE local /amicode/* dispatch (the exact-match route table
AND the :262 catch-all) so a REGISTERED route (GET /amicode/problems, POST
/amicode/connections) no longer shadows the fleet branch — the request routes
to the host's authoritative amicode_service (reads via HubProxy, non-GETs via
the write-failure contract). /amicode/fleet/* stays LOCAL (the client's own
posture/mode/staging honesty surface, #1261 AC6). Gated on the client role:
standalone AND the engine-armed base machine are byte-identical.

Extends #1261's stub-host relay test: a registered /amicode/* GET returns the
host sentinel (AC1); the fleet honesty surface stays local (AC1); a mutation
lands + is accepted host-side with the hub mint translated (AC2); standalone
still serves /amicode/* locally (AC5).
…ency pin (AC3/AC4)

The write-failure contract's deriveRefetchPath was session-shaped: an
/amicode/* write fell through to /${segs[0]} = /amicode, dropping the mutated
family and yielding a refetch that hides a partial write. Add
deriveAmicodeRefetchPath — the /amicode/* variant that refetches the FAMILY
GET twin (POST /amicode/connections/credential → GET /amicode/connections),
and dispatch to it from deriveRefetchPath. So a proxied /amicode/* non-GET
resolves through delivered/failed/ambiguous with an /amicode/*-appropriate
refetch, never a raw proxy that hides a partial write (AC3).

Concurrency policy (AC4): LAST-WRITE-WINS with whole-request atomic delivery.
The write pipeline buffers each body fully and sends it in ONE request, so two
clients' bodies never interleave; the single-threaded host applies whole
requests, so the resource holds exactly one client's COMPLETE value. No
lost-update DETECTION (no CAS/ETag) — a client that read-modify-wrote stale
state can silently lose. A two-client concurrent-POST test pins it.
… (AC1/AC2/AC3)

The #1261 relay proxied HTTP + SSE, but both proxies are body-pipe-only and
the relay's HTTP server had no `upgrade` handler — so the integrated terminal
(the engine's WS PTY route, GET /pty/:id/connect) was dead on a thin client:
the handshake fell through to dispatch → the body-pipe HubProxy, which drops
`Connection`, so the host never upgraded (answered 426).

Extend the SAME fleet branch (fleetPlane.client === true && routingMode ===
"fleet") with an upgrade path:

- server.ts: an `upgrade` listener on the relay's http.Server. A fleet CLIENT
  tunnels the upgrade to the host engine via HubProxy.handleUpgrade; every
  other posture (engine-armed base, standalone, no fleet plane) destroys the
  socket — the prior no-listener behavior, so loopback/never-fork is unchanged.
- hub_proxy.ts: HubProxy.handleUpgrade re-implements credential translation for
  the 101/raw-socket path (the body-pipe proxies have no upgrade path to reuse).
  It forwards the engine's OWN 101 verbatim — status line + rawHeaders (the
  engine-computed Sec-WebSocket-Accept) + the head buffer — NEVER a synthesized
  101; keeps Connection/Upgrade/Sec-WebSocket-* so the host upgrade fires;
  strips the client mint + ?auth_token= and attaches the hub mint; preserves
  ?ticket=/?cursor=/Origin. The host's PTY route authorizes by ticket OR Basic.
- Clean teardown (AC3): any end/close/error on either side destroys BOTH sockets
  (pipe with { end: false }) so a dropped WS never leaks the counterpart via a
  half-open FIN; a pending pre-upgrade drop aborts the upstream request.
- server.ts stop(): closeAllConnections() so a live WS/PTY tunnel at shutdown
  can't wedge server.close() on a hijacked socket.

New test fleet_ws_upgrade.test.ts: a WS-echo test against a stub WebSocket
upstream (no `ws` dep — hand-rolled framing). The stub returns a SENTINEL
Sec-WebSocket-Accept a synthesized 101 could never produce, so the client
observing it proves verbatim 101 forward. Covers handshake + both-way frames
(AC1), the PTY route with hub-mint translation + ?ticket=/?cursor=/Origin
preserved (AC2), leak-free teardown on an isolated host (AC3), and the
scope-fence that a non-fleet-client relay never proxies the upgrade.
…undary dedupe (AC1/AC2/AC3/AC4)

The client relay's event stream sent no cursor and the engine's multiplexed
streams emit id: undefined, so a tunnel blip dropped every event in the gap.
The engine's per-session route /api/session/{id}/event?after=<seq> is resumable
(replay durable events after an aggregate seq) but unused.

Make the relay resume it losslessly, client-side (no engine change):
- SessionEventResume tracks the last delivered aggregate seq PER SESSION
  (per aggregate, never one global cursor), carried across relay reconnects.
- HubProxy injects ?after=<seq> on a per-session reconnect and routes the SSE
  body through a byte-verbatim dedupe/track filter (a replayed seq at or below
  the resume point is dropped — idempotent resume, no double-delivery).
- SSE headers are flushed on connect so a fully-caught-up resume still opens
  the stream (Node sends headers lazily; a zero-byte resume would otherwise
  hang the client).
- The non-resumable /event and /global/event streams have no cursor form and
  are LEFT AS-IS (never given ?after=, never assumed resumable) — documented
  out of scope in ADR 0024; making them resumable is separate engine work
  coordinated with #775, not forked from it.

Only armed for a fleet CLIENT (never-fork); the engine-armed base machine
falls back to its local engine on hub-down, so its steady state is unchanged.

Tests (test/fleet_sse_resume.test.ts, reusing #1261's stub-host harness):
AC1 drop-mid-stream loses zero events, gap replayed, boundary deduped;
AC2 per-session cursors resume independently (A@3, B@7);
AC3 /event + /global/event never given ?after=; AC4 steady-state byte-verbatim.
…p — single writer, transition-only (AC1/AC3)
…ed render (AC1/AC2/AC3)

The relay's FleetPostureDetector computes the hub-up-but-slow DEGRADED steady
state the extension's own up/down probe cannot; #780 left feeding it into the
posture state file as an explicit seam. Wire it THROUGH #780's single writer:

- fleet_posture_feed.ts: the RELAY→WRITER seam. Maps the relay posture
  vocabulary (fleet|degraded|standalone) onto #780's PostureFacts and drives
  the SAME FleetPostureStateWriter — no fs write of its own, no second writer.
  Transition-only is the writer's own signature discipline. degraded is
  hub-UP-but-slow (reachable=true), never rendered as fell-back.
- extension.ts: checkFleet's fleet/standalone writes and the manual
  goStandalone fallback both go through the seam (recordPostureState) — one
  fact-builder, the one writer. goStandalone now persists standalone posture
  so the render stops claiming 'attached' the instant the user chooses
  standalone (AC1: never a stale attached claim).
- stack_state.test.ts: locks the honest DEGRADED render (machine, hub,
  reachable-but-slow, timestamped, never as-if-attached) and OS-neutral copy
  (no launchd/plist) — coverage the relay-distinctive state lacked. #780's
  renderPostureLines already handled it; these guard it.

AC1 render for fleet/standalone/missing/corrupt/stale was #780's; this adds
the degraded-state coverage + the relay feed. The client-relay boot that
co-locates the detector with checkFleet is a later #792 world-merge.
The pluggable-transport seam (ADR 0024) as a new module, fully unit-tested:

- FleetTransportProvider interface (the Data Contract the tailscale/direct
  slices consume): resolveBaseUrl() -> URL|undefined, health() -> status.
  undefined base URL = the honest 'no forward bound' (hub-down), never a
  fallback to another provider's URL.
- createSshProvider: the current launchd/systemd -L forward behind the seam.
  resolveBaseUrl() is the loopback hub URL (read late); health() is an active
  probe (reachable+latency+version | unreachable+reason). (AC2)
- resolveFleetTransportKind: the amicode.fleetTransport selector — default ssh,
  independently disableable, a NAMED not-ok for a disabled/unshipped/unknown
  kind, NEVER a silent cross-provider fallback. (AC3)
- transportHealthToOutcome: the SHARED drop-vs-slow-link posture contract this
  slice OWNS — maps any provider's health into the existing FleetPostureDetector
  (reachable -> responded -> p95 decides degraded; unreachable -> no-response ->
  streak decides hub-down); the provider kind rides the reason. (AC4)
- sshForwardArgs + systemdTunnelUnit: the ssh provider's OS forms — the Linux
  systemd form of the launchd plist, loopback-only -L on both ends (ADR 0002/0005).
- hubUrlStringFromProvider: the byte-identical adapter back to the getUrl seam.

Provider-conformance tests reuse the #792 relay stub-hub contract (test/support/
stub_hub.ts). 23 tests.
…no fallback (AC1/AC3)

startAmicodeService constructs the transport provider from the fleetTransport.kind
setting (default ssh) and derives hub.getUrl from provider.resolveBaseUrl() through
the byte-identical adapter:

- unset/ssh reproduces today's launchd-forward behavior exactly — the 13
  existing activation-wiring tests stay green (the no-regression gate). (AC1)
- a not-yet-shipped provider (tailscale/direct) binds NO base URL -> the honest
  'hub upstream not available' 503, NEVER a dial of the configured ssh URL
  (no silent cross-provider fallback). (AC3)
- the boot log names the transport selection outcome (never a silent no-op).

The late-bound hub-URL getter is preserved (a de-armed activation still yields
the honest upstream absence). 3 new end-to-end wiring tests; 16 total in the file.
… (AC1)

- package.json declares amicode.fleetTransport (enum ssh|tailscale|direct,
  default ssh) — the one knob; the description states the no-fallback law and
  that unset/ssh reproduces today's behavior.
- extension.ts reads it (readFleetTransportOption) and passes fleetTransport.kind
  to both startAmicodeService call sites. Empty/unset -> the wiring defaults ssh.
…ifecycle

The Data Contract lists resolveBaseUrl() + health() + start()/stop(). Add the
lifecycle members to the provider interface. The ssh tunnel is OS-managed
(launchd/systemd, installed by the fleet installer) in this build, so its
start()/stop() are honest no-ops that defer to the service manager — the seam
members exist so the tailscale/direct slices (which own their lifecycle)
implement them behind the SAME interface. 24 tests.
…bind preservation (AC tailscale slice)

The host runs `tailscale serve` fronting its loopback service; the client
resolves the host's MagicDNS origin as the base URL. createTailscaleProvider
implements the SAME FleetTransportProvider interface as ssh (resolveBaseUrl →
the MagicDNS origin, read LATE; health() an active probe; start/stop honest
no-ops — serve is host-side, tailscaled is a system daemon). MagicDNS + the
`tailscale serve` mapping are modeled behind injectable seams (no `tailscale`
binary in CI), exactly as the ssh provider models its `-L` forward.

- resolveFleetTransportKind ships `tailscale` in the available set; it is
  independently disableable. `direct` remains the unshipped seam member that
  pins the no-cross-provider-fallback law.
- transportForSelection maps a selection → ITS OWN provider (tailscale → the
  tailscale provider, never a silent ssh substitution); the host wiring uses it.
- tailscaleServeMapping fronts the MagicDNS origin onto a LOOPBACK target
  (127.0.0.1), so the host engine/service bind stays loopback and the mutation
  guard never trips — asserted TOGETHER with MagicDNS resolution in the
  bind-host guard suite, with the rejected direct-tailnet-bind (100.x) contrast.
- health() reuses transportHealthToOutcome (the ONE shared posture contract);
  an unreachable host is the honest hub-down, never a reroute to ssh.
- #775 front-door composition: the serve target is the same loopback front-door
  endpoint the ssh forward reaches, never a bypass.
- Refactor: extracted the shared probeTransportHealth (ssh + tailscale).
…e / systemd WantedBy+Restart, run the #955 runner not a bespoke serve (one writer, ADR 0005)
…only) — launchd/systemd-user unit runs the #955 runner with RunAtLoad+KeepAlive, one writer (ADR 0005), never-fork gate on the parsed role (ADR 0023)
@jeonghun-jj-lee
jeonghun-jj-lee marked this pull request as ready for review September 19, 2026 02:50
@jeonghun-jj-lee
jeonghun-jj-lee merged commit c1a9512 into feature/free-tier-fleet Sep 19, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant