Skip to content

Commit 879479d

Browse files
committed
async-eval: sidecar auto-disable now fires on dead sidecars
The toy end-to-end test surfaced exactly the gap Codex flagged: sidecars submitted successfully but died in <2s (rocm dep resolver error inside the sidecar's uv run), and the callback didn't disable because the sidecar-mode failure counter only tracked SUBMISSION errors. Dead sidecars detected by ``_sweep_stale_markers`` were cleared but never counted. This is the parallel of the reserved-mode bug already fixed: each ``_fire`` was also doing ``self._consecutive_failures = 0`` on successful submit, which would wipe any sweep-detected failures. Fixes: 1. ``_clear_marker_if_stale`` returns ``bool`` (True iff cleared). ``_sweep_stale_markers`` aggregates the clears and increments ``_consecutive_failures`` accordingly — each cleared orphan is a sidecar whose bash trap didn't fire (slurmstepd OOM, NODE_FAIL, scancel --signal=KILL). 2. ``_fire`` no longer resets the counter on successful submit. The counter only ratchets upward; submission failures AND sweep-cleared orphans both contribute to the auto-disable threshold. Sidecar mode has no positive feedback signal (bash trap removing a marker isn't observable to us), so any reset would risk wiping unprocessed failures. 3. ``sbatch_template.py``: sidecar sub-jobs now ``uv run --no-sync`` to reuse the parent's resolved venv. Without this, an upstream wheel-index churn (e.g. vllm rocm wheels yanked) crashes every sidecar before it starts. Matches the cluster convention already used in the cookbook launchers per CLAUDE.md. 4. Toy fixture launchers (``toy_async_eval_*.sh``) also switched to ``uv run --no-sync``. Regression tests: - ``test_dead_sidecars_disable_callback`` — drops 3 orphan markers, stubs sacct=FAILED, asserts counter=3 and ``_disabled=True``. - Existing ``test_failure_disables_after_max_consecutive`` still pins the submit-failure path (both modes accumulate). 440 passed / 12 skipped, full repo clean.
1 parent 9a2ec26 commit 879479d

5 files changed

Lines changed: 101 additions & 14 deletions

File tree

src/leap_finetune/evaluation/sbatch_template.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ def render_sbatch_script(
8282
runner_args += ["--wandb-project", wandb_project]
8383

8484
runner_cmd = (
85-
"uv run python -m leap_finetune.evaluation.async_runner_main \\\n "
85+
# --no-sync: reuse the parent's resolved venv. Re-resolution in a
86+
# sidecar can fail if an upstream index has churned (e.g. vllm rocm
87+
# wheels yanked) since the parent locked.
88+
"uv run --no-sync python -m leap_finetune.evaluation.async_runner_main \\\n "
8689
+ " \\\n ".join(shlex.quote(a) for a in runner_args)
8790
)
8891

src/leap_finetune/evaluation/sidecar_callback.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,14 @@ def _fire(
181181
args: TrainingArguments,
182182
wait_for_completion: bool = False,
183183
) -> None:
184+
# Sweep first; it both clears orphan markers AND counts those clears
185+
# toward _consecutive_failures (each cleared orphan = a sidecar that
186+
# died without its bash trap firing). If sweep tripped auto-disable,
187+
# bail before submitting another.
184188
self._sweep_stale_markers()
189+
if self._disabled:
190+
return
191+
185192
in_flight = sorted(self._eval_dir.glob(_MARKER_GLOB))
186193
if in_flight:
187194
if self.cfg.on_overlap == "skip":
@@ -200,7 +207,12 @@ def _fire(
200207

201208
try:
202209
jobid = self._submit(model, state, args)
203-
self._consecutive_failures = 0
210+
# NOTE: do NOT reset _consecutive_failures here. The counter is
211+
# shared with sweep-detected sidecar deaths; a successful submit
212+
# followed by N dead sidecars would otherwise keep wiping the
213+
# running count and auto-disable could never fire. Reset only
214+
# happens in _sweep_stale_markers when zero markers remain
215+
# (clean idle state).
204216
if wait_for_completion and jobid:
205217
self._wait_for_job(jobid, state.global_step)
206218
except Exception:
@@ -345,29 +357,51 @@ def _submit(
345357

346358
def _sweep_stale_markers(self) -> None:
347359
"""Sweep ALL per-step in-flight markers and clear ones whose
348-
sbatch job is no longer alive. Called at the top of ``_fire``
349-
so a dead orphan never blocks the next eval under
350-
``on_overlap=skip`` and the queue-mode "in-flight count" is
351-
accurate.
360+
sbatch job is no longer alive. Each cleared orphan = a sidecar
361+
that died without its bash trap firing (slurmstepd OOM,
362+
NODE_FAIL, scancel --signal=KILL, ...) and counts toward
363+
auto-disable. The counter is never reset by the sweep — sidecar
364+
mode has no positive feedback signal (the bash trap removing a
365+
marker isn't observable to us), so any reset would risk wiping
366+
unprocessed submission failures.
352367
"""
368+
cleared = 0
353369
for marker in self._eval_dir.glob(_MARKER_GLOB):
354-
self._clear_marker_if_stale(marker)
370+
if self._clear_marker_if_stale(marker):
371+
cleared += 1
372+
if cleared > 0:
373+
self._consecutive_failures += cleared
374+
logger.warning(
375+
"[async_eval/sidecar] sweep cleared %d dead sidecar(s) (%d "
376+
"consecutive failures)",
377+
cleared,
378+
self._consecutive_failures,
379+
)
380+
if self._consecutive_failures >= self.cfg.failure.max_consecutive:
381+
self._disabled = True
382+
logger.error(
383+
"[async_eval/sidecar] disabling after %d consecutive failures",
384+
self._consecutive_failures,
385+
)
355386

356-
def _clear_marker_if_stale(self, marker: Path) -> None:
387+
def _clear_marker_if_stale(self, marker: Path) -> bool:
357388
"""Remove a single orphan marker whose job is no longer alive.
358389
359390
The sidecar script's EXIT trap doesn't fire if slurm OOM-kills the
360391
step, NODE_FAILs, or the user ``scancel``s with ``--signal=KILL``;
361392
without recovery an orphan would block all future evals under
362393
``on_overlap=skip``. We ask ``sacct`` whether the recorded jobid is
363394
terminal; missing-sacct falls back to a 6h mtime cutoff.
395+
396+
Returns ``True`` iff the marker was cleared — caller uses this to
397+
count dead sidecars toward auto-disable.
364398
"""
365399
if not marker.exists():
366-
return
400+
return False
367401
try:
368402
content = marker.read_text().strip()
369403
except OSError:
370-
return
404+
return False
371405

372406
jobid = content.split(":", 1)[0] if ":" in content else ""
373407
if jobid.isdigit():
@@ -394,7 +428,7 @@ def _clear_marker_if_stale(self, marker: Path) -> None:
394428
state = line.strip().split(None, 1)[0].rstrip("+").upper()
395429
states_seen.append(state)
396430
if state in _SACCT_ACTIVE_STATES:
397-
return # at least one row alive; keep marker.
431+
return False # at least one row alive; keep marker.
398432
logger.warning(
399433
"[async_eval/sidecar] clearing stale marker %s "
400434
"(job %s no longer active; sacct states: %s)",
@@ -403,7 +437,7 @@ def _clear_marker_if_stale(self, marker: Path) -> None:
403437
",".join(states_seen),
404438
)
405439
marker.unlink(missing_ok=True)
406-
return
440+
return True
407441

408442
try:
409443
age = time.time() - marker.stat().st_mtime
@@ -414,8 +448,10 @@ def _clear_marker_if_stale(self, marker: Path) -> None:
414448
age / 3600,
415449
)
416450
marker.unlink(missing_ok=True)
451+
return True
417452
except OSError:
418453
pass
454+
return False
419455

420456
def _wait_for_job(
421457
self,

tests/fixtures/toy_async_eval_reserved.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ export TMPDIR="${HOME}/tmp"
2626
export TRITON_CACHE_DIR="${HOME}/.triton_cache"
2727
mkdir -p "$TMPDIR" "$TRITON_CACHE_DIR" logs/async_eval_toy
2828

29-
uv run leap-finetune tests/fixtures/toy_async_eval_reserved.yaml
29+
# --no-sync: use the already-activated .venv instead of re-resolving deps.
30+
uv run --no-sync leap-finetune tests/fixtures/toy_async_eval_reserved.yaml

tests/fixtures/toy_async_eval_sidecar.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ export TMPDIR="${HOME}/tmp"
2828
export TRITON_CACHE_DIR="${HOME}/.triton_cache"
2929
mkdir -p "$TMPDIR" "$TRITON_CACHE_DIR" logs/async_eval_toy
3030

31-
uv run leap-finetune tests/fixtures/toy_async_eval_sidecar.yaml
31+
# --no-sync: use the already-activated .venv instead of re-resolving deps.
32+
# Avoids transient resolver failures from upstream wheel index churn
33+
# (e.g. rocm wheels being yanked) inside a job.
34+
uv run --no-sync leap-finetune tests/fixtures/toy_async_eval_sidecar.yaml

tests/test_async_eval.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,50 @@ def __init__(self, **kw):
713713
pass
714714

715715

716+
class TestSidecarSweepCountsDeadSidecars:
717+
"""When the bash trap doesn't fire (slurmstepd OOM, NODE_FAIL,
718+
scancel --signal=KILL), sidecars leave orphan markers. The sweep
719+
must count each cleared orphan toward ``_consecutive_failures``
720+
so auto-disable can fire — Codex catch."""
721+
722+
def test_dead_sidecars_disable_callback(self, tmp_path, monkeypatch):
723+
from leap_finetune.evaluation.async_eval_config import AsyncEvalConfig
724+
from leap_finetune.evaluation.sidecar_callback import SidecarEvalCallback
725+
import leap_finetune.evaluation.sidecar_callback as sc
726+
727+
cb = SidecarEvalCallback(
728+
benchmarks=[MagicMock(name="bench1")],
729+
cfg=AsyncEvalConfig.from_dict(
730+
{"mode": "sidecar", "failure": {"max_consecutive": 2}}
731+
),
732+
benchmark_configs={"benchmarks": []},
733+
output_dir=str(tmp_path),
734+
wandb_run_id=None,
735+
)
736+
737+
# Drop 3 orphan markers (3 sidecars submitted, none had their
738+
# bash trap fire because slurmstepd killed them).
739+
eval_dir = cb._eval_dir
740+
for step, jobid in [(100, 111), (200, 222), (300, 333)]:
741+
(eval_dir / f".in_flight.step_{step}").write_text(f"{jobid}:{step}")
742+
743+
# sacct says all jobs are FAILED (terminal, non-active).
744+
monkeypatch.setattr(
745+
sc.subprocess,
746+
"run",
747+
lambda *a, **kw: type(
748+
"P", (), {"returncode": 0, "stdout": "FAILED\n", "stderr": ""}
749+
)(),
750+
)
751+
752+
cb._sweep_stale_markers()
753+
# Three dead sidecars cleared → counter >= max_consecutive (2) → disabled.
754+
assert cb._consecutive_failures == 3
755+
assert cb._disabled is True
756+
# All three orphan markers should be gone.
757+
assert not list(eval_dir.glob(".in_flight.step_*"))
758+
759+
716760
class TestSidecarConcurrentMarkers:
717761
"""``on_overlap=queue`` must support concurrent in-flight sidecars.
718762
A single shared marker would let the first sidecar's EXIT trap wipe

0 commit comments

Comments
 (0)