feat: Aether cloud provider — run T3 Code agents in isolated microVMs - #5995
feat: Aether cloud provider — run T3 Code agents in isolated microVMs#5995pranav100000 wants to merge 21 commits into
Conversation
The path is a committed submodule gitlink with no .gitmodules entry anywhere in the repo (upstream has no .gitmodules at all). Plain clones ignore it, and upstream CI sparse-checkout excludes /.repos/, but any submodule-aware clone fails hard: fatal: No url found for submodule path '.repos/alchemy-effect/.vendor/alchemy' in .gitmodules (exit 128) That broke every Aether workspace clone of this repo. Nothing references the gitlink; the directory contents were never part of this repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h
…release (pingdotgg#1) * ci: run fork CI on GitHub-hosted runners; guard upstream-only deploy/release Blacksmith runner labels are bound to the pingdotgg account and queue forever on this fork, so CI jobs move to GitHub-hosted runners (ubuntu-24.04 / macos-latest) with timeouts widened for the smaller machines. The relay deploy and the nightly release schedule are upstream-only and now skip outside pingdotgg/t3code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * ci: widen slow-runner timeout for the image-compression give-up test The too-large give-up path walks the whole quality/scale ladder and takes ~18s on the 2-core GitHub-hosted runners this fork uses, tripping the 15s default. Explicit 60s timeout for that one test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * style: oxfmt formatting for the widened test timeout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ored catalog (pingdotgg#2) * feat(aether): provider driver skeleton — settings, registration, vendored catalog AetherDriver T1: contracts settings (AetherSettings via makeProviderSettingsSchema, apiBaseUrl with prod default, API key via sensitive AETHER_API_KEY env var), driver registration with makeManagedServerProvider snapshot (GET /profile probe: missing-key / 401 / transport failures all distinguished; catalog models with reasoning-effort option descriptors on every draft path), typed not-implemented adapter stubs (real protocol lands in T3-T6), deterministic textGeneration stubs, and vendored aether knowledge (catalog, 20-value canonical item-type map, tool-display parser port) with source paths + sync recipe documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): keep every snapshot unavailable until the turn protocol exists Review: a valid-key instance passed isProviderInstancePickerReady and routed turns into the not-implemented adapter. The draft funnel now stamps availability=unavailable with an explicit preview reason on every probe outcome — key validation still works in settings, the picker excludes Aether until T6 removes the gate. Pinned by test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): route the healthy-probe draft through the availability gate The success path built its snapshot directly and skipped the gated draft funnel, leaving a healthy instance picker-visible — exactly the reviewed defect. All probe outcomes now share the single gated funnel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): gate honors the full unavailable contract on every snapshot Review round 2: availability=unavailable snapshots MUST set enabled:false and installed:false (server.ts contract), and mobile's model options only honor those flags — so the T6 gate now forces all three on the pending snapshot AND every probe draft through one gateUntilTurnProtocol helper. Key-validation fidelity stays in auth/message; top-level status reads disabled while gated. Tests pin the whole flag set on pending, disabled, and healthy-probe paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(aether): REST task client + adapter session core
T2+T3 of the AetherDriver chain. REST client (restClient.ts +
restSchemas.ts): tasks create/respond/stop/update/remove-from-queue,
task read with status-probe dispatch (unknown-status forward-compat
carrier; known status with malformed payload fails loudly), conversation
messages/delta with pagination, projects, loose additive-tolerant
schemas, tagged errors for 401/402/404/409(code+kind)/4xx/transport/
decode, 30s timeout, caller-driven retry via client_message_id.
Session core: startSession preflight (clean tree, pushed+synced branch,
actionable remediations), repo→project resolution through the shared
normalizeGitRemoteUrl (ssh/https equivalence; ambiguity listed loudly),
resumeCursor {schemaVersion, taskId, latestSequence, turnLedger}
validated against task existence AND project membership (404 → typed
session-not-found), stopSession/stopAll as pure disconnects, minimal
readThread snapshot via vendored classification. sendTurn and the event
pump stay typed not-implemented until T4-T6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h
* fix(aether): stopAll emits one graceful session.exited per thread
Review: bulk disconnect cleared the session map silently, so ingestion
never saw the per-session exit events it uses to clear active-turn and
liveness state — stale running UI after ProviderService teardown. Both
disconnect paths now share one pure-disconnect helper; test pins one
graceful exit per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…er (pingdotgg#4) * feat(aether): workspace attach, WS transport, and the full event mapper T4+T5: the live pipeline. wireEvents parses the 13-kind agent union loosely (unknown-kind/malformed carriers; the socket never dies on a frame). eventMapper is the single WS+durable transform: deterministic event ids from durable identity (crash replay collides idempotently), durable-wins dedupe, exactly-one settle per turn, WS/REST pending-input correlation, the ready-not-waiting rule for message-idle, vendored classification + parseFileChanges for tool cards, todo_list → plan updates, truncation → warning. workspaceSocket: attach poll with every terminal branch (errored payload, parked null-context durable-only), connect union incl. 409-as-data, passive never boots a VM, reconnect ladder re-running full attach + delta reconciliation from the cursor. Adapter streams mapped events on resumed sessions; teardown closes scoped pumps. Golden fixtures for both transports; 150 tests in the touched surfaces; full suite 2055 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): attribute REST-delta rows of the in-flight turn to its TurnId Review: durable rows carry no turn field, so rows of the active turn finalized with turnId null and their settle never owned them. The wire turn id IS the user row that opened the turn, so that row's sequence is the turn boundary: rows above it get the mapped aether-turn id, the previous turn's tail stays unowned. Mutation-verified boundary tests. The reasoning-shape finding is refuted with evidence in the PR thread: no driver's reasoning renders today (ingestion reads only assistant_text/assistant_message; reasoning is not a tool-lifecycle type; zero UI consumers) — the mapper already emits the ecosystem shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): keep the previous turn's late tail owned across a warm transition Review round 2: a delta carrying [tail-of-u1, opener-u2, rows-of-u2] attributed the tail to undefined even when the mapper was already tracking u1 — it finalized unowned moments before trackTurn(u2) settled u1. Pre-opener rows now fall back to the tracked turn; only a cold mapper leaves them unowned. Test pins the reviewer's exact repro including tail→settle→next-output ordering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): opener-based turn attribution + cross-task frame guard Review round 3. Attribution now models the mapper's own invariant directly: delivered user rows open turns (queued/cancelled park ahead of theirs and do not), rows before the first opener fall back to the tracked turn, and opener-less batches sit mid-turn under activeProcessingTurn — which makes the cold resume to an awaiting task own both its output rows and the pending-input request (captured before the settle clears tracking). The workspace socket now drops frames whose taskId is not the subscribed task, logged once per foreign task. Cold golden-replay snapshot legitimately gains four owned turnIds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): close the raw socket on every pre-open failure path Review round 4: an upgrade error/close before open failed openSocket before the acquireRelease finalizer registered, and the reconnect ladder retries open failures indefinitely — one leaked socket per attempt. All pre-open exits (error, close, timeout, interrupt) now close the raw socket themselves; close() is idempotent. Leak pinned by the open-retry test asserting both failed sockets closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): session.exited carries providerInstanceId Ingestion rewrites the thread session from this event and preserves instance identity only when the event carries it — every other adapter emission already stamped it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…er goes live (pingdotgg#5) * feat(aether): turn lifecycle, mirror sync engine, write guards, gate flip T6 — Aether becomes selectable end to end. sendTurn: create (composite slug + effort validation, base_branch from preflight, turn-1 wire id harvested from the timeline), respond (deterministic epoch-scoped client_message_id), steer (deferred turn.started until pickup, FIFO). interruptTurn: stop with discard, re-offered steer texts, read-side confirmation, interrupted settle through the pipeline. Mirror engine: fingerprint verify (content-tree via temp index; catches edits, untracked files, local commits) → git-channel diff over the session socket → fetch + resolve the diff's own baseRef → reset --hard + clean -fd → reconstructed unified diff apply (modes, renames, no-newline, binary via files read) → only then settle; detached settles skip lazily; pauses are loud, never silent. Acceptance tests run against real temp git repos. Fork-side guards: refcounted mirror registry + ws.ts refusals at all seven dispatch sites, removeWorktree keyed on resolved target (basename bypass covered). The T1 availability gate is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): router-before-onConnected, turn-start ordering, binary path guard Review round 1 on T6. The request-response router now drains before onConnected fires (an onConnected reconcile that requests a git diff completes instead of deadlocking — regression test with a hang guard); sendTurn records the turn and emits turn.started before forking the attach pipeline so a fast first settle cannot precede its start; binary diff paths (oldPath removal AND newPath write) are validated repo-relative — absolute paths, '..' segments, and resolved escapes pause loudly and touch nothing outside the mirror, pinned by sentinel tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): idempotent first-turn retry; validate binary paths before mutating Review round 2 on T6. A createTask that succeeded but failed its turn-1 harvest now leaves the session in an explicit firstTurnPending state: the retry re-enters the first-turn path (no second create, no respond — the prompt can never double-send), a different-text retry refuses loudly, and bring-up completion clears the flag. Binary diff application is two-phase: every oldPath/newPath in the batch validates repo-relative BEFORE any removal or write, so a rename with a safe oldPath and an escaping newPath refuses with the mirror untouched. Both pinned by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): full dispatch fingerprint on first-turn retry; refuse .git paths Review round 3 on T6. The pending-first-turn guard now fingerprints every dispatch-relevant input (prompt, resolved slug, effort, interaction mode, attachment payloads; length-prefixed control-char join — compared, never parsed) so a same-text retry with changed attachments or model refuses instead of silently proceeding. The binary path validator additionally refuses any '.git' segment — direct writes bypass git's refusal to track such paths, and .git/hooks would be code execution on the next git invocation; both cases pinned in the escape test matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): writeFile guard checks the resolved target, not just cwd Review round 4 on T6: projects.writeFile resolves relativePath under cwd, so a parent-project cwd could descend into an active mirror without owning it. New ownsPathWithin containment check (at-or-under any claim) guards the resolved target; prefix-sharing neighbours and siblings stay writable, pinned by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…olish (pingdotgg#6) * feat(aether): question/plan responses, revert ledger, model switch, polish T7: respondToUserInput maps t3 answers to aether's exact ask_user wire shape (index-keyed answers, -1 custom sentinel + customAnswers); plan accept/reject rides the fresh-turn route t3 actually uses, as propose_plan {approved, feedback}; stale requests render t3's stale-request affordance; 409 bodies surface their decoded message after a delta re-sync; typed turn→message ledger rides the resume cursor; rollback is a typed one-way-mirror refusal. T8: in-session model switch via read-modify-write full-replace PUT (auto_fix flags read live first); explicit interaction_mode on plan-mode sends; remote-originated turns surface as warning cards with the injected text; cancelled steers settle interrupted and re-offer their text; out-of-band question resolution clears the pending panel (with a corrective ready when observed into message-idle); mobile ProviderIcon + server badge parity; load-bearing stopAll copy annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): option-only model switch, mobile empty-option questions, reconcile ordering Review round 1 on T7/T8. (1) The between-turns switch guard now fires on a resolved reasoning-effort change with the same model slug, not only a slug change — an option-only switch reaches the task instead of silently keeping the old effort. (2) apps/mobile threadActivity keeps an answerable question with zero parsed options (custom-answer-only Aether question) instead of dropping it, matching the mapper/web contract. (3) The idle-session eager reconcile no longer lets a stale pre-reconcile event emit after the reconcile catches the same completion — ordering made deterministic. Each pinned by a test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…s render (pingdotgg#9) Fixes pingdotgg#7. Aether stamps every live WS frame with turnId = msg.messageId, a fresh crypto.randomUUID minted per prompt dispatch — distinct from the durable user-row turn id the driver keys turns by. Keying settlement off that random live id fragmented one user turn into multiple t3 turns (4 checkpoints; "Latest turn" diff read a post-change-vs-post-change pair and showed nothing), and no per-turn alias could disambiguate a random id arriving out of order across turn boundaries (a stale terminal frame could settle the wrong or next turn). Settlement is now durable-authoritative: when a durable turn is grounded, live turn.completed/turn.failed/turn.awaiting_input frames no longer settle (nor fabricate a runtime.error card) — they only trigger an immediate durable reconcile so settle latency stays low; the durable reconcile (task-status flip) emits the single settle, which the adapter intercepts for mirror-sync-then-forward. A live random id can no longer settle any turn in the grounded path. The cold mapper-only path keeps live settlement so unit tests / degenerate resume still terminate. Net effect: exactly one turn.started/turn.completed per user turn, the mirror change in that settled segment, so t3's CheckpointReactor captures baseline+post and the diff panel renders. Regression tests: live terminal frame with a grounded turn emits no settle; stale-frame-after-next-turn- start cannot settle the new turn; cold path still settles; mirror change lands in the settled segment. Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ngdotgg#10) * fix(aether): skip the clean-tree preflight for driver-owned worktrees An Aether turn leaves its mirror output as uncommitted working-tree content (so t3's diff/checkpoint panel shows it), but startSession's clean-tree preflight then refuses the next thread on that checkout — even a chat message trips it. When a thread runs in its own worktree, that friction is pointless: a `git worktree add` branch has no upstream and (after turn 1) a dirty tree, yet the mirror owns it exclusively and resets --hard to baseRef every sync, so there is no user work to protect. The orchestration layer (the authority on worktree ownership) now sets managedWorktree=true on ProviderSessionStartInput when thread.worktreePath is non-null; the Aether adapter then uses a structural-only preflight (is-repo + non-detached) instead of the clean-tree/upstream checks. The shared "Current checkout" path is unchanged — it still refuses on uncommitted work, protecting real edits. Both fresh and resume start paths flow through the shared helper. Regression tests: a dirty, no-upstream, ahead managed worktree starts ready; "Current checkout" with uncommitted changes still refuses; the worktree cwd (not the project root) is what registers with the mirror. Follow-ups (not blockers): default Aether threads to a fresh worktree in the composer (primary-agent + live integration test), worktree cleanup on thread archive, mobile default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): gate managedWorktree on a bootstrap-created marker, not path shape Review: managedWorktree = (worktreePath !== null) also matched a user's pre-existing secondary worktree, whose uncommitted edits the mirror would then reset --hard/clean — data loss. Now the bootstrap prepareWorktree handler stamps a durable worktreeManaged=true on the thread when it creates a fresh ephemeral worktree; that marker is plumbed through the projection (decider → projector → ProjectionThreads + migration 039) to the read model, and the reactor sets managedWorktree only from it. A user-attached worktree has no marker → the clean-tree preflight is enforced and their work is protected. Regression test: worktreePath set but unmanaged + dirty still refuses; bootstrap-managed still skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): make worktreeManaged server-authoritative and sticky Review round 2 on T10. Two holes from making the marker a client-writable, clearable meta field: (1) a client could smuggle worktreeManaged:true onto a thread.meta.update and skip the dirty-tree preflight on its own worktree; (2) the first-turn branch rename (a meta update omitting the field) cleared it off a genuinely-managed worktree. Fix: worktreeManaged is dropped from the client ThreadMetaUpdateCommand entirely and set only via a dedicated server-origin ThreadWorktreeAttachManagedCommand the bootstrap emits, so no client input can set it; and the projection preserves it across meta updates that keep the same worktree, resetting only when the worktree path changes. Tests: a smuggled client worktreeManaged is ignored; the first-turn branch rename keeps the marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * test: update server-seam bootstrap sequences for the attach-managed command The bootstrap now dispatches thread.worktree.attach-managed (server-only) in place of the thread.meta.update it used to emit for the worktree marker, so the three server.test.ts command-sequence assertions are updated to match. Behavior unchanged; the new command still carries worktreePath. (Missed initially because the scoped test run excluded src/server.test.ts — full vp run test is green: 2158 passed.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…omposer (pingdotgg#11) A fresh, un-touched local composer draft with an Aether model selected now defaults its Workspace to a new worktree, so the Aether cloud driver never hits the clean-working-tree preflight error. This is a render-time overlay (resolveProviderDefaultsToWorktree) — never persisted — so switching the model to any non-Aether provider flips the Workspace back to the current checkout, and every other provider's default is unchanged. Hardening (from live testing + adversarial review): - The overlay never leaks into persisted draft.envMode: the branch auto-seed path uses the sticky (persisted) mode, not the effective overlay value. - Explicit workspace picks and PR-checkout drafts are marked user-set; legacy drafts (absent flag) are treated as user-set so upgrades never surprise-flip. - The auto-worktree honors the newWorktreesStartFromOrigin preference.
…t the local scratch branch (pingdotgg#12) A driver-owned worktree sits on a local-only branch that is never pushed; sending it as the cloud task base_branch failed workspace startup with remote_ref_missing (404) — the common path once new Aether drafts default to a worktree. The adapter now bases new tasks on the fork branch recorded in branch.<head>.gh-merge-base (a real origin branch), and only for new-task starts (resume never sends base_branch).
When the Aether cloud VM opens a port (agent runs a dev server), a 'Port N is live — Open preview' CTA appears in the thread and opens the workspace preview URL. The driver parses the workspace ports channel, builds the preview URL from the connect transport's preview_token ({port}-{workspaceId8}-{token}.preview.runaether.dev), and emits a port.opened runtime event → thread activity → web + mobile timeline. Verified end-to-end in the desktop app (chip URL returns the VM's app, HTTP 200).
…ingdotgg#14) The 'Port N is live — Open preview' CTA now opens the workspace preview in the desktop embedded browser (right panel) via openPreviewSession + openBrowser, matching how discovered local ports open; falls back to the system browser / new tab on web or if the embedded session fails. Verified live: clicking the chip loads the microVM's app in an in-app webview.
…ngdotgg#15) * feat(aether): cloud terminal — attach a shell in the workspace VM Route the integrated terminal to a shell running INSIDE the Aether cloud VM for cloud-backed threads, over its own tab-scoped workspace WebSocket (channel:"terminal"), independent of the turn engine's agent stream. Local threads keep their local PTY. No workspace-service changes — the VM already serves the PTY. - CloudTerminalConnector: optional adapter capability (cloud providers only) - AetherAdapter exposes it using its existing restClient + connect primitives - terminalConnection: tab-scoped WS carrying create/input/resize/close -> output/close, reusing resolveTaskWorkspace + connectForTransport(start:true) - AetherTerminalManager: session lifecycle + scrollback; a per-session lock guarantees snapshot-then-live-output ordering on (re)attach - ws.ts routes each per-thread terminal RPC by provider (cached per thread) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(aether): tear down cloud terminal socket on shell exit + archive Addresses the Aether Review on pingdotgg#15 — two lifecycle leaks: - The drain now closes the session's connection scope when the VM shell exits (previously the tab-scoped workspace WS was held open until the user closed/reopened the terminal). The close is forked onto the manager scope so the drain — which lives in the session scope — does not interrupt its own scope-close. - Archiving a thread now closes BOTH the local and the Aether terminal managers for it (closing the one with no sessions is a no-op), so an archived Aether thread's cloud socket is torn down too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(aether): stub AetherTerminalManager.close in server-test mock Archive now tears down both terminal managers; the mock only stubbed handles, so close hit Layer.mock's die-on-unstubbed guard (a defect Effect.catch does not catch), failing the 6 server.test archive cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(aether): cloud terminal lifecycle gaps from deep review Follow-ups to pingdotgg#15 surfaced by a deep investigation: - Keepalive (BLOCKER): ping {channel:"activity",type:"user_activity"} every 30s while a terminal is attached. The VM's interactive idle lease is renewed ONLY by the activity channel — terminal I/O does not count — so without this the VM suspended ~15 min after connect, mid-session, with the socket still looking live. - Shutdown finalizer: close every open session's connection scope on manager teardown (session scopes are standalone Scope.make; nothing else reaped them). - thread.delete now tears down terminals too (was archive-only): a deleted cloud thread otherwise leaked its VM socket and the keepalive held it warm. - Per-connection ingress queue: a torn-down connection's in-flight frames can no longer bleed into a later one (e.g. a stale "closed" after restart). - Connect-error re-emitted on attach: open()->error happens before the listener attaches, so the snapshot carried status but not the message; attach now replays the error event so "run a turn first" / missing-key surfaces instead of a blank errored terminal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(aether): apply initial terminal size on connect Send a resize with the requested cols/rows right after create so the VM PTY isn't stuck at its default 80x24 until the UI emits a resize — the first command's output otherwise wraps incorrectly (e.g. 120x30 project-script launches). Addresses the Aether Review on pingdotgg#15. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ingdotgg#16) * feat(aether): reconnect the cloud terminal on transient socket drops The terminal previously died on any WS drop (network blip, VM restart), forcing a manual reopen. Now the connection runs a forked reconnect loop: a socket drop transparently re-attaches with backoff (a fresh shell — the VM reaps the PTY on disconnect — with the client's scrollback kept and a [reconnecting…] marker), while a shell exit or an exhausted budget ends it. The first attach still fails loudly. Adds a fake-socket test covering first-attach sizing, reconnect-on-drop, and stop-on-shell-exit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(aether): reconnect the terminal at the latest size, not the initial Track cols/rows in the connection closure (updated on every resize) and use them for the fresh PTY's initial resize, so a resize followed by a socket drop recreates the shell at the size the user is actually looking at rather than the stale initial dimensions. Adds a regression test that resizes, drops the socket, and asserts the second socket is sized to the new value. Addresses the Aether Review on pingdotgg#16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…st (pingdotgg#17) * feat(aether): surface cloud terminals in the cross-thread terminal list The AetherTerminalManager now implements subscribeMetadata — an initial snapshot of its sessions plus upsert on open/status-change and remove on close — and the ws subscribeTerminalMetadata handler merges it with the local manager's stream, folding the Aether snapshot into upserts so cloud terminals augment (not replace) the local list. Previously cloud terminals never appeared in the cross-thread terminal list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(aether): release terminal-metadata subscriptions on interrupt Two interrupt-safety leaks from the Aether Review on pingdotgg#17: - AetherTerminalManager.subscribeMetadata added the listener before the initial snapshot; an interrupt mid-snapshot left it registered. Drop it via onInterrupt. - The ws subscribeTerminalMetadata handler combined both subscriptions into one acquire; an interrupt after the local subscription acquired but before the Aether one left the local listener registered. Split into two nested acquireReleases so each finalizer registers independently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
| } | ||
| const domain = deriveAetherPreviewDomain(input.apiBaseUrl); | ||
| const subdomain = `${input.port}-${input.workspaceId.slice(0, 8)}-${input.previewToken}`; | ||
| const protocol = domain.startsWith("localhost") ? "http" : "https"; |
There was a problem hiding this comment.
🟡 Medium aether/portPreview.ts:48
buildAetherPreviewUrl always emits https:// for any non-localhost domain, so an HTTP-only self-hosted API base like http://127.0.0.1:8080 or http://aether.internal yields an https:// preview URL that fails to connect. The protocol is inferred solely from whether the derived host starts with localhost, ignoring the actual protocol of apiBaseUrl. Consider deriving the preview protocol from the parsed apiBaseUrl protocol (or accepting it as an explicit parameter) instead of hardcoding based on the hostname.
- const protocol = domain.startsWith("localhost") ? "http" : "https";
+ const apiProtocol = new URL(input.apiBaseUrl).protocol.replace(/:$/, "");
+ const protocol = domain.startsWith("localhost") ? "http" : apiProtocol === "http" ? "http" : "https";🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/portPreview.ts around line 48:
`buildAetherPreviewUrl` always emits `https://` for any non-`localhost` domain, so an HTTP-only self-hosted API base like `http://127.0.0.1:8080` or `http://aether.internal` yields an `https://` preview URL that fails to connect. The protocol is inferred solely from whether the derived host starts with `localhost`, ignoring the actual protocol of `apiBaseUrl`. Consider deriving the preview protocol from the parsed `apiBaseUrl` protocol (or accepting it as an explicit parameter) instead of hardcoding based on the hostname.
| context: " ", | ||
| }; | ||
|
|
||
| function renderTextFilePatch(file: AetherWsGitDiffFile): string { |
There was a problem hiding this comment.
🟡 Medium aether/mirrorSync.ts:178
renderTextFilePatch interpolates raw file paths directly into unified-diff header lines like diff --git a/${oldPath} b/${newPath} without Git's path-quoting rules. A repository path containing a newline, backslash, double quote, or other special character produces a malformed patch header, so git apply rejects the cumulative diff and the engine permanently pauses mirror sync for that thread. Consider applying Git's path-escaping (the \" and \\ doubling plus the \"...\" quoting form with \n-escaped newlines) before interpolating paths, or reject paths that require quoting as a PauseSync rather than emitting a patch git apply cannot parse.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/mirrorSync.ts around line 178:
`renderTextFilePatch` interpolates raw file paths directly into unified-diff header lines like `diff --git a/${oldPath} b/${newPath}` without Git's path-quoting rules. A repository path containing a newline, backslash, double quote, or other special character produces a malformed patch header, so `git apply` rejects the cumulative diff and the engine permanently pauses mirror sync for that thread. Consider applying Git's path-escaping (the `\"` and `\\` doubling plus the `\"...\"` quoting form with `\n`-escaped newlines) before interpolating paths, or reject paths that require quoting as a `PauseSync` rather than emitting a patch `git apply` cannot parse.
| const reconcile = context.reconcile ?? Effect.void; | ||
|
|
||
| const socket = options.socket; | ||
| if (socket === undefined) { |
There was a problem hiding this comment.
🟠 High Layers/AetherAdapter.ts:1229
In REST-only mode (socket === undefined), ensureTaskPipeline returns immediately without calling context.reconcile. On resume of a task whose status is processing, context.activeTurn is initially undefined and turn adoption only happens inside processMapperEvents — which is driven by the reconcile that was just skipped. startSettlePoll is also never armed because its loop condition checks context.activeTurn and context.deferredTurns, both empty at this point. The result is a resumed processing session receives no durable feed updates or turn settlement until the user sends another message, even though the comment on line 1231 states the settle backstop poll is the only feed in REST-only mode. Consider triggering an initial reconcile (and arming the settle poll when adoption is pending) before returning in the REST-only branch.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/AetherAdapter.ts around line 1229:
In REST-only mode (`socket === undefined`), `ensureTaskPipeline` returns immediately without calling `context.reconcile`. On resume of a task whose status is `processing`, `context.activeTurn` is initially `undefined` and turn adoption only happens inside `processMapperEvents` — which is driven by the reconcile that was just skipped. `startSettlePoll` is also never armed because its loop condition checks `context.activeTurn` and `context.deferredTurns`, both empty at this point. The result is a resumed processing session receives no durable feed updates or turn settlement until the user sends another message, even though the comment on line 1231 states the settle backstop poll is the only feed in REST-only mode. Consider triggering an initial `reconcile` (and arming the settle poll when adoption is pending) before returning in the REST-only branch.
| resolvedTargets.set(`new:${file.newPath}`, yield* resolveInMirror(file.newPath)); | ||
| } | ||
| } | ||
| for (const file of binaries) { |
There was a problem hiding this comment.
🟠 High aether/mirrorSync.ts:516
applyBinaries processes each binary entry one at a time, so cumulative diffs with overlapping old/new paths across entries corrupt the mirror. For example, if one entry renames a→b and a later entry deletes b, the rename writes b and the subsequent deletion removes it — the sync then fingerprints and reports success with b missing. The entries are validated in a two-phase pass but written in a single interleaved loop, so removals from later entries clobber new paths written by earlier ones. Consider removing all old paths in a separate pass before writing any new targets.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/mirrorSync.ts around line 516:
`applyBinaries` processes each binary entry one at a time, so cumulative diffs with overlapping old/new paths across entries corrupt the mirror. For example, if one entry renames `a`→`b` and a later entry deletes `b`, the rename writes `b` and the subsequent deletion removes it — the sync then fingerprints and reports success with `b` missing. The entries are validated in a two-phase pass but written in a single interleaved loop, so removals from later entries clobber new paths written by earlier ones. Consider removing all old paths in a separate pass before writing any new targets.
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
|
|
||
| const normalize = (cwd: string): string => NodePath.resolve(cwd); |
There was a problem hiding this comment.
🟠 High provider/AetherMirrorRegistry.ts:30
ownsCwd, ownsTargetPath, and ownsPathWithin return false when a mirror is registered via its real path but an RPC passes a symlinked path to the same checkout. The write guard is bypassed, allowing mutation of the driver-owned mirror that the registry is meant to protect. normalize uses only NodePath.resolve, which collapses ../. but does not resolve filesystem symlinks, so the registered real path and the symlinked lookup path produce different keys. Consider canonicalizing with realpath (or the nearest existing parent's realpath) on both registration and lookup.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/AetherMirrorRegistry.ts around line 30:
`ownsCwd`, `ownsTargetPath`, and `ownsPathWithin` return `false` when a mirror is registered via its real path but an RPC passes a symlinked path to the same checkout. The write guard is bypassed, allowing mutation of the driver-owned mirror that the registry is meant to protect. `normalize` uses only `NodePath.resolve`, which collapses `..`/`.` but does not resolve filesystem symlinks, so the registered real path and the symlinked lookup path produce different keys. Consider canonicalizing with `realpath` (or the nearest existing parent's `realpath`) on both registration and lookup.
| export function stubBranchName(message: string, isoDate: string): string { | ||
| return sanitizeBranchFragment(`${sanitizeBranchFragment(message)}-${isoDate}`); | ||
| } |
There was a problem hiding this comment.
🟡 Medium textGeneration/AetherTextGeneration.ts:67
stubBranchName sanitizes the message up to 64 chars before appending -${isoDate}, then sanitizes the combined string with the same 64-char limit. When the message slug is already 64 chars, the second sanitizeBranchFragment call truncates the entire date suffix, so stubBranchName returns identical branches for different dates and can collide with an existing branch. Reserve space for the date suffix before truncating the message slug.
+export function stubBranchName(message: string, isoDate: string): string {
+ const dateSuffix = `-${isoDate}`;
+ const messageSlug = sanitizeBranchFragment(message).slice(0, 64 - dateSuffix.length);
+ return sanitizeBranchFragment(`${messageSlug}${dateSuffix}`);
+}🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/textGeneration/AetherTextGeneration.ts around lines 67-69:
`stubBranchName` sanitizes the message up to 64 chars before appending `-${isoDate}`, then sanitizes the combined string with the same 64-char limit. When the message slug is already 64 chars, the second `sanitizeBranchFragment` call truncates the entire date suffix, so `stubBranchName` returns identical branches for different dates and can collide with an existing branch. Reserve space for the date suffix before truncating the message slug.
| }).pipe(Effect.ignore), | ||
| ); | ||
| currentSocket = socket; | ||
| socket.send( |
There was a problem hiding this comment.
🟠 High aether/terminalConnection.ts:275
The create and resize sends in attachOnce call socket.send(...) directly. If send throws (e.g., the socket closed immediately after openSocket succeeded), the throw is an Effect defect rather than a typed error. runLifecycle catches only typed errors via Effect.catch, so the fork dies without failing ready, and openAetherTerminalConnection blocks forever at Deferred.await(ready). Consider wrapping the pre-ready send calls in Effect.try so a throw fails ready as a typed error.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/terminalConnection.ts around line 275:
The create and resize sends in `attachOnce` call `socket.send(...)` directly. If `send` throws (e.g., the socket closed immediately after `openSocket` succeeded), the throw is an Effect defect rather than a typed error. `runLifecycle` catches only typed errors via `Effect.catch`, so the fork dies without failing `ready`, and `openAetherTerminalConnection` blocks forever at `Deferred.await(ready)`. Consider wrapping the pre-ready `send` calls in `Effect.try` so a throw fails `ready` as a typed error.
| * path makes `join(cwd, …)` return the path itself, and a `..` segment | ||
| * walks straight out. | ||
| */ | ||
| const resolveInMirror = (relative: string): Effect.Effect<string, PauseSync> => |
There was a problem hiding this comment.
🔴 Critical aether/mirrorSync.ts:463
resolveInMirror validates only the lexical path, so a binary newPath that names an existing symlink inside the checkout passes validation. fs.writeFile follows the symlink and writes outside cwd, letting a hostile or compromised workspace overwrite arbitrary files writable by the server process. The same applies to fs.mkdir(NodePath.dirname(target)), which can create directories outside the checkout through a symlinked parent. Consider resolving and validating the real filesystem path (or using symlink-safe open semantics) before any write.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/mirrorSync.ts around line 463:
`resolveInMirror` validates only the lexical path, so a binary `newPath` that names an existing symlink inside the checkout passes validation. `fs.writeFile` follows the symlink and writes outside `cwd`, letting a hostile or compromised workspace overwrite arbitrary files writable by the server process. The same applies to `fs.mkdir(NodePath.dirname(target))`, which can create directories outside the checkout through a symlinked parent. Consider resolving and validating the real filesystem path (or using symlink-safe open semantics) before any write.
| >; | ||
| } | ||
| >(); | ||
| yield* Effect.gen(function* () { |
There was a problem hiding this comment.
🟡 Medium aether/workspaceSocket.ts:808
When the router receives a closed signal, it enqueues the signal to routed and exits without failing the entries in pending. If onConnected or onEvent is currently awaiting requestGitDiff or readWorkspaceFile, the consumer cannot process the queued closed until that handler returns — but the in-flight request cannot return until its 30-second timeout. A dropped socket therefore stalls disconnect notification and reconnection for up to requestTimeoutMs instead of immediately failing with AetherWorkspaceDetachedError. The router should fail all pending deferreds as soon as it observes closed, before enqueuing the signal to routed.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/workspaceSocket.ts around line 808:
When the router receives a `closed` signal, it enqueues the signal to `routed` and exits without failing the entries in `pending`. If `onConnected` or `onEvent` is currently awaiting `requestGitDiff` or `readWorkspaceFile`, the consumer cannot process the queued `closed` until that handler returns — but the in-flight request cannot return until its 30-second timeout. A dropped socket therefore stalls disconnect notification and reconnection for up to `requestTimeoutMs` instead of immediately failing with `AetherWorkspaceDetachedError`. The router should fail all pending deferreds as soon as it observes `closed`, before enqueuing the signal to `routed`.
There was a problem hiding this comment.
Reviewed the new Aether provider service code against the Effect service conventions. Six concrete issues, all in files added by this PR: a standalone service-shape interface, service instances injected into a production factory, Effect.catchTag instead of Effect.catchTags, and three error-modeling sites that stringify a cause into detail/message or drop the underlying error entirely. Everything else (namespace subpath imports, make/layer exports, no ManagedRuntime/runPromise in domain code) looks consistent with the conventions.
Posted via Macroscope — Effect Service Conventions
Resolve conflicts: renumber driver migration 039->041 (upstream took 39/40); keep upstream's parkingCommand session-stop + the driver's dual-manager terminal cleanup in ws.ts; adopt upstream's new-thread draft helpers in useHandleNewThread.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- workspaceSocket: Effect.catchTag -> Effect.catchTags for statically-known tags - CloudTerminal errors + AetherApiRequestError: preserve structured cause (Schema.Defect) - AetherAdapter: propagate the structured terminal error across the translation boundary - AetherTerminalManager: inline the Context.Service shape, referenced as AetherTerminalManager["Service"] - AetherAdapter: express git + mirrorRegistry as Effect requirements (Context.Service tags) instead of instance options Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // Register + snapshot atomically against the drain: the listener is | ||
| // added and the snapshot captured under the lock, so no output is | ||
| // delivered before the snapshot or dropped between the two. | ||
| yield* target.lock.withPermits(1)( |
There was a problem hiding this comment.
🟠 High terminal/AetherTerminalManager.ts:475
In attachStream, the listener is added to target.listeners before the initial snapshot or error replay is sent. If either of those listener calls defects, or if the attach fiber is interrupted before the unsubscribe function is returned, the stale listener stays in the set. Subsequent terminal output is delivered to all listeners inside drainLoop; when delivery hits the dead listener it raises a defect, terminating the drain fiber. After that, all further output for that terminal stops being recorded or delivered to the remaining healthy clients. The fix is to remove the listener from the set when the initial delivery fails or when the attach is interrupted, so the scope guarantees cleanup even when no unsubscribe function is returned.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/terminal/AetherTerminalManager.ts around line 475:
In `attachStream`, the listener is added to `target.listeners` before the initial snapshot or error replay is sent. If either of those listener calls defects, or if the attach fiber is interrupted before the unsubscribe function is returned, the stale listener stays in the set. Subsequent terminal output is delivered to all listeners inside `drainLoop`; when delivery hits the dead listener it raises a defect, terminating the drain fiber. After that, all further output for that terminal stops being recorded or delivered to the remaining healthy clients. The fix is to remove the listener from the set when the initial delivery fails or when the attach is interrupted, so the scope guarantees cleanup even when no unsubscribe function is returned.
| openSocket(factory, url, timing.openTimeoutMs), | ||
| ({ socket }) => Effect.sync(() => socket.close()), | ||
| ); | ||
| opened.socket.send( |
There was a problem hiding this comment.
🟠 High aether/workspaceSocket.ts:703
The socket loop treats a non-throwing socket.send(subscribe) as proof that the subscription succeeded: it immediately resets consecutiveFailures, sets everConnected = true, and invokes onConnected, which starts reconciliation. But a server can close right after the open event — the close signal is already in the queue — and WebSocket.send() silently discards data when the socket is in CLOSING/CLOSED state. The code proceeds to run onConnected reconciliation against a dead connection, and any correlated git/files request issued there waits the full requestTimeoutMs (30s default) before the queued close is consumed and reconnection begins. The closed signal should be consumed before declaring the connection live, or the subscribe/onConnected work should race against the already-queued close rather than treating a non-throwing send as a successful subscription.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/aether/workspaceSocket.ts around line 703:
The socket loop treats a non-throwing `socket.send(subscribe)` as proof that the subscription succeeded: it immediately resets `consecutiveFailures`, sets `everConnected = true`, and invokes `onConnected`, which starts reconciliation. But a server can close right after the `open` event — the close signal is already in the queue — and `WebSocket.send()` silently discards data when the socket is in `CLOSING`/`CLOSED` state. The code proceeds to run `onConnected` reconciliation against a dead connection, and any correlated git/files request issued there waits the full `requestTimeoutMs` (30s default) before the queued close is consumed and reconnection begins. The `closed` signal should be consumed before declaring the connection live, or the subscribe/`onConnected` work should race against the already-queued close rather than treating a non-throwing `send` as a successful subscription.
| ); | ||
| for (const key of keys) { | ||
| const session = sessions.get(key); | ||
| if (!session) continue; | ||
| sessions.delete(key); | ||
| yield* closeOne(session); | ||
| } | ||
| }); |
There was a problem hiding this comment.
🟠 High terminal/AetherTerminalManager.ts:570
close deletes the session from sessions (line 574) before closeOne runs, so if closeOne fails or is interrupted before teardownConnection, the live cloud shell and socket are orphaned — the manager's shutdown finalizer only closes sessions still in the map. The session must be removed from the map only after teardown is guaranteed.
for (const key of keys) {
const session = sessions.get(key);
if (!session) continue;
yield* closeOne(session);
+ sessions.delete(key);
}🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/terminal/AetherTerminalManager.ts around lines 570-577:
`close` deletes the session from `sessions` (line 574) before `closeOne` runs, so if `closeOne` fails or is interrupted before `teardownConnection`, the live cloud shell and socket are orphaned — the manager's shutdown finalizer only closes sessions still in the map. The session must be removed from the map only after teardown is guaranteed.
AetherApiTransportError / AetherApiRequestError put a stable phrase in `detail` and let the preserved `cause` carry the dynamic error, matching the decode errors' style (Macroscope Effect Service Conventions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Effect service conventions review of the Aether provider changes at this head.
The findings from the previous run (standalone AetherTerminalManagerShape, service instances passed into makeAetherAdapter, catchTag, and the errors that lost their cause) are unchanged at this commit and are not re-posted — later commits on this branch address them.
One new item below, in packages/contracts/src/project.ts.
Posted via Macroscope — Effect Service Conventions
Add a ProjectFileFailure "aether_mirror_read_only" literal and derive the ProjectRead/WriteFileError message from it (AETHER_MIRROR_REFUSAL now lives in contracts), dropping the message-override field. Failure mode is encoded structurally, not in a prose string (Macroscope Effect Service Conventions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Follow-up review of the convention fixes since the last run (4427f33, 8155f4c, 3ae2e17). Three items remain in the changed scope: two wrapper detail fields still derive their message from the stringified/underlying error, and the mirror-refusal refactor dropped the legacy decoded-message passthrough for the project file errors.
Posted via Macroscope — Effect Service Conventions
…ecode fallback
- CloudTerminalWriteError (terminalConnection) + CloudTerminalTransportError
(AetherAdapter boundary): `detail` is a stable structural phrase; the dynamic
error stays in the preserved `cause`.
- project.ts: restore the decodedProjectErrorMessage fallback so legacy
{_tag, message} payloads still decode their message (mirror case still derives
structurally from the failure literal); restore the covering test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||
| const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = Effect.fn( | ||
| "interruptTurn", | ||
| )(function* (threadId, _turnId) { |
There was a problem hiding this comment.
🟠 High Layers/AetherAdapter.ts:2312
interruptTurn ignores its _turnId argument and always stops whatever turn is currently active. If an interrupt for turn A arrives after A has settled and turn B has started, this handler calls stopTask and interrupts B instead of rejecting the stale turn id. The supplied turnId should be checked against the active and deferred turns before stopping the remote task.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/AetherAdapter.ts around line 2312:
`interruptTurn` ignores its `_turnId` argument and always stops whatever turn is currently active. If an interrupt for turn A arrives after A has settled and turn B has started, this handler calls `stopTask` and interrupts B instead of rejecting the stale turn id. The supplied `turnId` should be checked against the active and deferred turns before stopping the remote task.
| ), | ||
| ].join("\u0001"); | ||
| if (context.taskId === undefined) { | ||
| const created = yield* restClient |
There was a problem hiding this comment.
🟠 High Layers/AetherAdapter.ts:2058
sendTurn calls createTask without an idempotency key. If Aether commits the task but the 202 response is lost (timeout, dropped connection), the effect fails before context.taskId is assigned on line 2073. Retrying the same first turn sees context.taskId === undefined and creates a second cloud task, leaving the original task orphaned and executing the prompt twice. Consider adding an idempotency key to createTask so a retry after a lost 202 resolves to the original task instead of creating a duplicate.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/AetherAdapter.ts around line 2058:
`sendTurn` calls `createTask` without an idempotency key. If Aether commits the task but the 202 response is lost (timeout, dropped connection), the effect fails before `context.taskId` is assigned on line 2073. Retrying the same first turn sees `context.taskId === undefined` and creates a second cloud task, leaving the original task orphaned and executing the prompt twice. Consider adding an idempotency key to `createTask` so a retry after a lost 202 resolves to the original task instead of creating a duplicate.
What this adds
A first-class Aether provider for T3 Code: run any T3 Code agent (Claude Code, Codex, …) in an isolated cloud microVM instead of locally — one click, no VPS, no Tailscale, and no long cold start. T3 Code stays the client; the agent, workspace, and a live preview all run in the VM.
Highlights:
Shape
Built as a clean, reviewable commit chain:
~86 files, largely isolated under
apps/server/src/provider/Layers/aether/*plus a thin provider registration;packages/contractsadditions are additive.Opening as a draft to start the conversation — happy to split it or adjust to fit however you'd want this to land.
Note
Add Aether cloud provider to run T3 Code agents in isolated microVMs
aetherprovider driver (AetherDriver.ts) backed by a REST client (restClient.ts), a WebSocket agent stream (workspaceSocket.ts), and a stateful event mapper (eventMapper.ts) that converts Aether WS/REST events into deterministicProviderRuntimeEvents.AetherMirrorRegistryand guards (AetherMirrorGuards.ts) that reject git mutations, worktree removals, and filesystem writes targeting directories owned by an active cloud session, returning structuredGitCommandErrororProjectWriteFileErrorresponses.AetherTerminalManager(AetherTerminalManager.ts) for cloud-backed threads, with WebSocket reconnection, exponential backoff, and keep-alive pings.thread.worktree.attach-managedinternal orchestration command, aworktree_managedcolumn (migration 041), and UI surfaces including provider selection, settings schema, port-preview CTAs in timeline and mobile work log, and anAetherIcon.Macroscope summarized 2bb0a73.
Note
High Risk
Changes orchestration persistence, session start preflight behavior, and new RPC refusal paths for active Aether mirrors; incorrect
worktreeManagedhandling could reset user uncommitted work or leave mirrors writable mid-session.Overview
This slice extends the Aether cloud-provider work with fork-safe CI, driver-owned worktree tracking, mirror write guards, and mobile port-preview UX.
CI / release: Main CI jobs move from Blacksmith runners to
ubuntu-24.04/macos-latestwith longer timeouts. Relay deploy and scheduled nightly release are gated topingdotgg/t3codeso forks do not hit upstream production credentials or publish nightlies.Worktree provenance (
worktreeManaged): Threads gain a persisted flag (migration041, projection SQL/repos) distinguishing worktrees the bootstrap created from paths the user already had. A server-onlythread.worktree.attach-managedcommand marks driver-owned paths viathread.meta-updated;thread.meta.updatederives the marker when the path stays the same (e.g. branch rename) and clears it on re-attach.ProviderCommandReactorpassesmanagedWorktree: trueintostartSessiononly when the flag is set so Aether can skip clean-tree preflight without touching user dirty checkouts.Runtime / UI:
port.openedis ingested into activities and mapped on mobile to work-log rows withportPreview(globe icon, Open preview opens the URL). Mobile also keeps free-text-only user-input questions (emptyoptions) so submissions are not partial.Aether infrastructure (partial in diff): New
AetherMirrorRegistry+AetherMirrorGuardsrefuse local VCS mutations,removeWorktreetargets, and typedprojects.writeFilewhile a mirror cwd is claimed.AetherDriverregisters the REST/adapter stack;CloudTerminalConnectordefines the optional remote PTY capability. Mobile adds an aetherProviderIcon.Reviewed by Cursor Bugbot for commit 2bb0a73. Bugbot is set up for automated code reviews on this repo. Configure here.