workflow-fix: gate _eps_phase done on OOM-detection (GCP)
Highlight text on any card (body, plan, or an event) to anchor a comment, or leave a whole-task comment here. Mention @claude to summon a reply.
No comments yet.
Overview / Motivation
Auto-filed by the workflow-fix-on-bug protocol from a workflow-fix candidate raised on task #744 (emitting agent: orchestrator-self-observation). The GCP backend's startup-script success path published _eps_phase done even though the workload bash chain experienced an OOM-kill, masking a complete workload failure as success and stranding the orchestrator at verifying with no usable artifacts.
Goal
Harden the GCP backend's startup-script success path so _eps_phase done is never published when the workload bash chain rc-survived an OOM-kill or other SIGKILL — gate the explicit _eps_phase done on a positive workload-rc check (cgroup memory.events.oom_kill counter == 0, AND the planner-named primary deliverable paths actually exist) so an OOM'd run routes via the EXIT trap's phase=failed branch.
Workflow gap
-
Bug observed: On eps-issue-744 (
g2-standard-4/ 1× L4, ~16 GB host RAM), Phase 1dump_and_stream.py(workload python pid 1553,total-vm:51673852kB,anon-rss:14983132kB) was OOM-killed by the kernel atbroader pass1 7350/7389at 2026-06-30T04:34:12Z. The serial console showsoom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=google-startup-scripts.service,mems_allowed=0,global_oom,task_memcg=/system.slice/google-startup-scripts.service,task=python3,pid=1553,uid=0. systemd loggedgoogle-startup-scripts.service: Failed with result 'oom-kill'ANDUnit process 1155 (startup-script) remains running after unit stopped. Despite that, at 2026-06-30T04:34:14Z (2s after the kill) the workload log shows[phase=done] startup-script 2026-06-30T04:34:14Zand the completion sentinel/workspace/eps-issue-744/eval_results/issue_744/att-20260630-035706/.completion-sentinel.json({"phase":"done","issue":744,"attempt_id":"att-20260630-035706"}) was written.gcloud compute instances get-guest-attributes eps-issue-744 --query-path=eps/phasereturneddone.scripts/backend_poll.py --issue 744reportedstatus=done, current_phase=workload_done, pid_alive=false, new_milestone=true. The orchestrator advanced tostatus:verifyingeven though zero usable artifacts exist on disk (nobroader_random_pairs.pt,broader_summaries.json,population_stats.pt,eval_results/issue_744/,figures/issue_744/, noinstruct/arm — only Phase 0 corpora + the partial NS dump). -
Why it is a workflow gap: The startup-script's success-path code at
src/explore_persona_space/backends/gcp.pylines 1493–1515 unconditionally writes the completion sentinel and calls_eps_phase doneonce execution reaches that point. The EXIT-trap's failure branch at lines 1353–1365 fires only whenrc != 0. The bash chainspec.workload_cmd(herebash X && bash Y) somehow returnedrc=0to the parent startup-script even though its child python pid 1553 was SIGKILLed by the OOM-killer andset -euo pipefailis in effect — exact mechanism not yet pinned (the bash chain may have been in a state where the rc didn't propagate through the&&short-circuit +set -esemantics expected of it, or the OOM-killer's targeting only hit the python child while the bash chain races + ANOTHER subshell return path swallowed the failure rc). The async-poller failover predicateGcpBackend._is_gcp_async_workload_failureis gated oncurrent_phase == "terminal_workload_failed", so aphase=doneposted on a failed workload bypasses every defense and the only signal that the run actually failed is the absence of artifacts at the upload-verification step — which then either prompts manual recovery (this incident) or worse, a downstream task silently inherits empty inputs. Thephase=doneposting is the bug; the rc-propagation mystery is downstream of it. -
Confidence (emitter): medium (direct empirical evidence the bug exists, including the kernel OOM trace + the contradictory
phase=doneposting + the empty artifact tree; the root-cause mechanism for how rc=0 emerged from the OOM'd chain is not fully diagnosed and will be the spawned/issue --autosession's planner's job).
Proposed change (candidate diff sketch — refine in planning)
Inside _render_startup_script() in src/explore_persona_space/backends/gcp.py, between the existing workload_block and the existing _eps_phase done line:
+ # === Verify the workload actually succeeded BEFORE publishing done ===
+ # The bash chain's rc-propagation has known weakness modes (set -e + && +
+ # subshells/pipes) under SIGKILL. Cross-check the kernel's authoritative
+ # OOM signal AND the planner-named primary deliverable paths to refuse
+ # publishing done on a workload that the chain swallowed the rc for.
+ _eps_oom_seen() {
+ # cgroup v2: memory.events lists oom_kill counter. Any non-zero ⇒ the
+ # workload (or one of its children) was killed by the kernel oom-killer
+ # in this cgroup, regardless of what rc the bash chain claims.
+ for f in /sys/fs/cgroup/memory.events /sys/fs/cgroup/memory.events.local; do
+ if [ -r "$f" ] && grep -qE '^oom_kill [1-9]' "$f"; then return 0; fi
+ done
+ # systemd journal fallback (some images expose v1)
+ journalctl -k --since "10 min ago" 2>/dev/null | grep -q "Out of memory: Killed process" && return 0
+ return 1
+ }
+ if _eps_oom_seen; then
+ echo "[startup-script] OOM-kill detected in cgroup despite rc=0 — routing to failed" >&3 2>/dev/null || true
+ exit 137 # force the EXIT trap's failed branch
+ fi
+ # OPTIONAL — additional defense: verify the planner-named primary
+ # deliverable paths (handle.extra.expected_artifacts.git_paths) contain
+ # at least one file before publishing done. A workload that emits done
+ # but produced no expected artifact has self-disagreed.
cat > <sentinel> <<EOF
{"phase":"done",...}
EOF
_eps_phase done
The _eps_oom_seen check leverages the kernel-authoritative cgroup v2 memory.events oom_kill counter (always set when an OOM-killer hit the cgroup, regardless of what shell rc the bash chain reported). Forcing exit 137 re-routes through the existing EXIT trap's rc != 0 branch, which already does the right thing (_eps_phase failed + _eps_persist_diagnostics + shutdown). The optional primary-deliverable-existence check is a secondary defense against non-OOM silent failures.
A test should land alongside this in tests/test_gcp_backend.py covering: (a) a rendered startup-script body containing the new _eps_oom_seen guard; (b) a simulated cgroup memory.events fixture showing oom_kill 1 triggering exit 137 in the guard; (c) the existing happy-path rendering (no OOM, no exit, _eps_phase done lands) still passing byte-identical to the pre-change baseline.
Scope / surfaces
- Primary target:
src/explore_persona_space/backends/gcp.py(the only file where_eps_phase doneis rendered into the startup-script body — confirmed viagrep -rln '_eps_phase done'over.claude/ CLAUDE.md scripts/ src/; the only OTHER workflow-surface hit istests/test_gcp_backend.pyfor the regression test). - Grep the workflow surface for the pattern before editing (
grep -rln '_eps_phase done' .claude/ CLAUDE.md scripts/ src/explore_persona_space/backends/); update every hit if any new ones appear; list them in the plan.
Constraints / invariants
- Workflow-surface only — never experiment code,
configs/, ortasks/. scripts/workflow_lint.py --check-askspasses; ruff on touched files passes; ifworkflow.yamlorCLAUDE.mdchange, they stay consistent with this rule file.- This session runs under
EPM_WORKFLOW_FIX_SESSION=1(auto-set from theworkflow_fix_target:Provenance line below) and MUST NOT auto-route any of its own subagents' workflow-fix candidates (recursion guard,.claude/rules/workflow-fix-on-bug.md§ Recursion guard). - The new guard MUST NOT add wall-time to the happy path (one cgroup-events file read + one journalctl fallback is ~ms).
- The new guard MUST be idempotent on systems where cgroup v2 isn't exposed at the expected paths (a missing file is "no OOM seen", which preserves the pre-change happy path).
Provenance
- workflow_fix_target: src/explore_persona_space/backends/gcp.py
- fingerprint: c6c9455b338f
target_file: src/explore_persona_space/backends/gcp.py
bug_observed: On eps-issue-744 (g2-standard-4 / 1xL4), the workload python (pid 1553, 49 GB virt, 14 GB RSS) was OOM-killed at broader pass1 7350/7389 (2026-06-30T04:34:12Z); systemd logged 'Failed with result oom-kill'; yet the startup-script wrote the completion sentinel + published phase=done 2s later, the async poller reported status=done with no artifacts produced (no broader_random_pairs.pt, broader_summaries.json, population_stats.pt, eval_results/issue_744, figures/issue_744, no instruct arm).
why_workflow_gap: The startup-script's success-path block at gcp.py:1493-1515 unconditionally writes the completion sentinel + calls _eps_phase done once reached. The EXIT-trap's failure branch (gcp.py:1353-1365) fires only on rc != 0. Bash chain rc-propagation under set -e + && + SIGKILL has weakness modes that allowed the OOM'd chain to return rc=0 to the parent startup-script. The async-poller failover predicate _is_gcp_async_workload_failure gates on terminal_workload_failed, so a phase=done posted on a failed workload bypasses every existing defense.
proposed_change: Insert a kernel-authoritative OOM-detection guard (_eps_oom_seen reading cgroup memory.events oom_kill counter) immediately before the sentinel-write + _eps_phase done lines, forcing exit 137 on any detected OOM kill so the EXIT trap's failed branch fires correctly. Optional secondary defense: verify planner-named primary deliverable paths exist.
diff_sketch: |
- _eps_oom_seen() {
- for f in /sys/fs/cgroup/memory.events /sys/fs/cgroup/memory.events.local; do
-
[ -r "$f" ] && grep -qE '^oom_kill [1-9]' "$f" && return 0 - done
- journalctl -k --since "10 min ago" 2>/dev/null | grep -q "Out of memory: Killed process" && return 0
- return 1
- }
- if _eps_oom_seen; then
- echo "[startup-script] OOM-kill in cgroup despite rc=0 — routing to failed" >&3 2>/dev/null || true
- exit 137
- fi cat > ... _eps_phase done confidence: medium related_task: #744