feat(worker): resume a task's claude session in a terminal

This commit is contained in:
Mika Kuns
2026-07-23 16:47:13 +02:00
committed by mika kuns
parent 865e12c0de
commit 140ae2fda1
5 changed files with 176 additions and 1 deletions
+33
View File
@@ -575,6 +575,39 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public Task OpenInteractiveTerminalAsync(string taskId) =>
_interactive.StartAsync(taskId, Context.ConnectionAborted);
// Picks up a task's Claude session in a real terminal window (--resume) so the user can
// drive it by hand — distinct from OpenInteractiveTerminalAsync (the in-app streaming
// session). Only for tasks the worker isn't actively running, with a persisted session
// id and a live worktree.
public Task ResumeTaskInTerminal(string taskId) => HubGuard(async () =>
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
?? throw new KeyNotFoundException();
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
throw new InvalidOperationException("Can't pick up a running or queued task — interrupt it first.");
var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, Context.ConnectionAborted);
if (run?.SessionId is not { Length: > 0 } sessionId)
throw new InvalidOperationException("This task has no resumable Claude session yet.");
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
throw new InvalidOperationException("This task has no active worktree to resume in.");
if (!Directory.Exists(worktree.Path))
throw new InvalidOperationException("The task's worktree directory no longer exists.");
try
{
await _launcher.LaunchResumeAsync(worktree.Path, sessionId, Context.ConnectionAborted);
}
catch (TerminalLaunchException ex)
{
throw new InvalidOperationException(ex.Message);
}
});
public Task SendInteractiveMessage(string taskId, string text) =>
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
@@ -7,6 +7,10 @@ public interface ITerminalLauncher
{
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
// Resumes an arbitrary task's Claude session (--resume <id>) in a visible terminal so
// the user can pick up the conversation by hand — the "pick up in terminal" action.
Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken);
}
public sealed class TerminalLaunchException : Exception
@@ -88,6 +88,26 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
return Task.CompletedTask;
}
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
{
if (!Directory.Exists(workingDir))
throw new TerminalLaunchException($"Working directory does not exist: {workingDir}");
var resolvedWt = ResolveWtOrThrow();
var resolvedClaude = ResolveClaudeOrThrow();
var command = BuildResumeCommand(resolvedClaude, claudeSessionId);
StartInWindowsTerminal(resolvedWt, workingDir, command, static _ => { });
return Task.CompletedTask;
}
// Resumes a session by id in default (interactive) permission mode: the user drives
// tool approvals in the terminal, unlike planning which pins --permission-mode plan.
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
BuildPwshCommand(claudePath, new[] { "--resume", claudeSessionId });
// Builds the PowerShell command that launches an interactive planning session.
// Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the
// single-line kickoff prompt is positional, so it must follow a single-value flag
@@ -54,7 +54,7 @@ public sealed class PlanningHubTests : IDisposable
private WorkerHub CreateHub()
{
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!,
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
@@ -85,6 +85,88 @@ public sealed class PlanningHubTests : IDisposable
return (listId, task.Id);
}
private async Task SeedRunAsync(string taskId, string? sessionId)
{
await new TaskRunRepository(_ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
RunNumber = 1,
IsRetry = false,
Prompt = "p",
SessionId = sessionId,
});
}
private async Task<string> SeedWorktreeAsync(string taskId, WorktreeState state)
{
var path = Path.Combine(_rootDir, $"wt_{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
await new WorktreeRepository(_ctx).AddAsync(new WorktreeEntity
{
TaskId = taskId,
Path = path,
BranchName = "claudedo/x",
BaseCommit = "base",
State = state,
CreatedAt = DateTime.UtcNow,
});
return path;
}
[Fact]
public async Task ResumeTaskInTerminal_LaunchesWithWorktreePathAndSessionId()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-99");
var wtPath = await SeedWorktreeAsync(taskId, WorktreeState.Active);
var hub = CreateHub();
await hub.ResumeTaskInTerminal(taskId);
Assert.Equal(1, _launcher.LaunchTerminalResumeCalls);
Assert.Equal(wtPath, _launcher.LastResumeWorkingDir);
Assert.Equal("sess-99", _launcher.LastResumeSessionId);
}
[Fact]
public async Task ResumeTaskInTerminal_NoSession_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, sessionId: null);
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task ResumeTaskInTerminal_RunningTask_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-1");
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var task = await _tasks.GetByIdAsync(taskId);
task!.Status = TaskStatus.Running;
await _tasks.UpdateAsync(task);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task ResumeTaskInTerminal_NoWorktree_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-1");
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
{
@@ -192,6 +274,19 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher
LaunchResumeCalls++;
return Task.CompletedTask;
}
public int LaunchTerminalResumeCalls { get; private set; }
public string? LastResumeWorkingDir { get; private set; }
public string? LastResumeSessionId { get; private set; }
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchTerminalResumeCalls++;
LastResumeWorkingDir = workingDir;
LastResumeSessionId = claudeSessionId;
return Task.CompletedTask;
}
}
internal sealed class RecordingClientProxy : IClientProxy
@@ -69,4 +69,27 @@ public sealed class WindowsTerminalLauncherTests
// The legacy env-var indirection is gone.
Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command);
}
[Fact]
public void BuildResumeCommand_ResumesSessionSingleQuoted()
{
var command = WindowsTerminalLauncher.BuildResumeCommand("claude.exe", "sess-42");
Assert.Contains("--resume", command);
// The session id is single-quoted so ';' and friends pass through untouched.
Assert.Contains("'--resume' 'sess-42'", command);
// A plain resume drives the real interactive TUI — no planning-only flags.
Assert.DoesNotContain("--permission-mode", command);
Assert.DoesNotContain("--append-system-prompt-file", command);
}
[Fact]
public async Task LaunchResumeAsync_WorkingDirMissing_Throws()
{
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
var missing = Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid());
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
sut.LaunchResumeAsync(missing, "sess-1", CancellationToken.None));
Assert.Contains("Working directory", ex.Message);
}
}