Dashboard: where the agents are #419

Merged
mph merged 13 commits from dashboard-control-plane into main 2026-08-14 14:00:52 +00:00
Owner

Slice 06 — Where the agents are

Goal. Land on the board and tell, for every worktree, whether an agent is
working in it right now, stopped and waiting for me, or not there at
all
— and decide what to pick up from that, without checking tmux.

Fit test. Michal returns to the board after a break and chooses what to work
on next from the board alone: nothing he knows to be running is missing, nothing
dead pretends to be alive, and "is anything already moving on this, or do I have
to start it?" is answered correctly.

Measured at definition against the live server: 22 worktrees held an open agent
session and 7 of them appeared nowhere on the board.

What ships

  • Live sessions are found by walking /proc in-process (LiveAgentReader),
    keeping entries whose comm is claude and reading each one's cwd. A
    process counts for a worktree when its cwd is that path or below it, on a
    separator — so -IW-1 cannot swallow -IW-10. Forking ps costs 73 ms
    against 17 ms for the walk, so it does not fork.
  • What the agent is doing is read from the transcript tail
    (TranscriptReader), taking the freshest .jsonl across both
    ~/.claude and ~/.claude-iw and reading it backwards in 256 KB chunks —
    doubling to a 4 MB bound, never whole. The open sessions hold 81 MB between
    them; a board refresh cannot read that.
  • The classification is pure (domain/AgentSession.scala). A live process
    proves presence and nothing more — one had been running 38 days. The file's
    own mtime proves nothing either: the records that bump it carry no timestamp.
  • Liveness rides the act card. A worktree with a pending act is read once,
    in its waiting lane, with the agent state and the age on it. The lane "Where
    the agents are" keeps only the sessions no act card speaks for.
  • PendingAct is untouched. Liveness is its own channel: a running agent
    neither creates nor removes an act. This is display, not claim.

Measured on the running board

Worktrees on the board twice 0
Act cards 32, of which 16 carry an agent badge
Sessions in the lane (claiming nothing) 6
Board render, including the whole reading ~250 ms

Three corrections the fit reads forced

  1. stop_reason, not content kinds, says whether a turn ended. One model
    response is written as several records (thinking → text → tool call), each
    carrying the response's stop_reason. Reading content alone would have
    called 456 of 2532 assistant records "stopped" mid-turn.
  2. A session is dated by everything that happened in it, not only by its
    conversation. MEDECA-407 read 56 minutes stale while its sub-agents reported
    in 1.8 minutes ago.
  3. Which tool was called says what blocks the agent — not how long it has
    been quiet.
    AskUserQuestion blocks 22 min at the median and up to 7 days;
    Bash 0.2 s at the median but 15 minutes when it polls CI. Duration cannot
    separate them; identity can. 38 of 466 long silences were a question misread
    as work.

Fit outcome — closed with the remainder carried forward

The fit test did not fully pass, and Michal closed the slice deliberately
rather than amend a frozen card mid-flight. Two sessions still read working
while waiting on him.

The cause is architectural, and it is the next slice's spine: the transcript
is a reliable record of the past and not a contract about the present.
Claude
Code does not flush the assistant turn carrying an AskUserQuestion until the
human answers it — measured on a session whose terminal was displaying the
prompt while its newest dated record was 118 minutes old. The same lesson is
already recorded in procedures' agent-lifecycle-notify.sh (issue #414):
"the transcript file on disk lags behind, so reading it would return the
penultimate message."

Michal's rule: never rely on when a tool writes its transcript — buffering
will be common, and no tool guarantees the file reflects current state.

Close review — 6 skills, 2 rounds

Round 1 (style, testing, security, scala3, composition, architecture) raised
3 criticals; all are fixed and re-verified in round 2.

  • A worktree registered at / took the landing page down. It encoded to an
    empty transcript-directory name, and asking os-lib for a segment named by
    nothing throws rather than misses. The same path matched every agent on the
    machine, because a prefix rule rooted at "" contains everything — so it
    fired as soon as any agent ran. Registration only checks that a path starts
    with /. Verified against the running server: PUT a worktree at /, then
    GET /200, where it previously threw out of the handler.
  • The /proc walk and the two-reader composition had no tests and no way to
    get one.
    Both are parameterised now, and the wiring is pinned — which
    reader answers "where are the agents" and which answers "what is this one
    doing" is the whole of what the service composes, and swapping them would
    still have compiled.
  • The 4 MB bound did not hold. It was checked after widening, so a file
    just past it doubled once more, reached the file's start and read all of it —
    a 5.6 MB transcript was read whole. Found because the round-2 reviewer
    mutation-tested my own regression test and proved it vacuous. Both the bound
    and the test are fixed; stripping the bound now turns the test red.

Also fixed: the state vocabulary lived in three places (AgentStateBadge now
owns the words, the order and the shape, and the tally walks the states
themselves); projectOf was duplicated verbatim (MainProject.projectName);
five methods were public that are steps of of/tailFor (private[dashboard],
matching DashboardService); Try(...).toOption where ujson has total
accessors; @tailrec on the backward read.

Two findings deliberately NOT fixed — they need your decision

  • The lane broadcasts live presence to any unauthenticated tailnet peer.
    The dashboard has no auth and binds on a Tailscale IP. The board already
    showed worktree keys and acts; this adds live working/stopped state and
    last-acted age, refreshed per load — enough to tell when someone is actively
    driving an agent versus stepped away, per issue. Cheapest options: a
    shared-secret header in a Cask decorator, or render the lane only for
    loopback requests. Recorded as an accepted risk unless you say otherwise
    — auth was scoped out of this effort, and dashboard-off-localhost is
    already a candidate on the map.
  • Worktree paths are barely validated. WorktreeRegistration.create accepts
    any string starting with /, so a short prefix like /home still matches
    unrelated agents and would display their state under an attacker-chosen
    issue id. Requiring the path to exist and hold a .git would close it.

Follow-up checklist (flushed to the effort map)

  • Hook-sourced liveness — the next slice. Hooks are already wired in both
    homes and already classify stopped/started/resumed/finished;
    proposed shape is that the hook writes a state file the board reads on
    refresh, not a fire-and-forget POST, so nothing is lost while the server is
    down. Contradicts this card's "hooks parked, never in place of the
    reading" fork — the next card must settle it the other way.
  • Fold the dead terminals — of the 16 badged act cards, 13 read
    stopped and 11 have been silent over a day. The fold must reach the act
    cards, not only the lane.
  • The lane refreshes itselfhx-trigger: every 30s.
  • The workflow does not republish review-state when a phase is
    launched
    — writer-side. MEDECA-407 sat in "Launch the next phase" while
    an agent implemented it. The badge is what made it visible.

Carried debts, none touched here: withLock in ServerStateService;
CaskServer as a god object; whether domain/ and application/ are the
target layout.

## Slice 06 — Where the agents are **Goal.** Land on the board and tell, for every worktree, whether an agent is **working in it right now**, **stopped and waiting for me**, or **not there at all** — and decide what to pick up from that, without checking tmux. **Fit test.** Michal returns to the board after a break and chooses what to work on next from the board alone: nothing he knows to be running is missing, nothing dead pretends to be alive, and "is anything already moving on this, or do I have to start it?" is answered correctly. Measured at definition against the live server: 22 worktrees held an open agent session and **7 of them appeared nowhere on the board**. ### What ships - **Live sessions are found by walking `/proc` in-process** (`LiveAgentReader`), keeping entries whose `comm` is `claude` and reading each one's `cwd`. A process counts for a worktree when its cwd is that path or below it, on a separator — so `-IW-1` cannot swallow `-IW-10`. Forking `ps` costs 73 ms against 17 ms for the walk, so it does not fork. - **What the agent is doing is read from the transcript tail** (`TranscriptReader`), taking the freshest `.jsonl` across **both** `~/.claude` and `~/.claude-iw` and reading it backwards in 256 KB chunks — doubling to a 4 MB bound, never whole. The open sessions hold 81 MB between them; a board refresh cannot read that. - **The classification is pure** (`domain/AgentSession.scala`). A live process proves presence and nothing more — one had been running 38 days. The file's own mtime proves nothing either: the records that bump it carry no timestamp. - **Liveness rides the act card.** A worktree with a pending act is read once, in its waiting lane, with the agent state and the age on it. The lane "Where the agents are" keeps only the sessions no act card speaks for. - **`PendingAct` is untouched.** Liveness is its own channel: a running agent neither creates nor removes an act. This is display, not claim. ### Measured on the running board | | | |---|---| | Worktrees on the board twice | **0** | | Act cards | 32, of which 16 carry an agent badge | | Sessions in the lane (claiming nothing) | 6 | | Board render, including the whole reading | ~250 ms | ### Three corrections the fit reads forced 1. **`stop_reason`, not content kinds, says whether a turn ended.** One model response is written as several records (thinking → text → tool call), each carrying the response's `stop_reason`. Reading content alone would have called **456 of 2532** assistant records "stopped" mid-turn. 2. **A session is dated by everything that happened in it**, not only by its conversation. MEDECA-407 read 56 minutes stale while its sub-agents reported in 1.8 minutes ago. 3. **Which tool was called says what blocks the agent — not how long it has been quiet.** `AskUserQuestion` blocks 22 min at the median and up to 7 days; `Bash` 0.2 s at the median but 15 minutes when it polls CI. Duration cannot separate them; identity can. 38 of 466 long silences were a question misread as work. ### Fit outcome — closed with the remainder carried forward The fit test **did not fully pass**, and Michal closed the slice deliberately rather than amend a frozen card mid-flight. Two sessions still read `working` while waiting on him. The cause is architectural, and it is the next slice's spine: **the transcript is a reliable record of the past and not a contract about the present.** Claude Code does not flush the assistant turn carrying an `AskUserQuestion` until the human answers it — measured on a session whose terminal was displaying the prompt while its newest dated record was **118 minutes old**. The same lesson is already recorded in `procedures`' `agent-lifecycle-notify.sh` (issue #414): *"the transcript file on disk lags behind, so reading it would return the penultimate message."* Michal's rule: never rely on *when* a tool writes its transcript — buffering will be common, and no tool guarantees the file reflects current state. ### Close review — 6 skills, 2 rounds Round 1 (style, testing, security, scala3, composition, architecture) raised **3 criticals**; all are fixed and re-verified in round 2. - **A worktree registered at `/` took the landing page down.** It encoded to an empty transcript-directory name, and asking os-lib for a segment named by nothing throws rather than misses. The same path matched *every* agent on the machine, because a prefix rule rooted at `""` contains everything — so it fired as soon as any agent ran. Registration only checks that a path starts with `/`. Verified against the running server: `PUT` a worktree at `/`, then `GET /` → **200**, where it previously threw out of the handler. - **The `/proc` walk and the two-reader composition had no tests and no way to get one.** Both are parameterised now, and the wiring is pinned — which reader answers "where are the agents" and which answers "what is this one doing" is the whole of what the service composes, and swapping them would still have compiled. - **The 4 MB bound did not hold.** It was checked *after* widening, so a file just past it doubled once more, reached the file's start and read all of it — a 5.6 MB transcript was read whole. Found because the round-2 reviewer mutation-tested my own regression test and proved it vacuous. Both the bound and the test are fixed; stripping the bound now turns the test red. Also fixed: the state vocabulary lived in three places (`AgentStateBadge` now owns the words, the order and the shape, and the tally walks the states themselves); `projectOf` was duplicated verbatim (`MainProject.projectName`); five methods were public that are steps of `of`/`tailFor` (`private[dashboard]`, matching `DashboardService`); `Try(...).toOption` where ujson has total accessors; `@tailrec` on the backward read. ### Two findings deliberately NOT fixed — they need your decision - **The lane broadcasts live presence to any unauthenticated tailnet peer.** The dashboard has no auth and binds on a Tailscale IP. The board already showed worktree keys and acts; this adds *live* working/stopped state and last-acted age, refreshed per load — enough to tell when someone is actively driving an agent versus stepped away, per issue. Cheapest options: a shared-secret header in a Cask decorator, or render the lane only for loopback requests. **Recorded as an accepted risk unless you say otherwise** — auth was scoped out of this effort, and `dashboard-off-localhost` is already a candidate on the map. - **Worktree paths are barely validated.** `WorktreeRegistration.create` accepts any string starting with `/`, so a short prefix like `/home` still matches unrelated agents and would display *their* state under an attacker-chosen issue id. Requiring the path to exist and hold a `.git` would close it. ### Follow-up checklist (flushed to the effort map) - [ ] **Hook-sourced liveness** — the next slice. Hooks are already wired in both homes and already classify `stopped`/`started`/`resumed`/`finished`; proposed shape is that the hook **writes a state file** the board reads on refresh, not a fire-and-forget POST, so nothing is lost while the server is down. Contradicts this card's "hooks parked, never in place of the reading" fork — the next card must settle it the other way. - [ ] **Fold the dead terminals** — of the 16 badged act cards, 13 read `stopped` and 11 have been silent over a day. The fold must reach the act cards, not only the lane. - [ ] **The lane refreshes itself** — `hx-trigger: every 30s`. - [ ] **The workflow does not republish `review-state` when a phase is launched** — writer-side. MEDECA-407 sat in "Launch the next phase" while an agent implemented it. The badge is what made it visible. Carried debts, none touched here: `withLock` in `ServerStateService`; `CaskServer` as a god object; whether `domain/` and `application/` are the target layout.
mph added 12 commits 2026-08-14 13:14:42 +00:00
The board answered "what waits for you" and could not answer "what is
already moving". Twenty-two worktrees had an open agent session and seven
of them appeared nowhere on it.

A session is now two readings, because neither is honest alone. A live
process says an agent is present — one that has run for 38 days says
nothing more. What that agent is doing comes from the last user or
assistant record in its transcript: the file's own timestamp is bumped
constantly by records nobody spoke. The transcript is read backwards in
bounded chunks, never whole; the open sessions hold 81 MB between them.

Liveness is its own lane, below the acts that want him. It raises no act
and removes none — an agent working in a worktree is not work waiting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane put every live session on the board, including the sixteen the
waiting board already had a card for, so half the sessions were read
twice. Working agents were duplicated as readily as stopped ones: three
of the four agents at work also had an act.

Liveness now rides the act card. A worktree that wants something shows
once, in its lane, with the agent state and how long ago it acted under
the act. The lane keeps only the sessions no act card speaks for — the
work in flight that claims nothing, which the waiting board has no card
for and which is the reason the lane exists.

PendingAct is still untouched. Which acts exist is its question; this is
where they are read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane dated a session by its conversation, so MEDECA-407 read 56
minutes idle while its sub-agents reported in 1.8 minutes ago. A session
that has handed work to sub-agents says nothing in the conversation for
as long as they run.

A session is now dated by the newest moment any record names, while who
holds the turn still comes from the conversation alone. The two answer
different questions and no longer share one clock.

This does not make the file's mtime honest. The records that bump it —
mode, permission-mode, last-prompt, ai-title — carry no timestamp and
still date nothing: measured across the open sessions, every dead one's
newest dated record sits within three minutes of its last conversation,
39 days ago for MEDECA-363.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An outstanding tool call read as work in progress whatever the tool was,
so an agent that had asked a question and was waiting for an answer read
as busy. Thirty-eight of the four hundred sixty-six long silences in the
transcripts were exactly this.

Which tool was called says it, and no clock is needed. AskUserQuestion
blocked for 22 minutes at the median and for as long as seven days;
ExitPlanMode is the same shape. Bash returns in 0.2 s at the median, and
the times it does not are the agent working: a call polling CI has taken
15 minutes, Agent 96 minutes, Workflow 9.7 hours. Duration cannot
separate those from a question. Identity can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from the close review, one of them a crash.

A worktree registered at "/" encoded to an empty transcript directory
name, and asking os-lib for a path segment named by nothing throws
rather than misses. Nothing caught it, so it came out of the landing
page. The same path also matched every agent on the machine, because a
prefix rule rooted at "" contains everything — so the throw fired as
soon as any agent was running, which is the normal state of this
machine. Registration only checks that a path starts with "/", so "/"
passed. A path that names no session directory now has no transcripts,
and a worktree at the filesystem root holds no agent.

The /proc walk and the composition of the two readings had no tests and
no way to get one: the process table was hardcoded and the readers were
called directly. Both are parameters now, and the wiring is pinned —
which reader answers "where are the agents" and which answers "what is
this one doing" is the whole of what the service composes, and swapping
them would still have compiled.

The state vocabulary was written in three places. AgentStateBadge now
decides the words, the order and the shape; the tally walks the states
themselves, so one added later is counted without anyone remembering.
MainProject.projectName is the one place a worktree's project is named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bound refuses the wider read before taking it
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 6s
CI / compile (pull_request) Successful in 1m17s
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Has been cancelled
CI / dashboard-build (pull_request) Has been cancelled
7924ebf5a4
The backward read checked its bound after widening, so a file just past
it doubled once more, reached the file's start and read all of it — the
one thing the bound exists to refuse. A 5.6 MB transcript was read whole.
It is refused before the wider window is taken now.

The test that was supposed to catch this could not: with no record
anywhere in the fixture, a read that ignored the bound returned the same
nothing. The record now sits at the front, past the bound, where finding
it proves the read walked the whole file.

transcriptDirsFor holds on its own terms rather than on who calls it. A
path that names no directory was safe only because the one caller filters
through runsIn first, and the next caller would not know that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
workflow(sl): close 06-where-the-agents-are (flush parked items)
All checks were successful
CI / compile (pull_request) Successful in 1m12s
CI / dashboard-build (pull_request) Successful in 58s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8m29s
85d2c657b0
mph merged commit 9ab39a05cd into main 2026-08-14 14:00:52 +00:00
Sign in to join this conversation.
No description provided.