fix(claude-do): fix(worker): Interaktive Session auf einem Nicht-Repo-Workin

Eine interaktive ("Quick") Session soll auf Pfaden, die kein Git-Repo sind, keinen Worktree erzwingen, sondern direkt im ausgewählten Ordner starten. Der User hat entschieden: BEIDE Einstiegspunkte prüfen — den task-gebundenen Pfad fixen, den Ad-hoc-Pfad verifizieren.

## Pfad 1 (der eigentliche Fix) — task-gebundene Session

- Einstieg: Kontextmenü der Task-Zeile "Open interactive session" — src/

ClaudeDo-Task: 15e126d564874973be2a5bbe7d796646
This commit is contained in:
mika kuns
2026-08-21 16:36:11 +02:00
parent 8dce2d0898
commit 0d1dd169ac
3 changed files with 142 additions and 25 deletions
@@ -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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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]