feat(worker): resume a task's claude session in a terminal
This commit is contained in:
@@ -575,6 +575,39 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
public Task OpenInteractiveTerminalAsync(string taskId) =>
|
public Task OpenInteractiveTerminalAsync(string taskId) =>
|
||||||
_interactive.StartAsync(taskId, Context.ConnectionAborted);
|
_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) =>
|
public Task SendInteractiveMessage(string taskId, string text) =>
|
||||||
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
|
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ public interface ITerminalLauncher
|
|||||||
{
|
{
|
||||||
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||||
Task LaunchPlanningResumeAsync(PlanningSessionResumeContext 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
|
public sealed class TerminalLaunchException : Exception
|
||||||
|
|||||||
@@ -88,6 +88,26 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
|
|||||||
return Task.CompletedTask;
|
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.
|
// Builds the PowerShell command that launches an interactive planning session.
|
||||||
// Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the
|
// 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
|
// 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()
|
private WorkerHub CreateHub()
|
||||||
{
|
{
|
||||||
var hub = new WorkerHub(
|
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!,
|
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
||||||
@@ -85,6 +85,88 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
return (listId, task.Id);
|
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]
|
[Fact]
|
||||||
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
||||||
{
|
{
|
||||||
@@ -192,6 +274,19 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher
|
|||||||
LaunchResumeCalls++;
|
LaunchResumeCalls++;
|
||||||
return Task.CompletedTask;
|
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
|
internal sealed class RecordingClientProxy : IClientProxy
|
||||||
|
|||||||
@@ -69,4 +69,27 @@ public sealed class WindowsTerminalLauncherTests
|
|||||||
// The legacy env-var indirection is gone.
|
// The legacy env-var indirection is gone.
|
||||||
Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user