implement-loop 20260905-094605: #423, #430, #431, #433, #435, #436, #437, #438, #439, #441, #442 - #453
Conversation
The `broker` fixture skipped on both paths — no `mosquitto` on PATH, and a broker that would not start — so a failed install in CI removed every suite built on the bus from the run and the gate still reported green. `no_broker` now decides by the environment: a skip where `CI` is unset, a failure where it says CI. A broker is software and belongs in the gate where hardware does not (#372), and a machine without one still runs everything else. The module docstring states both behaviours and why they differ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The six app command lines were written one after another and repeat each other: `_connected` byte-identical in seven modules, `PERIOD_S`, `BROKER_S` and `RETAINED_S` with the same docstrings six, six and four times, and the same argparse construction in the four apps that read documents. `lib` is where the shape goes, being the one import the apps share (ADR-0013). A sibling of `loading.py` rather than a part of it: what an app does when the railroad moves under it is that module's subject, and the way in is this one's. Nothing imports it yet. `retained` waits on `stop`, which is the rule `dropped` already states and the two apps that poll a retained window break. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_connected` was byte-identical in seven modules and the three startup numbers repeated with their docstrings; the four apps that read documents built the same three flags four times over, one comment included verbatim. All of it now comes from `lib/startup.py`. The two apps whose flags genuinely differ keep their own parser: the driver reads no documents and takes no `--store`, and the translator takes a station and no railroad. `dccex`'s window, waited out whole for want of anything to key on, keeps its reason — moved into `_retained` where the behaviour is, now that the constant it hung on is shared. Behaviour is unchanged here: the scheduler's and the dispatcher's polled retained wait is still their own and is the next commit's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#430) `lib/loading.py` states the rule: `retained_s` is a moment given to the broker, "waited on `stop` so a signal arriving in it ends the process rather than being sat on". `layout` and `dccex` do that; these two took no `stop` at all and polled `time.sleep(0.01)` until the row landed or the deadline passed, so a SIGTERM arriving in the window was honoured up to `RETAINED_S` — a second — late. Both now wait through `lib/startup.py`, which looks for the row a tick at a time and ends on `stop` between two looks. `serve` takes `retained_s` as `dccex`'s already does, so a test can hold an app inside a window and stop it there; it is the same number the reload's `dropped` is given. The test in each app fails against the old wait: with a 30-second window and a stop set inside it, the app is still running ten seconds later. The simulator's `_waiting` docstring loses the words `time.sleep`, which named no call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The term is load-bearing in sixty-odd places across src, tests and docs while the glossary told a reader to avoid it, and glossed it as a kind of restart where the code uses it for something explicitly not one. Give it an entry rather than rename it: an app clearing the retained rows it owns and building them again from nothing, without the process restarting, naming ADR-0060 for the railroad-loading case. Restart drops it from _Avoid_ and says how the two differ instead, and the section's preamble says why the new entry is not a fourth failure. No file outside CONTEXT.md changes.
Nothing compared `CONTEXT.md` against `lib/inventory.py`, so the glossary could name a topic that does not exist. It did once — `tc49/layout/ railroad_wanted` was in the prose two batches before the topic reached the inventory — right in advance rather than wrong, and nothing would have caught it had it never arrived. Same shape as `test_state_leaves.py` and `test_store_routes_are_proxied.py`: both sides read at run time, no copy of either list here, and the failure names the line and the topic. One reading covers the three ways the prose writes a name — a whole topic is a row, a prefix written with a trailing slash holds where the inventory has a topic under it or is that topic itself, and the bare namespace `tc49/` is the widest prefix of all rather than an exception. The reverse direction is out: the glossary is about words, not about every row. Per the batch's rulings, `prefix` is the glossary's word for a trailing-slash name and is used here in place of the issue's "family".
A mechanical extraction, no behaviour change: the loop over the bus's retained values in the constructor moves out to `_traction_rest`, beside `_traction_write` it calls, so a second caller can want the same thing. The point asymmetry moves with the loop, the startup reasoning stays where it is said.
An `off` that is applied writes `0.0` over every retained `wanted/traction` row before publishing `wanted/track`. "The railroad comes up at rest" is a promise about a power cycle as well as about a process start, and the startup rule does not reach a power cycle: an arrival zeroes only a train this app drives, so a plain `off` left a person's throttle standing in the row and the next `on` brought current back over it. Ordering is load-bearing — the translator acts on desired rows as they arrive, so the zeros are the last thing the station is told before the supply goes. Only on the applied `off`: a refused gesture changes nothing (ADR-0062), `stopped` is a run meant to resume from, and `wanted/point` has no resting value to write (ADR-0054). No new state, no new topic, no new field. Five tests in `tests/layout/test_power.py`, one of them on the order the two topics go out in and not on both merely having happened; `docs/layout/README.md` states the rule beside the guard it rides on.
Wording only. "Power cycle" was a term this repo does not have; the glossary names the two halves — a **power off** and the `on` after it — and says a **restart** is the process returning, which removing the supply is not.
`{train: null, mode: manual}` took every train `layout` holds a position
for, including one mid-transit under a grant. `_took` clears the
crossing's `driving` flag, so the arrival's `0.0` stopped going out and
the train kept its commanded speed past the block it was sent to,
standing only when somebody found it.
`_modes` now skips those trains and takes the rest — applied in part
rather than refused whole, so one automatic train under way does not
stop a person taking any train. `_driven` reads the same
`crossing.driving` flag off the same crossing `_took` writes. The
skipped trains go to the trace and nothing is published to name them:
`state/mode` is the level that says where every train's mode stands.
A gesture naming a train is untouched in either direction, and so is
`{train: null, mode: automatic}`.
SYSTEM.md's `mode_wanted` payload entry and its manual-driving section, and the layout README's map paragraph, each said `train: null` takes every train. `docs/layout/README.md` carries the reason and where the refused trains go.
Every test that presses a drag and then names the request it composed spelled the id `<train>-N` out. The next commit puts a per-process nonce in a gesture-minted id, so those spellings stop being writable — and they were never the thing under test in any of these, which are about what a cancellation frees or what order a held queue drains in. `tests/harness.py` grows `minted`, the id the scheduler minted for a train's nth request, off the trace's `request_submitted` lines; `test_cancellation.py`'s own `drag` returns the id it just caused, and `test_replay.py` compares train and ordinal either side of the nonce, which is what two runs of one document have to agree on. No behaviour changes here: these tests pass identically before and after the nonce lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) The counter starts empty in every process, and the scheduler and the dispatcher are separate processes now (ADR-0059), so a supervisor restarting the scheduler alone is ordinary: the next drag minted an id the dispatcher was still holding, which it drops at the top of admission before any check runs — no `request_admitted` and no `request_rejected` ever came back. That is #73's failure reproduced by a restart instead of a page reload. ADR-0033 settled this class by splitting the minters, and the split has moved inside this app now the panel mints nothing: a timetable keeps `<train>-N`, deterministic and in the file's order, because replay's traces are byte-identical; a gesture is minted `<train>-<nonce>-<n>` from `secrets.token_hex(4)`, made once at construction. Not clock- derived, which SYSTEM.md forbids of the bus, and not deterministic, which is what makes it work — a run carrying gestures never claimed determinism. The counter stays undivided (ADR-0036): the ordinal is still the last part. `nonce` is public so a test can assert the shape against the nonce the instance holds rather than against a regex for some hex. Re-minting a timetable's ids on a restart stays correct and is untouched, and `_reverse` composes no request, so it mints nothing either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ADR names the panel as the nonce-minter and the panel no longer mints, so the text was stale where it stands. An amendment block at its head says the split is unchanged and has moved inside the scheduler, that a timetable keeps `<train>-N` for the reason it always did, and that what forced the move is separate processes making an independent restart of the minter ordinary. Nothing about uniqueness-not-meaning changes. Beyond the issue's criteria, two pages that stated the shape as the whole of it: CONTEXT.md's **Request id** and SYSTEM.md's scheduler section both said the scheduler mints from one counter in the timetable's order, which is now half of what it does. No glossary term is introduced or retired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both gates that stop work over dead rails read the observed power, not the commanded one, so between `wanted/track: off` going out and the station reporting `device/track: off` a `run_wanted: running` is accepted, the sweep grants, and `_cross` writes a speed onto rails that are going dark. The outcome is the "safe but wedged" ADR-0040 already names, with ADR-0049's placement as the recourse, so neither gate changes and this is the record. The gate sites are named by path and symbol rather than by the line numbers the issue quoted: those were against 9e79384 and have since drifted, and no other ADR here cites a line.
The Moving entry stated one half — a manual train on its route is active and so moving. The complement is that a manual train off its route is neither, so `moving` reads false, `layout` writes its wheels on the throttle gesture alone, and a plain `off` under it is applied. That is a user error the app does not guard, and it is a scope declaration rather than a trade-off, so it gets no ADR. Criterion 3 sends the same qualification to the two pages that stated the unqualified half: DISPATCH.md said every train moves on a route the dispatcher allocated, and the layout's Power section said a plain `off` is applied only where nothing is moving. The layout's "no move while the rails are dead" paragraph gains the other half of the amendment, the value it reads being the observed one.
`_reverse` dropped a `reversal_wanted` where the train was in `_train_of.values()`, and `_train_of` was filled in one place — `_submit` — so the guard covered only the requests this scheduler minted. SYSTEM.md's rule 1 gives a request topic one responder and any number of writers, and its preamble names a page submitting beside the scheduler, so under a second submitter the arrow could be turned under somebody else's queued request: the request still departs the end the facing named when it was composed, and the move answering it reads as propelling. `_on_dispatch` now records `id -> train` off `request_submitted`, the leaf it already receives and ignored, read through `readable_id` and `named_train` and dropped where either is unreadable — the insertion side of what the answers already did by id. The write in `_submit` stays: same key, same value, and it holds the guarantee for this app's own requests across the round trip before the frame comes back. `_reverse`'s and `_on_dispatch`'s docstrings say the set is now every request the dispatcher has announced. `request_submitted` leaves the ignored-leaf test, no longer being one. SYSTEM.md is untouched: this implements its rule 1 rather than amending it, and the scheduler section's "one writer of requests" is that page's to state.
Press OFF while trains are moving and the last one reaches its sensor before the dispatcher answers the drain: #406 has the dispatcher republish the row whenever `moving` moves under a standing run word, so `{run: running, moving: false}` arrives, the panel read the value, and the wait went. The later `{held, moving: false}` found `draining` already false, `power_wanted: off` was never sent, and the railroad went quiet with the supply still on and the button back to OFF — the exact surprise the wait exists to prevent (ADR-0051, ADR-0062). The wait is now dropped by news about the run word: the previous word was not `running` and the new one is. The previous word is the one `wasRunning` already holds, captured into a local before the assignment above the drain block rather than into a second field. `drained` and `pressPower` are untouched, and nothing in `dispatcher` changes. `power.test.ts` gains the last-train case, which fails without the fix, and the `{draining, moving: false}` case that leaves the wait standing. The existing "drops the wait when the run is released" fed `running` twice — a sequence no dispatcher publishes, it comparing the whole row before it writes — and is rewritten to press, `draining`, `running`; its closing `{held, moving: false}` is kept as what makes "no `power_wanted`" mean the wait was dropped. Refs #441
PANEL.md said a row reading `running` while the wait stands is a drain somebody abandoned, which is the value and not the change and so no longer true of the code. ADR-0062's decision 4 already says "drops the wait when the run is released", which the fix makes literally so, and it is left as the record it is. Refs #441
…442) `mkdir -p "${TC49_STORE:-$HOME/tc49}"` expanded the variable in the deploy shell, while compose resolved the same one two lines later out of `--env-file /etc/tc49/deploy.env`. A box that moves its store by putting `TC49_STORE` in that file had `$HOME/tc49` made for it, nothing mounted it, and the daemon made the real source as root — the fault #387 added the line to prevent, and worse than before it, since the store no longer runs as root and cannot write what the daemon made. `scripts/store-root.sh` answers the question compose answers: the shell's value first, then the env file's, then `~/tc49`. The file is read rather than sourced, being the one place a secret sits on disk, and an unreadable or absent one leaves today's default. `deploy.sh` names the env file once and hands it to both the resolution and compose. The resolution is a script of its own because that is the part of the deploy a test can ask a question of without a box to ssh to; the tests sit with the other three lines that keep the store the person's.
The page documents `TC49_STORE` in the env file as the way to move the store, and now says which of the two places the deploy reads it from wins.
Range review —
|
…the timetable (#437) The range review of this batch found ADR-0033's amendment claiming a timetable's deterministic ids exist "for exactly the reason it always did: byte-identical replay". No replay takes that path. `bench/replay.py` feeds a document's requests as drags — deliberately, so that no app is given a scenario and every app keeps one placement path (#171, ADR-0030) — so a replayed request is minted like any other gesture, nonce and all. The claim was copied into CONTEXT.md and SYSTEM.md. Two separate things were hiding behind that one sentence. Two runs of one document have to agree id for id, or a benchmark trace is not comparable with itself. That was true by accident before the nonce and is false with a minted one, so `Scheduler` takes the nonce as an argument and the harness states it. Production is unchanged: the default is still `secrets.token_hex(4)`, and nothing but `bench/runner.py` passes anything. Nothing is protected by the value being unguessable — the collision a nonce prevents needs a second scheduler process, and a bench run is one. A run fed as drags and the same run fed as a timetable cannot agree on ids at all, whatever the nonce is: one shape carries one and the other does not. So #437's acceptance 4, that `tests/bench/test_replay.py` pass unchanged, was not meetable — the issue assumed replay rides the timetable path. Comparing ids with the nonce taken out is the right comparison there, the way that test already drops the departure end a gesture cannot state, and its docstring now says which of the two jobs it does. `Scheduler.nonce` goes with this: a caller that wants to know the nonce states it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The review's one unmet requirement is addressed, and the finding turns out to be half a defect and half a wrong issue.
What the review — and #437 — got wrong: acceptance 4 was not meetable. " The other half was a real gap: two runs of one document also have to agree id for id, or a benchmark trace is not comparable with itself. That held by accident before the nonce and stopped holding with a minted one.
Gate green on the result. The remaining review findings are follow-up work, not blockers, and get their own issues. |
The
implement-loopbatch of 2026-09-05 09:46, landed by hand after the runwas stopped early.
Each issue below passed
./scripts/check.shand a cold-agent audit during therun. The whole range then passed the gate again after reconciliation, and the
range review the run never reached is below.
Held rather than auto-merged. The range touches
.github/workflows/ci.yml(#423), which is on the run's protected list: a batch that can change what CI
runs does not get to merge on CI being green.
Why this branch has
-reconciledin its name. A session working in thesame checkout switched branches under the running loop at about 12:05. The run
did not notice: #439, #441 and #442 were committed onto that other branch
instead of onto
implement-loop/20260905-094605, which stopped at #438. Nowork was lost and nothing wrong was pushed — the other branch went to
mainas #452 carrying only its own commit. This branch is the run's work put back
in order: #423–#438 rebased onto current
main, then #439, #441 and #442cherry-picked on top, all five picks clean, gate green on the result. The
implement-loop/20260905-094605ref on the remote is the stale #438 tip andcan be deleted once this merges.
#443 was next; the run was stopped before it wrote anything.