Files
settled-reach/tooling/core/command.py
T
jpmschweitzerandClaude Opus 5 c924b0934e 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>
2026-08-31 16:46:19 +02:00

72 lines
2.7 KiB
Python

"""`@command` — the whole contract in one decorator (D-263).
Cross-cutting concerns are decorators, never call-site discipline. The point of
composing them here is that a command author **cannot apply half the contract**:
there is no way to get logging without error handling, or to remember one and
forget the other on the 40th command of a long porting session. That failure
mode is the reason D-263 makes these decorators rather than conventions.
Usage, and it goes UNDER the Typer registration so it wraps the function Typer
will call:
@app.command("client-version")
@command
def client_version() -> None:
...
"""
from __future__ import annotations
import functools
import os
from collections.abc import Callable
from typing import Any, TypeVar
from tooling.core import jobs
from tooling.core.errors import handle_errors
from tooling.core.logging import logged
F = TypeVar("F", bound=Callable[..., Any])
# Set by @command and asserted by the conformance test. A marker attribute is
# used rather than inspecting the composition after the fact, because unwrapping
# functools.wraps chains to prove "this was decorated" is brittle in exactly the
# way a conformance test must not be.
MARKER = "__reach_command__"
def command(func: F) -> F:
"""Compose the invocation contract onto one command function.
**Order is load-bearing, in both directions.**
`handle_errors` wraps `logged`, not the reverse: the logger's `finally` then
sees the ORIGINAL exception and records its type as the outcome. Invert them
and the error handler converts everything to `SystemExit` first, so every
failure is logged as "SystemExit" and the record says nothing about what
actually went wrong — while still looking like it worked.
The **job context is outermost**, wrapping both. It has to be: `logged`
emits from its `finally` and `handle_errors` emits its verdict while
unwinding, so a context established inside either would already be gone by
the time the two most important events are written. Those would then be the
only untagged lines in the log — and they are precisely the ones a detached
run is read back for.
"""
inner = handle_errors(logged(func))
@functools.wraps(func)
def wrapped(*args: Any, **kwargs: Any) -> Any:
# A detached child adopts the id its parent already reported; a
# foreground run mints a fresh one.
# A detached child adopts the id its parent already reported; a
# foreground run mints a fresh one.
token = jobs.begin(os.environ.get(jobs.ENV_JOB_ID))
try:
return inner(*args, **kwargs)
finally:
jobs.end(token)
setattr(wrapped, MARKER, True)
return wrapped # type: ignore[return-value]