Files
settled-reach/tooling/test_job_exit_codes.py
jpmschweitzerandClaude Opus 5 3b211a5450 feat(config): T-1279 — a detached failure reaches its caller
The non-negotiable from D-263, pointed at its worst hiding place: a foreground
command that swallows a failure at least does it in front of someone, while a
background runner that reports "started" and loses the failure does it where
nothing is watching.

Testing the two timing cases the ticket names — fails before the parent exits,
fails long after — needs a command slow enough to tell them apart, and every
verb in reach finishes in milliseconds. So `reach dev selftest` exists: emits
progress for N seconds, then optionally fails with a chosen code. A genuine
diagnostic rather than a test hook, in the dev domain the map already planned,
and the only way to answer "does streaming work here, can I tail it, does a
failure survive detach" by observation instead of argument.

The slow case is the one that proves the design. --detach returned in 75ms
while the child ran six seconds, so the parent was demonstrably gone long
before the child failed — and wait still relayed exit 7. That is the half of
the recording path only this case reaches, and why T-1277 moved completion
recording into the child.

Also pinned: --detach exits 0 for starting and SAYS "not succeeded" in words,
which the test asserts on rather than trusting the code to be read correctly;
a failed job nobody waited on shows as failed in jobs list; and every event a
detached job emits carries its job id.

Closed T-1278's open gap in passing — jobs log --follow had never run against a
genuinely long job because none existed. It now has: attached mid-flight,
streamed the remaining steps live, and caught the final verdict after the job
ended.

Proven to fail by making effective_exit_code always return 0 — the trap itself.
Both timing cases failed by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:14:23 +02:00

167 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""A detached job's exit code must survive (T-1279).
This is the non-negotiable in D-263, pointed at its worst hiding place. A
foreground command that swallows a failure at least does it in front of someone.
A background runner that reports "started" and then loses the failure does it
where nobody is looking, and whatever gated on the run carries on as if it had
passed.
Four properties, and the ordering of the cases matters:
1. `jobs wait` exits with the JOB's code, so a Makefile or hook can gate on a
detached run exactly as on a foreground one.
2. It works whether the job fails almost immediately or long after the parent
has exited. Those are different halves of the recording path: the fast case
can finish while the parent is still alive, the slow case certainly cannot,
and only the second proves the child records its own ending.
3. `--detach` exits 0 for STARTING, which is a different claim from the job
succeeding — so it has to say so in words rather than leave a reader to
infer it from a 0.
4. A failed job nobody waited on is visible as failed, not merely absent.
The failing cases come first deliberately. A job runner that has only ever run
successful jobs has never been tested.
Run: python3 tooling/test_job_exit_codes.py
"""
import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
JOB_ID = re.compile(r"^\d{8}T\d{6}-[0-9a-f]{4}$")
def _reach(*args: str) -> subprocess.CompletedProcess[str]:
"""Invoke by BARE NAME — never a path or an interpreter (T-1261)."""
return subprocess.run(
["reach", *args], capture_output=True, text=True, cwd=REPO_ROOT
)
def _detach(*args: str) -> tuple[str, subprocess.CompletedProcess[str], float]:
started = time.monotonic()
result = _reach("--detach", *args)
elapsed = time.monotonic() - started
job_id = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
return job_id, result, elapsed
def test_failure_survives(failures: list[str], seconds: str, code: str, label: str) -> None:
"""A job that fails is waited on and relays its own code."""
job_id, spawn, elapsed = _detach(
"dev", "selftest", "--seconds", seconds, "--fail", "--exit-code", code
)
if not JOB_ID.match(job_id):
failures.append(f"[{label}] --detach did not print a job id, got {job_id!r}")
return
if spawn.returncode != 0:
failures.append(
f"[{label}] --detach exited {spawn.returncode}; starting a job succeeded, "
"so the launch itself must report 0"
)
# Property 3: the 0 above must not be mistakable for "the job succeeded".
if "not succeeded" not in spawn.stderr:
failures.append(
f"[{label}] --detach's output does not distinguish STARTED from SUCCEEDED "
"in words; a bare 0 invites exactly the wrong reading"
)
# Property 2, slow case: the parent must be long gone before the child ends.
if label == "slow" and elapsed > float(seconds) / 2:
failures.append(
f"[{label}] --detach took {elapsed:.2f}s for a {seconds}s job — it waited, "
"which means this case is not testing what it claims to"
)
waited = _reach("jobs", "wait", job_id)
if waited.returncode != int(code):
failures.append(
f"[{label}] jobs wait exited {waited.returncode}, expected {code} — "
"the failure was lost between the job and its caller, which is the "
"exit-0 trap in the place nobody watches"
)
# Property 4: visible as failed without anyone having waited.
listed = _reach("jobs", "list", "--limit", "20")
row = [line for line in listed.stdout.splitlines() if job_id in line]
if not row:
failures.append(f"[{label}] the job is absent from jobs list")
elif "failed" not in row[0]:
failures.append(
f"[{label}] jobs list does not show the job as failed: {row[0]!r}"
)
def test_success_relays_zero(failures: list[str]) -> None:
job_id, spawn, _ = _detach("dev", "selftest", "--seconds", "0")
if spawn.returncode != 0:
failures.append(f"[success] --detach exited {spawn.returncode}")
return
waited = _reach("jobs", "wait", job_id)
if waited.returncode != 0:
failures.append(
f"[success] jobs wait exited {waited.returncode} for a job that succeeded"
)
def test_events_are_correlated(failures: list[str]) -> None:
"""Every event a detached job emits carries its job id."""
job_id, _, _ = _detach("dev", "selftest", "--seconds", "0")
_reach("jobs", "wait", job_id)
log = REPO_ROOT / ".cache" / "reach" / "jobs" / f"{job_id}.jsonl"
if not log.is_file():
failures.append("[correlation] no log file was written for a detached job")
return
for line in log.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
event = json.loads(line)
if event.get("job") != job_id:
failures.append(
f"[correlation] an event carries job={event.get('job')!r}, expected "
f"{job_id!r} — an untagged line cannot be attributed when logs interleave"
)
return
def main() -> int:
if shutil.which("reach") is None:
print(
"test_job_exit_codes: `reach` is not on PATH.\n Fix: make install-reach",
file=sys.stderr,
)
return 1
failures: list[str] = []
# Failing paths first — see the module docstring.
test_failure_survives(failures, seconds="0", code="3", label="fast")
test_failure_survives(failures, seconds="2", code="7", label="slow")
test_success_relays_zero(failures)
test_events_are_correlated(failures)
if failures:
print("test_job_exit_codes: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print(
"test_job_exit_codes: OK — codes relay through detach for fast and slow "
"failures, start is distinguished from success, events stay correlated"
)
return 0
if __name__ == "__main__":
sys.exit(main())