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:
mika kuns
2026-08-26 13:55:51 +02:00
parent c593be2f02
commit 0f007c5367
14 changed files with 103 additions and 151 deletions
+1 -1
View File
@@ -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>();
+8 -1
View File
@@ -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
View File
@@ -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;
+1 -6
View File
@@ -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)
{ {
+29 -29
View File
@@ -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/&lt;guid&gt; 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/&lt;guid&gt; 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);
}
-10
View File
@@ -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);