fix(planning): recover the planning session id from the on-disk transcript
The interactive planning TUI never reports its claude session id back, so planning_session_id stayed NULL and UpdatePlanningSessionIdAsync had no caller at all -- ResumeAsync always threw "No Claude session ID captured yet". Resume now looks the id up in the transcript Claude Code writes to ~/.claude/projects/<encoded cwd>/<sessionId>.jsonl for the planning worktree (newest file wins; a worktree hosts exactly one session) and persists it, so the next resume is a plain DB read. No transcript -> a clear "cannot resume" instead of resuming the wrong session.
This commit is contained in:
@@ -21,9 +21,11 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
private readonly AppSettingsRepository _settingsRepo;
|
||||
private readonly PlanningSessionManager _sut;
|
||||
private readonly TaskStateServiceBuilder.Built _built;
|
||||
private readonly string _projectsRoot;
|
||||
|
||||
public PlanningSessionManagerTests()
|
||||
{
|
||||
_projectsRoot = Path.Combine(Path.GetTempPath(), $"cd_projects_{Guid.NewGuid():N}");
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
@@ -34,7 +36,8 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
|
||||
_sut = new PlanningSessionManager(
|
||||
_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain);
|
||||
_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain,
|
||||
_projectsRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -42,6 +45,20 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
|
||||
try { Directory.Delete(_projectsRoot, recursive: true); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Mirrors what Claude Code writes: ~/.claude/projects/<cwd, every non-alphanumeric char
|
||||
// replaced by '-'>/<sessionId>.jsonl
|
||||
private void WriteTranscript(string cwd, string sessionId, DateTime lastWriteUtc)
|
||||
{
|
||||
var encoded = string.Concat(Path.GetFullPath(cwd)
|
||||
.Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-'));
|
||||
var dir = Path.Combine(_projectsRoot, encoded);
|
||||
Directory.CreateDirectory(dir);
|
||||
var file = Path.Combine(dir, $"{sessionId}.jsonl");
|
||||
File.WriteAllText(file, "{}\n");
|
||||
File.SetLastWriteTimeUtc(file, lastWriteUtc);
|
||||
}
|
||||
|
||||
private async Task<(string listId, string workingDir)> SeedListAsync()
|
||||
@@ -165,17 +182,52 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_NoClaudeSessionId_Throws()
|
||||
public async Task ResumeAsync_NoSessionIdAndNoTranscript_Throws()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
// UpdatePlanningSessionIdAsync not called
|
||||
// Nothing persisted planning_session_id, and no transcript exists on disk.
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
// The interactive TUI never reports its session id, so planning_session_id stays NULL.
|
||||
// Resume has to recover it from the transcript Claude Code left for the planning worktree.
|
||||
[Fact]
|
||||
public async Task ResumeAsync_NoSessionId_RecoversNewestTranscriptAndPersistsIt()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
WriteTranscript(startCtx.WorktreePath, "older-session", new DateTime(2026, 8, 1, 10, 0, 0, DateTimeKind.Utc));
|
||||
WriteTranscript(startCtx.WorktreePath, "newest-session", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("newest-session", resumeCtx.ClaudeSessionId);
|
||||
var reloaded = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal("newest-session", reloaded!.PlanningSessionId);
|
||||
}
|
||||
|
||||
// A transcript must never override an id we already captured.
|
||||
[Fact]
|
||||
public async Task ResumeAsync_ExistingSessionId_IgnoresTranscript()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "persisted-session");
|
||||
|
||||
WriteTranscript(startCtx.WorktreePath, "transcript-session", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("persisted-session", resumeCtx.ClaudeSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user