Files
ClaudeDo/src/ClaudeDo.Worker/Program.cs
T
mika kuns 69fcceb2ed feat(worker): run both MCP servers stateless
Set Stateless = true on the internal (/mcp on the SignalR port) and external
MCP HTTP transports. Two effects:

- No Mcp-Session-Id, so a worker restart can no longer 404 a session that
  outlives it -- ConPTY tiles in the UI process and externally registered
  claude sessions keep working across a restart.
- A 2026-07-28 client is no longer refused back to the initialize handshake.
  A stateful server rejects that revision on purpose (see the SDK's
  StreamableHttpHandler.s_sessionSupportingProtocolVersions), so the new
  per-request protocol path was unreachable regardless of SDK version.

Nothing here used the stateful-only features (sampling, elicitation, resource
subscriptions, unsolicited notifications). In-tool progress notifications ride
the POST's own response stream and are unaffected -- which matters, since ~20
tools use them to hold off the client's 300s idle abort. All three consumers
(TaskRunner, PlanningSessionManager, the installer's RegisterMcpStep) already
register type: "http", so dropping the legacy SSE endpoint breaks nothing.

Verified against the running worker across two restarts: tools/list returns
57 tools over a bare POST with no initialize and no session id, and a full
2026-07-28 tools/call round-trip returns real data.
2026-08-26 11:05:31 +02:00

388 lines
18 KiB
C#

using System.Threading;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Findings;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Logging;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Online.Interfaces;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
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);
if (!createdNew)
return; // another instance already owns the port; exit 0
var cfg = WorkerConfig.Load();
var builder = WebApplication.CreateBuilder(args);
var logRoot = cfg.LogRoot;
Directory.CreateDirectory(logRoot);
// In-memory ring + broadcast sink power the footer log strip and the Log Visualizer
// overlay. Created pre-build so Serilog can write to the sink; the SignalR broadcaster
// is attached once the host is built (see below).
var logBuffer = new LogRingBuffer(TimeSpan.FromMinutes(30));
var broadcastSink = new BroadcastLogSink(logBuffer);
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true)
.WriteTo.Sink(broadcastSink));
builder.Services.AddSingleton(logBuffer);
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddSingleton(cfg);
builder.Services.AddSingleton<AttachmentStore>();
// Singleton so the always-on and in-task MCP servers share the same index write lock.
builder.Services.AddSingleton<FindingsStore>();
builder.Services.AddHostedService<StaleTaskRecovery>();
builder.Services.AddHostedService<OrphanRecovery>();
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
builder.Services.AddHostedService<PromptFileRecovery>();
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// Runner stack.
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
builder.Services.AddSingleton<HubBroadcaster>();
builder.Services.AddSingleton<GitService>();
builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton<TaskRunTokenRegistry>();
builder.Services.AddSingleton<PendingQuestionRegistry>();
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSpecService>();
builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>();
builder.Services.AddSingleton<IVerifyCommandRunner, VerifyCommandRunner>();
builder.Services.AddSingleton<TaskMergeService>();
builder.Services.AddSingleton<InteractiveReviewSubmissionService>();
builder.Services.AddSingleton<PlanningAggregator>();
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
builder.Services.AddSingleton<PlanningChainCoordinator>();
// Queue dispatch primitives. QueueWaker holds the wake semaphore; the queue picker
// performs atomic Queued→Running claim. Both injected into the state service so it
// can wake the dispatcher without depending on QueueService directly.
builder.Services.AddSingleton<QueueWaker>();
builder.Services.AddSingleton<IQueueWaker>(sp => sp.GetRequiredService<QueueWaker>());
builder.Services.AddSingleton<IQueuePicker, QueuePicker>();
builder.Services.AddSingleton<RunCancellationRegistry>();
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
builder.Services.AddSingleton<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
builder.Services.AddSingleton<IBaseDirtyChecker, BaseDirtyChecker>();
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<HubBroadcaster>(),
sp.GetRequiredService<IQueueWaker>(),
sp.GetRequiredService<PlanningChainCoordinator>(),
sp.GetRequiredService<RunCancellationRegistry>(),
sp.GetRequiredService<Func<IActiveMergeState>>(),
sp.GetRequiredService<IBaseDirtyChecker>(),
sp.GetRequiredService<ILogger<TaskStateService>>()));
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
defaultAgentsBundleDir,
agentsDir,
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
// Override slot owns RunNow / ContinueTask. Queue slot is the BackgroundService.
builder.Services.AddSingleton<OverrideSlotService>();
builder.Services.AddSingleton<IClaudeHistoryReader>(_ =>
new ClaudeHistoryReader(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects")));
builder.Services.AddSingleton<WeekReportService>();
// Usage
builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>();
// TokenTracker — the analytics backend. Optional at runtime: every consumer degrades when the
// CLI is missing, so nothing here is a startup requirement.
builder.Services.AddSingleton<ITokenTrackerClient, TokenTrackerClient>();
builder.Services.AddSingleton<TokenTrackerState>();
builder.Services.AddSingleton<TokenTrackerService>();
// Prime Claude
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
builder.Services.AddSingleton<PrimeScheduleSignal>();
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
builder.Services.AddHostedService<PrimeScheduler>();
// Refine
builder.Services.AddSingleton<RefineRunner>();
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
// "Continue on session limit reset" toggle — depends on UsageState, registered below.
builder.Services.AddSingleton<UsageLimitAutoContinueCoordinator>();
// QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton<QueueService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
// Planning session services.
var planningSessionsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".todo-app", "planning-sessions");
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<GitService>(),
cfg,
sp.GetRequiredService<ITaskStateService>(),
sp.GetRequiredService<PlanningChainCoordinator>(),
planningSessionsDir));
builder.Services.AddHostedService(sp => new PlanningLineageRecovery(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
planningSessionsDir,
sp.GetRequiredService<HubBroadcaster>(),
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
builder.Services.AddSingleton<ITerminalLauncher>(sp =>
new WindowsTerminalLauncher("wt.exe", cfg.ClaudeBin));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PlanningMcpContextAccessor>();
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
builder.Services.AddScoped<TaskRunMcpService>();
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<PlanningMcpService>();
builder.Services.AddScoped<FindingsStoreLocator>();
builder.Services.AddScoped<TaskRunFindingsMcpTools>();
builder.Services.AddMcpServer()
// Stateless: no Mcp-Session-Id, so a worker restart doesn't 404 the sessions that
// outlive it (ConPTY tiles in the UI process, externally registered claude sessions),
// and a 2026-07-28 client isn't refused back to the initialize handshake. Nothing here
// needs the stateful-only features (sampling, elicitation, resource subscriptions,
// unsolicited notifications); in-tool progress works in both modes. Implies no legacy
// SSE endpoint — setting EnableLegacySse alongside this throws at startup.
.WithHttpTransport(o => o.Stateless = true)
.WithTools<PlanningMcpService>()
.WithTools<TaskRunMcpService>()
.WithTools<TaskRunFindingsMcpTools>();
// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work
// even when sync is disabled. The sync stack (api client, auth, hosted service) is
// only registered when enabled.
builder.Services.AddSingleton(cfg.OnlineInbox);
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
builder.Services.AddSingleton<OnlineTokenStore>();
#pragma warning restore CA1416
if (cfg.OnlineInbox.Enabled)
{
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
builder.Services.AddHttpClient();
#pragma warning disable CA1416
builder.Services.AddSingleton<IOnlineAuthProvider, ZitadelAuthProvider>();
#pragma warning restore CA1416
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
{
client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/");
});
builder.Services.AddHostedService<OnlineSyncService>();
}
// OAuth usage monitor: reads the access token Claude Code keeps fresh in
// ~/.claude/.credentials.json and polls Anthropic's usage endpoint.
builder.Services.AddSingleton<UsageState>();
builder.Services.AddHttpClient<IUsageClient, ClaudeOAuthUsageClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
});
builder.Services.AddSingleton<IUsageGate, UsageGate>();
builder.Services.AddSingleton<UsageSnapshotBuilder>();
builder.Services.AddSingleton<IRunningTaskProbe, RunningTaskProbe>();
// Singleton + hosted service (same instance) so WorkerHub.RefreshUsage can drive a manual poll.
builder.Services.AddSingleton<UsageMonitorService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<UsageMonitorService>());
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
var app = builder.Build();
// Now that the hub context exists, let the broadcast sink push Warn/Error to the footer.
var logBroadcaster = app.Services.GetRequiredService<HubBroadcaster>();
broadcastSink.Attach((message, level, ts) => logBroadcaster.WorkerLog(message, level, ts));
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
try
{
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
var seedResult = await seeder.SeedMissingAsync();
app.Logger.LogInformation(
"Default agents seeded: {Copied} copied, {Skipped} already present",
seedResult.Copied, seedResult.Skipped);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
app.UseMiddleware<PlanningTokenAuthMiddleware>();
app.MapHub<WorkerHub>("/hub");
app.MapMcp("/mcp");
// Claude CLI preflight: fail fast if the configured binary is unreachable or non-zero.
// Skippable via CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (e.g. tests).
if (Environment.GetEnvironmentVariable("CLAUDEDO_SKIP_CLI_PREFLIGHT") != "1")
{
var preflight = await ClaudeCliPreflight.CheckAsync(cfg.ClaudeBin);
if (!preflight.Ok)
{
app.Logger.LogCritical(
"Claude CLI preflight failed (bin: '{Bin}', exit: {Exit}): {Error}. " +
"Fix `claude_bin` in worker.config.json or set CLAUDEDO_SKIP_CLI_PREFLIGHT=1 to bypass.",
cfg.ClaudeBin, preflight.ExitCode, preflight.Error);
Environment.Exit(1);
}
app.Logger.LogInformation("Claude CLI preflight OK: {Version}", preflight.Version);
}
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
cfg.SignalRPort, cfg.DbPath);
// Build the external MCP endpoint as a separate WebApplication on its own port.
// Rationale: ModelContextProtocol.AspNetCore registers one server per DI container,
// so we need a second app to expose a different tool set under different auth.
// Shared singletons (QueueService, HubBroadcaster, WorkerConfig, db factory) are
// injected by instance so both apps operate on the same runtime state.
WebApplication? externalApp = null;
if (cfg.ExternalMcpPort > 0)
{
var externalBuilder = WebApplication.CreateBuilder();
externalBuilder.Services.AddSingleton(cfg);
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<HubBroadcaster>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<QueueService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<OverrideSlotService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<ITaskStateService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IQueueWaker>());
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
externalBuilder.Services.AddScoped<TaskRepository>();
externalBuilder.Services.AddScoped<ListRepository>();
externalBuilder.Services.AddScoped<TaskRunRepository>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IBaseDirtyChecker>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<PlanningMergeOrchestrator>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<InteractiveReviewSubmissionService>());
externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddScoped<BatchMcpTools>();
externalBuilder.Services.AddScoped<ListMcpTools>();
externalBuilder.Services.AddScoped<ConfigMcpTools>();
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
externalBuilder.Services.AddScoped<HandoffMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>());
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<FindingsStore>());
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
externalBuilder.Services.AddScoped<FindingsMcpTools>();
externalBuilder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
.WithTools<ExternalMcpService>()
.WithTools<BatchMcpTools>()
.WithTools<ListMcpTools>()
.WithTools<ConfigMcpTools>()
.WithTools<RunHistoryMcpTools>()
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<HandoffMcpTools>()
.WithTools<AppSettingsMcpTools>()
.WithTools<TaskWaitMcpTools>()
.WithTools<QueueStateMcpTools>()
.WithTools<AttachmentMcpTools>()
.WithTools<FindingsMcpTools>();
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
externalApp = externalBuilder.Build();
externalApp.UseMiddleware<ExternalMcpAuthMiddleware>();
externalApp.MapMcp("/mcp");
externalApp.Logger.LogInformation(
"ClaudeDo.Worker external MCP listening on http://127.0.0.1:{Port} (auth: {Auth})",
cfg.ExternalMcpPort,
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
}
if (externalApp is null)
{
await app.RunAsync();
}
else
{
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
}
GC.KeepAlive(mutex);