feat(dashboard): the worktree list matches what is on disk #414

Merged
mph merged 10 commits from dashboard-control-plane into main 2026-08-06 19:55:36 +00:00
Owner

Slice: dashboard-control-plane/01-worktrees-match-disk

Goal (the use). Open the dashboard after the server was down — including after making
worktrees while it was down — and see every worktree actually on disk, with no re-registration
by hand.

Fit test. Stop the server, ./iw start a worktree, bring the server up, read the list at
http://localhost:9876 against git worktree list in each registered project. It closes on the
human's read: nothing known is missing, nothing foreign is present.

Fit passed on 2026-08-03 — measured across 17 registered projects: 60 live worktrees on
disk, 60 registered, 0 missing, 0 foreign.

What ships

The sweep. The server reads git worktree list --porcelain for every registered project at
startup and on /, throttled to one pass per 30s, and registers what state is missing. Disk is
read, never written: no worktree is created, moved, or removed. Registration still flows through
the one WorktreeRegistrationService.register that ./iw start drives — the sweep is a recovery
for that path, not a second copy of it.

Foreign entries are decided by git, not by os.exists. A directory left behind after a
worktree is removed loses its card; a worktree of a project that is not registered keeps one; a
scan that reached no project concludes nothing.

Every worktree of a registered project gets a card. WorktreeKind.Plain names a worktree
with neither a tracker issue nor a framed effort — a release or hotfix checkout — keyed on its
branch and badged "Branch". Checks that read "is an effort" now read "has no tracker issue".

Grouping stopped guessing. WorktreeRegistration.projectPath records the owning repository,
sent by ./iw start, iw register and the dashboard's own create path. The field is optional
and the sweep fills it in for registrations that predate it.

Any command can start the server. DashboardJar.locate resolves the jar from
IW_INSTALL_DIR in the order iw-run uses — environment override, the jar a release ships, the
jar a dev checkout builds. ProcessManager no longer requires IW_DASHBOARD_JAR, which iw-run
sets only on the dashboard command paths.

Root-cause fix found on the way

WorktreeRegistrationService.register and updateLastSeen rebuilt ServerState from the
worktrees map alone, dropping all four caches and every registered project. The HTTP endpoint had
masked this for as long as it existed by reading one registration out of the result and throwing
the state away; the sweep stored it, and the loss surfaced immediately. Both carry the state over
now, with regression tests at the register and the reconcile level.

Review

Seven skills, 4 critical findings, all fixed in loop — see
project-management/efforts/dashboard-control-plane/slices/01-worktrees-match-disk/review-slice-20260803-210757.md.

  1. updateLastSeen passed its statePath into the new projectPath parameter, so every
    iw issue / iw open overwrote the recorded project with the path of state.json.
  2. The HTMX out-of-band target used an id selector, so a card keyed on v1.9.15 could never be
    swapped in — the dots parse as class names.
  3. A stored non-absolute path made os.Path throw inside the sweep, which runs in the server
    constructor — one bad PUT would have stopped the dashboard from starting.
  4. The projectPath wiring in iw register had no test.

Gates

core.test           186 SUCCESS
dashboard.test      241 SUCCESS
dashboard.itest     240 SUCCESS
BATS E2E              0 failures
scala-cli fmt         clean
core -Werror          clean

Follow-ups (parked in the effort map, none blocking)

  • withLock helper in ServerStateService — nine copies of lock/mutate/persist
  • CaskServer is a god object; the sweep adds another responsibility
  • Decide whether dashboard/jvm/src/domain/ and application/ are the target layout
  • WorktreeRegistration.create is bypassed for kind and projectPath
  • Bring core/test/ under the pre-commit -Werror compile
  • The sweep runs from GET / only, not from the project or worktree pages
  • No test that the sweep throttle suppresses a second scan
  • plainKey can collapse two branches to one key
## Slice: dashboard-control-plane/01-worktrees-match-disk **Goal (the use).** Open the dashboard after the server was down — including after making worktrees while it was down — and see every worktree actually on disk, with no re-registration by hand. **Fit test.** Stop the server, `./iw start` a worktree, bring the server up, read the list at `http://localhost:9876` against `git worktree list` in each registered project. It closes on the human's read: nothing known is missing, nothing foreign is present. **Fit passed** on 2026-08-03 — measured across 17 registered projects: **60 live worktrees on disk, 60 registered, 0 missing, 0 foreign.** ### What ships **The sweep.** The server reads `git worktree list --porcelain` for every registered project at startup and on `/`, throttled to one pass per 30s, and registers what state is missing. Disk is read, never written: no worktree is created, moved, or removed. Registration still flows through the one `WorktreeRegistrationService.register` that `./iw start` drives — the sweep is a recovery for that path, not a second copy of it. **Foreign entries are decided by git, not by `os.exists`.** A directory left behind after a worktree is removed loses its card; a worktree of a project that is not registered keeps one; a scan that reached no project concludes nothing. **Every worktree of a registered project gets a card.** `WorktreeKind.Plain` names a worktree with neither a tracker issue nor a framed effort — a release or hotfix checkout — keyed on its branch and badged "Branch". Checks that read "is an effort" now read "has no tracker issue". **Grouping stopped guessing.** `WorktreeRegistration.projectPath` records the owning repository, sent by `./iw start`, `iw register` and the dashboard's own create path. The field is optional and the sweep fills it in for registrations that predate it. **Any command can start the server.** `DashboardJar.locate` resolves the jar from `IW_INSTALL_DIR` in the order `iw-run` uses — environment override, the jar a release ships, the jar a dev checkout builds. `ProcessManager` no longer requires `IW_DASHBOARD_JAR`, which `iw-run` sets only on the dashboard command paths. ### Root-cause fix found on the way `WorktreeRegistrationService.register` and `updateLastSeen` rebuilt `ServerState` from the worktrees map alone, dropping all four caches and every registered project. The HTTP endpoint had masked this for as long as it existed by reading one registration out of the result and throwing the state away; the sweep stored it, and the loss surfaced immediately. Both carry the state over now, with regression tests at the register and the reconcile level. ### Review Seven skills, 4 critical findings, all fixed in loop — see `project-management/efforts/dashboard-control-plane/slices/01-worktrees-match-disk/review-slice-20260803-210757.md`. 1. `updateLastSeen` passed its `statePath` into the new `projectPath` parameter, so every `iw issue` / `iw open` overwrote the recorded project with the path of `state.json`. 2. The HTMX out-of-band target used an id selector, so a card keyed on `v1.9.15` could never be swapped in — the dots parse as class names. 3. A stored non-absolute path made `os.Path` throw inside the sweep, which runs in the server constructor — one bad `PUT` would have stopped the dashboard from starting. 4. The `projectPath` wiring in `iw register` had no test. ### Gates ``` core.test 186 SUCCESS dashboard.test 241 SUCCESS dashboard.itest 240 SUCCESS BATS E2E 0 failures scala-cli fmt clean core -Werror clean ``` ### Follow-ups (parked in the effort map, none blocking) - [ ] `withLock` helper in `ServerStateService` — nine copies of lock/mutate/persist - [ ] `CaskServer` is a god object; the sweep adds another responsibility - [ ] Decide whether `dashboard/jvm/src/domain/` and `application/` are the target layout - [ ] `WorktreeRegistration.create` is bypassed for `kind` and `projectPath` - [ ] Bring `core/test/` under the pre-commit `-Werror` compile - [ ] The sweep runs from `GET /` only, not from the project or worktree pages - [ ] No test that the sweep throttle suppresses a second scan - [ ] `plainKey` can collapse two branches to one key
Registrations arrive as one push from `./iw start`, so a worktree created
while the dashboard server is down never reaches state.json and is
unrecoverable. Disk is now the ground truth the server reads back from.

GitWorktreeEntry parses `git worktree list --porcelain`.
WorktreeDiscovery walks every registered project and describes each
worktree from its own .iw/config.conf, carrying the same fields
`./iw start` would have sent. WorktreeReconciliationService adds only
what state is missing — existing registrations keep their data, and
nothing on disk is ever written.

The sweep runs at startup and on the `/` route alongside the existing
prune, throttled to one sweep per 30s since it shells out to git per
project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WorktreeRegistrationService built a fresh ServerState holding only the
worktrees map, so register and updateLastSeen silently dropped the issue,
progress, PR and review-state caches along with every registered project.
The HTTP endpoint pulled a single registration out of the result and
never stored the returned state, so the loss stayed hidden until the disk
sweep stored it.

Both functions now carry the rest of the state over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A directory left behind after `git worktree remove` still exists, so the
prune that tests os.exists kept a card alive for a worktree that no longer
existed. Git decides now: a registration survives when a scanned project
still lists its path, or when git calls the path a work tree — the second
clause keeps worktrees whose parent project is not registered.

A scan that reached no project drops nothing, so an unreachable disk
cannot be read as "no worktrees exist". Disk is never written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard hid worktrees it could not name or group. A branch that was
neither TEAM-123 nor a framed effort was skipped entirely, and a worktree
whose directory did not end in -<key> grouped under no project, because
the project was inferred by stripping that suffix from the directory name.

WorktreeKind.Plain now names a worktree with neither a tracker issue nor
an effort behind it — a release or hotfix checkout — keyed on its branch
and rendered on the trackerless card under a "Branch" badge. Checks that
asked "is this an effort" now ask "does a tracker issue stand behind it".

WorktreeRegistration.projectPath records the owning repository, sent by
`iw start`, `iw register` and the dashboard's create path. The sweep
fills it in for registrations that predate the field, and grouping
prefers it over the directory name. A worktree whose own .iw/config.conf
is unreadable takes its tracker type and team from its project.

reconcileWorktrees persists on any change, not only on a recovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ProcessManager required IW_DASHBOARD_JAR, which iw-run sets only on the
dashboard command paths. Any other command that had to bring the server
up failed its own guard and dropped the registration behind a warning.

DashboardJar.locate applies the same order iw-run uses — the environment
override, the jar a release ships, then the jar a development checkout
builds — from IW_INSTALL_DIR, which iw-run exports for every command.
fix(dashboard): keep the recorded project honest and the sweep survivable
All checks were successful
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 8s
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m18s
CI / dashboard-build (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 8m47s
540ca6d2b8
Four defects the review pass found:

- ServerClient.updateLastSeen passed its statePath into registerWorktree's
  new projectPath parameter, so every iw issue and iw open overwrote the
  project a worktree is grouped under with the path of state.json.
  projectPath is an Option now, omitted from the request when absent.
- The HTMX out-of-band target used an id selector, which reads the dots in
  a release-branch key such as v1.9.15 as class names. Cards are addressed
  by attribute instead.
- A stored path that is not absolute made os.Path throw inside the sweep,
  which runs in the server constructor. Registration rejects such a path,
  and the sweep skips what it cannot read rather than dying.
- Register's projectPath wiring had no test at all.

A plain worktree also read Branch on its card and Effort on its detail
page; the label lives on WorktreeKind now.
workflow(sl): close dashboard-control-plane/01-worktrees-match-disk (flush parked items)
All checks were successful
CI / compile (pull_request) Successful in 1m18s
CI / format (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 7s
CI / contract (pull_request) Has been skipped
CI / dashboard-build (pull_request) Successful in 51s
CI / test (pull_request) Successful in 8m25s
1f2d1376c7
mph merged commit 5c96ee864c into main 2026-08-06 19:55:36 +00:00
Sign in to join this conversation.
No description provided.