feat(config): T-1276 — every invocation is a job, carried ambiently

Streaming as a decorator, first half. Each invocation of reach gets an id and
every event it emits is tagged with it, which is what will let a detached run's
log be read back and what correlates the lines of a run that streamed for nine
minutes. No command signature changed and no command imports core.jobs — that
is the point, per the D-263 amendment: a command must not know jobs exist,
because the alternative is call-site discipline wearing a different hat.

A ContextVar rather than 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 which would then interleave two jobs'
events under one id with nothing reporting an error.

The job context is the OUTERMOST wrapper, and 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 reset by the time the two most
important events are written — leaving them the only untagged lines in the log,
and they are precisely the ones a detached run gets read back for.

Fixed in passing: the job id used local time while every event's ts is UTC, so
an id read 155327 beside its own first log line reading 13:53:27. Two hours
apart reads as a logging bug every time someone correlates them by eye.

New conformance invariant — nothing outside core/ may import core.jobs. My
first version of it inspected only the module path, so it missed
`from tooling.core import jobs`, where the name is in the import LIST and which
is the form anyone would actually write. It passed while checking nothing.
Rewritten to catch all three reachable forms and then verified by committing a
real violation, which it named by file and line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:59:39 +02:00
co-authored by Claude Opus 5
parent b5beda0df7
commit 5d83e1d2eb
8 changed files with 203 additions and 7 deletions
+4
View File
@@ -22,3 +22,7 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-08-31 13:24:08.442', '2026-08-31 13:24:08.442', NULL, 'e9e9cdde0d2dff4f40df155ed2aafea3', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', '06G5FZDN9YBP0MZ021N3TJCFXM', '2026-08-31 13:24:11.142', '2026-08-31 13:24:11.142', NULL, 'a6d583edb124e48fd3cd118a08084346', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', '06G1S3D0M1TQW0GMFBBPQZG3ZM', '2026-08-31 13:38:12.778', '2026-08-31 13:38:12.778', NULL, '7c1ba64b8e8f5e2a0f877b4c950c84db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', '06G5G5TWD0VVBM4F2WZYAQFET0', '2026-08-31 13:52:24.203', '2026-08-31 13:52:24.203', NULL, 'd6a556a4dae5c27e36df7b4eba3539af', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', '06G5G5VJ89C7Q4EPR6Q73FSS74', '2026-08-31 13:52:24.697', '2026-08-31 13:52:24.697', NULL, '218b6d193f61cc4d57a2a003f9570ae6', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', '06G5G5WNGDMENP7AY9VMB5G3XM', '2026-08-31 13:52:27.554', '2026-08-31 13:52:27.554', NULL, '54243bd0f601e2c9e1c64fcd3b747175', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5WNGDMENP7AY9VMB5G3XM', '06G5G5XC1XX5179S0X6VFW0TM0', '2026-08-31 13:52:28.146', '2026-08-31 13:52:28.146', NULL, 'ecf87e352cbbe2b2aded206ce86212ba', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
+19
View File
@@ -1886,3 +1886,22 @@ Reason, and it follows from the primary-user statement in D-263: running reach w
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours no-args-prints-help-and-exits-0, and unknown-command-enumerates and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.
DONE 2026-08-31. Bare reach and bare ''reach <domain>'' print help and exit 0. Verified across the full matrix, because this change flirts with exactly the trap D-263 opens with making failures pass: bare reach 0, bare domain 0, --help 0, unknown domain 2, unknown verb 2, real failure (bad SR_REPO_ROOT) 1. All five are now PINNED in tooling/test_conformance.py as a sixth invariant, since an exit code is the kind of thing that regresses silently and no other assertion would notice. Proven to fail: setting the exit back to 2 made the test report both discovery cases by name. IMPLEMENTATION also removed a duplicated class, which is the part worth keeping. core/cli.py now holds ReachGroup carrying both shared behaviours — no-args-prints-help-and-exits-0, and unknown-name-enumerates — and main.py''s LazyDomainGroup EXTENDS it rather than subclassing TyperGroup directly, so it keeps only the lazy loading and the domain-specific wording. Before this the enumeration logic existed twice in slightly different forms, which is the drift that would have left the root and the domains disagreeing about their own conventions. ReachDomainGroup survives as an alias because domain routers read better with the name, but there is no separate behaviour: a domain group IS a reach group.', NULL, '2026-08-31 13:48:56', '2026-08-31 13:48:56.725', '2026-08-31 13:48:56.725', NULL, '0672c5ef76cf2432b82e0423a7c79a79', 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 ('06G5G433M6K7YSX3ZQFAMW95GC', 'status', 'in_progress', 'done', NULL, '2026-08-31 13:49:10', '2026-08-31 13:49:10.948', '2026-08-31 13:49:10.948', NULL, '26f5cb495e5751003bf007d45a2aa596', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 13:51:22', '2026-08-31 13:51:22.480', '2026-08-31 13:51:22.480', NULL, 'b5dfd6b0e01ec79286f5807ff9b28557', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'status', 'in_progress', 'in_progress', NULL, '2026-08-31 13:51:43', '2026-08-31 13:51:43.891', '2026-08-31 13:51:43.891', NULL, '36dbc2b13278365cecedb7f0f158aee0', 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', 'backlog', 'in_progress', NULL, '2026-08-31 13:52:48', '2026-08-31 13:52:48.626', '2026-08-31 13:52:48.626', NULL, 'f06eabafb682d251511f97b17127bda8', 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', 'description', '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.', '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.', 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;
+5
View File
@@ -35,3 +35,8 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'T-1273', '2026-08-31 13:23:59.951', '2026-08-31 13:23:59.951', NULL, 'b9643fcc4583630d76552c35dc0ed48d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZEM6JT5KKVE1RZ68DENSC', 'T-1274', '2026-08-31 13:24:07.865', '2026-08-31 13:24:07.865', NULL, 'dfd2948f52c1342b42f88097c0c43274', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G433M6K7YSX3ZQFAMW95GC', 'T-1275', '2026-08-31 13:44:24.225', '2026-08-31 13:44:24.225', NULL, '7870375af5df61aea8e9de0a34c98796', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'T-1276', '2026-08-31 13:51:51.124', '2026-08-31 13:51:51.124', NULL, '26715627d0a0b23130196f6a3760c868', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'T-1277', '2026-08-31 13:52:01.128', '2026-08-31 13:52:01.128', NULL, 'a149c3edb4cd37937c32dd479c2f85e9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'T-1278', '2026-08-31 13:52:06.723', '2026-08-31 13:52:06.723', NULL, '42e95f35b23f3db4daba0a2ff2f9ac3d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5WNGDMENP7AY9VMB5G3XM', 'T-1279', '2026-08-31 13:52:15.747', '2026-08-31 13:52:15.747', NULL, '8b3a53f0c442bfdb2538753116558a97', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5XC1XX5179S0X6VFW0TM0', 'T-1280', '2026-08-31 13:52:21.519', '2026-08-31 13:52:21.519', NULL, '06b24e04c2d29f8d70b0fbba278183b4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+38
View File
@@ -2156,3 +2156,41 @@ Reason, and it follows from the primary-user statement in D-263: running reach w
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours no-args-prints-help-and-exits-0, and unknown-command-enumerates and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.
DONE 2026-08-31. Bare reach and bare ''reach <domain>'' print help and exit 0. Verified across the full matrix, because this change flirts with exactly the trap D-263 opens with making failures pass: bare reach 0, bare domain 0, --help 0, unknown domain 2, unknown verb 2, real failure (bad SR_REPO_ROOT) 1. All five are now PINNED in tooling/test_conformance.py as a sixth invariant, since an exit code is the kind of thing that regresses silently and no other assertion would notice. Proven to fail: setting the exit back to 2 made the test report both discovery cases by name. IMPLEMENTATION also removed a duplicated class, which is the part worth keeping. core/cli.py now holds ReachGroup carrying both shared behaviours — no-args-prints-help-and-exits-0, and unknown-name-enumerates — and main.py''s LazyDomainGroup EXTENDS it rather than subclassing TyperGroup directly, so it keeps only the lazy loading and the domain-specific wording. Before this the enumeration logic existed twice in slightly different forms, which is the drift that would have left the root and the domains disagreeing about their own conventions. ReachDomainGroup survives as an alias because domain routers read better with the name, but there is no separate behaviour: a domain group IS a reach group.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:48:56.725', NULL, 'befbb50fcdf4b6b7e53e298311d13539', 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 ('06G5G433M6K7YSX3ZQFAMW95GC', 'task', '06G1S37Y1ARMV68RJT802Z3VPG', 'Bare reach is an alias for --help, and exits 0', 'Bare reach and bare ''reach <domain>'' currently print help and exit 2 — Click''s no_args_is_help raises a usage error. That was flagged as an open question on T-1260 and is now settled: they exit 0.
Reason, and it follows from the primary-user statement in D-263: running reach with no arguments is the DISCOVERY action, not a mistake. An agent learning what exists should not receive a failure code for succeeding, and one that branches on exit status would treat its own onboarding as an error. Nothing about D-263''s exit-code contract is weakened that contract governs FAILURES, and printing the domain list is not one.
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours no-args-prints-help-and-exits-0, and unknown-command-enumerates and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.
DONE 2026-08-31. Bare reach and bare ''reach <domain>'' print help and exit 0. Verified across the full matrix, because this change flirts with exactly the trap D-263 opens with making failures pass: bare reach 0, bare domain 0, --help 0, unknown domain 2, unknown verb 2, real failure (bad SR_REPO_ROOT) 1. All five are now PINNED in tooling/test_conformance.py as a sixth invariant, since an exit code is the kind of thing that regresses silently and no other assertion would notice. Proven to fail: setting the exit back to 2 made the test report both discovery cases by name. IMPLEMENTATION also removed a duplicated class, which is the part worth keeping. core/cli.py now holds ReachGroup carrying both shared behaviours — no-args-prints-help-and-exits-0, and unknown-name-enumerates — and main.py''s LazyDomainGroup EXTENDS it rather than subclassing TyperGroup directly, so it keeps only the lazy loading and the domain-specific wording. Before this the enumeration logic existed twice in slightly different forms, which is the drift that would have left the root and the domains disagreeing about their own conventions. ReachDomainGroup survives as an alias because domain routers read better with the name, but there is no separate behaviour: a domain group IS a reach group.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:49:10.948', NULL, '70975bb01ad76d50ddce27e7bbd3da16', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-31 13:51:22.480', NULL, 'b7d7bbf75cc0f627ccaa51c92a7cde47', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-31 13:51:43.891', NULL, '712b245b166616ac9cd06cbe86864b0b', 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.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:51:51.124', NULL, '862f8ea6892d5bb7e374b05ad4aea532', 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.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 13:52:01.128', NULL, '7f719d3330d36a2429fe5066a6577e5b', 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.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 13:52:06.722', NULL, '80174d012a18dbc46da3145b10f77283', 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 ('06G5G5WNGDMENP7AY9VMB5G3XM', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Exit codes survive a detached run — the non-negotiable', 'A runner that reports started and then loses the failure is the exit-0 trap from the top of D-263 relocated somewhere nothing is watching, which is strictly worse than the original: at least a foreground exit 0 is in front of someone. Requirements: reach jobs wait <id> EXITS WITH THE JOB EXIT CODE, so a Makefile or hook can gate on a detached run exactly as it would on a foreground one; a failed job that nobody waited on is visible as failed in reach jobs list rather than merely absent; and reach --detach itself exits 0 for SUCCESSFULLY STARTING, which is a different claim from the job succeeding, so the output must say so in words rather than leaving a reader to infer it. TEST THE FAILING PATH FIRST and give it a deliberately failing command, since a job runner that has only ever run successful jobs has never been tested — this is the same discipline that caught the gate tests. Also test the case where the job fails BEFORE the parent has exited, and where it fails long AFTER, since those exercise different halves of the recording path.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:15.747', '2026-08-31 13:52:15.747', NULL, 'be0987680480ae25059d8a28cb354112', 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 ('06G5G5XC1XX5179S0X6VFW0TM0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job log retention, so .cache/reach/jobs does not grow forever', 'An append-only log directory with no retention is a disk leak that nobody notices until it matters. Deliverables: a cap by count or age, applied at write time rather than by a separate sweep — a cleanup nothing invokes is a cleanup that does not happen; reach jobs prune as the explicit escape hatch; and a rule that a RUNNING job is never pruned regardless of age, since a long generator can outlive the window. Bound the whole thing to .cache/, which is gitignored and already the repo''s scratch space, so a wrong answer here costs disk rather than data. Deliberately last in the epic: retention pressure is not real until jobs exist and the log sizes are observable, and guessing a cap before seeing a Blender batch''s output volume would just be a number to change later.', 'backlog', 'low', NULL, NULL, 'D-263', '2026-08-31 13:52:21.519', '2026-08-31 13:52:21.519', NULL, '99cfce863916cf9655e365ed4adcf370', 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.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:52:48.626', NULL, '40a3358f3a729964918a9df2f7c277f9', 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.', '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;
+26 -7
View File
@@ -17,9 +17,11 @@ will call:
from __future__ import annotations
import functools
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
@@ -35,13 +37,30 @@ MARKER = "__reach_command__"
def command(func: F) -> F:
"""Compose the invocation contract onto one command function.
**Order is load-bearing.** `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.
**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.
"""
wrapped = handle_errors(logged(func))
inner = handle_errors(logged(func))
@functools.wraps(func)
def wrapped(*args: Any, **kwargs: Any) -> Any:
token = jobs.begin()
try:
return inner(*args, **kwargs)
finally:
jobs.end(token)
setattr(wrapped, MARKER, True)
return wrapped # type: ignore[return-value]
+10
View File
@@ -33,6 +33,8 @@ import sys
from datetime import datetime, timezone
from typing import Any, TextIO
from tooling.core import jobs
LEVELS: dict[str, int] = {"debug": 10, "info": 20, "warn": 30, "error": 40}
ENV_LEVEL = "SR_LOG_LEVEL"
@@ -115,6 +117,14 @@ def _now() -> str:
def _write(payload: dict[str, Any]) -> None:
# Tagged here rather than at each call site, so nothing can emit an
# uncorrelated line. The two events that matter most for a detached run —
# the verdict and the invocation record — are written while unwinding, and
# would be the ones a call-site approach missed.
job_id = jobs.current()
if job_id:
payload = {**payload, "job": job_id}
stream: TextIO = sys.stderr
if _render_as_text(stream):
stream.write(_render(payload))
+63
View File
@@ -0,0 +1,63 @@
"""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)
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()
+38
View File
@@ -103,6 +103,43 @@ def check_transport_isolation(failures: list[str]) -> None:
)
def check_jobs_stay_ambient(failures: list[str]) -> None:
"""No domain imports core.jobs — job identity is the decorator's business.
The whole point of carrying the job ambiently is that a command never has to
open one, tag one, or remember to close one. The moment a domain imports
`core.jobs`, that has become call-site discipline again, and it will fail the
same way: one command forgets and its output loses correlation silently.
core/ is exempt — command.py and console.py are where the ambience is
implemented.
"""
for path in _package_files():
if path.parent.name == "core":
continue
source = path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(path))
for node in ast.walk(tree):
# Three forms reach the same module and all three must be caught:
# from tooling.core import jobs -> module, names
# from tooling.core.jobs import ... -> module
# import tooling.core.jobs -> names
hit = False
if isinstance(node, ast.ImportFrom) and node.module:
hit = node.module == "tooling.core.jobs" or (
node.module == "tooling.core"
and any(alias.name == "jobs" for alias in node.names)
)
elif isinstance(node, ast.Import):
hit = any(alias.name == "tooling.core.jobs" for alias in node.names)
if hit:
failures.append(
f"[jobs] {path.relative_to(REPO_ROOT)}:{node.lineno} imports core.jobs — "
"job identity is ambient and belongs to @command; a command that "
"touches it has reintroduced the call-site discipline this replaced"
)
def check_single_output_path(failures: list[str]) -> None:
"""(2) Nothing prints but console."""
for path in _package_files():
@@ -229,6 +266,7 @@ def main() -> int:
failures: list[str] = []
check_transport_isolation(failures)
check_jobs_stay_ambient(failures)
check_single_output_path(failures)
check_commands_decorated(failures)
check_errors_name_a_remedy(failures)