[Perf] Agents Window hangs for 12s when switching sessions
- Dominant language
- TypeScript
- Stars
- 193k
- Forks
- 42.4k
- PR merge metrics
- PR metrics pending
Description
## TL;DR
Switching sessions froze the renderer because three problems compounded:
1. The outgoing archived/unarchived session had a partially deleted worktree. Git reported 6,613 tracked files as deleted, so the session exposed a 6,615-file Changes editor.
2. The agent-feedback overlay inspected the multi-diff one original/modified URI at a time. This happens even when the session has no feedback; no feedback is the worst case because the search does not stop early.
3. Each candidate eventually called the innocuous-looking `ISessionsManagementService.getSession()`. That method rebuilds every provider's session catalog and then performs a linear URI-identity search. Calling it thousands of times kept the renderer main thread busy for 6.24 seconds.
There are likely separate fixes here: harden archived worktree recreation, deduplicate session-level feedback work across multi-diff resources, and reconsider the complexity/contract of `getSession()`.
## User-visible symptom
In the Agents Window:
1. A large existing session was open.
2. I selected New Session.
3. The window stopped responding for several seconds.
4. The new-session page eventually appeared.
A DevTools performance trace captured a 6.24 second renderer-main-thread task rooted in the mouse-release handler.
(This is a different attempt, the trace analyzed by the agent is 200 MB, but it's the exact same scenario/issue)
[Trace-20260825T182648.json.zip](https://github.com/user-attachments/files/31444330/Trace-20260825T182648.json.zip)
## Full report
### Session involved
The outgoing Agent Host session was:
- Title: `Trace session modified time`
- Session ID: `9f4d6732-44b0-4187-888e-f16c6529e2b5`
- Branch: `roblou/agents/fix-session-modified-time-issue`
- PR: https://github.com/microsoft/vscode/pull/331728
- Restored turns: 18
- Working directory: `vscode.worktrees/fix-session-modified-time-issue`
The Agent Host summary reported:
```text
files: 6615
additions: 18
deletions: 2344462
uncommitted: 6613
```
During the long task, the client switched Changes editors and unsubscribed the outgoing session's branch changeset plus approximately 16 turn changesets. The new draft was initialized only after the long task completed.
### Why the session showed thousands of deleted files
The worktree is currently in a partially deleted state:
- Git still registers it as a worktree.
- The directory and `.git` file still exist.
- `git status --porcelain` reports exactly 6,613 tracked deletions.
- That exactly matches the `uncommittedChanges: 6613` value published in the Agent Host summary.
The likely lifecycle is:
1. Archiving dispatched `session/isArchivedChanged`.
2. `AgentSideEffects` called `cleanupWorktreeOnArchive`.
3. Cleanup committed pending work and called `git worktree remove`.
4. Worktree removal partially removed the checkout but did not leave it in a healthy state.
5. Unarchive later called `recreateWorktreeOnUnarchive`.
6. Recreation checked only whether the worktree path existed. Because a partial directory remained, it returned without recreating the checkout.
7. Subsequent Git status/diff computation treated every missing tracked file as a deletion and published that as the session's changes.
This failure mode is consistent with the existing `AgentHostGitService.removeWorktree` documentation: concurrent Git/status/diff activity can allow `git worktree remove` to delete checkout contents and then fail to remove the worktree admin directory because of an `index.lock` or "Directory not empty" race.
The original archive-time log is no longer available, so the exact failing Git command is inferred rather than directly observed. The current registered-but-partially-deleted worktree and exact 6,613-count match are confirmed.
Potentially related:
- https://github.com/microsoft/vscode/issues/329776
- https://github.com/microsoft/vscode/issues/332442
### Feedback comments are not required
I found no evidence that this session had an Agent Feedback annotation loaded during the transition.
The performance problem occurs without a comment:
1. `getActiveResourceCandidates()` returns original and modified URIs for each multi-diff item.
2. The overlay asks `getFeedbackSessionResource(candidate)` for each URI.
3. It then calls `getFeedback(sessionResource)` and builds session comments.
4. If comments are found, the loop breaks.
5. If no comments are found, it scans the entire candidate list.
Therefore no feedback is the worst case. A comment found for the session can allow the existing loop to stop after the first matching session candidate.
### Why `getSession()` was expensive
`ISessionsManagementService.getSession(resource)` sounds like a map lookup, but currently it:
1. Calls `_getMergedSessions()`.
2. Calls every registered provider's `getSessions()`.
3. Agent Host iterates and filters its adapter cache; the local provider makes another pass to synchronize automation markers.
4. Copilot Chat copies and sorts its cache and performs chat/session grouping.
5. The management service linearly scans the merged array using `extUri.isEqual`.
The migration deduplication for legacy Copilot CLI rows is not part of this particular lookup path: `getSession()` uses the raw `_getMergedSessions()` result.
URI normalization and Copilot Chat grouping were significant costs, but they were amplified by the per-resource loop rather than independently taking seconds.
### Trace breakdown
Within the 6.241-second task:
| Work | Approximate self time |
|---|---:|
| `getSession` and linear lookup work | 2.03s |
| URI normalization/comparison (`URI.with` under `extUri.isEqual`) | 1.12s |
| Agent Host `getSessions()` | 910ms |
| Copilot Chat `getSessions()` | 581ms |
| Garbage collection | 115ms |
| Layout/style | A few milliseconds |
Sample-stack analysis found approximately:
- 1,455 sampled `getFeedback` regions
- 2,481 sampled `getSession` regions
- 5.52 seconds sampled underneath `getSession`
These are sampling regions, not exact invocation counts, but they show that the expensive session lookup was repeated throughout almost the entire blocked task.
### Recent changes that made the composition more likely
The base `getSession()` behavior is not brand new. Several newer behaviors converged:
- Agent Feedback annotations added per-session backend routing, so `getFeedback()` resolves the owning session.
- The feedback overlay operates over all resources in the active multi-diff.
- Single-pane session transitions replace the outgoing Changes editor while opening a new session.
- Local Agent Host catalog reads gained another pass for automation markers.
- Feedback visibility added another overlay invalidation source.
The issue is therefore less "one new slow function" and more "an occasional O(all sessions) operation moved into a per-file loop over a pathological Changes editor."
## Candidate narrow fix
A narrow renderer-hang fix can avoid changing provider ownership:
1. When a file maps to the already-active session, reuse the active `IActiveSession` facade rather than rediscovering it through `ISessionsManagementService.getSession()`.
2. Lazily deduplicate multi-diff candidates by session resource, so feedback/backend work runs once per distinct session instead of once per original/modified file URI.
3. Preserve early exit when feedback is found.
This requires only a short-lived `ResourceSet` during one candidate iteration; it does not introduce a persistent session cache.
## Recommendations / discussion topics
### 1. Repair archived worktree lifecycle
- Treat a merely existing directory as insufficient proof that an archived worktree was successfully recreated.
- Validate that the worktree is registered, points to the expected branch/HEAD, and has a healthy checkout before skipping recreation.
- If archive cleanup partially removes a worktree, either finish removal or restore it before reporting archive completion.
- Consider recording explicit materialization state rather than inferring it from `fs.access(worktreePath)`.
- Ensure Git status/diff probes cannot race worktree cleanup, or cancel/drain them before removal.
- Add a regression test for: removal deletes most files but leaves the worktree registered/path present; unarchive must repair the checkout instead of publishing every tracked file as deleted.
### 2. Keep session-level work out of per-file loops
- Land the narrow feedback deduplication described above.
- Audit other `getActiveResourceCandidates()` consumers for work that is session-scoped rather than file-scoped.
- Add a large multi-diff test with thousands of resources and assert that feedback/session resolution happens once per distinct session.
### 3. Revisit `getSession()` complexity and naming
- Decide whether a method named `getSession` should have an O(1) or otherwise bounded lookup contract.
- Possible designs include provider-owned direct lookup, a management-owned observable catalog/index, or provider catalogs that are stable/memoized rather than rebuilt on read.
- This needs design agreement because lookups must preserve provider ordering and semantics for drafts, hidden sessions, remote unpublished sessions, cloud-withheld sessions, and legacy migration.
- Avoid adding a consumer-side cache without a clear ownership and invalidation contract.
### 4. Make provider catalog reads cheaper
- Copilot Chat should not necessarily sort and regroup the full catalog for every lookup-oriented read.
- Local Agent Host should avoid repeating full-catalog work when no automation marker changed.
- Measure these independently after removing the per-file multiplication; they may be acceptable once called at normal frequency.
## Suggested ownership discussion
This crosses three areas and may be best split after initial triage:
- Agent Host worktree lifecycle and changeset computation
- Agent Feedback / multi-diff candidate processing
- Sessions management/provider lookup contracts
(Written by Copilot)
Contributor guide
Assessment
This issue has not been assessed yet.