fix(cloud): coder and terminal hardening deployed on staging (follow-up to #5023) - #5329
Merged
Merged
Conversation
Link the baked harness into /etc/skel with `ln -sfn` so the layer is safe to re-run over its own output, matching the convention already used in cloud/nodeops/Sandbox.Dockerfile. Assert the link resolves to an executable in the same layer so a dangling link fails the build instead of shipping. This is idempotency hardening, not a build-blocker fix. A build failure reported as `ln: Already exists` was traced to a contaminated local `codercom/enterprise-base:ubuntu` tag on one preview host (no RepoDigests, same image id as a prior ao-coder-workspace:local), so that build re-ran this layer over its own earlier output. Against the clean upstream base the previous `ln -s` builds fine: that digest ships no /etc/skel/.local/bin at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the coding agent started and wrote files (e.g. .claude) into the workspace before repository checkout finished, checkout took the "clone into a staging dir and merge" path and created that staging dir in filepath.Dir(workspace) — the provider's durable root. On Coder that root is /home/coder, owned by the coder user (uid 1000), while the AO worker runs as ao-worker (uid 1001), so the staging mkdir failed with "permission denied" and the repo never checked out. The faster launch path (preinstalled worker, #4795) plus a roomier host made the agent win the startup race more often, so this latent bug went from rare to reliably hitting concurrent fan-outs (observed 3 of 6 workers failing checkout). Stage inside the workspace itself instead: it is always writable by the worker (the agent just wrote into it, which is why this path runs) and is on the same filesystem, so the entry moves stay same-filesystem renames. The hidden staging dir is removed before checkout returns. Provider-neutral. New test asserts staging happens under the workspace (uid-independent, so it holds even when tests run as root) and that the clone is merged while the agent's file is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…po race, report fan-in Root-caused via three parallel investigations of the coder session UX. 1. Terminal reconnect storm (the garbled/"Connecting…"-while-"Connected" terminal): keepTerminalAlive closed the socket on the FIRST ping timeout, but the keepalive ping shares the websocket write mutex with the output writer, so a large replay Write stalls the ping past its 5s timeout on a healthy socket. The close triggered a reconnect that replayed from the start, stalling the next ping — a self-sustaining storm (~every 25s) whose repeated from-zero replays garble the terminal. Tolerate a few consecutive ping misses (terminalPingMaxFailures) before closing; a genuinely dead socket still closes within a bounded window. 2. Empty-repository race: the worker launched the checkout and the coding agent as two unsynchronized goroutines. The agent's first task is baked into its launch argv, so it began acting the instant its process existed, racing the git clone; a fast worker start (baked worker) won that race and the agent inspected an empty workspace and gave up. Gate only the agent PTY spawn on a workspaceReady channel closed after checkout; all agent prep still overlaps the clone, and the workspace shell (a separate terminal) is untouched, so the snappy-shell UX is preserved. On checkout failure the run context is cancelled so the agent goroutine unblocks instead of parking. 3. `ao report` 409 under fan-in: the partial unique index ao_turns_one_active_per_session forbade a second QUEUED turn, so several workers reporting to one orchestrator at once collided (23505 -> 409) even though the work succeeded. Migration 00037 drops 'queued' from the index so reports FIFO-queue; the single-executing-turn guarantee is preserved and ClaimWorkerTurn already drains a multi-row queue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A cloud agent pane's mux opens no socket from mux.open(); the socket only opens after SSE agent.ready -> upgradeToAgent -> mint ticket -> open -> server ready, a chain that routinely exceeds the 3s OPEN_TIMEOUT_MS. The open-timeout therefore fired, tore down the mux (and its SSE), and reattached with countAsCloudFailure=false so the connect-failure breaker never bounded it — a self-sustaining ~4s reconnect storm. Each cycle rebuilt the SSE (replaying agent.ready from 0) and reset the replay cursor to 0, so the repeated from-0 replays garbled the pane and kept busy sessions stuck on "Connecting…"; the client closing its own socket (1000) produced no server close log. Do not arm the client open-timeout for a cloud pane. Local panes keep the 3s probe/spawn budget. Genuine cloud failures stay bounded by the existing machinery (mint 409 -> "waiting", socket close -> connect-failure breaker, CP's own ready deadline). Also guard upgradeToAgent so a re-firing agent.ready cannot re-open a socket on the same worker epoch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous fix removed the client open-timeout for cloud panes entirely to stop the ~4s reconnect storm. That stopped the storm but left a worse failure: if agent.ready never arrives (a stalled SSE subscription), the socket is never even attempted, so the CP-close and mint-409 recovery bounds cannot apply, and the pane hangs on "Connecting…" forever. Give a cloud pane a generous 30s open budget instead of none: - long enough that a normal slow open (agent.ready SSE -> mint -> dial -> CP ready ack, ~5-20s) completes without a storm — the timer is cleared on open; - short enough that a genuinely stalled attach recovers by rebuilding the mux and re-arming the agent.ready subscription, once, not as a storm. Local panes keep the 3s daemon-spawn budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…timeout hacks Replace the race/timeout-based cloud terminal attach with a server-driven one. The agent pane no longer waits on an agent.ready SSE (which stalled forever for large-event-log sessions like a busy orchestrator) and then opens the socket; it opens the agent terminal directly and drives its state off the control plane's existing starting/ready messages. All the client-side open-timeout hacks for cloud panes (the 3s that caused a ~4s reconnect storm, then the 30s band-aid) are removed; local daemon panes keep their 3s spawn budget. Readiness/recovery is bounded by the CP (its 20s ready deadline closes a never-ready socket) and the mint-409 "waiting" poll — no client clock. - frontend cloud-terminal-mux: always connect(kind) at construction; drop waitForAgentReady/subscribeAgentReady/upgradeToAgent. Guard resize so a hidden 0x0 attach sends NO resize (a 0x0 resize was rejected by the CP, which closed the socket — the real cause of the "distorted / wrong-width" agent TUI, since a parked pane looped on that and left the PTY at a stale width). A visible pane sends its real fitted size, so the TUI redraws. - CP: serialize agent-terminal find-or-create (OpenTerminal + the worker's EnsureWorkerAgentTerminal) with a transaction-scoped advisory lock so a browser open racing the worker before the agent starts converges on one terminal row instead of spawning a duplicate agent. Reuse of a running agent's terminal is unchanged (non-disruptive). Send reset for agent terminals too (from-0 replay is correct across per-epoch sequences). Fixes the orchestrator "Connecting…" stall and the distorted agent TUI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…banner never garbles The agent terminal is spawned at worker boot (StartAgent) with no client dimensions, so openTerminal fell back to 120x40. The coding agent draws its full-screen intro (welcome box, "What's new") once and commits that fixed-layout box art to scrollback, which never reflows. A viewer whose pane is narrower than 120 columns then sees every banner line overflow and wrap, garbling the intro permanently even though the live region repaints correctly on the client's resize. Fall back to the canonical 80x24 instead. Every realistic viewer pane is at least 80 columns wide, so the committed banner renders cleanly (under-filling at worst, never overflowing); the client's authoritative resize still expands the live UI to the full pane width. A client-provided size always wins over the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rminals reconnect state-driven When a replacement worker supersedes an epoch (resume/repair), the retire transaction updated ao_worker_connections and failed the old requests but never touched the old epoch's ao_terminal_sessions rows. Those rows stayed state=open for the full session TTL (~30m) even though nothing was behind them, so the terminal-output stream kept serving a silently-dead terminal. The only things that ever noticed were a queued keystroke or a slow ping/pong keepalive timeout. Close the retired epoch's terminal rows in the same retire transaction. The existing writeTerminalOutput loop already re-reads terminal state every tick and returns on 'closed', so the stream closes within a tick and the client re-attaches against the live epoch. No new liveness poll: detection stays driven off the authoritative state the output loop already reads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes from a live 10-worker run on project l-a (lottie-android): 1. The orchestrator invented file names when decomposing a task, so two workers were handed paths that do not exist in the repo (e.g. AnimationValueCallback.java, PathInterpolator.java) and blocked without a PR. Add an inspect-before-spawn rule: confirm a file/class/path exists in the checkout before naming it in a worker prompt. 2. Worker ao report messages were multi-paragraph, so a long one landed in the orchestrator conversation looking like an injected prompt. Constrain reports to one or two sentences (outcome + PR number, or the single blocking reason); never paste diffs, logs, or file contents. Both rules land in the orchestrator/worker system prompts and the using-ao skill; command_test asserts each. Prompts compile into ao-worker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n the state-driven mux
The coder workspace image bakes ao-worker/ao from the control-plane image so Coder's bootstrap fast path can skip the multi-megabyte PTY upload. When the CP ships a new ao-worker, the baked SHA stops matching AO_WORKER_EXPECTED_SHA256 and every spawn AND resume falls back to the slow, flaky upload until the image is rebaked by hand. Add scripts/publish-coder-workspace.sh: builds coder/Sandbox.Dockerfile against the exact control-plane image (so the baked /ao-worker and /ao bytes are identical to what the reconciler advertises), verifies the baked SHA-256s match the CP image, publishes the image (ECR by default; local-tag override for a single-host Coder daemon), and pushes a new Coder template version pinned to the immutable repo@digest. Wire it into deploy-staging.sh: when AO_CLOUD_SANDBOX_PROVIDER=coder, the rebake runs from the just-built control-plane digest before the rollout. The nodeops path is untouched. promote-production.sh stays docker-free by design, so the production rebake is documented as an explicit promote step in coder/README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…PTY upload When a coder sandbox launches/resumes and its baked ao-worker (or ao helper) does not match the SHA the control plane advertises, the bootstrap fell straight back to the slow, flaky PTY binary upload (the __AO_PREINSTALLED_MISS__ path). On a miss, the launch-only bootstrap shell now first tries a fast HTTP pull of the exact build from the control plane at AO_CLOUD_PUBLIC_URL/api/cloud/v1/worker/binary/<expected_sha> (content-addressed and unauthenticated, like /worker/bootstrap), verifies the sha256, and installs it in place. Only if that self-heal fails does the workspace emit the miss marker and fall back to the existing PTY upload. The origin is embedded as a shell literal because worker.env is not sourced yet at the preinstalled-check stage; no new sandbox env var is required (both AO_CLOUD_PUBLIC_URL and the expected hash are already available). curl is used with a wget fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A user resume stamps startup_started_at and a short interaction lease (interactive_until), but a slow coder restore can outlast that lease. When coder then auto-stops the still-starting box for idleness, the reconciler accepted the external idle stop and re-paused the sandbox mid-resume, so the user saw "Connected" with no terminal that flipped back to "resuming" and stalled. Guard the accept-idle-stop branch in reconcileSandbox with a new startingUp(record) check: while startup_started_at is set and the startup deadline has not elapsed, refuse the provider idle stop and fall through to restore, keeping the bring-up alive. The guard is bounded by StartupTimeout, so a bring-up that never converges ages out and is paused/failed normally rather than holding compute awake forever. This complements the existing interactive_until bump on terminal attach/input (IssueTerminalTicket, queueTerminalRequest, RefreshTerminalInteraction) and AcceptSandboxProviderPause's atomic interactive_until re-check, which already fence the "user attaches while the reconciler is mid-accept" race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the one conflict in cloud-terminal-mux.ts (+ its test) toward #5329's state-driven attach. main (#5350 relay) still carries the agent-ready SSE wait / upgradeToAgent path that #5329 deliberately removed; both sides already have the 410 TERMINAL_SESSION_EXITED handling so that converges. The renderer callers use none of the dropped options (only the test did), so taking #5329's mux + test keeps the tree consistent. #5350's relay is CP/worker-side and merged cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lure path Command.Cleanup is only declared, never assigned, so it is always nil; every other call site guards with if command.Cleanup != nil, but main.go:339 and :349 called it unguarded. On the checkout-failure path this PR adds (cancel() -> ctx.Done()), the select fired the nil func -> worker SIGSEGV crash-loop (the recorded main.go:349 RCA) on exactly the failure this change means to handle gracefully. Guard both call sites to match the established pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator
|
Blocking issue found and fixed
During that checkout window:
There is no replay of those failed resize/input requests after checkout completes. This makes early terminal interaction unreliable and can leave the agent at the fallback 80×24 size. |
Pritom14
pushed a commit
that referenced
this pull request
Sep 17, 2026
…al-init error path Command.Cleanup is only declared, never assigned, so it is always nil. The ensureAgentTerminal error path called it unguarded, so an agent-terminal init failure would nil-panic the worker into a crash-loop. Guard it (matching the pattern used at the other call sites and in #5329). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mohakchakraborty2004
self-requested a review
September 17, 2026 14:48
mohakchakraborty2004
approved these changes
Sep 17, 2026
Pritom14
added a commit
that referenced
this pull request
Sep 17, 2026
…t, first-click delete, and worker self-heal (#5518) (#5519) * feat(cloud): session transcript capture + restore endpoints Control-plane side of cloud session delete + restore. - Migration 00037: ao_session_transcripts (org_id, session_id PK, FK to ao_sessions ON DELETE CASCADE) with tenant + service RLS policies, to durably hold the harness transcript blob and preserved git ref a deleted sandbox would otherwise lose. - Migration 00038: scope ao_projects repository-URL uniqueness to live (archived_at IS NULL) projects so a repo can be re-registered after its project is archived/deleted. - Store: PutSessionTranscript (UPSERT) / GetSessionTranscript (ErrNotFound when absent), worker-scoped via withOrg; RestoreSession un-terminates the same session_id and re-arms its sandbox for reconciliation in one tenant tx. - httpapi: TranscriptStore Put/Get abstraction (Postgres impl behind an adapter, swappable for S3); PUT/GET /worker/transcript (worker:connect, base64 body); POST /orgs/{orgId}/sessions/{sessionId}/restore -> 202. - Handler unit tests: transcript PUT/GET happy path, 404, scope + base64 guards, restore un-terminate + not-found + bad-UUID. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): delete cloud projects + restore terminated cloud sessions Cloud-only DELETE/RESTORE UI, gated so local sessions/projects are untouched: - cloud-cp client: add restoreSession(orgId, sessionId) -> POST /orgs/{orgId}/sessions/{sessionId}/restore (202) with a CloudCpRestoreSessionResponse type mirroring the delete/resume shape. (deleteProject/deleteSession already existed and are reused.) - Project delete: _shell.tsx removeProject now routes CLOUD_PROJECT_KIND projects to cloudClient.deleteProject and invalidates the cloud projects/sessions queries, instead of hitting the local daemon endpoint (fixes the mis-routed cloud project delete). Local projects keep the existing path. - Session restore: useRestoreSession detects a cloud session from the cloud-sessions cache and calls restoreSession (mirroring the non-hook cloud pattern in useTerminateSession), then invalidates the cloud + workspace queries so the session shows as reviving. All existing restore entry points (archive card, terminal, notifications) now work for cloud sessions; local restore is unchanged. - Session delete copy: SessionTerminationPopover shows restore-aware copy for cloud sessions; the Sidebar remove-project dialog warns cloud deletes remove all sessions. - i18n: add termination.bodyCloud/bodyCloudNamed and shell.removeCloudProjectBody to all locale catalogs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): in-sandbox capture/rehydrate for session delete + restore Add durable-restore checkpointing to the in-sandbox worker so a deleted cloud session can be rebuilt on a fresh sandbox with --resume working and uncommitted work returned. Capture (ao-worker): a throttled (~15s) checkpoint loop, started once the checkout and git credential helper are in place, locates the harness transcript (Claude first-class via CLAUDE_CONFIG_DIR/projects glob; Codex best-effort via CODEX_HOME rollout; others extensible), commits any uncommitted work to refs/ao/preserved/<session-id> through a temp index (no touch to the agent's index/worktree) and force-pushes it to origin via the worker's repo-local credential helper, then PUTs {agentSessionId, harness, transcript(base64), preservedGitRef} to /worker/transcript. Change-detection skips no-op pushes/PUTs; every step is best-effort. Rehydrate (ao-worker): on boot, after checkout and before the agent is built (gated via rehydrateDone), GET /worker/transcript; on 200 fetch and cherry-pick --no-commit the preserved ref onto the checkout and write the transcript to the harness resume path (Claude project dir encoded as [^a-zA-Z0-9]->'-'); 404 is the normal fresh-session no-op. Reconciler: verified the deleted->running restore already re-provisions a fresh sandbox (observed 'deleted', provider id NULL); added a minimal guard so a 'terminated' sandbox with a running desired-state also un-parks and re-provisions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(cloud/worker): drive durable-restore capture off the Stop hook, not a timer Replace the 15s checkpoint ticker with an event trigger. The worker serves a unix socket (ao-checkpoint.sock, AO_CHECKPOINT_SOCKET); the harness Stop hook (ao hooks <harness> stop) pokes it on every turn completion, and the bridge runs one change-detected checkpoint per poke. A single-slot trigger coalesces bursts; the capture is fire-and-forget so a completed turn never waits on git or the network. No timers or polling. * fix(cloud): renumber session_restore migration 00037 -> 00039 00037 collided with 00037_turns_allow_queued_fanin (already deployed to staging on 09-11). goose keys on the numeric version, so the duplicate 37 silently skipped session_restore and ao_session_transcripts was never created, leaving capture and restore no-ops against a missing table. A fresh migrate against the combined image would instead hard-fail on the duplicate version. Renumber to the next free slot (00039); 00038 keeps the active-project unique index. Matches goose versions 38/39 now recorded on staging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): reconnect terminal after restore and make delete land on first click Two renderer bugs in the cloud delete/restore flow. Restore left the terminal stuck on the dead epoch (connected but TERMINAL ENDED, cannot type). A cloud terminal is keyed only on the unchanged session id and reuses a per-session mux factory whose replay cursor never resets, so after a restore re-provisions a fresh sandbox under the same id but a NEW worker epoch, the pane clung to the exited terminal and dialed the old replay position that the new epoch never sends. Fix: a per-session terminal-reset nonce (terminal-reset-store) that useRestoreSession bumps on a successful cloud restore, folded into the terminal cache key and the cloud mux factory key so a restore behaves like a fresh open: new pane, new factory closure, cursor at 0, re-mint against the new epoch. The nonce rides the cache key only (not the generation field) so the workspace reconcile loop still compares the raw terminalGeneration and does not dispose the freshly mounted terminal. Delete needed two or three clicks. The trash trigger only toggled the confirm popover (a second tap dismissed it), and useTerminateSession had no optimistic update: its one cache write targeted workspaceQueryKey, which does not drive the cloud board (recomputed from cloudSessionsQueryKey), so a cloud card sat still until the slow CP round trip and users re-clicked. Fix: optimistic onMutate marks the session terminated in BOTH the workspace and every cloud sessions cache so the card archives on the click, with onError rollback; and each trash trigger forces the confirm open instead of toggling it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): key the cloud terminal on the worker epoch so it reconnects on resume The recurring 'connected but TERMINAL ENDED / can't type' on cloud sessions is a terminal that never re-mints against a NEW worker epoch. When a paused session resumes (idle-pause wake) the CP starts a fresh worker at a new epoch with a new open agent terminal and the agent runs fine, but the renderer had NO epoch signal (terminalGeneration was never set) so it clung to the dead epoch's exited terminal. Nothing attached, so the CP idle-paused the session again ~3 min later -> reopen -> new epoch -> infinite loop. The earlier restore nonce covered only delete->restore, not resume. Surface the worker epoch (MAX agent-terminal worker_epoch) on the session DTO and fold it into terminalGeneration. The workspace reconcile loop already disposes and remounts a worker terminal when terminalGeneration changes, so ANY new epoch (resume, restore, re-provision, crash-recovery) now re-mints against the live agent. It is stable within an epoch, so it does not churn the pane between resumes. CP: domain.Session.WorkerEpoch + sessionSelect subquery + scanSession + the create INSERT RETURNING + sessionResponse.workerEpoch. Renderer: CloudCpSession .workerEpoch + toCloudWorkspaceSession sets terminalGeneration from it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): terminate the session on delete so it archives on the first click deleteSession only set the sandbox desired_state to 'deleted' and left the session row active (is_terminated stayed false), relying on the reconciler to terminate it later. The idle scanner races that by resetting desired_state back to 'paused' (verified: a deleted coder session's sandbox went straight back to paused), so the session was never terminated -> the board (which archives by is_terminated) kept showing the card -> the 5s poll re-added it -> the user had to click delete repeatedly. Restore, by contrast, flips is_terminated directly and lands in one click. Add TerminateSession (the exact inverse of RestoreSession): set is_terminated =true + activity_state='exited' AND request the sandbox teardown, atomically in one tenant transaction. Wire deleteSession to it. Now a delete archives the session immediately and the idle scanner cannot undo it. CP-only; no worker or migration change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): run harness hooks from the self-healed ao helper, not the baked path (#5518 pt1) The claude/codex/cursor activity hooks invoked a hardcoded /usr/local/bin/ao. The worker self-heals the current helper to <dataDir>/bin/ao and only shadows it on PATH, but the hooks used the absolute baked path, so on any deploy that changed the ao binary the Stop hook ran the STALE baked helper (no pokeCheckpoint) and durable-restore capture silently stopped until the template was rebaked. This is the recurring stale-bake capture breakage. Make a stale bake a non-event for hooks: - healHelper now ALWAYS keeps <dataDir>/bin/ao current (copies the baked helper when it already matches, downloads when stale) so the healed path is never empty or stale (new copyExecutable, atomic temp+rename). - Hooks resolve their binary via hookHelperPath(dataDir): the healed helper when present, else the baked path as a last resort. Threaded through the claude, codex, and cursor installers. - Hook removal matches on the ' hooks claude-code ' suffix instead of a fixed binary prefix, so a hook installed with any prior path is still cleaned up. Provider-agnostic; the worker binary changes, so a deploy carrying this must rebake both templates once (via deploy-staging.sh) — after which future worker changes self-heal without a rebake. Coder PTY-stream fallback reliability (#5518 pt2) is separate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud/worker): nil-guard agentCommand.Cleanup on the agent-terminal-init error path Command.Cleanup is only declared, never assigned, so it is always nil. The ensureAgentTerminal error path called it unguarded, so an agent-terminal init failure would nil-panic the worker into a crash-loop. Guard it (matching the pattern used at the other call sites and in #5329). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): show Connecting while a restored/resuming session's fresh box comes up Restoring a cloud session re-provisions a FRESH sandbox/container that rehydrates the saved context, which takes ~10s+. During that window the pane lingered on the previous box's dead terminal: typing went nowhere, then it flashed 'process exited', then finally 'Connecting'. The isRestoring flag cleared as soon as the restore API returned (near-instant), well before the new worker connected. Drive the connecting state off the session runtime instead: whenever a cloud session is active but its worker is not yet connected (and the terminal has not attached), show a calm 'Connecting…' overlay and suppress the dead terminal and the process-exited strip. It covers restore, resume-from-idle-pause, and fresh provision uniformly, and lifts the instant the new terminal attaches. Cloud only; local terminals unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): simplify the box-coming-up overlay to just 'Connecting…' Drop the 'saved context is being restored' subtitle (and its i18n key); the single Connecting label is what is wanted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): show Connecting immediately on restore, before the runtimeConnected poll The box-coming-up overlay only keyed on the polled runtimeConnected, which lags up to the 5s cloud poll. A board-initiated restore therefore left a window where the previous box's dead terminal still showed (unresponsive typing, then process exited) before the poll flipped and Connecting appeared. Add an immediate, synchronous reconnecting signal: the terminal-reset store's bump (already called by useRestoreSession) now marks the session reconnecting, the terminal clears it the instant it attaches, and isBoxComingUp ORs it with the runtimeConnected fallback. Connecting now shows the moment restore is clicked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud/worker): add a coarse periodic safety-net checkpoint (~25s) alongside the Stop hook Capture is event-driven off the Stop hook (turn completion), but a delete/restore in the MIDDLE of a turn (before any Stop fires) had nothing to restore from and re-ran from the task prompt. Add a coarse 25s periodic poke to the same coalescing trigger so in-progress work is captured. It is not the old 15s polling: the Stop hook is still primary, the checkpoint is change-detected (an unchanged tick is a no-op), and the trigger is a single coalescing entry point (poke) any producer can call. A future move to file-modifying tool-use events can lengthen or drop this timer without touching the bridge. Interval is a var so tests can shorten it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): don't re-cover a live terminal with Connecting on a post-attach flicker The box-coming-up overlay gated on state !== 'attached', so once a restored terminal attached, a transient reconnect (e.g. on the first keystroke, while the polled runtimeConnected still lagged false) flipped state off 'attached' and pulled the full Connecting cover back over the live terminal — the user typed, hit enter, and it went to Connecting. Gate on !hasAttached instead: once the pane has ever attached, the cover never returns; a same-epoch reconnect uses the subtle reattaching banner, and a real restore mounts a fresh pane (hasAttached resets) so the cover still shows until the new box attaches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): keep Connecting until the WORKER connects, not just the PTY attaches A restored session's terminal attaches to the sandbox PTY and replays the transcript as soon as the box is running, but the worker may still be coming up (or stuck). Gating the Connecting cover on PTY attach (!hasAttached) therefore lifted it too early, dropping the user onto a terminal that shows content but cannot accept input (the 'Restoring agent' state, runtimeConnected=false). Gate on the worker being connected instead: latch workerHasConnected when runtimeConnected first turns true, show Connecting until then, and never re-cover once the worker has connected (a transient reconnect uses the subtle banner). A restore mounts a fresh pane so the latch resets. Matches 'do not present the terminal until the box is up and the worker connected'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): make session restore idempotent so a rapid second restore can't cross the mapping RestoreSession un-terminated the session and re-armed the sandbox unconditionally, clearing the reconcile lease (reconcile_lease_owner='', reconcile_lease_until=NULL) with no guard. The lease is the reconciler's only exclusivity mechanism, so a second restore fired while the first restore's provision was still in flight fenced that provision and let a second one start concurrently for the same session — crossing the session<->sandbox<->workspace mapping (worker binds to the wrong box, live_workers=0, stuck 'Restoring agent'). Two changes: - Gate the un-terminate on is_terminated=true (with the row lock it takes). Once the first restore un-terminates the session, a rapid second restore matches 0 rows; return an idempotent no-op for an already-active session (distinguished from a missing one -> ErrNotFound) BEFORE touching the sandbox. - Stop clearing the reconcile lease. An expired/free lease is already claimable (ClaimSandboxes keys on reconcile_lease_until, not owner); a live lease must be left to its owner or to expire, never stomped. Verified against the staging schema (rolled-back tx): first restore un-terminates (1 row) and re-arms while PRESERVING a live lease; a second restore on the active session is a no-op (0 rows); a missing id is not-found. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud/terminal): gate restore 'Connecting' on a new worker epoch, not runtimeConnected A rapid delete->restore left the terminal showing the OLD box for ~10s: it displayed content but could not accept input, then flipped to Connecting, then finally became usable. Root cause (confirmed from CP logs + ao_terminal_sessions): on restore the fresh sandbox provisions and its worker only creates its agent terminal ~10-13s later under a NEW worker epoch. In that gap the only attachable terminal is the OLD epoch's — content replays but its box is being torn down, so typing does nothing. The previous cover keyed on runtimeConnected, which the OLD worker's lingering connection keeps true, so the cover lifted early onto the dead terminal. Fix: baseline the worker epoch at restore time (captured in useRestoreSession from the session DTO's terminalGeneration = MAX(agent terminal worker_epoch)) and show 'Connecting' until the current epoch advances PAST that baseline — i.e. the fresh worker's terminal actually exists. The epoch only moves forward, so the cover spans the whole boot gap and never flickers back once the new epoch attaches. - terminal-reset-store: bump(sessionId, currentEpoch) records baselineEpoch; markConnected clears reconnecting; drop the runtimeConnected-based markAttached. - useRestoreSession: capture the pre-restore epoch and pass it to bump. - TerminalPane: clear reconnecting when currentEpoch > baselineEpoch; isBoxComingUp = cloud && isReconnecting (epoch-gated), replacing the runtimeConnected latch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: gofmt resource_handlers.go and mock useCloudOrg in shell tests - Run gofmt on cloud/internal/httpapi/resource_handlers.go to fix struct field alignment (CI cloud-build-test gate). - Add missing vi.mock for useCloudOrg in shell-new-session-shortcut.test.tsx so ShellLayout no longer calls the real useQuery without a QueryClientProvider, which was timing out all 43 tests. * fix(cloud): scope agent-terminal exit detection to the latest epoch (nodeops restore false-410) IssueTerminalTicket returned TERMINAL_SESSION_EXITED (410) for restored nodeops sessions, so the browser showed 'The coding-agent terminal has exited' with a green Connected badge instead of the Connecting cover, even though the restore succeeded and the agent was alive. Root cause: exit detection fired when ANY agent terminal was closed/failed. A restore provisions a fresh box under a NEW worker epoch and closes the OLD epoch's terminal, so an old 'closed' row is expected. The prior guard tried to suppress that by requiring an open terminal WITH a live ao_worker_connections row, but nodeops sessions do not populate ao_worker_connections, so the guard false-fired on every nodeops restore (coder populates it, so coder was unaffected). Fix: scope the closed/failed check to MAX(worker_epoch). Only the latest epoch's agent terminal state signals a real exit; a superseded old epoch's 'closed' row is ignored. Provider agnostic (no ao_worker_connections dependency) and applied to both IssueTerminalTicket exit-detection sites. Verified against staging: for the reported session the old logic returns exited true (the 410), the new logic returns false. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): remove unused workspace status binding * fix(cloud): don't false-fire terminal-exited 410 when the latest epoch has a live terminal The MAX(worker_epoch) exit-detection reported 'agent exited' whenever a closed/failed agent terminal existed at the latest epoch. But a worker whose first agent-terminal attempt fails and then reopens leaves a stale 'failed' row BESIDE a live 'open' row at the SAME epoch (seen on a fresh nodeops orchestrator spawn: epoch 5556 had both). The EXISTS matched the failed row and returned a false 410, so the browser showed 'The coding-agent terminal has exited' on a perfectly live terminal. Fix: only count a closed/failed terminal as an exit when its epoch has NO open/opening agent-terminal sibling. Verified against staging: for the affected session the old predicate returns exited=t, the new one returns exited=f. Applied to both IssueTerminalTicket exit-detection sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud/terminal): retry a transient 410 instead of a permanent exited banner The mux treated any 410 TERMINAL_SESSION_EXITED from the ticket mint as a permanent agent exit: it showed 'The coding-agent terminal has exited' and stopped, never retrying. But on nodeops a worker's first agent-terminal attempt can fail then reopen at the same epoch within ~40s, and the control plane correctly reports that transient gap as a 410. So a perfectly live terminal got a permanent banner the browser never recovered from (a reload did not re-mint). Give the 410 the same bounded readiness poll the 409 WORKER_UNAVAILABLE already gets: retry as 'waiting' for up to ~60s (60 x the 1s cloud poll), reset on any successful mint, and only surface the permanent banner once the exit persists past that window (a genuine exit). No new timer: reuses the existing poll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revert "fix(cloud/terminal): retry a transient 410 instead of a permanent exited banner" This reverts commit c4844a7. * fix(cloud): gate browser agent ticket on a live agent terminal, not just a worker connection Root cause of the false 'coding-agent terminal has exited' banner on a fresh nodeops spawn: the worker connection registers on bootstrap, but the coding agent only starts after the repository checkout (~40s later), when the worker creates the agent terminal. IssueTerminalTicket issued a browser agent ticket on the worker connection alone, so the browser attached in that gap and find-or-created an agent terminal that no agent served; it timed out to 'failed', which then poisoned the next mint as a 410 exit. Gate the browser agent ticket on an already-live (opening/open, unexpired) agent terminal at the current epoch. The worker creates that terminal independently via EnsureWorkerAgentTerminal at agent start, so there is no deadlock: until then the browser gets ErrWorkerUnavailable (409) and shows 'Connecting', exactly as during provisioning, instead of attaching to a doomed terminal. CP-only, no worker change. The workspace shell terminal stays available earlier (kind='agent' only). Verified against staging: with only a failed terminal at the epoch the gate is closed (409); once the worker's open terminal exists the gate opens; an expired open terminal keeps the gate closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: merge test <test@ao.local> Co-authored-by: t <t@ao.local> Co-authored-by: mohak <c.mohak2004@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The cloud fixes that are deployed and tested on the staging control plane (task-def :102) but were not part of #5023. This lands exactly the deployed CP state on top of #5023.
Commits (all cloud/ or cloud-gated frontend)
Orchestration fixes (from a live 10-worker run on project l-a, lottie-android)
Two agent-guidance bugs surfaced by the run, fixed in the orchestrator/worker system prompts and the using-ao skill (both compile into ao-worker):
Inspect before spawn. The orchestrator invented file names when decomposing a task, so two workers were handed paths that do not exist in the repo (AnimationValueCallback.java, PathInterpolator.java) and blocked with no PR. The orchestrator prompt now requires confirming a file, class, or path exists in the checkout before naming it in a worker prompt.
Terse worker reports. Worker ao report messages were multi-paragraph, so a long one landed in the orchestrator conversation looking like an injected prompt. Reports are now constrained to one or two sentences (outcome plus PR number, or the single blocking reason) with no diffs, logs, or file contents.
command_test asserts both rules. Neither touches local-app behavior.
Testing
Deployed on staging as ao-cloud-staging-api:102 and exercised end to end (coder + nodeops). cloud module gofmt, build, vet, and test are green; no cloud-client codegen drift. The orchestration fixes ride the ao-worker binary, so a CP redeploy propagates them to workers via self-update.
Notes
Follow-up to #5023.
Resume reliability fixes (added)
Four cloud resume/terminal reliability fixes, cherry-picked as clean commits:
Note: the backend false-positive 410 fix is a separate PR (#5477) because it corrects #5350 code that lives in main, not in this branch.