Skip to content

feat(ca): flat lifecycle commands — list, create, ssh, wake, sleep, delete - #1064

Merged
codyde merged 5 commits into
masterfrom
cody/ca-lifecycle-commands
Aug 9, 2026
Merged

feat(ca): flat lifecycle commands — list, create, ssh, wake, sleep, delete#1064
codyde merged 5 commits into
masterfrom
cody/ca-lifecycle-commands

Conversation

@codyde

@codyde codyde commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Adds six flat lifecycle subcommands to railway ca.

Command Enables
ca list (ls) Every agent you own, across all projects. -e narrows, --all includes teammates', --json for scripts
ca create (new) An agent VM without connecting to it. --variable, --env-file, --no-wait, --json
ca ssh (connect) Attach to an agent's durable session. -- bash for a plain shell, --session to pick one, --keep-awake to skip sleep-on-disconnect
ca wake Wake a sleeping agent, waiting for RUNNING
ca sleep Stop the compute bill, keep the disk. --all for every running agent you own
ca delete (rm) Destroy an agent and its disk, with confirmation (-y to skip)

Agents are addressed by name or id. With neither, commands use this directory's agent, then your only live one, then list the candidates.

Structure

  • src/controllers/cloud_agent.rs — agent model normalised across the generated query types, the operations, the shared resolver, and config pointer bookkeeping.
  • src/commands/cloud_agent/lifecycle.rs — the six verbs.
  • ca create makes a VM only; ca ssh never creates. ca start and railway code remain the create-and-launch path.
  • list is account-wide via myCloudAgents, so it works in an unlinked directory.
  • railway code --rm, --keep-awake and --new keep working; --rm points at ca delete.

Telemetry

Extends #1063's cloud_agent::telemetry to the new verbs, which otherwise had only the generic per-dispatch event that can't say which verb ran.

  • One event per verb: cli_list, cli_create, cli_ssh, cli_wake, cli_sleep, cli_delete, with _failed variants and real duration.
  • Detail slugs: cli_ssh_attach / cli_ssh_new_session / cli_ssh_command, and cli_sleep_all.
  • The cli_ prefix keeps these separate from the TUI's agent_sleep/agent_wake/agent_delete — same mutations, different surface. A test pins the separation.
  • Fixed slugs only, no free text.

Fixes

  • Sleeping an agent could lose recent work. cloudAgentSleep snapshots the disk without quiescing the guest, so pages still dirty in its page cache were absent after a wake. Sleep now runs sync over a side ssh first, paired inside controllers::cloud_agent::sleep so none of the four suspend paths can skip it. Best-effort, 5s cap; sleep --all flushes concurrently. Adds ~1s to a disconnect.
  • A failed connect left an agent it woke awake and billing. Now rolled back, and only when this run did the waking.
  • ca sleep reported "asleep" before the transition finished, contradicting a following ca list.

Testing

957 unit tests, clippy clean. Full create → ssh → wake → sleep → delete run against live agents: create RUNNING in 7.3s, remote command in 1s, auto-wake in 12s, delete clears the local pointer. Error paths checked (unknown name, --all without -e, delete over a pipe, wake on a running agent). Disk-loss case re-run after the fix and confirmed resolved.

Not included

forward, exec, and fork/checkpoint/template.

🤖 Generated with Claude Code

codyde and others added 3 commits August 8, 2026 23:11
…elete

The lifecycle operations already existed, wearing flag costumes on a command
whose job is something else: `--new` creates, `--rm` destroys, `--keep-awake`
declines to sleep. `launch()` even short-circuits `--rm` before the launch
pipeline with a comment noting it is not a launch. This gives each one a name.

The line that matters is creation. `ca create` makes a VM and nothing else;
`ca ssh` connects to an agent that exists and errors otherwise; `ca start` and
`railway code` stay the create-and-launch path. So a mistyped agent name is an
error rather than a second billed VM.

`ca ssh` rather than `ca connect`: it is an ssh connection over the same relay
sandboxes use, and `railway sandbox ssh` already means "get onto the box,
optionally resuming a named session" — durable-session resume included. Bare
`ca ssh` attaches to the agent's session, `ca ssh <agent> -- bash` is a plain
shell, and `connect` is a visible alias so both spellings work.

Notes on the shape:

- One resolver for every verb, in src/controllers/cloud_agent.rs: an explicit
  name or id, then this environment's remembered agent, then your sole live
  one, then a list of candidates. No interactive prompt anywhere in it — a
  lifecycle command that stops to ask is unusable in a script, and ambiguity
  has a better answer than a guess.
- `list` defaults to the whole account via myCloudAgents. Scoping it to the
  linked environment would print nothing in the common case, since agent work
  is rarely done from a linked directory.
- `ensure_running` will not adopt the launcher's habit of treating a crashed
  agent as a cue to create a fresh one. That is a fine answer to "get me
  coding" and a bad one to "wake this agent".
- `sleep --all` exists and `delete --all` does not: agents have no idle
  timeout so bulk sleep is the cost valve, but bulk disk destruction behind one
  flag is not.
- Pointer bookkeeping lives in the controller, so a delete cannot leave
  `railway code` waking a corpse.

`--rm` keeps working and now points at `ca delete`, and the launcher's exit
hints name the agent instead of printing a raw ssh line and two UUIDs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…asleep"

Both found running the lifecycle end to end.

`ca ssh <agent> --session <typo>` woke a sleeping agent, failed the session
lookup, and returned — leaving a machine with no idle timeout awake and
billing. Measured at 12s, so the wake was real, not a no-op. Now a failed
connect puts back what the run changed, and only that: an agent found already
running was someone's deliberate state, possibly with a session open in another
terminal, and a failed connect here is no reason to suspend it.

The sleep mutation also returns before the agent finishes transitioning, so
`ca sleep foo && ca list` printed "is asleep" followed by "running". Reworded
to describe the action rather than assert a state the next command contradicts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sleeping an agent could silently lose its most recent work. `cloudAgentSleep`
snapshots the disk without quiescing the guest, so pages still dirty in its
page cache are absent from the image the next wake restores.

Measured on scratch agents, three runs isolating the variable:

  write, no sync   + sleep-on-disconnect            -> lost
  write + sync     + explicit sleep, 10s quiesce    -> survived
  write + sync     + sleep-on-disconnect, immediate -> survived

So it is the flush that matters, not the timing — waiting longer before
sleeping would only narrow the window. A side ssh running `sync` is enough,
because sync(2) flushes the whole filesystem regardless of which process
dirtied it, so it covers whatever the durable session was writing.

The flush is paired with the mutation inside controllers::cloud_agent::sleep
rather than left to callers: there are four paths that suspend an agent (the
two `railway ca` ones, the launcher's disconnect, and the TUI's), and any one
of them forgetting is silent data loss. The bare mutation is now private.

It is best-effort and bounded at 5s. Failing to reach an agent must not stop us
sleeping it — agents have no idle timeout, so the alternative to an imperfect
sleep is a machine that bills until someone remembers it. Deliberately not
ssh_plumbing, whose ~20s retry budget exists for waking agents and would make
every disconnect slow whenever the relay is unhealthy. `sleep --all` runs its
flushes concurrently so the cost-control command does not become a second per
agent.

Verified end to end: the write that vanished before now survives the same
sleep/wake, and a disconnect costs ~1s more (1.0s -> 2.1s).

The real fix belongs server-side, in cloudAgentSleep quiescing the guest before
it snapshots — the dashboard and any future API caller have the same problem,
and only the platform can make it a guarantee rather than a narrowed window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codyde codyde added the release/minor Author minor release label Aug 9, 2026
codyde added 2 commits August 9, 2026 00:19
Two conflicts, both where #1063's telemetry landed on lines this branch had
already changed:

- `prepare_inner`'s resolve_target call: keep master's telemetry wrapper, bind
  `_project_id` since this branch removed `Prepared::project_id`.
- The TUI's `close_and_sleep`: keep both. Sleeping goes through the controller
  so the disk is flushed first, and the failure still fires
  `quit_sleep_failed` — an agent left billing with nothing attached is exactly
  what that event is for. The no-environment fallback keeps the bare mutation,
  since without a relay target there is nothing to flush through.

`run_agent_op`'s trailing `let _ = environment_id` goes: the Sleep arm now uses
it to reach the agent.
#1063 covered launches, the TUI's manage-screen ops, and setup. The flat CLI
verbs this branch adds had only the generic per-dispatch event, which says
`command="cloud_agent"` and cannot say which verb ran.

Adds `track_lifecycle`, following the module's existing shape:

- One event per verb — `cli_list`, `cli_create`, `cli_ssh`, `cli_wake`,
  `cli_sleep`, `cli_delete`, with `_failed` variants — carrying real duration,
  which is the interesting part for `create` and `wake` since both wait for
  RUNNING.
- Detail slugs for the paths worth separating: `cli_ssh_attach` vs
  `cli_ssh_new_session` vs `cli_ssh_command` (reattaching to existing work,
  provisioning a first session, and one-shot commands are three different
  things), and `cli_sleep_all` for the fleet-wide cost valve.

The `cli_` prefix keeps these distinct from `track_agent_op`'s `agent_sleep`
and friends. Same three mutations, two surfaces — merging them would hide which
one people actually reach for. A test pins that separation.

Five verbs are wrapped at the dispatch, where the shape is identical. `ssh`
tracks itself: it ends in `std::process::exit` to propagate the remote command's
status, which would skip anything wrapped around it. Its body moved to
`ssh_connect` returning the exit code, so the event fires before the exit, and a
non-zero remote status is reported as a success — ssh worked, the command it ran
did not. Verified: `ca ssh <agent> -- sh -c 'exit 3'` still exits 3.

No free text in any of it: fixed slugs only, with `error_message` following the
same convention as every other failure event in the CLI.
@codyde
codyde merged commit 265a10e into master Aug 9, 2026
9 checks passed
@codyde
codyde deleted the cody/ca-lifecycle-commands branch August 9, 2026 07:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release/minor Author minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant