Skip to content

feat(pod): expose real pod runtime state on pod get and list (CON-690) - #315

Merged
justinwlin merged 17 commits into
mainfrom
justinlin/con-690-pod-runtime-state
Aug 5, 2026
Merged

justinwlin merged 17 commits into
mainfrom
justinlin/con-690-pod-runtime-state

Conversation

@justinwlin

@justinwlin justinwlin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

pod get and pod list only ever showed desiredStatus, so a pod pulling a 20 GB image and a pod serving traffic looked identical, and ssh just said pod not ready with no reason. This adds a derived runtimeStatus + runtimeStatusReason, built only from signals the platform genuinely exposes.

Linear: CON-690

What changed

  • New internal/podstate: derives a five-value status (running, initializing, stopped, terminated, unknown) plus reason tokens from desiredStatus, runtime-telemetry presence and lastStatusChange. One derivation, used by pod get, pod list, ssh info and ssh connect.
  • pod get reuses the graphql call it already made for the ssh block — no extra round trip.
  • pod list makes one bulk myPods call (never N+1), skipped unless some listed pod is RUNNING, capped at 5s, and best-effort: a failure degrades to unknown/runtime_unavailable instead of failing the list. Also carries lastStatusChange now.
  • ssh errors name the actual cause, including three distinct reasons for "running but no reachable port 22".
  • Two bugs fixed on the way: uptimeSeconds was published as a permanent 0 (the real field is runtime.uptimeInSeconds), and an EXITED pod kept reporting stale runtime ports, so pod get / ssh connect handed back an ssh command that could not connect.
  • Additive output contract; docs/ regenerated.

Notable decisions

  • No pulling/starting split. PodRuntime exposes no phase field (verified in the backend schema, the host telemetry struct, and live prod probes); initializing covers pull + create + boot as one state. The backend ask is a follow-up.
  • No image_pull_error. PodStopReason is never persisted or queryable — the strongest honest token is stopped_by_runpod. Persisting Pod.stopReason is a follow-up.
  • runtimeStatus derives from the graphql snapshot, not REST's desiredStatus, so pod get and pod list can never disagree with the ports they display. The two surfaces can still print desiredStatus: RUNNING beside runtimeStatus: stopped during momentary skew — that is the point.
  • Not done from the ticket: the machine-fields half of the investigation (maintenance windows would explain a pod stuck initializing). Carried as a follow-up rather than left implicit.
Full deviation analysis and review triage (2 rounds, 29 findings: 22 fixed, 2 rejected, rest disclosed)

Every deviation from the ticket was source-verified by independent reviewers against runpod-backend / host and live prod probes:

ticket asked for shipped why
pulling/starting states one initializing state no phase field exists anywhere in the pipeline (podRuntime.ts, host/pkg/telemetry/store.go); live probes for runtime.status/.phase/.state all fail validation
image_pull_error (spike brief) stopped_by_runpod PodStopReason is input-only; stopPod.ts mails it and drops it — no column, not queryable
"timebox a spike first" full CLI implementation, no backend PRs the spike answer was "the field cannot be faked"; everything shipped derives from existing fields
investigate machine fields not done disclosed gap; the maintenance fields exist and are a follow-up

Key review fixes across both rounds: pod list and pod get disagreeing under REST/graphql skew (both now derive via sshconnect.PodState); the suggested --ports 22/tcp remediation silently unpublishing every other port (now unions with declared ports and warns the container may restart); pod list stalling 30s on unresponsive graphql (now skipped when nothing is RUNNING, capped at 5s — measured 30.04s → 0.01s empty); user terminates reporting stopped with no reason (now terminated with attribution); Outbid: attribution preserved; stale-ssh guard extended to ssh connect ([], not a dead command).

Rejected: hint placement framing (moved anyway, with the port logic); "report transcripts not verbatim" (report issue, not a code defect).

Known nit, deliberately unchanged (pre-existing shape): ssh info puts an "error" key on stdout with exit 0. Follow-up. (A second nit — remediation strings printing a literal <pod-id> — was fixed in 80a1d19: both call sites interpolate the real id now.)

Testing

internal/podstate 100% coverage; cmd/pod 24.6% → 41.3%. New tests drive pod list, pod get, ssh info and ssh connect end-to-end against a stub REST+graphql control plane. 18 mutations applied across both review rounds — all caught. Live prod e2e observed the initializing → running transition on the cheapest available community GPU ($0.17/hr A4000); all resources deleted, pod list -a empty after.

Evidence: stub transcripts, live e2e, gates

Stub control plane (free):

# running pod declaring only 8888/http -> the suggestion keeps it
$ rp pod get p | jq -r .ssh.error
pod not ready: pod does not publish 22/tcp; add it with 'runpodctl pod update <pod-id>
  --ports 8888/http,22/tcp' (--ports replaces the whole list, and changing it may restart the container)

# rest RUNNING vs graphql EXITED: list and get agree, no stale uptime
$ rp pod list -a   -> runtimeStatus stopped / stopped_by_user
$ rp ssh connect   -> {"connections": []}

# EXITED-only account, graphql sleeping 20s: probe skipped entirely
$ time rp pod list -a    -> real 0.01  (graphql hits: 0)

# running pod, graphql sleeping 20s: 5s cap fires, degrades, exit 0
$ time rp pod list -a    -> real 5.01, runtimeStatus unknown / runtime_unavailable

Live prod (round 1):

[18:45:04] GET  status=initializing reason=awaiting_container uptime=None
[18:45:18] GET  status=running uptime=7
$ pod stop ... && pod get ...   -> runtimeStatus stopped / stopped_by_user, uptimeSeconds absent, no ssh_command
$ ssh connect                   -> {"connections": []}
$ pod delete ... && pod list -a -> []

$ go test -tags e2e -run TestCLI_PodRuntimeStatusTransition ./e2e/...
--- PASS: TestCLI_PodRuntimeStatusTransition (21.52s)

Gates: gofmt -l . clean, go vet ./... + go vet -tags e2e ./e2e/... clean, go test -count=1 --cover ./... all ok, go run ./docs/docs-gen.go no diff. (gofmt was not clean at base; fixed in its own style: commit.)

Follow-ups

  • update the runpodctl agent skill — new runtimeStatus/runtimeStatusReason vocabulary, lastStatusChange on pod list, pod update --ports replaces rather than appends
  • read Pod.machine maintenance fields (the ticket's unfinished half)
  • decide whether ssh info's stdout "error" key should be renamed or documented as an exception
  • backend: persist + expose Pod.stopReason; add PodRuntime.phase to split pulling from starting
  • pod logs against v2-rest.runpod.io/v2/pods/{id}/logs (already reachable with the current key)
  • pre-existing bugs found, untouched: main-ui LayoutLowBalanceBanner case-sensitive match on "Exited by RunPod:"; pod list --output=table silently returns json; FindPodConnection panic on a pods:[null] element (also on main)

rest never returns `runtime` for a pod, so graphql is the only source of
runtime telemetry. the myPods query asked for ports alone; ask for
uptimeInSeconds, container and gpus too, which is everything PodRuntime
exposes. `Pod.uptimeSeconds` is deprecated and always 0 in prod, so
`runtime.uptimeInSeconds` is the only real uptime.
`desiredStatus` says what was asked for, never what is happening: a pod whose
image is still downloading and one that has been serving for an hour are both
RUNNING, so the only signal was a bare "pod not ready".

internal/podstate derives a small lowercase vocabulary from the signals the
platform actually exposes — running, initializing, stopped, terminated,
unknown — plus a stable runtimeStatusReason token. it deliberately has no
`pulling` value: the pull state never leaves the host, so initializing covers
the whole pre-container window rather than guessing.

pod get gets this for free from the graphql call it already made for the ssh
block. pod list adds one bulk myPods call, never one per pod, and degrades to
unknown/runtime_unavailable if it fails. `ssh info` and the ssh block of
`pod get` now say why a pod is not ready instead of only that it is not.

also fixes pod get publishing a permanent "uptimeSeconds": 0.
walks a real community pod from create to container-up, asserts pod get and
pod list agree on runtimeStatus, then stops it to exercise the stopped /
stopped_by_user values. deletes the pod in t.Cleanup regardless of outcome.
pre-existing: struct tags were left unaligned when ModelReferences was added,
so `gofmt -l .` was not clean on main. no behaviour change.
…CON-690)

observed live: an EXITED pod keeps reporting non-null runtime with its last
uptimeInSeconds and its last ports for a while after the container is gone.
that was enough for pod get / ssh info to hand back an ssh command that cannot
connect, and to publish a frozen uptimeSeconds on a stopped pod.

gate both on the derived runtimeStatus being running. the ssh block now
explains the stop instead of lying.

also lets the e2e harness take RUNPODCTL_BIN so a worktree build can be tested
without overwriting the shared install.
…-690)

triage of three adversarial reviews. the derivation was reporting things it
had not checked, and the ssh advice was actively wrong in the common case.

- `pod list`: a pod rest lists that graphql `myself.pods` omits was reported
  as `initializing`, i.e. "the container is down", on no evidence at all —
  `pod get` said `unknown` for the same pod at the same instant. the probe
  flag now only gets set for pods actually present in the result.
- `pod list`: the runtime side-call turned a ~70ms command into a hard 30s
  stall when graphql was unresponsive, and fired even when the filtered
  result set was empty. it is now skipped when there is nothing to decorate
  and capped at 5s (measured: 30.04s -> 5.27s blackholed, 0.01s empty).
- `pod get`: the state was derived from rest's desiredStatus while the ports
  it gates came from the graphql snapshot, so momentary skew between the two
  bypassed the stale-ssh guard entirely. it now derives from the snapshot the
  runtime block belongs to.
- `ssh info` / `pod get`: the gate was "not running" rather than "known
  down", so a live connection was discarded for any pod in a state this cli
  does not model (RESTARTING, CREATED, PAUSED, DEAD).
- `ssh connect` with no args still handed back an ssh command built from a
  stopped pod's lingering ports; ListConnections now skips known-down pods.
- the not-ready message told people to "recreate the pod with --ports
  22/tcp" without checking whether the pod already declared it — printed in
  the same json object that listed 22/tcp. it now distinguishes not
  declared / mapping not published yet / mapped but not publicly routable.
  this text moved to internal/sshconnect, where the ssh hints live.
- new `stopped_outbid` / `terminated_outbid`: an outbid spot pod is the one
  involuntary stop the backend records a real cause for, and it was landing
  as a bare `stopped` indistinguishable from a user stop. `pod list` also
  carries `lastStatusChange` now, so an unrecognised phrasing still reaches
  the caller.
- `initializing` no longer claims the container is starting: the resolver
  returns null on any hapi error, so it means "no container reported".
- dropped the unused `container`/`gpus` runtime fields from the myPods query.

testing: the new logic had no CI-visible coverage (runList/runGet/ssh info
were all at 0%). added stub-control-plane tests that drive the commands end
to end; 10 mutations that previously survived `go test ./...`, including a
full revert of the stale-ssh fix, are now all caught. the e2e test picks the
cheapest community gpu instead of whatever the api lists first (an A100 at
$1.19/hr) and now fails if the initializing -> running transition it exists
to observe was never actually seen.
verified live against prod: `pod update <id> --ports 22/tcp` on a running pod
created without it produced a public 22 mapping within seconds -- no restart,
no recreate. the message told people to destroy and rebuild the pod instead.
…N-690)

`available: true` is not a capacity guarantee, so picking only the cheapest gpu
made the test skip outright (the A5000 at $0.16/hr was sold out). it now walks
the four cheapest candidates and only skips if all of them are full. verified
live: created on an RTX A4000 at $0.17/hr instead of the $1.19/hr A100 the
first-listed helper returns.
…CON-690)

The vocabulary assumed terminate -> desiredStatus TERMINATED. It does not.
Every "Terminated by ..." writer in runpod-backend sets desiredStatus
**EXITED** (model/src/pod/terminatePod.ts:218 and :276,
terminateAllStoppedPods.ts:80, cluster/deleteCluster.ts:129 and :210), and
the only two writers of TERMINATED are the outbid paths
(model/src/utils/index.ts:495, resumePod.ts:570). So the most common
terminate reported `stopped` with runtimeStatusReason absent - the one stop
whose actor is plainly in the text was the one that lost it - while
terminated_by_user / terminated_by_runpod were documented but unreachable.

Derive now reads the attribution on EXITED and reports terminated for a
destroy. The outbid fallback is deliberately kept out of that probe, since
EXITED + "Outbid:" is a real stop (model/src/utils/index.ts:481); stopReason
and the new actorReason split that responsibility.

Also records what the platform really does with terminated pods: both paths
set terminatedAt and getMyPods filters `terminatedAt: null`
(model/src/pod/getMyPods.ts:107 and :128), so `terminated` is a narrow
window rather than a state you will normally observe.
…N-690)

`pod list` read the runtime block from the graphql snapshot but gated it on
rest's desiredStatus - the exact mismatch this branch fixed in `pod get` and
then wrote down as a rule in AGENTS.md. Under skew the two commands
contradicted each other in the same second: with rest RUNNING and graphql
EXITED, list said `running` with a dead container's uptimeSeconds: 261 while
get said `stopped` / `stopped_by_user`. Both now go through the one
derivation, sshconnect.PodState(gqlPod); rest's desiredStatus is still what
is published.

Also tightens the probe guard from "non-empty result set" to "some listed pod
is RUNNING". podstate only consults telemetry on the RUNNING branch, so
`pod list --all` on an account of stopped pods was paying up to the 5s cap
for an answer that is provably discarded - measured 5.02s -> 0.01s against a
stub whose graphql sleeps 20s.
`pod update --ports` replaces the pod's whole port list, it does not add to
it: cmd/pod/update.go sets req.Ports to exactly what was passed (unlike
--env, which merges two lines below), and runpod-backend writes it through
verbatim (model/src/pod/editJob.ts:161 `ports: input.ports`). So the advice
"add it with 'pod update <pod-id> --ports 22/tcp'", followed literally,
silently unpublished every other port the pod had - and the message only
fires when the pod declares ports, i.e. exactly when there is something to
lose. The earlier live transcript showed that loss (created with 8888/http,
`ports ['22/tcp']` after) and read it as a success.

The suggested command now carries the pod's declared ports plus 22/tcp. The
message also stops implying the change is free: editJob bumps `version`, the
host names containers {podId}-{version} (host/pkg/dockpose/api.go:215) and
docker port bindings are fixed at container create time, so a new public port
implies a new container. "desiredStatus stayed RUNNING and the port appeared
in seconds" cannot distinguish that from a recreate against a cached image,
so it says "may restart" rather than claiming either way. Settling it for
certain needs a paid pod; not spent.

Also pins the deliberate null -> [] change in ListConnections, which only had
a len == 1 assertion behind it.
LimitTimeout is the whole latency story for `pod list` and nothing
constrained it: inverting its comparison turned the 5s cap into a no-op and
`go test ./...` stayed green. Covers shortening, refusing to lengthen,
ignoring non-positive input, and not clobbering a tighter configured
graphqlTimeout.
- `terminated` is not just desiredStatus TERMINATED, and a terminated pod
  drops out of `pod list`; the README table said otherwise.
- README now says runtimeStatus is derived from the graphql snapshot and can
  briefly disagree with the desiredStatus printed beside it. That note only
  existed in AGENTS.md, which users do not read, while the README table
  asserted `stopped` means desiredStatus EXITED.
- the "no restart needed" claim about `pod update --ports` is gone, replaced
  with what is verifiable: --ports replaces the whole list, and the version
  bump can restart the container.
- AGENTS.md gains the two pitfalls behind those: which desiredStatus a
  terminate writes, and that --ports is not additive.
@justinwlin
justinwlin force-pushed the justinlin/con-690-pod-runtime-state branch from 89731bf to 0a10871 Compare August 4, 2026 19:44
@justinwlin
justinwlin marked this pull request as ready for review August 4, 2026 19:46
the not-ready remediation strings printed a literal <pod-id>, so an agent
copying the command verbatim ran it with the placeholder. both call sites
already hold the pod id; an empty id keeps the placeholder.
…timeStatus (CON-690)

the <pod-id> fallback lived twice (podstate + sshconnect); it is now one
exported helper. ssh info's not-ready runtimeStatus key was asserted
nowhere — the test table's status field was dead. it is now asserted on
every not-ready row.

@lukepiette lukepiette 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.

Approving. Checked the PR body's claims against pre-PR main and live schemas and they hold: pod get adds zero extra round trips (main already called GetPods() for the ssh block), pod list adds exactly one bulk graphql call — never N+1 — capped at 5s and degrading to unknown on failure (all test-pinned), the JSON contract is strictly additive with desiredStatus values untouched, no flags or exit codes changed, runtime.uptimeInSeconds exists in the published schema, and REST really does carry lastStatusChange. No tests deleted or weakened; old behavior is pinned alongside the new.

Comments (none blocking):

  1. Release-note the ssh output changes. They are the edges most likely to surface in someone's script: the ssh info error string changed from exactly "pod not ready" to "pod not ready: <detail>" (exact-match consumers break, prefix-match survives); stopped pods no longer get a dead ssh_command and drop out of the connections inventory; empty connections changed from JSON null to []. All correct fixes — the old commands could never connect — but they deserve a release-note line, not just the PR body.
  2. Pre-merge check worth one live run: pods ship runpodctl with pod-scoped API keys. If a pod-scoped key can hit REST /pods but not graphql myPods, every in-pod pod list becomes permanent unknown/runtime_unavailable plus a possible repeated 5s stall per invocation. It degrades rather than breaks, but one test with a pod-scoped key before merge would settle it.
  3. Minor: uptimeSeconds is now absent where it used to be a constant (garbage) 0 — consumers reading it as always-present get null; --status still filters the RUNNING/EXITED vocabulary while the output now also speaks running/initializing/stopped, so --status initializing silently matches nothing (a help-text sentence would cover it); the e2e transition test hard-requires observing initializing, which a fast cached-image boot can flake.

review follow-ups:

- --status filters desiredStatus, but the output now also speaks the derived
  runtimeStatus vocabulary, so `--status initializing` silently matched nothing.
  the help text (and generated docs) now say which vocabulary it takes.
- the transition e2e hard-failed when it never observed `initializing`, which a
  boot faster than the first poll can cause. it now polls every 1s instead of
  2s, and reports the unexercised transition as a skip at the very end of the
  test, so a fast boot is not a red build while every other assertion still
  runs and still fails on its own.
- pin the pod-scoped-key case: pods ship runpodctl with a key that rest /pods
  may accept and graphql myPods may reject. a 401 on the probe must degrade to
  unknown/runtime_unavailable with the pods still listed, exactly like the 500
  case.
@justinwlin
justinwlin force-pushed the justinlin/con-690-pod-runtime-state branch from 276f003 to a4a424a Compare August 5, 2026 17:40
@justinwlin
justinwlin merged commit 7113ca6 into main Aug 5, 2026
1 check passed
@justinwlin
justinwlin deleted the justinlin/con-690-pod-runtime-state branch August 5, 2026 18:52
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.

2 participants