-
Notifications
You must be signed in to change notification settings - Fork 5
Comparing changes
Open a pull request
base repository: JetBrains/thinkrail
base: main@{1day}
head repository: JetBrains/thinkrail
compare: main
- 7 commits
- 49 files changed
- 4 contributors
Commits on Jul 27, 2026
-
fix(e2e): per-worktree isolation for tmp state dirs and ports (#133)
* fix(website): keep theme-menu keydown lint-clean with a typed indexOf biome's useIndexOf flags the findIndex-for-equality in the theme-menu keydown handler (pre-existing red lint on main). The raw autofix would break typecheck (activeElement is Element | null vs HTMLButtonElement[]), so narrow via instanceof first — same semantics, findIndex's -1 fallback included. * fix(e2e): per-worktree isolation for tmp state dirs and ports Parallel e2e runs from different worktrees used to collide: every worktree resolved the same machine-global $TMPDIR/thinkrail-e2e (one run's globalSetup/teardown rmSync'd a sibling run's live host state — persistence, fixture repo, HOME, pi agent dir, picker/central control files) and the same fixed ports (24252/24272/24254; exact-bind EADDRINUSE, or during the build-phase race two serial suites interleaving against one surviving host on the shared baseURL; the binary suite could even drift ports while Playwright's /health poll was answered by another worktree's host). Fix, in the one seam every suite already imports (e2e/fixtures/paths.ts): - derive a stable per-worktree key from the module's own repo root (sanitized basename + sha256(realpath) prefix) and suffix every machine-global dir with it: E2E_DATA_DIR, E2E_BINARY_CACHE, and the restart spec's data dir + host log (moved here so all machine-global names live in one file) - derive a per-worktree port block (25000 + hash%500*10, clear of dev's 24242): main suite +0, e2e:binary +2, restart spec +4 — deterministic because the Playwright runner, workers, and global setup each evaluate the module and must agree; THINKRAIL_E2E_PORT_BASE overrides a rare slot clash (invalid values throw) - smoke:binary needs no change: the CLI free-scans past a taken port and the script reads the served URL from stdout (comment corrected) - within ONE worktree the suites still share state and stay sequential (unchanged rule, documented); AGENTS.md verification paragraph updated Verified: check:deps/check:seams/lint/typecheck green; derivation deterministic per root and distinct across sibling worktrees; bun run e2e 98/98 green on the derived port/state dir with clean teardown. * fix(e2e): arbitrate port blocks via an atomic machine-local claim registry Review-flagged residual: the pure path hash mapped worktrees onto 500 slots, so two worktrees could derive the same port block — and under that collision the binary suite's failure shape is silent (the CLI free-scans past the taken port while Playwright polls the expected one, which the other worktree's host answers), reproducing exactly the class this change set exists to kill. The hash now only picks the *preferred* slot; ownership is arbitrated by a tiny machine-local registry (e2e/fixtures/portBlock.ts): one claim file per slot under $TMPDIR, content = the owning repo root, created with O_EXCL so the filesystem arbitrates races. Claims persist across runs (runner, workers, global setup, and later runs all converge with no coordination) and self-clean (a claim whose recorded worktree path no longer exists is reclaimed). Suite teardown never touches the registry — it is the memory that keeps live worktrees apart. THINKRAIL_E2E_PORT_BASE still bypasses it to pin a block explicitly. e2e/port-block.spec.ts pins the contract (convergence, distinct blocks under a preferred-slot collision, stale reclaim, wrap-around, registry bootstrap); a 16-process concurrent-claim race was verified by hand. Full suite: 104/104. * fix(e2e): serialize port-block claims with an interprocess lock; sticky two-pass allocation Review round 2 found two real protocol flaws: 1. The read→reclaim→create dance was not atomic: two processes could both judge one claim stale, and the slower rmSync then deleted the faster one's FRESH claim — two worktrees on one block, the exact silent class the registry exists to prevent. The whole transaction now runs under an interprocess mkdir-lock (atomic to create; a lock older than 10s is a crashed holder's leftover and is broken — it guards a sub-millisecond critical section). Stale claims are plain overwrites under the lock; no unlink window remains. 2. The scan could claim an earlier newly-freed slot before discovering this worktree's existing later claim — migrating the assignment and stranding the old claim as a live-looking leak (predecessor churn could strand slots indefinitely). Allocation is now two-pass under the same lock: an existing claim for this repo root always wins (assignments are sticky, never migrate), duplicates are deduped to the lowest slot; only then does the preferred-slot scan allocate. port-block.spec.ts grows the churn scenario (displaced worktree stays put after its predecessor dies; a newcomer reclaims the stale slot), dedupe, stale-lock breaking, and fresh-lock timeout; a 16-process race over pre-seeded stale claims was verified by hand (16 distinct stable blocks, no leaked slots). Full suite: 108/108. * fix(e2e): liveness-arbitrated registry lock with fenced release Review round 3: lock age is not proof the holder died — a suspended or descheduled holder outliving the age threshold would have its lock broken, resume inside the critical section, and its unconditional release would then delete the NEW owner's lock, cascading the loss of mutual exclusion. Node/Bun expose no flock, so the dependency-free equivalent: - a lock is born atomically WITH its owner record ({pid, nonce}, staged then rename()d into place — rename refuses to replace a non-empty dir, so no created-but-ownerless window exists) - a held lock is broken ONLY when its recorded pid is provably dead (kill(pid,0) → ESRCH): a crashed holder breaks immediately (faster than the old age wait); a live-but-suspended holder is never usurped — waiters time out LOUDLY; pid reuse degrades the same conservative way (loud wait, never a broken mutex) - release is fenced by the nonce: only the acquisition that still owns the lock may remove it, so no resume interleaving can delete a successor's mutex - age (STALE_LOCK_MS) survives only for a garbled lock with no readable owner, which the protocol itself cannot produce Tests: dead-pid lock broken immediately; live-pid lock times out loudly (never usurped); garbled lock waits fresh / breaks old. 16-process race over pre-seeded stale claims re-verified (16 distinct stable blocks, zero lock artifacts leaked). Full suite: 109/109. * fix(e2e): serialize lock-breaking behind a token; verify claims post-release Review round 4: dead-owner reclamation was still an unfenced check-then-delete — two waiters could both read one dead owner, and the slower rmSync, acting on its stale decision, deleted the faster one's freshly installed lock, re-admitting two processes into the claim transaction. node/bun expose no flock without native deps, so this is the fenced equivalent: - breaking now happens only inside tryBreakLock: the breaker must win an exclusive break-token (mkdir, single winner), and the kill decision is re-made on FRESHLY READ state under that token — a stale earlier reading can never delete a successor's live lock. A leftover token (crashed breaker) can only BLOCK breaking, never corrupt, so the crude age rule is safe for tokens. - belt over the whole protocol: claimPortBlock re-reads its slot AFTER releasing the lock and retries the transaction (bounded) if the claim was overwritten — a hypothetical residual mutex failure self-heals at the layer that matters instead of yielding two worktrees on one block. - the residual floor is stated in the header: without an OS-death- released lock, what remains requires a process SIGSTOP'd >10s inside a sub-millisecond window, twice, at exactly the wrong instants. Tests add the reviewer-requested forced two-reclaimer interleaving (driven through the real exported break routine: A breaks the dead lock, a successor installs, B executes its stale decision — successor untouched, exclusivity intact) and token serialization (a foreign fresh token blocks breaking; a stale token only ever blocks and ages out). 16-process race re-run with a planted dead-owner lock contended by all racers: 16 distinct stable blocks, zero artifacts. Full suite: 111/111. * fix(e2e): never auto-reclaim break-tokens — wedge loudly instead Review round 5: aging an orphaned break-token out was itself a stat-then-delete on a mutable path — the same unfenced-reclamation class one level down (B pauses after stat, A replaces the token, B's rm deletes A's live token), and any reclamation primitive would just recurse the problem. Terminate the regress by removing reclamation: tokens are never auto-reclaimed. This is sound because a token exists for microseconds and is orphaned only by a kill inside that window; an orphaned token only ever BLOCKS breaking (installs don't consult it); and because the token holder keeps the sole break right until its own rm fires, a pending removal can only ever hit the dead lock it verified — never a successor's. The degraded case is availability-shaped, not correctness-shaped: the acquire timeout now names the holder state (live pid / dead pid + wedged / unreadable) and the exact registry dir to remove, once, by hand. The token test now pins the wedge semantics (an ancient orphan still blocks — loudly, with the cleanup path in the message — and manual removal recovers); race + full suite re-verified (16/16 distinct stable blocks; 111/111). * fix(e2e): restore import order in ask-restart spec after main mergeConfiguration menu - View commit details
-
Copy full SHA for 96968fb - Browse repository at this point
Copy the full SHA 96968fbView commit details -
fix(dialog): the Windows folder picker opens in front, and a failed p…
…icker says why (#141) * fix(dialog): the Windows folder picker opens in front, and a failed picker says why "Open project" on Windows did nothing: no dialog, no error. Two causes, both present since the picker landed (#11) — nothing in a recent release touched this path. - **Z-order.** We spawn the picker from a background process, which Windows forbids from taking the foreground, so an *unowned* `FolderBrowserDialog` opens behind the browser the user is looking at. It is now owned by an invisible top-most form (`$d.ShowDialog($owner)`), which puts it in front. - **Silence.** Every failure read as "user cancelled": a non-zero exit was treated as a cancel and `pickAndOpen` swallowed the rest. But PowerShell exits **0** on cancel — a non-zero exit there is a real failure (a blocked `Add-Type` under ConstrainedLanguage, no host on PATH). `Picker` now declares `nonZeroExit` per picker (osascript/zenity/kdialog do cancel that way), a failure or "no runnable candidate" throws with the reason, and the hook surfaces it in the existing notice — the picker is the only way to add a project, so a silent null is a dead button. Also `-Sta` (a WinForms dialog needs a single-threaded apartment — the default for powershell.exe, not for pwsh) and a `pwsh.exe` fallback, matching the hosts and order `apps/cli/src/powershell.ts` already uses. Unverified on Windows (no box here): the z-order fix is a best diagnosis of a symptom I can't reproduce. The error surfacing is what makes the next report name a cause instead of nothing. * fix(transport, dialog): the picker outlives the request timeout; a failure always names a cause Both from the Windows review of #141, which confirmed the z-order fix works (dialog Z0/topmost/foreground, 4/4 runs) and found two follow-ons. - **The 60s request timeout defeated the picker** (reproduced live): a folder dialog is held open by a human, so at 62s `request` rejected — surfacing "dialog.selectDirectory timed out" *while the dialog was still on screen* — and, because the pending entry was already deleted, the host's later reply was dropped, leaving the rail at 0 projects. Before this PR the same timeout was swallowed by `catch {}`; now it was a misleading error. `request` takes a per-call `timeoutMs` (the unused third positional `sessionId` becomes a `RequestOptions` bag — no call site passed it), and the picker raises it to 30min: generous for a human, finite so a dead host still releases it. - **The failure message could come out empty.** `split("\n")[0] ?? …` never falls back — `split` always yields at least `""` — so a non-zero exit with empty stderr (a killed picker) produced "The folder picker failed: ", and PowerShell's CRLF left a trailing `\r` on the line. Now `|| exit <code>`, with `\r` stripped. Both message builders are extracted and unit-tested: the missing coverage on the new throw paths is why this slipped through. Also from the review: the `-Sta` comment claimed it isn't `pwsh`'s default, which is wrong for 7+ (both hosts default to STA on Windows — the flag only pins the requirement at the spawn site), and `panels/SPEC.md` hadn't recorded the picker-failure notice path that `dialog/SPEC.md` already described.
Configuration menu - View commit details
-
Copy full SHA for bf71747 - Browse repository at this point
Copy the full SHA bf71747View commit details
Commits on Jul 28, 2026
-
fix(dialog): the Windows picker takes the keyboard, not just the z-or…
…der (#142) * fix(dialog): the Windows picker takes the keyboard, not just the z-order Top-most won the z-order but not the *active* window: the browser stayed foreground, so the dialog opened on top with no keyboard focus — the user had to click it before typing or arrowing around. `SetForegroundWindow` is blocked by the same foreground lock that put the dialog behind the browser in the first place, so the script attaches our input thread to the current foreground thread first (the documented way past the lock), sets the owner form foreground, then detaches. Best-effort in a try/catch: a host that can't compile the P/Invoke still gets the top-most dialog, just unfocused, rather than no dialog at all. That block needs C# string literals, and quoting embedded double quotes through a Windows command line into PowerShell's own parser is the minefield `apps/cli/src/powershell.ts` avoids with a temp `.ps1`. The script now travels as `-EncodedCommand` (base64 UTF-16LE) instead of `-Command` — one quote-proof argv element, no temp file. Newlines come free with it, so the script reads as a script. * docs(dialog): the spec names the focus grab, measured on Windows The module spec still credited the invisible top-most owner for getting the picker in front — but top-most only wins the z-order. Measured on Windows 11 with an unrelated app in front, that dialog came up unfocused in 3/3 runs: visible, with the keyboard still in the browser. Record what actually works (AttachThreadInput to the foreground thread, then SetForegroundWindow on the owner form), the measurements for both, and that the grab is best-effort by design. Two test assertions to match: the attach/detach calls are paired (a stuck attachment would hand us another app's keyboard state for the picker's lifetime), and the grab stays inside its catch so a host that can't compile the P/Invoke still gets a dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Configuration menu - View commit details
-
Copy full SHA for 8a2c87f - Browse repository at this point
Copy the full SHA 8a2c87fView commit details -
feat(web): split the turn divider's written files into specs and code…
… changes, each deep-linking the panel that owns it
Configuration menu - View commit details
-
Copy full SHA for 78ede30 - Browse repository at this point
Copy the full SHA 78ede30View commit details -
feat(web): make the turn divider's artifact chips a single-choice swi…
…tch that reveals the owning panel view
Configuration menu - View commit details
-
Copy full SHA for c76a658 - Browse repository at this point
Copy the full SHA c76a658View commit details -
refactor(web): extract the shared workspace-read hook, path/array pri…
…mitives, and the store's key-omit helper
Configuration menu - View commit details
-
Copy full SHA for 77da520 - Browse repository at this point
Copy the full SHA 77da520View commit details -
fix(web): canonicalize reported paths in one place — ./-prefixed matc…
…hes, one tab identity per file, no keysEqual alias
Configuration menu - View commit details
-
Copy full SHA for 71fcc91 - Browse repository at this point
Copy the full SHA 71fcc91View commit details
This comparison is taking too long to generate.
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
You can try running this command locally to see the comparison on your machine:
git diff main@{1day}...main