feat(pod): expose real pod runtime state on pod get and list (CON-690) - #315
Merged
Merged
Conversation
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
force-pushed
the
justinlin/con-690-pod-runtime-state
branch
from
August 4, 2026 19:44
89731bf to
0a10871
Compare
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
approved these changes
Aug 4, 2026
lukepiette
left a comment
Contributor
There was a problem hiding this comment.
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):
- Release-note the ssh output changes. They are the edges most likely to surface in someone's script: the
ssh infoerror 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 deadssh_commandand drop out of theconnectionsinventory; emptyconnectionschanged from JSONnullto[]. All correct fixes — the old commands could never connect — but they deserve a release-note line, not just the PR body. - Pre-merge check worth one live run: pods ship runpodctl with pod-scoped API keys. If a pod-scoped key can hit REST
/podsbut not graphqlmyPods, every in-podpod listbecomes permanentunknown/runtime_unavailableplus 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. - Minor:
uptimeSecondsis now absent where it used to be a constant (garbage)0— consumers reading it as always-present get null;--statusstill filters the RUNNING/EXITED vocabulary while the output now also speaks running/initializing/stopped, so--status initializingsilently matches nothing (a help-text sentence would cover it); the e2e transition test hard-requires observinginitializing, which a fast cached-image boot can flake.
3 tasks
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
force-pushed
the
justinlin/con-690-pod-runtime-state
branch
from
August 5, 2026 17:40
276f003 to
a4a424a
Compare
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.
pod getandpod listonly ever showeddesiredStatus, so a pod pulling a 20 GB image and a pod serving traffic looked identical, andsshjust saidpod not readywith no reason. This adds a derivedruntimeStatus+runtimeStatusReason, built only from signals the platform genuinely exposes.Linear: CON-690
What changed
internal/podstate: derives a five-value status (running,initializing,stopped,terminated,unknown) plus reason tokens fromdesiredStatus, runtime-telemetry presence andlastStatusChange. One derivation, used bypod get,pod list,ssh infoandssh connect.pod getreuses the graphql call it already made for the ssh block — no extra round trip.pod listmakes one bulkmyPodscall (never N+1), skipped unless some listed pod is RUNNING, capped at 5s, and best-effort: a failure degrades tounknown/runtime_unavailableinstead of failing the list. Also carrieslastStatusChangenow.ssherrors name the actual cause, including three distinct reasons for "running but no reachable port 22".uptimeSecondswas published as a permanent0(the real field isruntime.uptimeInSeconds), and an EXITED pod kept reporting stale runtime ports, sopod get/ssh connecthanded back an ssh command that could not connect.docs/regenerated.Notable decisions
pulling/startingsplit.PodRuntimeexposes no phase field (verified in the backend schema, the host telemetry struct, and live prod probes);initializingcovers pull + create + boot as one state. The backend ask is a follow-up.image_pull_error.PodStopReasonis never persisted or queryable — the strongest honest token isstopped_by_runpod. PersistingPod.stopReasonis a follow-up.runtimeStatusderives from the graphql snapshot, not REST'sdesiredStatus, sopod getandpod listcan never disagree with the ports they display. The two surfaces can still printdesiredStatus: RUNNINGbesideruntimeStatus: stoppedduring momentary skew — that is the point.machine-fields half of the investigation (maintenance windows would explain a pod stuckinitializing). 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/hostand live prod probes:pulling/startingstatesinitializingstatepodRuntime.ts,host/pkg/telemetry/store.go); live probes forruntime.status/.phase/.stateall fail validationimage_pull_error(spike brief)stopped_by_runpodPodStopReasonis input-only;stopPod.tsmails it and drops it — no column, not queryablemachinefieldsKey review fixes across both rounds:
pod listandpod getdisagreeing under REST/graphql skew (both now derive viasshconnect.PodState); the suggested--ports 22/tcpremediation silently unpublishing every other port (now unions with declared ports and warns the container may restart);pod liststalling 30s on unresponsive graphql (now skipped when nothing is RUNNING, capped at 5s — measured 30.04s → 0.01s empty); user terminates reportingstoppedwith no reason (nowterminatedwith attribution);Outbid:attribution preserved; stale-ssh guard extended tossh 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 infoputs an"error"key on stdout with exit 0. Follow-up. (A second nit — remediation strings printing a literal<pod-id>— was fixed in80a1d19: both call sites interpolate the real id now.)Testing
internal/podstate100% coverage;cmd/pod24.6% → 41.3%. New tests drivepod list,pod get,ssh infoandssh connectend-to-end against a stub REST+graphql control plane. 18 mutations applied across both review rounds — all caught. Live prod e2e observed theinitializing → runningtransition on the cheapest available community GPU ($0.17/hr A4000); all resources deleted,pod list -aempty after.Evidence: stub transcripts, live e2e, gates
Stub control plane (free):
Live prod (round 1):
Gates:
gofmt -l .clean,go vet ./...+go vet -tags e2e ./e2e/...clean,go test -count=1 --cover ./...all ok,go run ./docs/docs-gen.gono diff. (gofmtwas not clean at base; fixed in its ownstyle:commit.)Follow-ups
runtimeStatus/runtimeStatusReasonvocabulary,lastStatusChangeonpod list,pod update --portsreplaces rather than appendsPod.machinemaintenance fields (the ticket's unfinished half)ssh info's stdout"error"key should be renamed or documented as an exceptionPod.stopReason; addPodRuntime.phaseto splitpullingfromstartingpod logsagainstv2-rest.runpod.io/v2/pods/{id}/logs(already reachable with the current key)LayoutLowBalanceBannercase-sensitive match on"Exited by RunPod:";pod list --output=tablesilently returns json;FindPodConnectionpanic on apods:[null]element (also on main)