Skip to content

fix(agent-manager): report worktree problems accurately and recover from them - #14137

Merged
kirillk merged 15 commits into
mainfrom
golden-sparrow
Sep 15, 2026
Merged

kirillk merged 15 commits into
mainfrom
golden-sparrow

Conversation

@kirillk

@kirillk kirillk commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Why

Agent Manager told me "Git is not installed or not found in PATH" on a machine where git was installed and working fine. Chasing that turned up a cluster of related problems, all of which make Agent Manager look broken while giving the user no way to find out what actually happened:

  • A worktree whose folder had been deleted stayed in the list forever and kept getting polled.
  • A status check that failed or timed out showed the worktree as having no changes, so badges silently disappeared instead of reporting a problem.
  • One unresponsive worktree slowed down status updates for every other row, because its failures fed the backoff shared by the whole polling loop.
  • PR badges stopped updating with no explanation when a gh call hung.
  • Leftover folders under .kilo/worktrees/ piled up invisibly (on my checkout: 60 folders, 8 real worktrees, 24 rows in state, ~15 of them pointing at folders that no longer existed).

What was actually wrong

Three variations of the same mistake: a failure was reported as a fact about something else.

Symptom Cause
"Git is not installed" A failed process launch reports ENOENT whether the program or the working directory is missing. The code assumed the program.
Worktree shows no changes A failed or timed-out status check returned zero counts — indistinguishable from a clean worktree.
PR badges stop updating forever A timed-out GitHub CLI call was recorded as a success, which reset the backoff meant to stop retrying it.

On top of that, nothing ever reconciled the three views of a worktree — the row in the UI, git's own registration, and the folder on disk — so drift between them accumulated silently and was polled forever.

How we approached it

Name each state and never guess between them. A worktree is healthy, restorable, gone, not-a-worktree, or unmeasurable. Each state gets the message and the recovery action that actually applies, and "we could not check" is never rendered as "it is fine" or "it is broken".

Self-heal metadata, never delete files on its own. On startup the three views are reconciled: stale git metadata is pruned, and a row is dropped automatically only when its folder, its branch, and its sessions are all gone — nothing can be lost. Everything else is offered as an explicit action.

Contain failures instead of spreading them. A worktree that keeps failing is parked on its own for a while rather than degrading the whole panel. Git and GitHub CLI calls get time budgets sized to the work they do, instead of one long budget shared by everything from a version check to a full diff.

Make the answer findable. Both clients get a diagnostics report with the same sections, so a user can see what git and gh actually did instead of guessing from a log.

Both clients share the parts that decide whether a failure is reported honestly: the same distinction between "broken" and "we could not check", the same wording for it, and the same diagnostics report. The recovery actions are not at parity yet — restore, "Remove, keep sessions", and orphan cleanup are VS Code only, and JetBrains reports orphans without offering to remove them. The two sections below list what each client actually needed; the underlying bugs were similar in kind but different in detail, because the two clients have independent worktree implementations.


Fixed in the VS Code extension

The false "git is missing" message. Worktree errors are now classified with the context the message alone cannot carry, so that text only appears when a git version probe genuinely failed to launch. A launch failure in a folder that no longer exists says the worktree is missing; a broken worktree registration says so; a timeout says it timed out.

Ghost rows that never went away. Startup now reconciles rows, git registrations, and folders on disk. Stale git metadata is pruned, and rows are dropped only when nothing can be lost. A dead code path that would have deleted worktree rows and their conversations on sight is gone.

Recovery actions on the row itself, instead of a dead end:

  • Restore worktree — the folder is gone but the branch survives, so it can be recreated
  • Remove, keep sessions — the folder is unrecoverable, but the conversations move to Local rather than disappearing
  • Clean up leftover folders — a notice lists the exact paths and requires a second click; nothing is deleted automatically

One broken worktree no longer degrades the rest. Repeated failures park a single worktree (with backoff, cleared by any success or an explicit refresh) instead of feeding the loop-wide backoff that slowed every other row.

PR lookups no longer stall on a hanging GitHub CLI. The lookup form that hangs is demoted to a short-budget fallback, and timeouts are reported as timeouts rather than as a missing gh install.

A startup that opened dozens of git processes at once. Discovering worktrees used to launch one git process per folder in parallel — the same storm that makes every command look slow. It now takes a single snapshot from git and probes with a bounded number of processes.

Failures that were invisible. Errors sent to the panel were silently dropped and never shown; the log helper printed {} for every error it was given, losing the message and the stack. Both are fixed.

Also: an installed git-lfs was ignored until the window was reloaded (a negative check was cached forever), and new "Agent Manager: Show Worktree Diagnostics" command. All new strings are translated in 21 languages.

Fixed in the JetBrains plugin

Every git command timing out at 30 seconds — including git --version. The real cause was on our side, not git's: status polls could stack up on a slow repository, each one fanning out its own set of git processes, until trivial commands could not get through. Polls are now one-at-a-time with a single shared process budget for the whole repository, and commands get budgets sized to their work (a metadata lookup no longer waits as long as a full diff).

Git comparison failed (exit=-1) with no explanation. A timeout and a failed process launch both collapsed into the same meaningless message with empty output. A timeout now says it timed out; a real failure reports its exit code and error.

Worktree badges silently disappearing. A failed status check returned zeros, which the UI rendered as a clean worktree. Failures are now marked as unmeasured, and rows keep their previous values with an explanatory tooltip instead of quietly reading as "no changes".

PR badges that stopped updating and never recovered. A timed-out gh call was classified as success, which reset the very backoff meant to slow it down — so every poll kept paying the full timeout. Timeouts are now their own state: they slow retries down, explain themselves in the banner, and do not raise a notification the user cannot act on. The PR lookup also names the branch first instead of using the form that hangs.

Review and CI badges disappearing across the whole IDE. Deleting a worktree while its status was being checked was enough to lose the approved badge, the checks badge, and the conversation count on every row until the IDE restarted. A worktree deleted mid-poll fails before gh runs, and the platform's "working directory does not exist" message was read as gh rejecting the --json field names — a verdict the resolver latches for the whole backend, because a gh release that cannot answer those fields will never answer them. It now distinguishes a directory that vanished from a field that was refused. Found in a real log: one deleted worktree at 14:17 hid an approval landing 17 hours later.

Rows and status disagreeing about what exists. The worktree list and the status polls used to reconcile differently, so a row could exist that nothing would ever report status for. They now share one reconciliation, which also reports leftover folders that git does not track (reported only — nothing is deleted).

Also: new Copy report action in Advanced settings, producing the same diagnostics report as VS Code.


Notes for reviewers

  • Writing the reconciler against real git repositories surfaced a latent bug: git worktree list reports canonical paths, so on macOS every worktree under a symlinked path (e.g. /var → /private/var) compared as unregistered. Path comparison now canonicalizes.
  • Two existing tests were rewritten rather than made to pass: the PR lookup order assertions (now assert branch-first plus the short fallback budget) and a JetBrains test that asserted a failed status check produces a "neutral" all-zero entry — which is exactly the behavior this PR removes.
  • Telemetry records counts per health state once per project init. No paths, no branch names.

Verification

  • bun run typecheck, bun run lint, bun run knip, bun run compile, 5708 unit tests pass
  • ./gradlew typecheck test — BUILD SUCCESSFUL (backend + frontend)
  • Repo guards: opencode annotations, kilocode_change markers, markdown tables, workflow allowlist, source links
  • Tests cover each health state against real git repositories, the auto-drop guard, quarantine isolation, the "unavailable ≠ clean" retention, timeout classification, and both diagnostics reports, plus the deleted-worktree race that must not latch the gh field downgrade

…rom them

Agent Manager could tell a user "Git is not installed or not found in PATH"
while git was installed and working, show a worktree as having no changes when
the status check had actually failed, and keep polling worktrees whose folder
was long gone. One unresponsive worktree was enough to slow down status updates
for every other row.

Three causes, all of them the same mistake in different places: a failure was
reported as a fact about something else.

- A failed process launch reports ENOENT whether the program or the working
  directory is missing, and the code read that as "git is missing".
- A failed or timed-out status check returned zero counts, which is
  indistinguishable from a clean worktree.
- A timed-out GitHub CLI call was recorded as a success, which reset the
  backoff that was supposed to stop retrying it.

Nothing reconciled the three views of a worktree either (the row, git's own
registration, the folder on disk), so stale rows accumulated and were polled
forever, and a timed-out `gh pr view` was retried on every cycle.

Approach: name each state and never guess between them. A worktree is healthy,
restorable, gone, not-a-worktree, or unmeasurable, and each state gets the
message and the recovery action that actually applies. Startup reconciles the
three views and repairs metadata only; deleting files is always a user's
click. Repeated failures park one worktree instead of the whole panel, and
git/gh calls get budgets sized to the work they do.

Both clients implement the same states, wording, and recovery actions.
@kilo-code-bot

kilo-code-bot Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

The single outstanding finding from the previous review is resolved in 3b1861209c: the host pattern no longer accepts a bare . boundary, so a path segment ending in .github.com is no longer read as GitHub. The host must now sit at the start of the string or after // or @, with subdomain labels in between, and a regression test covers the path-segment case. No new issues in the incremental diff.

Files Reviewed (2 files)
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt
Previous Review Summaries (12 snapshots, latest commit b6c9e99)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit b6c9e99)

Status: 1 Issue Found | Recommendation: Address before merge

Incremental review of 68ef839b41..b6c9e99412 (one commit, fix(jetbrains): detect the ref conflict git only summarizes). The commit accepts the git remote prune summary as conflict evidence, extracts conflictBranch, guards against naming the imported branch as its own blocker, and widens the GitHub host boundary so www.github.com parses. Both previous SUGGESTIONs are resolved: a www. origin no longer parses as null, and the reload-origin test is no longer vacuous (a new test covers the construction-time lookup).

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt 9 The new . boundary is not anchored to the authority, so a path segment ending in .github.com parses as a GitHub host.
Files Reviewed (4 files)
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt - no issues
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt - no issues
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt - no issues
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt - 1 issue

Fix these issues in Kilo Cloud

Previous review (commit 68ef839)

Status: 2 Issues Found | Recommendation: Address before merge

Incremental review of 664be7d75..68ef839b41 (one commit, fix(jetbrains): address PR review on PR import guards). The commit extracts the shared foreignPr guard, requires a host boundary in the GitHub URL regex, adds the refs/heads/ namespace to the conflicting-branch label, resolves origin at controller construction, and rewrites the affected tests.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt 7 The new host boundary also excludes www.github.com, and a null slug disables the cross-repo PR guard instead of enforcing it.
packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt 73 Resolving origin at construction makes WorktreeControllerTest's reload-origin test pass without reload().
Files Reviewed (6 files)
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt
  • packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties

Fix these issues in Kilo Cloud

Previous review (commit 664be7d)

Status: No Issues Found | Recommendation: Merge

Incremental review of dead6df70..664be7d75 (one commit, fix(jetbrains): reorder worktree row context menu). The commit only reorders the worktree row context menu, renames three action labels, and updates the tests that assert that order; no new code-review findings.

Files Reviewed (5 files)
  • .changeset/jetbrains-worktree-row-menu-order.md
  • packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml
  • packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt

Context (no action requested): the three renamed keys (action.Kilo.OpenSetupScript.text, action.Kilo.Worktree.OpenDiff.text, action.Kilo.Worktree.OpenLocalDiff.text) exist only in the base KiloBundle.properties, so non-English locales fall back to the new English wording instead of showing stale translations. The HistorySessionActionsTest order assertion was re-ordered to match the new XML exactly.

Previous review (commit dead6df)

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 4

Incremental review of 6624db6e50..dead6df70 (one commit, fix(jetbrains): explain PR import failures and recover from stale refs). This commit adds the JetBrains-side PR-import guards: a parseRepoSlug origin lookup in listBranches, an origin slug check in both the backend importPr and the New Worktree dialog, a prune-and-retry for git's directory/file ref conflict, and import-specific failure wording via fetchReason. All findings below are on code added by this commit.

Issue Details (click to expand)

WARNING

File Line Issue
packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt 1132 conflictBranch strips only refs/remotes/origin/, so local-namespace ref conflicts (the cross-repo fetch into $branch and the branch --force step) render a raw refs/heads/... path in the new message.

SUGGESTION

File Line Issue
packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt 581 Exact slug equality blocks imports that still work through GitHub's rename/transfer redirect when the local origin URL is stale.
packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt 6 REPO_URL is unanchored and used with find, so a non-GitHub host containing github.com parses as a slug, while the port form parses as null.
packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt 346 The dialog guard is inert until the Agent Manager tab has been opened once, because origin is only populated by reload()/refresh().
packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt 1027 The case-only-slug test falls through the guard into real gh and git fetch against github.com, so its result depends on the environment.
Files Reviewed (12 files)
  • .changeset/jetbrains-pr-import-errors.md
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt - 2 issues
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt - 1 issue
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt - 1 issue
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt
  • packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt - 1 issue
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt

Fix these issues in Kilo Cloud

Previous review (commit 6624db6)

Status: No Issues Found | Recommendation: Merge

Incremental review of 1459d814d0..6624db6e50 (one commit, fix(vscode): stop routing a PR error that belongs to another project). The sole prior finding is resolved: reportFailure now answers "stale" | undefined, and AgentManagerApp.tsx:1626 returns on "stale", so another project's agentManager.prError is no longer routed into projectLive.apply for the project on screen. The new failure-toast.test.ts exercises all four paths against the real helper.

Files Reviewed (3 files)
  • packages/kilo-vscode/webview-ui/agent-manager/failure-toast.ts
  • packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
  • packages/kilo-vscode/tests/unit/failure-toast.test.ts

Previous review (commit 1459d81)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

Fix these issues in Kilo Cloud

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx 1626 Carried forward and re-verified at 1459d814d0: reportFailure's boolean result is ignored by its only caller, so the early return that used to skip projectLive.apply(msg) for another project's agentManager.prError is now implicit rather than enforced.
Files Reviewed (8 files)
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt
  • packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
  • packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts
  • packages/kilo-vscode/src/agent-manager/worktree-doctor.ts
  • packages/kilo-vscode/tests/unit/git-stats-poller.test.ts
  • packages/kilo-vscode/tests/unit/worktree-diagnostics.test.ts

Incremental review of 9e6bdf20..1459d814d0 (one commit, fix(agent-manager): make a parked worktree recoverable and bound the poll prune). No new issues in the changed code. Both prior suggestions this commit targeted are resolved: GitStatsPoller now exposes revive(id)/paused() and RecoveryHost.refresh calls revive, so a stats-parked worktree has a real recovery path and the diagnostics report can name it (deduped across loops in collect); and the poll-driven JetBrains worktree prune moved to GIT_PRUNE_TIMEOUT_MS (5 s, the metadata probe budget) so the repository mutation mutex is held for at most that long. The DoctorHost.out rewrite preserves the previous append-then-reveal behaviour and this.outputChannel satisfies the new shape. The one remaining suggestion is on a file this increment does not touch and was re-verified at HEAD as still unresolved.

Previous review (commit 9e6bdf2)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 3

Fix these issues in Kilo Cloud

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx 1626 reportFailure's boolean result is ignored by its only caller, and the early return that skipped projectLive.apply for another project's agentManager.prError was dropped in the move.
packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts 237 The documented explicit-refresh bypass of the stats quarantine still has no production caller, so a parked worktree keeps stale values until the window elapses and diagnostics cannot explain it.
packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt 276 The poll-driven worktree prune still holds the repository mutation mutex on the 180 s write budget, so a wedged prune can block user-initiated create/import/remove.
Files Reviewed (4 files)
  • packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx - 1 issue
  • packages/kilo-vscode/webview-ui/agent-manager/failure-toast.ts
  • packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts - re-verified, 1 issue
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt - re-verified, 1 issue

Incremental review of 8100652397..9e6bdf20. This increment is a merge of origin/main plus one refactor commit (refactor(vscode): move host failure toasts out of AgentManagerApp) that moves the two failure-toast branches into failure-toast.ts to stay under the file's line cap. The merged tree has no conflict markers, and the branch's existing worktree-health changes are intact after the merge. The refactor preserves behavior for both the PR-error and host-error paths; the only new note is the unused return contract. The two remaining suggestions are on files this increment does not touch and were re-verified against current HEAD as still unresolved. Existing PR-error classification, quarantine isolation, reconcile, and i18n findings from the prior review remain addressed.

Previous review (commit 8100652)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2

Fix these issues in Kilo Cloud

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts 237 The documented explicit-refresh bypass of the new stats quarantine has no production caller, so a parked worktree keeps stale values until the window elapses and diagnostics cannot explain it.
packages/kilo-jetbrains/backend/.../rpc/KiloWorktreeRpcApiImpl.kt 276 The poll-driven worktree prune holds the repository mutation mutex on the 180 s write budget, so a wedged prune can block user-initiated create/import/remove for three minutes.

Incremental review of aabe2191c9..8100652397. The commit closes the gaps the cross-cutting review raised, and both remaining findings are suggestions. Verified on the changed lines: the JetBrains sync prune now contends on the same mutex lock() uses and its key matches mainWorktree(); status is off the probe budget; the batched seed path skips unhealthy/parked worktrees and classifies a batch timeout before retrying; pathKey is used for the worktree-path comparisons and placement; the orphan re-check reports nothing on an unanswerable listing; and the { path, kind } orphan payload is consistent from healthPayload through the store, OrphanNotice, and both 21-language dictionaries (checkout/confirmCheckout include {{count}} in every language).

Files Reviewed (52 files)
  • .changeset/worktree-health-recovery.md
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt - 1 issue
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt
  • packages/kilo-vscode/src/agent-manager/{AgentManagerProvider,GitStatsPoller,PRStatusPoller,WorktreeManager,WorktreeStateManager}.ts - 1 issue (GitStatsPoller.ts)
  • packages/kilo-vscode/src/agent-manager/pr/am-pr-seed.ts
  • packages/kilo-vscode/src/agent-manager/{provider-lifecycle,quarantine,types,worktree-health,worktree-reconcile,worktree-recovery}.ts
  • packages/kilo-vscode/tests/unit/* (10 files)
  • packages/kilo-vscode/webview-ui/agent-manager/{OrphanNotice,ProjectSidebarBody,SidebarBody}.tsx
  • packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css, project/store.ts
  • packages/kilo-vscode/webview-ui/agent-manager/i18n/* (21 dictionaries)
  • packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts

Previous review (commit aabe219)

Status: No Issues Found | Recommendation: Merge

Incremental review of 23bc3858f6..aabe2191c9. The new badDir guard in richRefusal keeps a worktree deleted mid-poll from latching the gh field downgrade for the whole backend, and the added tests cover both latch call sites (query()/comments()). No new issues.

Files Reviewed (3 files)
  • .changeset/worktree-health-recovery.md
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt

Previous review (commit 23bc385)

Status: No Issues Found | Recommendation: Merge

The previous stats/dirty unavailable-on-RPC-failure warning is resolved by this commit, and no new issues were introduced in the incremental diff.

Files Reviewed (3 files)
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt

Previous review (commit 4075601)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0

Fix these issues in Kilo Cloud

Issue Details (click to expand)

WARNING

File Line Issue
packages/kilo-jetbrains/frontend/.../worktree/WorktreeStatusService.kt 307, 325 New drop = !dto.unavailable clears every stats/dirty badge on an RPC failure, because KiloWorktreeService.stats/dirty swallow the exception and return unavailable = false.
Files Reviewed (51 files)
  • packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
  • packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
  • packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
  • packages/kilo-vscode/src/agent-manager/git-import.ts
  • packages/kilo-vscode/src/agent-manager/project/pollers.ts
  • packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts
  • packages/kilo-vscode/src/agent-manager/worktree-create.ts
  • packages/kilo-vscode/src/agent-manager/worktree-health.ts
  • packages/kilo-vscode/src/agent-manager/worktree-importer.ts
  • packages/kilo-vscode/src/agent-manager/worktree-reconcile.ts
  • packages/kilo-vscode/src/extension.ts
  • packages/kilo-vscode/webview-ui/agent-manager/{ProjectSidebarBody,SidebarBody,WorktreeItem}.tsx, agent-manager.css
  • packages/kilo-vscode/webview-ui/agent-manager/i18n/*.ts (21 dictionaries, key parity verified)
  • packages/kilo-jetbrains/backend/.../diff/GitComparison.kt
  • packages/kilo-jetbrains/backend/.../rpc/KiloWorktreeRpcApiImpl.kt
  • packages/kilo-jetbrains/backend/.../rpc/PrResolver.kt
  • packages/kilo-jetbrains/frontend/.../worktree/GhStatusCoordinator.kt
  • packages/kilo-jetbrains/frontend/.../worktree/WorktreeDiagnosticsAction.kt
  • packages/kilo-jetbrains/frontend/.../worktree/WorktreeStatusService.kt - 1 issue
  • packages/kilo-jetbrains/shared/.../rpc/dto/WorktreeDto.kt
  • packages/kilo-vscode/tests/unit/* (5 files) and packages/kilo-jetbrains/**/test/**/* - tests

Note: this incremental pass is against 4075601209 (previous review at 91040bcff4). All 17 findings from the previous summary were re-verified as fixed in 100914908f; they are no longer listed.

Previous review (commit 91040bc)

Status: 17 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 13
SUGGESTION 3

Fix these issues in Kilo Cloud

Issue Details (click to expand)

CRITICAL

File Line Issue
packages/kilo-vscode/src/agent-manager/WorktreeManager.ts 733 removeOrphanDirectory fails open: a failed git worktree list leaves the live-worktree guard falsy, then recursively deletes the directory.

WARNING

File Line Issue
packages/kilo-vscode/src/agent-manager/worktree-health.ts 52 entries.some(syncBranch) short-circuits, so only one drifted branch syncs per presence tick.
packages/kilo-vscode/src/agent-manager/worktree-reconcile.ts 130 Session count captured before prune/drop can be stale, dropping freshly attached sessions.
packages/kilo-vscode/src/agent-manager/project/pollers.ts 56 unavailable is skipped like a hard failure and is never re-reconciled, disabling polling for the session.
packages/kilo-vscode/src/agent-manager/git-import.ts 202 "timed out" text never appears for the git --version probe, so git_timeout is unreachable.
packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts 536 A timed-out lookup becomes null and is reported/reset as "no PR", bypassing quarantine.
packages/kilo-jetbrains/.../worktree/WorktreeStatusService.kt 53 New statsJob/dirtyJob guards are never cancelled in stop(), leaving polls stuck past detach.
packages/kilo-jetbrains/.../worktree/WorktreeStatusService.kt 300 merge clears all badges when the backend returns an empty list after a failed git worktree list.
packages/kilo-jetbrains/.../worktree/GhStatusCoordinator.kt

[Snapshot truncated.]

Additional previous summary content was truncated to keep this comment within platform limits.


Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

Follow-up to the worktree-health PR, fixing the cases where an
indeterminate answer was still treated as a definite one.

- `removeOrphanDirectory` failed open: an unanswerable `git worktree
  list` skipped the live-worktree guard and recursively deleted the
  path. It now refuses.
- `applyPresence` used `some` to sync branches, so only the first
  drifted worktree was synced per presence tick.
- The reconcile drop loop re-reads the session count: `prune` is awaited
  after classification, and a session attached in that window would have
  been deleted with the row.
- `unavailable` is no longer treated as broken. It paused stats/PR
  polling for the rest of the session, rendered as a warning badge, and
  offered the entry-dropping removal actions for a worktree that is
  probably fine. A presence probe now also re-schedules a reconcile
  while the last report is degraded.
- Git and gh timeouts are classified from the killed/signal shape rather
  than from message text, so a watchdog kill no longer surfaces as a raw
  "Command failed" string.
- A PR strategy ladder that only timed out now reports the failure
  instead of falling through to "no PR", which cleared the quarantine
  and rendered a wedged `gh` as a clean worktree.
- The PR loop counts rejections that are not attributable to already
  quarantined worktrees, instead of comparing two unrelated totals.
- JetBrains: `stop()` cancels the stats/dirty jobs, an empty list from a
  failed `git worktree list` no longer clears every badge, a TIMEOUT no
  longer consumes the one-shot gh notification, the diagnostics
  clipboard write moved to the EDT, `git fetch`/`worktree add` got their
  own wider budget, and a timed-out review-thread query no longer reads
  as "every conversation settled".
- Added the three missing `agentManager.setup.error.*` keys in all 21
  dictionaries; they were rendering as literal keys.
CI's Linux (ext4) `fs.readdir` order is not alphabetical the way it
tends to be on macOS (APFS), so `reconcileWorktrees`' orphan list came
back in a different order than the "reports untracked directories as
orphans without deleting them" test expected, failing only on Linux CI.

Sort worktreeDirs() so both the orphan report and the deterministic
slug-suffix scan get a stable, environment-independent order.
KiloWorktreeService.stats/dirty caught every exception and returned a
plain WorktreeStatsListDto()/WorktreeDirtyListDto(), whose unavailable
default is false. A thrown RPC failure (or a stop()-triggered
CancellationException, unlike open()) therefore looked identical to a
successful "no worktrees" answer one layer up, so
WorktreeStatusService.merge's drop=!dto.unavailable cleared every
stats/dirty badge on exactly the failure this flag was added to guard
against.

Rethrow CancellationException before the generic catch, and report
unavailable=true from it, matching the pattern open() already uses.

Covered by "an RPC exception keeps every badge instead of clearing
them", which drives a thrown FakeWorktreeRpcApi.stats/dirty through
the real KiloWorktreeService catch path and WorktreeStatusService.merge.
@marius-kilocode

Copy link
Copy Markdown
Collaborator

@kilocode-bot can you investigate with multiple subagents all previous changes that where recently done to gh, worktree, staleness and worktree pooling. Check if any of those changes conflict or introduce bugs in combination with this change.

@kilo-code-bot

kilo-code-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Cross-cutting review: this PR against recent worktree / gh / pooling work

Investigation only, no code changes. I read the full diff of this PR on both clients, then walked the last six weeks of history for adjacent work (gh usage, worktree creation/removal/pooling, staleness, backoff, polling) and looked for double-handling, conflicting backoff strategies, paths still assuming the old semantics, and cross-client divergence.

Related recent changes

  • 7a6ab9a "pre-warm worktrees" and b5ab644 "keep pool warm-up off the git lock" -- src/agent-manager/worktree-pool.ts, not touched by this PR. It creates, retargets and removes real git worktrees under .kilo/worktrees/ that have no Agent Manager state row, which overlaps this reconcile and orphan logic directly.
  • 81b9c21 "batch PR status lookups into one GraphQL request per sync" -- src/agent-manager/pr/am-pr-seed.ts, pr/am-pr-batch.ts, PRStatusPoller.fetchAll. Full syncs no longer use the per-worktree ladder this PR rewrites.
  • f99f82a "keep a rejected branch lookup from aborting a full sync" -- pr/am-pr-seed.ts, same function this PR rewrites.
  • c1195f0 "do not inherit a merged PR on a reused branch name", merged after this branch was cut -- pr/am-pr-utils.ts and PRStatusPoller.claimed, adds git calls inside the path this PR re-budgets.
  • 743fafc and b521829 "reduce background Git/GitHub churn" -- the loop-wide backoff that the new per-worktree quarantine now sits beside.
  • JetBrains: 3d7d5f0 "prune only stale kilo managed worktrees", 66e6871 "skip probes for removed worktrees", fc8077b "report a spent GitHub budget instead of blanking the badges", b803cda "keep the review conversation count through a refused lookup", 23007e1 "keep PR lookups to one at a time". This PR generalises all of them, and weakens a couple of their invariants.

Findings

F1 -- High -- the batched PR path bypasses everything this PR adds. Since 81b9c21 a full sync resolves every worktree through seed() in pr/am-pr-seed.ts, and fetchOne only falls back to fetchPRForBranch for worktrees the batch left unresolved. That batch path uses its own budget of 20000 ms (PRStatusPoller.host(), "gh: (args, cwd) =: this.gh(args, { cwd, timeout: 20_000 })"), contradicting BUDGET.gh = 10000 in command-budget.ts. It never classifies a timeout (isTimeout is not used) and never records a quarantine failure, because chunk() at am-pr-seed.ts:105-113 logs and returns and the fetchAll .catch swallows the rest. It also ignores isUnhealthy and quarantine.blocked entirely: one() at am-pr-seed.ts:71-85 gates only on existsSync(wt.path), so an unregistered or quarantined worktree still gets a "git rev-parse HEAD", is still put into the chunk, and items[0].cwd (possibly that same worktree) becomes the cwd of the whole "gh api graphql" call. Net effect: the headline symptom, a hanging gh costing a full timeout on every poll with no explanation, is still reachable on what is now the primary code path. The hardened ladder only runs as a fallback.

F2 -- High -- the diff targets a pre-batch fetchAll and needs a rebase. The hunk replacing "const ok = results.every(...)" sits in a function that has since gained the seed step. The rewrite itself survives a rebase (index alignment holds, settled() at PRStatusPoller.ts:861-877 writes results[i] by input index), but please re-check one interaction: empty() now calls quarantine.clear(worktreeId), and a batch-derived "seeds.set(item.id, null)" at am-pr-seed.ts:129 reaches empty(), so a batch answer clears a quarantine the fallback ladder deliberately keeps armed.

F3 -- Medium -- reconcile can report live pool slots as orphaned directories. reconcileWorktrees takes its registered() snapshot first, then performs per-row exists/branchExists awaits, and only afterwards lists dirs(). Neither call holds the git lock. WorktreePool.fill/build (worktree-pool.ts:135-174) creates new slot directories under the same .kilo/worktrees/ while holding that lock, and WorktreeManager re-warms on a timer after every claim (WorktreeManager.ts:291). A slot created inside that window is not claimed, is absent from the stale registered set, and has a .git file, so it is classified as an orphan of kind "broken", shown in OrphanNotice, and counted in the new "Agent Manager Worktree Health" telemetry. Clicking Clean up leftover folders then fails with "Refusing to remove a live worktree" from removeOrphanDirectory. The fail-closed re-check does its job so nothing is lost, but the user sees a phantom problem and an error. This is not only a startup race: reconcileProject also runs from the presence-driven HealthScheduler and after every recovery action, concurrently with pool warm-ups. Fixes to consider: list dirs() before registered(), re-read registration for candidate orphans only, or skip directories whose pool metadata says pooled (worktreeInfo already reads that metadata).

F4 -- Medium -- recovery does not clear the PR quarantine. restoreWorktree in worktree-recovery.ts clears ctx.stale and re-reconciles, but nothing clears the PRStatusPoller quarantine for that id, so after a successful restore the worktree can stay parked for up to QUARANTINE_MAX (30 minutes) even though the user just fixed it. That is the same "badges stop updating and never recover" experience this PR targets. PRStatusPoller.refresh(id) already clears the quarantine, so call it (and invalidate the stats cache) from the recovery paths.

F5 -- Medium -- the orphan cleanup wording is wrong for "broken" orphans, and there is now a two-click path to deleting real files. reconcile distinguishes "broken" (still has a .git file, an ex-worktree that can hold uncommitted work) from "leftover" (a bare directory), but healthPayload sends only orphans.map(o =: o.path) and agentManager.orphans.confirm reads "Delete these folders permanently? Nothing here is tracked by git." For a broken orphan that is not true. Combined with the new flow (unregistered row, then Remove keep sessions, which turns the directory into an orphan, then Clean up leftover folders) this is two clicks from permanently deleting a checkout with uncommitted changes. Recommend sending kind, listing broken entries separately with different wording, or excluding them from bulk cleanup.

F6 -- Medium -- the new removal action for unregistered can dead-end. removeStaleLifecycleWorktree now accepts a worktree because the health report says it is broken, which includes unregistered, a state where the directory still exists. The untouched PTY cleanup failure branch at provider-lifecycle.ts:362-377 only bypasses cleanup when lstat reports ENOENT, so for an existing directory it posts "Failed to stop terminals before removing the stale worktree" and returns. If acquirePtyCleanup throws for an unregistered worktree, the only action the UI offers fails with a message unrelated to the actual problem.

F7 -- Medium -- JetBrains: the poll-driven "git worktree prune" is not under the per-repo mutation lock, and this PR widens its blast radius. reconcile() now backs list() as well as stats() and dirty(), and it runs "git worktree prune -v" whenever staleWorktrees is non-empty. create, import, remove, rename and adopt all serialise through lock(base, op) at KiloWorktreeRpcApiImpl.kt:451-463, reconcile does not. staleWorktrees excludes trash-doomed paths but not a worktree mid "git worktree add", whose registration exists before the checkout completes, so the most frequent poll can now prune an in-flight add. The VS Code side deliberately wraps its prune in withGitLock (WorktreeManager.pruneWorktrees), so the two clients now disagree on the safety rule. Recommend taking the same mutex, or skipping the prune while it is held.

F8 -- Medium -- JetBrains: "git status" was moved onto the 5s probe budget. PROBES in GitComparison.kt includes "status", so the working-tree scan behind dirty() drops from 30s to GIT_PROBE_TIMEOUT_MS. A status is a content scan, not a metadata query, so on large or cold worktrees this converts previously successful measurements into unavailable rows. The same list includes "worktree", so the reconcile prune runs on 5s even though the GIT_WRITE_TIMEOUT_MS docstring claims worktree prune uses the write budget.

F9 -- Low/Medium -- VS Code paths still on the old semantics, in files the PR did not update. WorktreeManager.fetchPRInfo (WorktreeManager.ts:1284-1307) keeps a hardcoded 30000 gh timeout and calls classifyPRError(msg) with no isTimeout, so a timed-out PR import is still reported as an unexplained failure, while JetBrains got an explicit GhAvailability.TIMEOUT guard for the equivalent importPr flow in this same PR. WorktreeManager.hasWork (WorktreeManager.ts:327-331) still fails safe to "no work" on any git error, the same "a failure reported as a fact about something else" pattern removed elsewhere (impact limited to auto branch naming). prepareWorktreePath (WorktreeManager.ts:519-528) and WorktreeStateManager.findWorktreeByPath (161-163) still compare with the lexical normalizePath rather than the new pathKey, so the macOS symlink and case-folding class of bug fixed in reconcile survives in those comparisons.

F10 -- Low -- Quarantine.blocked() mutates, and a diagnostics run consumes the retry token. blocked() sets entry.until = 0 once the window has elapsed, PRStatusPoller.paused() calls it for every worktree, and paused() is what the new diagnostics command reads, so generating a report silently releases expired quarantines. Harmless today, but a report-generating getter should not have side effects. Consider a non-mutating peek().

F11 -- Low -- cross-client state semantics still differ. The description says both clients implement the same states, the same wording and the same recovery actions. On JetBrains there is no absent-restorable, absent-gone or unregistered classification, no Restore, no Remove keep sessions, and orphans are report-only, while rows whose directory is missing are still silently dropped by live(managedWorktrees(all).filter { Files.isDirectory(...) }). Only unavailable plus reason is genuinely shared. Suggest softening the claim and tracking the JetBrains gap as a follow-up.

F12 -- Low -- failure containment is only half implemented on the VS Code stats path. Quarantine is wired into PRStatusPoller only. GitStatsPoller gained the isUnhealthy filter, but unavailable is (correctly) excluded from broken, so an unmeasurable worktree keeps getting a full status plus diff fan-out every cycle on the new shorter budgets, and stats failures still fall back to prior with no per-worktree backoff (GitStatsPoller.ts:264-266).

Worth saying explicitly: removeOrphanDirectory failing closed on an unanswerable worktree list, the re-read of the session count before drop, pathKey canonicalisation, formatLog, and the unavailable-is-not-broken distinction are all the right calls, and the reconcile and pool logic is safe in steady state. The findings above are windows and adjacent paths, not the core design.

Recommendation

  • Before merge: F1, F2 (rebase onto the batched-seed poller and extend budget, timeout classification, quarantine and unhealthy filtering into am-pr-seed.ts), F3, F4, F5, F7.
  • Follow-up: F6, F8 (cheap enough to fold in), F9, F10, F11, F12.

Built for marius-kilocode by Kilo

@kilo-code-bot

kilo-code-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Investigation complete — full findings are posted here: #14137 (comment)

Key points for @marius-kilocode:

  • F1/F2 (High): This PR hardens the per-worktree gh pr view fallback ladder, but 81b9c21c24 made batched GraphQL (fetchAll/am-pr-seed.ts) the primary path. That primary path still has its own 20s budget, no isTimeout, no quarantine recording, no isUnhealthy/quarantine.blocked checks — so the "hanging gh silently eats a full timeout every poll" bug this PR targets is still reachable via the now-dominant code path.
  • F3 (Medium): worktree-pool.ts (untouched) creates real worktrees in the same directory the new reconciler scans, with a race between snapshotting registered() and listing dirs() without the git lock — can produce phantom "broken orphan" reports for a slot the pool just created. Fail-closed re-check prevents data loss but users see a bogus error.
  • F5 (Medium): webview drops the broken vs leftover distinction; the "Nothing here is tracked by git" confirmation text is false for broken orphans, making it too easy to delete a checkout with uncommitted work in two clicks.
  • F7/F8/F11: JetBrains reconcile runs git worktree prune without the mutation mutex VS Code uses (withGitLock), moved git status to a 5s probe budget, and doesn't implement the five health states/restore/orphan cleanup — so parity claims don't hold yet.

Recommendation: address F1/F2 before merge (it's the exact bug this PR is meant to fix, just on the other code path); F3/F5 should at least get a follow-up issue; F7/F8/F11 are tracked as JetBrains gaps. Full report has file/line refs and all 12 findings.

…tree

A worktree deleted while a status poll is in flight fails before gh runs:
the platform reports `Cannot start a process, the working directory '...'
does not exist`. That message is captured as the command's stderr, and
richRefusal() matched its bare "does not exist" as a rejected --json field
name, so PrResolver latched `rich = false` (and, one call later, `threads
= false`) for the life of the backend.

One resolver serves every checkout, so a single deleted worktree stripped
reviewDecision, statusCheckRollup, mergeable, and the review-conversation
count from every row in the IDE. Rows kept their PR number and silently
lost their approved, checks, and conversation badges until the IDE was
restarted — observed in the wild for 17h, across an approval that never
appeared.

badDir() already recognises both shapes of this race, so richRefusal()
consults it before classifying anything: a directory that vanished says
nothing about what this gh can answer.
Twelve findings from a review of this PR against the recent gh, worktree,
staleness, and pooling work. The theme is that the hardening landed on the
paths this PR touched, while adjacent paths kept the old semantics.

VS Code:

- The batched GraphQL lookup became the primary PR path after 81b9c21,
  and it bypassed everything this PR added: a 20s budget instead of
  BUDGET.gh, no timeout classification, and no quarantine or health gate,
  so a parked worktree still cost a git process per full sync and could
  become the working directory of the whole `gh api graphql` call. It now
  runs on the shared budgets, skips parked worktrees, and reports a
  timeout as a timeout instead of retrying it as an unsupported field.
- Orphan reporting re-checks git registration before naming a directory,
  so a worktree the pool created mid-pass is no longer offered for
  deletion, and an unanswerable re-check reports nothing.
- Restoring a worktree clears its PR polling backoff, instead of leaving
  the worktree the user just repaired parked for up to half an hour.
- The orphan kind reaches the webview: a directory that still holds a
  checkout is marked and gets a confirmation that says it may hold
  uncommitted work, rather than "nothing here is tracked by git".
- Removing a stale entry no longer dead-ends when terminals cannot be
  stopped. Nothing on that path deletes files, so for an unregistered
  worktree the only action offered used to fail with an unrelated error.
- PR import runs gh on BUDGET.gh and reports a hang as a timeout.
- findWorktreeByPath and prepareWorktreePath compare with pathKey, so a
  symlinked parent or a case variant cannot hide a registration.
- Quarantine gained a non-mutating peek, so generating a diagnostics
  report no longer releases the quarantines it is reporting.
- The stats loop gained the same per-worktree backoff as the PR loop; an
  explicit refresh still measures a parked worktree.

JetBrains:

- A poll prunes stale worktree metadata only when no mutation holds the
  repository lock, and never waits for it. `git worktree add` registers a
  worktree before its checkout is written, so pruning underneath one
  could delete the metadata of a worktree being created.
- `git status` is off the 5s probe budget: it scans the working tree, so
  on a large or cold worktree the probe budget reported unavailable for a
  measurement that would have succeeded. The sync prune uses the write
  budget its docstring already claimed.
@kirillk

kirillk commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all twelve findings in 8100652397. Thanks — F1 was the right call to flag as blocking: the hardening in this PR landed on the ladder, and the ladder stopped being the primary path.

Checks: bun run typecheck, bun run lint, bun run knip, 5737 unit tests pass; ./gradlew typecheck test BUILD SUCCESSFUL (132 in KiloWorktreeRpcApiImplTest).

F1 — the batched path bypassed everything

SeedHost gained skip(id), wired to isUnhealthy(id) || quarantine.peek(id), and one() returns early for it — so a parked worktree costs no git rev-parse, never enters the query, and can never become items[0].cwd for the whole gh api graphql call. host() now passes BUDGET.gh / BUDGET.probe instead of 20s/5s literals: a batch is one request for up to CHUNK worktrees, so a wider budget only buys a longer stall before the fallback. chunk() tests isTimeout(err) before the field-degrade branch and returns instead of retrying — a killed process can still have flushed partial output, and retrying spends a second full budget on a command that already proved it does not answer.

Covered by "leaves a parked worktree out of the batch entirely" (asserts no git call in the parked directory and that the gh cwd is the healthy one) and "does not retry the batch after a timeout, even when the output reads like a refused field".

F2 — rebase and the empty() interaction

No rebase needed: 81b9c21c24 is already an ancestor of this branch, so the hunk was written against the seeded fetchAll (PRStatusPoller.ts:314), and settled() writes by input index as you noted. On the empty() interaction — with F1 in place a blocked worktree no longer receives a batch answer at all, so seeds.set(id, null) cannot reach empty() for it and cannot clear a quarantine the ladder is keeping armed. For an unparked worktree, a definitive batch "no PR" is a successful lookup, so clearing there is correct.

c1195f0815 is not in this branch. It touches pr/am-pr-utils.ts and claimed, which this PR does not, so I left the merge out of this commit rather than mixing it in.

F3 — pool slots reported as orphans

The orphan pass now collects candidates and re-reads deps.registered() before classifying any of them, so a directory git registered during the pass (pool build() lands mid-reconcile) is not reported. One extra worktree list only when there is at least one candidate. An unanswerable re-check reports no orphans — "we could not check" is not "nothing owns it".

I did not gate on pool metadata: the pool writes pooled: true into the worktree's git dir, which is exactly what an orphan candidate no longer has, so the registration re-read is the check that actually holds. Covered by "does not report a worktree that git registered during the pass" (real git worktree created from inside dirs()) and "reports no orphans when the re-check cannot answer".

F4 — recovery did not clear the quarantine

RecoveryHost gained refresh(worktreeId), called by restoreWorktree after the reconcile and wired to prBridge.poller.refresh(id, true). Covered by "recreates the directory and drops the polling backoff it earned while broken".

The stats side needs nothing: an absent worktree is filtered out by the presence probe before it can ever be measured, so a restore has no stats backoff to clear, and the explicit-refresh path (snapshot(true) → includeSkipped) bypasses the new backoff anyway. Both are documented at the filter.

F5 — wrong wording for broken orphans

orphanDirectories now carries { path, kind } end to end (healthPayload → StateMessage → store → OrphanNotice). A broken entry is marked in the list and styled as a warning, and when any is included the confirmation switches to agentManager.orphans.confirmCheckout: "…{{count}} still contain a git checkout that may hold uncommitted work." Two new keys, translated in all 21 dictionaries.

I kept broken orphans deletable rather than excluding them from the bulk action: excluding them would leave the only UI that can remove them unable to, which is the dead end F6 is about. What was wrong was the false reassurance, so that is what changed.

F6 — the unregistered removal dead-end

removeStaleLifecycleWorktree never deletes files, so a failed PTY cleanup is no longer a reason to refuse: it is logged and the row is dropped. For an unregistered worktree the directory still exists, the terminal keeps working, and the row is what the user asked to remove. The test asserting the old behavior was rewritten ("removes a stale entry whose terminals cannot be stopped, keeping its directory") — it asserted the bug.

F7 — JetBrains prune outside the mutation lock

The sync prune now runs through exclusive(mutex, op, path), which takes the repository's mutation mutex with tryLock and answers null when a mutation holds it. Not pruning is harmless — the entry is still stale next poll — while waiting would park every status poll behind a git worktree add budgeted in minutes. Keyed exactly like lock(), so polls and mutations contend on the same mutex. Covered by "a poll skips its prune while a mutation holds the repository lock" and "a poll still prunes stale metadata once the mutation lock is free".

F8 — status on the probe budget

Removed status from PROBES: it scans the working tree, so its cost follows the checkout, not .git, and the probe budget turned measurements that would have succeeded into unavailable rows. worktree stays (the list query is metadata); the sync prune now passes GIT_WRITE_TIMEOUT_MS explicitly, so the docstring's claim about worktree prune is true. Asserted in "git budgets separate cheap metadata from content reads".

F9 — VS Code paths still on old semantics

  • fetchPRInfo runs on BUDGET.gh and reports isTimeout as "GitHub CLI (gh) did not respond in time" instead of "Failed to fetch PR info". Covered by a new createFromPR test asserting both the message and the budget.
  • prepareWorktreePath and WorktreeStateManager.findWorktreeByPath compare with pathKey. Covered by "finds worktree through a symlinked parent and a case variant".
  • hasWork I left alone deliberately: its only consumer is auto branch naming, and "assume no work" is the conservative answer there — reporting a git failure as work would name branches after a failure. It logs the error, so it is diagnosable.

F10 — blocked() mutates

Added Quarantine.peek(), used by paused() (the diagnostics source) and by the failed accounting in fetchAll, which is also a read rather than a poll. blocked() keeps the half-open probe for the one caller that is about to poll. Covered by "reports a quarantine without spending the retry it allows".

F11 — the parity claim

Softened in the description: the clients share the honesty rules, the wording, and the diagnostics report; restore, "Remove, keep sessions", and orphan cleanup are VS Code only, and JetBrains reports orphans without offering removal. Tracking the JetBrains gap as a follow-up rather than growing this PR.

F12 — no backoff on the stats path

GitStatsPoller now has its own Quarantine: a status scan that keeps failing for a reason the reconcile cannot name (locked index, permissions, unmounted volume) parks that worktree instead of costing a full status plus diff fan-out every tick. Cleared by any success, reset on stop(), retained against live ids, and bypassed by an explicit refresh. Covered by "parks a worktree whose status keeps failing and keeps measuring the others" and "measures a parked worktree again on an explicit refresh".

# Conflicts:
#	packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx
The merge left AgentManagerApp.tsx 11 lines over its 2800-line lint cap:
main had grown it to 2799 and this branch adds the health props and the
error toast. Rather than raise the cap, both failure-reporting branches
move to failure-toast.ts — the PR error code and the plain error message
are the same concern, and neither needs anything from the component but
the toast, the translator, and the project on screen.
…poll prune

Two review findings on the previous commit.

The stats backoff had no way out that a user could reach: the bypass its
comment pointed at, `snapshot(true)`, is only called from tests, and the
recovery path cleared the PR quarantine alone. A worktree parked for a
reason the reconcile cannot name — locked index, permissions, unmounted
volume — therefore kept its previous badges until the half-open probe at
the end of a window that reaches 30 minutes, and the diagnostics report
could not say why, because it read only the PR loop. GitStatsPoller gained
`revive` (called by the recovery actions) and `paused`, and the report now
merges the parked ids from both loops.

The poll-driven `git worktree prune` ran on the 180s write budget while
holding the repository's mutation lock, so one wedged prune could park a
user-initiated create for three minutes. It only rewrites $GIT_DIR
bookkeeping, so it now runs on the probe budget: five seconds is the
longest a mutation can wait for a poll, and a prune that misses it is
skipped — the entry is still stale on the next poll.

Also folds the doctor's output-channel writes into the host object it is
already given, so the report path is one seam instead of a callback that
each caller reimplements.
@kirillk

kirillk commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Merged latest main (166 commits) and pushed. One conflict, in ProjectSidebarBody.tsx: main moved the sidebar section header into SectionHeader, this branch added the OrphanNotice and actionable imports — kept both changes, dropped the SidebarSectionHeader import as main did (the component is still used by SectionHeader, ProjectsSection, and SidebarBody).

The merge also put AgentManagerApp.tsx 11 lines over its 2800-line lint cap: main had grown it to 2799 and this branch adds the health props plus the error toast. Rather than raise the cap, both failure-reporting branches moved to failure-toast.ts — the PR error code and the plain error message are one concern, and neither needs anything from the component but the toast, the translator, and the project on screen.

Both review findings on the previous commit are addressed in 1459d814d0; replies are on their threads.

Post-merge verification: bun run typecheck, bun run lint, bun run knip, bun run format, 5824 unit tests pass; ./gradlew typecheck test BUILD SUCCESSFUL.

One note for whoever runs this locally: after the merge, :frontend:test failed with NoSuchMethodError on MdCodeBlockOptions.<init> until I forced :frontend:compileTestKotlin --rerun-tasks. Stale incremental test classes compiled against the pre-merge constructor — a local artifact, not a code problem, but :frontend:clean alone did not clear it.

The extraction dropped the early return the inline branch had: a
`agentManager.prError` for a background project used to stop there, and
after the move it fell through to `projectLive.apply(msg)`. Harmless only
because that router has no branch for the message today.

`reportFailure` now answers "stale" for it — the same contract
`routeReview` already uses in this handler — and the call site returns on
it, so the control flow is in the code rather than implied by a boolean
nobody read.
- Recover automatically when a stale remote-tracking ref blocks a pull
  request fetch (the exact directory/file ref conflict reported against
  PR imports): detect it, run `git remote prune origin` once, retry.
- Translate known fetch failures (timeout, ref conflict, missing remote
  ref) into actionable text instead of raw git stderr; unknown failures
  keep a short diagnostic snippet while full stderr stays in the log.
- Reject a pull request URL that belongs to a different repository
  before spawning gh, both inline in the New Worktree dialog and in the
  backend import guard, naming both repositories in the message.
- listBranches now reports the checkout's origin owner/repo so the
  dialog can validate without an extra RPC round trip.
Group the worktree row menu into: rename; open in browser; diff
actions (show diff against base, show uncommitted changes); copy
actions (branch name, branch path, PR reference); setup script
actions; delete. Moves Copy PR Reference out of the diff group and
into the copy group, and shortens several labels (Show Diff Against
Base, Show Uncommitted Changes, Show Worktree Setup, Copy PR
Reference).
- Strip refs/heads/ as well as refs/remotes/origin/ when naming the
  branch that blocks a fetch: the cross-repo pull-ref fetch and the
  closing `branch --force` both write refs/heads/, so those conflicts
  were rendered as raw ref paths.
- Require a host boundary before github.com so a host that merely ends
  in it (notgithub.com) is no longer parsed as GitHub, and accept the
  ssh://…:22/ port form that previously returned null.
- Resolve the origin slug when the worktree controller is created, not
  only on reload, so the dialog's cross-repo check is armed on the
  chat-dock and worktree-editor entry paths too.
- Name the origin remote as a possible cause in the cross-repo message,
  since the comparison is by remote URL and an origin left stale by a
  GitHub rename still reaches the same repository.
- Replace the case-insensitivity test that fell through to real gh and
  `git fetch` calls with hermetic coverage of the comparison itself.
The prune recovery matched only git's "exists; cannot create" detail
line, which the Linux CI image does not print: the fetch reported just
the summary naming `git remote prune` as the fix, so the conflict went
undetected and the retry never ran. Match that summary too, and fall
back to unnamed wording when git did not name the blocking ref instead
of reporting the imported branch as its own blocker.

Also allow github.com subdomains when parsing remotes: requiring a
scheme or userinfo boundary dropped www.github.com, and a null slug
reads as "cannot tell", which silently disabled the cross-repo guard
for such a checkout.

Splits the origin-slug controller test so reload()'s own assignment
stays covered now that construction also resolves it.
The subdomain dot was unanchored, so a path segment ending in
.github.com matched too: parseRepoSlug("https://gitlab.com/team/
x.github.com/owner/repo.git") returned owner/repo. That turns the
cross-repo guard's "cannot tell, skip" into an active comparison
against a slug the checkout never published. Allow subdomain labels
only between a scheme/userinfo boundary and the host.
@kirillk
kirillk merged commit 742c32d into main Sep 15, 2026
27 checks passed
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.

2 participants