Repository navigation
Peer fleet: two-way serving, heartbeat sync, and live health indicators - #1407
Conversation
|
Important Review skippedToo many files! This PR contains 412 files, which is 312 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configuration
⛔ Files ignored due to path filters (2)
📒 Files selected for processing (412)
You can disable this status message by setting the
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. Comment |
|
holy mother reviewing bits of this now |
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).
…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.
…y (AC1/AC2/AC4/AC5)
…p — single writer, transition-only (AC1/AC3)
…e-topology-reader guard (AC5)
…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).
…robe, no tunnel lifecycle (AC direct)
…anti-substitution (AC direct / shared AC)
…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)
… supersedes 0005 Remote-SSH rejection; flip gated on FSP (#1267) + UI-kind extension split
…s host files (with the mandatory capability label)
In fleet-client (thin-client) posture, mount a dedicated amico-host://
FileSystemProvider so the native Explorer, open-in-editor, and save operate
on the HOST filesystem over the ALREADY-PROXIED /amicode/* relay data plane —
reversing nothing about the thin-client posture (a new host-file VIEW, not a
file: override).
Design (per the terrain's honesty constraint):
- host_file_client.ts — the PURE core (no vscode, NO node:fs): path→route
mapping, auth injection, response decode, error→typed-code translation. The
READ plane (list/read/stat) maps onto the REAL vault-browser contract the
engine already serves (GET /amicode/vaults + /amicode/vault-files +
/amicode/vault-file) so AC1/AC2-read/AC5/AC6 are backed by the real engine.
The WRITE plane (write/rename/delete/mkdir) maps onto the INTENDED host
mutation routes the engine does NOT yet serve; an absent route 404s and the
client surfaces an explicit RouteAbsent — never a fabricated success, never a
local write. Having no node:fs at all makes a silent local fallback
STRUCTURALLY impossible (AC5): baseUrl()===undefined throws HubDown before any
fetch.
- mount_policy.ts — the dedicated scheme, the fleet-client gate (AC6), and the
MANDATORY disclosure label (AC4: Explorer=host while terminal/LSP/SCM/search=
local, points to Remote-SSH).
- mount.ts — the injectable, vscode-free wiring seam (register provider + add
folder + show label), so AC4/AC6 are pinned without the editor host.
- provider.ts — the thin vscode.FileSystemProvider adapter: HostFsError →
vscode.FileSystemError (HubDown → Unavailable, honest degraded posture).
- extension.ts — wires the mount ONLY in the fleet-client attach branch, base
URL = the tunnel origin (undefined when down → honest hub-down), auth = the
relay-translated header. Opt-out via amicode.fleet.hostExplorer.
Tests reuse and EXTEND the stub-hub harness (Testing Decisions) with the host
file routes; hostReadOnly emulates the current real engine (mutation routes
404) to pin the honest RouteAbsent path. 33 new tests; neighboring
fleet/relay/posture/proxy suites unaffected; typecheck clean.
HONEST GAP (host-side follow-up): the real engine has NO generic host
write/rename/delete/mkdir route today — only the vault-browser read routes.
AC2-write/AC3 are green against the stub that serves the intended routes; they
go green against the real engine once the host adds POST /amicode/vault-file{,
/rename,/delete,/mkdir}. Until then the provider surfaces RouteAbsent honestly.
Part of #792. Refs #1267, #1262.
…cing Window mode (Remote-SSH vs editor-local) is an axis ORTHOGONAL to the link-health posture (#780). Overloading the posture field would break its single-writer / transition-only contract and SILENTLY SWALLOW a window-mode-only change (the mode flips while hub reachability is unchanged, so the posture signature never moves). So window mode gets its OWN field, its OWN writer with its OWN transition signature, and its OWN state file. - src/fleet_window_mode_state.ts (new): WindowMode = "remote-ssh" | "local" (AC1: never the posture `standalone` token). WindowModeStateWriter mirrors #780's FleetPostureStateWriter — injectable clock/sink, atomic write, never-throw, transition-only — but its signature is the WINDOW-MODE VALUE ALONE, independent of the posture signature (AC2). windowModeFromRemoteName is a PURE derivation from vscode.env.remoteName (ssh-remote* → remote-ssh, else local) so it TDDs with no live editor. State file: ~/.amico/ops/fleet/window-mode.json (env AMICO_FLEET_WINDOW_MODE_STATE). - opencode-plugin/stack_state.ts: read the window-mode file shape-tolerantly and render one honest, timestamped line in ## Fleet (live) (AC3 — agent context). Additive & optional: absent/corrupt/unknown → nothing. - src/status_bar.ts: statusBarLabel gains an optional windowMode param that reflects a remote-ssh window in the text + tooltip; local/undefined leave the label unchanged (AC3 — status). StatusBarManager.setWindowMode wires it. - src/extension.ts: at activation, derive the window mode from vscode.env.remoteName, record it via its own writer, reflect it in status. Runs for EVERY window regardless of fleet role. The link-health posture writer/signature is UNTOUCHED (AC4) — verified by a cross-writer test: a window-mode-only transition writes via the window-mode writer while the posture writer, fed unchanged posture facts, does NOT write. Judgment calls: - Signature = window_mode value only; remote_name is provenance recorded in the file but EXCLUDED from the signature — re-attaching to a different SSH host at the same window mode is not a transition. - A corrupt/unknown window-mode file renders NOTHING (unlike posture it is not a "role is config, not live" correction, so it emits no degraded note). Tests: +19 (fleet_window_mode_state) +5 (stack_state surfacing) +4 (status_bar). Fast gate green (only pre-existing skill_freshness_orchestrator failures remain); typecheck clean.
…e hub, never forks (+ relocation audit template) Slice #1270 (ADR 0025): the walking skeleton for host-side attach over Remote-SSH. The never-fork guarantee ALREADY exists via adoptOrSpawn + its activation wiring (extension.ts:986 surfaces foreign-error/incompatible-error and skips the spawn) — so the honest, correct delta is the PROVING scenario test, not an invented change. No production code changed; no gap found. - test/adopt_live_server.test.ts: add the #1270 host-side-relocation scenario (4 tests), composing the three seams end to end — windowModeFromRemoteName (ssh-remote -> remote-ssh, the relocation signal, #1272) + divertToFleetRelay (hub host is role=server -> no relay divert, proceeds to adopt) + adoptOrSpawn against a live durable-hub survivor. Asserts AC2 (adopts at the handshake; no second engine, no second store) and AC3 (unadoptable handshake -> surfaced foreign-error, never a silent cold-spawn). Reuses the #1185 fixtures verbatim. - docs/fleet/relocation-audit-1270.md: the AC4 HITL review artifact — a TEMPLATE enumerating each surface (activation, engine adopt/spawn, unadoptable-surfaced, durable-hub service, native Explorer, terminal, runs, chat) with PASS/BROKEN boxes + the exact manual check for a live Remote-SSH window. No results invented; awaits a live run. AC1 (runtime host-side activation) is runtime/HITL — not unit-testable here; covered by surface 1 of the audit template. Gate: 152 fleet/adopt/never-fork suite tests pass (4 new); typecheck clean. Pre-existing skill_freshness_orchestrator failures (6) unrelated to this slice.
…e projection
Add 'Amicode: Connect to Hub over Remote-SSH' (amicode.fleet.connectRemoteSsh):
resolve the hub coordinates from the projection via readFleetTopology (the ONE
topology reader, ADR 0023 — never a hand-built host string or a second config
source) and open a Remote-SSH window onto the hub workspace.
- src/fleet_connect_remote_ssh.ts: pure resolveRemoteSshTarget (canonical coords
+ workspace path -> vscode-remote://ssh-remote+<alias>/<path> URI, or a typed
cannot-resolve reason), the resolveRemoteSshFromTopology adapter (absent/broken
surface the reader's own actionable message verbatim), and the thin
connectToHubOverRemoteSsh handler (opens exactly once on success; on every
error path shows the honest message and opens nothing — no half-window, AC2).
- extension.ts: register the command; read the remote folder from the new
setting; log the outcome to the opencode channel.
- package.json: declare the command + amicode.fleet.hubWorkspacePath.
Workspace-path decision: the projection's canonical carries {host,port,sshAlias}
only (no remote path), so the folder defaults to the hub home ~ (where ~/.amico/
lives), overridable via amicode.fleet.hubWorkspacePath; a configured-but-relative
path is an honest AC2 error. The typed RemoteSshResolution (authority/path/uri)
is the stable contract #1274 will drive programmatically.
Tests: test/fleet_connect_remote_ssh.test.ts (22, green). Typecheck clean.
…ove + honest thin-client fallback (no-regression baseline) #1268-S3. A COMPOSITION / proving slice in the shape of #1270 — it adds NO production code. The single-writer guarantee (ADR 0025 / ADR 0005) is already carried by the primitives; this pins the cross-posture scenario that composes them, so a regression that forked a second writer or invented a new degraded fallback path fails here. AC1 — single-writer across the move (asserted): a round-trip thin-client ⇄ Remote-SSH move composes windowModeFromRemoteName (relocation signal) + divertToFleetRelay + adoptOrSpawn against a live password-armed durable-hub fixture. One shared spawn counter across every step stays 0: host-side the window reads role=server → does NOT divert → ADOPTS the hub (never a second engine/store); client-side it DIVERTS to the relay → spawns nothing. Exactly one canonical writer (the hub) at every step. The unhappy edge (stale/ unadoptable handshake) SURFACES foreign — never a silent cold-spawn of a rival. AC2 — Remote-SSH unavailable/unconfigured → the EXISTING thin-client posture: a not-ok resolution (no-ssh-alias unconfigured; open-failed unavailable, via connectToHubOverRemoteSsh which never throws; topology-absent) leaves a client diverted to the relay (divert=true) — no new state, no second writer. The NAMED no-regression baseline is test/fleet_client_relay.test.ts (the relay contract: UI-local, engine-data-plane proxy, credential translation, honest hub-down 503, NO local engine); this slice does NOT modify or fork it — the gate runs it green alongside, unchanged. Reuses the #1185 live-server fixtures verbatim (ephemeral port, tmp handshake — never 43117, never the real ~/.amico). Non-vacuity confirmed by a mutation check (adopted→cold-spawned / spawns 0→1 goes RED). Gate: 131/131 across the fleet/adopt/never-fork/relay suites + baseline; typecheck clean.
… go/no-go New workspace package `packages/companion` (`amicode-companion`, `extensionKind: ["ui"]`) — the always-local companion that activates on the CLIENT and stays alive while the main extension relocates to the host under Remote-SSH. Load-bearing feasibility gate for ADR 0025's default-flip (P2). Proves the three unit-testable feasibility halves (25 tests, all green): - AC1: `activate()` runs client-side, registers `amicode.companion.reopenWindow`, wires the probe + reopen (src/companion.ts). "Survives live relocation" is runtime/HITL — recorded in the go/no-go, not faked. - AC2: client-side probe (src/probe.ts) — reads ONLY the client hubUrl setting + an injected/global fetch, hits /global/health, host-independent by construction. Minimal seam #1275 bundles the full detector on top of. - AC3: pure bidirectional reopen URI builders + the executeCommand("vscode.openFolder", …) wiring (src/reopen.ts). Live reopen is runtime. - AC4: docs/fleet/p2-companion-go-no-go-1274.md — the ADR 0025 P2 kill-switch, proofs cited, live proof + final feasible/abort decision PENDING human (NOT pre-decided). Never-fork preserved: the companion holds no engine/store (zero runtime deps). Judgment calls: - Duplicated the ~10-line pure Remote-SSH URI builder from the main extension's fleet_connect_remote_ssh.ts rather than importing — the companion is a separate package and must not take a cyclic dep on the workspace extension. Contract fields kept identical so #1275 can extract a shared helper trivially. - Second VSIX is a NEW release-matrix artifact: deliberately NOT wired into release.yml / the main VSIX / packaging (flagged to release owners in the doc). Inert to vsix-gate + packaging.test.ts. - pnpm-lock.yaml updated (new importer) so `pnpm install --frozen-lockfile` (CI) passes; companion has working build/typecheck/test for `pnpm -r`. Part of #1269 · ADR 0025 (P2).
|
Carries the fix for #1649 — the thought rail's foreground-subagent liveness (the fifth root of the #1617 staleness family). Commit Gates green locally: app tsgo clean for the 4 touched files (one pre-existing unrelated error on this branch at |
… remote delete The titlebar Sessions flyout had three defects on remote (peer) rows: - Laggy list: the <For> rendered every loaded session across all projects plus all fleet peers; `activeLimit` only bounded the backend fetch, never the DOM, so hundreds of rows each carried the per-row useSessionTabAvatarState subscriptions and re-diffed on every 3s fleet poll. Slice the rendered list to `activeLimit()` (after the search filter, so search still spans everything loaded) via `pagedActiveSessions`, and redefine `hasMoreActive` to mean "rows exist beyond the slice, or the fetch cap was hit". Caps the DOM at 50 rows (+50 per Show more). - Archive overlaid the machine tag: the owner tag now fades out on hover/focus so the right-edge affordance never covers it (active + archived remote rows). - Delete on active remote rows: removed the owner-routed remote-delete arm→confirm + its plumbing (onRemoteDelete, remoteDeleteSession). Remote now matches local — archive from the active row, delete from the Archived tab (deleteArchivedSession is already owner-routed + control-gated). Also fix a latent bug the overlay typecheck gate surfaced: session-composer-region-controller.ts called `resolve` on the directory-scoped session store, which only re-exported `sync`. At runtime the #1647 S7 owner-side archived-flag convergence threw every 5s (caught/ignored) and never fired. Add an info-only `resolve` passthrough to directory-sync.ts delegating to serverSync.session.resolve (NOT `sync`, which force-reloads messages and would disrupt a live stream). Manifest refreshed for the three overlay files (drift gate verifies).
Two defects on tagged (peer/owner) rows in the titlebar Sessions flyout: - Hover flash: while the flyout is open, the 3s fleet poll re-fetches the projection and mergePeerSessions mints a NEW object for every peer/owner- tagged row on each fetch. SolidJS <For> keys by referential identity, so a new reference at an index disposes + remounts the row — resetting its CSS :hover state and replaying the machine-tag opacity transition under a resting pointer. The archived tab hits the same on its own 5s poll. Add a pure stabilizeSessionIdentity(next, cache) helper: an id-keyed cache that returns the PREVIOUS row object when a row's render signature is unchanged, so unchanged rows keep their reference (and DOM node) across the poll. The signature (sessionRowSignature) excludes time.updated — it churns per streamed token but changes nothing the row paints — and tracks title/directory/owner/ control. Wired into the active (activeIdentityCache) and archived (archivedIdentityCache) memos feeding <For>; component-scoped so it survives memo re-runs; self-evicts departed ids. - Owner tag vanished on hover: instead of fading the machine tag to nothing to reveal the action cluster, teleport it — the resting badge still fades out, and the SAME tag reappears inside the (fade-in) action cluster to the left of the archive/delete controls. Reads as the tag sliding aside for the action, not disappearing. Applied to the active row (local + remote clusters) and the archived row; marked data-slot="…-teleported". Tests: 5 stabilizeSessionIdentity/signature unit tests (same-ref-on-unchanged, new-ref-on-content-change, time.updated is ignored, order + eviction) and source assertions for the teleported badges + the identity wiring. Manifest refreshed for the four overlay files (drift gate verifies).
…n hover Follow-up to 62e45b6, which fixed the wrong way: it faded the resting tag out and rendered a SECOND, max-w-[40%]-truncated copy inside the archive's own absolute cluster — so the tag AND the archive appeared to jump into place, and the copy was shortened. Now there is ONE tag element that stays full length and slides left by exactly the action cluster's rendered width, with the archive pinned at the row's right edge (it never moves). The cluster is no longer an absolute right-edge overlay; it reveals IN FLOW via a CSS-grid `grid-cols-[0fr]→[1fr]` column, so it reserves the controls' exact width — the row button (now flex-1, was w-full) shrinks and pushes the tag over by precisely that amount. Width-agnostic: no hardcoded pixel shift (the large icon-button width lives in a compiled stylesheet). The tag is never faded and never truncated on hover; the title gives up the space instead. Applied to the active row (local + remote clusters) and the archived row. The #1652 identity-cache flash fix from 62e45b6 is unchanged. Tests updated: one owner badge per row (no teleported copy), the tag is not faded on hover, and the cluster reveals via the 0fr→1fr grid (the old absolute overlay is gone). session-header 18/18; fleet-peers/dropdown/archived 100/100; overlay typecheck + drift green. Manifest refreshed.
…leet-main-v3 # Conflicts: # packages/schema/src/index.ts
…n-v3 Reconcile feature/free-tier-fleet with main (v3)
… not sync (#1646) Root cause of the local-archive lag: every archive handler ended with session.sync(id,{force:true}) whose only job is to flip the composer's archived read-only banner (metadata). But sync(force) also re-fetches the viewed/warmed session's full message page + every parent (the #1297 chain) — seconds on a loaded LOCAL session, instant on a REMOTE one with nothing loaded. That asymmetry was the reported bug. Swap all 4 archive handlers (dropdown, home flyout, Rail, chat menu) and 2 unarchive handlers (chat menu, composer banner) to the info-only resolve(id,{force:true}) — session.get -> remember, no message reload — matching the composer controller's own convergence. timeline/model.ts forceSyncViewed stays on sync(force): it is the SSE-reconnect transcript self-heal where the reload is the point, not a flag flip. Tests: 4 source-assertion files retargeted to resolve + a negative assertion locking out the heavy sync(force). manifest.json refreshed for the drift gate.
…wn; drop subagent rows Root cause (measured in headless Chromium against the live service): the dropdown merges the CACHED fleet projection, and allSessionsFromProjection (bee23ff) keeps LOCAL rows too. After a local archive removed the row from the local store, mergePeerSessions re-filled it from the stale projection as a twin row until the next 3s poll landed — visible 1.3–2.2s. Remote archive only felt instant because its path refetched the projection. - Archive tombstones (hideArchivedTombstones / settleArchivedTombstones): hide the id at click, before the PATCH, for local and remote rows; retire when a fresh projection stops listing it, on unarchive, on a failed PATCH, or after a 15s TTL. Local archive now refetches the projection too. Verified: row gone in 4–10ms and never resurrected (was 1.3–2.2s). - Projection extractors drop CHILD sessions (parentID), mirroring roots(): subagent sessions were flooding the dropdown (76 of 100 rows). - Dropdown + Home archive splice the row from its directory store instead of re-fetching and reconciling the whole session list (wasted work).
…rol (#1764) Two review follow-ups to the FLEET header work: - Add machine now routes BOTH standalone and empty section states to /create-a-fleet (no fleet exists yet, so start one); populated, unreachable, and the not-yet-posted case keep /add-fleet-device. Unreachable stays on add-fleet-device because a down host can't be known to be empty. - Remove the per-row focus control (the target-glyph button + its CSS) for now — it did not earn its place in the UI. The focus SUBSYSTEM is kept intact: the focus-machine message/handler, FleetFocusStore, applyFocusRequest, focus-follows-tab, the overlay push, and the focused-row marking all stay, so restoring the control is a re-add. Tests updated accordingly; full fast suite green (5299 passed).
…-actions feat(sidebar): FLEET header Add-machine + Refresh actions (#1764)
…point 1) SessionOwnerMap.update() now also records each owned session's working directory from the same owner-tagged projection entry it reads the owner from (the entry's own `directory`, owner-tag fallback), byte-for-byte. resolveDirectory() never invents one: unknown, unowned, empty, or a pending create-time binding all resolve undefined. Existing owner-map behavior is unchanged.
…1766, checkpoint 2) New focused module fleet_session_status.ts: - resolution: ids (trim / drop-empty / de-dup, 64-id cap) + the live owner map → per-(owner, directory) query plan in request order (16-query cap) + unresolved (unknown / no-directory / over-cap); never invents a directory. - fan-out: each owner's /session/status?directory=<d> exactly once per distinct directory, reached as the fleet projection reaches it (local engine password or bare loopback; peer base URL + its own reader token; never the hub credential); concurrent, per-query timeout, 3 s overall deadline; entries validated against the engine's SessionStatus union; idle by absence for a successfully queried directory; honest omission + named per-source / per-directory reasons otherwise. Never throws. Gate: test/fleet_session_status.test.ts (49 cases).
…nt 3)
Registered on every boot under the never-proxied /amicode/fleet/*
prefix. It reads the live owner map of whichever branch built one (the
premium staged plane or the observation-only base peer) via hoisted
deps, assembling peers / tokens / trust / blocked identities per request
exactly as /amicode/fleet/sessions does. Where no fleet-peer provider /
owner map exists it answers 404 {ok:false, reason:"no-fleet-peers"} —
the app's silent-fallback signal — instead of the generic no-route 404.
Auth is the shared service gate (identical refusal to fleet/sessions).
Gate: test/amicode_service_fleet_session_status_route.test.ts (namespace,
auth parity, not-found without fleet peers on 3 boot shapes, live read
on both owner-map branches, empty request).
…ckpoint 4) - context/fleet-session-status.ts (pure, import-free): the route builder (one comma-separated `ids` param), the documented-shape parse (per-entry drop of invalid status / unresolved entries; whole-body fallback only for a non-documented top level), per-cause rate-limited diagnostics, and runFleetReseed: fetchedAt stamped before the request, 5 s client timeout, late-response discard, apply via the recency-guarded reconcile, or today's local /session/status re-seed (silent for 404 no-fleet-peers, diagnosed otherwise). - server-session: pinnedIds() + onPinned() (0 → 1 pin edge). - utils/amicode-fetch: amicodeGetResponse — amicodeGet's credential idiom, resolving status + body of a non-2xx (the not-served 404 names its reason). - server-sync: reconcileFromStatus sends the pinned ids to the fleet read; the old local re-seed is kept, unchanged, as the fallback. - manifest re-stamped (3 modified + 3 added overlay files). Gate: fleet-session-status.test.ts, amicode-fetch.test.ts, the extended #1637 reconcile tests in server-session.test.ts.
… checkpoint 5) - context/fleet-reseed-scheduler.ts (pure, injected timers): the connected-edge and sync-gap triggers, a debounced newly-pinned trigger, `unknown` retries (2 s, ≤3 per trigger), and the 10 s self-heal tick (connected + visible + pinned, served machines only). Single-flight with pending-flag coalescing; viewed session first, then least-recently- reseeded; diagnostics after 3 consecutive all-omitted re-seeds and for a partial failure persisting 3 re-seeds per (owner, directory); teardown clears every timer and cancels the in-flight read. Once the read answers 404 no-fleet-peers, the tick and pin triggers stand down (today's behavior); the edges still re-probe. - runFleetReseed: injectable timers + a teardown signal (cancelled outcome). - server-sync: the scheduler replaces the direct reconcile on the edges; session.onPinned feeds the debounced trigger; stopped on cleanup. - end to end, two halves bound by one import-free fixture: extension/test/fleet_session_status_e2e.test.ts drives the REAL route against a stubbed peer owner, asserts its answer equals the fixture, and runs it through the app's own parse (imported across packages); fleet-session-status.e2e.test.ts (bun) takes the fixture through parse + runFleetReseed + the real ServerSession reconcile → busy in the store. - manifest re-stamped (2 modified + 4 added overlay files). Gate: fleet-reseed-scheduler.test.ts (fake clock) + both e2e halves.
The cold-owner-map retry budget is per TRIGGER (connected edge, sync gap, new pin). Re-arming it on every 10 s self-heal tick made a permanently unknown pinned id (a tab for a deleted session, or for a peer that stopped serving) cost the tick's read plus three retries each interval: about four fleet reads per 10 s instead of one. Only edge and pin runs re-arm now. RED first: the new test measured 12 reads over 3 intervals before the fix, and 3 after it. Found by CodeRabbit review on PR #1768.
…dance Update peer fleet setup guidance
…n-status fix(fleet): remote session keeps its working rail + tab state after reopen (fleet-aware status re-seed) (#1766)
Summary
Transforms the fleet from a hub-and-spoke topology (one server, N thin clients) into a peer fleet where every machine runs its own engine, advertises
serving, and maintains live cross-machine roster sync with heartbeat-driven health indicators.210 files changed, ~31k lines added across 89 commits (all individually PR-reviewed and squash-merged into this branch).
Architecture decisions (ADRs 0023–0029)
What shipped
1. Peer fleet topology (ADR 0027 / 0029)
server_mode: "server"— each spawns its own engineFleetPlanegeneralization:resolveAmicodeTargetwired intodispatch()andhandleUpgrade()(fleet(#1376): FleetPlane generalization + resolver wiring in dispatch() and handleUpgrade() #1378 / fleet(#1378): FleetPlane generalization + resolver wiring in dispatch() and handleUpgrade() #1384)servingcapability tag on roster rows +device_type(Desktop/Laptop) on boot (fleet(#1376): serving advertisement — roster serving tag + device_type on boot #1379 / fleet(#1379): serving advertisement — roster serving tag + device_type on boot #1385)2. Heartbeat + cross-machine roster sync
sshAlias/transportfields that caused silentbad_requestrejections (fleet: fix heartbeat — add missing sshAlias/transport + serving capability #1396)3. Sidebar fleet section
displayRole(): non-hub servers show as "Peer", hub shows as "Server" (fleet(#1394): display 'peer' for non-hub servers in sidebar and tooltips #1395)/troubleshoot-fleetsession; Manage button hidden until Fleet Manager ships (fleet: orphaned opencode engines survive VS Code reload and squat on the configured port #1359 / fleet: hide Manage button until Fleet Manager is wired #1402)4. Fleet health / drift popup
5. Thin-client infrastructure (H1 foundation)
/amicode/*to host in client mode, write-refetch + multi-client concurrency (Fleet thin client — Slice 2: proxy /amicode/* to the host (host owns all state) #1262)amico-host://FileSystemProvider for native Explorer on thin clients (Fleet client: native Explorer shows host files via an amico-host:// FileSystemProvider (with honest capability label) #1267)6. Fleet setup + enrollment
amico fleet enrollverb with verify-attach, port/auth layout, build signing (fleet enroll verify-attach fails: hub port collision + fleet-token auth gap #1354–fix(fleet): complete the enroll port/auth layout + pin real version + sign the build (#1354) #1357)amico fleet discoverfor candidate machine discovery (Fleet setup, generalizable machine roles & the Fleet Manager #1316 / Fleet setup, generalizable machine roles & the Fleet Manager (#1316) #1323)/create-a-fleetorchestrator skill (/create-a-fleet orchestrator skill #1320)7. Real-SSH e2e test
8. Session curation (piggyback)
9. Housekeeping
opencode— extend the gate to all overlay-touched packages (+ fix 9 base-drift errors) #1348 / ci: typecheck all overlay-touched packages + fix 9 base-drift errors (#1348) #1351)effectto single version (fix(build): dedupe effect to a single version (prevents a.name prompt crash) #1246)Testing
cli_gatefailure is a worktree build-artifact issue, not fleet-related)Deployment notes
After merge, both machines need a rebuild from latest
mainto pick up the heartbeat cadence and drift-check changes. Runpnpm sync --fixon each machine.Issues closed (54)
JJ's fleet issues — implemented (37)
Closes #1258
Closes #1260
Closes #1261
Closes #1262
Closes #1263
Closes #1264
Closes #1265
Closes #1267
Closes #1268
Closes #1269
Closes #1270
Closes #1271
Closes #1272
Closes #1273
Closes #1274
Closes #1275
Closes #1276
Closes #1277
Closes #1278
Closes #1279
Closes #1316
Closes #1318
Closes #1319
Closes #1320
Closes #1321
Closes #1340
Closes #1341
Closes #1342
Closes #1343
Closes #1344
Closes #1345
Closes #1346
Closes #1354
Closes #1358
Closes #1359
Closes #1363
Closes #1375
Aaron's fleet issues — superseded by peer fleet (17)
The peer fleet model (ADR 0029: every machine runs its own engine) makes the hub-client infrastructure these issues target obsolete. The thin-client relay, guard, tunnel, and hub-specific ops are no longer the active architecture.
Closes #780
Closes #792
Closes #651
Closes #775
Closes #776
Closes #781
Closes #782
Closes #1106
Closes #1131
Closes #1227
Closes #1283
Closes #1284
Closes #1288
Closes #1289
Closes #1290
Closes #1294
Closes #1302
NOT closed (future work)