chore: untrack the relay working notes

.pi-web/ is gitignored local state; the relay packet was force-added while
running the work and should not land on main. The notes stay on disk for the
worktree, they are just no longer tracked.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:25:59 +02:00
parent ba37bd51ae
commit c9fabbf1b6
4 changed files with 0 additions and 977 deletions
@@ -1,131 +0,0 @@
# Charter — relay "worktree-autodetect"
## Relay identity
- **Name:** `worktree-autodetect`
- **Root:** `.pi-web/relays/worktree-autodetect/` in worktree `/srv/dev/pi-web-worktrees/worktree-autodetect`
- **Branch:** `feat/worktree-autodetect` (based on `main`)
## Goal / finish line
Worktrees created or removed outside PI WEB become visible in the browser workspace
list **with no user action of any kind**, on the next natural browser resume, on both
local and remote machines.
Concretely, the relay is finished when all of the following are true:
1. `discoverGitWorktrees` no longer reports worktrees whose checkout directory is gone
(`prunable` in `git worktree list --porcelain`), so worktrees deleted outside PI WEB
stop appearing as selectable ghost workspaces.
2. `WorkspaceController` can re-list the workspaces of the selected project and apply the
result **without disturbing the current selection, session, or scroll state** when the
selected workspace still exists, and without silently yanking the user out of a
workspace that vanished while they were working in it.
3. `PiWebApp` calls that refresh from the existing browser-resume path
(`refreshAfterBrowserResume`) and the existing plugin-facing `refreshAppData` path.
No new timer, no new watcher, no new process, no new WebSocket channel.
4. Tests cover: prunable parsing/filtering, refresh-preserves-selection,
refresh-when-selected-workspace-disappeared, and the resume wiring.
5. `npm run verify` is green, and a changeset exists describing the user-visible behavior.
**Explicitly out of scope** (decided in leg 0, do not re-open without the human):
- Filesystem watchers on `.git/worktrees` or anywhere else.
- Polling timers for worktree discovery.
- Any server→browser push channel for workspace topology.
- Instant (sub-second) detection while the browser tab already has focus.
- Auto-*adopting* anything into `projects.json`. Worktrees are derived, never registered;
nothing is being adopted, and no project registry write is part of this work.
## Sizing
**One leg = one vertical slice that leaves the tree green and committed.**
A leg is done when its slice is implemented, its tests are written and passing, the
narrowest meaningful checks are run (`npm test -- --run <file>`, plus `npm run typecheck`
if exported types changed), and the work is committed. Do not carry uncommitted work
across a handoff.
Expected shape is three legs (see `plan.md`). If a leg turns out bigger than one slice,
split it and hand off the remainder rather than doing "just a bit more".
## Task selection policy
1. Take the explicit **next leg** named in `status.md`.
2. If `status.md` does not name one, take the next unfinished slice in `plan.md` in order.
3. If neither is clear, or the next slice would change the design rather than implement it,
**stop and raise the intervention signal**. Do not redesign inside a leg.
## Handover protocol
Before handing off, in this order:
1. Make the work durable: source + tests written, checks run, changes **committed** with a
Conventional Commit message.
2. Update `status.md`: current position, last completed leg, next leg to run, next task,
relevant context for the next runner, blockers.
3. Append a concise entry to `log.md`: what you did, decisions and why, artifacts changed,
exact checks run and their results, handing-off vs stopping.
4. Then `spawn_session` **once**, with a prompt starting:
```text
Relay "worktree-autodetect" leg <N> begins now.
You are the next runner in this Relay method chain.
Read:
- .pi-web/relays/worktree-autodetect/charter.md
- .pi-web/relays/worktree-autodetect/status.md
Do not read log.md end-to-end. Use it only for targeted lookup if status.md or charter.md points you there.
Run one leg according to the charter. Before handing off, update status.md, append log.md, make work durable, then either spawn the next leg once or stop with a clear intervention note.
```
## Intervention signal
**Stop, do not spawn**, and write a clearly marked `## BLOCKED` section at the top of
`status.md` plus a log entry, if any of these happen:
- The next task is ambiguous, or doing it would require a design decision not in this charter.
- You are tempted to add a watcher, a timer, a new process, or a new push channel. That
means the design boundary is being crossed — get the human.
- Refresh-on-resume cannot be made to preserve selection without visible UI churn
(list reordering, chat scroll jump, session reload, terminal teardown). This is the
main known risk; it is a stop, not a workaround.
- Filtering `prunable` would remove a workspace the user could plausibly still want
(for example a temporarily unmounted network path) and you cannot bound that safely.
- `npm run verify` fails for a reason you did not introduce.
## Reading discipline
Read to orient: `charter.md`, then `status.md`, then only the files `status.md` names.
Do **not** read `log.md` end-to-end; use it only for targeted lookup when pointed there.
Do **not** read the sibling worktrees `/srv/dev/pi-web-worktrees/worktree-create-ui` or
`/srv/dev/pi-web-worktrees/model-questions-ux` — they are separate, parallel efforts. Per
the human's decision, assume they contribute nothing to this relay; this relay owns the
workspace-topology refresh seam outright.
Relevant source surface, small enough to read directly when your leg touches it:
- `src/server/workspaces/gitWorktreeDiscovery.ts` (39 lines)
- `src/server/workspaces/workspaceService.ts` (44 lines)
- `src/client/src/controllers/workspaceController.ts` (~105 lines)
- `src/client/src/appShell/browserResumeController.ts` + its test
- `src/client/src/components/PiWebApp.ts` — only `refreshAfterBrowserResume`
(~line 432) and `refreshAppData` (~line 485). Do not read this 2300-line file whole.
## Project conventions that apply
- **Changesets:** this is user-visible. Add a `.changeset/*.md` fragment
(see `.agents/skills/changeset-changelog/SKILL.md`). Never hand-edit `CHANGELOG.md`.
- **Skills:** use `.agents/skills/code-quality-architecture/SKILL.md` when writing
production code and `.agents/skills/testing-guide/SKILL.md` when writing tests.
- **Session daemon:** this design deliberately touches **no** sessiond code, no session
runtime ownership, and no daemon protocol. **No manual session daemon restart is
required.** Changes land on the autoreloading `pi-web-web-ui-dev.service` path only.
If a leg finds itself editing `src/server/sessiond.ts`, that is the intervention signal.
- **Client URL conventions:** no new endpoints are added; the existing
`workspacesApi.workspaces()` request path is reused unchanged.
- **No `npm install`** — `node_modules` here is a symlink to the main checkout.
-580
View File
@@ -1,580 +0,0 @@
# Log — relay "worktree-autodetect"
Append-only. One entry per leg. Do not read end-to-end unless `status.md` points you here.
---
## Leg 0 — Assessment, design, packet creation
**Runner:** assessment/design session
**Outcome:** recommendation = **reduced scope**. Packet created. Relay parked pending
human approval. No production code written.
### Feature request
> "auto detect and show new worktrees, even when created outside of pi-web."
With the user's framing: nice-to-have, not a must, expects it may not be feasible, and
**must require zero user intervention**.
### What I found in the codebase
The single most important finding reframed the whole feature:
**Worktree discovery is already fully dynamic. There is no cache and no registry.**
`WorkspaceService.list()` (`src/server/workspaces/workspaceService.ts`, 44 lines) calls
`isGitRepository()` then `discoverGitWorktrees()` — which shells out to
`git worktree list --porcelain` — on **every single** `GET /projects/:projectId/workspaces`
request. I grepped for any cache/memo in that path: there is none. Workspace ids are
derived by hashing `${project.id}:${worktree.path}`, so they are stable across calls
without being stored anywhere.
And `projects.json` (`src/server/storage/projectStore.ts`) stores only
`{ id, name, path, createdAt }` per **project**. Workspaces are never persisted.
Two consequences, both of which delete a large chunk of the anticipated problem:
1. **A worktree created outside PI WEB is already detected.** The server has no stale
state to invalidate. The gap is not detection at all — it is that **the browser never
re-asks**. `WorkspaceController` fetches workspaces in `selectProject()` and in
`refreshProjectWorkspaces()`, and the only caller of the latter is the
workspace-*deletion* flow. So the list is fetched on project selection and then frozen
for the lifetime of that selection.
2. **"Auto-adoption" is a non-question.** The brief asked whether zero-intervention
detection implies zero-intervention adoption, and whether a discovered worktree should
be a distinct "discovered" state. Since worktrees are derived and nothing is written to
a registry, there is nothing to adopt and no state to distinguish. A new worktree is
simply a member of the derived list. This was the design's biggest apparent risk and it
evaporated on inspection.
Measured cost of the discovery itself: **~2ms**. 20 sequential
`git worktree list --porcelain` runs on this repo took 42ms total.
I also found the delivery mechanism already built and already debounced:
`src/client/src/appShell/browserResumeController.ts` listens to window `focus` and
document `visibilitychange`, batches signals per animation frame, and collapses
concurrent refreshes through `TrailingRefreshCoordinator`. It drives
`PiWebApp.refreshAfterBrowserResume()`, which today refreshes the selected session,
machine activities, and workspace-deletion runs. Workspace topology is conspicuously
absent from that list.
And remote machines need no work: `GET /projects/:projectId/workspaces` is already in
`FEDERATED_HTTP_ROUTES` (`src/shared/federatedRoutes.ts:25`) and `workspacesApi.workspaces`
already takes a `machineId` and routes via `machinePrefix`.
### The inverse case, verified against real git
I built a throwaway repo in `/tmp/wtprobe` and checked what git actually reports.
- `.git/worktrees/` does not exist until the first linked worktree is added, then gains
one directory per worktree.
- After `rm -rf`ing a worktree's directory **without** `git worktree remove`,
`git worktree list --porcelain` **still lists it**, with an extra line:
`prunable gitdir file points to non-existent location`.
- A locked worktree gets a bare valueless `locked` line.
The current parser ignores both keys. So **PI WEB today shows worktrees that no longer
exist as normal, selectable workspaces** — a real bug, present regardless of whether the
detection feature is built. Selecting one produces a workspace whose path does not exist.
### Options compared
**A. `git worktree list` on a timer (server or client poll).**
Rejected. It is the obvious answer and it is the wrong one. A timer runs forever to catch
an event that happens a few times a week, and it must run per project, per machine, or it
does not actually satisfy "no intervention". For a nice-to-have, a permanent background
cost to serve a rare event is exactly the trade the user warned against. It also has no
natural interval: fast enough to feel automatic is wasteful, slow enough to be cheap is
not noticeably better than the resume trigger, which is free.
**B. Watch `.git/worktrees/` with `fs.watch`/inotify.**
This was the most interesting candidate and the one I most wanted to work. The watch
target is genuinely small and precise — one directory in the main repo, one entry per
worktree, written by git itself. That is far better than watching filesystems for new
directories.
Rejected anyway, on cost and correctness:
- **Lifecycle ownership is the real problem, not the watcher.** A watcher must be created
and destroyed as projects are added/removed, and it must live somewhere long-lived. The
web/API process autoreloads (`pi-web-web-ui-dev.service`), so watchers there churn
constantly. The natural long-lived home is the session daemon — but that would drag a
purely presentational concern into session runtime ownership and, per `AGENTS.md`, make
every change to it require a manual daemon restart. For a nice-to-have, that is a
disproportionate architectural commitment.
- **The directory does not exist until the first worktree exists**, so a repo with no
linked worktrees needs a watch on `.git/` itself to catch `worktrees/` being created —
a noisier target that fires on every ref update, index write, and fetch.
- **It only fixes the local case.** Remote machines would need the event pushed over the
machine transport, which means a new workspace-topology realtime event type, publishing
it from the daemon, adding it to `FEDERATED_WEBSOCKET_ROUTES` plumbing, parsing it in
`sessionSocket.ts`, and handling it in `PiWebApp`. That is a meaningful new protocol
surface for a feature the user called optional.
- **Environment caveats are real.** `fs.watch` is unreliable on Docker bind mounts on
macOS/Windows (the repo ships `docker/compose.yml` with bind-mounted checkouts) and on
network filesystems, and inotify watch limits are a known operational failure mode. So
the "instant" promise would be silently broken for a subset of users — worse than an
honest "updates when you come back to the tab".
- **It still would not be enough.** `fs.watch` on `.git/worktrees` catches creation but
the removal case still needs the `prunable` fix, because `rm -rf` of the *checkout*
does not touch `.git/worktrees/<name>` at all — I verified the metadata directory
survives. So the watcher does not even subsume the cheaper fix.
**C. Piggyback on events that already happen.** ← chosen
The refresh already exists, is already debounced, already covers remote machines, and
costs one ~2ms request per tab refocus. Marginal cost is as close to zero as this feature
can get, and the code surface is ~60 production lines.
### Recommendation: reduced scope
Do the cheap 90%:
1. **Fix the inverse case** — filter `prunable` worktrees out of the workspace list.
Read-only; PI WEB must not run `git worktree prune` as a side effect of listing.
2. **Add a non-disruptive topology refresh** to `WorkspaceController` that re-lists the
selected project's workspaces without touching selection or session state.
3. **Call it from the existing resume path** — no new timer, watcher, process, or channel.
**Scope boundary, stated plainly:** detection is **resume-scoped, not instant**. A worktree
created in another terminal while the PI WEB tab already has focus is not noticed until the
tab is refocused or becomes visible again. That is the honest limit, and it should be
documented rather than papered over.
Within that boundary it is genuinely zero-intervention: no button, no config, no opt-in,
works on local and remote machines, works for creation and removal.
### The risk that could kill it
`WorkspaceController.selectWorkspace()` calls `clearActiveSession()` and
`resetWorkspaceScopedState()`. If a background refresh routes through it, the user's chat
is torn down every time they refocus the tab. The refresh must apply the list through
`applyProjectWorkspaces` only. `ProjectActivityOwnershipCoordinator` is the existing
precedent for background topology hydration that deliberately leaves selection alone, and
is the model to follow. Leg 2 proves this with tests before leg 3 wires the trigger — and
if it cannot be made non-disruptive, that is an explicit stop.
### What would change the recommendation
- If the user says instant-while-focused is actually required, option B comes back on the
table — but with the session daemon commitment, the new realtime event type, and the
Docker/network-filesystem caveats accepted as the price.
- If a future need arises for reliable server-pushed workspace topology for another reason,
the watcher becomes incremental rather than a feature-specific cost, and the ledger flips.
### Artifacts created
- `.pi-web/relays/worktree-autodetect/charter.md`
- `.pi-web/relays/worktree-autodetect/status.md`
- `.pi-web/relays/worktree-autodetect/plan.md`
- `.pi-web/relays/worktree-autodetect/log.md` (this file)
### Checks run
None — no code was changed in this leg.
### Human decisions received at the end of leg 0
All three open questions were answered, and the reduced scope was approved:
1. **Latency** — resume-scoped detection is acceptable. No timer, no watcher.
2. **Removed worktrees** — yes, hide worktrees whose directory is gone.
3. **Sibling overlap** — assume the other session does nothing; this relay owns the
refresh seam outright and should not design for sharing.
The human also asked for the `selectWorkspace` risk to be explained concretely. The
worked failure mode is now recorded inline in `plan.md` → Leg 2, because the buggy version
is the one that looks correct: re-resolving the selection after a refresh via
`selectPreferredWorkspace` + `selectWorkspace` (mirroring `selectProject`) tears down the
session on **every** browser resume, since `selectWorkspace` has no already-selected guard
and always runs `clearActiveSession()` (closing the session socket mid-stream and dropping
buffered deltas) plus `resetWorkspaceScopedState()` (clearing chat, file tree, open file,
git status, open diff, terminal selection).
### Handing off?
**Yes.** Packet committed, `status.md` un-parked with the decisions recorded, leg 1
dispatched via `spawn_session`.
---
## Leg 1 — Stop reporting removed worktrees (server truth)
**Commit:** `266f941``fix(workspaces): hide worktrees whose checkout directory is gone`
### What I did
1. **Parser reports facts.** `src/server/workspaces/gitWorktreeDiscovery.ts` now reads the
`prunable` and `locked` keys. Extracted the pure parsing step into an exported
`parseGitWorktreeList(stdout)`; `discoverGitWorktrees` is now just the `execFile`
boundary plus that call. `GitWorktreeInfo` gained optional `prunable` / `locked`.
2. **Service decides policy.** `src/server/workspaces/workspaceService.ts` filters prunable
linked worktrees in a small private `selectable()` step, keeping the entry whose path
equals `project.path` unconditionally. Existing "no worktrees" fallback then also covers
the case where every listed worktree was filtered away, so a project can never present
an empty workspace list.
3. **Injected the git boundary.** `WorkspaceService` now takes an optional
`WorkspaceGitPort` (`{ isGitRepository, discoverGitWorktrees }`) defaulting to the real
implementation. This is what made the policy testable without a real repo or a
subclass-override fake, and it left every existing `new WorkspaceService()` call site
(`app.ts`, `app.testSupport.ts`, `sessiond.ts`) untouched.
4. **Two new test files** (the plan predicted one; the policy assertions belong next to the
service that owns them, not next to the parser):
- `gitWorktreeDiscovery.test.ts` — parser only, fixtures captured verbatim from real git.
- `workspaceService.test.ts` — prunable hidden, locked kept, project path kept even when
marked prunable, fallback when everything is filtered, plus pre-existing labeling and
non-git behavior pinned so leg 2/3 have a regression net.
### Decisions and why
- **Facts in the parser, policy in the service**, as `plan.md` preferred. The words
`prunable` and `project.path` now appear in exactly one place each.
- **Constructor injection rather than subclass-override fakes.** `workspaceDeletionRoutes.test.ts`
fakes `WorkspaceService` by subclassing and overriding `list()`, which cannot test `list()`
itself. A narrow port is the smaller seam and keeps the production default unchanged.
- **Read-only, as mandated.** No `git worktree prune`, no repo metadata mutation anywhere.
- **`locked` is parsed but deliberately not acted on.** A locked worktree is a real checkout
and stays selectable. It is surfaced now because git emits it in the same records and
future UI may want it; adding it later would mean touching the parser again.
- **Re-verified the git behavior** rather than trusting it: created a throwaway repo,
`rm -rf`'d a linked worktree and locked another. Confirmed `prunable gitdir file points to
non-existent location` and `locked keep me`. Test fixtures are that exact output. Probe
repo deleted afterwards.
- **No API/type change.** `prunable`/`locked` never leave the server, so the shared
`Workspace` type and the client are untouched. Leg 2 needs no type work.
### Checks run
- `npm test -- --run src/server/workspaces/gitWorktreeDiscovery.test.ts src/server/workspaces/workspaceService.test.ts` → 10 passed
- `npm test -- --run src/server/workspaces/` → 12 files, 88 passed
- `npm test -- --run src/server/app.projects.test.ts` → 5 passed (workspace list route contract)
- `npm run typecheck` → clean
- `npx eslint` on all four changed files → clean
- pre-commit `verify:staged` → cached typecheck, knip, eslint, 14 related test files / 63 tests, all green
`npm run verify` was not run; per `plan.md` that is leg 3's gate.
### Artifacts changed
- `src/server/workspaces/gitWorktreeDiscovery.ts` (modified)
- `src/server/workspaces/workspaceService.ts` (modified)
- `src/server/workspaces/gitWorktreeDiscovery.test.ts` (new)
- `src/server/workspaces/workspaceService.test.ts` (new)
- `status.md` (leg tracking → last completed 1 / next 2, next task set to leg 2, added a
note about the new `WorkspaceGitPort` seam)
No changeset yet — charter allows it any time up to leg 3, and leg 3 owns one fragment for
the whole user-visible behavior.
### Blockers
None. Nothing ambiguous, no design decision needed, no intervention trigger fired.
### Handing off?
**Yes.** Work committed, packet updated, leg 2 dispatched via `spawn_session`.
---
## Leg 2 — Non-disruptive workspace topology refresh in the client
**Commit:** `d0f8f9f``feat(workspaces): add non-disruptive workspace topology refresh`
### What I did
Added `WorkspaceController.refreshSelectedProjectTopology()`: it reads the selected project
and machine, calls `api.workspaces(project.id, machineId)`, re-reads state, discards the
response if machine or selected project changed mid-flight, and applies the list via the
existing private `applyProjectWorkspaces` — and nothing else. No selection is re-derived, no
session is cleared, no workspace-scoped state is reset, no URL update.
Added `src/client/src/controllers/workspaceController.test.ts` (7 tests, new file).
### Decisions and why
- **Did not use `selectPreferredWorkspace` / `selectWorkspace`.** `plan.md` documented this
as the plausible-looking destructive shape; I confirmed it in the source before writing.
`selectWorkspace` has no already-selected guard, so it unconditionally calls
`sessions.clearActiveSession()` and `setState({ ...resetWorkspaceScopedState() })` — which
clears `sessions`, `fileTree`, `expandedDirs`, `selectedFilePath`, `gitStatus`, the three
diff fields, and `selectedTerminalId`. Since leg 3 calls this on every browser resume, that
would blank the UI on every alt-tab. Applying the list alone is sufficient.
- **Selected workspace that disappeared: selection left untouched**, per plan. No new
recovery path; `refreshAfterWorkspaceDeleted` still owns that. Covered by a test asserting
the vanished workspace stays selected and `clearActiveSession` is not called.
- **Errors: injected sink, not `state.error`.** Added optional
`onBackgroundError(message, error)` to `WorkspaceControllerDependencies`, defaulting to
`console.warn`, mirroring `SessionNotificationController`'s existing pattern. This keeps the
behavior testable without stubbing `console` and guarantees a background refresh never
paints an error banner over a working session. The method therefore never rejects.
- **Guard style copied from `selectProject`** (compare `selectedMachineId` and
`selectedProject?.id` after the await) rather than inventing a generation counter — the
method is a single request with no trailing-pass semantics, so
`ProjectActivityOwnershipCoordinator`'s heavier machinery would have been disproportionate.
- **Wrote a `why` comment on the method**, not a `what` one: it records the invariant and why
the simpler-looking `selectWorkspace` route is unsafe, so a future editor cannot "simplify"
it back into the bug.
- **Nothing wired into `PiWebApp`**, per plan — deliberately leg 3.
### Verification that the guard is not vacuous
Mutation-tested the tests: temporarily patched the method to the plausible wrong version
(`selectPreferredWorkspace(...)` + `await this.selectWorkspace(...)`) and re-ran the file —
**2 of 7 tests failed** (selection-preserved and vanished-workspace). Restored the file from
backup and re-confirmed 7/7 green. The tests genuinely pin the invariant.
### Checks run
- `npx vitest --run src/client/src/controllers/workspaceController.test.ts`**7 passed**
- `npx vitest --run src/client/src/controllers`**26 files, 204 tests passed** (no
regression in sibling controllers)
- `npm run typecheck`**clean** (one iteration: `expandedDirs` is
`Record<string, FileTreeEntry[]>`, not booleans — fixed in the fixture)
- `npx eslint` on both changed files → **clean**
- pre-commit `npm run verify:staged` on commit → typecheck (cached), knip, eslint, and
related vitest (5 files / 21 tests) all **passed**
`npm run verify` was not run: the charter assigns it to leg 3, and this leg's change is
contained to one controller.
### Artifacts changed
- `src/client/src/controllers/workspaceController.ts` (modified: new method, new optional
`onBackgroundError` dependency)
- `src/client/src/controllers/workspaceController.test.ts` (new, 7 tests)
- `status.md` (leg tracking → last completed 2 / next 3, next task set to leg 3 with the
method's exact call signature, added notes on test coverage + the mutation check + the new
`onBackgroundError` dependency, rewrote Blockers to record that the UI-churn risk did not
materialize)
Still no changeset — leg 3 owns it, and `status.md` now flags it explicitly as missing.
### Blockers
None. The charter's main intervention trigger for this leg (refresh cannot be made
non-disruptive without visible UI churn) did not fire: no workaround was needed. No watcher,
timer, process, endpoint, or push channel added. No sessiond code touched, so **no manual
session daemon restart is required**.
### Handing off?
**Yes.** Work committed, packet updated, leg 3 dispatched via `spawn_session`.
---
## Leg 3 — wire the refresh to browser resume, document, changeset, full verify (final)
### What I did
Wired the leg-2 refresh into the two existing refresh paths, documented the behavior,
added the changeset, and ran the full `npm run verify` the charter requires of the last leg.
1. **Wiring (`src/client/src/components/PiWebApp.ts`, 2 lines).** Added
`this.workspaces.refreshSelectedProjectTopology()` to the `Promise.all` in
`refreshAfterBrowserResume` (~438) and in `refreshAppData` (~496). Nothing else in that
2300-line file was read or touched, per the charter's reading discipline.
2. **Wiring test (`src/client/src/components/PiWebApp.workspaceTopology.test.ts`, new,
3 tests).** Verifies the refresh is invoked by the exact callback
`BrowserResumeController` calls (`browserResume.callbacks.refreshAfterResume`, not a
hand-picked method reference), by `refreshAppData`, and that it still runs when a sibling
refresh in the same batch rejects. Sibling refreshes are stubbed so the assertions observe
only the topology call.
3. **Docs (`docs/faq.html`).** New FAQ entry `#worktree-list-out-of-date` plus its TOC link:
worktrees are listed on demand and never registered; the list is re-read on tab
focus/visibility rather than continuously, so refocusing updates it; selection/session/
scroll are preserved; worktrees with a missing checkout directory are hidden and git still
tracks them until `git worktree prune`. Placed in the FAQ per
`.agents/skills/documentation-guide/SKILL.md` (troubleshooting/edge-case content);
`README.md` deliberately untouched.
4. **Changeset (`.changeset/worktree-autodetect.md`).** `patch` for `@jmfederico/pi-web`
(CalVer: patch is correct for a non-breaking user-facing capability), written as user
behavior rather than an implementation log.
### Decisions and why
- **Did not add the `connectRealtime` `onReconnect` call.** `plan.md` marked it optional
"only if it costs nothing". It is not free: `onReconnect` captures the machine id at
connect time, while `refreshSelectedProjectTopology` reads the selected machine at call
time, so wiring them together would either need a machine-scoped variant or would fire a
refresh for a machine the user has since left. Adding a second concept for no user-visible
gain over the resume path failed the cost test. Socket reconnect on resume is already
covered by the resume path itself.
- **Did not pass a message-prefixing `onBackgroundError` sink from `PiWebApp`.** Leg 2 made
it optional with a `console.warn` default that already includes project and machine in the
message. `status.md` explicitly recorded this as not-required work; adding it would be
scope creep for identical output.
- **Verified the wiring test is not vacuous.** Removed both call sites with `perl`, re-ran the
file: 3/3 failed. Restored the file (verified 2 occurrences back) and re-ran: 3/3 passed.
- **No charter boundary crossed.** No timer, watcher, process, endpoint, or push channel;
no change inside `refreshSelectedProjectTopology`; leg 2's tests were not weakened; the
resume path re-derives no selection from the refreshed list.
### Checks run
- `npx vitest --run src/client/src/components/PiWebApp.workspaceTopology.test.ts`
**3 passed** (first run had 1 failure from a stub-shape mistake in my own test helper,
fixed by failing one named sibling refresh instead of all of them)
- mutation check (call sites removed) → **3 failed**, as intended; restored → **3 passed**
- `git diff --check` → clean
- `npx eslint` on the new test and `PiWebApp.ts`**clean**
- **`npm run verify` → green**: typecheck, lint, knip, and 228 test files /
1840 passed, 2 skipped
- pre-commit `npm run verify:staged` → typecheck (cached), knip, eslint, related vitest
(6 files / 20 tests) all **passed**
### Artifacts changed
- `src/client/src/components/PiWebApp.ts` (2 lines added)
- `src/client/src/components/PiWebApp.workspaceTopology.test.ts` (new, 3 tests)
- `docs/faq.html` (new FAQ entry + TOC link)
- `.changeset/worktree-autodetect.md` (new)
- committed as `84545fb feat(workspaces): refresh worktrees on browser resume`
- `status.md` rewritten as a finished-relay baton: finish-line conditions checked off one by
one with their commits, leg tracking set to last completed 3 / next none, shipped behavior
described in user terms, and the never-route-through-`selectWorkspace` invariant recorded
for whoever edits this code next
### Blockers
None. No intervention signal fired in this leg or any earlier one. No sessiond code touched,
so **no manual session daemon restart is required**; the change lands on the autoreloading
web/UI service path only.
### Handing off?
**No — this was the final leg.** All five charter finish-line conditions are met and
`npm run verify` is green, so per the charter this runner stops instead of spawning. The
branch `feat/worktree-autodetect` is ready for human review and merge.
---
## Post-relay — selected-workspace freshness fix (review follow-up, not a leg)
Triggered by a human review question after the relay finished: "do we have pragmatic
reasonable and stable code?" I reviewed the production diff against
`code-quality-architecture` and probed the runtime rather than trusting the leg summaries.
### What the review checked and found sound
- `handleWorkspaceChange` early-returns on equal workspace id → no `clearActiveSession`,
no terminal teardown on resume.
- `WorkspaceList.updated()` re-scrolls on any `workspaces` change, but via
`scrollIntoView({ block: "nearest" })`, a no-op when the row is already visible.
- Open row menu survives refresh: guarded by an id membership check, and ids are path-derived.
- Stale responses guarded on both machine and project id; background failures go to
`onBackgroundError`, never `state.error`, so a flaky resume shows no error toast.
### The one real gap, and the fix
`applyProjectWorkspaces` wrote a fresh `workspaces` array but left `selectedWorkspace`
pointing at the pre-refresh object. Reproduced with a scratch test: after a refresh where a
worktree's branch changed outside PI WEB, the list row showed `feature-b` while
`selectedWorkspace.branch` was still `feature-a`. User-visible in the collapsed Workspaces
header and the mobile context bar until reselect. Not a regression (both were stale before),
but a new fresh/stale inconsistency introduced by making the list refresh.
Fixed by re-pointing `selectedWorkspace` at its refreshed entry, keyed by `id`. Safety rests
on two things: `id` is derived from the path, so this can never change *which* workspace is
selected; and `handleWorkspaceChange` gates on `id`, so no session/terminal teardown fires.
The patch is skipped when metadata is unchanged, because `patchChangesState` is
identity-based and a real HTTP response returns fresh-but-equal objects every resume —
without that guard every browser focus would push a new object into state.
### Decisions
- **Left `locked` parsed-but-unconsumed.** Flagged it as YAGNI in review, then kept it: it
documents the deliberate policy that locked worktrees are *kept*, and that policy is pinned
by a real `workspaceService` test. Removing the field would not remove the policy.
- **Left the two refresh entry points un-deduped.** Both are idempotent, stale-guarded, and
~2ms; cross-path collapsing would add a concept for no user-visible gain.
- **Compared metadata field-by-field** (`sameWorkspaceMetadata`) rather than `JSON.stringify`,
which is key-order sensitive, or a deep-equal helper this file does not otherwise need.
### Checks run
- `npx vitest --run src/client/src/controllers/workspaceController.test.ts`**9 passed**
- mutation A, never re-point (restores the original bug) → the re-point test failed, alone
- mutation B, always re-point (drops the unchanged guard) → the identity test failed, alone
- `npx eslint` on both changed files → clean; `npm run typecheck` → clean
- **`npm run verify` → green**: 228 files, **1842 passed**, 2 skipped (was 1840)
- pre-commit `verify:staged` → 6 files / 26 tests passed
### Artifacts changed
- `src/client/src/controllers/workspaceController.ts` (`refreshedSelection` +
`sameWorkspaceMetadata`; `applyProjectWorkspaces` early-returns for the non-selected project)
- `src/client/src/controllers/workspaceController.test.ts` (+2 tests, 9 total)
- committed as `79577e4 fix(workspaces): keep selected workspace metadata fresh on refresh`
- no changeset added: `.changeset/worktree-autodetect.md` already promises the list stays
correct without user action, and this fix delivers that promise rather than adding to it
- `status.md`: new "Post-relay review fix" section and the second invariant recorded
### Blockers
None. No new timer, watcher, process, endpoint, or push channel; no sessiond code touched, so
**no manual session daemon restart is required**. Branch still ready for review and merge.
---
## Post-relay — review nits 2 and 3 (locked flag, overlapping refreshes)
Human asked to fix nit 2 and to fold in nit 3 if it belonged to the same group. It did:
both are small, contained changes to code this branch already introduced, and neither adds a
concept. Done as two commits because they are different concerns.
### Nit 2 — `8f060d1` remove the unused `locked` flag
I had defended keeping it in the previous review, arguing it documented the keep-locked
policy. That was wrong on inspection: the *test input* documents the policy, not the field.
Removed `GitWorktreeInfo.locked` and its parse line. The parser test still feeds a real
`locked keep me` porcelain line and now asserts it is ignored, and the service test still
pins that a present non-prunable worktree is kept. So the policy is covered with less surface,
and unknown porcelain keys were already ignored anyway.
### Nit 3 — `f8c3fdb` serialize overlapping topology refreshes
**This was not the cosmetic nit I rated it. It was a correctness bug**, and probing it first
is what revealed that. Scratch reproduction: two `refreshSelectedProjectTopology()` calls
issued 2 HTTP requests; resolving the newer one first and the older one second left
`state.workspaces` at the *older* list. So a worktree created outside PI WEB could appear and
then vanish again, which is precisely the bug this whole branch exists to fix. The machine and
project stale guards do not order responses.
Fixed by routing the refresh through `TrailingRefreshCoordinator` keyed by
`machineProjectKey(machineId, project.id)` — the primitive already used by
`browserResumeController`, `sessionController`, and `activityController`, so this is reuse,
not a new mechanism.
**Learned mid-implementation:** the coordinator does *not* collapse overlapping calls into one
request. It runs the second as a single trailing pass after the first finishes. My first test
asserted `toHaveBeenCalledOnce()` and hung for 5s until the vitest timeout. The code was
right and my assumption was wrong, so I rewrote the test to assert the real and stronger
guarantee: max-in-flight is 1, and the last response applied is the newest. Recorded in
`status.md` so the next person does not repeat the mistaken assertion.
### Checks run
- `npx vitest --run workspaceController.test.ts src/server/workspaces/`**98 passed**
(10 in `workspaceController.test.ts`)
- mutation check for nit 3: bypassed the coordinator by invoking the refresh body directly →
the new serialization test failed, **alone**; restored → all pass
- `grep` for leftover `locked` references → only the intentional test input/comments
- `npx eslint` on all four changed files → clean
- **`npm run verify` → green**: 228 files, **1843 passed**, 2 skipped
- pre-commit `verify:staged` on each commit → 14 files/63 tests and 6 files/27 tests, passed
### Artifacts changed
- `src/server/workspaces/gitWorktreeDiscovery.ts` (removed `locked` field + parse line)
- `src/server/workspaces/gitWorktreeDiscovery.test.ts`,
`src/server/workspaces/workspaceService.test.ts` (retargeted to the policy, not the field)
- `src/client/src/controllers/workspaceController.ts` (+`TrailingRefreshCoordinator`)
- `src/client/src/controllers/workspaceController.test.ts` (+1 test, 10 total)
- `status.md`: commit list, verify count, and the coordinator-semantics warning
### Blockers
None. No changeset added: `f8c3fdb` fixes a defect in unreleased work from this same branch,
and the existing fragment already promises the list stays correct without user action. Still
no timer, watcher, process, endpoint, or push channel, and no sessiond code touched, so
**no manual session daemon restart is required**.
-168
View File
@@ -1,168 +0,0 @@
# Implementation plan — worktree-autodetect (reduced scope)
Three legs. Each is a vertical slice: source + tests + checks + commit.
The order is deliberate: server truth first, then client application of that truth,
then the trigger that makes it zero-intervention.
---
## Leg 1 — Stop reporting removed worktrees (the inverse case)
**Why first:** it is independently valuable, has zero UI risk, and is the only part of
the feature that is a straight bug fix. Today a worktree deleted with `rm -rf` outside
PI WEB stays in the workspace list forever as a selectable ghost.
**Files**
- `src/server/workspaces/gitWorktreeDiscovery.ts`
- new `src/server/workspaces/gitWorktreeDiscovery.test.ts`
**Work**
1. Extend the porcelain parser to read the valueless `prunable` and `locked` keys.
`git worktree list --porcelain` emits `prunable <reason>` for a linked worktree whose
checkout directory no longer exists, and a bare `locked` line for a locked one.
Verified in leg 0 against real git.
2. Surface `prunable` on `GitWorktreeInfo`, and filter prunable entries out of what
`discoverGitWorktrees` returns — or return them and filter in `WorkspaceService`,
whichever keeps the parser honest and the policy visible. Prefer: parser reports
facts, `workspaceService` decides policy.
3. Do **not** run `git worktree prune`. Read-only. PI WEB must not mutate the user's
repo metadata as a side effect of listing.
4. Keep the main worktree unconditionally: never filter the entry whose path equals
`project.path`, so a project can never end up with an empty workspace list.
**Tests** (pure parser tests, no git process needed — inject or fake the exec boundary)
- parses `prunable` with a reason and `locked` without a value
- a prunable linked worktree is excluded from the workspace list
- a locked worktree is still included
- the main worktree survives even if git somehow marks it prunable
**Checks:** `npm test -- --run src/server/workspaces/gitWorktreeDiscovery.test.ts`,
plus the workspaceService/app.projects tests if they touch the shape, plus
`npm run typecheck` (`GitWorktreeInfo` is exported).
---
## Leg 2 — Non-disruptive workspace topology refresh in the client
**Why second:** this is the risky part, and it must be provably non-disruptive before
anything starts calling it automatically.
**Files**
- `src/client/src/controllers/workspaceController.ts`
- new `src/client/src/controllers/workspaceController.test.ts`
**Work**
1. Add a method — suggested name `refreshSelectedProjectTopology()` — that re-lists the
selected project's workspaces and applies them via the existing
`applyProjectWorkspaces` path.
2. **Selection invariants it must hold:**
- If the currently selected workspace is still present, do **not** call
`selectWorkspace`, do **not** clear the active session, do **not** reset
workspace-scoped state. Only `workspaces` / `workspacesByProjectId` change.
**Read this before writing the method — the wrong version looks correct.** The
tempting shape, mirroring `selectProject()` six lines above it, is: refresh the list,
then "re-resolve the selection to be safe" via
`selectPreferredWorkspace(...)` + `await this.selectWorkspace(...)`. That is the bug.
`selectWorkspace` has **no already-selected guard**, so even when it re-picks the very
same workspace it unconditionally runs:
- `sessions.clearActiveSession()``socket.close()` (closes the session WebSocket
mid-stream), `clearPendingUpdates()`, `streamWatermark = undefined` (buffered deltas
dropped), and `setState({ selectedSession: undefined, messages: [] })` (chat empties);
- `setState({ ...resetWorkspaceScopedState() })` → clears `sessions`, `fileTree`,
`expandedDirs`, `selectedFilePath`, `selectedFileContent`, `gitStatus`,
`selectedDiffPath`, `selectedDiff`, `selectedStagedDiff`, `selectedTerminalId`.
Because leg 3 calls this from `refreshAfterBrowserResume`, that would fire on **every**
alt-tab back into PI WEB — not only when a worktree actually changed — blanking the
chat, collapsing the file tree, and closing any open diff every time, and losing stream
deltas that arrive while the socket is down. Applying the list via
`applyProjectWorkspaces` alone is sufficient for the feature; `handleWorkspaceChange`
early-returns when the selected workspace id is unchanged, so a fresh-but-equal list
causes no downstream churn on its own.
- If nothing is selected, just apply the list.
- If the selected workspace **disappeared**, do not silently jump. Leave the
selection as-is and let the existing deletion path own recovery; the user is
currently working there and a surprise switch is worse than a stale label.
If leg 2 finds this cannot be left alone safely, that is the intervention signal.
3. Guard against machine/project changing mid-flight, exactly like `selectProject` does
(compare `selectedMachineId` and `selectedProject?.id` before applying).
4. Swallow-and-report errors the way sibling background refreshes do (`console.warn`,
not `setState({ error })`) — a background topology refresh must never paint an error
banner over a working session.
**Tests** (controller-layer, fake `api.workspaces`)
- a newly appeared worktree lands in `workspaces` and `workspacesByProjectId`
- the selected workspace is preserved; `sessions.clearActiveSession` is **not** called
- a stale response for a project the user has since left is discarded
- a rejected request does not set `state.error`
**Checks:** `npm test -- --run src/client/src/controllers/workspaceController.test.ts`.
---
## Leg 3 — Wire it to the existing resume path, document, changeset
**Why last:** only after leg 2 proves the refresh is inert.
**Files**
- `src/client/src/components/PiWebApp.ts``refreshAfterBrowserResume` (~432) and
`refreshAppData` (~485). Touch only these two methods.
- possibly `src/client/src/components/PiWebApp.*.test.ts` (a focused new test file is fine)
- `docs/` — one short paragraph where workspaces/worktrees are explained; follow
`.agents/skills/documentation-guide/SKILL.md` and do **not** grow `README.md`
- `.changeset/*.md`
**Work**
1. Add `this.workspaces.refreshSelectedProjectTopology()` to the `Promise.all` in
`refreshAfterBrowserResume` and to `refreshAppData`.
- `BrowserResumeController` already debounces per animation frame and collapses
concurrent requests through `TrailingRefreshCoordinator`, so no extra throttling
is needed. Verified in leg 0.
- This inherits remote-machine support for free: `api.workspaces(projectId, machineId)`
already routes through the machine proxy, and `/projects/:projectId/workspaces` is
already in `FEDERATED_HTTP_ROUTES`.
2. Optionally also refresh on realtime-socket reconnect (`connectRealtime`'s
`onReconnect`), which is the same class of natural event. Only if it costs nothing.
Add the call directly; this relay owns the seam and is not coordinating with any
other branch.
3. Document the behavior honestly: detection happens when the tab regains focus /
becomes visible, not instantly.
4. Add the changeset (`npm run changeset`, or write the fragment directly).
**Checks:** the new/affected client tests, then **`npm run verify`** — this is the final
leg and the change is cross-cutting.
---
## Cost ledger (accepted in leg 0)
| Cost | Amount |
|---|---|
| New processes | 0 |
| New watchers (inotify/fs.watch) | 0 |
| New timers | 0 |
| New endpoints / push channels | 0 |
| Extra request per browser resume, per selected project | 1 (~2ms of `git worktree list` server-side) |
| Production lines changed | ~60 |
| New test files | 3 |
## Known risks
- **UI churn on refresh.** Mitigated by leg 2's invariants and its tests. This is the
one that can kill the feature; it is an explicit intervention trigger.
- **Latency expectation.** Detection is resume-scoped. A user staring at an already-focused
tab while a worktree appears in another window sees nothing until they refocus. This is
an accepted, documented limit — not a bug to fix with a timer.
- **Overlap with the sibling `worktree-create-ui` effort.** Settled by the human: assume
that session does nothing. This relay owns the refresh seam; build it here without
designing for reuse, and do not read that worktree.
@@ -1,98 +0,0 @@
# Status — relay "worktree-autodetect"
## 🏁 FINISHED — the relay reached its finish line
All three legs are complete and committed on `feat/worktree-autodetect`. `npm run verify` is
green. No further leg was spawned; leg 3 was the last one by design.
Remaining human action: review the branch and merge it. Nothing is blocked.
## Post-relay review fix
A human review question after leg 3 ("do we have pragmatic reasonable and stable code?")
found one real gap, fixed in `79577e4`: `applyProjectWorkspaces` replaced the list but left
`selectedWorkspace` pointing at the old object, so a branch switched inside a worktree
outside PI WEB showed the new name in the list and the old one in the collapsed Workspaces
header and mobile context bar. Now re-pointed by id, and skipped entirely when metadata is
unchanged so a normal resume does not churn identity. Two tests added (9 total in
`workspaceController.test.ts`), each mutation-checked in both directions. `npm run verify`
green: 1842 passed, 2 skipped (1843 after the two follow-up fixes below).
The other two review nits were then fixed as well, after the human asked:
- `8f060d1` removed the unused `GitWorktreeInfo.locked` flag. The parser test still feeds a
real `locked` porcelain line and asserts it is ignored, so the keep-locked-worktrees policy
stays covered without a speculative field.
- `f8c3fdb` fixed what turned out to be a **correctness bug, not the cosmetic nit I first
rated it**. The resume path and the plugin-facing app refresh call
`refreshSelectedProjectTopology` independently, and the stale guards check machine and
project but *not ordering* — so a slower earlier response landing last overwrote a newer
list and a just-created worktree disappeared again. Reproduced with a scratch test before
fixing. Now routed through `TrailingRefreshCoordinator` keyed by machine+project.
Note for anyone extending this: `TrailingRefreshCoordinator` does **not** collapse two
overlapping calls into one HTTP request. It runs the second as one trailing pass after the
first completes, so the newest response is always applied last. An assertion of
`toHaveBeenCalledOnce()` will hang against it.
## Current position
Every charter finish-line condition is satisfied:
1.**Prunable worktrees hidden**`266f941`. `discoverGitWorktrees` parses `prunable`;
`WorkspaceService` filters those worktrees out, while always keeping the project's own
worktree so a project is never empty.
2.**Non-disruptive client refresh**`d0f8f9f`.
`WorkspaceController.refreshSelectedProjectTopology()` applies results through
`applyProjectWorkspaces` only, never `selectWorkspace`, so selection, session, file tree,
git status, and terminal selection survive. A vanished selected workspace is left alone
for the existing deletion path to handle.
3.**Wired to the existing resume path**`84545fb`. Called from
`PiWebApp.refreshAfterBrowserResume` and `refreshAppData`. No new timer, watcher,
process, endpoint, or push channel. Remote machines work through the existing
`machinePrefix` + `FEDERATED_HTTP_ROUTES` plumbing.
4.**Tests** — prunable parsing/filtering (`gitWorktreeDiscovery.test.ts`,
`workspaceService.test.ts`), refresh-preserves-selection and
refresh-when-selected-workspace-disappeared (`workspaceController.test.ts`, 7 tests),
resume + app-data wiring (`PiWebApp.workspaceTopology.test.ts`, 3 tests).
5.**`npm run verify` green** (228 files / 1843 passed, 2 skipped) and
`.changeset/worktree-autodetect.md` exists.
Nothing in this work touched `src/server/sessiond.ts`, session runtime ownership, or the
daemon protocol. **No manual session daemon restart is required.**
## Leg tracking
- **Last completed leg:** 3 (final — wiring, docs, changeset, full verify)
- **Next leg to run:** none. Relay complete; do not spawn another runner.
## Shipped behavior, as a user sees it
A worktree created or deleted outside PI WEB shows up in (or disappears from) the workspace
list the next time the browser tab regains focus or becomes visible. Detection is
resume-scoped, not instant, by explicit human decision. A worktree whose checkout directory
was `rm -rf`ed no longer appears as a selectable workspace. Documented in `docs/faq.html`
under `#worktree-list-out-of-date`.
## Relevant context if anyone picks this branch up
- Commits: `266f941` (server filter), `d0f8f9f` (client refresh method),
`84545fb` (wiring + docs + changeset), `79577e4` (selected-workspace freshness fix),
`8f060d1` (drop unused `locked`), `f8c3fdb` (serialize overlapping refreshes),
plus the `docs(relay)` packet commits.
- Second invariant, added by `79577e4`: re-pointing `selectedWorkspace` is safe **only**
while it is keyed by `id` and skipped on unchanged metadata. Keying it by anything that can
differ between two lists would change the selection on a background refresh; dropping the
unchanged-metadata guard would push a new object into state on every browser focus.
- The invariant to protect on any future edit: **never route a background topology refresh
through `selectWorkspace`.** It calls `clearActiveSession()` and
`resetWorkspaceScopedState()` with no already-selected guard, which would close the session
socket and blank the chat on every browser resume. `workspaceController.test.ts` was
mutation-checked against exactly that regression in leg 2.
- The relay packet lives under `.pi-web/`, which is gitignored; packet commits used
`git add -f`.
## Blockers
None. No intervention signal fired in any leg. The one serious known risk — visible UI churn
on refresh — did not materialize and needed no workaround.