You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
RS-13: close the no-radio items before the desk check and range-edge session - #150
Before: the next RS-13 radio session (the range-edge walk) and the RS-13.2 desk check were blocked on open no-radio items. Copilot's two findings on #144 were unaddressed. vector_stats could not show what the A16 repair sends, and the A8 relabel counts were lower bounds. The switch-gap guard had no firmware SIL case. The desk-check rehearsal's renderer gaps were still in the code, and the spec did not describe what #138 and #143 changed.
After: every no-radio item in front of the desk check and the range-edge session is closed or written up as a decision for the design owner. What is left needs the bench: rebuild both images from this merge, the camera-only step 0/1 pass, then the desk check. Radios stay parked until the operator gives a GO.
No firmware binary changes. The only L072 file touched is a host SIL test.
provision_bench_board.sh: the retire check now passes only units systemd cannot load. That means no unit file, or a mask that is a symlink to /dev/null. It also requires job= exactly. These are Copilot's two findings on Bench boards: retire the three failing boot units; provisioning enforces it #144. A systemctl link symlink and the linked / static / disabled / enabled states are retired by disable plus a move. alias, indirect, generated, transient and the -runtime forms are refused with a message and nothing changes.
deploy_base.sh (RS-5.18): ships only base_station/ firmware/ Dockerfile docker-compose.yml. It renames an old docker-compose.video-test.yml on the base to ….RETIRED-<date>, as the tractor's was renamed. It refuses to do so while a container made from that file is in any state but exited, and it never runs docker compose on it.
RS-5.15: the 25 May one-off scripts that carried "DO NOT RUN on a bench board" are deleted (24 in the repo root, plus tractor_restart_video_test.sh). The last commit that has them is cfb93d8.
RS-5.16: the four April firmware/tractor_x8/systemd/*.service sketches are marked obsolete, and SETTINGS_REFERENCE now lists them as deprecated.
RS-5.20: deploy_base.sh also leaves the tractor's compose files (firmware/tractor_x8/docker-compose*.yml, mosquitto.video-test.conf) out of the tarball, and removes an old copy from the base's tree. Each of those compose files maps /dev/ttymxc3 with restart: unless-stopped, and the base uses none of them. The script stops before copying anything if a YAML file in the tarball, other than the base's own docker-compose.yml, names a ttymxc UART or LIFETRAC_LORA_DEVICE outside a comment, or uses a compose route to host devices that names none: privileged (any value), device_cgroup_rules, or all of /dev. The files stay in git, because the tractor image is built from its own archive.
VECTOR encoder and SIL
vector_stats gains cumulative counters: frames, epochs_by (committed epoch starts per cause, exact for A8), share_frames, share_resends, share_repeats, carousel_resends and del_repeats. vector_dry_run.py tractor-log totals them on a counters line. The range-edge A16 confirmation and the results template now use them.
make check-rx-grid-policy sequences 10–11 pin what a 1.038 s switch gap does on the L072. At 2 fps, a base frame in the gap, or before the 8th tractor frame after it, is adopted, and the next demotion resets the tractor's clock. At 0.99 s the frame is refused.
A16 acceptance re-run on the merged fix: legs/a16_sil_summary_fix.json. The pre-fix a16_sil_summary.json is unchanged.
Operator console (RS-13.2 rehearsal findings)
F1/F2: the banner and chips are drawn in CSS pixels below the button bar and beside open side panels, and they wrap. In a narrow window they split at " · " and shrink to fit between open panels. RESYNC is now visible during the A16 run.
F3: §7.5 rule 1. A scene fault gives a black "VECTOR?" panel and one refusal POST per fault episode, at most one every 10 s.
F5: the banner reads "· age · loss". The store's snapshot gains frame_age_ms and loss (seq gaps over 10 s). A late frame, or a copy of a frame of the same seq chain, is not counted as loss. After more than 10 s of silence the next frame starts a new chain, even with the same seq.
F6: the "SAFETY DETECTOR: NO PIXELS" chip is drawn.
F8: the top line counts VS frames. The "enc:" pill shows the received codec when the pin cannot produce it.
A horizon without measured colours stays black instead of showing a built-in sky (principle 4).
Still open, as decisions: F4 (rule 5 for old clients), F7, F9, F10. See the desk-check doc, Appendix A.
firmware/murata_l072: make check and make check-rx-grid-policy pass with host gcc (-Wall -Wextra -Werror).
The encoder counters change no payload byte. The old and new encoders ran side by side over 3,300 photo frames, and over 1,280 A16, trigger-sweep and masked frames.
a16_sil.py: A16-like 0/100 at bw500 (243 B) and at bw250 (203 B), recovery max 10 / 11 frames, lossfree_out_of_step_frames 0. The results are identical before and after the counters.
Console: Chromium (Playwright) against web_ui from this tree, replaying the 2a A16 window with vector_replay_publish.py at 1280×720 and 1920×1080, plus console injections for rule 1 and the chips. The replays logged 0 refusal POSTs and 0 page errors. The injections gave exactly one POST per fault episode. A layout check at ten window sizes from 640×480 to 1920×1080, with no panel, either panel and both open, found no chip under a panel, except with both panels open below about 730 px wide, where even the 9 px banner does not fit between them.
Bench scripts: bash -n and shellcheck -S warning are clean. Throwaway fake-board harnesses passed 111 provisioning scenarios and 34 deploy scenarios, including the boards' 2026-10-12 replies. No scenario started or stopped a unit, ran docker compose or removed a container. A deploy_base.sh --dry-run tarball has none of the tractor's compose files. Twenty dry runs on test commits checked the guard. The committed tree (with the desk check's compose file), radio names only in comments, and /dev/shm or ./dev volumes pass. These stop the dry run: a /dev/ttymxc3 default behind LIFETRAC_LORA_DEVICE or another variable, LIFETRAC_LORA_DEVICE without a default, an environment value, a nested docker-compose.yml, ttymxc1, privileged as true, 'True', false, ${PRIV:-true}, a quoted key or JSON-form YAML, device_cgroup_rules, /dev:/dev, ["/dev/"] and an upper-case .YML.
Not done here (bench work, then the GO)
Rebuild lifetrac-v25:latest with deploy_base.sh and the tractor image from this merge. This is also deploy_base.sh's first real run (RS-5.19).
Camera-only step 0/1 from the image's /app with a bw500 video pass. The exact A8 counts come from that run.
The RS-13.2 desk check (no radio).
Range-edge Round A, on the operator's GO. It also needs the attenuator hardware and the P6/P8 sign-off.
The RS-12.15 reverse-delivery firmware fix comes in a separate PR. It gates only the FHSS command-delivery legs.
…t_stats
The A16 fix (e0309c8) cannot be seen on air: nothing on the tractor says
whether a frame was given the repair share, what the share re-sent, or how
many DELs went out twice. RS-13.1 A8 had the same gap for epoch starts:
the trigger of a 2 s vector_stats sample is a lower bound.
last_stats now also carries counts since the encoder was built, so a
periodic sample still counts every frame:
- frames: the encoder's frame number;
- epochs_by_trigger: committed epoch starts per cause (first, forced,
horizon, safety, relabel), counted in _commit_epoch, so a failed
attempt counts only when its start commits; they sum to epochs;
- share_frames: frames given a repair share (_share_due, one in four);
- share_resends, share_repeats: re-sends the share packed, and the
repeat-once (slot 6) copies among them (_pack_frame marks by_share);
- carousel_resends: slot-7 re-sends, by the share or under the kappa cap;
- del_repeats: DEL repeats packed (slot 5, exempt).
Counting only: nothing reads them back. Byte identity against cfb93d8,
both encoders interleaved frame by frame in one process (k-means is
seeded before each use): encoder_perf's ab.py photo runs (11 runs x 300
frames, synthetic sources) and 1280 more frames on the A16 scene at 243
and 203 B (400 each), a sweep over every trigger, V0-V3 and budgets
30-2436 B (330), and a self-mask (150). Every payload identical, every
pre-existing last_stats key equal. Encoder wall p50 27.0 / 27.3 ms,
p95 39.2 / 40.0 ms (old / new, interleaved).
On the A16 scene at 243 B, 400 frames: share_frames 100, share_resends
658 (53 repeat-once), carousel_resends 605 (all of them by the share),
del_repeats 1060 (2.65 a frame); at 203 B 100, 551 (122), 429, 995.
Tests: RepairCounterTests in test_vector_sync.py (frame by frame on the
A16 stream at both budgets: a share frame one in four that re-sends,
carousel re-sends equal to the packed slot-7 candidates, DEL repeats
equal to the DELs of ids the mirror had already dropped; and a hand-built
pack with and without the share), and
test_epoch_starts_are_counted_per_trigger in test_vector_encoder.py
(every cause in turn; failed attempts at 8 B count only on commit).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
… amendments
Writes the behaviour that PR #138 (merged 2026-10-04) and PR #143
(merged 2026-10-11) changed into the sections it changes. Each change is
marked "Amended: <what> (PR, anomaly or commit, date)" with the reason
and the text it replaced, and the status block indexes them:
- §3.3 DIGEST: the base also compares n_live (A1d).
- §3.3 LAYER_CLEAR: ids no longer start an epoch, and the refresh
re-states the kept layers (A3, A1c).
- §3.4 rule 4: a DEL naming an absent id is not an orphan, but still
counts as a record in the > 20 % rule (A16).
- §4.2 items 5, 7 and 8: the DEL repeat, the repair share (up to 25 %,
bound by the CONFIRM/DIGEST reserve, counted against κ) and the
re-statement; the wire order (_defines_first).
- §4.3: the per-pixel relabel trigger at RELABEL_EPOCH_FRACTION 0.50
(A8); ids under pressure: score order, cool-down, re-use statement
and eviction (A3); re-verification: keep and confirm, colour below the
FILL's resolution, re-sends and same-hash rules (A1, A1b, A2); the
re-statement of the kept layers (A1c); the DEL repeat and the repair
share (A16); the DIGEST exit from resync (c922efb); the tractor's
mirror TTL (A1).
Every statement was checked against encode_vector.py,
vector_scene_store.py and vector_scene/codec.py at cfb93d8; constants
are cited by name with their values. Documentation only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…r-log totals them
The vector_stats line gains frames, epochs_by (the committed epoch starts
per cause, as compact JSON), share_frames, share_resends, share_repeats,
carousel_resends and del_repeats, after the existing fields, so the
key=value parser and older summaries still read it. All are cumulative
since the encoder was built; camera_service builds it once per process
and the leg harness recreates camera_svc for every leg, so a leg's last
line is the leg's total.
tools/vector_dry_run.py tractor-log: summarise_counters() totals them to
the log's last line, starting a new run where frames falls (a restart),
and the summary prints a `counters` line: frames, epoch starts per cause
(MISMATCH when they do not add up to epochs), the repair counters and
ttl_dropped. Lines from a camera_service without the counters are left
out of it, and a log with none says so.
Docs: BENCH_RUNBOOK (tractor-log), RS13_RANGE_EDGE_LEG (the code-guards
prerequisite, the A16 confirmation and encoder rows, the Round A
record), RS13_VECTOR_LEG step 1 (exact epoch starts per cause, A8) and
the step-1 record in RS13_RESULTS_TEMPLATE.
Tests: test_vector_dry_run_sil.py round-trips the new fields through
camera_service's own line and adds the counters up over a restart.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
… vs 0.99 s)
The open item of the RS-13 code guards. camera_service's VECTOR_PREBUILD
guard keeps a switch to VECTOR under 1 s without a TX; this pins what a
longer gap does on FHSS, against the real consider_remote.
Sequence 10, the 1.038 s gap of the 2026-10-04 2d_r4 leg, in a 2 fps
stream (one fragment per 500 ms), the base a follower whose copy of the
grid lags 6 ms:
- a base frame 20 ms past the authority decay (in the gap) is handed
UNANCHORED and adopted (SNAPPED): the tractor's slots move 6 ms late,
onto its follower's copy of its own grid;
- streaming on rebuilds a full streak, but an adopted grid holds no
authority; the next demotion resets the adopted clock, and the next TX
re-anchors "the next slot starts now", renumbering the grid under the
base (the RS-12.12/14 lock-loss mechanism);
- just after the gap the first frame is streak 1 and a base frame is
adopted (ALIGNED);
- the window closes at the 8th frame after the gap (adopted after 7,
refused after 8): 38 ms + 7 x 500 ms = 3.54 s without authority.
Sequence 11, the same switch at 0.99 s: the base frame in the gap is
handed FRESH and refused (LOCKED_OUT), the first VECTOR frame continues
the streak (11), the grid is untouched, and a demotion keeps the
self-anchored clock.
Mutation check on scratch copies: 990 ms in sequence 10 fails 4 checks,
1.038 s in sequence 11 fails 6.
Traceability: sequence 10 names VECTOR_PREBUILD / _prebuild_vector_encoder
and test_vector_entry_guards.py VectorEncoderPrebuild; the guard's comment
in camera_service.py names sequences 10-11; the code-guards prerequisite
of RS13_RANGE_EDGE_LEG points at the target.
make check-rx-grid-policy and make check pass (host gcc, -Wall -Wextra
-Werror). On the L072 side only the SIL test file changes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
The range-edge prerequisites ask for the A16 fix's a16_sil.py summary
JSON in the repo; #143 merged the fix without one, and
legs/a16_sil_summary.json is the record of the shipped encoder
(4ab58b8, 33/100 and 60/100) that RESULTS.md cites, so it stays as it
is. New beside it: a16_sil_summary_fix.json and a16_sil_run_fix.log,
from this branch's encoder (the counters only; the code is unchanged
otherwise). The same run on main cfb93d8 gave identical results apart
from the timing fields (encode_ms_p50, replay_s), and all 24 episode and
trace files byte-identical.
case (A16-like / 100 drops) bw500 243 B bw250 203 B
real (60 s refresh) 0, max 10 frames 0, max 11
no safety refresh 0, max 9 0, max 12
lossfree_out_of_step_frames 0 in every case
As e0309c8 noted, the floor/floorall rows patch enc._pack with the
shipped packer's counterfactual (3/100 and 7/100 at 203 B, 0 and 7 at
243 B), so they measure that patch, not the fix.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…me (RS-13.2 F5/F8)
The RS-13.2 PC rehearsal (RS13_2_DESK_CHECK.md, Appendix A) found the
banner without its "· age · loss" (F5), and the console's "enc:" pill
and top line reading the operator's pin during VECTOR (F8). The renderer
commit that follows draws them; this one gives it the numbers.
Store (vector_scene_store.py), two snapshot keys:
- frame_age_ms: now minus the capture time of the newest applied frame,
whatever records it carried (rx - airtime - 200*AAAA, like every age
in the store; web_ui passes airtime 0). None before the first frame.
- loss: {window_ms, frames, lost, ratio} over the last 10 s, from
base_seq gaps (§4.5.2, the §4.5.4 window). Every VS frame is one
fragment, so a gap of n is n frames that never arrived. An
undecodable frame did arrive; the same seq again (an epoch-start copy)
is one frame; a jump of more than 128 is a restart; a gap after more
than 10 s of silence is not counted (the age shows it). ratio is
lost / (frames + lost), None when the window is empty; loss is None
while no frame has carried a seq. ingest() takes seq=, and
break_seq() starts a new chain.
web_ui:
- _ingest_vector_frame passes frame.base_seq and restarts the chain on
the first VS frame after another codec: the VS seq is its own counter
(RS-13.1 leg 2d goes from tile seq 124 to VS seq 1).
- _ingest_tile_delta sets encode_mode from the codec of every tile
frame, not only on the way out of VECTOR, so the snapshot names what
arrives (§7.1) and the pill can compare it with the pin.
- The /api/health/refusal docstring names vector_renderer.js as a
caller (rule 1).
Tests: BannerAgeLossTests (5) and the golden key list in
test_vector_scene_store.py; in test_web_ui_vector.py, age and seq-gap
loss in the snapshot, the chain restart after a tile frame, encode_mode
for every tile codec, and a vector_scene_fault refusal reaching
control/badge_refusal. Suite: 1607 passed, 2 skipped (was 1598, 2).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…, F5, F6, F8)
The PC rehearsal of the RS-13.2 desk check (RS13_2_DESK_CHECK.md,
Appendix A) found the banner under the CURL / DUMP / REQ CTRL buttons at
1280x720 (F1), RESYNC and DIGEST MISMATCH drawn past the 384 px edge and
clipped (F2), no rule-1 refusal (F3), no "· age · loss" (F5), no
"SAFETY DETECTOR: NO PIXELS" chip (F6), and the "enc:" pill and the top
line reading the pin and the snapshot rate during VECTOR (F8). The base
image is rebuilt once with these, and the desk check verifies them.
vector_renderer.js:
- Layout (F1, F2). The backing store follows the element's CSS size (x
devicePixelRatio, at most 2). The scene is drawn in its grid px
through a transform, stretched with the panel as before. The banner
and the chips are drawn in CSS px: below the top bar
(data-vector-avoid="below"), beside the settings drawer and the
telemetry panel while they are open ("beside"; when that leaves no
room in the panel's top half the chips go back to the plain layout),
12-18 px with the panel width, wrapping to a new row instead of
running off the edge. A resize lays them out again at once.
- Banner (F5): "VECTOR — NOT CAMERA PIXELS · age N.N s · loss N %" from
the snapshot's frame_age_ms and loss.ratio, "?" for either when it is
unknown. Chips in order: banner, safety chip, RESYNC or DIGEST
MISMATCH, epoch/anchor, anomalies.
- Safety chip (F6): drawn whenever the snapshot says safety_detector
"no_pixels", in every mode.
- Rule 1 (F3): v != 1, scene badge != 7, or an anchor_age_ms that is
absent or not a number gives a black panel "VECTOR?" with the banner,
and a POST to /api/health/refusal of {reason: "vector_scene_fault",
fault, v, badge, epoch, ts_ms}. anchor_age_ms null is the store's "no
anchor yet in this epoch" (a mid-epoch join) and is drawn ("anchor
none yet"). A scene that throws while drawn is refused the same way
(fault "render_error", with the message); the context is reset first,
so a save() the throw left open cannot make the black panel
see-through. One POST per fault episode, at most one per 10 s; an
episode that opens sooner posts when the 10 s have passed if it is
still open. The episode ends with the next scene drawn or a snapshot
outside VECTOR.
- No invented sky: a horizon without measured colours stays black
(the old fallback colours were made up).
- The Lab export (renderVectorScene) keeps its canvas size and posts
nothing.
index.html: the data-vector-avoid marks; cache-busting
vector_renderer.js?v=2 and app.js?v=9.
app.js (F8): during VECTOR the top line counts the VS frames the store
received (vector_scene.stats.frames_rx deltas), "N.N frames/s · vector
stream", instead of the snapshot rate, which is the 2 Hz keepalive
whatever arrives. The "enc:" pill compares the pin with the mode the
snapshot says is arriving, once a frame has arrived: a codec the pin
cannot produce shows "enc: <pin> → <received> ▸" as a mismatch;
full/rawstream and motion_only/y_only are one family each, and within a
family the tractor ack still decides, as before.
Checked in Chromium (Playwright) against web_ui from this tree, with
tools/vector_replay_publish.py replaying 2a #473-#595 onto a loopback
broker. At 1280x720 and 1920x1080, with raw mode on and off, the banner
and every chip are whole and readable; DIGEST MISMATCH at #525-#526,
RESYNC from #527 to #589. The browser saw the same resync rows as the
store off line, and no refusal POST in 2 x 123 frames. The desk check's
console injections give a black "VECTOR?" and 1 POST for a 3.5 s fault
at 2 Hz, each relayed on control/badge_refusal. The chips wrap at
640x480 to 1024x600 and stay sharp at devicePixelRatio 2.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…y open
The two commits before this one fix the rehearsal's findings F1, F2,
F3, F5, F6 and F8 (Appendix A). The base image is rebuilt once with
them, and the desk check confirms them on the base:
- Step 1 greps the image for the fixes as well ("SAFETY DETECTOR" in
vector_renderer.js, frame_age_ms in the store); a 0 means a tree from
before them.
- The checklist rows C1-C3, C7, C15, C18-C20 give the PC result after
the fixes, marked "fixed 2026-10-14", next to the rehearsal's; C1 is
also checked with the telemetry panel and the settings drawer open,
C18 covers the pill and the line above the panel, and C11 points to
the F4 decision.
- Appendix A: a status column in the findings table, the PC check of
the fixes (2a window at 1280x720 and 1920x1080, raw mode, console
injections), what it did not cover, and what F4 needs decided before
any code.
- Step 5 corrected: the 2a window's 12.5 s join resync (#473-#497) was
the rehearsal store's (bb4a207). Since 4b464cb the window starts
without one (#501-#505, #514-#523, then A16 #527-#589), in the
browser and off line; the injected-drop episodes are unchanged, and
only the joins at the start of the full captures move.
- A16: the RESYNC chip now shows for the whole run; the banner's loss
is no cue there (0 % from #558 while the scene is one shape short).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…xactly
Addresses the two Copilot review findings on provision_bench_board.sh
chk_units_retired:
- It passed any symlink in /etc/systemd/system and any is-enabled word but
enabled*, so a 'systemctl link' symlink (linked), a static or disabled
unit file, or a -runtime state read ok. Now a unit passes only when
systemd cannot load it from a file: is-enabled finds no unit file (on
systemd 250 no word, only "Failed to get unit file state ...: No such
file or directory"; later versions print not-found) and nothing of that
name is left there, or it reads masked and the path is a symlink to
/dev/null. It must also be inactive or failed, with no <unit>.d left.
- unit_job must read exactly "job=" (the empty line both boards gave on
2026-10-12); an unreadable reply no longer passes.
The apply step disables (never --now) and moves enabled, disabled, static
and linked units, moves a dangling or non-mask symlink, leaves a mask in
place, and refuses -runtime, alias, indirect, generated, transient and any
word that does not match what is in /etc/systemd/system, before it changes
anything. It still never starts or stops a unit, never runs docker compose
and never removes a container. The documented units_gone() guard follows
the same rule. The 2026-10-12 board replies still read ok.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…the May one-offs
RS-5.18 (operator OK): deploy_base.sh ships only base_station/, firmware/,
Dockerfile and docker-compose.yml, whatever the commit: no longer
docker-compose.video-test.yml and no longer deploy/ (an old deploy/ on the
base goes with the clean; the backup keeps it). An old
docker-compose.video-test.yml in the deployed tree is renamed
docker-compose.video-test.yml.RETIRED-<PC UTC date> (-2, -3 ... when taken),
as the tractor's compose file was, before the backup is made. The rename is
fail-closed: it runs only while every container made from that file
(compose project lifetrac-vtest, or the file among its compose config
files) is exited; any other state, or a docker ps that fails, stops the
deploy before anything changes. It never runs docker compose on that file.
DEPLOY.md, BENCH_BOARDS.md ("Retired boot units", rule 7) and the
RS13_2_DESK_CHECK.md manual route and port note follow.
RS-5.15: delete the 24 repo-root scripts and
firmware/x8_lora_bootloader_helper/tractor_restart_video_test.sh, each of
which opened with "DO NOT RUN on a bench board" (or "bench base"). Rules 1,
4, 6, 7 and 8 in BENCH_BOARDS.md now only list the removed names; the last
commit that has them is cfb93d8. The two deploy/*.service headers note that
their install scripts are deleted, with their line numbers unchanged
(SETTINGS_REFERENCE cites :33).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
….16)
firmware/tractor_x8/systemd/lifetrac-{camera,logger,params,time}.service
(a90ae75, 2026-04-27) are installed on neither bench board and no script
installs them. They run the host's python3 from
/opt/lifetrac/firmware/tractor_x8/, which does not exist on the tractor, and
three want a host mosquitto.service; the tractor's lifetrac-camera.service
is firmware/x8_lora_bootloader_helper/lifetrac-camera.service. Each now
opens with the same OBSOLETE - DO NOT INSTALL header as the deploy/ units.
Comments only. Both images copy these files (the tractor's COPY . /app, the
base's COPY firmware/), so their copies gain the headers at the next build.
SETTINGS_REFERENCE.md rows 588-590 go from live to deprecated, the status the
deploy/ units use, with the shifted line numbers; row 587 keeps live (its
source is the Dockerfile) and points the systemd variant at its new line.
No line is added, so the line citations into SETTINGS_REFERENCE stay valid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
frame_age_ms and loss in the snapshot, rule 1's null anchor, render errors
and refusal cadence, the banner text and its layout clear of the console
chrome, and the horizon left black without measured colours. The safety
chip's trigger is left open: the code follows the codec, the spec asks for
the detector's input age.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
deploy_base.sh shipped all of firmware/, so the tractor's
docker-compose.yml and the two video-test compose files reached the base
tree and image. Each maps /dev/ttymxc3 with restart: unless-stopped, and
the base uses none of them.
- The tarball leaves out firmware/tractor_x8/docker-compose*.yml and
mosquitto.video-test.conf (git archive exclude pathspecs).
- It stops before copying anything if a compose file in the tarball maps
/dev/ttymxc3.
- The board-side script removes an old copy from the deployed tree, also
with --no-clean; the backup keeps it.
- DEPLOY.md, BENCH_BOARDS.md rule 7, the desk check's manual route and
TODO RS-5.20 say the same. The files stay in git: the tractor image is
built from its own archive.
Checked: bash -n and shellcheck -S warning clean on the script and the
generated board script; a --dry-run tarball has none of the files; a test
commit with a compose file mapping /dev/ttymxc3 stops the dry run.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
Copilot review of #150: a frame that arrived behind the newest moved the
seq chain back, so 10, 11, 10, 12 read one frame lost of four, and 10, 9,
11 one lost, although every distinct frame arrived.
A seq at most REORDER_MAX = 8 behind the newest is now late or a copy:
it never moves the chain back, a copy of a frame in the window is not
counted again, and a late frame takes back the loss its gap counted. A
step further back is still a restart, and a forward gap is counted as
before. Two tests cover copies, reorders, late fills, an open gap and a
restart. VECTOR_SCENE.md's loss amendment says the same.
TODO RS-5.20 also notes that build_tractor_image.sh unpacks the tractor
tree under /home/fio/lifetrac_build on the base during a build.
base_station: 1613 passed, 2 skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
…uard
Copilot review of #150, second round.
- Store: copies are looked for in the current seq chain only. 1, 2,
another codec, then 1, 3, 2 read one lost of five, because the old
chain's 2 hid the new chain's late 2. Each window entry now carries its
chain; a new chain (first frame, restart, silence, another codec) bumps
it. New test reproduces (4, 1) on the old code and gives (5, 0).
- deploy_base.sh: the tarball check matched only device entries that start
with a literal /dev/ttymxc3, so "${LIFETRAC_LORA_DEVICE:-/dev/ttymxc3}"
would pass. Now any YAML file in the tarball other than the base's own
docker-compose.yml stops the deploy if, outside a comment, it names
ttymxc3 or LIFETRAC_LORA_DEVICE (the base's .env sets it to
/dev/ttymxc3) or runs privileged. Ten dry-run cases in a scratch clone:
the tree as committed passes; interpolated defaults, the variable without
a default, privileged true/'True', an environment value and a nested
docker-compose.yml stop it; comments only and privileged false pass.
- DEPLOY.md, TODO RS-5.20 and VECTOR_SCENE.md's loss amendment say the
same.
base_station: 1614 passed, 2 skipped. shellcheck -S warning and bash -n
clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
When an open side panel pushes the chip layout below half the canvas height, this fallback discards every panel bound and draws the chips across the panels again. The settings drawer has z-index 40, while the VECTOR canvas has z-index 5 (index.html:244–250,96–98), so overlapping banner and RESYNC text is obscured. Preserve panel avoidance at narrow widths, for example by wrapping into the available space or reserving a visible alert strip. Add a narrow-window case with the drawer open to the layout checks.
Copilot review of #150, third round: with both side panels open in a
narrow window, the chip layout gave up on avoiding the panels and drew the
banner and RESYNC under the settings drawer and the telemetry panel
(z-index 40, over the canvas's 5).
When the chips do not fit beside the open panels in the top half, each
chip now splits at its " · " (the banner into "VECTOR — NOT CAMERA
PIXELS", "age …" and "loss …") and the font steps down to 9 px before the
old fallback. Chromium at ten window sizes from 640×480 to 1920×1080, with
no panel, either panel and both open: no chip under a panel, except with
both panels open below about 730 px wide, where even the 9 px banner does
not fit between them. 800×600 now fits at 12 px, 760×560 at 11 px and
730×540 at 9 px. No page errors. The desk check and VECTOR_SCENE.md
describe the rule and that residue.
base_station: 1614 passed, 2 skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
After more than 10 seconds of silence, a reused sequence number is discarded from loss accounting before freshness is checked. Seq 14 at 10,000 ms followed by seq 14 at 21,000 ms reports frames=0, ratio=None despite the new arrival. This can occur after a restart or sequence wrap during an outage. Deduplicate only while the baseline is fresh, so the recovery frame starts a new chain. Add a regression for this case alongside the existing silence test.
…ilence seq
Copilot review of #150, fourth round.
- deploy_base.sh: `"privileged": true` (a quoted key, or JSON-form YAML)
passed the check, which matched `privileged:` followed by true. Rather
than chase spellings, a shipped YAML file other than the base's own
docker-compose.yml now stops the deploy if, outside a comment, it names
a ttymxc UART or LIFETRAC_LORA_DEVICE, or uses any of compose's routes
to host devices that name no device: `privileged` with any value (false
is the default, and a value can come from "${VAR}"),
`device_cgroup_rules`, or all of /dev as a device or volume. YAML names
match in any case. 20 dry-run cases on test commits in a scratch clone:
the tree as committed (with the desk check's compose file), radio names
only in comments, and /dev/shm or ./dev volumes pass; both quoted keys,
JSON form, privileged false and ${PRIV:-true}, device_cgroup_rules,
/dev:/dev, ["/dev/"], ttymxc1, an upper-case .YML and the round-two
cases stop it.
- Store: the same seq after more than 10 s of silence was taken for a copy
and dropped, so 14 then 14 eleven seconds later read frames=0. A copy
is now looked for only inside the window; after a silence the frame
starts a new chain. New test; it fails on the previous code with that
reading.
- DEPLOY.md, TODO RS-5.20 and VECTOR_SCENE.md's loss amendment say the
same.
base_station: 1615 passed, 2 skipped. bash -n and shellcheck -S warning
clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Radio-safety deployment guards and fail-closed console changes warrant human review, with real-board validation and design-owner decisions still pending.
Base station protocol tests failed in the push run on 220ea72. The failing test is test_MR_C_raises_after_30s_monotonic_deadline, which recorded 13,355 sleeps against a limit of 10. The same job passed in this commit's pull_request run.
The suite reloads web_ui about 40 times, so about 40 of these threads are alive.
test_mqtt_retry_backoff_sil.py patches sleep and monotonic on the shared time module.
If a worker wakes while the patch is in place, it calls the fake sleep. The fake sleep returns at once, so the worker spins and fills clock.sleeps until the patch ends.
Forcing a worker to wake inside that window reproduces the failure (49,366 sleeps). This PR adds no thread and no reload.
The fix belongs in the test: the fake clock should apply only to the thread that created it, and other threads should get the real sleep and monotonic. That fix is now in #151. The one re-run of the failed job passed.
Base station protocol tests failed once on #150 (220ea72, push run):
test_MR_C_raises_after_30s_monotonic_deadline recorded 13355 sleeps
against a limit of 10. The same commit passed in its pull_request run.
Each web_ui import starts a radio-auto daemon (#91) that loops on
time.sleep(5.0), and the suite reloads web_ui about 40 times. The SIL
patches sleep and monotonic on the time module, which every thread
shares, so a worker that woke inside a patched window called the fake
sleep, which returns at once, and spun until the patch ended, filling
clock.sleeps. Forcing a worker to wake inside MR-C's window reproduced
it (49366 sleeps).
The fake clock now applies only to the thread that made it. Other
threads get the real sleep and monotonic. The same forced wake records
the 9 expected sleeps. New MR-E test: another thread sleeping while the
clock is patched leaves clock.sleeps empty and reads real time. It fails
on the old clock with [0.01, 0.01, 0.01].
base_station: 1616 passed, 2 skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
… stale
- DESIGN-CONTROLLER/TODO.md: RS-12.15's reverse-delivery half is in
progress. Its entry gives the fix (de2ae16 + f9cc98b), the SIL results,
the bench build step, the on-air legs V/W/X with PASS and stop criteria,
and the items open for the design owner (decay window, lead tolerance,
self-follow in scan FAILED, half-duplex collisions). The 2026-09-12
SHARPENED note had been split by the RS-12.14 title; it is back in one
piece under RS-12.15.
- v25 TODO.md: the D-VS9 line says the base -> tractor fix is coded and
waits for the bench build and the operator's GO.
- murata_l072/README.md: the expected md5 pair is stale until the bench PC
builds both images. CI's Linux build of the same toolchain release has
not matched the bench PC's (bench 6f5073c0..., production 7983744b... on
2026-10-11 and on #150), so the md5s come from the bench PC.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Dorkmo · project thread
Before: the next RS-13 radio session (the range-edge walk) and the RS-13.2 desk check were blocked on open no-radio items. Copilot's two findings on #144 were unaddressed.
vector_statscould not show what the A16 repair sends, and the A8 relabel counts were lower bounds. The switch-gap guard had no firmware SIL case. The desk-check rehearsal's renderer gaps were still in the code, and the spec did not describe what #138 and #143 changed.After: every no-radio item in front of the desk check and the range-edge session is closed or written up as a decision for the design owner. What is left needs the bench: rebuild both images from this merge, the camera-only step 0/1 pass, then the desk check. Radios stay parked until the operator gives a GO.
No firmware binary changes. The only L072 file touched is a host SIL test.
What changed
Bench kit (follow-ups to #144)
provision_bench_board.sh: the retire check now passes only units systemd cannot load. That means no unit file, or a mask that is a symlink to/dev/null. It also requiresjob=exactly. These are Copilot's two findings on Bench boards: retire the three failing boot units; provisioning enforces it #144. Asystemctl linksymlink and thelinked/static/disabled/enabledstates are retired by disable plus a move.alias,indirect,generated,transientand the-runtimeforms are refused with a message and nothing changes.deploy_base.sh(RS-5.18): ships onlybase_station/ firmware/ Dockerfile docker-compose.yml. It renames an olddocker-compose.video-test.ymlon the base to….RETIRED-<date>, as the tractor's was renamed. It refuses to do so while a container made from that file is in any state but exited, and it never runsdocker composeon it.tractor_restart_video_test.sh). The last commit that has them iscfb93d8.firmware/tractor_x8/systemd/*.servicesketches are marked obsolete, and SETTINGS_REFERENCE now lists them asdeprecated.deploy_base.shalso leaves the tractor's compose files (firmware/tractor_x8/docker-compose*.yml,mosquitto.video-test.conf) out of the tarball, and removes an old copy from the base's tree. Each of those compose files maps/dev/ttymxc3withrestart: unless-stopped, and the base uses none of them. The script stops before copying anything if a YAML file in the tarball, other than the base's owndocker-compose.yml, names attymxcUART orLIFETRAC_LORA_DEVICEoutside a comment, or uses a compose route to host devices that names none:privileged(any value),device_cgroup_rules, or all of/dev. The files stay in git, because the tractor image is built from its own archive.VECTOR encoder and SIL
vector_statsgains cumulative counters:frames,epochs_by(committed epoch starts per cause, exact for A8),share_frames,share_resends,share_repeats,carousel_resendsanddel_repeats.vector_dry_run.py tractor-logtotals them on acountersline. The range-edge A16 confirmation and the results template now use them.make check-rx-grid-policysequences 10–11 pin what a 1.038 s switch gap does on the L072. At 2 fps, a base frame in the gap, or before the 8th tractor frame after it, is adopted, and the next demotion resets the tractor's clock. At 0.99 s the frame is refused.legs/a16_sil_summary_fix.json. The pre-fixa16_sil_summary.jsonis unchanged.Operator console (RS-13.2 rehearsal findings)
frame_age_msandloss(seq gaps over 10 s). A late frame, or a copy of a frame of the same seq chain, is not counted as loss. After more than 10 s of silence the next frame starts a new chain, even with the same seq.Docs
How it was checked
base_station:python -m pytest tests -qgives 1615 passed, 2 skipped (main: 1598 passed, 2 skipped).firmware/murata_l072:make checkandmake check-rx-grid-policypass with host gcc (-Wall -Wextra -Werror).a16_sil.py: A16-like 0/100 at bw500 (243 B) and at bw250 (203 B), recovery max 10 / 11 frames,lossfree_out_of_step_frames0. The results are identical before and after the counters.web_uifrom this tree, replaying the 2a A16 window withvector_replay_publish.pyat 1280×720 and 1920×1080, plus console injections for rule 1 and the chips. The replays logged 0 refusal POSTs and 0 page errors. The injections gave exactly one POST per fault episode. A layout check at ten window sizes from 640×480 to 1920×1080, with no panel, either panel and both open, found no chip under a panel, except with both panels open below about 730 px wide, where even the 9 px banner does not fit between them.bash -nandshellcheck -S warningare clean. Throwaway fake-board harnesses passed 111 provisioning scenarios and 34 deploy scenarios, including the boards' 2026-10-12 replies. No scenario started or stopped a unit, randocker composeor removed a container. Adeploy_base.sh --dry-runtarball has none of the tractor's compose files. Twenty dry runs on test commits checked the guard. The committed tree (with the desk check's compose file), radio names only in comments, and/dev/shmor./devvolumes pass. These stop the dry run: a/dev/ttymxc3default behindLIFETRAC_LORA_DEVICEor another variable,LIFETRAC_LORA_DEVICEwithout a default, an environment value, a nesteddocker-compose.yml,ttymxc1,privilegedastrue,'True',false,${PRIV:-true}, a quoted key or JSON-form YAML,device_cgroup_rules,/dev:/dev,["/dev/"]and an upper-case.YML.Not done here (bench work, then the GO)
lifetrac-v25:latestwithdeploy_base.shand the tractor image from this merge. This is alsodeploy_base.sh's first real run (RS-5.19)./appwith a bw500 video pass. The exact A8 counts come from that run.🤖 Generated with Claude Code
https://claude.ai/code/session_017xHffg93jSdYuYEWpSUWeR