feat(config): T-1277 — detach, and a failed job that looked busy
core/process.py spawns a child that outlives its parent: its own session, so a signal to the parent's group or a timeout kill does not take the work with it; re-execing reach by BARE NAME, because an absolute path would freeze the child to whichever checkout was current at spawn time and silently run the wrong source after a repoint; and streams kept separate exactly as in the foreground, events to <id>.jsonl and real output to <id>.out. Testing a case the ticket did not name found a real hole. Recording completion inside @command looked right and was wrong: a child that fails BEFORE any command runs — bad arguments, an unknown verb, an import error — never reaches that decorator. `reach --detach check bogus` left its metadata reading "running" forever with the process long gone. That is the exit-0 trap wearing a new disguise and worse than the original, because a failed job that looks busy sits somewhere nobody is watching, and a caller polling for completion would wait indefinitely on something that failed in milliseconds. So completion is recorded at the PROCESS's exit instead. main.py gains main(), wrapping cli() in a single try/finally, and the entry point moves to main:main. Every exit path now passes through one place. Removed from @command rather than left in both — two writers of one field is how they drift. Verified on three paths: success records done/0, a real drift failure records failed/1, and the parse failure that exposed the hole now records failed/2. One narrow conformance exemption, with its reason inline so it does not read as an oversight: the no-domain-imports-core.jobs invariant fired on main.py, correctly by its letter and wrongly by its purpose. main.py is not a command; it is the entry point, and it already owns --detach. Still open, and carried to T-1278: a child killed outright cannot record anything, so jobs list must reconcile against process liveness rather than trusting the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,41 @@ cli = typer.Typer(
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Console entry point — `reach`.
|
||||
|
||||
Exists so a detached child records its completion at the PROCESS's exit
|
||||
rather than at a command's. Recording it inside `@command` looked right and
|
||||
was subtly wrong: a child that fails before any command runs — bad
|
||||
arguments, an unknown verb, an import error — never reaches that decorator,
|
||||
so its metadata said `running` forever. A failed job that looks busy is the
|
||||
exit-0 trap wearing a new disguise, and worse than the original because
|
||||
nothing is watching a background job.
|
||||
|
||||
Here, every exit path passes through one `finally`.
|
||||
"""
|
||||
exit_code = 0
|
||||
try:
|
||||
cli()
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if isinstance(exc.code, int) else 1
|
||||
raise
|
||||
except BaseException:
|
||||
exit_code = 1
|
||||
raise
|
||||
finally:
|
||||
# Imported lazily and only when detached, so `reach --help` never pays
|
||||
# for it — the laziness T-1260 protects applies here too.
|
||||
import os
|
||||
|
||||
from tooling.core import jobs
|
||||
|
||||
if os.environ.get(jobs.ENV_JOB_ID):
|
||||
from tooling.core import process
|
||||
|
||||
process.finish_if_detached(exit_code)
|
||||
|
||||
|
||||
@cli.callback()
|
||||
def root(
|
||||
verbose: bool = typer.Option(
|
||||
@@ -130,6 +165,9 @@ def root(
|
||||
no_input: bool = typer.Option(
|
||||
False, "--no-input", help="Never prompt. Hooks and agents should always pass this."
|
||||
),
|
||||
detach: bool = typer.Option(
|
||||
False, "--detach", help="Run in the background; print a job id and return at once."
|
||||
),
|
||||
) -> None:
|
||||
"""Global options, declared once here so every domain inherits them.
|
||||
|
||||
@@ -147,3 +185,18 @@ def root(
|
||||
if verbose:
|
||||
console.set_level("debug")
|
||||
runtime.set_no_input(no_input)
|
||||
|
||||
if detach:
|
||||
# Handled here, before any domain loads, because detaching is a property
|
||||
# of the INVOCATION rather than of the verb — every command gets it and
|
||||
# no command implements it. The child re-runs this same argv with
|
||||
# --detach stripped, so it does the work instead of forking again.
|
||||
from tooling.core import process
|
||||
|
||||
job_id = process.spawn_detached(process.current_argv())
|
||||
console.out(job_id)
|
||||
console.verdict(
|
||||
f"started job {job_id} — this exit status means STARTED, not succeeded",
|
||||
fix=None,
|
||||
)
|
||||
raise typer.Exit(0)
|
||||
|
||||
Reference in New Issue
Block a user