Covers the headline session panel, the three progress bars an overnight
run drives, the garden view — the panel the dashboard is mostly for —
and what the page does when it can no longer reach the server behind it.
The page's headline panel — runs, cost, errors, in flight — is scoped to
one session: the newest run in the history plus every run contiguous
with it, where "contiguous" means no quiet gap longer than
state.SESSION_GAP_SECONDS (6 hours). state.session_stats() walks the
history newest-first and stops at the first such gap.
Six hours is picked from what the history actually looks like rather than
rounded off: inside one overnight batch the gap between two recorded runs
is at most a single dispatch, well under an hour, while the gap between one
night's run and the next is most of a waking day. So a session is one
night's rotation, or one manual tend.
The gap rule alone would chain, though — an overnight ending at 03:00 and
a manual tend at 08:30 are inside the threshold, and every further run
under it extends the window — so a session is additionally capped at
state.MAX_SESSION_SPAN_SECONDS (24 hours), measured back from the newest
run. A panel headed "Latest session" can then never be showing three days,
which is the exact failure it was rescoped to remove, and the walk gains a
ceiling on the rows it reads per poll.
The heading states the window it is showing — 20:45 – 03:36, dated when
an end falls on a day other than today — rather than leaving it implied.
Both ends, not only the start: a session that finished this morning would
otherwise read as one still running, beside an "in flight" tile counting
something else entirely.
This panel used to be headed Tonight over the most recent
run_limit (40) rows of history, which is not a night and does not claim
to be — state.list_runs() applies no time predicate at all. With a garden
of ~32 repos one full cycle already fills most of that window, so the panel
routinely straddled two or more nights and reported a previous night's
failures and dollars as this one's. The error count there is the page's
most glanceable failure signal and the first thing read when deciding
whether an overnight run went badly, so it is the one number that most
needed a window it could actually name.
The heading also carries how long the session actually spent dispatching.
overnight is a time-budget scheduler, so wall-clock is what explains why
only a fraction of the candidates were attempted and which repo consumed
the budget — a number recorded on every run, aggregated per repo, and
until now displayed nowhere. It appears per run in Recent runs, per repo
in the garden table and detail card, and per night in the history panel.
The Recent runs table further down keeps the row window — "the last 40
runs" is exactly what that table claims to be, so no rescoping is owed
there. It does now name that window (last 40 · Aug 12 – Aug 15) and rule
between calendar days, because a bare HH:MM clock on every row made four
days of history read as one morning. ?repo= and ?limit= on
/api/status narrow it — state.list_runs() always accepted both, and
nothing ever passed them.
Three progress bars sit under the session stats while an overnight run is
live, outermost first. They answer different questions and are deliberately
not merged into one:
| Bar | Question | Where the numbers come from |
|---|---|---|
| Cycle | How far through the garden is the current rotation? | The resume cursor (overnight.read_attempted / read_cursor) against the garden list |
| Budget | How much of tonight is left? | The run's own overnight starting … budget=6.0h header (dashboard.parse_overnight_start) and the start time in the log's filename (dashboard.log_started_at) |
| Batch | Which candidates is the run dispatching right now? | The (N-M/T candidates this run) line (dashboard.parse_batch_progress) |
Only the batch bar used to be on the page, and its denominator is this
invocation's candidate count — itself a consequence of how many repos a
previous night already attempted. So candidates 1–2 of 29 could not say
whether the garden was nearly through a cycle or had just started one, which
is the question overnight is designed around: it resumes a cycle across
several nights whenever the garden is bigger than one budget window (see
Overnight).
The cycle bar has to name which cursor it read, because the cursor file holds
two keys and only the running strategy says which one is the cycle position:
next_index for round-robin, the attempted name list for issue-count
and random (the default). The payload previously carried next_index
alone, under the strategy-neutral name overnight_next_index — so under the
default strategy an API consumer read 0 all night and would reasonably take
it for "cycle just started". It is now overnight_cycle
(garden_size/attempted/next_index/strategy) alongside
overnight_run (budget_hours/started_at/elapsed_seconds/
remaining_seconds/strategy/garden_size_at_start/log), and
PAYLOAD_SCHEMA was bumped with the rename. The two garden sizes are named
apart on purpose: overnight_cycle.garden_size is the garden as it is now,
overnight_run.garden_size_at_start is what the running invocation saw when
it began, and a repo added mid-run makes them legitimately disagree.
strategy is null whenever no run is live, since nothing in the cursor file
records which strategy wrote it — the cycle bar then says which cursor key it
fell back to rather than implying a strategy it cannot know. overnight_run
is null in the same case, and for a plain tend --repo log, which has no
budget header at all.
The budget header is read from the head of the log rather than the tail
every other live panel uses: it is the first line a run writes, and a full
garden's narration runs well past tail_lines' 400-line window, so by the
time the remaining budget is worth knowing the header has long since scrolled
out of it.
Shown only when the session has errors. The error count in the headline
panel cannot say what failed, and failures in a real overnight run are
overwhelmingly one systemic cause repeated across many repos — an
exhausted usage limit, a claude that left PATH, a cached clone stuck
dirty. Rendered as N rows interleaved among the successes, that reads as N
unrelated repo failures rather than as one dead run.
So state.session_stats() now returns the session's error rows
(errors_detail) alongside the count, collected during the same walk, and
the panel groups identical summaries into reason ×N with the affected
repos listed beneath. Nothing new is captured — outcome and
gap_summary were already columns on runs.
The one panel showing a raw narration rather than an aggregate, and the only one that still shows a single file: interleaving two runs' stderr would be unreadable.
More than one run can be live at once — a manual gardener tend alongside
the devsrv-managed overnight run is a supported configuration, which is
why repo_lock.py exists — and every other live panel aggregates across
all of them (find_active_logs). This one used to name the others and
stop there, captioning itself · N other live log(s) not tailed. Being
told a second run exists, being shown its path, and not being able to open
it is the least useful of the three possible states, so the caption is now
a picker (issue #117):
| Live logs | What the panel renders |
|---|---|
| 0 | (no active log), no caption, no picker |
| 1 | The tail, captioned with that log's full path. Unchanged from before the picker existed — the <label> carrying it is hidden in the markup, so this is the state the page starts in |
| 2+ | The tail of whichever log is selected, captioned with its full path, with a <select> over every live log listing them by filename (<command>-<YYYYmmdd-HHMMSS>.log, which is what tells them apart) |
The picker sits beside the heading rather than inside it, on the same
line. A <select> inside an <h2> contributes its selected option to
that heading's accessible name, so heading-by-heading navigation would
announce the filename inside what is supposed to be a label — and the
caption in the heading already carries the full path.
The payload carries a tail per live log in log_tails, keyed by exactly
the path strings in active_logs, alongside the unchanged log_tail —
still the newest log's, still what the panel shows until the reader picks
otherwise. This costs no extra I/O: build_status already read every live
log in full on every poll to build the in-flight and batch panels, and
then discarded all but one before serialising.
A picked log is held across polls and cleared the moment it leaves
active_logs. find_active_logs sorts by mtime descending, so with two
runs both writing, "the newest" swaps between polls — without the hold,
the view would be yanked away from the log being read every few seconds.
The selection is not persisted to localStorage the way the plot/table
view choice is: a log path is only meaningful while something is still
writing to it.
session_stats answers "how did tonight go" and repo_stats "how is this
repo doing"; neither can answer "is this getting better or worse", so a
night that was a total loss became invisible the moment it stopped being
the newest session. state.daily_stats() is one GROUP BY over the
existing table — runs, errors, cost and time per calendar day, newest
first. Days are grouped on the raw timestamp prefix, matching how
state.now_iso() writes them, so it inherits that function's timezone
rather than introducing handling of its own.
gardener dashboard (see Usage) renders a garden panel over
dashboard.build_garden_rows()'s joined view of the garden list, the merge
allow-list, and each repo's all-time run history.
The dashboard's garden panel used to be two panels: one listing the garden, one listing the merge allow-list, both as bare repo pills. Once both lists were opted in garden-wide that was the same ~32 repo names printed twice, and neither answered the question actually worth asking — how is each repo doing?
They're now one panel, built by dashboard.build_garden_rows(): one row per
repo opted in to either list, joined with that repo's all-time run history
from state.repo_stats(). Merge-allow-list membership is a column
(can_merge), not a second list. A repo on the allow-list but not in the
garden still gets a row, flagged — that mismatch means something is
permitted to merge that overnight will never dispatch, which is exactly
the kind of thing a list of names printed twice was hiding.
The panel renders those rows two ways, toggled in the page (the choice is
remembered in localStorage):
A filter box beside the tabs narrows both views by substring over the full
owner/name. At 135 rows the plot is 135 tiles labelled only by short
name — and once repos move between orgs those short names collide, so a
label that is ambiguous within the current garden is drawn as the full
owner/name instead. The summary line beside it also names how many repos
have never been tended, which is routinely the largest single group
and previously appeared nowhere.
-
Table — repo, health, tends, errors, last tended, time, cost, merge. Every column header sorts; sorting Health ascending puts the repos that need attention first. Each header is a real
<button>, so the sort is reachable by keyboard and not only by mouse, and the active column carries botharia-sortand a▲/▼caret — repeat-activating a column toggles the direction, and without the caret the inverse order was indistinguishable from the one asked for.The narrow-viewport layout turns each row into a card, which means
display: blockon the table elements — that strips the implicittable/row/cellroles in every engine, so the roles are re-asserted explicitly in the markup along withscope="col"headers and a visually hidden<caption>. Both tables are re-rendered only when a signature of their contents changes, for the same reason the plot is: an unconditional rebuild every four seconds destroys any text selection inside them before it can be copied.That card layout also hides the whole
<thead>, and these headers are not only labels — they are the sort control — so below 720px the table was not sortable at all, on the layout the page is written for. A Sort by select and a direction toggle therefore render inside the table view at exactly those widths, from the same media block that hides the header row, sticky at the top of the view's own scroll box. Both controls change the order through onesetGardenSortand are re-rendered from one read ofgardenSort, so the caret,aria-sortand the select cannot describe different orders; the select's options are built from the header cells at load rather than listed a second time, so a new column appears in both controls or in neither. -
Plot — each repo drawn as a plant, in SVG, generated in the page from the same row. This is the "look at the garden" view rather than the "read the garden" one:
Plant Data behind it Stem height, leaf count All-time successful dispatches ( successes) — every non-erroroutcome instate.SUCCESS_OUTCOMES, so analignrun or a dev-loop bootstrap grows the plant too, not just atendLeaf colour, how far the leaves droop Days since last_success— thriving (<2d), steady (<5d), dry (<10d), wilting beyond thatShort brown stem with a single drooping leaf Runs recorded but none of them succeeded — the leaf count starts at one, so there is no leafless state Seed in the ground Never dispatched — sitting in bare soil, or in the pot below when the repo isn't in the garden Blossom On the merge allow-list, i.e. allowed to merge its own PRs Brown leaf litter on the soil errorruns, up to sixWidth of the soil mound Dollars spent tending that repo Terracotta pot instead of soil Allow-listed but not in the garden Pulsing glow Being tended right now (from the same best-effort log parse the "Currently tending" panel uses) Every colour above is a CSS custom property resolved through the page's theme, not a literal in the SVG — half the plot already flipped with
prefers-color-schemeand half didn't, which left the light-mode buckets within 0.3 of each other at under 3:1. The naturalistic leaf colours are the illustration; they are not a severity ramp, so the table's status dot takes the page's semantic tokens (--err,--warn,--accent) instead, where the escalation has to be unmistakable.Plants are ordered by attention rather than by name: in flight first, then struggling/wilting/dry, then steady/thriving, then never-tended, alphabetically within each band so nothing jumps between polls. The payload's own row order stays alphabetical — that is the canonical order, and both views treat ordering as a presentation decision. On a narrow viewport the plot scrolls inside its own box rather than making the page 135 plants tall.
Each plant is a button. Tapping or activating one opens a detail card under the plot with that repo's full
owner/name, health, tends out of total runs, errors, last successful tend, last attempt and its outcome, time spent, cost, merge eligibility and whether it is actually in the garden, plus links out to the repo on GitHub and a control that filters the Recent runs table to just that repo — the card used to report "17 errors" with no route to any of them. Because one shared card serves every plant and sits after the whole plot, opening it moves focus into it; it is a labelledrole="region", and closing it returns focus to the plant that opened it. The last-attempt pair (last_run/last_outcome) appears nowhere else on the page, and "the most recent attempt errored" is a different fact from "the last success was three days ago". The card is the only way any of this is reachable on a touch device: the equivalenttitletooltip is kept for pointer users, but a hover is the one interaction a phone cannot perform, and the plot is the view the page defaults to.Each plant's lean, leaf jitter, grass tufts and flower colour are decoration, deterministic per repo name (an FNV-1a hash seeding a small LCG, never
Math.random()), so a plant keeps its shape between refreshes and only changes when its data does. The plot is re-rendered only when a signature of the drawn values changes, so the 4 s poll doesn't restart the in-flight glow animation every cycle.Growth is drawn from the whole run history rather than the
run_limitwindow the Recent runs table uses — a plant's size means "how much tending this repo has had", not "how recently it appeared in the log tail". That's whystate.repo_stats()exists as its own aggregate query instead of the dashboard foldinglist_runs()in Python.
Every panel on the page renders whatever the last successful poll returned, so a failed poll that only changed the header caption left a dead server looking like a healthy dashboard — the failure mode most likely to occur during an unattended overnight run (the server OOM-killed, the phone off the network) and the hardest to spot.
A poll now fails loudly, and the ways it can fail are told apart rather than collapsed into one caption:
| Failure | Reason shown |
|---|---|
| The request never completed | fetch failed |
| The request hung past the poll timeout, or no poll has landed in three intervals | no response |
A non-2xx response (res.ok, checked before the body is parsed at all, so a 500 is detected as a 500 rather than as "the error body didn't parse") |
server returned 500 |
| A 2xx whose body doesn't parse | bad response body |
A payload that throws while being rendered, including one whose schema doesn't match the page's |
render failed |
Any of them marks the whole page stale: the content below the header is
desaturated, a ⚠ stale badge appears beside the heartbeat, and the
caption is replaced with the age of the snapshot on screen (from the
payload's own generated_at) plus the reason and a count of consecutive
failures. Desaturation rather than heavy dimming is the deliberate choice
— when the server has died mid-overnight, the stale snapshot is the only
data there is, so it has to stay readable while still being unmistakably
not live. Desaturation is not the only signal, though: it says nothing
to a screen reader, on a monochrome display, or with severe colour vision
deficiency, which is what the badge and the live region below are for.
For the same readability reason the rule applies no opacity at all —
compositing muted text at 0.78 already dropped it under 4.5:1.
Three failures the caption alone used to miss:
- A poll that stalls rather than errors. No RST is delivered when a
phone switches network or a host suspends mid-request, so the request
sat open for the OS timeout — minutes — with the page still claiming to
be live. Every fetch now carries an
AbortControllertimeout, and a separate one-second ticker re-derives the caption's age from the last good payload, so a page whose polls simply stopped happening still goes stale on time. - A payload the page doesn't understand. Missing keys stringify to
"undefined"rather than throwing, so a renamed key renderedundefined runs/undefined errorsunder a confident heartbeat.build_statusstamps aPAYLOAD_SCHEMAand the page refuses anything that doesn't match its own copy. Bump both together when a key the page reads is renamed, removed, or changes meaning —overnightself-updates before each run, so a tab open across a restart is routine. - First paint. The static markup asserted "nothing in flight" over
empty tables before any fetch had returned. The page starts in a
loadingstate that says "checking…" and reserves the panel heights, and a first poll that fails says "could not reach the server — nothing loaded yet" rather than reporting an idle fleet.
The page is marked fresh only once every panel has actually rendered the
new snapshot, never on receipt of it. Polls don't overlap either: a slow
request against a restarting server could otherwise resolve after a
later successful one and re-mark a live page stale. The one reader-driven
refetch (the per-repo runs filter) supersedes the running poll instead of
racing it — the old request is aborted and a generation counter discards
its result, so "never two live requests" still holds. The first successful
poll clears all of it, and the existing visibilitychange listener is
still the fast path back.
Transitions — live↔stale, and the set of in-flight repos changing — are
also written to a visually hidden role="status" live region, so a
screen reader learns about them at all. Deliberately only on transitions:
the heartbeat caption changes every four seconds and would babble.