feat(worker,data): persist interactive session id so a closed/aborted ConPTY session can be resumed
Generates the claude session id up front (--session-id <guid>) for a fresh interactive task session and persists it to TaskEntity.InteractiveSessionId before launch, so an abort at any point still leaves a resumable id. BuildForTaskAsync now resumes this task's own last interactive session in preference to the latest autonomous run's session, but never across a freshly (re)created worktree.
This commit is contained in:
@@ -104,12 +104,30 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
// The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
|
||||
var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model);
|
||||
|
||||
// Resume an existing session as-is; for a fresh session, seed the interactive TUI with
|
||||
// the task's brief (title + description) via a file, never as a positional CLI argument --
|
||||
// see BuildFreshTaskArgsAsync for why.
|
||||
var args = run?.SessionId is { Length: > 0 } sessionId
|
||||
? WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), effort)
|
||||
: await BuildFreshTaskArgsAsync(task, effort, ct);
|
||||
// Resume this task's own last interactive conversation when it has one -- it takes
|
||||
// precedence over the latest autonomous run's session, since an interactive session is a
|
||||
// distinct conversation from an autonomous run even against the same worktree. Fall back
|
||||
// to the autonomous run's session so opening a task interactively for the first time still
|
||||
// picks up prior context. Neither survives a freshly (re)created worktree (isFreshWorktree
|
||||
// already forced `run` to null above).
|
||||
var resumeSessionId = isFreshWorktree ? null : task.InteractiveSessionId ?? run?.SessionId;
|
||||
|
||||
// For a fresh session, seed the interactive TUI with the task's brief (title +
|
||||
// description) via a file, never as a positional CLI argument -- see
|
||||
// BuildFreshTaskArgsAsync for why. The session id claude will run under is generated and
|
||||
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
|
||||
// past startup -- still leaves an id the next open can resume.
|
||||
IReadOnlyList<string> args;
|
||||
if (resumeSessionId is { Length: > 0 })
|
||||
{
|
||||
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
|
||||
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
|
||||
}
|
||||
|
||||
// Same run environment variable ClaudeProcess sets for every headless run: the
|
||||
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
|
||||
@@ -445,11 +463,15 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
// Read; --effort (single-value) must sit directly before the positional kickoff so the
|
||||
// preceding variadic --add-dir doesn't swallow the kickoff as another directory.
|
||||
// No brief (task has neither a title nor a description) -> no positional arg at all.
|
||||
private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(TaskEntity task, string effort, CancellationToken ct)
|
||||
// `--session-id` pre-assigns the claude session id the caller already persisted (see
|
||||
// BuildForTaskAsync) so this fresh conversation is resumable from its very first turn --
|
||||
// it's a single-value flag, so it may sit directly before the positional kickoff.
|
||||
private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(
|
||||
TaskEntity task, string effort, string sessionId, CancellationToken ct)
|
||||
{
|
||||
var brief = BuildTaskBrief(task);
|
||||
if (string.IsNullOrEmpty(brief))
|
||||
return new[] { "--effort", effort };
|
||||
return new[] { "--effort", effort, "--session-id", sessionId };
|
||||
|
||||
var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id);
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
@@ -460,6 +482,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
{
|
||||
"--add-dir", sessionDir,
|
||||
"--effort", effort,
|
||||
"--session-id", sessionId,
|
||||
$"Read the file {briefPath} first. It contains the task you must work on. " +
|
||||
"After reading it, begin the session as your instructions describe.",
|
||||
};
|
||||
|
||||
@@ -17,9 +17,12 @@ public interface IInteractiveLaunchSpecService
|
||||
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
|
||||
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
|
||||
/// demand (same mechanism as an autonomous run) provided the task's list has a working
|
||||
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. A task
|
||||
/// that has never run, or whose worktree was just created fresh, gets a fresh-start spec
|
||||
/// (no --resume); an existing worktree with a persisted SessionId gets --resume.</summary>
|
||||
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
|
||||
/// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
|
||||
/// it has one, else the latest autonomous run's session; a task that has never run either
|
||||
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
||||
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
||||
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
||||
Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct);
|
||||
|
||||
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
||||
|
||||
Reference in New Issue
Block a user