Skip to content
Back to skills

Job Reports Success While Its Worker Was Oom Killed

ASecurity

A batch or cloud job lists as SUCCEEDED while the work inside it produced nothing, because the kernel's out-of-memory killer took a forked WORKER and the parent caught the failure, wrote its record and exited 0. Use when: (1) a run's own output says a worker "died"/"was terminated abruptly" but the execution listing says succeeded, (2) you are about to report "the job failed" or "the run died" to a coordinator who will quote you upstream, (3) a peer checks the listing, sees success and tells ...

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 22, 2026
ai-agentsbashnode

Security analysis

A100/100

Scanned September 24, 2026

npx -y skills add wan-huiyan/agent-traffic-control --skill job-reports-success-while-its-worker-was-oom-killed --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Job Reports Success While Its Worker Was Oom Killed?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Job Reports Success While Its Worker Was Oom Killed
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wan-huiyan-job-reports-success-while-its-worker-was-oom-kille/badge)](https://www.skillsdirectory.com/skills/wan-huiyan-job-reports-success-while-its-worker-was-oom-kille)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: job-reports-success-while-its-worker-was-oom-killed
description: |
  A batch or cloud job lists as SUCCEEDED while the work inside it produced
  nothing, because the kernel's out-of-memory killer took a forked WORKER and the
  parent caught the failure, wrote its record and exited 0. Use when: (1) a run's
  own output says a worker "died"/"was terminated abruptly" but the execution
  listing says succeeded, (2) you are about to report "the job failed" or "the run
  died" to a coordinator who will quote you upstream, (3) a peer checks the
  listing, sees success and tells you your sentence is wrong, (4) a harness has
  classified the death as transient or retryable, (5) you are sizing a machine
  from a run that died and need its true peak. The platform logs a memory message
  only for a CONTAINER kill, so silence about memory is expected for a child kill
  and is NOT evidence against it. Record the cgroup counters (`memory.oom_control`
  oom_kill, `memory.failcnt`) at the start and end of every run so a kill is
  COUNTED rather than inferred, and read the kernel's own peak, because a
  half-second sampler misses a spike that arrives in five seconds. Name the layer
  that failed: "a worker was killed and the search returned nothing" and "the job
  failed" are different sentences and only one of them is true. NOT for a genuine
  application failure with a recorded exit status, and NOT for a container kill,
  where the platform does say so.
version: 1.0.0
date: 2026-09-17
author: wan-huiyan
disable-model-invocation: true
---
# A Job Reports SUCCESS While Its Worker Was OOM-Killed

## Problem

A long research run fans routing work out to forked worker processes. One worker
is killed by the kernel's out-of-memory killer. The parent process catches the
resulting "a process in the process pool was terminated abruptly", records the
failure in its own JSON, writes its log and returns 0 — which is correct
behaviour for a harness that reports rather than crashes.

The result is three artefacts that disagree in a way nobody notices:

| artefact | what it says |
|---|---|
| the execution listing | `succeededCount 1`, condition `Completed True` |
| the run's own record | the work raised "a worker died while routing item 10 of 100", zero results written |
| the platform's logs | nothing about memory at all |

So "the run died" is wrong, "the run succeeded" is wrong, and the useful sentence
is longer than either.

## Why the platform says nothing about memory

A managed runtime emits its memory-limit message when it kills the CONTAINER. A
child process killed inside a container that stays alive is invisible to it: the
task did not exceed anything the platform enforces, and from outside, nothing
failed. **Absence of a memory message is therefore the expected state for a child
kill, not evidence that memory was not the cause** — which is exactly the
inference a reviewer makes when asked "was it really memory?".

## Make it counted, not inferred

Read the kernel's own counters into the run's record, at the start and again at
the end. On cgroup v1:

```bash
CG=/sys/fs/cgroup/memory
cat $CG/memory.oom_control        # oom_kill_disable 0 / under_oom 0 / oom_kill N
cat $CG/memory.failcnt            # charges refused at the limit
cat $CG/memory.max_usage_in_bytes # the kernel's own peak, never reset
cat $CG/memory.limit_in_bytes
```

On cgroup v2 the equivalents are `memory.events` (`oom_kill`), `memory.peak` and
`memory.max`. Read both layouts: a runtime advertising a modern generation can
still present v1 inside the container, which is what the observed run did.

Two readings did the work in the observed case:

- **`oom_kill` was 0 at the moment the workers forked and 1 at the end.** One line,
  and the finding stopped being a guess. A coordinator quoting it upstream could
  say "the kernel counted the kill" instead of "we think it ran out of memory".
- **`memory.max_usage_in_bytes` was 7.41 GiB against an 8 GiB limit**, while a
  sampler polling `memory.usage_in_bytes` every half second had caught less: usage
  went from about 3 GB just after the fork to about 8 GB within roughly five
  seconds of work starting. **Sample for the shape, but report the kernel's peak.**

## Per-process peaks cannot be added up

`getrusage(RUSAGE_CHILDREN).ru_maxrss` is the peak of the LARGEST single
descendant, not a sum — and for a forked child it counts pages the child still
SHARES with the parent. A worker reading 2.70 GB beside a 2.57 GB parent has not
used 2.70 GB extra; it has copied about 0.13 GB of its own.

To get what a fan-out really costs the machine:

- read the container's total (cgroup usage or peak) and subtract the parent, or
- compare container usage just after the fork with usage at the peak.

Reporting the per-child figure as a sum overstates the workers and understates
the parent, and it points a fix at the wrong half. Say which number you read.

## Why the workers cost so much: it is copied, not built

In the observed run each worker grew by roughly 1.2 GB within seconds of
starting, on top of a 2.6 GB parent, and the cause is worth knowing because it
decides the fix:

- the shared structure was a graph held as plain interpreter objects — a
  dictionary of about 1.7 million node positions plus nested edge dictionaries;
- forked children share those pages until they touch them, and a
  reference-counting runtime WRITES to an object's header when it merely reads it;
- so traversing the structure turns shared pages private, and the container pays
  for them once per worker;
- nothing in the worker's path built an index of its own — which the readings
  support: each worker's peak sat level with the parent's rather than far above it.

**Fixes that follow:** fewer workers, more memory, or holding the shared
structure in a form that survives forking (arrays rather than per-element
objects); a pre-fork `gc.freeze()` is the cheap partial. **A fix that shares a
rebuilt per-worker index is aimed at nothing**, because no such index exists.

## The sentence to send a coordinator

Say which layer failed, what the run produced, and where each reading came from:

> "The search returned no route: a routing worker was killed for memory at the
> first item, and the job itself finished and reported the failure. Container peak
> 7.41 GiB of 8 GiB, kernel `oom_kill` 1 (0 at the fork), `failcnt` 0, read from
> the run's own record and the execution listing."

- **"The job failed" invites a peer to check the listing and correct you**, which
  is what happened: the listing said succeeded, and the peer was right to ask.
- **"The job succeeded" hides a search that produced nothing**, and if the run was
  a machine-sizing measurement, that is the whole result.
- **A harness may have filed the death as `transient`.** A memory kill is not
  retryable at the same size, so a retry classifier reading "worker died" as
  transient will loop a run that cannot pass. Say so when you report it.

## Notes

- **Deaths that predate the counters are weaker evidence.** Two earlier runs in
  the same measurement died the same way before the counters were being read;
  they can only be reported as "most likely memory". Add the counters to the
  harness the first time you see this, not the second.
- **A run that survives is not proof of headroom.** The same wish at the same size
  finished at 7.11 GiB of 8 GiB, and the pair differed only in which items each
  happened to work on. Report the margin, not the pass.
- **Prevention, where the worker is a local test leg**: [`leg-guard`](../../hooks/leg-guard/)
  refuses to start one while a peer's is running. It does **not** judge memory — a
  process count cannot see RAM — so it prevents the contention case, not this one.
  Worth installing anyway: on 2026-09-22 a leg died with `exit=-15` while memory read
  81% free, and the artefact was indistinguishable from an OOM kill. The guard that
  would have prevented it is a contention guard, and the reading that would have
  classified it is the receipt's refusal, not the exit code.
- **Sister skill**: [`parallel-gate-legs-killed-for-memory-count-cannot-see-ram`](../parallel-gate-legs-killed-for-memory-count-cannot-see-ram/SKILL.md)
  — the same killer one layer down, where a local test leg is stopped for memory
  on a shared machine and a process-count guard cannot see RAM.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.