Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
433653f
feat: Fiesta pairing improvements — NIC fix, pairing string, LAN push…
PureWeen Mar 9, 2026
ac30ceb
fix: close two race conditions in Fiesta pairing
PureWeen Mar 9, 2026
a0c1585
fix: address PR #322 review comments
PureWeen Mar 11, 2026
4dccd7f
fix: address PR #322 round 2 review — concurrent send race, deny deli…
PureWeen Mar 11, 2026
d58ca24
fix: PR #322 round 3 -- SendComplete guard, inline deny, 256KB size l…
PureWeen Mar 11, 2026
43056a7
fix: prevent Process.HasExited race causing UnobservedTaskException
PureWeen Mar 12, 2026
9532444
fix: PR 322 review fixes - overlay visibility, TCS race, JsonDocument…
PureWeen Mar 12, 2026
53475e5
Add onboarding instructions to Direct Connection and Fiesta Workers s…
PureWeen Mar 13, 2026
0815f64
Fix Razor parse error: escape @ in @worker-name mention example
PureWeen Mar 13, 2026
f3bd5f0
fix: address security review findings - TimeoutException, injection, …
PureWeen Mar 13, 2026
55b5f10
fix: address security review round 2 - path traversal, specific catch…
PureWeen Mar 13, 2026
35476cc
Security: use base64 to safely embed GitHub auth token in SSH command
PureWeen Mar 13, 2026
af87bdd
fix: use Tailscale IP in Fiesta pairing string when available
PureWeen Mar 13, 2026
21050dd
Fix: Fiesta pairing string and discovery use Tailscale IP when available
PureWeen Mar 13, 2026
05053f9
fix: use Tailscale IP in push-to-pair approval response
PureWeen Mar 13, 2026
a5f318e
feat: add Tailscale installation instructions to Settings
PureWeen Mar 13, 2026
0b5e633
fix: harden structural test and add behavior test for process error f…
PureWeen Mar 15, 2026
433191f
fix: address PR 322 round 10 review findings
PureWeen Mar 16, 2026
4219146
fix: eliminate all flaky test failures in parallel test suite
PureWeen Mar 30, 2026
a65a881
fix: address PR 322 round 11 review findings
PureWeen Mar 30, 2026
20df76a
fix: address PR 322 round 13 review findings
PureWeen Mar 30, 2026
6200a3f
fix: address all below-consensus and informational review findings
PureWeen Mar 30, 2026
b9b7696
fix: address all remaining deferred review items
PureWeen Mar 30, 2026
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
60 changes: 60 additions & 0 deletions PolyPilot.Tests/ConnectionRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,66 @@ public void RestorePreviousSessions_FallbackCoversProcessErrors()
"IsProcessError must be included in the RestorePreviousSessionsAsync fallback condition (not found after the 'Session not found' anchor)");
}

// ===== Behavior test: process error → CreateSessionAsync fallback =====
// Proves that when RestorePreviousSessionsAsync encounters a stale CLI server process,
// the session is recreated via CreateSessionAsync rather than silently dropped.
//
// Architecture note: CopilotClient is a concrete SDK class (not mockable), and
// ResumeSessionAsync is not virtual, so we can't inject a throwing client through
// the full RestorePreviousSessionsAsync pipeline. Instead, this test verifies the
// behavioral contract at the seam: IsProcessError detects the exception, and
// CreateSessionAsync (the fallback) successfully creates the replacement session.
// The structural test above guarantees these are wired together in RestorePreviousSessionsAsync.

[Fact]
public async Task ProcessError_DuringRestore_FallbackCreatesSession()
{
// GIVEN: a process error exception (CLI server died, stale handle)
var processError = new InvalidOperationException("No process is associated with this object.");

// WHEN: IsProcessError evaluates it
Assert.True(CopilotService.IsProcessError(processError));
// Also detected as a connection error (broader category)
Assert.True(CopilotService.IsConnectionError(processError));

// THEN: the CreateSessionAsync fallback path works — session is created and accessible
var svc = CreateService();
await svc.ReconnectAsync(new PolyPilot.Models.ConnectionSettings
{
Mode = PolyPilot.Models.ConnectionMode.Demo
});

var fallbackSession = await svc.CreateSessionAsync("Recovered Session", "gpt-4");
Assert.NotNull(fallbackSession);
Assert.Equal("Recovered Session", fallbackSession.Name);

var allSessions = svc.GetAllSessions().Select(s => s.Name).ToList();
Assert.Contains("Recovered Session", allSessions);
}

[Fact]
public async Task ProcessError_WrappedInAggregate_FallbackCreatesSession()
{
// GIVEN: a process error wrapped in AggregateException (from TaskScheduler.UnobservedTaskException)
var inner = new InvalidOperationException("No process is associated with this object.");
var aggregate = new AggregateException("A Task's exception(s) were not observed", inner);

// WHEN: IsProcessError evaluates the wrapped exception
Assert.True(CopilotService.IsProcessError(aggregate));
Assert.True(CopilotService.IsConnectionError(aggregate));

// THEN: the fallback path works
var svc = CreateService();
await svc.ReconnectAsync(new PolyPilot.Models.ConnectionSettings
{
Mode = PolyPilot.Models.ConnectionMode.Demo
});

var session = await svc.CreateSessionAsync("Recovered Aggregate", "gpt-4");
Assert.NotNull(session);
Assert.Equal("Recovered Aggregate", session.Name);
}

// ===== SafeFireAndForget task observation =====
// Prevents UnobservedTaskException from fire-and-forget _chatDb calls.
// See crash log: "A Task's exception(s) were not observed" wrapping ConnectionLostException.
Expand Down
6 changes: 6 additions & 0 deletions PolyPilot.Tests/DiagnosticsLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ namespace PolyPilot.Tests;
/// (not just DEBUG). The #if DEBUG guard was removed so Release builds also
/// get lifecycle diagnostics for post-mortem analysis.
/// </summary>
/// <remarks>
/// In the "BaseDir" collection because CopilotService.BaseDir is a shared static.
/// MultiAgentRegressionTests temporarily changes it via SetBaseDirForTesting(),
/// which would change the log file path mid-test if we ran in parallel with them.
/// </remarks>
[Collection("BaseDir")]
public class DiagnosticsLogTests
{
private readonly StubChatDatabase _chatDb = new();
Expand Down
59 changes: 46 additions & 13 deletions PolyPilot.Tests/ExternalSessionScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,22 +459,55 @@ public void FindActiveLockPid_DetectsCurrentProcess()
var dir = Path.Combine(_sessionStateDir, sessionId);
Directory.CreateDirectory(dir);

// Start a real "dotnet" process so the name passes the process-name validation
using var child = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("dotnet", "--info")
// Use a command guaranteed to run for much longer than the test:
// `dotnet repl` / `dotnet watch` aren't available everywhere, but
// reading stdin on a `dotnet` REPL-like loop works cross-platform.
// Simplest portable option: run `sleep` on Unix, `timeout` on Windows.
System.Diagnostics.Process child;
if (OperatingSystem.IsWindows())
{
RedirectStandardOutput = true,
UseShellExecute = false,
});
Assert.NotNull(child);

File.WriteAllText(Path.Combine(dir, $"inuse.{child.Id}.lock"), "");

var scanner = new ExternalSessionScanner(_sessionStateDir, () => new HashSet<string>());
var detectedPid = scanner.FindActiveLockPid(dir);
child = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("cmd", "/c timeout /t 60 /nobreak")
{
RedirectStandardOutput = true,
RedirectStandardInput = true,
UseShellExecute = false,
})!;
}
else
{
child = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("sleep", "60")
{
UseShellExecute = false,
})!;
}

Assert.Equal(child.Id, detectedPid);
Assert.NotNull(child);

if (!child.HasExited) child.Kill();
try
{
File.WriteAllText(Path.Combine(dir, $"inuse.{child.Id}.lock"), "");

// `sleep 60` / `timeout /t 60` will not exit in the test window — no race guard needed.
Assert.False(child.HasExited, "Long-running child process should still be alive");

// FindActiveLockPid requires a dotnet/copilot/node/github process name.
// `sleep`/`cmd` won't pass that filter. We need to use the current test process instead.
// Verify the behaviour using the test process itself (definitely alive, name = "dotnet").
var testSessionId = Guid.NewGuid().ToString();
var testDir = Path.Combine(_sessionStateDir, testSessionId);
Directory.CreateDirectory(testDir);
var myPid = Environment.ProcessId;
File.WriteAllText(Path.Combine(testDir, $"inuse.{myPid}.lock"), "");

var scanner = new ExternalSessionScanner(_sessionStateDir, () => new HashSet<string>());
var detectedPid = scanner.FindActiveLockPid(testDir);
Assert.Equal(myPid, detectedPid);
}
finally
{
if (!child.HasExited) child.Kill();
child.Dispose();
}
}

[Fact]
Expand Down
Loading