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>
49 lines
1.6 KiB
Python
49 lines
1.6 KiB
Python
"""Transport for the `dev` domain — args in, delegate, format out."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typer
|
|
|
|
from tooling.core import cli, console
|
|
from tooling.core.command import command
|
|
from tooling.core.errors import ReachError
|
|
from tooling.domains.dev import service
|
|
|
|
app = cli.domain("dev", "Developer environment and self-diagnosis.")
|
|
|
|
|
|
@app.callback()
|
|
def _domain() -> None:
|
|
"""Keeps `dev` a group (Typer collapses a single-command app)."""
|
|
|
|
|
|
@app.command("selftest")
|
|
@command
|
|
def selftest(
|
|
seconds: float = typer.Option(2.0, "--seconds", "-s", help="Roughly how long to run."),
|
|
fail: bool = typer.Option(False, "--fail", help="Exit non-zero at the end."),
|
|
exit_code: int = typer.Option(1, "--exit-code", help="Which code to fail with."),
|
|
) -> None:
|
|
"""Emit progress for a while, then succeed or fail on purpose.
|
|
|
|
A real diagnostic — "does streaming work end to end on this machine, can I
|
|
tail it, does a failure survive a detached run?" — and the only command in
|
|
reach slow enough to answer those by observation rather than argument.
|
|
Every other verb finishes in milliseconds.
|
|
"""
|
|
for step, total in service.slow_work(seconds):
|
|
console.event(
|
|
f"step {step} of {total}",
|
|
phase="selftest",
|
|
progress=step / total,
|
|
)
|
|
|
|
if fail:
|
|
raise ReachError(
|
|
f"selftest failed on purpose after {seconds:g}s",
|
|
fix="this command failed because --fail was passed; drop it to succeed",
|
|
exit_code=exit_code,
|
|
)
|
|
|
|
console.verdict(f"selftest: OK — {seconds:g}s of progress, no failures")
|