From 9ab48d70943a414cd92b9a255389586660e67583 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Jul 2026 15:15:37 +0200 Subject: [PATCH] feat(interactive): fresh-worktree-on-demand + ad-hoc launch specs BuildForTaskAsync now creates a worktree on demand (via WorktreeManager.CreateAsync, the same path TaskRunner uses) when a task has a configured working dir but no Active/Kept worktree, returning a fresh-start spec -- so never-run tasks can be opened interactively. Adds BuildForDirectoryAsync + GetAdHocLaunchSpec hub/client for ad-hoc sessions in an arbitrary directory (no task, no worktree, no skill seeding). --- .../Services/Interfaces/IWorkerClient.cs | 3 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 3 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 9 +++ .../Runner/InteractiveLaunchSpecService.cs | 43 +++++++++- .../IInteractiveLaunchSpecService.cs | 16 +++- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 + .../InteractiveLaunchSpecServiceTests.cs | 81 ++++++++++++++++++- .../UiVm/TasksIslandViewModelPlanningTests.cs | 2 + 8 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 8b0ba673..bf031d36 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -90,6 +90,9 @@ public interface IWorkerClient : INotifyPropertyChanged /// 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); + /// Launch spec for an ad-hoc interactive session in an arbitrary directory -- + /// no task, no worktree. + Task GetAdHocLaunchSpecAsync(string directory, 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 cd250ce6..afaa3188 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -567,6 +567,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct); + public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) + => await _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct); + public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 98a2fad6..90e9227f 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -671,6 +671,15 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted); }); + // Builds the launch spec for an ad-hoc interactive session in an arbitrary directory -- + // no task, no worktree, no session-skills seeding. + public Task GetAdHocLaunchSpec(string directory) => HubGuard(() => + { + if (_interactiveLaunchSpec is null) + throw new InvalidOperationException("Interactive launch spec service is not configured."); + return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted); + }); + public Task SendInteractiveMessage(string taskId, string text) => _interactive.SendAsync(taskId, text, Context.ConnectionAborted); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 91b50b59..1ac10ba1 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -28,17 +28,20 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService private readonly IDbContextFactory _dbFactory; private readonly ISessionSkillSeeder _skillSeeder; private readonly ISessionSkillRegistry _skillRegistry; + private readonly WorktreeManager _wtManager; private readonly string _claudePath; public InteractiveLaunchSpecService( IDbContextFactory dbFactory, ISessionSkillSeeder skillSeeder, ISessionSkillRegistry skillRegistry, + WorktreeManager wtManager, WorkerConfig cfg) { _dbFactory = dbFactory; _skillSeeder = skillSeeder; _skillRegistry = skillRegistry; + _wtManager = wtManager; _claudePath = cfg.ClaudeBin; } @@ -52,14 +55,32 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService 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); + var isFreshWorktree = false; + 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)) + { + // No usable worktree yet -- if the task's list points at a git repo, create one + // on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync: + // branch naming, base commit resolution, worktree-root strategy, DB registration). + var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct); + if (list?.WorkingDir is null) + throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree."); + + await _wtManager.CreateAsync(task, list, ct); + worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct) + ?? throw new InvalidOperationException("Worktree creation did not persist a worktree row."); + isFreshWorktree = true; + } + else 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); + // A brand-new worktree has no prior session to resume, regardless of any session + // history the task accumulated before its previous worktree went away. + var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct); var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills); var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct); @@ -83,6 +104,22 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(worktree.Path, resolvedClaude, args, env); } + public Task BuildForDirectoryAsync(string directory, CancellationToken ct) + { + if (!Directory.Exists(directory)) + throw new InvalidOperationException($"Directory does not exist: {directory}"); + + var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) + ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + + var env = new Dictionary + { + ["MCP_TOOL_TIMEOUT"] = "200000", + }; + + return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty(), env)); + } + private async Task> FilterToInstalledSkillsAsync(IReadOnlyList requested, CancellationToken ct) { if (requested.Count == 0) return requested; diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs index b0fdc230..8a3719c9 100644 --- a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs @@ -2,9 +2,17 @@ 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. + /// 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. A task + /// that has never run, or whose worktree was just created fresh, gets a fresh-start spec + /// (no --resume); an existing worktree with a persisted SessionId gets --resume. Task BuildForTaskAsync(string taskId, CancellationToken ct); + + /// 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. + Task BuildForDirectoryAsync(string directory, CancellationToken ct); } diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 1dea0ecc..d7fa5f0b 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -107,6 +107,8 @@ public abstract class StubWorkerClient : IWorkerClient 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 GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(directory, "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 index 3f97a005..132e338e 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -1,9 +1,11 @@ +using ClaudeDo.Data.Git; 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 Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Runner; @@ -21,6 +23,9 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable private readonly string _claudeStubPath; private readonly FakeSessionSkillSeeder _seeder = new(); private readonly FakeSessionSkillRegistry _registry = new(); + private readonly List _gitFixtures = new(); + + private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public InteractiveLaunchSpecServiceTests() { @@ -34,9 +39,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable File.WriteAllText(_claudeStubPath, "stub"); } + private GitRepoFixture CreateRepo() + { + var f = new GitRepoFixture(); + _gitFixtures.Add(f); + return f; + } + public void Dispose() { _db.Dispose(); + foreach (var f in _gitFixtures) f.Dispose(); try { Directory.Delete(_tempDir, true); } catch { /* best effort */ } } @@ -52,15 +65,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable } private InteractiveLaunchSpecService BuildService() => - new(_db.CreateFactory(), _seeder, _registry, new WorkerConfig { ClaudeBin = _claudeStubPath }); + new(_db.CreateFactory(), _seeder, _registry, + new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance), + new WorkerConfig { ClaudeBin = _claudeStubPath }); - private async Task SeedListAsync() + private async Task SeedListAsync(string? workingDir = null) { 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, + Id = listId, Name = "L", WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow, }); return listId; } @@ -122,7 +137,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable } [Fact] - public async Task BuildForTaskAsync_NoWorktreeRow_ThrowsInvalidOperation() + public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ThrowsInvalidOperation() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); @@ -133,6 +148,43 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } + [Fact] + public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation() + { + var listId = await SeedListAsync(workingDir: null); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + } + + [Fact] + public async Task BuildForTaskAsync_FreshTask_NoWorktreeButGitRepoConfigured_CreatesWorktreeOnDemand() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var listId = await SeedListAsync(workingDir: repo.RepoDir); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + // No worktree row seeded -- task has never run. + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + using var readCtx = _db.CreateContext(); + var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId); + Assert.NotNull(wtRow); + Assert.Equal(WorktreeState.Active, wtRow!.State); + Assert.True(Directory.Exists(wtRow.Path)); + + Assert.Equal(wtRow.Path, spec.Cwd); + Assert.Empty(spec.Args); + Assert.Equal(_claudeStubPath, spec.Exe); + } + [Theory] [InlineData(WorktreeState.Merged)] [InlineData(WorktreeState.Discarded)] @@ -249,4 +301,25 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.True(call.IsWorktree); Assert.Equal(new[] { "installed-skill" }, call.SkillNames); } + + [Fact] + public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec() + { + var svc = BuildService(); + var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None); + + Assert.Equal(_tempDir, spec.Cwd); + Assert.Equal(_claudeStubPath, spec.Exe); + Assert.Empty(spec.Args); + Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); + Assert.Empty(_seeder.Calls); + } + + [Fact] + public async Task BuildForDirectoryAsync_NonExistentDirectory_ThrowsInvalidOperation() + { + var svc = BuildService(); + await Assert.ThrowsAsync( + () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); + } } diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index c839c3b4..1a5105a5 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -79,6 +79,8 @@ sealed class FakeWorkerClient : IWorkerClient 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 GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(directory, "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)