Skip to content

Commit 6b44386

Browse files
acmoreclaude
andauthored
feat(status,exec): surface container termination causes (OOMKilled) (#152)
* feat(status,exec): surface container termination causes (OOMKilled) An OOMKilled container previously showed up as ready=1/2 with reason "-" in status, and as a bare `container not found` from exec/jobs — every diagnosis required kubectl and hand-digging containerStatuses. - PodSummary now carries ContainerIssues (reason/exitCode/finishedAt, current-vs-before-restart) extracted from containerStatuses; the pod Reason falls back to the termination reason when no waiting reason applies. - `status --details` prints one line per abnormal termination, e.g. `container pytorch: OOMKilled (exit 137, finished ...)`, and JSON gains a containerIssues array. - exec FAILED summaries, single-pod exec errors, and jobs list/logs errors append the termination cause when the target container is gone, via GetContainerRestartInfo (now also reads the current terminated state — restartPolicy Never leaves LastTerminationState empty — and records FinishedAt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: gofmt podSummaryFromPod literal alignment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b8a874 commit 6b44386

10 files changed

Lines changed: 427 additions & 48 deletions

File tree

docs/command-reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ agents can react without launching a diagnostic chain on every blip:
5858
- Shows session status for the current or selected session.
5959
- When `session` is provided, `okdev` can resolve the saved config from session metadata even outside the repo.
6060
- `--details`: prints a single-session diagnostic view with target selection, pod list, pod IP and node placement, mount persistence, sync path semantics, managed SSH state, key local paths, and target pod details.
61-
- Detailed JSON output includes per-pod mount metadata and a `pathSemantics` section describing which configured sync paths are shared via the workspace sync and which are expected to survive a session restart.
61+
- Abnormally terminated containers are surfaced per pod, e.g. `container pytorch: OOMKilled (exit 137, finished 2026-07-05T06:32:11Z)` — both the current state (container stayed down) and the last termination before a restart (suffixed `before last restart`). The pod `reason` column also falls back to the termination reason when no waiting reason applies.
62+
- Detailed JSON output includes per-pod mount metadata, a `containerIssues` array (`container`, `reason`, `exitCode`, `finishedAt`, `current`), and a `pathSemantics` section describing which configured sync paths are shared via the workspace sync and which are expected to survive a session restart.
6263
- `--details` is only valid when exactly one session is selected.
6364

6465
### `okdev target show`
@@ -130,6 +131,7 @@ agents can react without launching a diagnostic chain on every blip:
130131
- Lists detached `okdev exec --detach` jobs across the session's running pods.
131132
- Reads job metadata from `/var/okdev/exec/*.json` in the target container (plus the legacy `/tmp/okdev-exec/` location for jobs launched by older okdev versions).
132133
- If the target container is gone (e.g. OOMKilled), listing and `jobs logs` automatically retry through the `okdev-sidecar` container, which mounts the same runtime volume.
134+
- When a command fails because the container is gone, the error carries the termination cause when it can be determined, e.g. `container "pytorch" terminated: OOMKilled (exit 137, finished 2026-07-05T06:32:11Z)`. The same hint is appended to `okdev exec` FAILED lines and single-pod exec errors.
133135
- Text output is grouped by logical job id, with one row per detached launch showing job id, summarized state, pod count, earliest start time, and original command.
134136
- JSON output includes both the logical job summary and the per-pod `podStates` records.
135137
- State values: `running` (wrapper alive and user command in flight), `exited` (user command finished; exit code is recorded in `podStates` / JSON), and `orphaned` (metadata still says `running` but the pid has exited or been recycled - typically the wrapper was `SIGKILL`ed or the container was restarted before the completion metadata could be written). Grouped text summaries render forms such as `running(1/2)`, `exited(2/2)`, and `failed(1/2)`.

internal/cli/exec_jobs.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"sort"
1111
"strings"
12+
"time"
1213

1314
"github.com/acmore/okdev/internal/connect"
1415
"github.com/acmore/okdev/internal/fanout"
@@ -181,6 +182,11 @@ func collectDetachJobs(ctx context.Context, client connect.ExecClient, namespace
181182
}
182183
}
183184
if err != nil {
185+
if isContainerUnavailableExecError(err) {
186+
if hint := containerUnavailableHint(ctx, client, namespace, pod.Name, container); hint != "" {
187+
err = fmt.Errorf("%w; %s", err, hint)
188+
}
189+
}
184190
results <- podDetachJobsResult{pod: pod.Name, err: err}
185191
return
186192
}
@@ -361,6 +367,38 @@ func isContainerUnavailableExecError(err error) bool {
361367
strings.Contains(msg, "container_exited")
362368
}
363369

370+
// containerLastStateClient is satisfied by *kube.Client; kept as a narrow
371+
// interface so exec paths that only hold a connect.ExecClient can probe for
372+
// the capability.
373+
type containerLastStateClient interface {
374+
GetContainerRestartInfo(ctx context.Context, namespace, pod, container string) (*kube.ContainerRestartInfo, error)
375+
}
376+
377+
// containerUnavailableHint explains why an exec target container is gone,
378+
// e.g. `container "pytorch" terminated: OOMKilled (exit 137, finished
379+
// 2026-07-05T06:32:11Z)`. Returns "" when the cause cannot be determined —
380+
// callers append it to the raw exec error, never replace it.
381+
func containerUnavailableHint(ctx context.Context, client any, namespace, pod, container string) string {
382+
if strings.TrimSpace(container) == "" {
383+
return ""
384+
}
385+
g, ok := client.(containerLastStateClient)
386+
if !ok {
387+
return ""
388+
}
389+
hintCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
390+
defer cancel()
391+
ri, err := g.GetContainerRestartInfo(hintCtx, namespace, pod, container)
392+
if err != nil || ri == nil || ri.LastReason == "" {
393+
return ""
394+
}
395+
hint := fmt.Sprintf("container %q terminated: %s (exit %d", container, ri.LastReason, ri.LastExitCode)
396+
if !ri.LastFinishedAt.IsZero() {
397+
hint += ", finished " + ri.LastFinishedAt.UTC().Format(time.RFC3339)
398+
}
399+
return hint + ")"
400+
}
401+
364402
func parseDetachMetadataLines(raw string) ([]detachMetadata, error) {
365403
lines := strings.Split(raw, "\n")
366404
out := make([]detachMetadata, 0)

internal/cli/exec_jobs_test.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"sync"
1111
"testing"
12+
"time"
1213

1314
"github.com/acmore/okdev/internal/kube"
1415
)
@@ -239,10 +240,17 @@ func TestDetachJobsCommandReconcilesAndReapsTmpFiles(t *testing.T) {
239240
// fakeContainerExecClient routes exec results by "pod|container" so tests can
240241
// model a dead target container alongside a healthy sidecar.
241242
type fakeContainerExecClient struct {
242-
mu sync.Mutex
243-
calls []string
244-
outputs map[string]string
245-
errs map[string]error
243+
mu sync.Mutex
244+
calls []string
245+
outputs map[string]string
246+
errs map[string]error
247+
restartInfo map[string]*kube.ContainerRestartInfo
248+
}
249+
250+
func (f *fakeContainerExecClient) GetContainerRestartInfo(ctx context.Context, namespace, pod, container string) (*kube.ContainerRestartInfo, error) {
251+
f.mu.Lock()
252+
defer f.mu.Unlock()
253+
return f.restartInfo[pod+"|"+container], nil
246254
}
247255

248256
func (f *fakeContainerExecClient) ExecInteractive(ctx context.Context, namespace, pod string, tty bool, command []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
@@ -314,6 +322,52 @@ func TestCollectDetachJobsDoesNotFallBackOnCommandError(t *testing.T) {
314322
}
315323
}
316324

325+
func TestCollectDetachJobsErrorIncludesTerminationHint(t *testing.T) {
326+
// When both the target container and the sidecar fallback fail, the pod
327+
// error should say WHY the container is gone instead of a bare
328+
// "container not found".
329+
client := &fakeContainerExecClient{
330+
errs: map[string]error{
331+
"okdev-sess-worker-0|pytorch": errors.New(`container not found ("pytorch")`),
332+
"okdev-sess-worker-0|" + syncthingContainerName: errors.New("sidecar unavailable"),
333+
},
334+
restartInfo: map[string]*kube.ContainerRestartInfo{
335+
"okdev-sess-worker-0|pytorch": {
336+
LastReason: "OOMKilled",
337+
LastExitCode: 137,
338+
LastFinishedAt: time.Date(2026, 7, 5, 6, 32, 11, 0, time.UTC),
339+
},
340+
},
341+
}
342+
pods := []kube.PodSummary{{Name: "okdev-sess-worker-0", Phase: "Running"}}
343+
_, podErrors := collectDetachJobs(context.Background(), client, "default", pods, "pytorch", "", pdshDefaultFanout)
344+
if len(podErrors) != 1 {
345+
t.Fatalf("expected one pod error, got %+v", podErrors)
346+
}
347+
if !strings.Contains(podErrors[0].Error, `container "pytorch" terminated: OOMKilled (exit 137, finished 2026-07-05T06:32:11Z)`) {
348+
t.Fatalf("expected termination hint in error, got %q", podErrors[0].Error)
349+
}
350+
}
351+
352+
func TestContainerUnavailableHint(t *testing.T) {
353+
client := &fakeContainerExecClient{
354+
restartInfo: map[string]*kube.ContainerRestartInfo{
355+
"pod-1|pytorch": {LastReason: "OOMKilled", LastExitCode: 137},
356+
},
357+
}
358+
got := containerUnavailableHint(context.Background(), client, "ns", "pod-1", "pytorch")
359+
if got != `container "pytorch" terminated: OOMKilled (exit 137)` {
360+
t.Fatalf("unexpected hint: %q", got)
361+
}
362+
if hint := containerUnavailableHint(context.Background(), client, "ns", "pod-1", "other"); hint != "" {
363+
t.Fatalf("expected empty hint without termination info, got %q", hint)
364+
}
365+
// Clients without restart-info capability (plain ExecClient) yield "".
366+
if hint := containerUnavailableHint(context.Background(), &fakePdshExecClient{}, "ns", "pod-1", "pytorch"); hint != "" {
367+
t.Fatalf("expected empty hint for capability-less client, got %q", hint)
368+
}
369+
}
370+
317371
func TestIsContainerUnavailableExecError(t *testing.T) {
318372
cases := []struct {
319373
err error

internal/cli/jobs.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,11 @@ func streamDetachJobLog(ctx context.Context, client detachJobClient, namespace s
583583
stderr.Reset()
584584
err = client.StreamShInContainer(ctx, namespace, row.Pod, syncthingContainerName, script, writer, &stderr)
585585
}
586+
if err != nil && isContainerUnavailableExecError(err) {
587+
if hint := containerUnavailableHint(ctx, client, namespace, row.Pod, row.Container); hint != "" {
588+
err = fmt.Errorf("%w; %s", err, hint)
589+
}
590+
}
586591
if err != nil && strings.TrimSpace(stderr.String()) != "" {
587592
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
588593
}

internal/cli/pdsh.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,13 @@ func newExecCmd(opts *Options) *cobra.Command {
166166
if len(execCmd) == 1 && strings.TrimSpace(execCmd[0]) == "" {
167167
execCmd = []string{"sh", "-lc", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
168168
}
169-
return runConnectWithClient(cc.kube, cc.namespace, target, execCmd, !noTTY)
169+
connectErr := runConnectWithClient(cc.kube, cc.namespace, target, execCmd, !noTTY)
170+
if connectErr != nil && isContainerUnavailableExecError(connectErr) {
171+
if hint := containerUnavailableHint(cmd.Context(), cc.kube, cc.namespace, target.PodName, target.Container); hint != "" {
172+
return fmt.Errorf("%w; %s", connectErr, hint)
173+
}
174+
}
175+
return connectErr
170176
},
171177
}
172178
cmd.Flags().StringVar(&shell, "shell", "", "Shell to start (default auto-detects bash/sh)")
@@ -994,7 +1000,13 @@ func runMultiExec(ctx context.Context, client connect.ExecClient, namespace stri
9941000
if len(failures) > 0 {
9951001
parts := make([]string, 0, len(failures))
9961002
for _, f := range failures {
997-
parts = append(parts, formatPodExecFailureSummary(f.pod, container, f.err))
1003+
part := formatPodExecFailureSummary(f.pod, container, f.err)
1004+
if isContainerUnavailableExecError(f.err) {
1005+
if hint := containerUnavailableHint(ctx, client, namespace, f.pod, container); hint != "" {
1006+
part += " " + hint
1007+
}
1008+
}
1009+
parts = append(parts, part)
9981010
}
9991011
fmt.Fprintf(stderr, "\nFAILED:\n%s\n", strings.Join(parts, "\n"))
10001012
return fmt.Errorf("%d of %d pods failed", len(failures), len(pods))
@@ -1548,7 +1560,13 @@ func runMultiExecScript(ctx context.Context, client scriptCopyClient, namespace
15481560
if len(failures) > 0 {
15491561
parts := make([]string, 0, len(failures))
15501562
for _, f := range failures {
1551-
parts = append(parts, formatPodExecFailureSummary(f.pod, container, f.err))
1563+
part := formatPodExecFailureSummary(f.pod, container, f.err)
1564+
if isContainerUnavailableExecError(f.err) {
1565+
if hint := containerUnavailableHint(ctx, client, namespace, f.pod, container); hint != "" {
1566+
part += " " + hint
1567+
}
1568+
}
1569+
parts = append(parts, part)
15521570
}
15531571
fmt.Fprintf(stderr, "\nFAILED:\n%s\n", strings.Join(parts, "\n"))
15541572
return fmt.Errorf("%d of %d pods failed", len(failures), len(pods))

internal/cli/pdsh_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,33 @@ func TestFilterRunningPods(t *testing.T) {
340340
}
341341
}
342342

343+
func TestRunMultiExecFailureSummaryIncludesTerminationHint(t *testing.T) {
344+
// A dead container's FAILED line should carry the termination cause
345+
// (OOMKilled + exit + timestamp) instead of a bare kubelet error.
346+
client := &fakeContainerExecClient{
347+
errs: map[string]error{
348+
"worker-0|pytorch": errors.New(`container not found ("pytorch")`),
349+
},
350+
restartInfo: map[string]*kube.ContainerRestartInfo{
351+
"worker-0|pytorch": {
352+
LastReason: "OOMKilled",
353+
LastExitCode: 137,
354+
LastFinishedAt: time.Date(2026, 7, 5, 6, 32, 11, 0, time.UTC),
355+
},
356+
},
357+
}
358+
pods := []kube.PodSummary{{Name: "worker-0", Phase: "Running"}}
359+
var stdout, stderr bytes.Buffer
360+
sem := make(chan struct{}, 1)
361+
err := runMultiExec(context.Background(), client, "default", pods, "pytorch", []string{"true"}, "", 0, true, sem, &stdout, &stderr)
362+
if err == nil {
363+
t.Fatal("expected failure")
364+
}
365+
if !strings.Contains(stderr.String(), `container "pytorch" terminated: OOMKilled (exit 137, finished 2026-07-05T06:32:11Z)`) {
366+
t.Fatalf("expected termination hint in FAILED summary, got %q", stderr.String())
367+
}
368+
}
369+
343370
func TestNewDetachJobSpecPlacesFilesOnRuntimeVolume(t *testing.T) {
344371
// Detached job logs and metadata must prefer the okdev-runtime volume
345372
// (shared with the sidecar) so they survive a target-container crash;

internal/cli/status_details.go

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,30 @@ type detailedStatusTarget struct {
5757
}
5858

5959
type detailedStatusPod struct {
60-
Name string `json:"name"`
61-
Role string `json:"role,omitempty"`
62-
Attachable bool `json:"attachable"`
63-
Phase string `json:"phase"`
64-
Ready string `json:"ready"`
65-
Restarts int32 `json:"restarts"`
66-
Reason string `json:"reason"`
67-
Age string `json:"age"`
68-
Selected bool `json:"selected"`
69-
PodIP string `json:"podIP,omitempty"`
70-
NodeName string `json:"nodeName,omitempty"`
71-
Mounts []detailedStatusMount `json:"mounts,omitempty"`
60+
Name string `json:"name"`
61+
Role string `json:"role,omitempty"`
62+
Attachable bool `json:"attachable"`
63+
Phase string `json:"phase"`
64+
Ready string `json:"ready"`
65+
Restarts int32 `json:"restarts"`
66+
Reason string `json:"reason"`
67+
Age string `json:"age"`
68+
Selected bool `json:"selected"`
69+
PodIP string `json:"podIP,omitempty"`
70+
NodeName string `json:"nodeName,omitempty"`
71+
Mounts []detailedStatusMount `json:"mounts,omitempty"`
72+
ContainerIssues []detailedStatusContainerIssue `json:"containerIssues,omitempty"`
73+
}
74+
75+
// detailedStatusContainerIssue surfaces an abnormal container termination
76+
// (OOMKilled, Error, ...) so `ready=1/2` comes with its cause instead of
77+
// requiring a kubectl round-trip through containerStatuses.
78+
type detailedStatusContainerIssue struct {
79+
Container string `json:"container"`
80+
Reason string `json:"reason"`
81+
ExitCode int32 `json:"exitCode"`
82+
FinishedAt string `json:"finishedAt,omitempty"`
83+
Current bool `json:"current"`
7284
}
7385

7486
type detailedStatusMount struct {
@@ -180,18 +192,33 @@ func buildDetailedTarget(view sessionView, cfg *config.DevEnvironment) (detailed
180192

181193
pods := make([]detailedStatusPod, 0, len(view.Pods))
182194
for _, pod := range sortedSessionPods(view.Pods) {
195+
issues := make([]detailedStatusContainerIssue, 0, len(pod.ContainerIssues))
196+
for _, issue := range pod.ContainerIssues {
197+
finishedAt := ""
198+
if !issue.FinishedAt.IsZero() {
199+
finishedAt = issue.FinishedAt.UTC().Format(time.RFC3339)
200+
}
201+
issues = append(issues, detailedStatusContainerIssue{
202+
Container: issue.Container,
203+
Reason: issue.Reason,
204+
ExitCode: issue.ExitCode,
205+
FinishedAt: finishedAt,
206+
Current: issue.Current,
207+
})
208+
}
183209
pods = append(pods, detailedStatusPod{
184-
Name: pod.Name,
185-
Role: strings.TrimSpace(pod.Labels["okdev.io/workload-role"]),
186-
Attachable: !strings.EqualFold(strings.TrimSpace(pod.Labels["okdev.io/attachable"]), "false"),
187-
Phase: pod.Phase,
188-
Ready: pod.Ready,
189-
Restarts: pod.Restarts,
190-
Reason: pod.Reason,
191-
Age: age(pod.CreatedAt),
192-
Selected: pod.Name == view.TargetPod,
193-
PodIP: pod.PodIP,
194-
NodeName: pod.NodeName,
210+
Name: pod.Name,
211+
Role: strings.TrimSpace(pod.Labels["okdev.io/workload-role"]),
212+
Attachable: !strings.EqualFold(strings.TrimSpace(pod.Labels["okdev.io/attachable"]), "false"),
213+
Phase: pod.Phase,
214+
Ready: pod.Ready,
215+
Restarts: pod.Restarts,
216+
Reason: pod.Reason,
217+
Age: age(pod.CreatedAt),
218+
Selected: pod.Name == view.TargetPod,
219+
PodIP: pod.PodIP,
220+
NodeName: pod.NodeName,
221+
ContainerIssues: issues,
195222
})
196223
}
197224
return target, pods
@@ -545,6 +572,9 @@ func printDetailedStatus(w io.Writer, detail detailedStatus) {
545572
fmt.Fprintf(w, " %s\n", extra)
546573
}
547574
}
575+
for _, issue := range pod.ContainerIssues {
576+
fmt.Fprintf(w, " %s\n", renderContainerIssueLine(issue))
577+
}
548578
}
549579

550580
fmt.Fprintln(w, "\nSSH:")
@@ -665,6 +695,20 @@ func renderMountsLine(mounts []detailedStatusMount) string {
665695
return "mounts: " + strings.Join(parts, ", ")
666696
}
667697

698+
// renderContainerIssueLine formats one abnormal container termination, e.g.
699+
// "container pytorch: OOMKilled (exit 137, finished 2026-07-05T06:32:11Z)".
700+
func renderContainerIssueLine(issue detailedStatusContainerIssue) string {
701+
line := fmt.Sprintf("container %s: %s (exit %d", issue.Container, issue.Reason, issue.ExitCode)
702+
if issue.FinishedAt != "" {
703+
line += ", finished " + issue.FinishedAt
704+
}
705+
line += ")"
706+
if !issue.Current {
707+
line += " before last restart"
708+
}
709+
return line
710+
}
711+
668712
func summarizeConfiguredPorts(forwards []config.PortMapping) []string {
669713
lines := make([]string, 0, len(forwards))
670714
for _, forward := range forwards {

0 commit comments

Comments
 (0)