diff --git a/src/ClaudeDo.Worker/Lifecycle/WorkerStartupFailure.cs b/src/ClaudeDo.Worker/Lifecycle/WorkerStartupFailure.cs
new file mode 100644
index 00000000..1bb5615f
--- /dev/null
+++ b/src/ClaudeDo.Worker/Lifecycle/WorkerStartupFailure.cs
@@ -0,0 +1,19 @@
+namespace ClaudeDo.Worker.Lifecycle;
+
+///
+/// Formats the Fatal log line for a startup/bind failure (e.g. a port already in use) so
+/// Program.cs can log a diagnosable message and exit cleanly instead of crashing unhandled.
+///
+public static class WorkerStartupFailure
+{
+ public const int ExitCode = 1;
+
+ public static string FormatBindFailureMessage(Exception ex, int signalRPort, int externalMcpPort)
+ {
+ var ports = externalMcpPort > 0
+ ? $"ports {signalRPort} and {externalMcpPort}"
+ : $"port {signalRPort}";
+ return $"ClaudeDo.Worker failed to start on {ports}: {ex.Message} " +
+ "Is another worker instance already running, or is another process using this port?";
+ }
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index a1ba39a7..7724a690 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -31,10 +31,11 @@ using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using Serilog;
-// Single-instance per user session. Multiple launch paths exist (logon task,
-// app ensure-running, Restart button); a second instance exits cleanly instead
-// of fighting over the SignalR port.
-var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
+// Single instance machine-wide. Multiple launch paths exist (logon task, app
+// ensure-running, Restart button) and a Remote Desktop / Fast User Switching session
+// gets its own `Local\` namespace, so `Global\` is required or a second session's
+// worker silently fights the first one over the SignalR port instead of exiting.
+var mutex = new Mutex(true, @"Global\ClaudeDoWorker", out var createdNew);
if (!createdNew)
return; // another instance already owns the port; exit 0
@@ -65,6 +66,14 @@ builder.Host.UseSerilog((ctx, lc) => lc
.WriteTo.Sink(broadcastSink));
builder.Services.AddSingleton(logBuffer);
+// Last-resort net: any exception that misses every other handler (e.g. a crash on a
+// background thread) still gets one Fatal line in the log before the process dies.
+AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+{
+ Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (IsTerminating: {IsTerminating})", e.IsTerminating);
+ Log.CloseAndFlush();
+};
+
builder.Services.AddDbContextFactory(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
@@ -404,13 +413,24 @@ if (cfg.ExternalMcpPort > 0)
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
}
-if (externalApp is null)
+try
{
- await app.RunAsync();
+ if (externalApp is null)
+ {
+ await app.RunAsync();
+ }
+ else
+ {
+ await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
+ }
}
-else
+catch (Exception ex)
{
- await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
+ // Most commonly a Kestrel bind failure (port already in use by a second session's
+ // worker or an unrelated process) — log it plainly instead of an unhandled crash.
+ Log.Fatal(ex, "{Message}", WorkerStartupFailure.FormatBindFailureMessage(ex, cfg.SignalRPort, cfg.ExternalMcpPort));
+ Log.CloseAndFlush();
+ Environment.Exit(WorkerStartupFailure.ExitCode);
}
GC.KeepAlive(mutex);
diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/WorkerStartupFailureTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/WorkerStartupFailureTests.cs
new file mode 100644
index 00000000..40ce2da2
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/WorkerStartupFailureTests.cs
@@ -0,0 +1,38 @@
+using ClaudeDo.Worker.Lifecycle;
+using Xunit;
+
+namespace ClaudeDo.Worker.Tests.Lifecycle;
+
+public sealed class WorkerStartupFailureTests
+{
+ [Fact]
+ public void FormatBindFailureMessage_IncludesBothPorts_WhenExternalMcpEnabled()
+ {
+ var message = WorkerStartupFailure.FormatBindFailureMessage(
+ new IOException("Failed to bind to address http://127.0.0.1:47821: address already in use."),
+ signalRPort: 47821,
+ externalMcpPort: 47822);
+
+ Assert.Contains("47821", message);
+ Assert.Contains("47822", message);
+ Assert.Contains("address already in use", message);
+ }
+
+ [Fact]
+ public void FormatBindFailureMessage_OmitsExternalPort_WhenDisabled()
+ {
+ var message = WorkerStartupFailure.FormatBindFailureMessage(
+ new IOException("boom"),
+ signalRPort: 47821,
+ externalMcpPort: 0);
+
+ Assert.Contains("47821", message);
+ Assert.DoesNotContain("0", message);
+ }
+
+ [Fact]
+ public void ExitCode_IsNonZero()
+ {
+ Assert.NotEqual(0, WorkerStartupFailure.ExitCode);
+ }
+}