Skip to content

fix(ui): refresh + auto-switch worktrees on session idle#516

Open
omercnet wants to merge 2 commits into
NeuralNomadsAI:devfrom
omercnet:fix/059-worktree-detection-refresh
Open

fix(ui): refresh + auto-switch worktrees on session idle#516
omercnet wants to merge 2 commits into
NeuralNomadsAI:devfrom
omercnet:fix/059-worktree-detection-refresh

Conversation

@omercnet

@omercnet omercnet commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Stacked on #515 (task 058 question-reply idempotency). This PR's diff currently includes the 058 commit because it carries a baseline test fix (client-identity.ts / server-events.ts window/EventSource guards) required for the UI suite to be green. Merge #515 first; this PR's net change is the single commit fix(ui): refresh + auto-switch worktrees on session idle.

Problem

The opencode agent creates worktrees via git worktree add mid-session (outside CodeNomad's managed flow). CodeNomad cached its worktree list and never invalidated it for out-of-band git changes, so the files view stayed bound to a stale tree until a manual reload or restart.

Fix

packages/ui only — no server cache (server enumeration via git worktree list --porcelain is already live per request):

  • New debounced refreshWorktreesOnIdle(instanceId) in worktrees.ts, triggered from the existing handleSessionIdle path (no new sse-manager hook). Per-instance ~600ms trailing debounce, deduped on parent session id. Adds a reloadLoads in-flight guard to reloadWorktrees (it had none) to avoid overlap with rehydrateInstance.
  • Constrained auto-switch rule (PO-confirmed): on idle, diff the worktree list before/after reload. If exactly one new worktree appeared, auto-switch the active session's files view to it via setWorktreeSlugForParentSession (metadata-backed, per Migrate worktree mappings to session metadata #514). Guarded: switch only when the idle session is the active session AND the user hasn't manually switched in the meantime. Zero or multiple new worktrees → refresh list only, no switch (ambiguous attribution is not reliable without a per-session signal).

Why the constraint

CodeNomad cannot reliably attribute a newly-created worktree to a specific session (no per-session cwd signal). The single-new-worktree heuristic covers the common case (agent creates one worktree mid-turn) safely without yanking the user's view.

Validation

  • UI suite 56/56 pass (bun test --conditions browser) — 49 existing + 7 new
  • UI typecheck clean (tsc --noEmit), build OK
  • Tests cover: reload-on-idle, debounce coalescing, in-flight guard, single-new→switch, zero/multi→no-switch, different-active-session→no-switch

omercnet added 2 commits June 1, 2026 11:42
Eliminate opencode's "reply for unknown request" warning caused by
orphaned/stale question replies in the CodeNomad UI. Previously the
question request lifecycle lacked the idempotency and stale-guard layer
that permissions already had, so a question popup answered after a
reconnect, restart, or rapid double-submit could POST a reply for a
request the backend no longer tracked.

User-visible behavior change:
- Answering a question prompt that has already expired no longer fails
  silently or mis-routes; the UI now surfaces a localized "this prompt
  expired, send your answer as a message instead" notice (new i18n key
  toolCall.question.errors.expired added to all 7 locales).
- Rapid double-submit (Enter + button) can no longer fire two replies.
- Reconnect/rehydrate no longer re-surfaces an already-answered question.

Implementation:
- New stores/question-replies.ts ledger (markQuestionReplied /
  hasRepliedQuestion / pruneRepliedQuestions / clearRepliedQuestions),
  a structural mirror of permission-replies.ts (no shared abstraction,
  per YAGNI; prune semantics may diverge).
- sendQuestionReply/sendQuestionReject reconcile against question.list()
  before POSTing (no error-string parsing); on absent requestID they
  take the expired-prompt path instead of POSTing to a "root" fallback,
  and resolve the owning worktree when the stored slug is missing.
  markQuestionReplied is recorded on success before removeQuestionFromQueue.
- syncPendingQuestions adopts the full timestamp-based prune+filter
  pattern so replied questions are never rehydrated.
- The question ledger is cleared only on instance removal, never on
  rehydrate, so in-flight stale replies cannot slip through.
- session-events stale guards on handleQuestionAsked/handleQuestionAnswered.
- tool-call.tsx synchronous double-submit guard before the await boundary.

Also fixes two pre-existing module-load crashes uncovered while making the
UI test suite green (ownership policy): client-identity.ts dereferenced
window storage before its typeof-window guard, and server-events.ts opened
an EventSource at module load without an EventSource guard. Both fixes are
minimal and production-safe.

Validation: UI suite 49/49 pass (bun test --conditions browser), UI
typecheck clean (tsc --noEmit). Evidence packet under
evidences/058-question-reply-idempotency/.
The opencode agent can run `git worktree add` mid-session. CodeNomad
cached its worktree list with no invalidation trigger for out-of-band
git changes, so the files view stayed bound to a stale list and the
session remained mapped to its old worktree slug.

Wire a debounced refresh into the existing onSessionIdle path
(sse-manager -> handleSessionIdle) via a new refreshWorktreesOnIdle in
the worktrees store. On idle we reload the live server enumeration
(no server cache added) and apply a constrained auto-switch:

- Per-instance trailing debounce (~600ms) keyed by instanceId, deduped
  on the parent session id, so idle storms coalesce into one fetch.
- Add a reloadLoads in-flight guard to reloadWorktrees so it no longer
  overlaps with rehydrateInstance's reload.
- Diff the slug set before/after reload. Auto-switch only when exactly
  one worktree was added AND the idle session is the active session AND
  the parent session's slug has not changed since the snapshot (user
  has not manually switched). Zero or multiple additions refresh only.
- The switch persists via setWorktreeSlugForParentSession, which writes
  through OpenCode session metadata (per NeuralNomadsAI#514) so it survives reload.

Delete/prune paths are untouched. Adds co-located tests covering idle
reload, debounce coalescing, the in-flight guard, single-new auto-switch,
zero/multi-new no-switch, and the active-session guard.
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/26755126669

Artifacts expire in 7 days.
Artifacts:

  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-tauri-macos
  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-tauri-macos-arm64
  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-tauri-linux
  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-tauri-windows
  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-electron-macos
  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-electron-windows
  • pr-516-d0c0a7225f91a08acd0fc1c439088509f19ce5bf-electron-linux

@pascalandr

Copy link
Copy Markdown
Contributor

@CodeNomadBot

I want you to perform the gatekeeper checks for latest PR state

check for regressions
check for better possible implementations
Be the PR gatekeeper
Be ruthless about code quality

@codenomadbot

codenomadbot Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Gatekeeper review for latest PR state.

Understood request: review PR #516 ruthlessly for regressions and better implementation options. I treated the latest PR diff as including the stacked #515 baseline, but focused the gatekeeper verdict on the intended net #516 commit (worktrees.ts, session-events.ts, worktrees-idle-refresh.test.ts).

Findings:

  1. High: idle refresh can reuse a stale in-flight reload and miss the agent-created worktree entirely.
    packages/ui/src/stores/worktrees.ts:90-95, packages/ui/src/stores/worktrees.ts:171

    reloadWorktrees() now returns an existing reloadLoads promise for the instance. That coalesces overlap, but it also means the idle-triggered refresh can attach to a fetch that started before the agent ran git worktree add. In that timeline, the idle path does not perform a post-idle enumeration, added.length stays 0, and the files view remains stale. This is specifically risky because rehydrateInstance() calls hydrateInstanceData(..., { force: true }), which calls the same reloadWorktrees() path before/around session events.

    Concrete failure timeline:

    • A force reload starts and calls git worktree list before the new worktree exists.
    • The agent creates one worktree.
    • session.idle fires, debounce expires while the first reload is still in flight.
    • runIdleWorktreeRefresh() snapshots the stale list, then await reloadWorktrees() returns the old in-flight fetch.
    • The old fetch resolves without the new worktree; no second fetch runs; no refresh/no auto-switch.

    Better implementation: keep the overlap guard for generic reloads, but make idle refresh a trailing fresh read. For example, if reloadWorktrees() finds an existing promise, the idle path should await it and then perform/queue one fresh reload, or reloadWorktrees should support a freshAfterExisting/reason: "idle" mode. Add a regression test where a pre-idle reload is held open, the underlying worktree list changes, then idle refresh must issue one more fetch and switch.

  2. Medium: the debounce is keyed by instance and cancels different sessions/parents, which can drop the active session’s auto-switch candidate.
    packages/ui/src/stores/worktrees.ts:144-158

    The state stores parentSessionId, but the pending timer is keyed only by instanceId and every later idle clears the previous timer regardless of parent/session. If active session A creates a worktree and idles, then session B in the same instance idles within 600ms, the refresh runs under B’s sessionId. The list may refresh, but the auto-switch guard compares activeSessionId().get(instanceId) !== sessionId and refuses the switch because the active session is still A.

    This contradicts the PR description’s “deduped on parent session id” claim and makes the single-new-worktree heuristic less reliable under concurrent/child-session idle storms. Better implementation: debounce per (instanceId, parentSessionId) for attribution, or keep a single per-instance network fetch while preserving all parent/session candidates and applying the active candidate after the fetch. Add a test with two rapid idles from different sessions where the first/active session should still auto-switch.

Notes on stacked #515 diff:

  • I scanned the question-reply/idempotency changes included in this PR due to stacking. I did not find a clear gatekeeper blocker there from static review.
  • The stacked state makes this PR harder to review safely. Once fix: port permission idempotency model to question replies #515 merges, this should be re-reviewed as the single intended worktree-idle commit only.

Validation attempted:

  • Tried targeted tests: bun test --conditions browser packages/ui/src/stores/worktrees-idle-refresh.test.ts packages/ui/src/stores/question-replies.test.ts packages/ui/src/stores/question-stale-guard.test.ts packages/ui/src/stores/question-reply-exports.test.ts
  • The checkout could not resolve dependencies/modules (solid-js, @opencode-ai/sdk/v2/client), so I could not get a meaningful local pass/fail signal from this worktree. I did not treat that as a PR regression.

Verdict: not ready as-is. The feature’s core purpose is to refresh after an agent-created worktree on idle; finding #1 leaves a realistic stale-cache race in that exact path, and finding #2 weakens the auto-switch attribution under concurrent idle events.

--
Yours,
CodeNomadBot

@shantur

shantur commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Hey @omercnet,

Git worktrees were recently improved in CodeNomad + OpenCode.
Instead of refreshing it everytime, as its an agent, maybe we should first check if opencode provides any SSE event when it detects a new worktree. Can you check in opencode source code please

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants