feat: GitHub Codespace integration with SSH tunnel support#308
feat: GitHub Codespace integration with SSH tunnel support#308PureWeen merged 6 commits intoPureWeen:mainfrom
Conversation
Add ability to connect PolyPilot to GitHub Codespaces, creating remote Copilot sessions that run inside codespace environments. Core architecture: - CodespaceService (split into 3 partial files <326 lines each): SSH tunnel management, gh CLI lifecycle, dotfiles diagnostics - CopilotService.Codespace.cs (785 lines): group creation, health check loop with 30s interval, auto-reconnect, MaxConsecutiveFailures=5 backoff - Two connection strategies: direct SSH tunnel or SSH+port-forward fallback UI features: - ☁️ Codespace chip in New Session form lists all codespaces with state badges - 'Start & Add' boots stopped codespaces and adds them to sidebar - Codespace groups show connection state (●green/●yellow/●red/⏳starting) - Group ⋯ menu: New Session, Open in Browser, Stop Codespace, Start, Retry - Session ⋯ menu respects VS Code Insiders preference via --insiders flag - Copilot Console hidden for codespace sessions (can't resume remotely) - Sessions named 'Main', 'Main 2', etc. (not timestamps) Safety: - Codespace sessions bound to their group (can't be moved) - Health check backoff: 5 consecutive failures → SetupRequired state - Graceful degradation: no SSH → SetupRequired with browser link - Delete group cleans up sessions, tunnels, and client connections - App restart restores groups in Reconnecting state; health check reconnects SDK upgrade: 0.1.30 → 0.1.31 (resolves protocol v2/v3 mismatch) API change: PermissionRequestResult.Kind string → PermissionRequestResultKind.Approved enum Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
8 new test files with 100+ tests covering: - CodespaceModelTests: SessionGroup codespace properties, serialization, ConnectionState, CodespaceWorkingDirectory derivation - CodespaceServiceTests: FindGhPath, TunnelHandle, port allocation - CodespaceClientStateTests: client lifecycle, connection state transitions - CodespaceClientRoutingTests: session routing to codespace clients - CodespaceClientInvariantTests: thread safety, concurrent access guards - CodespaceIsolationTests: 14 CRITICAL regression tests ensuring local sessions are NEVER blocked by codespace logic - CodespaceHealthCheckTests: backoff, SetupMessage lifecycle, retry reset, AddStoppedCodespaceGroup - CodespaceUxTests: session naming (Main/Main 2), VS Code Insiders flag, move target exclusion, FindGhPath safety Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add CodespacesEnabled setting (default false) so codespace UI is hidden for users who don't use codespaces. Toggle available in Settings → UI → Features on desktop platforms. Codespaces are restricted to Embedded mode — tunnels die with the app, matching Embedded's lifecycle. The toggle is blocked from enabling in Persistent/Remote mode to prevent confusing session loss on mode switch. When disabled: - ☁️ Codespace chip hidden in New Session form - Codespace groups section hidden in sidebar - Health check loop not started Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
51dbef7 to
1d55219
Compare
|
This is all from the PolyPilot reviewer Review Findings (5-model consensus)
|
@PureWeen |
- Marshal health check state writes to UI thread via InvokeOnUI - Replace blocking .Wait() with async StopCodespaceHealthCheckAsync - Inject CodespaceService via DI instead of 5x new CodespaceService() - Fix sshFailed race condition with Volatile.Read/Write - Fix weak tests to assert on production types - Remove unused import, revert relaunch.sh workaround - Clarify port-forward fallback limitations in code comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1d55219 to
0cd3b17
Compare
|
PolyPilot did that comment 👇
|
yea
|
…egration # Conflicts: # PolyPilot.Tests/PolyPilot.Tests.csproj # PolyPilot/PolyPilot.csproj
btessiau
left a comment
There was a problem hiding this comment.
PR #308 Review — Codespace Integration
Well-architected feature with solid test coverage (113 tests). The SSH-first strategy with port-forward fallback and the health-check state machine are well designed. A few issues worth addressing before merge:
🔴 Bug: Health check starts in wrong modes after ReconnectAsync
CopilotService.cs:787 — Uses settings.CodespacesEnabled (raw setting) instead of CodespacesEnabled (the property that gates on Embedded mode). Compare with line 630 which correctly uses the property. This means after a reconnect in Persistent/Remote mode, the health check loop starts when it shouldn't.
// Line 787 (bug)
if (settings.CodespacesEnabled)
StartCodespaceHealthCheck();
// Line 630 (correct)
if (CodespacesEnabled)
StartCodespaceHealthCheck();🔴 DI registration is dead code
MauiProgram.cs registers CodespaceService as a singleton, but CopilotService's production constructor hard-codes new CodespaceService() (line 192) and never resolves from DI. Either inject it properly via IServiceProvider.GetRequiredService<CodespaceService>() or remove the dead DI registration to avoid confusing future developers.
🟡 Race condition: CodespacesEnabled setter teardown vs. health check
The setter fires StopCodespaceHealthCheckAsync() + Clear() on dictionaries inside Task.Run (fire-and-forget). Meanwhile, the health check loop that's still running could be iterating _codespaceClients/_tunnelHandles concurrently. ConcurrentDictionary handles individual operations safely, but the health check does multi-step read-then-use patterns (check TryGetValue → use handle) that can race with Clear(). Consider awaiting the stop before clearing, or at minimum ensuring the health check task has exited.
🟡 StartAndReconnectCodespaceAsync — background thread safety
StartAndReconnectCodespaceAsync is called from Task.Run in AddStoppedCodespaceGroup (line 289), making it a background thread. The group.ConnectionState / group.SshAvailable mutations at lines 791, 799, 811, 815 should use InvokeOnUI() to match the pattern documented in the health check loop. RetryCodespaceConnectionAsync has the same direct mutations but is likely called from the UI thread already — still worth wrapping for safety.
🟡 jq injection in GetCodespaceStateAsync
codespaceName is interpolated directly into a jq filter string: .[] | select(.name == "{codespaceName}"). A codespace name containing " would break the jq query. The fallback (full JSON parse) handles this gracefully so it's not exploitable, but the primary path silently fails for oddly-named codespaces. Consider sanitizing the name or just using the JSON parse path directly.
🟢 Minor: StartCopilotHeadlessAsync returns true on failure
End of method: return true; // Still return true (SSH worked). The comment explains the rationale, but the caller uses this to decide tunnelTimeoutSeconds (30s vs 15s). Returning true when copilot may have failed means the caller waits longer than needed. A comment at the call site would help clarify intent.
🟢 Good patterns observed
TunnelHandle.DisposeAsyncproperly kills entire process tree- Per-group
SemaphoreSlimreconnect locks prevent tunnel orphaning Volatile.Read/Writeonintfor thesshFailedflag is correct- Health check correctly snapshots
Organization.Groupswith try-catch for concurrent modification DisposeAsyncproperly tears down codespace clients, tunnels, and health check- Session restore defers codespace sessions as placeholders — clean separation
- Toggle isolation skips placeholders when disabled
- Fix health check starting in wrong modes after ReconnectAsync (use CodespacesEnabled property instead of settings.CodespacesEnabled) - Inject CodespaceService from DI properly (remove dead new() in public constructor, accept from MauiProgram's singleton registration) - Ensure CodespacesEnabled setter awaits health check stop before clearing dictionaries to prevent race with running health check - Wrap StartAndReconnectCodespaceAsync group mutations in InvokeOnUI (method runs from Task.Run on background thread) - Sanitize codespace name in jq filter to prevent query injection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
btessiau
left a comment
There was a problem hiding this comment.
All findings addressed in 5762a09:
🔴 Health check starts in wrong modes — Fixed. Line 787 now uses CodespacesEnabled (the property that gates on Embedded mode) instead of settings.CodespacesEnabled.
🔴 DI registration is dead code — Fixed. Public constructor now accepts CodespaceService from DI (MauiProgram.cs singleton registration). Removed the new CodespaceService() hard-coding. Internal test constructor still has CodespaceService? = null fallback.
🟡 Race in CodespacesEnabled setter — Fixed. The teardown now runs sequentially: await StopCodespaceHealthCheckAsync() first (ensures health check exits), then clears dictionaries. Health check cannot race with Clear().
🟡 StartAndReconnectCodespaceAsync background mutations — Fixed. All group.ConnectionState / group.SshAvailable mutations and OnStateChanged calls wrapped in InvokeOnUI().
🟡 jq injection — Fixed. Codespace name is now sanitized (backslash + quote escaping) before interpolation into the jq filter.
🟢 StartCopilotHeadlessAsync returns true — Acknowledged. The comment at the call site explains the intent. Will improve in follow-up.
2,215 tests pass (3 CliPathResolution tests excluded — CLI binary not downloaded in this build env).
btessiau
left a comment
There was a problem hiding this comment.
Re-review after 5762a09
All five issues from my first review are resolved:
✅ ReconnectAsync health check — Now uses CodespacesEnabled property (with Embedded mode guard) instead of raw settings.CodespacesEnabled
✅ DI registration — Public constructor now accepts CodespaceService from DI; no more dead new CodespaceService()
✅ Setter teardown race — StopCodespaceHealthCheckAsync() is awaited before clearing dictionaries, with a comment explaining the ordering requirement
✅ Background thread safety — All StartAndReconnectCodespaceAsync group mutations wrapped in InvokeOnUI()
✅ jq injection — codespaceName is now sanitized (backslash + quote escaping) before interpolation into the jq filter
The new commit also includes good hardening changes:
- SDK bumped to 0.1.32, Markdig to 1.1.1
- Cross-platform test fixes (dynamic port allocation instead of static counter, Windows symlink privilege handling)
- Null-safe LINQ queries on
Organization.Groupsin bridge integration tests Directoryparameter made nullable inCreateSessionFormfor codespace sessions (no local working dir)- GTK project: local appicon copy, provider abstractions reference, trimmer
RootMode="All"
No new issues found. LGTM 👍
|
@PureWeen I tried to test as much as possible and made it in a way that if you do not enable it in the settings it shouldn't change the current app. |
|
This is amazing @btessiau !!! Super excited to play with this. |
… codespace support (#229) Now that PR #308 (codespace support) is merged, an OrchestratorReflect group can have its sessions running in a codespace via a tunnel client stored in _codespaceClients[groupId] — not in the local _client field. EnsureOrchestratorReflectToolsAsync was calling _client.ResumeSessionAsync directly, which for a codespace orchestrator would use the wrong (local) client, causing ResumeSessionAsync to fail and silently fall back to @worker: text dispatch — negating the entire #229 feature for codespace groups. Fix: resolve the correct CopilotClient via GetClientForGroup(orchestratorGroupId), matching the pattern used by CreateSessionAsync, SendPromptAsync, and all other session operations throughout CopilotService. Regression test added: EnsureOrchestratorReflectTools_UsesGetClientForGroup verifies the source uses GetClientForGroup and not _client.ResumeSessionAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… codespace support (#229) Now that PR #308 (codespace support) is merged, an OrchestratorReflect group can have its sessions running in a codespace via a tunnel client stored in _codespaceClients[groupId] — not in the local _client field. EnsureOrchestratorReflectToolsAsync was calling _client.ResumeSessionAsync directly, which for a codespace orchestrator would use the wrong (local) client, causing ResumeSessionAsync to fail and silently fall back to @worker: text dispatch — negating the entire #229 feature for codespace groups. Fix: resolve the correct CopilotClient via GetClientForGroup(orchestratorGroupId), matching the pattern used by CreateSessionAsync, SendPromptAsync, and all other session operations throughout CopilotService. Regression test added: EnsureOrchestratorReflectTools_UsesGetClientForGroup verifies the source uses GetClientForGroup and not _client.ResumeSessionAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary Fixes #314 — Clicking "➕ New Session" on a connected codespace group (green dot) silently does nothing. > **Note:** This PR depends on #308 (codespace integration). Merge #308 first, then this cleanly applies on top. ## Root Cause Three independent bugs combine to create this silent failure: | # | Bug | Impact | |---|-----|--------| | 1 | **Fire-and-forget async lambda** — `@onclick` handler is void-returning, so Blazor never awaits `QuickCreateSessionForCodespace` | Exception silently swallowed | | 2 | **Error in wrong UI section** — `createError` only renders in `CreateSessionForm` at top of sidebar, not codespace section | Error invisible to user | | 3 | **Stale client undetected** — Health check only checks `tunnel.IsAlive` + `ContainsKey()`, never probes remote port | Green dot stays after remote copilot dies | ## Changes ### `SessionSidebar.razor` - Changed void lambda to `async () => { ... await QuickCreateSessionForCodespace(...) }` so Blazor properly awaits and re-renders - Added `codespace-create-error` banner below codespace group sessions (click to dismiss) ### `SessionSidebar.razor.css` - Added `.codespace-create-error` style ### `CopilotService.Codespace.cs` - Health check now does a TCP probe to the tunnel's local port when tunnel + client + Connected all pass - If probe fails: disposes stale client, removes from `_codespaceClients`, falls through to reconnect path - The green dot will correctly transition to reconnecting when the remote copilot dies ### `CodespaceSessionCreationTests.cs` (new, 8 tests) - Missing client → descriptive error (not "Service not initialized") - No session state leaked on failure - Non-codespace groups skip codespace guard - TCP probe detects stale client (old code would skip) - Healthy group still skips correctly - All disconnected states block creation - Connected state allows creation - Error message mentions health check + retry guidance ## Test Results 2,223 pass (8 new), 3 pre-existing CLI binary failures (unrelated). ## Risk - **TCP probe overhead**: One extra TCP connect per health check cycle (15s) per connected codespace group. Sub-millisecond for localhost. - **No behavior change for healthy connections**: Probe succeeds instantly → `continue` as before. - **Backward compatible**: No API changes. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… codespace support (#229) Now that PR #308 (codespace support) is merged, an OrchestratorReflect group can have its sessions running in a codespace via a tunnel client stored in _codespaceClients[groupId] — not in the local _client field. EnsureOrchestratorReflectToolsAsync was calling _client.ResumeSessionAsync directly, which for a codespace orchestrator would use the wrong (local) client, causing ResumeSessionAsync to fail and silently fall back to @worker: text dispatch — negating the entire #229 feature for codespace groups. Fix: resolve the correct CopilotClient via GetClientForGroup(orchestratorGroupId), matching the pattern used by CreateSessionAsync, SendPromptAsync, and all other session operations throughout CopilotService. Regression test added: EnsureOrchestratorReflectTools_UsesGetClientForGroup verifies the source uses GetClientForGroup and not _client.ResumeSessionAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… codespace support (#229) Now that PR #308 (codespace support) is merged, an OrchestratorReflect group can have its sessions running in a codespace via a tunnel client stored in _codespaceClients[groupId] — not in the local _client field. EnsureOrchestratorReflectToolsAsync was calling _client.ResumeSessionAsync directly, which for a codespace orchestrator would use the wrong (local) client, causing ResumeSessionAsync to fail and silently fall back to @worker: text dispatch — negating the entire #229 feature for codespace groups. Fix: resolve the correct CopilotClient via GetClientForGroup(orchestratorGroupId), matching the pattern used by CreateSessionAsync, SendPromptAsync, and all other session operations throughout CopilotService. Regression test added: EnsureOrchestratorReflectTools_UsesGetClientForGroup verifies the source uses GetClientForGroup and not _client.ResumeSessionAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… codespace support (#229) Now that PR #308 (codespace support) is merged, an OrchestratorReflect group can have its sessions running in a codespace via a tunnel client stored in _codespaceClients[groupId] — not in the local _client field. EnsureOrchestratorReflectToolsAsync was calling _client.ResumeSessionAsync directly, which for a codespace orchestrator would use the wrong (local) client, causing ResumeSessionAsync to fail and silently fall back to @worker: text dispatch — negating the entire #229 feature for codespace groups. Fix: resolve the correct CopilotClient via GetClientForGroup(orchestratorGroupId), matching the pattern used by CreateSessionAsync, SendPromptAsync, and all other session operations throughout CopilotService. Regression test added: EnsureOrchestratorReflectTools_UsesGetClientForGroup verifies the source uses GetClientForGroup and not _client.ResumeSessionAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When ResumeSessionAsync fails with 'Session not found' / 'corrupt' / 'session file' errors, the fallback path was calling CreateSessionAsync without recovering history from the old session's events.jsonl. This caused sessions to appear empty after app relaunch. The fix adds history recovery to the fallback path: 1. Load history from old session via LoadHistoryFromDisk(entry.SessionId) 2. Inject recovered messages into the new session's Info.History 3. Set MessageCount and LastReadMessageCount 4. Call RestoreUsageStats(entry) to preserve CreatedAt, token counts 5. Sync recovered history to chat DB under the new session ID 6. Add a system message indicating the session was recreated Bug introduced in PR #225 (commit 19219f1), worsened by PR #308 (commit 72886a2) which expanded the catch conditions without adding history recovery. Adds 5 structural regression tests guarding the fallback path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Problem
When `ResumeSessionAsync` fails during app restart ("Session not found"
/ "corrupt" / "session file" errors), the fallback path called
`CreateSessionAsync` which created a **blank** session with zero
messages. The old session's `events.jsonl` was never loaded, so all
conversation history was lost.
**User impact:** Sessions appeared empty after app relaunch — the user
would see their session restored but with 0 messages, forcing them to
create duplicate sessions.
## Root Cause
Introduced in PR #225 (commit `19219f1`) which added the "Session not
found" fallback. Worsened in PR #308 which expanded the catch conditions
to include "corrupt" and "session file" errors. Neither PR added history
recovery to the fallback path.
`ResumeSessionAsync` (the success path) correctly loads history via
`LoadHistoryFromDisk(sessionId)`. The fallback path skipped this
entirely.
## Fix
In the fallback path of `RestorePreviousSessionsAsync`:
1. **Load history** from the old session's `events.jsonl` via
`LoadHistoryFromDisk(entry.SessionId)` before creating the new session
2. **Inject history** into the recreated session's `Info.History`
3. **Set message counts** (`MessageCount`, `LastReadMessageCount`) so
the UI shows the correct state
4. **Restore usage stats** (`CreatedAt`, token counts) via
`RestoreUsageStats(entry)`
5. **Sync to DB** via `BulkInsertAsync` under the new session ID
6. **Add indicator** system message: "🔄 Session recreated — conversation
history recovered from previous session."
## Duplicate Session Issue
The user also observed a duplicate session (`session-20260311-001729`)
created with the same repo as the original. This was a downstream
consequence: after seeing the original session restored with zero
messages, the user manually created a new session. With this fix, the
full history is preserved, eliminating the need for duplicates.
## Tests
Added 5 structural regression tests in `SessionPersistenceTests.cs`:
- `RestoreFallback_LoadsHistoryFromOldSession` — verifies
`LoadHistoryFromDisk` appears before `CreateSessionAsync`
- `RestoreFallback_InjectsHistoryIntoRecreatedSession` — verifies
history injection + message count
- `RestoreFallback_RestoresUsageStats` — verifies `RestoreUsageStats`
call
- `RestoreFallback_SyncsHistoryToDatabase` — verifies `BulkInsertAsync`
call
- `RestoreFallback_AddsReconnectionIndicator` — verifies system message
All 2464 tests pass.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
) ## GitHub Codespace Integration (Alpha) Closes PureWeen#307 ### What Connect PolyPilot to running GitHub Codespaces via SSH tunnel. Sessions in a codespace get their own sidebar group with health monitoring, auto-reconnect, and lifecycle management. ### How it works 1. **Discovery**: `gh cs list --json` finds running codespaces 2. **Connection**: SSH tunnel to the codespace's copilot headless server (port 4321) 3. **Health check**: Background loop monitors tunnel health, reconnects on failure 4. **Opt-in**: Feature is behind Settings → Alpha → Codespaces toggle (off by default) ### Architecture - `CodespaceService` (3 files) — SSH tunnel lifecycle, codespace discovery, diagnostics - `CopilotService.Codespace.cs` — Group management, health check loop, session resume - Settings toggle — `CodespacesEnabled` property with runtime sync ### Requirements - **SSH server in codespace** — Required. Without SSH there is no way to start copilot remotely or establish a tunnel. The port-forward path (`gh cs ports forward`) exists as graceful degradation but cannot start copilot — it will transition to SetupRequired state. - `gh` CLI authenticated with codespace scope ### Key design decisions - **CodespaceService injected via DI** (singleton), not instantiated per-call - **Health check state writes marshaled to UI thread** via `InvokeOnUI` to prevent data races with Blazor render - **Per-group reconnect lock** (`SemaphoreSlim`) prevents concurrent reconnect attempts from orphaning SSH tunnel processes - **`StopCodespaceHealthCheckAsync`** uses async cancellation (no blocking `.Wait()`) - **`sshFailed` race** fixed with `Volatile.Read/Write` on int (C# bool has no Volatile overload) - **Null guards** on `ChangeModelAsync` and `AbortSessionAsync` for placeholder codespace sessions (Session=null until tunnel connects) - **Toggle isolation**: when CodespacesEnabled is OFF, no placeholder sessions are created on restore, no health check starts, codespace UI is hidden - **SDK bumped to 0.1.31** for `CopilotSession.ServerUri` property needed by codespace client routing ### Test coverage - 113 new tests across 8 test files (CodespaceModel, Service, HealthCheck, Isolation, ClientState, ClientRouting, ClientInvariant, UX) - All 2,218 tests pass - Tests cover: model contracts, error messages, toggle isolation, session isolation from codespace failures - Tests do NOT cover: tunnel lifecycle, reconnect state machine, health check loop (require real SSH/gh processes) ### Known limitations (alpha) 1. **SSH process orphaning on app crash** — If the app hard-crashes, spawned `gh cs ssh` processes survive with no PID file for cleanup on next launch. Mitigation: tunnels die when the codespace times out or user manually kills them. 2. **Port-forward fallback is graceful but non-functional** — Without SSH, `gh cs ports forward` opens a tunnel but copilot cannot be started remotely. The code correctly transitions to SetupRequired state rather than hanging. 3. **No integration tests for core paths** — Tunnel lifecycle, reconnect state machine, and health check loop are untested because they require real SSH/gh CLI processes. Model and isolation coverage is solid. 4. **No stale SSH process cleanup on startup** — App does not scan for orphaned tunnel processes from previous sessions. Future improvement: PID file tracking. ### Commits 1. `feat: GitHub Codespace integration with SSH tunnel support` — Core implementation 2. `test: comprehensive codespace integration test suite` — 113 tests 3. `feat: make codespace features opt-in via Settings toggle` — Alpha gate 4. `fix: address code review findings` — Thread safety, DI, async, null guards, reconnect lock, toggle isolation --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary Fixes PureWeen#314 — Clicking "➕ New Session" on a connected codespace group (green dot) silently does nothing. > **Note:** This PR depends on PureWeen#308 (codespace integration). Merge PureWeen#308 first, then this cleanly applies on top. ## Root Cause Three independent bugs combine to create this silent failure: | # | Bug | Impact | |---|-----|--------| | 1 | **Fire-and-forget async lambda** — `@onclick` handler is void-returning, so Blazor never awaits `QuickCreateSessionForCodespace` | Exception silently swallowed | | 2 | **Error in wrong UI section** — `createError` only renders in `CreateSessionForm` at top of sidebar, not codespace section | Error invisible to user | | 3 | **Stale client undetected** — Health check only checks `tunnel.IsAlive` + `ContainsKey()`, never probes remote port | Green dot stays after remote copilot dies | ## Changes ### `SessionSidebar.razor` - Changed void lambda to `async () => { ... await QuickCreateSessionForCodespace(...) }` so Blazor properly awaits and re-renders - Added `codespace-create-error` banner below codespace group sessions (click to dismiss) ### `SessionSidebar.razor.css` - Added `.codespace-create-error` style ### `CopilotService.Codespace.cs` - Health check now does a TCP probe to the tunnel's local port when tunnel + client + Connected all pass - If probe fails: disposes stale client, removes from `_codespaceClients`, falls through to reconnect path - The green dot will correctly transition to reconnecting when the remote copilot dies ### `CodespaceSessionCreationTests.cs` (new, 8 tests) - Missing client → descriptive error (not "Service not initialized") - No session state leaked on failure - Non-codespace groups skip codespace guard - TCP probe detects stale client (old code would skip) - Healthy group still skips correctly - All disconnected states block creation - Connected state allows creation - Error message mentions health check + retry guidance ## Test Results 2,223 pass (8 new), 3 pre-existing CLI binary failures (unrelated). ## Risk - **TCP probe overhead**: One extra TCP connect per health check cycle (15s) per connected codespace group. Sub-millisecond for localhost. - **No behavior change for healthy connections**: Probe succeeds instantly → `continue` as before. - **Backward compatible**: No API changes. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Problem
When `ResumeSessionAsync` fails during app restart ("Session not found"
/ "corrupt" / "session file" errors), the fallback path called
`CreateSessionAsync` which created a **blank** session with zero
messages. The old session's `events.jsonl` was never loaded, so all
conversation history was lost.
**User impact:** Sessions appeared empty after app relaunch — the user
would see their session restored but with 0 messages, forcing them to
create duplicate sessions.
## Root Cause
Introduced in PR PureWeen#225 (commit `19219f1`) which added the "Session not
found" fallback. Worsened in PR PureWeen#308 which expanded the catch conditions
to include "corrupt" and "session file" errors. Neither PR added history
recovery to the fallback path.
`ResumeSessionAsync` (the success path) correctly loads history via
`LoadHistoryFromDisk(sessionId)`. The fallback path skipped this
entirely.
## Fix
In the fallback path of `RestorePreviousSessionsAsync`:
1. **Load history** from the old session's `events.jsonl` via
`LoadHistoryFromDisk(entry.SessionId)` before creating the new session
2. **Inject history** into the recreated session's `Info.History`
3. **Set message counts** (`MessageCount`, `LastReadMessageCount`) so
the UI shows the correct state
4. **Restore usage stats** (`CreatedAt`, token counts) via
`RestoreUsageStats(entry)`
5. **Sync to DB** via `BulkInsertAsync` under the new session ID
6. **Add indicator** system message: "🔄 Session recreated — conversation
history recovered from previous session."
## Duplicate Session Issue
The user also observed a duplicate session (`session-20260311-001729`)
created with the same repo as the original. This was a downstream
consequence: after seeing the original session restored with zero
messages, the user manually created a new session. With this fix, the
full history is preserved, eliminating the need for duplicates.
## Tests
Added 5 structural regression tests in `SessionPersistenceTests.cs`:
- `RestoreFallback_LoadsHistoryFromOldSession` — verifies
`LoadHistoryFromDisk` appears before `CreateSessionAsync`
- `RestoreFallback_InjectsHistoryIntoRecreatedSession` — verifies
history injection + message count
- `RestoreFallback_RestoresUsageStats` — verifies `RestoreUsageStats`
call
- `RestoreFallback_SyncsHistoryToDatabase` — verifies `BulkInsertAsync`
call
- `RestoreFallback_AddsReconnectionIndicator` — verifies system message
All 2464 tests pass.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GitHub Codespace Integration (Alpha)
Closes #307
What
Connect PolyPilot to running GitHub Codespaces via SSH tunnel. Sessions in a codespace get their own sidebar group with health monitoring, auto-reconnect, and lifecycle management.
How it works
gh cs list --jsonfinds running codespacesArchitecture
CodespaceService(3 files) — SSH tunnel lifecycle, codespace discovery, diagnosticsCopilotService.Codespace.cs— Group management, health check loop, session resumeCodespacesEnabledproperty with runtime syncRequirements
gh cs ports forward) exists as graceful degradation but cannot start copilot — it will transition to SetupRequired state.ghCLI authenticated with codespace scopeKey design decisions
InvokeOnUIto prevent data races with Blazor renderSemaphoreSlim) prevents concurrent reconnect attempts from orphaning SSH tunnel processesStopCodespaceHealthCheckAsyncuses async cancellation (no blocking.Wait())sshFailedrace fixed withVolatile.Read/Writeon int (C# bool has no Volatile overload)ChangeModelAsyncandAbortSessionAsyncfor placeholder codespace sessions (Session=null until tunnel connects)CopilotSession.ServerUriproperty needed by codespace client routingTest coverage
Known limitations (alpha)
gh cs sshprocesses survive with no PID file for cleanup on next launch. Mitigation: tunnels die when the codespace times out or user manually kills them.gh cs ports forwardopens a tunnel but copilot cannot be started remotely. The code correctly transitions to SetupRequired state rather than hanging.Commits
feat: GitHub Codespace integration with SSH tunnel support— Core implementationtest: comprehensive codespace integration test suite— 113 testsfeat: make codespace features opt-in via Settings toggle— Alpha gatefix: address code review findings— Thread safety, DI, async, null guards, reconnect lock, toggle isolation