Dashboard: the pull request is true, the card names the act, and the work that wants Michal comes first #415

Merged
mph merged 32 commits from dashboard-control-plane into main 2026-08-11 20:34:48 +00:00
Owner

Three slices of the dashboard-control-plane effort, shipped as one batch from one
effort worktree. Each was closed on Michal's own read against its frozen card.

The thread through them: the dashboard tells the truth about what a worktree wants,
says it in the worktree's own words, and puts the work that wants him first.


Slice 02 — PR state stays current

Goal. I glance at the dashboard to decide what to pick up next, and the worktrees
showing an open pull request are exactly the ones that really have one.

Fit test. Merge a pull request from the forge web interface — not through iw
and leave the dashboard alone. Within about two minutes the card stops saying open.

Passed 2026-08-07. gladys-curation #672 merged in the Forgejo web interface at
13:51:36Z; the poller read it at 13:52:25Z. 49 seconds, against a two-minute target.

What it took:

  • The forge is resolved through ForgeType.resolve — the path ./iw phase-pr uses.
    detectPRTool picked gh whenever gh was installed, so every GitLab worktree
    reported no pull request and Forgejo had no branch at all. 0 of 61 worktrees carried
    a cached pull request; after the repair, 61 of 61 agree with their forge.
  • PullRequestPoller runs two lanes on a daemon thread: an open pull request every
    2 minutes, a worktree with none known every 15. Merged and closed are terminal.
  • A read that fails leaves the cache alone. The entry ages, and a card says how long a
    state has gone unconfirmed rather than presenting a guess as current.
  • The sweep drops cache entries that stand for no worktree — 315 at the first start.
  • RefreshThrottle.claim resolves check-and-record in one step, so two racing callers
    cannot both pass.

Slice 03 — The card names the pending act

Goal. A card names the one act pending on me — review this pull request, launch the
next phase, finish the workflow, read this analysis — and when I go and do it, it is the
right act.

Fit test. Walk the dashboard after a break and work through every flagged worktree.
For each, the act named is the act performed.

Passed 2026-08-09, on the third read, against the live server: 17 acts named, overview
count 17.

What it took:

  • PendingAct.resolve derives the act from what the writers publish — status,
    pr_url, activity, phase position — plus the pull request the forge confirmed.
    Where the published facts do not decide, it says so instead of inventing an answer.
  • activity = working names no act: an agent at work holds the ball.
  • A merge hands the ball back, so the act is restated, not cleared — launch the next
    phase, or finish the workflow. Six worktrees had been saying "final PR created"
    months after their pull request merged.
  • A closure is not a merge. It says only that the work was not accepted, so it names
    no act rather than guessing.
  • The pull request a review state names is read when the branch lookup answers
    nothing — glab mr view lists open merge requests only, so a closed one looked like
    none at all.
  • pr_url is presence-gated, never matched by equality: a worktree may publish a mirror
    URL, or an older phase's number. Exact matching would have silenced 4 of 6 acts.

Slice 04 — Pending acts come first

Goal. I open the dashboard after a break and the work that wants me is already in
front of me: the projects that want me at the top of the overview, the worktrees that
want me at the top of their project page, the act directly under the worktree's name.

Fit test. Walk the dashboard cold and work down until nothing wants me, never
reading past a card that wants nothing.

Passed 2026-08-11.

What it took:

  • The act moved out of the review block to directly under the issue title, on all four
    card variants and the detail page.
  • ProjectSummary.mostWaitingFirst orders the overview; WorktreeOrder.pendingFirst
    orders a project page. Inside the group that wants him, most recently published first
    — the worktree an agent just handed back is the one still in his head — and each act
    says how long it has waited. The worktrees that want nothing keep issue-id order;
    they are not a queue.
  • A refresh no longer shuffles the cards it refreshes. The page rendered
    pending-first while the sync endpoint still called issue-id order current, so every
    poll read the page's own order as a change, deleted each moved card and re-inserted it
    at the top — reversing the group, then flipping again. Both now read one order.
  • A card the refresh must move lands at its own slot. generateReorderOob is gone: it
    moved one card to a fixed position, which cannot express a correct multi-card move.
    A client whose order is wholly stale converges in one refresh — checked on all 13
    projects with more than one card.
  • The act asks for the phase the workflow reached, not the one after it. currentPhase
    is the first phase not completed, and the arithmetic added one to it.
  • The poller reads announcements from disk. announcedPullRequests read pr_url from a
    cache only a watched card writes, so the reaction to an agent's claim fired only for a
    worktree the human was already looking at.

Review

Seven reviewers, one skill each: style, testing, security, scala3, composition,
architecture, api. Full report:
project-management/efforts/dashboard-control-plane/slices/04-pending-acts-come-first/review-slice-2026-08-11.md

Four criticals, all fixed in-loop:

  1. Three hand-copied pending-act resolutions, already drifted on the progress gate.
    DashboardService.pendingActFor is now the one composition the count and the order
    both ask.
  2. The refresh ordered by fresh state and rendered from stale state — a card could be
    promoted by a state its own markup did not show. One fresh read now feeds both.
  3. last_updated had no test at the wire. The recency order rests on a hand-written
    codec no test read; a key-name typo would have compiled, passed, and silently ordered
    every worktree as if none had published. Verified by breaking the key and watching the
    new tests fail.
  4. status had no test at the wire — the same gap for slice 03's field.

Also fixed: a direct test for WorktreeOrder.pendingFirst, the announcedPullRequests
missing-worktree guard, a phase number past the end of a workflow, and seven unused
imports.

Gates: unit 366/366 · integration 240/240 · E2E 34/34.

Follow-ups (parked, in the effort map)

Two need a decision rather than a patch:

  • An unreadable review-state.json reads as "nothing pending" everywhere. What the order
    should do with a worktree whose state cannot be read is a card question.
  • The poller can resurrect a review-state entry the render just dropped; "never cached"
    and "just dropped" are the same value, so it needs an ownership rule.

Carried: CSS-selector escaping for a worktree key, a traversal guard on the effort
review-state path, forge read timeouts and 429 backoff, the poller's lifecycle never
closed, the unordered /api/worktrees/changes sibling, and the Map.empty cache bypass
in PullRequestReader.

🤖 Generated with Claude Code

Three slices of the `dashboard-control-plane` effort, shipped as one batch from one effort worktree. Each was closed on Michal's own read against its frozen card. The thread through them: the dashboard tells the truth about what a worktree wants, says it in the worktree's own words, and puts the work that wants him first. --- ## Slice 02 — PR state stays current **Goal.** I glance at the dashboard to decide what to pick up next, and the worktrees showing an open pull request are exactly the ones that really have one. **Fit test.** Merge a pull request from the forge web interface — not through `iw` — and leave the dashboard alone. Within about two minutes the card stops saying open. **Passed 2026-08-07.** `gladys-curation` #672 merged in the Forgejo web interface at 13:51:36Z; the poller read it at 13:52:25Z. **49 seconds**, against a two-minute target. What it took: - The forge is resolved through `ForgeType.resolve` — the path `./iw phase-pr` uses. `detectPRTool` picked `gh` whenever `gh` was installed, so every GitLab worktree reported no pull request and Forgejo had no branch at all. 0 of 61 worktrees carried a cached pull request; after the repair, **61 of 61 agree with their forge**. - `PullRequestPoller` runs two lanes on a daemon thread: an open pull request every 2 minutes, a worktree with none known every 15. Merged and closed are terminal. - A read that fails leaves the cache alone. The entry ages, and a card says how long a state has gone unconfirmed rather than presenting a guess as current. - The sweep drops cache entries that stand for no worktree — **315 at the first start**. - `RefreshThrottle.claim` resolves check-and-record in one step, so two racing callers cannot both pass. ## Slice 03 — The card names the pending act **Goal.** A card names the one act pending on me — review this pull request, launch the next phase, finish the workflow, read this analysis — and when I go and do it, it is the right act. **Fit test.** Walk the dashboard after a break and work through every flagged worktree. For each, the act named is the act performed. **Passed 2026-08-09**, on the third read, against the live server: 17 acts named, overview count 17. What it took: - `PendingAct.resolve` derives the act from what the writers publish — `status`, `pr_url`, `activity`, phase position — plus the pull request the forge confirmed. Where the published facts do not decide, it says so instead of inventing an answer. - `activity = working` names no act: an agent at work holds the ball. - A merge hands the ball back, so the act is restated, not cleared — launch the next phase, or finish the workflow. Six worktrees had been saying "final PR created" months after their pull request merged. - A closure is not a merge. It says only that the work was not accepted, so it names no act rather than guessing. - The pull request a review state *names* is read when the branch lookup answers nothing — `glab mr view` lists open merge requests only, so a closed one looked like none at all. - `pr_url` is presence-gated, never matched by equality: a worktree may publish a mirror URL, or an older phase's number. Exact matching would have silenced 4 of 6 acts. ## Slice 04 — Pending acts come first **Goal.** I open the dashboard after a break and the work that wants me is already in front of me: the projects that want me at the top of the overview, the worktrees that want me at the top of their project page, the act directly under the worktree's name. **Fit test.** Walk the dashboard cold and work down until nothing wants me, never reading past a card that wants nothing. **Passed 2026-08-11.** What it took: - The act moved out of the review block to directly under the issue title, on all four card variants and the detail page. - `ProjectSummary.mostWaitingFirst` orders the overview; `WorktreeOrder.pendingFirst` orders a project page. Inside the group that wants him, most recently published first — the worktree an agent just handed back is the one still in his head — and each act says how long it has waited. The worktrees that want nothing keep issue-id order; they are not a queue. - **A refresh no longer shuffles the cards it refreshes.** The page rendered pending-first while the sync endpoint still called issue-id order current, so every poll read the page's own order as a change, deleted each moved card and re-inserted it at the top — reversing the group, then flipping again. Both now read one order. - A card the refresh must move lands at its own slot. `generateReorderOob` is gone: it moved one card to a fixed position, which cannot express a correct multi-card move. A client whose order is wholly stale converges in one refresh — checked on all 13 projects with more than one card. - The act asks for the phase the workflow reached, not the one after it. `currentPhase` is the first phase *not* completed, and the arithmetic added one to it. - The poller reads announcements from disk. `announcedPullRequests` read `pr_url` from a cache only a watched card writes, so the reaction to an agent's claim fired only for a worktree the human was already looking at. --- ## Review Seven reviewers, one skill each: style, testing, security, scala3, composition, architecture, api. Full report: `project-management/efforts/dashboard-control-plane/slices/04-pending-acts-come-first/review-slice-2026-08-11.md` Four criticals, all fixed in-loop: 1. **Three hand-copied pending-act resolutions**, already drifted on the progress gate. `DashboardService.pendingActFor` is now the one composition the count and the order both ask. 2. **The refresh ordered by fresh state and rendered from stale state** — a card could be promoted by a state its own markup did not show. One fresh read now feeds both. 3. **`last_updated` had no test at the wire.** The recency order rests on a hand-written codec no test read; a key-name typo would have compiled, passed, and silently ordered every worktree as if none had published. Verified by breaking the key and watching the new tests fail. 4. **`status` had no test at the wire** — the same gap for slice 03's field. Also fixed: a direct test for `WorktreeOrder.pendingFirst`, the `announcedPullRequests` missing-worktree guard, a phase number past the end of a workflow, and seven unused imports. **Gates:** unit 366/366 · integration 240/240 · E2E 34/34. ## Follow-ups (parked, in the effort map) Two need a decision rather than a patch: - An unreadable `review-state.json` reads as "nothing pending" everywhere. What the order should do with a worktree whose state cannot be read is a card question. - The poller can resurrect a review-state entry the render just dropped; "never cached" and "just dropped" are the same value, so it needs an ownership rule. Carried: CSS-selector escaping for a worktree key, a traversal guard on the effort review-state path, forge read timeouts and 429 backoff, the poller's lifecycle never closed, the unordered `/api/worktrees/changes` sibling, and the `Map.empty` cache bypass in `PullRequestReader`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mph added 30 commits 2026-08-11 17:23:52 +00:00
The pull request lookup picked its tool by which CLI was installed, so
every worktree ran gh; on a GitLab remote that reports no PR, and
Forgejo had no path at all. The forge now comes from the worktree
through ForgeType.resolve, the same way iw phase-pr resolves it.

parseGitLabPR read a url field that glab mr view does not emit; the
merge request link is web_url.

ForgejoClient gains a read of the pull request opened from a branch,
preferring an open one over a resolved one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every registration in state.json carried no kind, so effort and plain
worktrees came back as issue worktrees. They then asked for a tracker
issue that does not exist and never left the loading card, which has no
pull request section — so a pull request already read from the forge was
thrown away. gladys-curation #672, the only open pull request across 61
worktrees, was one of them.

The sweep already reads the true kind from disk and already repairs
projectPath; it now repairs kind the same way. A card still waiting for
its issue keeps the pull request it knows about, so an unreachable
tracker no longer hides a forge signal. WorktreeListView routed around
renderForWorktree and repeated the routing without the effort branch;
it now goes through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A worktree card claimed attention as a durable boolean, so six worktrees
whose pull request merged in May-July still asked for a review that had
already happened, and one that was working asked for attention at once.

Resolve the claim into the single act pending: an open pull request asks
for a review, a merged or closed one hands the ball back and restates the
act, an agent at work leaves nothing pending, and where the published
facts do not decide, the card says so. The overview counts these acts
rather than raw claims.

Reading status meant a cached review state parsed before the reader knew
that field would be served forever, since its file has not changed to
announce it. The cache entry now records the reader that parsed it.
A branch lookup answers only what the forge tool lists: `glab mr view`
lists open merge requests, and a Forgejo listing scans the 50 most
recently updated. A claim whose pull request has closed or aged out then
looks like a claim with no pull request at all, and the card can say
nothing about it.

The address the workflow published is the other record of which pull
request the claim is about, so it is read by its number when the branch
answers nothing. The address counts only on the forge that hosts it — a
worktree can publish a mirror's address while its pull requests live
elsewhere, where the same number is a different pull request.

A pull request closed without merging is no longer read as a merge. A
merge accepts the work and the act moves on to the next phase; a closure
says only that the work was not accepted, and nothing published says
whether the phase is redone, dropped or already superseded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review block drew only when the worktree published at least one
artifact. A worktree that published a status, a message, or a claim and
no artifact drew nothing, so its card said nothing at all.

The overview does not gate on artifacts: it counts the act resolved from
the review state. A worktree in that state was therefore counted in the
header and silent on its card, and the count pointed at a card with no
reason on it.

The block now draws whenever the worktree published something to read.
The heading names the artifact list, so it comes and goes with the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The act a worktree has pending now reads directly under its title on every
card variant and on the detail page, out of the review block eight elements
down. Projects on the overview come ordered by how many worktrees want him,
and a project page puts the worktrees that want him above the ones that do
not, by issue id within each group.

Both orders read the act through the same resolution the attention count
uses, so the order and the cards cannot disagree. Position is all that
changes: every worktree keeps its full card and its page.
The project page rendered the worktrees that want the human first while the
sync endpoint still called issue-id order current. Every poll read the page's
own order as a change, deleted each moved card and re-inserted it at the top,
so the list flipped on every refresh.

Both now read WorktreeOrder.pendingFirst, keyed on the act each card names.
An order the page renders and the refresh disagrees with is not one order.
Inside the group that wants Michal, the most recently published worktree
comes first — the one he still holds in his head — and each act says how
long it has waited, so the order can be checked by looking instead of
trusted. The worktrees that want nothing are not a queue he works down, so
they keep issue-id order.

The clock is last_updated, which the workflows already write and the
dashboard ignored until now; a state that names no moment claims no wait,
so it shows none and sits behind the ones that do.
A card that gained or lost its act was deleted and re-inserted at the top,
and a new worktree was placed by comparing issue ids - both assume the id
order the page no longer renders, so a card that wants nothing could land
above one that does.

Placement now reads the slot from the list's own order: predecessorIn
anchors each card to the one before it, every removal is emitted before the
first placement, and placements run in list order so the anchor is always
there. A client whose order is wholly stale converges in one refresh.

generateReorderOob is gone - it moved one card at a time to a fixed
position, which cannot express a correct multi-card move; its cases are now
covered through generateChangesResponse.
currentPhase is the first phase the workflow has not completed - the phase
to launch - but the act added one to it, so MEDECA-410 merged phase 1 and
was told to launch phase 3 of 4.

It now asks for that phase by its own number. The phase files are written
just in time, so the phase to launch usually has no task file yet and that
absence must not hide the act. When every phase is done, currentPhase names
the last one instead of one left to run; that case still resolves to
unconfirmed rather than asking for a phase that has already run.
announcedPullRequests already put a worktree whose review state names an
unread pull request in the fast lane, but it read that claim from
reviewStateCache - and only the card and detail endpoints write that cache,
both of which fire only while the human already has the card on screen. GET /
reads every review state fresh and discards it; the sweep does not touch it.
So the claim reached the plan only after the wait it exists to spare him.

The poller now reads what the worktrees published from disk on its own
thread, each fast tick, before planning. An unchanged worktree costs a stat:
the read serves the cached entry back while the file's mtime holds. A state
that vanished keeps its entry - dropping it stays the render's call, which
cannot race a background thread.
fix(dashboard): read one pending act, and refresh from what the order read
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / dashboard-build (pull_request) Has been cancelled
CI / compile (pull_request) Has been cancelled
83e61b8a0f
The overview count, a project page's order and the refresh that keeps it
current each composed the resolution themselves, and had drifted on the
progress gate. DashboardService.pendingActFor is now the one composition
all of them ask.

The changes endpoint ordered by review state read from disk and rendered
the moved card from the in-memory cache, which only a watched card writes.
A card could be moved by a state its own markup did not show. It now
builds one fresh read and uses it for both.

Pins the two wire keys the acts and the order rest on: last_updated and
status were parsed by a hand-written codec no test read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
workflow(sl): close slices 02-04 (flush parked items)
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 2m16s
CI / dashboard-build (pull_request) Successful in 1m20s
CI / test (pull_request) Successful in 10m14s
bc325edd68
Three fits passed, one pull request: #415. Closes the map items
pr-state-current, attention-indicator and attention-prominence, and
flushes the parked lists into the effort map as three slice candidates
plus the debts that block no card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'main' into dashboard-control-plane
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m22s
CI / dashboard-build (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 9m6s
e45ec8a929
Records the merge of PR #414 (slice 01), whose content this branch
already carries. No files change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mph merged commit efeaad0118 into main 2026-08-11 20:34:48 +00:00
Sign in to join this conversation.
No description provided.