diff --git a/PolyPilot.Tests/SessionPersistenceTests.cs b/PolyPilot.Tests/SessionPersistenceTests.cs index 134da166..b87e2505 100644 --- a/PolyPilot.Tests/SessionPersistenceTests.cs +++ b/PolyPilot.Tests/SessionPersistenceTests.cs @@ -405,4 +405,81 @@ public void Merge_DeletedMultiAgentSessions_InClosedIds_Excluded() Assert.Single(result); Assert.Equal("regular-session", result[0].SessionId); } + + // --- SweepOrphanedTempSessionDirs tests --- + + [Fact] + public void Sweep_OrphanedDirs_AreDeleted() + { + var tempBase = Path.Combine(Path.GetTempPath(), $"polypilot-sweep-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempBase); + var orphanDir = Directory.CreateDirectory(Path.Combine(tempBase, "orphan1")).FullName; + try + { + CopilotService.SweepOrphanedTempSessionDirs(tempBase, Array.Empty()); + + Assert.False(Directory.Exists(orphanDir)); + } + finally + { + if (Directory.Exists(tempBase)) Directory.Delete(tempBase, true); + } + } + + [Fact] + public void Sweep_PersistedDirs_AreKept() + { + var tempBase = Path.Combine(Path.GetTempPath(), $"polypilot-sweep-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempBase); + var persistedDir = Directory.CreateDirectory(Path.Combine(tempBase, "persisted1")).FullName; + var orphanDir = Directory.CreateDirectory(Path.Combine(tempBase, "orphan1")).FullName; + try + { + CopilotService.SweepOrphanedTempSessionDirs(tempBase, new[] { persistedDir }); + + Assert.True(Directory.Exists(persistedDir)); + Assert.False(Directory.Exists(orphanDir)); + } + finally + { + if (Directory.Exists(tempBase)) Directory.Delete(tempBase, true); + } + } + + [Fact] + public void Sweep_WhenTempBaseDoesNotExist_DoesNotThrow() + { + var nonExistentBase = Path.Combine(Path.GetTempPath(), $"polypilot-nonexistent-{Guid.NewGuid():N}"); + var ex = Record.Exception(() => + CopilotService.SweepOrphanedTempSessionDirs(nonExistentBase, Array.Empty())); + Assert.Null(ex); + } + + [Fact] + public void Sweep_NullEntriesInPersistedList_AreIgnored() + { + var tempBase = Path.Combine(Path.GetTempPath(), $"polypilot-sweep-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempBase); + var orphanDir = Directory.CreateDirectory(Path.Combine(tempBase, "orphan1")).FullName; + try + { + // Passing nulls/empties should not throw and should treat them as not-persisted + CopilotService.SweepOrphanedTempSessionDirs(tempBase, new string?[] { null, "", null }); + + Assert.False(Directory.Exists(orphanDir)); + } + finally + { + if (Directory.Exists(tempBase)) Directory.Delete(tempBase, true); + } + } + + [Fact] + public void TempSessionsBase_IsIsolatedInTests() + { + // SetBaseDirForTesting should redirect TempSessionsBase away from the real temp dir + var realTempBase = Path.Combine(Path.GetTempPath(), "polypilot-sessions"); + Assert.NotEqual(realTempBase, CopilotService.TempSessionsBase, StringComparer.OrdinalIgnoreCase); + Assert.StartsWith(TestSetup.TestBaseDir, CopilotService.TempSessionsBase, StringComparison.OrdinalIgnoreCase); + } } diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index 3dc2a92b..c1d51db7 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -142,6 +142,26 @@ internal static List MergeSessionEntries( return merged; } + /// + /// Delete temp session directories under that are + /// not referenced by any persisted session's WorkingDirectory. + /// Called on startup to clean up directories left behind by crashed sessions. + /// + internal static void SweepOrphanedTempSessionDirs(string tempBase, IEnumerable persistedWorkingDirs) + { + if (!Directory.Exists(tempBase)) return; + + var keepDirs = new HashSet( + persistedWorkingDirs.Where(d => !string.IsNullOrEmpty(d)).Select(d => d!), + StringComparer.OrdinalIgnoreCase); + + foreach (var dir in Directory.GetDirectories(tempBase)) + { + if (!keepDirs.Contains(dir)) + try { Directory.Delete(dir, true); } catch { } + } + } + /// /// Load and resume all previously active sessions /// @@ -153,6 +173,10 @@ public async Task RestorePreviousSessionsAsync(CancellationToken cancellationTok { var json = await File.ReadAllTextAsync(ActiveSessionsFile, cancellationToken); var entries = JsonSerializer.Deserialize>(json); + + // Sweep orphaned temp session directories from crashed/killed sessions + SweepOrphanedTempSessionDirs(TempSessionsBase, entries?.Select(e => e.WorkingDirectory) ?? []); + if (entries != null && entries.Count > 0) { Debug($"Restoring {entries.Count} previous sessions..."); diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 74989aba..9c44cd09 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -128,8 +128,12 @@ internal static void SetBaseDirForTesting(string path) Volatile.Write(ref _copilotBaseDir, null); _sessionStatePath = null; _pendingOrchestrationFile = null; + _tempSessionsBase = Path.Combine(path, "polypilot-sessions"); } + private static string? _tempSessionsBase; + internal static string TempSessionsBase => _tempSessionsBase ??= Path.Combine(Path.GetTempPath(), "polypilot-sessions"); + private static string? _projectDir; private static string ProjectDir => _projectDir ??= FindProjectDir(); @@ -1304,7 +1308,17 @@ public async Task CreateSessionAsync(string name, string? mode var sessionModel = Models.ModelHelper.NormalizeToSlug(model ?? DefaultModel); if (string.IsNullOrEmpty(sessionModel)) sessionModel = DefaultModel; - var sessionDir = string.IsNullOrWhiteSpace(workingDirectory) ? ProjectDir : workingDirectory; + string sessionDir; + if (string.IsNullOrWhiteSpace(workingDirectory)) + { + var tempDir = Path.Combine(TempSessionsBase, Guid.NewGuid().ToString("N")); + try { Directory.CreateDirectory(tempDir); sessionDir = tempDir; } + catch (Exception ex) { Debug($"Failed to create temp session dir '{tempDir}': {ex.Message}"); sessionDir = ProjectDir; } + } + else + { + sessionDir = workingDirectory; + } // Build system message with critical relaunch instructions // Note: The CLI automatically loads .github/copilot-instructions.md from the working directory, @@ -2162,6 +2176,12 @@ public async Task CloseSessionAsync(string name) if (state.Session is not null) try { await state.Session.DisposeAsync(); } catch { /* session may already be disposed */ } + // Clean up auto-created temp session directory + var closedWorkingDir = state.Info.WorkingDirectory; + if (!string.IsNullOrEmpty(closedWorkingDir) && + string.Equals(Path.GetDirectoryName(closedWorkingDir), TempSessionsBase, StringComparison.OrdinalIgnoreCase)) + try { Directory.Delete(closedWorkingDir, true); } catch { } + if (_activeSessionName == name) { _activeSessionName = _sessions.Keys.FirstOrDefault();