Skip to content

Sort repository dropdown by most recently used#394

Merged
PureWeen merged 4 commits intomainfrom
fix/in-new-session-theres-a-drop-down-with-r-20260316-1734
Mar 16, 2026
Merged

Sort repository dropdown by most recently used#394
PureWeen merged 4 commits intomainfrom
fix/in-new-session-theres-a-drop-down-with-r-20260316-1734

Conversation

@StephaneDelcroix
Copy link
Copy Markdown
Collaborator

Problem

The repository dropdown in the "New Session" form listed repos in insertion order, not by most recently used. Users with multiple repos had to scroll to find the one they just worked with.

Fix

  • Added LastUsedAt nullable property to RepositoryInfo (backward-compatible — missing field deserializes as null)
  • RepoManager.Repositories now returns repos sorted by LastUsedAt descending, falling back to AddedAt for legacy entries
  • Added TouchRepository() method that stamps LastUsedAt = UtcNow and persists
  • Called TouchRepository() in CreateSessionWithWorktreeAsync (covers all session creation paths: new branch, PR checkout, and existing worktree)
  • Also stamps LastUsedAt inline in CreateWorktreeAsync and CreateWorktreeFromPrAsync

Tests

6 new tests in RepoSortOrderTests.cs:

  • Sort by LastUsedAt descending
  • Null LastUsedAt falls back to AddedAt
  • TouchRepository updates timestamp
  • TouchRepository on nonexistent repo doesn't throw
  • LastUsedAt round-trips through JSON serialization
  • Legacy repos.json without LastUsedAt deserializes gracefully

All 2675 tests pass.

Add LastUsedAt property to RepositoryInfo and sort the Repositories
list by LastUsedAt descending (falling back to AddedAt for repos
without usage history). TouchRepository() updates the timestamp
when a session is created via CreateSessionWithWorktreeAsync.

The dropdown in the new session form now shows the most recently
used repository first, matching user expectations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Owner

PR Review: Sort repository dropdown by most recently used

CI Status: ⚠️ No CI checks reported on this branch.
Prior Reviews: None.
Consensus Model: Issues flagged by 2+ of 5 models (claude-opus-4.6 ×2, claude-sonnet-4.6, gemini-3-pro-preview, gpt-5.3-codex).


🟡 MODERATE — Unconditional Save() on nonexistent repo in TouchRepository

PolyPilot/Services/RepoManager.cs ~line 311

lock (_stateLock)
{
    var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId);
    if (repo != null)
        repo.LastUsedAt = DateTime.UtcNow;
}
Save(); // ← called even when repo == null, no state changed

Save() is called unconditionally after the lock, even when repo == null (the repo ID wasn't found). This serializes and rewrites repos.json to disk for every call with an unrecognized repo ID — with zero state change. Save() should move inside the if (repo != null) block.

Flagged by: opus-4.6 ×2, sonnet-4.6, gemini-3-pro (4/5 models)


🟡 MODERATE — Redundant double-stamp and double Save() on the CreateWorktreeAsync path

PolyPilot/Services/CopilotService.cs ~line 2244
PolyPilot/Services/RepoManager.cs ~lines 579, 683

When a new session is created via CreateSessionWithWorktreeAsync:

  1. CreateWorktreeAsync (or CreateWorktreeFromPrAsync) sets repo.LastUsedAt = DateTime.UtcNow inside _stateLock and calls Save().
  2. Immediately after, CreateSessionWithWorktreeAsync calls TouchRepository(wt.RepoId), which re-acquires _stateLock, sets a later UtcNow, and calls Save() a second time.

This results in two disk writes per session creation, and a brief window where the in-memory LastUsedAt differs from what was just persisted. Since TouchRepository already handles stamping correctly on its own, the inline stamps in CreateWorktreeAsync / CreateWorktreeFromPrAsync are redundant and should be removed.

Flagged by: opus-4.6, sonnet-4.6, gemini-3-pro (3/5 models)


🟢 MINOR — LINQ sort executes under _stateLock in Repositories getter

PolyPilot/Services/RepoManager.cs ~line 38

lock (_stateLock) return _state.Repositories.OrderByDescending(r => r.LastUsedAt ?? r.AddedAt).ToList().AsReadOnly();

The O(N log N) sort and ToList() allocation happen while _stateLock is held, blocking concurrent readers/writers for the duration of the sort. The list snapshot should be taken under the lock, and the sort performed after releasing it.

Flagged by: sonnet-4.6, gemini-3-pro (2/5 models)


Test Coverage

The 6 new tests in RepoSortOrderTests.cs are well-structured and cover the key scenarios. One missing case: TouchRepository when repo exists but is called while CreateWorktreeAsync is simultaneously in progress — i.e., the double-stamp scenario above has no test that asserts final LastUsedAt is correct after both saves. This would help detect the issue and validate any fix.


Recommended Action: ⚠️ Request changes

Two MODERATE issues worth fixing before merge:

  1. Move Save() inside the if (repo != null) block in TouchRepository.
  2. Remove the inline repo.LastUsedAt = DateTime.UtcNow stamps inside CreateWorktreeAsync and CreateWorktreeFromPrAsync — let TouchRepository be the single stamp point.

The overall approach is solid and the backward-compatibility story (nullable LastUsedAt falling back to AddedAt) is correct.

PureWeen and others added 3 commits March 16, 2026 16:14
## Summary

Two fixes in this PR:

### 1. Dead Event Stream Recovery (abort interrupted tools on resume)

**Root cause:** When a session crashes mid-tool-execution (e.g.,
force-kill via `relaunch.ps1`), the SDK resumes the session but remains
stuck waiting for `tool.execution_complete` results that will never
arrive. All subsequent `SendAsync` calls are silently queued/ignored —
zero events written to disk, zero callbacks fired. The session appears
permanently stuck.

**Evidence pattern in events.jsonl:**
```
tool.execution_start   ← last real event before crash
session.resume         ← resume, zero new events after sends
abort                  ← THIS unlocks the session
user.message           ← now everything works normally
```

**Fixes:**
- **Abort on resume** (`CopilotService.Persistence.cs`): After
`ResumeSessionAsync`, scan `events.jsonl` for unmatched
`tool.execution_start` events. If found, send `AbortAsync` to clear the
SDK's pending tool state before the user's message.
- **HasInterruptedToolExecution helper**
(`CopilotService.Utilities.cs`): Streams last 30 lines of `events.jsonl`
via ring buffer. Scans backwards with correct reverse-order semantics
(`pendingCompletions` counter). Handles both graceful shutdown and
force-kill scenarios.
- **INV-16 fix** (`CopilotService.Persistence.cs`): Moved `.On()`
callback registration BEFORE `state.Session` assignment — closes a race
window where events arrive with no handler registered.
- **Watchdog Case D** (`CopilotService.Events.cs`): Safety net — if
`events.jsonl` hasn't grown 30s after `SendAsync` and no tools are
active, try `AbortAsync` to recover. Positioned after Case A/B with
`!hasActiveTool` guard to avoid false positives.

### 2. Settings.razor Cleanup

Removed orphaned **Copilot CLI** nav button and empty section shell from
Settings. The underlying setting was moved to the **Developer** group in
b94d0f4 but the nav button was left behind.

### Other Changes
- `relaunch.ps1`: Fixed PowerShell 5.1 compatibility (bracket strings
parsed as array indexers, `Join-Path` with 4 args)
- `PolyPilot.csproj`: Updated MauiDevFlow packages 0.12.1 → 0.23.1
- `ChatDatabaseResilienceTests.cs`: Fixed Windows-specific flaky test
(async file handle release)

### Invariants Validated
- INV-1 ✅ INV-3 ✅ INV-4 ✅ INV-11 ✅ INV-12 ✅ INV-16 ✅ INV-17 ✅

### Tests
2669/2669 passing

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ll sessions to hang (#390)

## Bug
When the Copilot CLI persistent server's GitHub auth token expires,
**all sessions** silently hang. The server stays TCP-alive (port check
passes) but can't process requests. No `SessionErrorEvent` is sent — the
watchdog eventually kills each session after ~135s. New sessions also
get stuck. There is no recovery path.

**User report:** "All my sessions can't be resumed because of auth
issues and new sessions get stuck"

## Root Cause
No mechanism to detect **server-wide** failures. `ConsecutiveStuckCount`
tracks timeouts per-session, but the app never concludes the server
itself is broken.

## Fix
1. **`IsAuthError()` helper** — detects auth-related exceptions (token
expired, unauthorized, 401/403, credentials, etc.)
2. **`IsConnectionError()` extended** — now catches auth errors,
enabling existing reconnect/restart recovery paths
3. **Service-level `_consecutiveWatchdogTimeouts` counter** — tracks
timeouts across ALL sessions (not per-session)
4. **`TryRecoverPersistentServerAsync()`** — when counter reaches
threshold (2), stops old server, starts fresh (forces
re-authentication), recreates client
5. **Counter reset** on successful `CompleteResponse` (proves server
health)
6. **`EnsureSessionConnectedAsync`** — catches auth errors on lazy
resume and attempts recovery

## Testing
- 31 new tests in `ServerRecoveryTests.cs`
- All 2697 existing tests still pass (2 pre-existing failures unrelated)
- Mac Catalyst build succeeds
- UI scenario added for `persistent-server-auth-recovery`

## Files Changed
- `CopilotService.Utilities.cs` — `IsAuthError()`, extended
`IsConnectionError()`
- `CopilotService.cs` — `_consecutiveWatchdogTimeouts`,
`TryRecoverPersistentServerAsync()`
- `CopilotService.Events.cs` — Increment counter in watchdog timeout,
trigger recovery, reset on success
- `CopilotService.Persistence.cs` — Auth error handling in
`EnsureSessionConnectedAsync`
- `ServerRecoveryTests.cs` — 31 new tests
- `mode-switch-scenarios.json` — New scenario

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Shane Neuville <shneuvil@microsoft.com>
…de lock

- TouchRepository: move Save() inside if (repo != null) to avoid disk write when repo not found
- CopilotService.CreateSessionWithWorktreeAsync: move TouchRepository inside the existing-worktree
  branch only; CreateWorktreeAsync/CreateWorktreeFromPrAsync already stamp LastUsedAt for new
  worktrees, so calling TouchRepository afterward was a redundant second stamp+save
- Repositories getter: take list snapshot under lock, sort outside lock to minimize lock hold time

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Owner

Re-Review: Sort repository dropdown by most recently used

CI Status: ⚠️ No CI checks reported on this branch.
Previous review: #394 (comment)
Models: claude-opus-4.6 ×2, claude-sonnet-4.6, gemini-3-pro-preview, gpt-5.3-codex (5/5)


Previous Findings — All Fixed ✅

# Finding Status
1 🟡 TouchRepository() called Save() unconditionally even when repo not found FIXEDelse { return; } exits before Save()
2 🟡 Double-stamp + double Save() on CreateWorktreeAsync path FIXEDTouchRepository moved inside the existing-worktree branch only; CreateWorktreeAsync/CreateWorktreeFromPrAsync keep their inline stamps for non-CopilotService callers
3 🟢 Repositories getter ran O(N log N) sort inside _stateLock FIXED — snapshot taken under lock, sort runs outside

All 5 models confirmed all 3 fixes. No new issue reached the 2+ model consensus threshold.

2708/2709 tests pass (1 pre-existing failure in RenderThrottleTests unrelated to this PR, present on main before these changes).


Recommended Action: ✅ Approve

@PureWeen PureWeen merged commit e06bfdb into main Mar 16, 2026
@PureWeen PureWeen deleted the fix/in-new-session-theres-a-drop-down-with-r-20260316-1734 branch March 16, 2026 21:20
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