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
+5 -4
View File
@@ -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<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
@@ -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)