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:
@@ -22,6 +22,7 @@ public sealed class PlanningSessionManager
|
|||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly string _rootDirectory;
|
private readonly string _rootDirectory;
|
||||||
|
private readonly string _projectsRoot;
|
||||||
private readonly ITaskStateService? _state;
|
private readonly ITaskStateService? _state;
|
||||||
private readonly PlanningChainCoordinator? _chain;
|
private readonly PlanningChainCoordinator? _chain;
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ public sealed class PlanningSessionManager
|
|||||||
WorkerConfig cfg,
|
WorkerConfig cfg,
|
||||||
ITaskStateService state,
|
ITaskStateService state,
|
||||||
PlanningChainCoordinator chain,
|
PlanningChainCoordinator chain,
|
||||||
string rootDirectory)
|
string rootDirectory,
|
||||||
|
string? projectsRoot = null)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
_git = git;
|
_git = git;
|
||||||
@@ -40,6 +42,7 @@ public sealed class PlanningSessionManager
|
|||||||
_state = state;
|
_state = state;
|
||||||
_chain = chain;
|
_chain = chain;
|
||||||
_rootDirectory = rootDirectory;
|
_rootDirectory = rootDirectory;
|
||||||
|
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test constructor.
|
// Test constructor.
|
||||||
@@ -51,7 +54,8 @@ public sealed class PlanningSessionManager
|
|||||||
WorkerConfig cfg,
|
WorkerConfig cfg,
|
||||||
string rootDirectory,
|
string rootDirectory,
|
||||||
ITaskStateService? state = null,
|
ITaskStateService? state = null,
|
||||||
PlanningChainCoordinator? chain = null)
|
PlanningChainCoordinator? chain = null,
|
||||||
|
string? projectsRoot = null)
|
||||||
{
|
{
|
||||||
_tasksOverride = tasks;
|
_tasksOverride = tasks;
|
||||||
_listsOverride = lists;
|
_listsOverride = lists;
|
||||||
@@ -61,6 +65,7 @@ public sealed class PlanningSessionManager
|
|||||||
_state = state;
|
_state = state;
|
||||||
_chain = chain;
|
_chain = chain;
|
||||||
_rootDirectory = rootDirectory;
|
_rootDirectory = rootDirectory;
|
||||||
|
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
|
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
|
||||||
@@ -235,8 +240,6 @@ public sealed class PlanningSessionManager
|
|||||||
if (task.PlanningPhase != PlanningPhase.Active)
|
if (task.PlanningPhase != PlanningPhase.Active)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Task planning phase is {task.PlanningPhase}; resume requires Active planning.");
|
$"Task planning phase is {task.PlanningPhase}; resume requires Active planning.");
|
||||||
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
|
||||||
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
|
||||||
|
|
||||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
if (!Directory.Exists(sessionDir))
|
if (!Directory.Exists(sessionDir))
|
||||||
@@ -252,12 +255,26 @@ public sealed class PlanningSessionManager
|
|||||||
if (!Directory.Exists(worktreePath))
|
if (!Directory.Exists(worktreePath))
|
||||||
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
|
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
|
||||||
|
|
||||||
|
// The planning session runs in an interactive TUI, which never reports its session id
|
||||||
|
// back to us — so nothing ever wrote planning_session_id. Recover it from the transcript
|
||||||
|
// Claude Code leaves on disk for the planning worktree, and persist it so the next
|
||||||
|
// resume is a plain DB read.
|
||||||
|
var sessionId = task.PlanningSessionId;
|
||||||
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
|
{
|
||||||
|
sessionId = PlanningTranscriptLocator.FindSessionId(worktreePath, _projectsRoot)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"No Claude session transcript found for this planning worktree; cannot resume. " +
|
||||||
|
"Finalize or discard the planning session instead.");
|
||||||
|
await tasks.UpdatePlanningSessionIdAsync(taskId, sessionId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
|
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
|
||||||
|
|
||||||
return new PlanningSessionResumeContext(
|
return new PlanningSessionResumeContext(
|
||||||
ParentTaskId: taskId,
|
ParentTaskId: taskId,
|
||||||
WorkingDir: worktreePath,
|
WorkingDir: worktreePath,
|
||||||
ClaudeSessionId: task.PlanningSessionId,
|
ClaudeSessionId: sessionId,
|
||||||
Token: token,
|
Token: token,
|
||||||
WorktreePath: worktreePath);
|
WorktreePath: worktreePath);
|
||||||
}
|
}
|
||||||
@@ -387,4 +404,40 @@ public sealed class PlanningSessionManager
|
|||||||
throw new InvalidOperationException($"Token file missing: {path}");
|
throw new InvalidOperationException($"Token file missing: {path}");
|
||||||
return (await File.ReadAllTextAsync(path, ct)).Trim();
|
return (await File.ReadAllTextAsync(path, ct)).Trim();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recovers the Claude session id of an interactive session from the transcript Claude Code
|
||||||
|
/// writes to <c>~/.claude/projects/<encoded cwd>/<sessionId>.jsonl</c>. A planning
|
||||||
|
/// worktree is used by exactly one session, so the newest transcript in that folder is it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The folder-name encoding is undocumented CLI behaviour (every non-alphanumeric character in
|
||||||
|
/// the absolute path becomes '-'). If it ever changes, <see cref="FindSessionId"/> returns null
|
||||||
|
/// and the caller reports "cannot resume" instead of resuming the wrong session.
|
||||||
|
/// </remarks>
|
||||||
|
public static class PlanningTranscriptLocator
|
||||||
|
{
|
||||||
|
public static string? FindSessionId(string worktreePath, string projectsRoot)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(projectsRoot, EncodeCwd(worktreePath));
|
||||||
|
if (!Directory.Exists(dir)) return null;
|
||||||
|
|
||||||
|
var newest = new DirectoryInfo(dir)
|
||||||
|
.EnumerateFiles("*.jsonl")
|
||||||
|
.OrderByDescending(f => f.LastWriteTimeUtc)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return newest is null ? null : Path.GetFileNameWithoutExtension(newest.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncodeCwd(string path)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(path);
|
||||||
|
return string.Create(full.Length, full, static (span, src) =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < src.Length; i++)
|
||||||
|
span[i] = char.IsAsciiLetterOrDigit(src[i]) ? src[i] : '-';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,9 +21,11 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
private readonly AppSettingsRepository _settingsRepo;
|
private readonly AppSettingsRepository _settingsRepo;
|
||||||
private readonly PlanningSessionManager _sut;
|
private readonly PlanningSessionManager _sut;
|
||||||
private readonly TaskStateServiceBuilder.Built _built;
|
private readonly TaskStateServiceBuilder.Built _built;
|
||||||
|
private readonly string _projectsRoot;
|
||||||
|
|
||||||
public PlanningSessionManagerTests()
|
public PlanningSessionManagerTests()
|
||||||
{
|
{
|
||||||
|
_projectsRoot = Path.Combine(Path.GetTempPath(), $"cd_projects_{Guid.NewGuid():N}");
|
||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_tasks = new TaskRepository(_ctx);
|
_tasks = new TaskRepository(_ctx);
|
||||||
_lists = new ListRepository(_ctx);
|
_lists = new ListRepository(_ctx);
|
||||||
@@ -34,7 +36,8 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||||
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
|
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
|
||||||
_sut = new PlanningSessionManager(
|
_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()
|
public void Dispose()
|
||||||
@@ -42,6 +45,20 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
_ctx.Dispose();
|
_ctx.Dispose();
|
||||||
_db.Dispose();
|
_db.Dispose();
|
||||||
try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
|
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()
|
private async Task<(string listId, string workingDir)> SeedListAsync()
|
||||||
@@ -165,17 +182,52 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResumeAsync_NoClaudeSessionId_Throws()
|
public async Task ResumeAsync_NoSessionIdAndNoTranscript_Throws()
|
||||||
{
|
{
|
||||||
var (listId, _) = await SeedListAsync();
|
var (listId, _) = await SeedListAsync();
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
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>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
_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]
|
[Fact]
|
||||||
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized()
|
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user