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:
2026-08-31 16:46:19 +02:00
co-authored by Claude Opus 5
parent 5d83e1d2eb
commit c924b0934e
8 changed files with 329 additions and 5 deletions
+28
View File
@@ -1905,3 +1905,31 @@ DESIGN CALL RECORDED: only DETACHED runs will get a log FILE (T-1277). A foregro
NEW CONFORMANCE INVARIANT: no module outside core/ may import core.jobs. The moment a domain imports it, ambience has become call-site discipline again and will fail the same way one command forgets and its output loses correlation silently.
AND THE INVARIANT WAS INITIALLY BROKEN, which is worth recording because it is the exact failure the prove-it-can-fail discipline exists to catch. My first version inspected only node.module, so it missed `from tooling.core import jobs` where jobs appears in the NAMES, not the module path, and which is the form anyone would actually write. The test passed while checking nothing. Rewritten to catch all three reachable forms (from tooling.core import jobs / from tooling.core.jobs import x / import tooling.core.jobs), then verified by adding a real violating import to the check router: it failed and named the file and line. Reverted, green.', NULL, '2026-08-31 13:59:26', '2026-08-31 13:59:26.864', '2026-08-31 13:59:26.864', NULL, '49cef2cd6dbfa13ec368a4f4ccfa6bc7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:59:44', '2026-08-31 13:59:44.052', '2026-08-31 13:59:44.052', NULL, '204f43c01b5c81d015bf49bab1ec7884', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 14:39:00', '2026-08-31 14:39:00.853', '2026-08-31 14:39:00.853', NULL, '11267c3cc7467ec3043ade9a824cf0d9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'status', 'in_progress', 'in_progress', NULL, '2026-08-31 14:39:40', '2026-08-31 14:39:40.450', '2026-08-31 14:39:40.450', NULL, '9294dac8cf3b6ee18531d5df700c29d7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'description', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.
DONE 2026-08-31. Detached execution works end to end, and testing it found a real hole that the design as written would have shipped.
DELIVERED: tooling/core/process.py (spawn, metadata, liveness), the --detach flag on the root callback, and completion recording. Verified against a real spawn — parent returns the job id and exits 0, child runs on and writes .cache/reach/jobs/<id>.jsonl tagged with that id, plus a .out sibling and a .json metadata record.
THE THREE THINGS THE TICKET FLAGGED, each handled and each verified:
- start_new_session=True, so the child gets its own session and process group and a signal to the parent''s group does not take the work with it.
- The child re-execs `reach` by BARE NAME. An absolute path would freeze it to whichever checkout was current at spawn time, so after make reach-repoint a detached job would silently run the wrong source the exact failure that command exists to fix.
- The child records its own exit code. Confirmed on the success path (status done, exit_code 0) and on a real failure path (status failed, exit_code 1) using a drift fixture pointed at by SR_REPO_ROOT.
THE HOLE, found only because I tested a THIRD case the ticket did not name.
Recording completion 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. Verified: `reach --detach check bogus` left its metadata reading status "running" FOREVER, with the process long gone.
That is the exit-0 trap wearing a new disguise, and worse than the original: a failed job that looks busy, in a place nobody is watching. A caller polling for completion would wait indefinitely on a job that failed in milliseconds.
FIX: completion is now recorded at the PROCESS''s exit rather than a command''s. tooling/main.py gains main(), which wraps cli() in one try/finally, and [project.scripts] points at main:main instead of main:cli. Every exit path success, ReachError, usage error, unhandled exception now passes through a single finally. Re-verified: `reach --detach check bogus` records status failed, exit_code 2.
The recording was REMOVED from @command rather than left in both places; two writers of the same field is how they drift.
CONFORMANCE EXEMPTION ADDED, deliberately narrow. The new 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, --verbose and --no-input. Reading a job-id constant there is far less coupled than the --detach flag it already carries. Exempted main.py explicitly, with the reason inline so it does not read as an oversight.
STILL OPEN, and correctly belongs to T-1278: a child killed outright (SIGKILL, OOM, interpreter crash) still cannot record anything, so its metadata stays "running". process.is_alive(pid) exists for exactly this, and jobs list/status must reconcile against it rather than trusting the file.
reach --help is still 73 ms, so the entry-point wrapper costs nothing on the fast path.', NULL, '2026-08-31 14:45:58', '2026-08-31 14:45:58.410', '2026-08-31 14:45:58.410', NULL, '71a871771d1e7e33b8e8885d4c68d776', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'description', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.
FROM T-1277 (2026-08-31) the reconciliation requirement is yours and it is not optional. A child killed outright (SIGKILL, OOM, an interpreter crash) never gets to record its own completion, so its metadata file stays status=running with the process long gone. jobs list and jobs status must therefore RECONCILE against process.is_alive(pid) rather than trusting the file: a job whose pid is dead and whose status still reads running is not running, it died. Report it as such ''died without recording an exit'' is honest and actionable, whereas showing it as running is the exit-0 trap in a place nobody is watching, and a caller polling for completion would wait forever on something that failed in milliseconds. Note the ordinary failure paths are already covered: T-1277 moved completion recording to the process''s exit (tooling/main.py main()), so bad arguments, unknown verbs, ReachErrors and unhandled exceptions all record correctly. What remains is only the case where the process cannot run code at all.', NULL, '2026-08-31 14:46:03', '2026-08-31 14:46:03.939', '2026-08-31 14:46:03.939', NULL, 'a02f3f35330e271011c615aef8f9a141', 2) ON CONFLICT(hash) DO NOTHING;
+42
View File
@@ -2194,3 +2194,45 @@ DESIGN CALL RECORDED: only DETACHED runs will get a log FILE (T-1277). A foregro
NEW CONFORMANCE INVARIANT: no module outside core/ may import core.jobs. The moment a domain imports it, ambience has become call-site discipline again and will fail the same way one command forgets and its output loses correlation silently.
AND THE INVARIANT WAS INITIALLY BROKEN, which is worth recording because it is the exact failure the prove-it-can-fail discipline exists to catch. My first version inspected only node.module, so it missed `from tooling.core import jobs` where jobs appears in the NAMES, not the module path, and which is the form anyone would actually write. The test passed while checking nothing. Rewritten to catch all three reachable forms (from tooling.core import jobs / from tooling.core.jobs import x / import tooling.core.jobs), then verified by adding a real violating import to the check router: it failed and named the file and line. Reverted, green.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:59:26.864', NULL, '78a2584293788d032977d9d55f3398bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job identity as ambient context — the decorator half of streaming', 'The piece that must land before T-1250, so every ported command arrives already streaming rather than being retrofitted. Deliverables: tooling/core/jobs.py holding the CURRENT JOB as ambient state (a ContextVar, not a global, so it is correct if anything ever runs concurrently); core/command.py assigning a job id at the start of every invocation; core/console.py reading that id and tagging every event with it. NO COMMAND SIGNATURE CHANGES and no command imports jobs — that is the whole point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat and it fails the fortieth command into a porting session. Every invocation gets an id, foreground or not, so the two paths are identical and detach is purely a question of where the stream is written. DESIGN CALL to record: only DETACHED runs get a log FILE. A foreground run streams to stderr and writes nothing, because logging 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 precisely the case that should have used --detach, so the tradeoff points the right way. Acceptance: reach --verbose check client-version shows a job id on its events, and the conformance test proves no domain module imports core.jobs.
DONE 2026-08-31. Every invocation now carries a job id, and every event it emits is tagged with it. No command signature changed and no command imports core.jobs.
core/jobs.py holds the current job as a ContextVar not a module global. A global is correct only until something runs two invocations in one process, which a test harness or a future batch verb does immediately, and it would then interleave two jobs'' events under one id with no error anywhere.
ORDERING, which took a moment to get right and is the reason this belongs in the decorator rather than anywhere else. The job context must be the OUTERMOST wrapper: @logged emits from its finally and @handle_errors emits its verdict while unwinding, so a context established inside either would already have been reset 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 gets read back for. Composition is now jobs-context(handle_errors(logged(func))). Verified on both paths: success emits verdict and debug record sharing one id, and the failure path''s verdict is tagged too.
BUG FOUND AND FIXED IN PASSING: the job id used time.strftime() with LOCAL time while every event''s ts field is UTC, so a job id read 155327 while its own first log line read 13:53:27. Two hours apart, which reads as a logging bug every time someone correlates them by eye. Now time.gmtime().
DESIGN CALL RECORDED: only DETACHED runs will get a log FILE (T-1277). A foreground run streams to stderr and persists nothing writing a log for all four gate invocations on every push would create retention pressure for output nobody reads. Cost: 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.
NEW CONFORMANCE INVARIANT: no module outside core/ may import core.jobs. The moment a domain imports it, ambience has become call-site discipline again and will fail the same way one command forgets and its output loses correlation silently.
AND THE INVARIANT WAS INITIALLY BROKEN, which is worth recording because it is the exact failure the prove-it-can-fail discipline exists to catch. My first version inspected only node.module, so it missed `from tooling.core import jobs` where jobs appears in the NAMES, not the module path, and which is the form anyone would actually write. The test passed while checking nothing. Rewritten to catch all three reachable forms (from tooling.core import jobs / from tooling.core.jobs import x / import tooling.core.jobs), then verified by adding a real violating import to the check router: it failed and named the file and line. Reverted, green.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:59:44.052', NULL, '7cdf2b60220de848051b16d2deabdf43', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:39:00.853', NULL, '2c467f375858784348443ddc46bf8d3e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:39:40.450', NULL, 'd805b1a9979af2638cd461f44c517cfa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.
DONE 2026-08-31. Detached execution works end to end, and testing it found a real hole that the design as written would have shipped.
DELIVERED: tooling/core/process.py (spawn, metadata, liveness), the --detach flag on the root callback, and completion recording. Verified against a real spawn — parent returns the job id and exits 0, child runs on and writes .cache/reach/jobs/<id>.jsonl tagged with that id, plus a .out sibling and a .json metadata record.
THE THREE THINGS THE TICKET FLAGGED, each handled and each verified:
- start_new_session=True, so the child gets its own session and process group and a signal to the parent''s group does not take the work with it.
- The child re-execs `reach` by BARE NAME. An absolute path would freeze it to whichever checkout was current at spawn time, so after make reach-repoint a detached job would silently run the wrong source the exact failure that command exists to fix.
- The child records its own exit code. Confirmed on the success path (status done, exit_code 0) and on a real failure path (status failed, exit_code 1) using a drift fixture pointed at by SR_REPO_ROOT.
THE HOLE, found only because I tested a THIRD case the ticket did not name.
Recording completion 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. Verified: `reach --detach check bogus` left its metadata reading status "running" FOREVER, with the process long gone.
That is the exit-0 trap wearing a new disguise, and worse than the original: a failed job that looks busy, in a place nobody is watching. A caller polling for completion would wait indefinitely on a job that failed in milliseconds.
FIX: completion is now recorded at the PROCESS''s exit rather than a command''s. tooling/main.py gains main(), which wraps cli() in one try/finally, and [project.scripts] points at main:main instead of main:cli. Every exit path success, ReachError, usage error, unhandled exception now passes through a single finally. Re-verified: `reach --detach check bogus` records status failed, exit_code 2.
The recording was REMOVED from @command rather than left in both places; two writers of the same field is how they drift.
CONFORMANCE EXEMPTION ADDED, deliberately narrow. The new 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, --verbose and --no-input. Reading a job-id constant there is far less coupled than the --detach flag it already carries. Exempted main.py explicitly, with the reason inline so it does not read as an oversight.
STILL OPEN, and correctly belongs to T-1278: a child killed outright (SIGKILL, OOM, interpreter crash) still cannot record anything, so its metadata stays "running". process.is_alive(pid) exists for exactly this, and jobs list/status must reconcile against it rather than trusting the file.
reach --help is still 73 ms, so the entry-point wrapper costs nothing on the fast path.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:45:58.410', NULL, '4e69f4d0ddf1ff636d194e33cd142fd2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'The jobs domain — list, status, log --follow, wait', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.
FROM T-1277 (2026-08-31) the reconciliation requirement is yours and it is not optional. A child killed outright (SIGKILL, OOM, an interpreter crash) never gets to record its own completion, so its metadata file stays status=running with the process long gone. jobs list and jobs status must therefore RECONCILE against process.is_alive(pid) rather than trusting the file: a job whose pid is dead and whose status still reads running is not running, it died. Report it as such ''died without recording an exit'' is honest and actionable, whereas showing it as running is the exit-0 trap in a place nobody is watching, and a caller polling for completion would wait forever on something that failed in milliseconds. Note the ordinary failure paths are already covered: T-1277 moved completion recording to the process''s exit (tooling/main.py main()), so bad arguments, unknown verbs, ReachErrors and unhandled exceptions all record correctly. What remains is only the case where the process cannot run code at all.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 14:46:03.939', NULL, 'f9aac99fd6449919ab2d48455a81b6e7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;