Skip to content

feat: wake a cluster agent's sandbox from the console instead of explaining - #1077

Merged
zfy0701 merged 4 commits into
mainfrom
feat/wake-sandbox-from-console
Aug 16, 2026
Merged

feat: wake a cluster agent's sandbox from the console instead of explaining#1077
zfy0701 merged 4 commits into
mainfrom
feat/wake-sandbox-from-console

Conversation

@zfy0701

@zfy0701 zfy0701 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Files of a cluster agent live on its pod's volume and are only readable through a running sandbox, so the console now wakes the sandbox instead of explaining why it cannot read — an explicit POST /agents/:id/wake, pressed once by the Files surfaces and followed by a polled read. A GET never wakes anything.

Closes #1070.

Decision (from the issue)

  • Wake, not copy: neither the sleeping-sandbox state nor the pool's "no holder" window is helped by better prose.
  • The wake is explicit and debounced, authorized like the other agent writes, and resolved through the placement resolver's dispatch answer — a set agent nobody serves reaches a live member, which claims it the way a turn would (Gap 1 is covered by the same press).
  • Read-only PVC mounts were considered and rejected in the issue; nothing here changes storage.

API

POST /orgs/:orgId/agents/:id/wake (wakeAgent, tagged Agents)

  • Viewers refused (403); canEdit on the agent; a restricted agent the caller cannot see is 404.
  • Resolves placementResolver.dispatchDaemon(agent); 503 agent has no live daemon when nothing can be reached.
  • Sends agent/wake only to a daemon advertising agent-wake-v1; otherwise 200 { state: 'unsupported' } — a machine-placed agent, or a daemon that runs no sandboxes, gets no frame at all.
  • 202 { state: 'running' | 'starting' } with what the daemon observed.
  • Debounced per agent (AgentWakeCoordinator): a wake in flight is joined; one settled within 30 s answers repeat callers from its result; a failure is not remembered.
  • The frame is org-scoped explicitly, because the dispatch member's connection has no entry for an agent it does not hold yet.

Daemon

  • New frame pair agent/wakeagent/wake/ok (AgentWakeReq / AgentWakeOk, state: running | starting | unsupported) and the agent-wake-v1 feature, advertised only under --k8s.
  • createAgentWaker: if the member does not hold the agent, claim the duty (claimDutyForTrigger, the same rendezvous a relay trigger uses); if the sandbox channel is bound already, running; else kick off plane.ensureChannel (sandbox → Running + shim bind, no host, no ACP session), coalesced per agent, and answer starting. A lost claim refuses with unknown-agent (BAD_PAYLOAD), which the CP maps to 503.
  • The bind is what workspaceFilesFor serves on, so a woken sandbox is exactly a readable one; the launch it records gets a full idle window before the sweep may suspend it again.
  • Memory reader deliberately unchanged — see below.

Console

  • useSandboxWake(agentId, read, retry, sandboxed): presses the wake once when the root read refuses with WORKSPACE_SANDBOX_UNAVAILABLE (or on open for an agent placed on the pool), then re-issues the read with backoff (2 s → 10 s) until it answers or 90 s pass. Then the existing not-available copy returns with a Start button that presses again; unsupported keeps the copy without one; a 403/404 on the press gives up at once, any other press failure lets the read decide.
  • Shared by the agent page's file browser (WorkspaceFiles, which now also gets the sandbox-specific copy) and the dock's Files panel; the two notices live in SandboxWakeNotice so the copy cannot drift. The "starting" line is calm (spinner, secondary text), not an error. One responsive tree, so mobile follows.

Memory location (issue Gap 2, question 2)

Verified rather than asserted: managed memory (<agent.dir>/memory/, channels/) is written by the daemon's own memory provider — the memory MCP tools and session-start injection run in the daemon process — under the member's state root, not on the sandbox volume. Under --k8s that root is the pod's ephemeral disk, so the content is member-local and a duty move loses it; native memory only moves under a private runtime HOME for the local OS sandbox, not for the cluster one. That is the storage gap #955 M1 already lists ("managed agent memory … durable user content with no cloud home"). Per the issue's own guidance this PR does not paper over it: the memory reader still reads agent.dir (a woken sandbox has no memory to read through), and the Memory tab is not wired to the wake, since its reads never refuse as asleep. Moving memory onto the agent's volume (or another durable home) is a separate change; once it lands, the memory reader gets the same one-resolution sandbox-unavailable refusal and the Memory tab takes the same hook.

Test plan

  • packages/protocol — typecheck, vitest (frame registration).
  • packages/daemontest/cp/agent-wake.test.ts (unsupported without a plane, running vs starting, one bind per burst, claim-then-wake, lost claim refuses, failed bind forgotten), client-dispatch (agent/wakeagent/wake/ok, unsupported without a waker, BAD_PAYLOAD reason), daemon-k8s-mode (feature advertised under --k8s), daemon-workspace-git-review-feature (not advertised locally). Full daemon suite: only the pre-existing environment failures (shim e2e / acp-matrix), verified identical on main.
  • packages/control-planetest:unit (agentWake.test.ts: join in flight, 30 s window, per-agent, failure not cached) and test:int agents.wake.route.test.ts (forwards to the serving pool member org-scoped, no-holder → live member, no member → 503, machine placement → 200 unsupported with nothing sent, debounce, edge failure → 503 without caching, viewer 403 / restricted 404).
  • packages/websandbox-wake.test.tsx (one press, backoff ladder, idle on ready, give-up after the bound with Start re-pressing, unsupported, 403 vs 503, sandboxed-on-open), FilesPanel.test.tsx (wake pressed once on the asleep 503, calm starting line, terminal copy without Start on unsupported, no press for a plain offline 503), WorkspaceFiles.test.tsx.
  • pnpm typecheck for protocol / daemon / control-plane / web, pnpm lint, pnpm format:check.
  • On a cluster: open the Files tab of a pool agent whose pod is suspended and watch it come back without a turn; open it during a lease-lapse window and confirm a member claims it.

Follow-ups (not in this PR)

@agentconnect-md-test agentconnect-md-test 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.

Requesting changes for one lifecycle regression in the session console. The dock intentionally keeps every panel mounted while only the selected panel is visible, and Sessions is the default tab. The new Files hook therefore wakes a sleeping cluster sandbox while the user is merely opening/reading a session, before they select Files. That makes the pod start implicit and can create avoidable cluster churn; the wake should be gated on the Files tab actually becoming active.

I verified the checkout as the trusted synthetic merge of dc90bfa3b4acdadced85b2a69de54f08cfdf255c and f935fd3c7163007288a37b3e017f5a56a3fec7e2, inspected the complete 26-file patch and relevant workspace/placement lifecycle code, and found no trailing-whitespace errors. I could not rerun the TypeScript/Vitest checks because this isolated checkout has no installed dependencies and the sandbox denies /dev/null, which prevents the Git/pnpm wrappers from starting.

sent by review-bot (Codex · gpt-5.6-sol) · open in session

const { dirs, root, expanded, toggleDir, loadMoreDir } = useWorkspaceTree(agentId, sessionId, refreshTick + wakeTick)
const { git, outcome, primaryBranch } = useWorkspaceGitStatus(agentId, sessionId, refreshTick + wakeTick)
const retryRoot = useCallback(() => setWakeTick((tick) => tick + 1), [])
const wake = useSandboxWake(agentId, workspaceRootReadState(root), retryRoot)

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.

FilesPanel is mounted even while hidden: SessionDetailView renders every DockPanel, defaults to Sessions, and DockPanel only applies display: none. Consequently this hook sees the hidden root read’s asleep refusal and immediately POSTs the wake just from opening a session page. That turns the intended explicit Files action into an implicit pod start. Please pass the tab’s active state into this panel/hook (or otherwise defer the automatic wake until Files is selected) while keeping the panel mounted if its status and UI state need to survive tab changes.

…box up without a turn

A cluster agent's files live on its pod's volume and are only readable through
a running sandbox, so a console read that refuses with `sandbox-unavailable`
needs an explicit way to start it — a GET must never wake anything (#1070).

`agent/wake` (C→D REQ → `agent/wake/ok`) is the first half of what a turn does
before it prompts: claim the duty if this member does not hold it (the same
activation rendezvous a relay trigger uses), then bring the sandbox to Running
and bind its shim channel — and deliberately not the second half: no host is
started and no ACP session opened. The daemon answers what it observed, never
a promise: `running` when the channel is already bound, `starting` when the
bind was kicked off (coalesced per agent, so a burst of wakes costs one resume),
`unsupported` on a daemon that runs no sandboxes. Only a `--k8s` daemon
advertises `agent-wake-v1`; elsewhere there is nothing to wake and the control
plane answers without sending a frame the daemon would ignore.

The memory reader is deliberately left as it is: managed memory (`memory/`,
`channels/`) is written by the daemon's own memory provider under the member's
state root, not on the sandbox volume, so reading it "through the sandbox"
would read nothing. That is the storage gap #955 M1 already records, and it
wants its own fix rather than a nicer refusal here.
… resolved through dispatch

The console's "start this agent's sandbox" (#1070). Authorized like every other
agent write (viewers refused, `canEdit` on the agent), the route resolves the
DISPATCH daemon rather than the serving one: a pool agent whose lease lapsed is
served by nobody for one horizon, and the wake is exactly the trigger that gives
it a holder again — it reaches a live member, which claims the agent the way a
turn would. The frame is org-scoped explicitly, since that member's connection
has no entry for an agent it does not hold yet.

Answers with what the daemon observed: 202 `running` / `starting` (poll the
workspace read), 200 `unsupported` when the daemon does not advertise the wake
(a machine-placed agent, or a daemon that runs no sandboxes) — nothing is sent
in that case. Wakes are debounced per agent in `AgentWakeCoordinator`: one in
flight is joined by every caller, and one settled within 30 s is answered from
its result, so a Files and a Memory tab opened together cost the daemon one
frame. A failed wake is not remembered.
When the workspace root read refuses with the sleeping-sandbox code (or the
agent is known to run on the pool and the tab is opened), the panel presses the
wake once, shows a calm "Starting the agent's sandbox…" line instead of an
error, and re-issues the read with backoff (2 s → 10 s) until it answers or
90 s pass. Then the existing not-available copy returns with a Start button
that presses the wake again; a daemon with nothing to wake (`unsupported`)
keeps the copy without one. A refused press (403/404) gives up at once; any
other failure lets the read decide, since the wake may simply have raced.

`useSandboxWake` is one hook shared by the agent page's file browser and the
dock's Files panel, with `SandboxWakeNotice` carrying the two shared notices so
the copy cannot drift. The Memory tab is not wired: its reads never refuse as
asleep, because managed memory lives on the member's state root rather than
the sandbox volume (the storage gap noted in the daemon commit).
The session dock keeps every panel mounted (a hidden one is `display:none`),
so the Files panel's hook saw its hidden root read refuse as asleep and
pressed the wake just from opening a session page — a pod start as a side
effect of a page whose reader had not asked for the files.

`useSandboxWake` takes an `active` option: while false it neither presses nor
polls, and a poll interrupted by hiding resumes on the next activation. The
dock's Files panel passes its tab's selected state through; the agent page's
file browser is on screen whenever it is mounted and keeps the default. The
Start button still presses when the panel is visible.
@zfy0701

zfy0701 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the dock finding: the session dock keeps every panel mounted, so a hidden Files tab could press the wake just from opening a session page. useSandboxWake now takes an active option — while false it neither presses nor polls (a poll interrupted by hiding resumes on the next activation) — and FilesPanel passes its tab's selected state through from SessionDetailView. The agent page's file browser is on screen whenever mounted, so it keeps the default. Tests added: hook (hidden never presses/polls; visible presses once and polls; hide mid-poll stops, show resumes) and FilesPanel (hidden tab does not wake; selecting it wakes once, re-selecting does not). Rebased on main; web tests, typecheck, lint and format:check green.

@zfy0701
zfy0701 force-pushed the feat/wake-sandbox-from-console branch from f935fd3 to 5feda2b Compare August 16, 2026 04:26

@agentconnect-md-test agentconnect-md-test 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.

Approved. The prior lifecycle blocker is resolved: SessionDetailView now passes the Files tab’s selected state into FilesPanel, and useSandboxWake gates both the automatic POST and polling on that state. Because the dock keeps hidden panels mounted, this prevents merely opening a session from waking a pod while preserving the panel’s status and UI state; the added hook and panel tests cover hidden→visible activation and hiding during polling.

I verified the checkout at exact head 5feda2b666919554a8713358c77dfe10e0d95820, inspected the fix commit and the complete 27-file base-to-head patch, confirmed every changed working file matches the head tree, checked all useSandboxWake callers after the options-signature change, and found no trailing-whitespace errors. I could not independently rerun TypeScript/Vitest because this isolated checkout has no installed dependencies and the sandbox denies /dev/null, which prevents the Git/pnpm wrappers from starting.

sent by review-bot (Codex · gpt-5.6-sol) · open in session

@agentconnect-md-test

Copy link
Copy Markdown
Contributor

Confirmed in 5feda2b: the active gating fixes the hidden-panel wake, and the added tests cover activation and paused polling. I approved this revision.

sent by review-bot (Codex · gpt-5.6-sol) · open in session

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.

A pool agent that is not currently served reads as an offline machine, and Memory has no not-running state at all

1 participant