Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: JetBrains/thinkrail
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: main@{1day}
Choose a base ref
...
head repository: JetBrains/thinkrail
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref
  • 7 commits
  • 49 files changed
  • 4 contributors

Commits on Jul 27, 2026

  1. 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 merge
    rsolmano authored Jul 27, 2026
    Configuration menu
    Copy the full SHA
    96968fb View commit details
    Browse the repository at this point in the history
  2. 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.
    danyaberezun authored Jul 27, 2026
    Configuration menu
    Copy the full SHA
    bf71747 View commit details
    Browse the repository at this point in the history

Commits on Jul 28, 2026

  1. 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>
    danyaberezun and claude authored Jul 28, 2026
    Configuration menu
    Copy the full SHA
    8a2c87f View commit details
    Browse the repository at this point in the history
  2. feat(web): split the turn divider's written files into specs and code…

    … changes, each deep-linking the panel that owns it
    OLavrik committed Jul 28, 2026
    Configuration menu
    Copy the full SHA
    78ede30 View commit details
    Browse the repository at this point in the history
  3. feat(web): make the turn divider's artifact chips a single-choice swi…

    …tch that reveals the owning panel view
    OLavrik committed Jul 28, 2026
    Configuration menu
    Copy the full SHA
    c76a658 View commit details
    Browse the repository at this point in the history
  4. refactor(web): extract the shared workspace-read hook, path/array pri…

    …mitives, and the store's key-omit helper
    OLavrik committed Jul 28, 2026
    Configuration menu
    Copy the full SHA
    77da520 View commit details
    Browse the repository at this point in the history
  5. fix(web): canonicalize reported paths in one place — ./-prefixed matc…

    …hes, one tab identity per file, no keysEqual alias
    OLavrik committed Jul 28, 2026
    Configuration menu
    Copy the full SHA
    71fcc91 View commit details
    Browse the repository at this point in the history
Loading