Skip to content

Worktree stuck on loading spinner forever when pinned during setup (.pending lifecycle never clears — no timeout/failure fallback) #383

@marvtub

Description

@marvtub

Summary

A worktree can get stuck on the loading spinner (WorktreeLoadingView) forever. The worktree itself is completely healthy (git intact, node_modules installed, terminal/zmx sessions alive and attached) — only the UI overlay is wedged. It does not recover on focus-away/return; the only fix is restarting the app.

I hit this on two worktrees simultaneously, both of which I had pinned while their setup/build was still running.

Environment

  • Version: 0.10.2 (142000)
  • macOS 26.5 (Apple Silicon)

Root cause (traced in source)

The loading overlay is gated on a single state field:

  • WorktreeDetailView.loadingInfo() returns a WorktreeLoadingInfo (→ shows WorktreeLoadingView) iff selectedRow.lifecycle == .pending.
  • lifecycle is set to .pending once, at creation: RepositoriesFeature.swift:1540. It is in-memory only (not persisted).
  • For the setup-script path, .pending is cleared only by .consumeSetupScript (RepositoriesFeature.swift:1594-1596lifecycleChanged(.idle)).
  • .consumeSetupScript is sent only from onSetupScriptConsumed?(), which fires behind this guard in WorktreeTerminalState.swift:
let tabId = createTab(TabCreation(/* initialInput: setup script */ ...))
if shouldConsumeSetupScript, tabId != nil {
  onSetupScriptConsumed?()
}

There is no else branch, no timeout, and no failure path, and .pending is never re-evaluated. So if createTab returns nil (or that creation/consume step is interrupted), .consumeSetupScript is never sent, lifecycle stays .pending, and the overlay spins indefinitely. Because the lifecycle is in-memory, only an app restart (which resets it to .idle) clears it.

Hypothesized trigger (not yet deterministically reproduced)

Pinning a worktree while its setup script is still running. Pinning moves the row between sidebar buckets (unpinnedpinned), which churns sidebar/terminal state and appears to race the first-tab creation, so the tabId != nil branch is skipped and the consume signal is lost. Both worktrees I had pinned-mid-build ended up stuck; others in the same repo were fine.

I want to be honest that I have not reproduced this in a controlled run — the race depends on createTab returning nil at the wrong moment, so it may be intermittent. The design gap is concrete regardless of trigger: a .pending worktree whose consume signal never arrives has no recovery.

Impact

  • Worktree appears permanently broken/"loading" even though all underlying work (terminals, running agents) is alive and fine.
  • No in-app way to recover — requires quitting and relaunching the whole app.

Suggested fix

  1. Tie .consumeSetupScript to the actual setup-script lifecycle (tab created and input dispatched / process exited), not a synchronous tabId != nil check.
  2. Add a failure/timeout transition out of .pending so an interrupted or failed setup can never strand the row on the overlay.
  3. Optionally, make pinning defer until creation settles, or have pin/bucket-move be a no-op for the in-flight creation task.

Workaround

Quit and relaunch the app — .pending is in-memory, so it resets to .idle and the worktree loads normally. (zmx sessions persist across the restart, so no terminal/agent state is lost.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions