Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions PolyPilot.Tests/SessionPersistenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?>());

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<string?>()));
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);
}
}
24 changes: 24 additions & 0 deletions PolyPilot/Services/CopilotService.Persistence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,26 @@ internal static List<ActiveSessionEntry> MergeSessionEntries(
return merged;
}

/// <summary>
/// Delete temp session directories under <paramref name="tempBase"/> that are
/// not referenced by any persisted session's WorkingDirectory.
/// Called on startup to clean up directories left behind by crashed sessions.
/// </summary>
internal static void SweepOrphanedTempSessionDirs(string tempBase, IEnumerable<string?> persistedWorkingDirs)
{
if (!Directory.Exists(tempBase)) return;

var keepDirs = new HashSet<string>(
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 { }
}
}

/// <summary>
/// Load and resume all previously active sessions
/// </summary>
Expand All @@ -153,6 +173,10 @@ public async Task RestorePreviousSessionsAsync(CancellationToken cancellationTok
{
var json = await File.ReadAllTextAsync(ActiveSessionsFile, cancellationToken);
var entries = JsonSerializer.Deserialize<List<ActiveSessionEntry>>(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...");
Expand Down
22 changes: 21 additions & 1 deletion PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -1304,7 +1308,17 @@ public async Task<AgentSessionInfo> 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,
Expand Down Expand Up @@ -2162,6 +2176,12 @@ public async Task<bool> 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();
Expand Down