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>
70 lines
2.9 KiB
Python
70 lines
2.9 KiB
Python
"""Job identity, carried ambiently so no command has to know it exists (D-263).
|
|
|
|
Every invocation of `reach` is a job. It gets an id, and every event emitted
|
|
during it is tagged with that id — which is what lets a detached run's log be
|
|
read back later, and what correlates the lines of a run that streamed for nine
|
|
minutes.
|
|
|
|
**Ambient on purpose.** The alternative is threading a job through every command
|
|
signature, or having each command open one and remember to close it. That is
|
|
call-site discipline wearing a different hat, and it fails the same way: the
|
|
fortieth command of a long porting session forgets, and its output silently
|
|
loses its correlation with nothing to indicate anything is missing. `@command`
|
|
sets this up; commands never touch it.
|
|
|
|
A `ContextVar` rather than a module global, because a global is only correct
|
|
until something runs two invocations in one process — which a test harness or a
|
|
future batch verb does immediately, and which would then interleave two jobs'
|
|
events under one id with no error anywhere.
|
|
|
|
**Foreground runs get an id but no file.** Only detached runs persist (T-1277).
|
|
Writing a log for all four gate invocations on every push would create retention
|
|
pressure for output nobody reads. The cost is that a foreground run killed by a
|
|
timeout loses its output — which is exactly the case that should have used
|
|
`--detach`, so the tradeoff points the right way.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from contextvars import ContextVar, Token
|
|
|
|
_current: ContextVar[str | None] = ContextVar("reach_job_id", default=None)
|
|
|
|
# How a detached child learns which job it IS. Set by the spawning parent and
|
|
# read by @command, so the child's events land under the id the parent already
|
|
# reported to its caller — otherwise the returned id would name a log that
|
|
# nothing ever wrote to.
|
|
ENV_JOB_ID = "SR_JOB_ID"
|
|
|
|
|
|
def new_id() -> str:
|
|
"""A sortable, readable job id: `20260831T134512-a3f2`.
|
|
|
|
Timestamp-first so `jobs list` sorts chronologically by name alone, and
|
|
short enough to type or paste without friction. The four random hex
|
|
characters break ties within the same second — two invocations from one
|
|
script would otherwise collide and write to the same log.
|
|
|
|
**UTC, matching the `ts` field on every event.** Local time here would put a
|
|
job id and its own log lines two hours apart, which reads as a bug in the
|
|
logging every time someone correlates them by eye.
|
|
"""
|
|
return f"{time.strftime('%Y%m%dT%H%M%S', time.gmtime())}-{os.urandom(2).hex()}"
|
|
|
|
|
|
def begin(job_id: str | None = None) -> Token[str | None]:
|
|
"""Start a job context. Returns the token needed to end it."""
|
|
return _current.set(job_id or new_id())
|
|
|
|
|
|
def end(token: Token[str | None]) -> None:
|
|
"""End a job context, restoring whatever was current before it."""
|
|
_current.reset(token)
|
|
|
|
|
|
def current() -> str | None:
|
|
"""The id of the invocation in progress, or None outside one."""
|
|
return _current.get()
|