Wire ListMcpTools, ConfigMcpTools, RunHistoryMcpTools, AgentMcpTools, LifecycleMcpTools, and AppSettingsMcpTools into the external MCP container and expose them via WithTools<>(). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
245 lines
10 KiB
C#
245 lines
10 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.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.State;
|
|
using ClaudeDo.Worker.Prime;
|
|
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);
|
|
builder.Host.UseSerilog((ctx, lc) => lc
|
|
.MinimumLevel.Information()
|
|
.WriteTo.File(
|
|
System.IO.Path.Combine(logRoot, "worker-.log"),
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 7,
|
|
shared: true));
|
|
|
|
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
|
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
|
|
|
builder.Services.AddSingleton(cfg);
|
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
|
builder.Services.AddHostedService<OrphanRecovery>();
|
|
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<TaskRunner>();
|
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
|
builder.Services.AddSingleton<TaskResetService>();
|
|
builder.Services.AddSingleton<TaskMergeService>();
|
|
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<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
|
|
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
|
sp.GetRequiredService<HubBroadcaster>(),
|
|
sp.GetRequiredService<IQueueWaker>(),
|
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
|
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>();
|
|
|
|
// Prime Claude
|
|
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
|
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
|
builder.Services.AddSingleton<IPrimeScheduleSignal>(sp => sp.GetRequiredService<PrimeScheduleSignal>());
|
|
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
|
|
builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
|
|
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
|
builder.Services.AddHostedService<PrimeScheduler>();
|
|
|
|
// 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<ILogger<PlanningLineageRecovery>>()));
|
|
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
|
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
|
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
|
builder.Services.AddScoped<TaskRepository>();
|
|
builder.Services.AddScoped<ListRepository>();
|
|
builder.Services.AddScoped<PlanningMcpService>();
|
|
builder.Services.AddMcpServer()
|
|
.WithHttpTransport()
|
|
.WithTools<PlanningMcpService>();
|
|
|
|
// 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();
|
|
|
|
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.AddScoped<ExternalMcpService>();
|
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
|
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
|
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
|
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
|
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
|
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
|
externalBuilder.Services.AddMcpServer()
|
|
.WithHttpTransport()
|
|
.WithTools<ExternalMcpService>()
|
|
.WithTools<ListMcpTools>()
|
|
.WithTools<ConfigMcpTools>()
|
|
.WithTools<RunHistoryMcpTools>()
|
|
.WithTools<AgentMcpTools>()
|
|
.WithTools<LifecycleMcpTools>()
|
|
.WithTools<AppSettingsMcpTools>();
|
|
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);
|