refactor: drop the remaining single-implementation interfaces
IBaseDirtyChecker, IInteractiveLaunchSpecService and the LaunchSpec wrapper had one implementation and one caller each; ProcessRunnerAdapter existed only to give a static class an interface, and InstallArtifactLocator used inheritance for two constructor arguments. The external MCP container now shares its singletons through a Share<T> helper instead of 17 near-identical registrations.
This commit is contained in:
@@ -128,7 +128,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
|
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
|
||||||
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
|
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
|
||||||
sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>();
|
sc.AddSingleton<IProcessRunner, ProcessRunner>();
|
||||||
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
||||||
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
|
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
|
||||||
sc.AddSingleton<ClaudeHelpLauncher>();
|
sc.AddSingleton<ClaudeHelpLauncher>();
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ using System.Diagnostics;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
|
using ClaudeDo.Installer.Core.Interfaces;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
public static class ProcessRunner
|
public sealed class ProcessRunner : IProcessRunner
|
||||||
{
|
{
|
||||||
|
/// The injectable, progress-less shape. Everything else calls the static overload directly.
|
||||||
|
public Task<(int ExitCode, string Output)> RunAsync(
|
||||||
|
string fileName, string arguments, string? workingDirectory, CancellationToken ct)
|
||||||
|
=> RunAsync(fileName, arguments, workingDirectory, progress: null, ct);
|
||||||
|
|
||||||
public static async Task<(int ExitCode, string Output)> RunAsync(
|
public static async Task<(int ExitCode, string Output)> RunAsync(
|
||||||
string fileName,
|
string fileName,
|
||||||
string arguments,
|
string arguments,
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
using ClaudeDo.Installer.Core.Interfaces;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
|
||||||
|
|
||||||
public sealed class ProcessRunnerAdapter : IProcessRunner
|
|
||||||
{
|
|
||||||
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) =>
|
|
||||||
ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct);
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,19 @@
|
|||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
public sealed class InstallerLocator : InstallArtifactLocator
|
// Two named types because DI resolves them by type, not by key.
|
||||||
{
|
public sealed class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
|
||||||
protected override string Subdir => "uninstaller";
|
|
||||||
protected override string ExeName => "ClaudeDo.Installer.exe";
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class WorkerLocator : InstallArtifactLocator
|
public sealed class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
|
||||||
{
|
|
||||||
protected override string Subdir => "worker";
|
|
||||||
protected override string ExeName => "ClaudeDo.Worker.exe";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Locates an executable inside a ClaudeDo install: walk up from the running
|
/// Locates an executable inside a ClaudeDo install: walk up from the running
|
||||||
/// directory to the folder containing install.json, otherwise read the
|
/// directory to the folder containing install.json, otherwise read the
|
||||||
/// uninstall registry key. Subclasses supply the subdirectory and exe name.
|
/// uninstall registry key (which is what makes this work from a dev build).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class InstallArtifactLocator
|
public abstract class InstallArtifactLocator(string subdir, string exeName)
|
||||||
{
|
{
|
||||||
private const string InstallJson = "install.json";
|
private const string InstallJson = "install.json";
|
||||||
|
|
||||||
protected abstract string Subdir { get; }
|
|
||||||
protected abstract string ExeName { get; }
|
|
||||||
|
|
||||||
public string? Find()
|
public string? Find()
|
||||||
=> FindByWalkingUp(AppContext.BaseDirectory)
|
=> FindByWalkingUp(AppContext.BaseDirectory)
|
||||||
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
||||||
@@ -34,10 +24,7 @@ public abstract class InstallArtifactLocator
|
|||||||
while (dir is not null)
|
while (dir is not null)
|
||||||
{
|
{
|
||||||
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
|
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
|
||||||
{
|
return Candidate(dir.FullName);
|
||||||
var candidate = Path.Combine(dir.FullName, Subdir, ExeName);
|
|
||||||
return File.Exists(candidate) ? candidate : null;
|
|
||||||
}
|
|
||||||
dir = dir.Parent;
|
dir = dir.Parent;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -52,10 +39,14 @@ public abstract class InstallArtifactLocator
|
|||||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||||
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
||||||
var location = key?.GetValue("InstallLocation") as string;
|
var location = key?.GetValue("InstallLocation") as string;
|
||||||
if (string.IsNullOrEmpty(location)) return null;
|
return string.IsNullOrEmpty(location) ? null : Candidate(location);
|
||||||
var candidate = Path.Combine(location, Subdir, ExeName);
|
|
||||||
return File.Exists(candidate) ? candidate : null;
|
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? Candidate(string installDir)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, subdir, exeName);
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -202,7 +202,7 @@ public sealed class ExternalMcpService
|
|||||||
private readonly WorktreeMaintenanceService _maintenance;
|
private readonly WorktreeMaintenanceService _maintenance;
|
||||||
private readonly TaskMergeService _merge;
|
private readonly TaskMergeService _merge;
|
||||||
private readonly PlanningMergeOrchestrator _planningMerge;
|
private readonly PlanningMergeOrchestrator _planningMerge;
|
||||||
private readonly IBaseDirtyChecker _baseDirtyChecker;
|
private readonly BaseDirtyChecker _baseDirtyChecker;
|
||||||
|
|
||||||
public ExternalMcpService(
|
public ExternalMcpService(
|
||||||
TaskRepository tasks,
|
TaskRepository tasks,
|
||||||
@@ -215,7 +215,7 @@ public sealed class ExternalMcpService
|
|||||||
WorktreeMaintenanceService maintenance,
|
WorktreeMaintenanceService maintenance,
|
||||||
TaskMergeService merge,
|
TaskMergeService merge,
|
||||||
PlanningMergeOrchestrator planningMerge,
|
PlanningMergeOrchestrator planningMerge,
|
||||||
IBaseDirtyChecker baseDirtyChecker)
|
BaseDirtyChecker baseDirtyChecker)
|
||||||
{
|
{
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
_lists = lists;
|
_lists = lists;
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ public sealed record DirtyBaseWarning(int ModifiedCount, int UntrackedCount);
|
|||||||
/// files. Non-blocking by design: callers surface the counts as a heads-up, never refuse the
|
/// files. Non-blocking by design: callers surface the counts as a heads-up, never refuse the
|
||||||
/// transition.
|
/// transition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBaseDirtyChecker
|
public sealed class BaseDirtyChecker
|
||||||
{
|
|
||||||
Task<DirtyBaseWarning?> CheckAsync(string? workingDir, CancellationToken ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class BaseDirtyChecker : IBaseDirtyChecker
|
|
||||||
{
|
{
|
||||||
// Long enough to collapse every task in one batch_update_task_status call over the same
|
// Long enough to collapse every task in one batch_update_task_status call over the same
|
||||||
// list into a single `git status` invocation; short enough that a later, independent queue
|
// list into a single `git status` invocation; short enough that a later, independent queue
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ClaudeDo.Worker.Planning;
|
|||||||
|
|
||||||
// Launches the Claude CLI in a visible terminal for human-driven planning sessions.
|
// Launches the Claude CLI in a visible terminal for human-driven planning sessions.
|
||||||
// Not used for headless task execution (that path is ClaudeProcess, prompt over stdin)
|
// Not used for headless task execution (that path is ClaudeProcess, prompt over stdin)
|
||||||
// nor for embedded ConPTY interactive sessions (those use IInteractiveLaunchSpecService).
|
// nor for embedded ConPTY interactive sessions (those use InteractiveLaunchSpecService).
|
||||||
public interface ITerminalLauncher
|
public interface ITerminalLauncher
|
||||||
{
|
{
|
||||||
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ namespace ClaudeDo.Worker.Planning;
|
|||||||
// Spawns the Claude CLI inside a visible Windows Terminal window for an interactive
|
// Spawns the Claude CLI inside a visible Windows Terminal window for an interactive
|
||||||
// planning session (start/resume). Headless task execution does NOT come through here —
|
// planning session (start/resume). Headless task execution does NOT come through here —
|
||||||
// that path is ClaudeProcess (prompt over stdin, no terminal) — nor does an embedded
|
// that path is ClaudeProcess (prompt over stdin, no terminal) — nor does an embedded
|
||||||
// ConPTY interactive session (IInteractiveLaunchSpecService), which reuses this class's
|
// ConPTY interactive session (InteractiveLaunchSpecService), which reuses this class's
|
||||||
// arg-building helpers (BuildResumeArgs, BuildPlanningStart/ResumeArgs) for a bare
|
// arg-building helpers (BuildResumeArgs, BuildPlanningStart/ResumeArgs) for a bare
|
||||||
// Exe/Args pair instead of a wrapped pwsh command line.
|
// Exe/Args pair instead of a wrapped pwsh command line.
|
||||||
public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
||||||
@@ -86,7 +86,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The raw claude CLI args for a --resume launch, shared with IInteractiveLaunchSpecService
|
// The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService
|
||||||
// (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
|
// (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
|
||||||
internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) =>
|
internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) =>
|
||||||
new[] { "--resume", claudeSessionId };
|
new[] { "--resume", claudeSessionId };
|
||||||
@@ -99,7 +99,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx));
|
BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx));
|
||||||
|
|
||||||
// The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY
|
// The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY
|
||||||
// planning path (IInteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than
|
// planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than
|
||||||
// a pwsh-wrapped command line. Arg order matters: variadic flags (--allowedTools, --add-dir)
|
// a pwsh-wrapped command line. Arg order matters: variadic flags (--allowedTools, --add-dir)
|
||||||
// come first; the single-line kickoff prompt is positional, so it must follow a single-value
|
// come first; the single-line kickoff prompt is positional, so it must follow a single-value
|
||||||
// flag (--append-system-prompt-file) or a variadic flag would swallow it.
|
// flag (--append-system-prompt-file) or a variadic flag would swallow it.
|
||||||
@@ -176,7 +176,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
?? throw new TerminalLaunchException("Failed to start Windows Terminal process.");
|
?? throw new TerminalLaunchException("Failed to start Windows Terminal process.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not private: reused by IInteractiveLaunchSpecService to resolve the claude executable
|
// Not private: reused by InteractiveLaunchSpecService to resolve the claude executable
|
||||||
// for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it.
|
// for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it.
|
||||||
internal static string? Resolve(string pathOrName)
|
internal static string? Resolve(string pathOrName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ builder.Services.AddSingleton<PendingQuestionRegistry>();
|
|||||||
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
||||||
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
||||||
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
||||||
builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSpecService>();
|
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
||||||
builder.Services.AddSingleton<TaskRunner>();
|
builder.Services.AddSingleton<TaskRunner>();
|
||||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||||
builder.Services.AddSingleton<TaskResetService>();
|
builder.Services.AddSingleton<TaskResetService>();
|
||||||
@@ -112,7 +112,7 @@ builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequire
|
|||||||
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
||||||
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
|
// 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<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
|
||||||
builder.Services.AddSingleton<IBaseDirtyChecker, BaseDirtyChecker>();
|
builder.Services.AddSingleton<BaseDirtyChecker>();
|
||||||
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<HubBroadcaster>(),
|
sp.GetRequiredService<HubBroadcaster>(),
|
||||||
@@ -120,7 +120,7 @@ builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
|||||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||||
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||||
sp.GetRequiredService<IBaseDirtyChecker>(),
|
sp.GetRequiredService<BaseDirtyChecker>(),
|
||||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||||
|
|
||||||
// Agent file management.
|
// Agent file management.
|
||||||
@@ -201,12 +201,6 @@ builder.Services.AddScoped<PlanningMcpService>();
|
|||||||
builder.Services.AddScoped<FindingsStoreLocator>();
|
builder.Services.AddScoped<FindingsStoreLocator>();
|
||||||
builder.Services.AddScoped<TaskRunFindingsMcpTools>();
|
builder.Services.AddScoped<TaskRunFindingsMcpTools>();
|
||||||
builder.Services.AddMcpServer()
|
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)
|
.WithHttpTransport(o => o.Stateless = true)
|
||||||
.WithTools<PlanningMcpService>()
|
.WithTools<PlanningMcpService>()
|
||||||
.WithTools<TaskRunMcpService>()
|
.WithTools<TaskRunMcpService>()
|
||||||
@@ -308,27 +302,38 @@ WebApplication? externalApp = null;
|
|||||||
if (cfg.ExternalMcpPort > 0)
|
if (cfg.ExternalMcpPort > 0)
|
||||||
{
|
{
|
||||||
var externalBuilder = WebApplication.CreateBuilder();
|
var externalBuilder = WebApplication.CreateBuilder();
|
||||||
|
// Hand the second container the SAME singleton instances, so both apps operate on one
|
||||||
|
// runtime state instead of two parallel copies of the queue/git/merge stack.
|
||||||
|
void Share<T>() where T : class =>
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<T>());
|
||||||
|
|
||||||
externalBuilder.Services.AddSingleton(cfg);
|
externalBuilder.Services.AddSingleton(cfg);
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<HubBroadcaster>());
|
Share<HubBroadcaster>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<QueueService>());
|
Share<QueueService>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<OverrideSlotService>());
|
Share<OverrideSlotService>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());
|
Share<IDbContextFactory<ClaudeDoDbContext>>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<ITaskStateService>());
|
Share<ITaskStateService>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IQueueWaker>());
|
Share<IQueueWaker>();
|
||||||
|
Share<WorktreeManager>();
|
||||||
|
Share<AgentFileService>();
|
||||||
|
Share<TaskResetService>();
|
||||||
|
Share<GitService>();
|
||||||
|
Share<BaseDirtyChecker>();
|
||||||
|
Share<WorktreeMaintenanceService>();
|
||||||
|
Share<TaskMergeService>();
|
||||||
|
Share<PlanningMergeOrchestrator>();
|
||||||
|
Share<InteractiveReviewSubmissionService>();
|
||||||
|
Share<AttachmentStore>();
|
||||||
|
Share<FindingsStore>();
|
||||||
|
|
||||||
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
externalBuilder.Services.AddScoped<TaskRepository>();
|
externalBuilder.Services.AddScoped<TaskRepository>();
|
||||||
externalBuilder.Services.AddScoped<ListRepository>();
|
externalBuilder.Services.AddScoped<ListRepository>();
|
||||||
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
|
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
||||||
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<ExternalMcpService>();
|
||||||
externalBuilder.Services.AddScoped<BatchMcpTools>();
|
externalBuilder.Services.AddScoped<BatchMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<ListMcpTools>();
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||||
@@ -336,16 +341,11 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||||
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
|
||||||
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
|
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>());
|
|
||||||
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
|
||||||
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
|
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<FindingsStore>());
|
|
||||||
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
|
||||||
externalBuilder.Services.AddScoped<FindingsMcpTools>();
|
externalBuilder.Services.AddScoped<FindingsMcpTools>();
|
||||||
externalBuilder.Services.AddMcpServer()
|
externalBuilder.Services.AddMcpServer()
|
||||||
.WithHttpTransport(o => o.Stateless = true)
|
.WithHttpTransport(o => o.Stateless = true)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ namespace ClaudeDo.Worker.Runner;
|
|||||||
// hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered
|
// hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered
|
||||||
// MCP server (installer's RegisterMcpStep), exactly as they already are for a plain
|
// MCP server (installer's RegisterMcpStep), exactly as they already are for a plain
|
||||||
// `--resume` pickup in a Windows Terminal window.
|
// `--resume` pickup in a Windows Terminal window.
|
||||||
public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
public sealed class InteractiveLaunchSpecService
|
||||||
{
|
{
|
||||||
// Claude Code caps HTTP MCP tool calls at 60s unless raised; every ConPTY spec built by this
|
// Claude Code caps HTTP MCP tool calls at 60s unless raised; every ConPTY spec built by this
|
||||||
// service lifts it well past wait_for_task_change's 900s server-side cap. Keep in sync with
|
// service lifts it well past wait_for_task_change's 900s server-side cap. Keep in sync with
|
||||||
@@ -59,6 +59,16 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
_claudePath = cfg.ClaudeBin;
|
_claudePath = cfg.ClaudeBin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
||||||
|
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
|
||||||
|
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
|
||||||
|
/// demand (same mechanism as an autonomous run) provided the task's list has a working
|
||||||
|
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
|
||||||
|
/// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
|
||||||
|
/// it has one, else the latest autonomous run's session; a task that has never run either
|
||||||
|
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
||||||
|
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
||||||
|
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
||||||
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
@@ -172,6 +182,9 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
return new LaunchSpec(sessionDir, resolvedClaude, args, env);
|
return new LaunchSpec(sessionDir, resolvedClaude, args, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps an already-prepared planning START context (worktree + prompt files + token,
|
||||||
|
/// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY
|
||||||
|
/// planning session — same planning CLI args as the wt launcher, planning env carried in Env.</summary>
|
||||||
public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
|
public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
|
||||||
{
|
{
|
||||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
@@ -194,6 +207,8 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
env);
|
env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
|
||||||
|
/// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
|
||||||
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
|
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
|
||||||
{
|
{
|
||||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
@@ -212,6 +227,9 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
env);
|
env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
||||||
|
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
||||||
|
/// directory doesn't exist.</summary>
|
||||||
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
@@ -241,6 +259,14 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
private const string MergeHelperAllowedTools =
|
private const string MergeHelperAllowedTools =
|
||||||
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task";
|
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task";
|
||||||
|
|
||||||
|
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
|
||||||
|
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
|
||||||
|
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/<guid> and exposes
|
||||||
|
/// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
|
||||||
|
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
|
||||||
|
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
|
||||||
|
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
||||||
|
/// taskIds is empty or the list has no existing working directory.</summary>
|
||||||
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
|
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (taskIds.Count == 0)
|
if (taskIds.Count == 0)
|
||||||
@@ -322,6 +348,15 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
// instructions for the role it is actually about to run, never the Triage dedupe/enhance ones.
|
// instructions for the role it is actually about to run, never the Triage dedupe/enhance ones.
|
||||||
// Writes a fresh handoff kickoff file in a NEW session dir -- the old ConPTY tile keeps running
|
// Writes a fresh handoff kickoff file in a NEW session dir -- the old ConPTY tile keeps running
|
||||||
// against its own session-dir files untouched.
|
// against its own session-dir files untouched.
|
||||||
|
/// <summary>Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to for
|
||||||
|
/// the given phase -- reuses the SAME handler task id (no new task, HandlerBaseCommit untouched),
|
||||||
|
/// writing only a fresh handoff kickoff naming the surviving tasks. nextPhase selects both the
|
||||||
|
/// system prompt and the model: "wait"/"wait_final" -> MergeHelperWait + HandlerWaitAlias,
|
||||||
|
/// "merge"/"merge_final" -> MergeHelperMerge + HandlerMergeAlias; the "_final" variants render an
|
||||||
|
/// extra line in the handoff kickoff marking the final round and forbidding further reruns. Throws
|
||||||
|
/// ArgumentException for an unrecognized nextPhase; KeyNotFoundException if the task/list doesn't
|
||||||
|
/// exist; InvalidOperationException if survivingTaskIds is empty or the list has no working
|
||||||
|
/// directory.</summary>
|
||||||
public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
|
public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
|
||||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct)
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -450,6 +485,11 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
// IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI
|
// IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI
|
||||||
// affordances all skip it, matching the "reminder only a human/ConPTY session can act on"
|
// affordances all skip it, matching the "reminder only a human/ConPTY session can act on"
|
||||||
// semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed.
|
// semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed.
|
||||||
|
/// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
|
||||||
|
/// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
|
||||||
|
/// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws
|
||||||
|
/// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds
|
||||||
|
/// is empty or the list has no existing working directory.</summary>
|
||||||
public async Task<string> CreateMergeHelperTaskAsync(
|
public async Task<string> CreateMergeHelperTaskAsync(
|
||||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct)
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
using ClaudeDo.Worker.Planning;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Runner;
|
|
||||||
|
|
||||||
public interface IInteractiveLaunchSpecService
|
|
||||||
{
|
|
||||||
/// <summary>Maps an already-prepared planning START context (worktree + prompt files + token,
|
|
||||||
/// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY
|
|
||||||
/// planning session — same planning CLI args as the wt launcher, planning env carried in Env.</summary>
|
|
||||||
LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx);
|
|
||||||
|
|
||||||
/// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
|
|
||||||
/// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
|
|
||||||
LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx);
|
|
||||||
|
|
||||||
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
|
||||||
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
|
|
||||||
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
|
|
||||||
/// demand (same mechanism as an autonomous run) provided the task's list has a working
|
|
||||||
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
|
|
||||||
/// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
|
|
||||||
/// it has one, else the latest autonomous run's session; a task that has never run either
|
|
||||||
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
|
||||||
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
|
||||||
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
|
||||||
Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct);
|
|
||||||
|
|
||||||
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
|
||||||
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
|
||||||
/// directory doesn't exist.</summary>
|
|
||||||
Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct);
|
|
||||||
|
|
||||||
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
|
|
||||||
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
|
|
||||||
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/<guid> and exposes
|
|
||||||
/// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
|
|
||||||
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
|
|
||||||
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
|
|
||||||
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
|
||||||
/// taskIds is empty or the list has no existing working directory.</summary>
|
|
||||||
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct);
|
|
||||||
|
|
||||||
/// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
|
|
||||||
/// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
|
|
||||||
/// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws
|
|
||||||
/// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds
|
|
||||||
/// is empty or the list has no existing working directory.</summary>
|
|
||||||
Task<string> CreateMergeHelperTaskAsync(
|
|
||||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct);
|
|
||||||
|
|
||||||
/// <summary>Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to for
|
|
||||||
/// the given phase -- reuses the SAME handler task id (no new task, HandlerBaseCommit untouched),
|
|
||||||
/// writing only a fresh handoff kickoff naming the surviving tasks. nextPhase selects both the
|
|
||||||
/// system prompt and the model: "wait"/"wait_final" -> MergeHelperWait + HandlerWaitAlias,
|
|
||||||
/// "merge"/"merge_final" -> MergeHelperMerge + HandlerMergeAlias; the "_final" variants render an
|
|
||||||
/// extra line in the handoff kickoff marking the final round and forbidding further reruns. Throws
|
|
||||||
/// ArgumentException for an unrecognized nextPhase; KeyNotFoundException if the task/list doesn't
|
|
||||||
/// exist; InvalidOperationException if survivingTaskIds is empty or the list has no working
|
|
||||||
/// directory.</summary>
|
|
||||||
Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
|
|
||||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace ClaudeDo.Worker.Runner;
|
|
||||||
|
|
||||||
// What an embedded ConPTY terminal (UI process) needs to start a real `claude` process for
|
|
||||||
// a task's worktree, with the same setup as an autonomous run (session-skills seeded onto
|
|
||||||
// disk, the same run environment variables) plus the --resume-vs-fresh-start choice.
|
|
||||||
public sealed record LaunchSpec(
|
|
||||||
string Cwd,
|
|
||||||
string Exe,
|
|
||||||
IReadOnlyList<string> Args,
|
|
||||||
IReadOnlyDictionary<string, string> Env);
|
|
||||||
@@ -18,7 +18,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
private readonly PlanningChainCoordinator _chain;
|
private readonly PlanningChainCoordinator _chain;
|
||||||
private readonly RunCancellationRegistry _runCancels;
|
private readonly RunCancellationRegistry _runCancels;
|
||||||
private readonly Func<IActiveMergeState> _mergeState;
|
private readonly Func<IActiveMergeState> _mergeState;
|
||||||
private readonly IBaseDirtyChecker _baseDirtyChecker;
|
private readonly BaseDirtyChecker _baseDirtyChecker;
|
||||||
private readonly ILogger<TaskStateService> _logger;
|
private readonly ILogger<TaskStateService> _logger;
|
||||||
|
|
||||||
public TaskStateService(
|
public TaskStateService(
|
||||||
@@ -28,7 +28,7 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
PlanningChainCoordinator chain,
|
PlanningChainCoordinator chain,
|
||||||
RunCancellationRegistry runCancels,
|
RunCancellationRegistry runCancels,
|
||||||
Func<IActiveMergeState> mergeState,
|
Func<IActiveMergeState> mergeState,
|
||||||
IBaseDirtyChecker baseDirtyChecker,
|
BaseDirtyChecker baseDirtyChecker,
|
||||||
ILogger<TaskStateService> logger)
|
ILogger<TaskStateService> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public static class TaskStateServiceBuilder
|
|||||||
public static Built Build(
|
public static Built Build(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
Func<IActiveMergeState>? mergeState = null,
|
Func<IActiveMergeState>? mergeState = null,
|
||||||
IBaseDirtyChecker? baseDirtyChecker = null)
|
BaseDirtyChecker? baseDirtyChecker = null)
|
||||||
{
|
{
|
||||||
var hub = new CapturingHubContext();
|
var hub = new CapturingHubContext();
|
||||||
var broadcaster = new HubBroadcaster(hub);
|
var broadcaster = new HubBroadcaster(hub);
|
||||||
|
|||||||
Reference in New Issue
Block a user