Skip to content

feat(runtime): run sessions in Cloud Hypervisor VMs behind a setting - #892

Draft
ilovecrayons wants to merge 3 commits into
aether/run-work-item-3-the-in-vm-helper-and-ygq840q53xfrom
aether/run-work-item-2-the-vm-engine-goal-a-pmvnsrnaj7
Draft

ilovecrayons wants to merge 3 commits into
aether/run-work-item-3-the-in-vm-helper-and-ygq840q53xfrom
aether/run-work-item-2-the-vm-engine-goal-a-pmvnsrnaj7

Conversation

@ilovecrayons

Copy link
Copy Markdown
Contributor

Stacked on #891 (item 3), which is stacked on #890 (item 4) and #889 (the plan).

Work item 2 of the microVM plan: the VM engine.

What was wrong before, what happens now

Every session ran in a Docker container; there was no other runtime.Runtime.

Now aether-server serve --session-runtime vm (config key session-runtime, default docker) runs each session in its own Cloud Hypervisor VM through the new internal/runtime/vm. The scheduler drives it through runtime.Runtime unchanged. With docker, nothing changes.

  • Hybrid, one routing rule (isSession in vm.go): a spec whose CreationKey equals its AETHER_RUN_ID is a session and becomes a VM with ID vm-<run id>. Everything else (environment terminals, assistant, agent updater, cleanup helper, browser companion, image methods) goes to the embedded Docker runtime. TestEveryContainerMethodIsRouted fails if Docker gains a method taking a container ID that the router does not route, so item 9 can remove the Docker delegate piece by piece.
  • Monitor units: each VM's Cloud Hypervisor runs in a transient systemd unit aether-vm-<run id>.service in aether.slice (PrivateMounts=yes, OOMScoreAdjust=500, CPUQuota= the exact CPU limit), outside the server's process tree, so a VM survives server restarts and updates. The unit runs aether-server vm-monitor <dir>, which composes the share, starts virtiofsd, then execs Cloud Hypervisor, so the unit ends with the VM. Per-VM files live in <data dir>/vm/<run id>/ (vm.json record, disks, monitor.log, console.log); FindByCreationKey and recovery read the record.
  • Lifecycle: Create resolves the base disk, makes the root and data disks and the record. Start cold-boots (a fresh root disk on every cold start, as the plan says; setup runs once per boot), preps the non-root user as root (shared runtime.UserScript), then Boot runs dockerd, setup and Main in the guest. Stop sends SIGTERM to Main, kills after the grace, records the exit and powers off. Main's exit is recorded on the host, so Inspect and Wait answer after a restart; a VM that ends without one reports 255. Pause/Resume are Cloud Hypervisor's pause/resume.
  • Optional interfaces: ManagedExecRuntime (the unchanged dev-exec supervisor started through the helper, exactly as Docker starts it through docker exec), SudoRuntime, CapacityRuntime (Docker's plus the VM disks' filesystem), InitPIDRuntime (Cloud Hypervisor's PID), ContainerStats (CPU, memory, pids from the helper; net and disk from Cloud Hypervisor's counters), and two new ones in internal/runtime: MemoryEventsRuntime (guest OOM kills; oomwatch.go prefers it, Docker's path unchanged, approved by the coordinator) and SaveRuntime (Save/Restore, for item 8).
  • Limits: vCPUs = CPU limit rounded up, held to the exact limit by CPUQuota; guest memory = memory limit, shared=on, balloon with free page reporting and page_reporting.page_reporting_order=0 (item 1: without it freed page cache stalls in the host); the process limit is applied by the helper.
  • Mounts (bridge until items 6 and 7): one virtio-fs share per VM. The monitor mounts a small tmpfs and binds every mount source into it inside the unit's private mount namespace (open_tree + mount_setattr: recursive, nosuid, nodev, read-only where asked; subpaths resolved with openat2(RESOLVE_BENEATH), as Docker's volume subpath refuses an escaping symlink). Why one share and not one virtiofsd per mount: one process and one PCI slot per VM instead of 10-20; virtiofsd 1.10 has no --readonly, so the host kernel's read-only bind is what enforces it; single-file mounts and subpaths work, which a virtiofsd shared dir cannot express; nothing leaks onto the host's mount table. Host sockets cannot cross virtio-fs: sockets at the top of a shared directory are forwarded by the helper over vsock, and <dir>/vsock.sock_<port> is a symlink to the host socket, so Cloud Hypervisor connects straight to it with no host proxy.
  • Network (all in network.go, for item 5): one tap per VM that Cloud Hypervisor creates, a /30 per VM from 10.200.0.0/16, guest DNS from the host's upstream resolvers, and NAT via three idempotent iptables rules.
  • Save/restore: Save pauses, snapshots, ends the monitor; Restore starts the monitor on the snapshot, resumes, waits for the helper and sets the guest clock. A VM with virtio-fs mounts is refused with an error naming them: measured below, a restored VM's share is dead.
  • Refused with an error naming the item: egress / Isolated tier (item 5), overlay mounts (item 7), the browser beside a VM session and Commit of a session VM (item 9).
  • Engine-neutral helpers move out of docker.go/docker_sudo.go/docker_managed_exec.go into session.go and managed_exec.go unchanged (user script, safe.directory env, setup output masking, root PATH, dev-exec control argv and reply parsing), so both engines share them; Docker's behaviour is byte-for-byte the same.

Interfaces agreed with items 3 and 4

  • Item 3 (internal/guest): Dial(vsockPath); Ping → Info{Protocol, Version, BootID, BootError}; Boot(BootSpec{Main, SetupScript, NestedDocker, PidsLimit}) (dockerd, setup once per boot, Main; *SetupError on failure, masked here); Spawn(ProcessSpec{ID, Argv, Env, User, Dir, TTY, Lossless}) (*StartError{126|127}); Process(id) for reattach (Attach(fromStart), Signal, Status, Wait, Resize, CloseStdin); Stats incl. OOMKills; SyncClock; PowerOff; Config{Hostname, Network, Mounts(share|data), Sockets}.KernelArgs(base). PID 1 forwards HUP/INT/QUIT/TERM/USR1/USR2/ALRM/WINCH to Main.
  • Item 4 (internal/vmimage): Open(<data>/vm/images, kernel, docker); Kernel; BaseDisk; NewRootDisk (qcow2, backing_files=on); NewDataDisk (raw ext4); HelperDisk (read-only, first disk, /init is the helper). Boot line: root=/dev/vda ro rootfstype=ext4 init=/init console=ttyS0 quiet page_reporting.page_reporting_order=0 + item 3's args.
  • Server: --vm-kernel (config key vm-kernel, default /usr/local/lib/aether/vmlinux, item 10 installs it).

How I verified it

Unit tests (go test ./internal/runtime/vm/, also under -race) with a fake Cloud Hypervisor REST API served on the VM's real API socket, fake systemd units, a fake helper and a fake image store: create/start/stop/wait/inspect/destroy, setup failure masking, a VM dying while booting (error carries monitor.log), SIGKILL after the grace, a second engine finding a running VM, exec limits and missing commands, managed exec through dev-exec and its recovery, save refusal and save/restore, stats, the mount plan and socket links, addressing and NAT rules, and the routing guard. oomwatch_test.go covers both OOM paths.

On the KVM test host (AWS m8id.4xlarge, nested KVM, Ubuntu 24.04, Cloud Hypervisor v54.1, virtiofsd 1.10.0, item 4's kernel and store, item 3's helper), TestKVMSessionLifecycle and TestKVMSaveRestoreKeepsProcesses (-tags integration, skipped without AETHER_VM_TEST_* and /dev/kvm) pass:

  • ubuntu:24.04 session: create 1.1 s cold (base disk built), 14 ms warm; start through unit, boot, setup and Main 0.68-0.72 s; attach replays Main's output; exec sees the checkout both ways, pwd is the working dir, the read-only mount refuses writes, 1.5 CPUs gives 2 vCPUs, the forwarded socket exists at /run/aether/coord3.sock, DNS resolves; stop 0.23-0.45 s.
  • A second engine process finds the running VM by its run ID and drives it; a cold restart reruns setup on a fresh root disk.
  • Save 0.10 s, restore 0.24 s: same PIDs and boot ID after restore.

Earlier runs of the same engine code with a stand-in guest init (before item 3's helper existed) also showed: the unit's main PID is Cloud Hypervisor in aether.slice/aether-vm-<id>.service; the composed share enforces read-only for a file and for the share root; an escaping subpath is refused at boot with resolve escape beneath ...: invalid cross-device link; the guest reaches the internet through NAT (wget http://example.com); a guest connection to vsock port 1025 reached the host socket through the symlink; vsock still answers after pause/resume; save of a 1 GiB guest wrote 132 MB and a 64 MB file in the guest's /tmp came back byte-identical.

Save with virtio-fs (the plan's Open question): pause + snapshot succeeds, but after restore every access to the share returns EBADF, because a fresh virtiofsd has none of the guest's FUSE state; remounting in the guest works (16 ms) only when no process holds an fd or cwd in the share, which an agent always does in /workspace. Hence the refusal.

Checks: make fmt-check, go vet, make lint (also with -tags integration) clean for these packages; go test of internal/runtime/..., internal/scheduler, internal/server, cmd/aether-server pass; make public-audit passes.

Greptile CLI: greptile: command not found in this environment, so it did not run; the Greptile bot reviews the pushes.

Left open

  • Saving a VM that has shares: blocked until items 6 and 7 move the checkout and home off virtio-fs.
  • The storage page does not count VM disks yet (ContainerSizes is Docker's).
  • VM-to-VM traffic is not blocked and the metadata address is reachable: item 5.
  • ContainerStats runs one systemctl show per recorded VM per sample; fine for tens of VMs.

This PR was made with the help of Claude Code.

Opened from Aether run run-pmvnsrnaj7

@ilovecrayons
ilovecrayons force-pushed the aether/run-work-item-2-the-vm-engine-goal-a-pmvnsrnaj7 branch from ca04518 to 4a3b68b Compare November 13, 2026 03:12
@ilovecrayons
ilovecrayons force-pushed the aether/run-work-item-3-the-in-vm-helper-and-ygq840q53x branch from fdc1d6e to 5f10e95 Compare November 13, 2026 03:14
Before, every session ran in a Docker container. With the new server
setting session-runtime=vm, the server runs each session in its own
Cloud Hypervisor VM through internal/runtime/vm, while environment
terminals, the assistant, the agent updater, the cleanup helper, the
browser companion and image operations stay on Docker until work item 9.
The default, docker, changes nothing.

Each VM's monitor runs in its own systemd unit in aether.slice, so a VM
survives server restarts and updates; the server finds it again from
<data dir>/vm/<run id>/vm.json. Mounts reach the guest over one
virtio-fs share composed from host bind mounts inside the unit's private
mount namespace; host sockets are forwarded over vsock.

Session preparation helpers and the dev-exec control parser move out of
the Docker files unchanged so both engines share them. The OOM watcher
asks the runtime for kills when it can report guest ones itself.
Docker refuses an exec into a paused container at once; a paused guest
would instead hold the exec until its context ends, so closed sessions,
which are paused, could stall a caller.
A restore that failed after resuming the guest kept the snapshot marked
saved. The guest's disks had already moved past it, so restoring it again
would pair old memory with newer disks.
@ilovecrayons
ilovecrayons force-pushed the aether/run-work-item-2-the-vm-engine-goal-a-pmvnsrnaj7 branch from 4a3b68b to 8161fb3 Compare November 13, 2026 03:16
@greptile-apps

greptile-apps Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5 Tier: apex

[Medium impact] The new VM path changes how session workloads run when operators enable it.

Not ready to merge: VM commands can hang or lose output, and waking or restoring a VM can stop live work or reuse stale saved state.

Fix All in Claude CodeFindings

  1. P1 Pipe commands never receive EOF ▶
  2. P1 Agent replies can lose bytes ▶
  3. P1 Crashed VMs stay marked running ▶
  4. P1 Command reads ignore cancellation ▶
  5. P1 Stderr failures leave commands stuck ▶
  6. P1 Recovery declares live commands gone ▶
  7. P1 Old waits stop new boots ▶
  8. P1 Restore can reuse stale memory ▶
  9. P2 Terminal exit codes disappear ▶
  10. P2 Paused VMs delay periodic work ▶
Fix with agent prompt
### Issue 1
internal/runtime/vm/managed.go:67-69
Pipe executions return the raw guest attachment, whose input closer leaves the process's stdin open. `runSSHConn.pump` closes that writer when an SSH client finishes sending input. A command such as `cat` therefore never gets EOF, so it keeps running and SSH never receives an exit status. Return a pipe-specific attachment that calls `Process.CloseStdin` when its input closes.

### Issue 2
internal/runtime/vm/managed.go:63
`StartExecPipe` must keep stdout lossless even when callers omit `ExecSpec.Lossless`. Passing that flag through unchanged lets the guest drop unread output after 256 KiB. Existing callers, including the Codex bridge and ACP keeper bridge, omit the flag. A slow reader can therefore lose part of a reply and break the conversation. Preserve lossless stdout for every pipe execution.

### Issue 3
internal/runtime/vm/lifecycle.go:171
`waitMain` stops checking the monitor while it waits for Main. If Cloud Hypervisor crashes or is killed during that wait, the guest client retries the disconnected call and waits for a connection that will never return. The scheduler passes its long-lived `superCtx`, so the session can stay marked running indefinitely instead of reporting exit 255. Watch the unit alongside the guest wait, or use bounded status calls.

### Issue 4
internal/runtime/vm/exec.go:107-111
`Exec` does not close its attachment when `ctx` ends. `readLimited` runs before `p.Wait(ctx)`, and the attachment reads use their own background context. If the command hangs or the VM pauses after spawning it, the caller's deadline cannot interrupt these reads. Tie the attachment to the call's context before reading output. The same reader is used by user preparation and managed control calls.

### Issue 5
internal/runtime/vm/exec.go:150-156
A stderr failure is not noticed until stdout ends. An endless stderr-only command can pass the output limit, causing its stderr reader to return. The guest then blocks on its full stderr buffer while the host keeps waiting on quiet stdout. `execAttached` has the same ordering when its stderr writer fails. Collect both read results concurrently and close the streams on the first failure.

### Issue 6
internal/runtime/vm/managed.go:103-107
This wraps every `requireRunning` failure as `runtime.ErrNotFound`, including a cancelled context or a temporary `systemctl` failure. Those errors do not prove that the VM or command ended. The scheduler treats this result as proof and marks the terminal gone. Preserve temporary errors as `ErrExecUnavailable`; add `ErrNotFound` only after confirming absence or exit.

### Issue 7
internal/runtime/vm/lifecycle.go:175-184
An old wait can power off a newer boot of the same VM. Sleep stops Main while the old supervision wait remains active. If wake starts the VM before that waiter takes this lock, `start` has already cleared `Exited`; the waiter then calls `finish` with the previous Main's exit code. The scheduler's `sleepGen` check happens only after `Runtime.Wait` returns. Compare a boot generation before finishing the VM.

### Issue 8
internal/runtime/vm/save.go:121-128
Restore resumes the guest before making the snapshot unusable. If saving `Saved=false` fails-for example, because the snapshot filled the disk-the deferred cleanup kills the resumed VM, but the old record still says `Saved=true` and the snapshot remains. Retrying restore then pairs old memory with disks the guest has already changed. Durably mark the snapshot consumed before resuming the guest.

### Issue 9
internal/runtime/vm/managed.go:172-174
A missing guest process record does not mean the managed execution's final record is gone. The guest forgets exited processes after 32 later exits, including the short control commands created here. Once that happens, `Status` and recovery return `ErrNotFound` without reading the saved `dev-exec` result. Completed terminals can lose an exit code the server already knew. Fall back to the control reply when the guest forgets the process.

### Issue 10
internal/runtime/vm/stats.go:35
A paused VM still has an active systemd unit, so this call asks a frozen helper for stats. The OOM watcher does that serially with a five-second deadline per VM, including paused and retained sessions. Each frozen VM adds five seconds to every scheduler sweep, delaying cleanup and other periodic work. Skip frozen guests or use cached counters. Apply the same check in `sample` so paused VMs do not exhaust its shared sampling deadline.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds opt-in session-runtime=vm, with one Cloud Hypervisor VM per session. Docker remains the default and handles other container types.

  • The server can send each session to its own VM.
  • Each VM session has its own recorded lifecycle and systemd unit.
  • Session commands run inside the guest VM.
  • Session VMs share host mounts and get their own network path.
  • A session VM can be saved and restored when it has no shared mounts.
  • Session resource reports include guest usage and OOM kills.
  • Docker and VM sessions use the same session helpers.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Setting["session-runtime"] --> Router["runtime.Runtime"]
    Router --> Kind{"Session spec?"}
    Kind -->|No| Docker["Docker"]
    Kind -->|Yes, vm selected| Engine["VM engine"]
    Engine --> Record["VM disks and vm.json"]
    Engine --> Unit["Per-session systemd unit"]
    Unit --> Monitor["vm-monitor"]
    Monitor --> Share["virtiofsd"]
    Monitor --> CH["Cloud Hypervisor"]
    Engine -->|vsock| Guest["Guest helper"]
    CH --> Guest
    Guest --> Main["Setup and session command"]
    Guest --> Exec["Managed commands"]
Loading

Reviews (1) · Last reviewed commit: "fix(runtime): drop a VM's snapshot as so..." · Reviewed by Greptile

Comment on lines +67 to +69
m := &managedExec{engine: e, key: key, tty: tty, proc: p, attachment: att, identity: runtime.ExecIdentity{
ContainerID: runtime.ID(idPrefix + key), ExecID: execID, CreationKey: spec.CreationKey, ClaimToken: claim, User: spec.User,
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Pipe commands never receive EOF

Pipe executions return the raw guest attachment, whose input closer leaves the process's stdin open. runSSHConn.pump closes that writer when an SSH client finishes sending input. A command such as cat therefore never gets EOF, so it keeps running and SSH never receives an exit status. Return a pipe-specific attachment that calls Process.CloseStdin when its input closes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/managed.go
Line: 67-69

Comment:
**Pipe commands never receive EOF**

Pipe executions return the raw guest attachment, whose input closer leaves the process's stdin open. `runSSHConn.pump` closes that writer when an SSH client finishes sending input. A command such as `cat` therefore never gets EOF, so it keeps running and SSH never receives an exit status. Return a pipe-specific attachment that calls `Process.CloseStdin` when its input closes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

}
execID := processID("managed")
argv := append([]string{coordtransport.CLIPath, devexec.Command, mode, spec.CreationKey, claim}, spec.Argv...)
p, att, err := e.spawn(ctx, key, guest.ProcessSpec{ID: execID, Argv: argv, Env: env, User: spec.User, Dir: spec.WorkingDir, TTY: size, Lossless: spec.Lossless})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Agent replies can lose bytes

StartExecPipe must keep stdout lossless even when callers omit ExecSpec.Lossless. Passing that flag through unchanged lets the guest drop unread output after 256 KiB. Existing callers, including the Codex bridge and ACP keeper bridge, omit the flag. A slow reader can therefore lose part of a reply and break the conversation. Preserve lossless stdout for every pipe execution.

Knowledge Base Used: Environment construction and execution

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/managed.go
Line: 63

Comment:
**Agent replies can lose bytes**

`StartExecPipe` must keep stdout lossless even when callers omit `ExecSpec.Lossless`. Passing that flag through unchanged lets the guest drop unread output after 256 KiB. Existing callers, including the Codex bridge and ACP keeper bridge, omit the flag. A slow reader can therefore lose part of a reply and break the conversation. Preserve lossless stdout for every pipe execution.

**Knowledge Base Used:** [Environment construction and execution](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fenvironment-construction-and-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

if err != nil {
return 0, false
}
code, err := h.Process(guest.MainID).Wait(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Crashed VMs stay marked running

waitMain stops checking the monitor while it waits for Main. If Cloud Hypervisor crashes or is killed during that wait, the guest client retries the disconnected call and waits for a connection that will never return. The scheduler passes its long-lived superCtx, so the session can stay marked running indefinitely instead of reporting exit 255. Watch the unit alongside the guest wait, or use bounded status calls.

Knowledge Base Used: Workspace execution and synchronization

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/lifecycle.go
Line: 171

Comment:
**Crashed VMs stay marked running**

`waitMain` stops checking the monitor while it waits for Main. If Cloud Hypervisor crashes or is killed during that wait, the guest client retries the disconnected call and waits for a connection that will never return. The scheduler passes its long-lived `superCtx`, so the session can stay marked running indefinitely instead of reporting exit 255. Watch the unit alongside the guest wait, or use bounded status calls.

**Knowledge Base Used:** [Workspace execution and synchronization](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fworkspace-execution-and-synchronization.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +107 to +111
defer func() { _ = att.Close() }()
if err = p.CloseStdin(ctx); err != nil {
return 0, "", "", fmt.Errorf("vm runtime: exec input: %w", err)
}
stdout, stderr, err := readLimited(att)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Command reads ignore cancellation

Exec does not close its attachment when ctx ends. readLimited runs before p.Wait(ctx), and the attachment reads use their own background context. If the command hangs or the VM pauses after spawning it, the caller's deadline cannot interrupt these reads. Tie the attachment to the call's context before reading output. The same reader is used by user preparation and managed control calls.

Knowledge Base Used: Keep container faults from degrading server availability

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/exec.go
Line: 107-111

Comment:
**Command reads ignore cancellation**

`Exec` does not close its attachment when `ctx` ends. `readLimited` runs before `p.Wait(ctx)`, and the attachment reads use their own background context. If the command hangs or the VM pauses after spawning it, the caller's deadline cannot interrupt these reads. Tie the attachment to the call's context before reading output. The same reader is used by user preparation and managed control calls.

**Knowledge Base Used:** [Keep container faults from degrading server availability](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Freverts%2Fincident-mitigation_136-20260915-container-availability-14cb623.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +150 to +156
errc := make(chan error, 1)
go func() { errc <- read(att.Stderr(), &errOut) }()
outErr := read(att.Stdout(), &out)
if outErr != nil {
_ = att.Close()
}
errErr := <-errc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stderr failures leave commands stuck

A stderr failure is not noticed until stdout ends. An endless stderr-only command can pass the output limit, causing its stderr reader to return. The guest then blocks on its full stderr buffer while the host keeps waiting on quiet stdout. execAttached has the same ordering when its stderr writer fails. Collect both read results concurrently and close the streams on the first failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/exec.go
Line: 150-156

Comment:
**Stderr failures leave commands stuck**

A stderr failure is not noticed until stdout ends. An endless stderr-only command can pass the output limit, causing its stderr reader to return. The guest then blocks on its full stderr buffer while the host keeps waiting on quiet stdout. `execAttached` has the same ordering when its stderr writer fails. Collect both read results concurrently and close the streams on the first failure.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +103 to +107
if _, err := e.requireRunning(ctx, key, "recover an execution in"); err != nil {
if errors.Is(err, runtime.ErrNotFound) {
return nil, err
}
return nil, fmt.Errorf("%w: %w: %w", runtime.ErrExecUnavailable, runtime.ErrNotFound, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Recovery declares live commands gone

This wraps every requireRunning failure as runtime.ErrNotFound, including a cancelled context or a temporary systemctl failure. Those errors do not prove that the VM or command ended. The scheduler treats this result as proof and marks the terminal gone. Preserve temporary errors as ErrExecUnavailable; add ErrNotFound only after confirming absence or exit.

Knowledge Base Used: Environment construction and execution

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/managed.go
Line: 103-107

Comment:
**Recovery declares live commands gone**

This wraps every `requireRunning` failure as `runtime.ErrNotFound`, including a cancelled context or a temporary `systemctl` failure. Those errors do not prove that the VM or command ended. The scheduler treats this result as proof and marks the terminal gone. Preserve temporary errors as `ErrExecUnavailable`; add `ErrNotFound` only after confirming absence or exit.

**Knowledge Base Used:** [Environment construction and execution](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fenvironment-construction-and-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +175 to +184
unlock := e.lock(key)
defer unlock()
rec, err := e.load(key)
if err != nil {
return 0, false
}
if rec.Exited {
return rec.ExitCode, true
}
if err := e.finish(ctx, rec, code); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Old waits stop new boots

An old wait can power off a newer boot of the same VM. Sleep stops Main while the old supervision wait remains active. If wake starts the VM before that waiter takes this lock, start has already cleared Exited; the waiter then calls finish with the previous Main's exit code. The scheduler's sleepGen check happens only after Runtime.Wait returns. Compare a boot generation before finishing the VM.

Knowledge Base Used: Workspace execution and synchronization

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/lifecycle.go
Line: 175-184

Comment:
**Old waits stop new boots**

An old wait can power off a newer boot of the same VM. Sleep stops Main while the old supervision wait remains active. If wake starts the VM before that waiter takes this lock, `start` has already cleared `Exited`; the waiter then calls `finish` with the previous Main's exit code. The scheduler's `sleepGen` check happens only after `Runtime.Wait` returns. Compare a boot generation before finishing the VM.

**Knowledge Base Used:** [Workspace execution and synchronization](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fworkspace-execution-and-synchronization.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +121 to +128
if err = api.resume(ctx); err != nil {
return fmt.Errorf("vm runtime: resume restored VM %s: %w", key, err)
}
rec.Saved = false
if err = e.store(rec); err != nil {
return err
}
if err = os.RemoveAll(dir); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Restore can reuse stale memory

Restore resumes the guest before making the snapshot unusable. If saving Saved=false fails—for example, because the snapshot filled the disk—the deferred cleanup kills the resumed VM, but the old record still says Saved=true and the snapshot remains. Retrying restore then pairs old memory with disks the guest has already changed. Durably mark the snapshot consumed before resuming the guest.

Knowledge Base Used: Workspace execution and synchronization

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/save.go
Line: 121-128

Comment:
**Restore can reuse stale memory**

Restore resumes the guest before making the snapshot unusable. If saving `Saved=false` fails—for example, because the snapshot filled the disk—the deferred cleanup kills the resumed VM, but the old record still says `Saved=true` and the snapshot remains. Retrying restore then pairs old memory with disks the guest has already changed. Durably mark the snapshot consumed before resuming the guest.

**Knowledge Base Used:** [Workspace execution and synchronization](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fworkspace-execution-and-synchronization.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +172 to +174
st, err := m.proc.Status(ctx)
if errors.Is(err, guest.ErrNotFound) {
return runtime.ExecState{}, fmt.Errorf("%w: %w: execution %s", runtime.ErrExecUnavailable, runtime.ErrNotFound, m.identity.ExecID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Terminal exit codes disappear

A missing guest process record does not mean the managed execution's final record is gone. The guest forgets exited processes after 32 later exits, including the short control commands created here. Once that happens, Status and recovery return ErrNotFound without reading the saved dev-exec result. Completed terminals can lose an exit code the server already knew. Fall back to the control reply when the guest forgets the process.

Knowledge Base Used: Environment construction and execution

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/managed.go
Line: 172-174

Comment:
**Terminal exit codes disappear**

A missing guest process record does not mean the managed execution's final record is gone. The guest forgets exited processes after 32 later exits, including the short control commands created here. Once that happens, `Status` and recovery return `ErrNotFound` without reading the saved `dev-exec` result. Completed terminals can lose an exit code the server already knew. Fall back to the control reply when the guest forgets the process.

**Knowledge Base Used:** [Environment construction and execution](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fenvironment-construction-and-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

if err != nil {
return runtime.MemoryEvents{}, err
}
st, err := h.Stats(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Paused VMs delay periodic work

A paused VM still has an active systemd unit, so this call asks a frozen helper for stats. The OOM watcher does that serially with a five-second deadline per VM, including paused and retained sessions. Each frozen VM adds five seconds to every scheduler sweep, delaying cleanup and other periodic work. Skip frozen guests or use cached counters. Apply the same check in sample so paused VMs do not exhaust its shared sampling deadline.

Knowledge Base Used: Environment construction and execution

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/runtime/vm/stats.go
Line: 35

Comment:
**Paused VMs delay periodic work**

A paused VM still has an active systemd unit, so this call asks a frozen helper for stats. The OOM watcher does that serially with a five-second deadline per VM, including paused and retained sessions. Each frozen VM adds five seconds to every scheduler sweep, delaying cleanup and other periodic work. Skip frozen guests or use cached counters. Apply the same check in `sample` so paused VMs do not exhaust its shared sampling deadline.

**Knowledge Base Used:** [Environment construction and execution](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fenvironment-construction-and-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant