diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 4fb4fcde..44e997bd 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -838,10 +838,11 @@ 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: no running/queued task, - // and (once a worktree exists) it must be live on disk. + // interactive Claude session for a task -- same worktree prep as an autonomous run when + // the task's list points at a git repo (session-skills seeding, run env vars), or a + // worktree-less spec opening directly in the list's WorkingDir when it isn't a repo. + // --resume if the task has a persisted session or a fresh-start spec otherwise. Guards: + // no running/queued task, and (once a worktree exists) it must be live on disk. public Task GetInteractiveLaunchSpec(string taskId) => HubGuard(() => { if (_interactiveLaunchSpec is null) diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 097a292d..c93d09b2 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -12,13 +12,16 @@ 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 -// (matches ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's -// --resume argument construction. Guards: no running/queued task, and (once a worktree -// exists) it must be live on disk -- but a never-run task (no persisted SessionId) is not -// an error here, it's a fresh-start spec. +// session for a task -- the SAME worktree prep as an autonomous run when the task's list +// points at a git repo: session-skills seeded onto disk (reuses ISessionSkillSeeder + +// TaskRunner.UnionSkillNames, exactly like TaskRunner.RunAsync/ContinueAsync) and the same +// run environment variables (matches ClaudeProcess's MCP_TOOL_TIMEOUT). When the list's +// WorkingDir is not a git repo, no worktree is created at all -- the session opens directly +// in that directory (session-skills seeded there too, but isWorktree: false so no +// .git/info/exclude write is attempted). Exe/Args reuse WindowsTerminalLauncher's --resume +// argument construction. Guards: no running/queued task, and (once a worktree exists) it +// must be live on disk -- but 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 @@ -67,25 +70,53 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); var isFreshWorktree = false; + string sessionDir; + bool sessionIsWorktree; if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept)) { // 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). + // If the list's directory isn't a git repo at all, there is nothing to branch from -- + // open the session directly in that directory instead, worktree-less. This mirrors an + // existing shape in the codebase (a list-handler task also has no worktree and commits + // straight to the list's working dir), so no WorktreeEntity is created here either. 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; + if (await _git.IsGitRepoAsync(list.WorkingDir, ct)) + { + // A stale (Merged/Discarded) row is already tracked in `ctx`'s identity map from + // the read above -- CreateAsync replaces it via its OWN context, so re-querying on + // `ctx` without detaching first would hand back the old cached instance instead of + // the freshly created row. + if (worktree is not null) + ctx.Entry(worktree).State = EntityState.Detached; + + 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; + sessionDir = worktree.Path; + sessionIsWorktree = true; + } + else + { + sessionDir = list.WorkingDir; + sessionIsWorktree = false; + } } else if (!Directory.Exists(worktree.Path)) { throw new InvalidOperationException("The task's worktree directory no longer exists."); } + else + { + sessionDir = worktree.Path; + sessionIsWorktree = true; + } var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct); var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct); @@ -95,7 +126,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService 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); + await _skillSeeder.SeedAsync(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); @@ -138,7 +169,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs, }; - return new LaunchSpec(worktree.Path, resolvedClaude, args, env); + return new LaunchSpec(sessionDir, resolvedClaude, args, env); } public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx) diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index d109f7be..eae10ea2 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -148,21 +148,77 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable } [Fact] - public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ThrowsInvalidOperation() + public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_OpensDirectlyInWorkingDir_NoWorktreeCreated() + { + var listId = await SeedListAsync(); // default WorkingDir (_tempDir) is not a git repo + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(_tempDir, spec.Cwd); + Assert.Equal(_claudeStubPath, spec.Exe); + + using var readCtx = _db.CreateContext(); + Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId)); + } + + [Fact] + public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_SeedsSessionSkillsWithIsWorktreeFalse() + { + _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\"]"); + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(_tempDir, spec.Cwd); + var call = Assert.Single(_seeder.Calls); + Assert.Equal(_tempDir, call.WorkingDir); + Assert.False(call.IsWorktree); + Assert.Equal(new[] { "installed-skill" }, call.SkillNames); + } + + [Fact] + public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ReopeningResumesItsOwnPriorSessionId() { 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)); + var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec); + + var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(_tempDir, secondSpec.Cwd); + Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec)); + + using var readCtx = _db.CreateContext(); + Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId)); } [Fact] public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation() { - var listId = await SeedListAsync(workingDir: null); + var listId = await SeedListAsync(); + using (var ctx = _db.CreateContext()) + { + var list = await ctx.Lists.FindAsync(listId); + list!.WorkingDir = null; // distinct from "not a git repo" -- no directory at all + await ctx.SaveChangesAsync(); + } var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); @@ -202,16 +258,45 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable [Theory] [InlineData(WorktreeState.Merged)] [InlineData(WorktreeState.Discarded)] - public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_ThrowsInvalidOperation(WorktreeState state) + public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_GitRepoConfigured_CreatesFreshWorktree(WorktreeState state) { - var listId = await SeedListAsync(); + 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); await SeedWorktreeAsync(taskId, state); var svc = BuildService(); - await Assert.ThrowsAsync( - () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + 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.Equal(wtRow.Path, spec.Cwd); + } + + [Theory] + [InlineData(WorktreeState.Merged)] + [InlineData(WorktreeState.Discarded)] + public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_NotAGitRepo_OpensDirectlyInWorkingDir(WorktreeState state) + { + var listId = await SeedListAsync(); // default WorkingDir (_tempDir) is not a git repo + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, state); + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(_tempDir, spec.Cwd); + + using var readCtx = _db.CreateContext(); + var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId); + Assert.NotNull(wtRow); + Assert.Equal(state, wtRow!.State); // the stale row is left untouched, not deleted or reused } [Fact]