From 1245e7590219f5c1a543cba2ea4a9ace8b4d0ec5 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Jul 2026 14:42:44 +0200 Subject: [PATCH] feat(interactive): worker launch-spec for embedded ConPTY sessions Adds InteractiveLaunchSpecService + GetInteractiveLaunchSpec hub method that prepares a task worktree (session-skills seeding, run env) and returns a LaunchSpec {cwd,exe,args,env} for a UI-hosted ConPTY terminal. Reuses ISessionSkillSeeder, TaskRunner.UnionSkillNames, and WindowsTerminalLauncher resume-arg/resolve logic. Guards mirror ResumeTaskInTerminal; a never-run task yields a fresh-start spec instead of an error. --- .../Services/Interfaces/IWorkerClient.cs | 3 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 9 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 16 +- .../Planning/WindowsTerminalLauncher.cs | 11 +- src/ClaudeDo.Worker/Program.cs | 1 + .../Runner/InteractiveLaunchSpecService.cs | 95 +++++++ .../IInteractiveLaunchSpecService.cs | 10 + src/ClaudeDo.Worker/Runner/LaunchSpec.cs | 10 + tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 + .../InteractiveLaunchSpecServiceTests.cs | 252 ++++++++++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 2 + 11 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs create mode 100644 src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs create mode 100644 src/ClaudeDo.Worker/Runner/LaunchSpec.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index bab8d1df..8b0ba673 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -87,6 +87,9 @@ public interface IWorkerClient : INotifyPropertyChanged // Picks up a task's Claude session in a real terminal window (--resume) — distinct // from OpenInteractiveTerminalAsync (the in-app streaming session). Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default); + /// Launch spec for an embedded ConPTY terminal to open an interactive session + /// in a task's worktree (same worktree prep as an autonomous run). + Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default); Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 09677cb3..cd250ce6 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -564,6 +564,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct); + public async Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct); + public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct); @@ -677,6 +680,12 @@ public sealed record WorktreeOverviewDto( DateTime CreatedAt, bool PathExistsOnDisk); +public sealed record LaunchSpec( + string Cwd, + string Exe, + IReadOnlyList Args, + IReadOnlyDictionary Env); + public sealed record ForceRemoveResultDto(bool Removed, string? Reason); public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 3d9a5cf7..98a2fad6 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -130,6 +130,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly InteractiveSessionService _interactive; private readonly LogRingBuffer? _logBuffer; private readonly ISessionSkillRegistry _skillRegistry; + private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec; public WorkerHub( QueueService queue, @@ -157,7 +158,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub Runner.PendingQuestionRegistry pendingQuestions, InteractiveSessionService interactive, ISessionSkillRegistry skillRegistry, - LogRingBuffer? logBuffer = null) + LogRingBuffer? logBuffer = null, + IInteractiveLaunchSpecService? interactiveLaunchSpec = null) { _queue = queue; _waker = waker; @@ -185,6 +187,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _interactive = interactive; _skillRegistry = skillRegistry; _logBuffer = logBuffer; + _interactiveLaunchSpec = interactiveLaunchSpec; } // Persistence boundary for the session_skills JSON-array columns (task/list/global). @@ -657,6 +660,17 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub } }); + // Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an + // interactive Claude session in a task's worktree -- same worktree prep as an + // autonomous run (session-skills seeding, run env vars), --resume if the task has a + // persisted session or a fresh-start spec otherwise. Guards mirror ResumeTaskInTerminal. + public Task GetInteractiveLaunchSpec(string taskId) => HubGuard(() => + { + if (_interactiveLaunchSpec is null) + throw new InvalidOperationException("Interactive launch spec service is not configured."); + return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted); + }); + public Task SendInteractiveMessage(string taskId, string text) => _interactive.SendAsync(taskId, text, Context.ConnectionAborted); diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs index 274da92c..f2c08797 100644 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalLauncher.cs @@ -106,7 +106,12 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher // Resumes a session by id in default (interactive) permission mode: the user drives // tool approvals in the terminal, unlike planning which pins --permission-mode plan. internal static string BuildResumeCommand(string claudePath, string claudeSessionId) => - BuildPwshCommand(claudePath, new[] { "--resume", claudeSessionId }); + BuildPwshCommand(claudePath, BuildResumeArgs(claudeSessionId)); + + // 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). + internal static IReadOnlyList BuildResumeArgs(string claudeSessionId) => + new[] { "--resume", claudeSessionId }; // Builds the PowerShell command that launches an interactive planning session. // Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the @@ -174,7 +179,9 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher ?? throw new TerminalLaunchException("Failed to start Windows Terminal process."); } - private static string? Resolve(string pathOrName) + // 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. + internal static string? Resolve(string pathOrName) { if (File.Exists(pathOrName)) return pathOrName; diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index c2678b47..59de7900 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -77,6 +77,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs new file mode 100644 index 00000000..91b50b59 --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -0,0 +1,95 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Skills; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Runner; + +// Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude +// session in an existing task's worktree -- the SAME worktree prep as an autonomous run: +// session-skills seeded onto disk (reuses ISessionSkillSeeder + TaskRunner.UnionSkillNames, +// exactly like TaskRunner.RunAsync/ContinueAsync) and the same run environment variables +// (reuses ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's +// --resume argument construction. Guards mirror WorkerHub.ResumeTaskInTerminal, except a +// never-run task (no persisted SessionId) is not an error here -- it's a fresh-start spec. +// +// The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner +// wires per headless run is intentionally NOT reused: it exists so an unattended run can +// ask the user a question, which is moot when the user is already driving the session by +// 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 +// `--resume` pickup in a Windows Terminal window. +public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService +{ + private readonly IDbContextFactory _dbFactory; + private readonly ISessionSkillSeeder _skillSeeder; + private readonly ISessionSkillRegistry _skillRegistry; + private readonly string _claudePath; + + public InteractiveLaunchSpecService( + IDbContextFactory dbFactory, + ISessionSkillSeeder skillSeeder, + ISessionSkillRegistry skillRegistry, + WorkerConfig cfg) + { + _dbFactory = dbFactory; + _skillSeeder = skillSeeder; + _skillRegistry = skillRegistry; + _claudePath = cfg.ClaudeBin; + } + + public async Task BuildForTaskAsync(string taskId, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + + var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException(); + if (task.Status is TaskStatus.Running or TaskStatus.Queued) + throw new InvalidOperationException("Can't open an interactive session for a running or queued task -- interrupt it first."); + + var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); + if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept)) + throw new InvalidOperationException("This task has no active worktree to open a session in."); + if (!Directory.Exists(worktree.Path)) + throw new InvalidOperationException("The task's worktree directory no longer exists."); + + var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct); + var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct); + var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct); + + var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills); + var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct); + await _skillSeeder.SeedAsync(worktree.Path, skillNames, isWorktree: true, ct); + + var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) + ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + + var args = run?.SessionId is { Length: > 0 } sessionId + ? WindowsTerminalLauncher.BuildResumeArgs(sessionId) + : Array.Empty(); + + // Same run environment variable ClaudeProcess sets for every headless run: the + // AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for + // every other tool. + var env = new Dictionary + { + ["MCP_TOOL_TIMEOUT"] = "200000", + }; + + return new LaunchSpec(worktree.Path, resolvedClaude, args, env); + } + + private async Task> FilterToInstalledSkillsAsync(IReadOnlyList requested, CancellationToken ct) + { + if (requested.Count == 0) return requested; + + var installed = (await _skillRegistry.ListAsync(ct)) + .Select(s => s.Name) + .ToHashSet(StringComparer.Ordinal); + return requested.Where(installed.Contains).ToList(); + } +} diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs new file mode 100644 index 00000000..b0fdc230 --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs @@ -0,0 +1,10 @@ +namespace ClaudeDo.Worker.Runner; + +public interface IInteractiveLaunchSpecService +{ + /// Builds a LaunchSpec for opening an interactive session in an existing task's + /// worktree. Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException + /// if it's Running/Queued or has no usable worktree. A task that has never run (no persisted + /// SessionId) is not an error -- the spec omits --resume for a fresh start. + Task BuildForTaskAsync(string taskId, CancellationToken ct); +} diff --git a/src/ClaudeDo.Worker/Runner/LaunchSpec.cs b/src/ClaudeDo.Worker/Runner/LaunchSpec.cs new file mode 100644 index 00000000..171dd387 --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/LaunchSpec.cs @@ -0,0 +1,10 @@ +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 Args, + IReadOnlyDictionary Env); diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 801cf393..1dea0ecc 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -105,6 +105,8 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; + public virtual Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) => Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0)); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs new file mode 100644 index 00000000..3f97a005 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -0,0 +1,252 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Skills; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Runner; + +/// Verifies InteractiveLaunchSpecService's guards mirror WorkerHub.ResumeTaskInTerminal +/// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the +/// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and +/// produces --resume for a resumable task vs. a fresh-start spec for a never-run task. +/// Never spawns the real claude CLI: ClaudeBin points at a stub file on disk. +public sealed class InteractiveLaunchSpecServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly string _worktreeDir; + private readonly string _claudeStubPath; + private readonly FakeSessionSkillSeeder _seeder = new(); + private readonly FakeSessionSkillRegistry _registry = new(); + + public InteractiveLaunchSpecServiceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cd_ilss_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + _worktreeDir = Path.Combine(_tempDir, "worktree"); + Directory.CreateDirectory(_worktreeDir); + + _claudeStubPath = Path.Combine(_tempDir, "claude.exe"); + File.WriteAllText(_claudeStubPath, "stub"); + } + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_tempDir, true); } catch { /* best effort */ } + } + + private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry + { + public List Installed { get; } = new(); + + public Task> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException(); + public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); + public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); + public Task> ListAsync(CancellationToken ct) + => Task.FromResult>(Installed); + } + + private InteractiveLaunchSpecService BuildService() => + new(_db.CreateFactory(), _seeder, _registry, new WorkerConfig { ClaudeBin = _claudeStubPath }); + + private async Task SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + using var ctx = _db.CreateContext(); + await new ListRepository(ctx).AddAsync(new ListEntity + { + Id = listId, Name = "L", WorkingDir = _tempDir, CreatedAt = DateTime.UtcNow, + }); + return listId; + } + + private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null) + { + using var ctx = _db.CreateContext(); + await new TaskRepository(ctx).AddAsync(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = status, + CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, + }); + } + + private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null) + { + using var ctx = _db.CreateContext(); + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = taskId, Path = path ?? _worktreeDir, BranchName = "claudedo/x", + BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + private async Task SeedRunAsync(string taskId, string? sessionId) + { + using var ctx = _db.CreateContext(); + await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, + Prompt = "p", SessionId = sessionId, + StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1), + ExitCode = 0, ResultMarkdown = "ok", + }); + } + + [Fact] + public async Task BuildForTaskAsync_TaskNotFound_ThrowsKeyNotFound() + { + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForTaskAsync(Guid.NewGuid().ToString(), CancellationToken.None)); + } + + [Theory] + [InlineData(TaskStatus.Running)] + [InlineData(TaskStatus.Queued)] + public async Task BuildForTaskAsync_TaskRunningOrQueued_ThrowsInvalidOperation(TaskStatus status) + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, status); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + } + + [Fact] + public async Task BuildForTaskAsync_NoWorktreeRow_ThrowsInvalidOperation() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + } + + [Theory] + [InlineData(WorktreeState.Merged)] + [InlineData(WorktreeState.Discarded)] + public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_ThrowsInvalidOperation(WorktreeState state) + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, state); + + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + } + + [Fact] + public async Task BuildForTaskAsync_WorktreeDirectoryMissing_ThrowsInvalidOperation() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, WorktreeState.Active, path: Path.Combine(_tempDir, "does-not-exist")); + + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + } + + [Fact] + public async Task BuildForTaskAsync_ResumableTask_ProducesResumeArgsAndCwd() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + await SeedRunAsync(taskId, "sess-123"); + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(_worktreeDir, spec.Cwd); + Assert.Equal(_claudeStubPath, spec.Exe); + Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args); + Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); + } + + [Fact] + public async Task BuildForTaskAsync_KeptWorktree_IsAllowed() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, WorktreeState.Kept); + await SeedRunAsync(taskId, "sess-kept"); + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args); + } + + [Fact] + public async Task BuildForTaskAsync_NeverRunTask_ProducesFreshStartSpec_NoResumeArg() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + // No TaskRunEntity at all -- never run. + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Empty(spec.Args); + Assert.Equal(_worktreeDir, spec.Cwd); + } + + [Fact] + public async Task BuildForTaskAsync_RunWithoutSessionId_ProducesFreshStartSpec() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + await SeedRunAsync(taskId, sessionId: null); + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Empty(spec.Args); + } + + [Fact] + public async Task BuildForTaskAsync_SeedsSessionSkills_FilteredToInstalled() + { + _registry.Installed.Add(new SessionSkillEntity + { + Name = "installed-skill", SourceUrl = "https://example.com/x.git", + PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d", + AddedAt = DateTimeOffset.UtcNow, + }); + + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle, + sessionSkillsJson: "[\"installed-skill\",\"missing-skill\"]"); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + + var svc = BuildService(); + await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + var call = Assert.Single(_seeder.Calls); + Assert.Equal(_worktreeDir, call.WorkingDir); + Assert.True(call.IsWorktree); + Assert.Equal(new[] { "installed-skill" }, call.SkillNames); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index a15e275f..c839c3b4 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -77,6 +77,8 @@ sealed class FakeWorkerClient : IWorkerClient public int PickUpInTerminalCalls { get; private set; } public string? LastPickUpTaskId { get; private set; } public Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) { PickUpInTerminalCalls++; LastPickUpTaskId = taskId; return Task.CompletedTask; } + public Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask; public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } public Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)