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:
@@ -194,7 +194,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.Equal(wtRow.Path, spec.Cwd);
|
||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||
|
||||
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec);
|
||||
var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
|
||||
Assert.Contains(briefPath, kickoff); // fresh session points claude at the brief file, not a raw prompt
|
||||
Assert.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title
|
||||
}
|
||||
@@ -272,10 +272,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title
|
||||
var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title
|
||||
Assert.Contains(briefPath, kickoff);
|
||||
Assert.Equal("T", File.ReadAllText(briefPath));
|
||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||
|
||||
// The generated session id is persisted BEFORE this call returns, so a closed/aborted
|
||||
// session still leaves a resumable id.
|
||||
Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -290,9 +294,73 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title
|
||||
var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title
|
||||
Assert.Contains(briefPath, kickoff);
|
||||
Assert.Equal("T", File.ReadAllText(briefPath));
|
||||
Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForTaskAsync_ExistingInteractiveSessionId_TakesPrecedenceOverRunSessionId()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
await SeedWorktreeAsync(taskId, WorktreeState.Active);
|
||||
await SeedRunAsync(taskId, "autonomous-run-session");
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "interactive-session-1");
|
||||
}
|
||||
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "--resume", "interactive-session-1" }, ArgsAfterEffort(spec));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForTaskAsync_ReopeningInteractiveSession_ResumesItsOwnPriorSessionId()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
await SeedWorktreeAsync(taskId, WorktreeState.Active);
|
||||
// No autonomous run at all -- only a prior interactive session for this task.
|
||||
|
||||
var svc = BuildService();
|
||||
var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec);
|
||||
|
||||
// Reopen (e.g. the pane was closed/aborted) -- must resume the SAME session id, not
|
||||
// start a new conversation.
|
||||
var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForTaskAsync_FreshlyRecreatedWorktree_IgnoresAndOverwritesStaleInteractiveSessionId()
|
||||
{
|
||||
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();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
// No worktree row seeded -- BuildForTaskAsync will create one fresh.
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "stale-interactive-session");
|
||||
}
|
||||
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
// Fresh-start, not a resume of the stale id.
|
||||
var (_, _, _, newSessionId) = ParseFreshTaskArgs(spec);
|
||||
Assert.NotEqual("stale-interactive-session", newSessionId);
|
||||
Assert.Equal(newSessionId, await ReadInteractiveSessionIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -312,7 +380,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec);
|
||||
var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
|
||||
Assert.Contains(briefPath, kickoff);
|
||||
Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath));
|
||||
}
|
||||
@@ -336,7 +404,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec);
|
||||
var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
|
||||
Assert.Contains(briefPath, kickoff);
|
||||
Assert.DoesNotContain('\n', kickoff);
|
||||
|
||||
@@ -364,7 +432,10 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Empty(ArgsAfterEffort(spec)); // no brief -> no --add-dir, no positional arg
|
||||
// no brief -> no --add-dir, no positional arg -- but a session id is still pre-assigned
|
||||
var rest = ArgsAfterEffort(spec);
|
||||
Assert.Equal("--session-id", rest[0]);
|
||||
Assert.Equal(rest[1], await ReadInteractiveSessionIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -427,22 +498,31 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// A fresh (never-resumed) task with a title/description produces
|
||||
/// `--add-dir <sessionDir> --effort <level> <kickoff>` -- the brief travels via the file at
|
||||
/// <sessionDir>/brief.md, never as a raw CLI argument. Asserts that exact shape and returns
|
||||
/// (sessionDir, briefPath, kickoff) for the test's own checks. Cleanup happens via
|
||||
/// `_seededTaskIds` in Dispose (the session dir is keyed by task id).
|
||||
private static (string SessionDir, string BriefPath, string Kickoff) ParseFreshTaskArgs(LaunchSpec spec)
|
||||
/// `--add-dir <sessionDir> --effort <level> --session-id <guid> <kickoff>` -- the brief
|
||||
/// travels via the file at <sessionDir>/brief.md, never as a raw CLI argument. Asserts that
|
||||
/// exact shape and returns (sessionDir, briefPath, kickoff, sessionId) for the test's own
|
||||
/// checks. Cleanup happens via `_seededTaskIds` in Dispose (the session dir is keyed by task id).
|
||||
private static (string SessionDir, string BriefPath, string Kickoff, string SessionId) ParseFreshTaskArgs(LaunchSpec spec)
|
||||
{
|
||||
var args = spec.Args.ToList();
|
||||
Assert.Equal(5, args.Count);
|
||||
Assert.Equal(7, args.Count);
|
||||
Assert.Equal("--add-dir", args[0]);
|
||||
var sessionDir = args[1];
|
||||
Assert.Equal("--effort", args[2]);
|
||||
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[3]);
|
||||
var kickoff = args[4];
|
||||
Assert.Equal("--session-id", args[4]);
|
||||
var sessionId = args[5];
|
||||
var kickoff = args[6];
|
||||
|
||||
var briefPath = Path.Combine(sessionDir, "brief.md");
|
||||
return (sessionDir, briefPath, kickoff);
|
||||
return (sessionDir, briefPath, kickoff, sessionId);
|
||||
}
|
||||
|
||||
private async Task<string?> ReadInteractiveSessionIdAsync(string taskId)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId);
|
||||
return task!.InteractiveSessionId;
|
||||
}
|
||||
|
||||
private readonly List<string> _mergeHelperSessionDirs = new();
|
||||
|
||||
Reference in New Issue
Block a user