diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 3df4b60b..a8f22759 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -75,7 +75,8 @@ public sealed class InteractiveLaunchSpecService /// 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 + /// directory pointing at a git repo; a non-repo directory is opened directly, and a list with + /// no working directory at all (manual/reminder list) opens in the user's home dir. 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- @@ -94,6 +95,7 @@ public sealed class InteractiveLaunchSpecService var isFreshWorktree = false; string sessionDir; bool sessionIsWorktree; + var seedSkills = true; if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept)) { @@ -105,10 +107,17 @@ public sealed class InteractiveLaunchSpecService // 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."); - - if (await _git.IsGitRepoAsync(list.WorkingDir, ct)) + if (list is null || string.IsNullOrEmpty(list.WorkingDir)) + { + // A manual/reminder list has no repo at all -- open the session in the user's + // home directory instead of refusing it. Skills are deliberately NOT seeded + // there: the destination would be the user's REAL ~/.claude/skills, not a + // throwaway session copy. + sessionDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + sessionIsWorktree = false; + seedSkills = false; + } + else 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 @@ -146,9 +155,12 @@ public sealed class InteractiveLaunchSpecService // 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); - await _skillSeeder.SeedAsync(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct); + if (seedSkills) + { + var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills); + var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct); + await _skillSeeder.SeedAsync(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct); + } var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 0703fdf4..e3c500af 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -220,8 +220,15 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable } [Fact] - public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation() + public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_OpensInUserHomeWithoutSeedingSkills() { + _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(); using (var ctx = _db.CreateContext()) { @@ -230,11 +237,18 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable await ctx.SaveChangesAsync(); } var taskId = Guid.NewGuid().ToString(); - await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle, + sessionSkillsJson: "[\"installed-skill\"]"); var svc = BuildService(); - await Assert.ThrowsAsync( - () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), spec.Cwd); + // Seeding here would copy into the user's REAL ~/.claude/skills. + Assert.Empty(_seeder.Calls); + + using var readCtx = _db.CreateContext(); + Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId)); } [Fact]