Skip to content

feat: Aether cloud provider — run T3 Code agents in isolated microVMs - #5995

Open
pranav100000 wants to merge 21 commits into
pingdotgg:mainfrom
pranav100000:aether-cloud-provider
Open

feat: Aether cloud provider — run T3 Code agents in isolated microVMs#5995
pranav100000 wants to merge 21 commits into
pingdotgg:mainfrom
pranav100000:aether-cloud-provider

Conversation

@pranav100000

@pranav100000 pranav100000 commented Aug 10, 2026

Copy link
Copy Markdown

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:

  • Provider driver wired into the existing provider registry (settings, model catalog).
  • REST task client + WS transport + full event mapper so cloud turns stream into the normal T3 Code timeline.
  • Turn lifecycle + mirror-sync engine + write guards — durable-authoritative settlement so checkpoint diffs render correctly.
  • Question/plan responses, revert ledger, model switch.
  • Cloud port previews surfaced in the composer (public HTTPS, per-workspace token — no Tailscale).
  • Cloud terminal — attach a real shell in the workspace VM, with reconnect on transient drops.

Shape

Built as a clean, reviewable commit chain:

  1. provider driver skeleton — settings, registration, vendored catalog
  2. REST task client + adapter session core
  3. workspace attach, WS transport, full event mapper
  4. turn lifecycle, mirror sync engine, write guards
  5. question/plan responses, revert ledger, model switch
  6. durable-authoritative turn settlement (checkpoint diffs)
  7. cloud port previews in the composer
  8. cloud terminal — attach a shell in the workspace VM (+ reconnect)

~86 files, largely isolated under apps/server/src/provider/Layers/aether/* plus a thin provider registration; packages/contracts additions 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

  • Introduces a new aether provider 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 deterministic ProviderRuntimeEvents.
  • Adds a mirror sync engine (mirrorSync.ts) that resets the local git worktree to the VM's declared state by reconstructing and applying a unified diff from structured Aether diff payloads, including safe binary file handling.
  • Introduces AetherMirrorRegistry and guards (AetherMirrorGuards.ts) that reject git mutations, worktree removals, and filesystem writes targeting directories owned by an active cloud session, returning structured GitCommandError or ProjectWriteFileError responses.
  • Routes per-thread terminal RPCs to a new AetherTerminalManager (AetherTerminalManager.ts) for cloud-backed threads, with WebSocket reconnection, exponential backoff, and keep-alive pings.
  • Adds a thread.worktree.attach-managed internal orchestration command, a worktree_managed column (migration 041), and UI surfaces including provider selection, settings schema, port-preview CTAs in timeline and mobile work log, and an AetherIcon.
  • Risk: git and filesystem operations on Aether-owned paths now fail with refusal errors rather than executing; local TerminalManager falls back for non-Aether threads but routing is now conditional.

Macroscope summarized 2bb0a73.


Note

High Risk
Changes orchestration persistence, session start preflight behavior, and new RPC refusal paths for active Aether mirrors; incorrect worktreeManaged handling 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-latest with longer timeouts. Relay deploy and scheduled nightly release are gated to pingdotgg/t3code so forks do not hit upstream production credentials or publish nightlies.

Worktree provenance (worktreeManaged): Threads gain a persisted flag (migration 041, projection SQL/repos) distinguishing worktrees the bootstrap created from paths the user already had. A server-only thread.worktree.attach-managed command marks driver-owned paths via thread.meta-updated; thread.meta.update derives the marker when the path stays the same (e.g. branch rename) and clears it on re-attach. ProviderCommandReactor passes managedWorktree: true into startSession only when the flag is set so Aether can skip clean-tree preflight without touching user dirty checkouts.

Runtime / UI: port.opened is ingested into activities and mapped on mobile to work-log rows with portPreview (globe icon, Open preview opens the URL). Mobile also keeps free-text-only user-input questions (empty options) so submissions are not partial.

Aether infrastructure (partial in diff): New AetherMirrorRegistry + AetherMirrorGuards refuse local VCS mutations, removeWorktree targets, and typed projects.writeFile while a mirror cwd is claimed. AetherDriver registers the REST/adapter stack; CloudTerminalConnector defines the optional remote PTY capability. Mobile adds an aether ProviderIcon.

Reviewed by Cursor Bugbot for commit 2bb0a73. Bugbot is set up for automated code reviews on this repo. Configure here.

pranav100000 and others added 16 commits August 8, 2026 05:54
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>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: edf7452a-98b5-4cef-af49-df511d72e9c8

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

❤️ Share

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

@github-actions github-actions Bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Aug 10, 2026
}
const domain = deriveAetherPreviewDomain(input.apiBaseUrl);
const subdomain = `${input.port}-${input.workspaceId.slice(0, 8)}-${input.previewToken}`;
const protocol = domain.startsWith("localhost") ? "http" : "https";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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 ab 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +67 to +69
export function stubBranchName(message: string, isoDate: string): string {
return sanitizeBranchFragment(`${sanitizeBranchFragment(message)}-${isoDate}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread apps/server/src/provider/Layers/AetherAdapter.ts
* path makes `join(cwd, …)` return the path itself, and a `..` segment
* walks straight out.
*/
const resolveInMirror = (relative: string): Effect.Effect<string, PauseSync> =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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* () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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`.

@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Aug 10, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread apps/server/src/terminal/AetherTerminalManager.ts Outdated
Comment thread apps/server/src/provider/CloudTerminalConnector.ts
Comment thread apps/server/src/provider/Layers/AetherAdapter.ts Outdated
Comment thread apps/server/src/provider/Layers/AetherAdapter.ts Outdated
Comment thread apps/server/src/provider/Layers/aether/restClient.ts Outdated
Comment thread apps/server/src/provider/Layers/aether/workspaceSocket.ts Outdated
Comment thread apps/server/src/provider/Layers/aether/restClient.ts
pranav100000 and others added 2 commits August 10, 2026 19:18
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)(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +570 to +577
);
for (const key of keys) {
const session = sessions.get(key);
if (!session) continue;
sessions.delete(key);
yield* closeOne(session);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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>

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/contracts/src/project.ts Outdated
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>

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/contracts/src/project.ts Outdated
Comment thread apps/server/src/provider/Layers/aether/terminalConnection.ts Outdated
Comment thread apps/server/src/provider/Layers/AetherAdapter.ts Outdated
…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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

@pranav100000
pranav100000 marked this pull request as ready for review August 11, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant