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:
@@ -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
|
// 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
|
// interactive Claude session for a task -- same worktree prep as an autonomous run when
|
||||||
// autonomous run (session-skills seeding, run env vars), --resume if the task has a
|
// the task's list points at a git repo (session-skills seeding, run env vars), or a
|
||||||
// persisted session or a fresh-start spec otherwise. Guards: no running/queued task,
|
// worktree-less spec opening directly in the list's WorkingDir when it isn't a repo.
|
||||||
// and (once a worktree exists) it must be live on disk.
|
// --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<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
|
public Task<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
|
||||||
{
|
{
|
||||||
if (_interactiveLaunchSpec is null)
|
if (_interactiveLaunchSpec is null)
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
namespace ClaudeDo.Worker.Runner;
|
namespace ClaudeDo.Worker.Runner;
|
||||||
|
|
||||||
// Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude
|
// 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 for a task -- the SAME worktree prep as an autonomous run when the task's list
|
||||||
// session-skills seeded onto disk (reuses ISessionSkillSeeder + TaskRunner.UnionSkillNames,
|
// points at a git repo: session-skills seeded onto disk (reuses ISessionSkillSeeder +
|
||||||
// exactly like TaskRunner.RunAsync/ContinueAsync) and the same run environment variables
|
// TaskRunner.UnionSkillNames, exactly like TaskRunner.RunAsync/ContinueAsync) and the same
|
||||||
// (matches ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's
|
// run environment variables (matches ClaudeProcess's MCP_TOOL_TIMEOUT). When the list's
|
||||||
// --resume argument construction. Guards: no running/queued task, and (once a worktree
|
// WorkingDir is not a git repo, no worktree is created at all -- the session opens directly
|
||||||
// exists) it must be live on disk -- but a never-run task (no persisted SessionId) is not
|
// in that directory (session-skills seeded there too, but isWorktree: false so no
|
||||||
// an error here, it's a fresh-start spec.
|
// .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
|
// 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
|
// 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 worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||||
var isFreshWorktree = false;
|
var isFreshWorktree = false;
|
||||||
|
string sessionDir;
|
||||||
|
bool sessionIsWorktree;
|
||||||
|
|
||||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
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
|
// 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:
|
// on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync:
|
||||||
// branch naming, base commit resolution, worktree-root strategy, DB registration).
|
// 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);
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct);
|
||||||
if (list?.WorkingDir is null)
|
if (list?.WorkingDir is null)
|
||||||
throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");
|
throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");
|
||||||
|
|
||||||
await _wtManager.CreateAsync(task, list, ct);
|
if (await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||||
worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
|
{
|
||||||
?? throw new InvalidOperationException("Worktree creation did not persist a worktree row.");
|
// A stale (Merged/Discarded) row is already tracked in `ctx`'s identity map from
|
||||||
isFreshWorktree = true;
|
// 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))
|
else if (!Directory.Exists(worktree.Path))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
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 listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct);
|
||||||
var globalSettings = await new AppSettingsRepository(ctx).GetAsync(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 requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
|
||||||
var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
|
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)
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||||
@@ -138,7 +169,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
|
return new LaunchSpec(sessionDir, resolvedClaude, args, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
|
public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
|
||||||
|
|||||||
@@ -148,21 +148,77 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 listId = await SeedListAsync();
|
||||||
var taskId = Guid.NewGuid().ToString();
|
var taskId = Guid.NewGuid().ToString();
|
||||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
|
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||||
() => 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]
|
[Fact]
|
||||||
public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation()
|
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();
|
var taskId = Guid.NewGuid().ToString();
|
||||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
|
|
||||||
@@ -202,16 +258,45 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(WorktreeState.Merged)]
|
[InlineData(WorktreeState.Merged)]
|
||||||
[InlineData(WorktreeState.Discarded)]
|
[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();
|
var taskId = Guid.NewGuid().ToString();
|
||||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||||
await SeedWorktreeAsync(taskId, state);
|
await SeedWorktreeAsync(taskId, state);
|
||||||
|
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||||
() => 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]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user