fix: remove the races behind the e2e flakes and the product bugs they exposed - #462
Conversation
The "starting with follow on and a nested file as default" test makes guides/setup.md the newest file and must load the page only once the server treats it as the default document. Its original fixed 800ms sleep lost to a slow watcher refresh (tjakobsson#233: README.md instead of guides/setup.md). Reproduced here with the sleep restored: 7 of 12 runs failed under load. Poll /api/state until `defaultDocumentId` resolves to guides/setup.md, which is the exact condition the SPA's boot selection reads, instead of the indexed mtime proxy. Drop the 15s preview allowance, whose comment described a post-boot watcher wait that no longer exists: the selection is now the boot selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Switching to single layout preserves the Source / Rendered preference" clicked Single while entering side-by-side was still in flight. Entering split fetches the missing Rendered view and then rebuilds the layout toolbar. When that rebuild lands between the Single button's mousedown and mouseup, the two events hit different elements, Chromium fires no click on the button, and the preview stays in split. The test then fails because `#preview > pre.uatu-source-pre` never appears. Wait for the split to render (the rebuilt toolbar marks Side by side as checked, and `#preview.is-split-h` is visible) before clicking Single. The test asserts the same things as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tput The touch terminal tests raced the PTY in two ways: - Several typed before the pane's attach was ready. The client forwards keystrokes only once the reconstruction has arrived, so the head of the command was silently dropped (the round-trip test's shell received `((6*7))"` and sat at a `dquote>` prompt). Every typing test now waits on `data-terminal-ready` first. - Delayed output was produced with `sleep 1`, so the transcript snapshot and the badge's tab switch raced the clock. That output is now gated on a file the test creates once it has taken the snapshot or left the tab (tjakobsson#253). Other wall-clock waits in the file become explicit boundaries: the paste test waits for the pasted second line to echo instead of sleeping 200ms and waits on printf output rather than the echoed command line; the file-event check waits for an added sibling's row instead of 600ms; the mermaid viewer waits one animation frame for its deferred fit instead of 120ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Specs that launch their own Chromium or WebKit run outside the configured page fixture, so a trace of them records only API calls. The WebKit iPad viewport test once stayed on "Connecting" for 10s after goto and left nothing to explain it. launchBrowser() instruments every context the browser opens with a bounded text log: console messages, uncaught page errors, non-asset requests with their response status or failure, and page lifecycle (visibility, pagehide/pageshow, online/offline). Query strings are reduced to their keys so terminal tokens stay out of CI artifacts. attachPageDiagnosticsOnFailure(test) attaches the log only when a test does not pass. recordContextDiagnostics() does the same for contexts a spec already holds. Measured under the same load, the WebKit iPad suite and the coordinated following suite pass and take the same time with and without the log. Tracing is not enabled: it slowed the performance-budget suites past the CI limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wer choice setSelectedId saved documentPath on every call, and every live document frame calls it to re-confirm the selection it already holds. So an open client that did nothing re-saved its own document on its first stream frame and on every watcher change. A later root arrival then resumed whichever client flushed last, not the document the user last picked, which breaks "a later root arrival restores the most recently persisted document". The same code is in v0.7.0. The document is now saved only when the selection moves, or when the user activates a document, including the one already shown. personal-state.e2e.ts reproduced this at the "later browser" assertion (1 in 30 runs): the second browser's first frame landed after the first browser navigated. The test now waits for the second client to be connected before navigating, confirms README.md is visible and unselected before clicking it, and edits the second client's document so a watcher frame reaches both clients. Without the fix that edit fails the test in 3 of 4 runs. A unit test covers repeated frames, a real move, and same-document activation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ttach A page reads its baseline conversation list, then subscribes to the inventory topic. The child's route opens every subscription with a reconcile tick that covers the gap between the two, but the hub broker only forwarded that tick to an upstream's first subscriber. A page that joined a shared or lingering upstream (a second tab, a reload inside the linger window) got `ready` alone, so a conversation created between its list and its attach stayed out of the chooser until an unrelated change. A first attach (no cursor) to an upstream that has already delivered its opening tick now gets one of its own. A fresh upstream's first subscriber still receives only the child's tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… seeding it After the switch to two agents the test seeded the claude conversation as soon as the reloaded page's panel opened; nothing established that the replacement upstream had reached the newly enabled agent. Poll the claude fixture's inventory subscribers first, as bootDualAgentChat does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… visibility read openChatPanel read #chat-expand's visibility once, straight after boot. Wait for html[data-chat-panel] to be open or collapsed, then branch on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng them The attachment drain tests stalled the upload route for 400-700 ms and relied on that window for Enter, drops, typing, and conversation switches to land while it was in flight; the failing-prompt tests leaned on the fixture's 500 ms rejection delay the same way. A held route released by the test replaces each timer: the test waits for the request to arrive, proves the submit took the draft, performs its interleaving steps, and only then lets the request through. The refusal-after-switch test now arms its response wait before releasing the upload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tate historyCommand picked its submit path from a one-shot read of the send button's label and clicked straight after fill. Callers now say whether a turn is running; the helper asserts the matching label, waits for Send to be enabled before tapping or clicking, and bounds its response wait. The fixture upload ran right after page load and raced the page's fire-and-forget promotion of the URL credential into the workspace cookie (a 401); it now presents the credential itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The turn timer is polled up to a second, then read once after navigation so a restarted timer cannot count back up and pass. - "Choosing an option does not reply" is settled by the explicit Answer's response: requests leave in order, so exactly one reply by then means the choice sent none. - The lazy-backend check waits for the credential promotion that gates chat init, plus a frame, instead of 250 ms. - A stale pagination failure's absence is asserted after that request settles, not after 350 ms. - Rail geometry is measured once two consecutive samples agree; the details visibility checks already auto-wait. - Clipboard and receipt-line reads poll for their exact value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completing the replacement turn delivers the held message, which starts running; the send control flips to cancel as that status lands. The old one-shot label read could see "Send message" and then click what had become Cancel. The redo now expects the running composer, and the turn order is read once the delivered message has rendered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oard The copy button's copied state lasts 1.5 s after the clipboard write resolves. Polling the clipboard first could spend that window, so the state is asserted first and the clipboard, already written by then, is read once after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eading once openShellRow read the group and row `open` state once and toggled on that read, so a render that grouped or opened the row in between left it closed. It now retries the open until the row is seen open and asserts it. The regrouping test anchors on the settled WebKit wheel scroll and waits for the new row before judging the window; the layout test presses through the separator locator and waits on aria-valuenow; the A/B geometry test polls the window bounds. The two "nothing happened" sleeps now await a positive event first: the hidden-output test waits until the page has handled the live envelope carrying the final update, and the disconnection test waits for an item published after the disconnect to arrive, before asserting the absence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests tagged @Perf form a `perf` Playwright project limited to two workers; every other test is in `e2e` (grep and grepInvert on the same pattern, so each test is in exactly one project and `playwright test` still runs all 848). Tagged: the chat-follow-stability frame-budget suites, the long-output shell test, and hub-live-stream. Where a budget test already counts work, the counters are now the pass criterion. Moving and resizing the floating window must not reset, re-parse, repaint or rewrite any of the 5,020 lines; navigating away must not touch them, and returning must build one new owner that writes each line once and paints once. The 3 s interaction budget and hub-live-stream's 1 s load budget are kept as evidence, annotated when exceeded, and fail only past a guard at twice the budget. On CI the e2e project retains the trace of a test's first failing attempt (retain-on-first-failure) and reports as blobs for merging; perf keeps on-first-retry. `test:e2e:perf` and `test:e2e:no-perf` run one project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The single validate job ran unit tests, build, smoke and then the whole e2e suite, finishing close to its 30-minute limit. Now: - changes classifies the diff once for the jobs below. - unit runs type check, audit, unit tests, license audit, build and the compiled-binary smoke test (installing only Chromium, for the smoke). - e2e is a matrix of three legs: the e2e project in two shards of four workers each, and the perf project with its own two-worker limit. Each leg uploads a blob report; test-results are uploaded on failure. - e2e-report merges the blobs into the HTML report when a leg failed and uploads it, as before. - validate keeps the required check's name and passes only when unit and every e2e leg passed, or all were skipped for a change with no code. Playwright browsers are cached per Playwright version, runner OS and arch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The e2e suite runs with 4 workers and fullyParallel, not serially with one worker, and the listed durations were stale. Describe the perf project, the scripts that run or skip it, and how CI splits the suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g it Every document render called mountLayoutToolbar, which removed the whole Single / Side by side / Stacked toolbar and inserted a new one. A render that landed between a click's mousedown and mouseup (entering a split completing its fetch, or a live reload of the open file) left the two events on different buttons, so the browser fired no click and the layout choice was silently lost. The toolbar is now created once and later renders only update its active segment (aria-checked and is-active) in place. Hiding it for documents without a split still removes it. The toolbar logic moves to preview/layout-toolbar.ts, free of appState and module-level DOM lookups, so the in-place contract is unit tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Enter side-by-side with the Source fetch held, press on Single, release the fetch so the split render lands, then release the mouse. The click must still switch back to single. Against the rebuilt toolbar it fails every time (0/3); with the in-place toolbar it passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every renderDocument error became 404 "document not found", so a read that failed for a reason unrelated to the file (EMFILE under load, EACCES, a renderer that throws) told the client the file was gone. documentErrorStatus now keeps 404 for an honest not-found (an id the index doesn't hold, or ENOENT/ENOTDIR when the file vanished after it was indexed), 415 for binary, and answers anything else with 500 "document render failed", which the client can treat as retryable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any failed /api/document read showed "File unavailable" and nothing ever asked again: the live document topic only re-fetches when the file's presence or mtime changes, which a server hiccup does not do. A request that got no answer at all was an unhandled rejection. A 5xx or a failed request now shows "This file couldn't be loaded. Retrying..." and retries with backoff (250 ms, 1 s, 3 s, 10 s) while the same load is still current. A newer load, a selection change, or a live reload supersedes the retry. 404 and 415 keep their existing handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fail the first diagram.md read with a 500, hold the retry until the interim "couldn't be loaded" notice is visible, then release it. The preview must render the document on its own. Against the previous client the preview stays on "File unavailable" (0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"⌘G steps matches without focus in the query box" (tjakobsson#233 item 2) opened find before the clicked document had rendered and pressed ⌘G straight after clicking the preview. It now waits for the document's title, then, after the click, for focus to have left the query box, the counter to still read "1 of 4", and the painted current match to sit inside the mounted preview, before stepping. The assertions are unchanged. The root cause of the reported "1 of 4" after ⌘G was not reproduced: 50 runs of this test and "finds matches in the rendered view" (workers=2, under heavy machine load, with in-page instrumentation) had no assertion failures, only hook timeouts from the load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the hub switcher rendered the menu and then refreshed the hub list in the background; that refresh, and every activity report while the menu was open, rebuilt the menu with replaceChildren(). A click, hover or keyboard focus that landed on an entry in that window hit a node that was being discarded, and focus on a workspace row was lost outright because only aria-labelled controls were restored. The menu is now built detached and reconciled position by position: an entry whose markup and captured data are unchanged stays the node already on screen, and focus on a replaced entry moves to the entry that stands for the same workspace, control or link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The e2e server passed its environment straight to the terminal server, so every test shell was the developer's own $SHELL with their rc files: slow, multi-line prompts that raced typed input. From macOS Terminal it also inherited TERM_PROGRAM=Apple_Terminal and one shared TERM_SESSION_ID, so every test shell restored and deleted the same ~/.zsh_sessions file. The harness now hands createTerminalServer an explicit shell (zsh where installed, for its bracketed-paste support; bash otherwise) and an environment whose HOME and ZDOTDIR point at a private directory holding only a fixed prompt, with the terminal-emulator session variables removed. The product's shell selection is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hub e2e server is worker-scoped and nothing reset it between tests, so with fullyParallel ordering a test could inherit a stopped session (with a child port the readiness line no longer described), a child's staged chat, live PTYs or armed terminal delays, the broker's finished/viewed marks, and the personal state. hub-fixtures now sends `reset <serial>` on the hub server's stdin before each test and waits for its acknowledgement. The server restarts every workspace it booted with (a stop the hub observes forgets the marks, and a fresh child has no conversations and no shells) and drops the personal state of every registered workspace. Children keep their port across restarts so the published childOrigin stays true. terminal-hub-navigation no longer needs its own shell cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l round trip Each terminal spec carried its own waitForPrompt/openTerminal, and they only checked that some xterm row was non-empty. That is true at once when a restored pane repaints its scrollback, while the client still drops every keystroke until the reattached socket is live (term.onData returns early without one), so a test could type into nothing; two helpers papered over it with a 400 ms sleep. The take-back in terminal-session-manager typed as soon as .xterm was visible for the same reason. tests/e2e/terminal-helpers.ts now holds one readiness wait (the pane host's data-terminal-ready), an echo-sentinel round trip whose output differs from its echoed command line, one openTerminal, focused typing, and the file-gate helper mobile.e2e.ts introduced. Every terminal spec (and chat-shell-output and mobile) uses them; the sleeps are gone, as are the 5 s timeout overrides that sat below the 10 s default without a reason. Two assertions that the echoed command line alone could satisfy now print an assembled marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not sleeps
The server-side hold on GET /api/terminal/sessions was a 1500 ms timer that
had to outlast the test's own polling and pagehide dispatch. It is now a
gate the test releases (`{ release: true }`), still reporting delivery
through `pending`; a reset releases a forgotten hold. A GET on the
terminal-close-delay hook now reports how many held closes the server has
yet to process, so a test can wait for a departing holder's release.
The "nothing happened" sleeps in terminal.e2e.ts and terminal-switcher now
wait for the event after which the unwanted outcome would already have
happened, then assert its absence: the held read's delivery and the pane
reading suspended (the pane chooses attach or release as it is added), the
accepted token moving the pane to suspended, rendered frames after the
restored pane reads ready, and the page receiving the stale inventory
response.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the switcher renders the menu and refreshes the hub list in the
background, re-rendering on the answer. Tests clicked, focused or tapped an
entry straight after opening (terminal-hub-navigation, hub-switcher,
hub-stopped-session and the worktree suites). A shared openHubMenu now waits
for that refresh's answer and the visible menu first.
In terminal-hub-navigation, keyboard activation uses locator.press("Enter")
instead of focus() plus a page-level key press, the new-tab gesture's
waitForEvent("page") fails after 5 s instead of at the test timeout, and the
new tab's URL is matched as a prefix (the SPA rewrites it to the opened
document, which made the exact match fail most runs). Restored panes wait for
their shell through the shared readiness helper. The 5 s wait after closing a
recovering pane now waits for the old holder's release and for the end of the
recovery budget the reload started, and hub-switcher asserts that no
viewed acknowledgement was sent once the page has shown the finished row,
instead of sleeping 500 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…utor docs CLAUDE.md still said 848 tests in two shards; CI now splits the e2e project into two legs by file list and the suite has about 870 tests. CONTRIBUTING.md listed serial `bun test` as the core check; CI's required unit job now runs `bun run test:ci`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readPublicKey took the killed ssh-keygen's exit status at face value. The other two helpers in this file return 124 when their timer fired. On a loaded CI runner (the parallel unit gate, run 9 of PR tjakobsson#462) the timed-out child's status came back as a clean exit with no output, so the import failed as "did not yield a public key" instead of the timeout's "could not be unlocked". A timed-out read is now a failure whatever the status reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aa0be09 gave shared/version.ts static `node:fs` and `node:path` imports. The module is also in the page's bundle, and `bun build --compile` keeps such imports as real imports in the page's script: the browser cannot fetch `node:fs`, the whole script failed to load, and the compiled binary's page never left "Connecting" (CI run 10's smoke test; reproduced locally 3/3, and bisected to aa0be09). The e2e server's bundler stubs the imports instead, which is why all 870 e2e tests still passed. version.ts now looks the two modules up at run time through process.getBuiltinModule, only where the runtime provides them; in the browser the file reads fall back to the Git lookup, as before. A new discipline test walks the page's static import graph from src/app.ts and fails on any reachable module that imports a Node built-in; it fails against aa0be09's version.ts. Smoke passes 2/2 after the fix. Unreleased: the regression never shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@claude please review this PR. Focus on correctness of the product fixes (selection persistence, boot freshness, live-broker reconcile tick, hub-nav in-place refresh, layout toolbar, render-dispatch status codes + load retry, tab-bar holdFocus, SSH-agent guardian recovery, serve shutdown ordering, state-dir lease retry, failed-shutdown lease hold) and on whether the CI restructure keeps every test in exactly one leg. Flag anything that changes behavior beyond what the PR description claims. |
|
Claude finished @tjakobsson's task in 1m 55s —— View job Review complete ✅
I read each fix against Product fixes — all check out
Nitpicks (non-blocking)
CI leg restructureStructurally verified correct: leg 2's One caveat: the review sandbox couldn't execute Not reviewed in depthGiven the PR's stated focus, this review concentrated on the eleven listed product fixes plus the CI leg split, per your request. The broader e2e harness changes (shared browser fixture, deterministic PTY, port allocation, per-test hub reset) and the ~30 individual e2e test file edits were not independently re-verified beyond what's needed to confirm they don't imply a product-behavior change. |
tjakobsson
left a comment
There was a problem hiding this comment.
Thanks for this — the product fixes read well and hold up against their callers, and the CI restructure is a real improvement. I ran a separate review pass over the full diff (not only the eleven fixes) and found eight things worth fixing before merge. The first is a harness regression this PR introduces; 2–6 are gaps in the new mechanisms; 7–8 are minor.
Inline comments below. Please push fixes to this branch and I'll re-run the suite.
| const port = this.nextPort; | ||
| this.nextPort += 1; | ||
| const child = spawn("bun", ["run", HARNESS_PATH], { | ||
| let port = this.ports.get(workspace.id); |
There was a problem hiding this comment.
Port pinning + non-awaited SIGKILL can restart a child on a port its predecessor still holds.
terminate() (below) resolves right after child.kill("SIGKILL") without awaiting the exit event. That was harmless before because every start took a fresh port; now this.ports.get(workspace.id) hands the replacement the same port. Sequence: resetForTest → stop → terminate(); the child's SIGTERM shutdown awaits singleAgentRouter.dispose() and watchSession.stop() (chokidar close is known to hang, see cli.ts) → 2 s later SIGKILL fires and terminate() resolves while the process is still dying → start spawns on the same port → EADDRINUSE → the child exits before announcing → reset reports error → every later test on that worker fails in hubReset.
Fix: make terminate() await exit after SIGKILL too (as hub-fixtures stopChild does), or waitForPortsFree before reuse.
There was a problem hiding this comment.
Fixed in 01082fc. terminate() now resolves only once the child has actually exited: it waits up to 2 s after SIGTERM, then sends SIGKILL and waits for the exit event (bounded at 3 s) instead of resolving right after the kill. As a second guard, start() calls waitForPortsFree([port]) before reusing a workspace's pinned port, so a dying predecessor can't cause EADDRINUSE.
| return Response.json({ error: "document not found" }, { status }); | ||
| } | ||
| return Response.json({ error: "document not found" }, { status: 404 }); | ||
| return Response.json({ error: "document render failed" }, { status }); |
There was a problem hiding this comment.
The new 500 branch swallows the underlying error.
A renderer throw or an fs read failure (EMFILE, EACCES) becomes { error: "document render failed" } with nothing on the server side. The client retries at 250 ms / 1 s / 3 s / 10 s and then shows "Select it again to retry", and the hub/session log has no trace of which file or why. The old 404 path hid the same errors, but this PR defines 500 as "the server failing", which is exactly the case that needs a console.error with the error and the document id.
There was a problem hiding this comment.
Fixed in a3439b3. Only the 500 path in /api/document logs: console.error("uatu: /api/document render failed for <documentId>:", error). 404 and 415 stay silent, and the response body is still the generic document render failed. routes.test.ts asserts the 500 case logs once with the document path and the EACCES error, and the 404 case logs nothing.
| cancel(); | ||
| if (key !== currentKey) { | ||
| currentKey = key; | ||
| attempts = 0; |
There was a problem hiding this comment.
"Select it again to retry" doesn't re-arm the schedule.
Attempts are keyed by selectionGeneration + documentId. Re-selecting the same tree row goes through setSelectedId(sameId, "navigation"), which leaves selectionGeneration unchanged, so failed(sameKey) finds attempts exhausted and returns false → the same dead-end message with no retry scheduled. The user has to pick a different file first.
Either call settle() on a navigation-origin activation, or fold a user-activation counter into the key.
There was a problem hiding this comment.
Fixed in d66b1bb, with a user-activation counter in the key (so selection doesn't import preview). setSelectedId(…, "navigation") now bumps an activation count even when the id is unchanged; a watcher reconcile doesn't. mount.ts keys the retry on selection generation + activation + document id. The new test in load-retry.test.ts uses the real selection module: it exhausts the schedule, checks a reconcile of the same id leaves it exhausted, then checks a navigation of the same id starts a fresh schedule at the first delay.
| // while it is open, including the refresh its own opening starts; replacing | ||
| // every node each time would drop a click, hover, or keyboard focus that | ||
| // landed on an entry meanwhile. | ||
| function reconcileMenuEntries(container: Element, next: Element[], signatures: WeakMap<Element, string>): void { |
There was a problem hiding this comment.
Reconciling by position reintroduces the lost click on any list-shape change.
reconcileMenuEntries compares next[i] to container.children[i]. If the background /api/hub/state refresh inserts, removes or regroups one workspace above the row the user is pressing, every following entry's positional signature differs and existing.replaceWith(node) runs between mousedown and mouseup — the click is dropped. menuFocusKey recovers focus, but not the click.
menuFocusKey already derives a stable identity (data-workspace-id / aria-label / href); keying the reconcile on that (match by identity, then move/insert) keeps unchanged rows regardless of index.
There was a problem hiding this comment.
Fixed in 821d0de. reconcileMenuEntries now matches entries by stable identity (group headers by data-repository, then the same workspace id / aria-label / href menuFocusKey uses; dividers by signature; duplicate identities pair in order). An entry is reused when identity and signature both match, so an unchanged row keeps its node when rows are inserted, removed or regrouped above it; kept nodes already in order don't move (longest-increasing-subsequence pass), the rest are placed with insertBefore, and the children end in exactly the new order. New tests cover insert above, remove above, regroup, and a changed row with focus following it; the first three fail on the positional version. On the worktreeApi closure the other review raised: the fork handler reads worktreeApi at click time, and unsetting it changes the header's markup and so its signature; I added a comment rather than code.
| return test.extend<EngineBrowserFixtures, EngineBrowserWorkerFixtures>({ | ||
| _webkitBrowser: [async ({ playwright }, use) => { | ||
| let launched: Promise<Browser> | undefined; | ||
| await use(() => launched ??= playwright.webkit.launch()); |
There was a problem hiding this comment.
A rejected WebKit launch is cached for the worker's lifetime.
launched ??= playwright.webkit.launch() stores the promise even when it rejects (a transient spawn failure or browser-process timeout under load). Every later launchBrowser("webkit") on this worker awaits the same rejection, and Playwright's per-test retries can't help because the fixture never re-launches.
launched ??= playwright.webkit.launch().catch(error => { launched = undefined; throw error; });There was a problem hiding this comment.
Fixed in e2fba83, as suggested: a rejected WebKit launch clears the cached promise (.catch(error => { launched = undefined; throw error; })), so the next test on that worker launches again. Teardown ignores a rejected or cleared launch and only closes a browser that actually started.
| state = "lease-retained"; | ||
| options.reportRetained?.(); | ||
| }); | ||
| }, retain); |
There was a problem hiding this comment.
retain on the rejection path holds the process with no proof the lease is held.
shutdownHub() never rejects (it catches internally and returns stateLeaseHeld), so a rejection here means the wrapper threw after the lease may already be released. retain() then sets up the 2^31-1 ms interval and reports a retained lease it doesn't hold, and the hub stays alive until an operator sends another signal (which force-exits 1).
Only the resolved stateLeaseHeld === true branch should hold the process; the rejection branch should log and forceExit(1).
There was a problem hiding this comment.
Fixed in 626f554. If shutdown() rejects, createHubSignalShutdown logs uatu hub: shutdown failed: … and calls forceExit(1); it no longer holds the process or reports a retained lease. Only a resolved stateLeaseHeld === true holds. The old test that asserted the hold on rejection is replaced by one asserting the log, exit code 1, and no hold or retained report.
| // with a harmless `status` round trip (whose reply is keyed by the | ||
| // nonce) before sending the irreversible `stop`. | ||
| await this.request(record, "status"); | ||
| await assertSocket(this.socketPath, record.agentSocket); |
There was a problem hiding this comment.
Minor: this assertSocket pair duplicates lines 289–290 with only the status round trip in between. Only this second pair is load-bearing (a missing socket makes request() fail anyway), so the first pair can go — or keep the first as pre-flight and drop this one. Two copies of the same guard drift when one is edited.
There was a problem hiding this comment.
Done in 71ccc09: removed the pair before the status probe and kept the one right before stop, with a comment on why it's there. One consequence worth knowing: that first pair was what produced the specific errors two recovery tests asserted (does not match its ownership record, ENOENT). Those cases now fail at the status probe with the generic SSH guardian request failed, so both tests expect that message and use a short stopTimeoutMs; they still check the ownership record and agent socket are left untouched.
| ? Math.max(0, header.getBoundingClientRect().bottom - rootRect.top) | ||
| : 0; | ||
| const triggerOffset = overlap + 8; | ||
| const triggerOffset = spyTriggerOffset(overlap, Number.parseFloat(getComputedStyle(scrollRoot).scrollPaddingTop)); |
There was a problem hiding this comment.
Minor: getComputedStyle(scrollRoot) now runs on every scroll-driven rAF tick to read scrollPaddingTop, which only changes on layout/ui-mode changes. This is the hottest path in the preview while reading. Reading it once in resolveRoots() (and on layout/mode change) and caching does the same job.
There was a problem hiding this comment.
Done in dc5b013: scroll-padding-top is cached per scroll-spy attachment and cleared on re-attach (every remount, including a split/single layout switch), on resize (the value depends on the ≤900px stacked media query and --device-safe-top), on UI-mode change, and via a MutationObserver on <html> class/style, since the macOS desktop host can change --titlebar-inset without a resize. The spyTriggerOffset tests are unchanged.
A renderer throw or a read failure (EMFILE, EACCES) became a bare
{ error: "document render failed" } with no server-side trace, while the
client quietly retried. Log the document id and the error on the 500 path
only; 404 and 415 stay silent and the response body stays generic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e same file again Re-selecting the row already shown goes through setSelectedId(sameId, "navigation"), which leaves the selection generation unchanged, so the exhausted retry schedule kept its key and "Select it again to retry" was a dead end. Count navigation-origin activations in the selection module and fold that epoch into the retry key; a watcher reconcile does not bump it, so it cannot re-arm the schedule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line scroll-spy updateActiveHeading ran getComputedStyle(scrollRoot) on every scroll frame just to read scroll-padding-top, which only changes with layout. Read it once per scroll-spy attachment and clear it on re-attach (remount, layout switch), resize (the stacked breakpoint and safe-area changes), UI mode change, and the desktop host's titlebar-inset updates on <html>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chmod 0o000 cannot deny root a read, so as root these cases rendered the file and could not prove the 500 path. Skip them there, with the reason in the test name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sition The switcher reconciled its menu position by position, so a background /api/hub/state refresh that inserted, removed or regrouped a workspace above the row being pressed replaced that row between mousedown and mouseup and the click was lost. Entries are now matched by stable identity (workspace id, repository header id, aria-label, href; dividers by signature, duplicates paired in document order). An unchanged entry keeps its node wherever it lands; only entries whose signature changed are replaced, vanished ones are removed and new ones inserted. Kept entries on the longest run already in order stay put, so an insert or a removal moves no kept node. Focus restore is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workspace's harness child keeps its port across restarts, so terminate() returning right after SIGKILL could let the replacement bind while the predecessor still held the port (EADDRINUSE, failed reset, and every later test on the worker failing in hubReset). terminate() now awaits the exit after SIGKILL too (bounded), and start() waits for a reused port to be free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shutdownHub() reports a retained lease by resolving; it never rejects. A rejection means the wrapper threw, possibly after the lease was released, so holding the process would claim a lease nothing proves is held. The signal handler now logs the failure and force-exits 1; only a resolved stateLeaseHeld holds the process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…atus probe Recovery checked both socket identities, sent the keyed `status` probe, and checked them again before `stop`. Only the check right before the irreversible `stop` is load-bearing: a missing or replaced control socket already fails the status probe. The recovery tests now expect that probe failure instead of the pre-flight's message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker's shared WebKit browser cached its launch promise even when it
rejected, so every later launchBrowser("webkit") on the worker awaited the
same failure. A rejected launch now clears the cache, and teardown tolerates
a rejected or cleared launch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@claude please review again. Nine commits landed since your last pass (a3439b3..e2fba83) addressing the review findings: /api/document 500 logging, load-retry re-arm on same-file selection with an activation counter in selection.ts, cached scroll-padding-top in the outline scroll-spy, hub-nav reconcile keyed on entry identity instead of position, hub-server terminate() awaiting exit + waitForPortsFree before port reuse, shutdown rejection now exits instead of retaining the lease, the duplicate assertSocket pair removed before the guardian status probe, WebKit launch rejection cleared from the cache, and the chmod EACCES tests skipped as root. Focus on the delta: does each fix hold, and does any introduce a new regression (especially the identity-keyed reconcile in hub-nav.ts and the activation counter in selection.ts). |
|
Claude finished @tjakobsson's task in 3m 27s —— View job Re-review complete ✅ — delta a3439b3..e2fba83 (9 commits)
Bottom line: all nine fixes hold up, and I found no new regressions — including in the two I looked at hardest, the identity-keyed hub-nav reconcile and the selection activation counter. The two flagged as highest-risk
Selection activation counter ( The other seven, briefly
Verification noteI wasn't able to execute No blocking issues found in this delta. |
tjakobsson
left a comment
There was a problem hiding this comment.
Thanks for the fast turnaround — all eight earlier findings are addressed and the fixes read well; the identity-keyed reconcile and the activation counter are both cleaner than what I sketched. Second pass over the new head found two things I'd like fixed before merge and five smaller ones (marked minor/optional inline).
One of the two is on me: in the guardian recovery I offered "drop the first pair or drop the second", and dropping the first turns out to lose the fast, precise failure. Details inline, with what I'd do instead.
Blocking: credential-ssh-agent.ts (recovery error quality/time), hub-nav.ts (fork-control focus key collides across same-named checkouts).
Minor / optional: mount.ts, render-dispatch.ts, load-retry.ts, live-broker.ts, version.ts.
| // from disk is not yet proven to belong to this guardian, so prove it | ||
| // with a harmless `status` round trip (whose reply is keyed by the | ||
| // nonce) before sending the irreversible `stop`. | ||
| await this.request(record, "status"); |
There was a problem hiding this comment.
Please put the pre-flight assertSocket pair back before this status probe — my earlier comment offered either option and this is the one that loses something. The test diff shows it: the two recovery cases that asserted does not match its ownership record / ENOENT now assert the generic SSH guardian request failed.
In production that means a control socket that was removed or replaced after a crash no longer fails at once with the identity mismatch; request(record, "status") waits the full stopTimeoutMs (2 s), then throws the advice to remove the runtime directory — steering the operator toward deleting state for what was an identity check. Healthy recovery also now pays up to 2× stopTimeoutMs in the worst case (status + stop).
Suggested shape: pre-flight pair → status → stop, and drop the post-status pair instead (the signed status reply already proves the nonce's owner is at that socket; a swap in the microseconds between is what the nonce protects against, not the metadata check). Restore the two precise test assertions with it.
There was a problem hiding this comment.
Done in 1e99f3b, with the shape you suggested: pre-flight assertSocket pair → status → stop, and the post-status pair dropped (the signed status reply is the identity proof; the pre-flight is the fast, precise failure). The two precise assertions are back (does not match its ownership record, ENOENT) and the stopTimeoutMs: 200 overrides are gone: the replaced-socket case now fails in ~180 ms with the default 2 s timeout, most of that being the guardian's own start in the test's setup. The forged-challenge test still passes, so a wrong-nonce recovery still leaves the real guardian alive. Sorry for picking the wrong option; your reasoning here was right.
| if (!element) return null; | ||
| const workspaceId = element.getAttribute("data-workspace-id"); | ||
| if (workspaceId !== null) return `workspace:${workspaceId}`; | ||
| const label = element.getAttribute("aria-label"); |
There was a problem hiding this comment.
Fork-control focus keys collide across same-named checkouts. The fork button's aria-label is Add worktree to ${workspaceMenuLabel(workspace)}, so two registered main checkouts with the same display name (legal — the file's own comment says the menu disambiguates by path) both key to label:Add worktree to docs. If focus is on the second repo's button when a refresh replaces that header, .find(item => menuFocusKey(item) === focusedKey) returns the first repo's button, and Enter forks the wrong repository.
This matters more now that the same key drives entry matching in the reconcile. Give the fork control a data-workspace-id (or a dedicated data-fork-for) and key on that, as rows are.
There was a problem hiding this comment.
Fixed in 1ca81d6. The fork control carries data-fork-for=<main checkout id>, and menuFocusKey keys it as fork:<id> (checked right after data-workspace-id, before any aria-label fallback), so it can't collide with its row's workspace:<id> or with a same-named repo's control. No other menu control keyed on a label: rows use data-workspace-id, headers data-repository, and the dashboard/sign-out links have unique hrefs. New tests: two main checkouts both named "docs", focus on the second repo's fork button, a refresh replaces both headers → focus lands on the second repo's new button (fails on the old code); and a refresh that changes only the first header keeps the second's header and button nodes. One correction to the premise: the reconcile matches only the menu's direct children, and the fork button sits inside its header (keyed by data-repository), so the collision could misplace focus but never mismatched reconcile entries.
| }), () => { | ||
| if (isCurrent()) void loadDocument(documentId); | ||
| }); | ||
| renderUnavailableDocument( |
There was a problem hiding this comment.
Minor (UX, optional but I'd take it): when the failing load is a reload of the document already on screen — a watcher frame for the same selection while the child answers 500 under load, or the hub proxy 503s during a child restart — renderUnavailableDocument tears down the rendered content, clears the outline and hides the facts strip, so the reader loses their place for up to 14 s while the schedule runs. The retry machinery makes "keep the last good render, retry quietly (maybe a small notice)" the natural behavior here; blanking was what the old code did because it had nothing else to do. Only blank when there is no current render for this document.
There was a problem hiding this comment.
Agreed on the behaviour, and I'd rather not fold it into this PR: keeping the last good render while a same-document reload retries changes what "unavailable" means for the outline and the facts strip too, and deserves its own change with its own e2e. Could you file it as an issue? Happy to take it next.
| if (message === "document is binary") return 415; | ||
| const code = (error as { code?: unknown } | null)?.code; | ||
| if (code === "ENOENT" || code === "ENOTDIR") return 404; | ||
| return 500; |
There was a problem hiding this comment.
Minor: everything non-ENOENT is 500 = "transient", so a document that fails permanently — a renderer throwing on its content, EISDIR, EACCES — is retried four times by every open client (five renders and five console.error lines per view, ten with two tabs), the user reads "Retrying…" for 14 s for a file that will never render, and "Select it again" restarts the cycle. EACCES/EISDIR are stable conditions, not the server failing; a 403 (or any final 4xx) for those would keep the retry for the cases it was built for (EMFILE, a mid-restart child) without the churn. Renderer throws are a judgment call — a 500 is honest there — so this is really about the fs codes.
There was a problem hiding this comment.
Agreed that EACCES/EISDIR are stable conditions and shouldn't be retried; I'd like to handle it in its own PR rather than here, since it's a semantics change to the status mapping this PR just introduced and I'd want the client's final-4xx message and the retry schedule reviewed together. Could you file it as an issue? The renderer-throw case I'd keep at 500, as you say.
| cancel(); | ||
| if (key !== currentKey) { | ||
| currentKey = key; | ||
| attempts = 0; |
There was a problem hiding this comment.
Minor: once the four attempts for a key are used up, failed keeps returning false for that key. A later watcher frame for the same selection does perform one fresh fetch (good), but if that one also fails the schedule is not re-armed — the frame is new evidence the file changed, so it would be reasonable to treat it like an activation and start over. Cheap version: settle() when a load is triggered by a live frame rather than a retry.
There was a problem hiding this comment.
Done in b13f83c. Loads now carry a trigger: only the retry timer's own reload is a retry; any other failed load (a live frame's reload of the current selection, a navigation, a view/layout change) resets the attempt count and starts the schedule over at the first delay, so a failed retry can never re-arm itself. The rule lives in load-retry.ts (failed(key, retry, trigger)) and the new test exhausts the schedule, fails a live-frame reload of the same selection, and asserts a fresh schedule that then runs out again without re-arming. Side effect worth knowing: a Source/Rendered toggle on an exhausted document also restarts it, which seems right (it's a user action).
| } | ||
| open.set(key, 0); | ||
| options.log(failureLine(record)); | ||
| setTimer(() => { |
There was a problem hiding this comment.
Minor: each (topic,status) arms one unref'd timer that closes over this sink's options.log, and nothing cancels it. If the sink is replaced via setLiveUpstreamDiagnostics (the e2e harness's quiet sink; unit tests that don't inject setTimer), a summary line still fires through the retired default sink's console.error up to 60 s later — the very harness noise the quiet sink exists to stop. Either return a dispose() that clears open timers and call it on replacement, or have the callback consult the current sink at fire time.
There was a problem hiding this comment.
Done in ab07bfa, option (a): the folding sink now has dispose(), which clears its open summary timers, drops the pending counts (passing them to the new sink would summarise lines it never saw) and silences it for good; setLiveUpstreamDiagnostics retires the previous sink when it installs a different one, and null now builds a fresh default sink instead of reusing a possibly disposed module instance. A unit test folds a repeat, swaps the sink, advances past the window and asserts neither sink logs. Metrics are unchanged.
| start: string = process.cwd(), | ||
| env: Record<string, string | undefined> = process.env, | ||
| ): { branch: string; commitSha: string } | null { | ||
| if (env.GIT_DIR || env.GIT_COMMON_DIR || env.GIT_WORK_TREE) return null; |
There was a problem hiding this comment.
Minor: the walk honours GIT_DIR/GIT_COMMON_DIR/GIT_WORK_TREE but not GIT_CEILING_DIRECTORIES (or GIT_DISCOVERY_ACROSS_FILESYSTEM), which the git rev-parse it replaces did. This PR itself exports GIT_CEILING_DIRECTORIES into the e2e servers' environment, so a source run below a ceiling now reports the ancestor repository's branch@sha where the old code fell back to main@unknown. Stop the walk at any ceiling directory (return null so the caller asks Git, which will refuse the same way).
There was a problem hiding this comment.
Done in 5c75d93. readGitHeadFromFiles now reads GIT_CEILING_DIRECTORIES from its env parameter with Git's rules (split on the platform delimiter; relative entries ignored; absolute entries symlink-resolved unless they follow an empty entry; the starting directory still searched) and returns null before ascending into a ceiling, so the caller asks Git, which refuses the same way. Tests check each case against real git rev-parse (ceiling at the repo's parent, at the repo root, a mixed list, a symlink alias, and that process.env is not consulted). GIT_DISCOVERY_ACROSS_FILESYSTEM is not modelled — it would need a device comparison at each level — and the doc comment says so.
|
@addiberra — to be clear about where this stands: CI is green on Needed for approval
Optional ( Once 1 and 2 are in, I'll re-run the suite and approve. |
… after Recovery again checks both socket identities against the ownership record before the keyed `status` probe, so a control socket removed or replaced after a crash fails at once with the precise mismatch instead of timing out the probe and advising the operator to delete the runtime directory. The post-status pair is dropped: the signed `status` reply already proves the nonce's owner is at that socket, so healthy recovery pays one request timeout at worst, not two. The recovery tests expect the precise `does not match its ownership record` and `ENOENT` failures again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… label The fork control's only identity was its aria-label, "Add worktree to <name>", and display names may repeat across registered repositories. A refresh that replaced the header under a focused fork control could hand focus, and the next Enter, to another repository's control. The control now carries data-fork-for=<main checkout id> and menuFocusKey keys it as fork:<id>, apart from its row's workspace:<id>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The file-based HEAD reader replaced git rev-parse but ignored GIT_CEILING_DIRECTORIES, so a source run below a ceiling (as the e2e servers now are) reported the ancestor repository's branch@sha instead of falling back. The walk now reads the ceiling list from its env parameter with Git's rules (absolute entries only, symlink-resolved unless after an empty entry, unresolvable entries dropped), never ascends into a ceiling, and returns null so the caller asks Git, which refuses the same way. The starting directory is still searched. GIT_DISCOVERY_ACROSS_FILESYSTEM is not modelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The repeat-folding sink armed one summary timer per (topic, status) window and nothing cancelled it, so a sink replaced through setLiveUpstreamDiagnostics could still print a summary through its log up to a window later. The folding sink now exposes dispose(), which clears every open window's timer, drops its pending counts, and silences the sink for good; setLiveUpstreamDiagnostics retires the previous sink when it installs a new one, and null installs a fresh default sink. The pending counts are dropped rather than handed to the replacement, which never saw the first line they would summarize. Metrics are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e's reload fails Every load now carries a trigger: "retry" from the retry schedule's own timer, "request" from everything else (navigation, view changes, a live frame reloading the selection). A request that fails transiently starts the schedule over even when the key's attempts are used up; a failed retry only continues the schedule, so the timer never re-arms itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjakobsson
left a comment
There was a problem hiding this comment.
Approving at b13f83c. Both items I asked for are in exactly the shape requested (pre-flight → status → stop with the precise assertions back; fork control keyed fork:<id>), the three minors you took are done, and CI ran the full suite green on this head (unit, both e2e legs, perf, contracts).
The two you asked me to file: #468 (keep the last good render while a same-document reload retries) and #469 (EACCES/EISDIR as final; also covers a non-JSON 200 body being retried, which I noticed on this pass).
Two non-blocking notes, take them or leave them — I won't hold the PR for either:
- With
trigger === "request"now resetting the schedule, the activation component of the retry key no longer changes any outcome (a retry always shares its scheduling load's key; a new key only arises from a request).selectionActivation/getSelectionActivationinselection.tsand the key helper could go, leaving a two-state schedule. My earlier ask created the redundancy, so no complaint if you'd rather leave it. src/shell/selection-persistence.test.tshas no sibling module; CLAUDE.md's colocation rule would put these cases inselection.test.ts(or name a module they belong to).
Thanks for the thoroughness across four rounds — the PR body's per-run evidence made this much easier to review.
Purpose
Make the e2e suite trustworthy again. In a sample of the last 60 CI runs, 25 had at least one test that only passed on retry (44 retried results in total). A few tests also failed reliably on macOS. This PR:
Addresses #253 and #233; partly addresses #438 (see "Known limitations").
What changed
Product fixes (all present in v0.7.0, so visible release notes)
src/shell/selection.ts): an idle open browser re-saved its own document on every live frame. A later browser then resumed whichever client saved last, not the document the user last picked. The document is now saved only when the selection moves or the user activates a document.src/shell/events.ts,src/shell/recovery.ts): boot applied/api/statewithout recording its freshness, so the first live frame was accepted whatever its age. Through the hub, a page joining an already-open upstream (a second tab, or the upstream a reload leaves lingering for 3 s) receives that upstream's retained last frame, produced before boot's fetch, and re-applied older roots and repositories. Boot now records the snapshot's freshness and older frames are refused. A unit test drives the real module: boot at 2000, a frame at 1000 is refused, a frame at 3000 applies.src/hub/live-broker.ts): a page that joined an existing inventory subscription (a second tab, or a reload within the linger window) never got the opening reconcile tick. So a conversation created between the page's list fetch and its subscription stayed missing from the chooser.src/shell/hub-nav.ts): opening the menu starts a background refresh that rebuilt every entry. A click or Enter landing mid-rebuild was lost, and keyboard focus fell off the row. Entries are now kept in place when unchanged, and focus follows a replaced entry.src/preview/layout-toolbar.ts): every render rebuilt the toolbar, so a click whose mousedown and mouseup straddled a render was dropped. The toolbar is now updated in place.src/server/render-dispatch.ts,src/preview/load-retry.ts):/api/documentanswered every render error with 404, and the client showed "File unavailable" permanently.outline-headings.ts): the scroll-spy trigger line sat above the scroll-padding that navigation lands headings at. So after clicking an outline entry, the highlight moved to the previous heading.search-model.ts): the results list was rebuilt on every stream chunk, including identical ones, which dropped keyboard focus from the selected hit. Unchanged markup is now kept, and a changed render refocuses the same hit.src/shell/tab-bar.ts,src/terminal/panel.ts): the arrow-key handler focused the target tab and then clicked it, and a click on the Terminal tab asks the terminal panel to focus its pane once one attaches. So keyboard tab navigation dropped focus into xterm whenever a pane attached in that window, and the next ArrowRight typed into the shell instead of moving to Files. Arrow/Home/End now activate the tab withholdFocus, which the panel honours on its show, attach and reveal paths; taps, clicks and Enter/Space still focus the terminal. A unit test drives the real tab bar and panel against a linkedom DOM and fails on the old code.initTabBaralso returns a teardown now, so that test does not leave the module measuring a stale element for later suites in a serial run.src/hub/credential-ssh-agent.ts): the guardian obeyed any well-formedstopon its control socket before the client had checked the reply's signature, and recovery sentstopstraight from the record on disk. A recovery with a wrong nonce therefore failed closed as intended but still shut the real guardian down and removed both sockets. Recovery now sends a harmlessstatusfirst; its signed reply proves the record belongs to this guardian, and only then does it sendstop. The test no longer needs to win a race.serveshutdown (src/cli.ts): the session child printed its ready URL, the hub's ready signal, before installing its SIGINT/SIGTERM/SIGHUP handlers. A SIGTERM in that gap took Bun's default action: exit 143 and an orphaned OpenCode child. The handlers are installed before the URL is printed. Reproduced 10/10 with a SIGTERM sent on the URL line; 20/20 clean exits after.src/hub/state-dir.ts): first-time lease acquisition opened SQLite withbusy_timeout=0, and anySQLITE_BUSYwas reported as "Hub state root is already in use". When several hubs started on a fresh state directory, the contenders' brief schema-setup locks (PRAGMA journal_mode,CREATE TABLE) made every one of them fail, so none started. Each attempt now uses a short busy timeout and retries a busy within a 500 ms window; only a lock still held after that counts as a live owner. A live owner is still refused (now after up to about 0.5 s instead of instantly). Reproduced 1 in 50 in the test loop and 4 in 600 with six contenders; 0 in 100 and 0 in 300 after. A deterministic test holds the lease file mid-setup and fails on the old code.src/hub/main.ts): after a failed shutdown the hub reports that it keeps the state-root lease until the next signal, but its server is already stopped, so nothing held the process open: it exited 0 and the OS released the lease a successor could then take.createHubSignalShutdownnow holds the process open while it retains the lease. Reproduced by delaying the test's second signal; two new unit tests.src/hub/live-broker.ts): when every topic on a child fails at once, the hub logged one "live upstream … subscription failed" line per topic. Repeats per topic and status within 60 s are now folded into one summary line; the first line and the failure metric are unchanged.Each fix has colocated unit tests, and most have a deterministic e2e reproduction that fails without the fix.
E2E harness
GIT_CEILING_DIRECTORIESstops that. Git time per refresh fell from 310 ms to 11 ms median at idle, and from 4.3 s to 65 ms under load. It also fixes CI's "stdout maxBuffer length exceeded" and stops tests depending on the developer's working tree.<bun-hmr>overlay blocked clicks after a child stopped, and editingsrc/reloaded open test pages. Pages are now served as a production bundle, left unminified because three tests patch served source text. Warm page load went from about 0.9 s to about 0.77 s.launchBrowser(engine)fixture frompage-diagnostics.ts. Chromium is Playwright's workerbrowser; WebKit is one worker-scoped browser launched on first use. Each test gets fresh contexts, closed even after a timeout, and the failure diagnostics still attach. A self-launched WebKit's first page took 11–25 s under 4-worker load. This removed the launch cost but not the flows' own length (see the slow flows under CI below).HOME/ZDOTDIRand a fixed prompt, instead of the developer's$SHELLand rc files.TERM_PROGRAMandTERM_SESSION_IDare stripped.parallelIndexslot, notworkerIndex, which grew with every worker restart until hub ports reached 4700 (a realuatu hub's default port). Hubs now use 21000+ and harness servers 20000+. Test servers exit when their worker's stdin closes, so a replacement worker can't collide with an orphan. Linked-worktree children are limited to their hub's port block.openTreeFilewaits for the tree to be idle, then clicks and asserts the preview. Folder toggles in manual-selection hit-test the click point after the tree settles, because a Playwright-driven scroll re-renders the virtualized rows and the resolved button can become a different row.terminal-helpers.tsprovides readiness viadata-terminal-readyplus a sentinel round trip, and file gates.openHubMenuwaits for the menu's refresh./__e2e/terminal-close-delayand/__e2e/terminal-sessions-delayare holds the test releases, not timers.chat-touchdrill-down intermittent (retried 5 times in 6 CI runs): a test race, not a product bug. The test tapped the answer option while the drill-down timeline was still scrolling to its end; Playwright's retry of an unstable click scrolls the target into view, which the app correctly read as the reader scrolling away, so it stopped following. The test now waits until the timeline is at its end and still. 6 failures in 57 before, 150/150 after at 8 workers.notification-presence.e2e.ts(added onmainduring this work): its grace-period sleeps now wait on the hub's notification journal for the actual decision (held, discarded or sent). This also fixed a latent bug where pushes from one test matched another test's conversation ("conversation-1" matched "conversation-10"); each test now enrols its own device. 40/40 after; the file runs in about 10 s.fs.utimes+ 800 ms),sleep 1in typed shell input, route stalls, and "nothing happened" waits, which now await the positive event before asserting absence.standardBeforeEachnow ends on real conditions, and the preview-settle check fails loudly instead of falling through.CI, the perf project, and parallel unit tests
validateis split into:changesunit, which now runsbun run test:ci(bun test --parallel=4with committed per-file timings so the slowest files start first; about 70 s locally against about 155 s serially). It ran as a non-required shadow job for runs 5–8 while the real-process tests that failed only under parallel contention were fixed (below), and became the gate after 10 clean and 5 CPU-loaded local runs all passede2ematrix of 3 legs:--project=e2eleg 1 and leg 2 with 4 workers each, and--project=perfwith 2 workerse2e-report, which merges the blob reports into the HTML report on failurevalidate, which always runs and passes only if every job succeeded, or all were skipped for docs-only changes. The required-check name is unchanged.--shard:--shardsplits by test count, and the first CI run showed shard 1 at 18.0 min against 13.6.UATU_E2E_LEG=1runs an explicit 34-file list;UATU_E2E_LEG=2runs everything else, so a new file lands in leg 2. Unset, the whole suite runs.--list: leg 1 = 397, leg 2 = 452,e2e= 849,perf= 21, total 870; the two legs' union equals--project=e2eand no test is in both. A move ofdocument-treebetween the legs was tried and reverted: with it in leg 1 the legs ran 18.0 / 15.3 min, without it 11.0 / 15.9, and the stage waits for the slower leg, so the lower maximum won; the WebKit flows' time depends on what runs beside them, so worker-time sums under-predict this.perfproject: 21@perf-tagged timing-budget tests (chat-follow-stability, the long-output shell tests, and hub-live-stream).test.slow(). Blob-report timings from the second run show they are long, not racy: the Chromium scrollback flows pass at a 22.7 s median (max 23.9 s) against the 30 s default, the WebKit ones cross 30 s under four-worker contention and pass at 12.8 s on the quieter retry, the geometry test passes at 29.7 s on WebKit and the history test at 23 s.worktree-ui(90 s ×7),worktree-integrationandchat-follow-stabilityalready extend their budgets the same way. Nothing is skipped or loosened; a hang still fails.e2eproject usesretain-on-first-failure;perfkeepson-first-retry.src/shared/browser-bundle-discipline.test.ts): walks the page's static imports fromsrc/app.tsand fails if any reachable module imports a Node built-in, since only the compiled-binary smoke test would otherwise notice.src/hub/main.integration.test.tstakes OS-assigned ports instead of 4795–4799. Tests with real-time bounds or races that failed when files share the cores now wait for the event they stood in for: a killed descendant being reaped, a fakemkfiforeleased by the test instead ofsleep 1, the terminal server's close event, live chat events being applied, the recovered SSH-agent guardian's processes exiting; plus fake timers, wider admission windows, and explicit budgets for files that spawn real processes or real Git. A cron test tied with the every-minute wakeup at 20:02 daily. No test is skipped or excluded; the count is unchanged.src/shared/version.ts): every module importing it rangittwice throughBun.spawnSyncat load. Under parallel tests that hit a known Bun bug wherespawnSyncloses a child's exit and spins forever (Bug: spawnSync never returns: child exit lost (child stays zombie), wait loop busy-spins at 100% CPU re-registering a finished pipe reader (macOS ARM64) oven-sh/bun#34069; fix spawnSync: release polls and keep-alives on the loop they counted on oven-sh/bun#40078 is unreleased), hanging a test worker. It now reads the branch and commit from Git's own files and asksgitonly for cases they don't cover. Compiled binaries inject their build identity, so released builds were never affected; this is not a release note.test:ci,test:e2e:perfandtest:e2e:no-perf. CLAUDE.md's stale test facts are corrected.Verification
bc620a8, first run of the new layout): green; 12 retried, all 30 s timeouts: 9 WebKitchat-shell-scrollbacktests,chat-responsivenesslong history,manual-selectiontouch multi-root,terminal-hub-navigationreleased-late. Shards 18.0 / 13.6 min; the browser cache missed only because it did not exist yet.ac554fd, shared browser, leg split, parallel unit): green; 11 retried (the 8 WebKit scrollback flows, 2 long-history, the touch-tab focus test). Legs 14.0 / 16.5 min; parallel unit step 51 s (serial was 166 s); browser cache hit.f88eb0a, slow budgets, tab-bar fix): e2e green with 1 retried (chat-touch.e2e.ts:813, a one-off); parallel unit failed one real-process test (a 5 s child-process budget, fixed).7589535): e2e green with 0 retried across 440 + 409 + 21; parallel unit failed two different real-process tests, which led to the guardian andserveshutdown fixes above.d30b1a4, serial gate + parallel shadow): e2e green with 0 retried (397 + 452 + 21), legs 11.0 / 15.9 min,unit-parallelgreen in 63 s; the serialunitjob failed 9chat/viewporttests withNaNpx, a module-state leak from the new panel test under CI's serial file order (fixed; see the tab-bar entry).d188a55tab-bar teardown;27d630bguardian, exit-wait andserveshutdown fixes): e2e green, 2 and 1 retried (allchat-touch.e2e.ts:813, below);unit-parallelfailed on run 6 (the exit-wait race, fixed in run 7) and passed on run 7. The serialunitjob failed on both with a second serial-only cause: an unhandledcancelAnimationFrame is not a functionfrom preact between tests, which abortedsearch-model.test.ts(0 of 23 ran).events.test.ts(new here) stubsrequestAnimationFrameon a linkedom window, which forwards toglobalThis, then importsevents.ts, which loads the tree and preact/hooks; preact decides once, at that load, that its effect flush may callcancelAnimationFrame.tree-view.test.tsstubbed rAF with a function that never fires, so a flush scheduled by itsafterEachdispose ran on preact's 35 ms fallback after the file had deletedcancelAnimationFrame. Both suites now queue frame callbacks and flush them before restoring globals.35202f4, animation-frame flush): serialunitgreen, 4,553 pass / 0 fail / 12 skip, no unhandled errors, full count restored;unit-parallelgreen; e2e legs green with 1 retried (chat-touch.e2e.ts:852, below), 396 + 452 + 21, legs 16.5 / 15.1 min;validategreen.4461e4a, the final round's fixes, first run with the parallel unit gate): e2e green, 1 retried (terminal-hub-navigation"explicit new-tab gesture", a one-off also seen once on run 1); the parallel unit gate failed one test: a timed-out SSH public-key read came back as a clean exit with no output and failed with the wrong message (fixed:readPublicKeynow reports a timeout as a timeout, as its two sibling helpers already did).37a8f68): e2e green with 0 retried (397 + 452 + 21) and all 4,561 unit tests passed, but the compiled-binary smoke test failed: the page never left "Connecting". The final round'sversion.tschange had added staticnode:fs/node:pathimports to a module the page also loads;bun build --compilekeeps them as real imports, the browser cannot fetchnode:fs, and the page script failed to load. The e2e server's bundler stubs such imports, which is why all 870 e2e tests still passed. Reproduced 3/3 locally and bisected to that commit; never released. Fixed by looking the modules up at run time, plus a new test that walks the page's import graph fromsrc/app.tsand fails on any Node built-in import.eecf1b6): all green, including the parallel unit gate and the smoke test; e2e with 0 retried for the second run in a row (397 + 452 + 21, legs 16.3 / 16.0 min); unit job 94 s, its test step 52 s (the serial suite was about 155 s).main, 850 passed, 0 failed, 0 flaky, in 12.9 min. After rebasing and fixing the port allocation: 863 passed, 1 failed, in 14.8 min; the failure was a 30 s timeout in one chat-shell-scrollback test that then passed 10/10 in isolation.bun run test:cipassed 4,560 / 0 fail in two final runs (about 68 s), and in the gate-promotion stress series 10 clean runs plus 5 runs with 2–4 cores kept busy all passed. The 4src/hub/credential-context.test.tsfailures seen earlier on the author's machine came from the Hub-managed workspace's Git/SSH wrapper directory onPATH(CLAUDE.md warns about this); with it removed they pass. The CI-order replay that reproduced the viewport leak (per-file shims importing the real tests in CI's order) passes 376/376 with the teardown. The guardian and shutdown fixes: 20/20 alone and 5/5 alongside a parallel hub run.bun run typecheckis clean, andactionlint+ shellcheck report no issues on the workflows.Review rounds
a3439b3..e2fba83):/api/document500 path logs the document and error; the load retry re-arms on a same-file selection via an activation counter; the outline scroll-spy cachesscroll-padding-top; the switcher menu reconciles entries by identity instead of position; the e2e hub harness awaits a stopped child's exit and a free port before reusing it; a rejected hub shutdown exits instead of retaining the lease; a rejected WebKit launch is retried instead of cached; the chmod-based EACCES tests skip as root. CI run 12 green, 0 retried.1e99f3b..b13f83c): the SSH-agent recovery checks the recorded sockets before thestatusprobe again (a missing or swapped socket fails at once with the precise error, not after a 2 s timeout); the switcher's fork control is keyed ondata-fork-for=<id>so same-named checkouts can't swap focus; the source-run Git walk honoursGIT_CEILING_DIRECTORIES; a replaced diagnostics sink retires its pending fold timers; a live frame's failed reload restarts the retry schedule. Two optional items (keep the last render while a same-document reload retries; answerEACCES/EISDIRwith a final 4xx) are left for follow-up issues by agreement. CI run 13: all green (unit gate, smoke test,validate), e2e with 0 retried for the fourth run in a row (397 + 452 + 21; legs 16.3 / 11.4 min).Known limitations and risks
spawnSynchang: the known Bun bug above can still hit the remaining synchronous spawns in tests (execFileSync("git", …)in several hub integration tests,Bun.spawnSyncincredential-context.test.tsandscripts/agent-coverage.test.ts). A proper fix needs a Bun release containing spawnSync: release polls and keep-alives on the loop they counted on oven-sh/bun#40078.find.e2e.tsat load average about 90. No code path that drops the click was found;find.e2e.tsnow attaches the page's request log on failure, which will show whether the click reached the app. Onmain, personal-state flaked in 4 of the last 10 runs, all mode A, which theselection.tsfix above addresses.BEGIN_COMMIT_OVERRIDE
fix(shell): stop an idle browser from overwriting the document another browser picked last
fix(shell): refuse live updates older than the state the page loaded at boot
fix(chat): show conversations created just before a page subscribes to the chat list
fix(shell): keep workspace switcher entries and focus in place while the open menu refreshes
fix(preview): stop layout clicks from being lost when the preview re-renders
fix(preview): retry documents that failed to load instead of showing them as unavailable
fix(preview): keep a clicked outline entry highlighted after its heading scrolls into place
fix(sidebar): keep keyboard focus on a project search result while results update
fix(shell): keep keyboard focus on the tab when arrow keys move to the Terminal tab
fix(hub): stop a wrong-nonce SSH-agent recovery from shutting down the real guardian
fix(cli): handle a stop signal that arrives as soon as a session reports ready
fix(hub): start one hub, not none, when several start at once on a fresh state directory
fix(hub): keep the state lease held after a failed shutdown until the next signal
fix(hub): fold repeated live-upstream failure lines in the hub log into one summary
END_COMMIT_OVERRIDE
🤖 Generated with Claude Code