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:
mika kuns
2026-08-06 09:10:50 +02:00
parent e113987d2e
commit b6791265d1
2 changed files with 113 additions and 8 deletions
@@ -22,6 +22,7 @@ public sealed class PlanningSessionManager
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _rootDirectory;
private readonly string _projectsRoot;
private readonly ITaskStateService? _state;
private readonly PlanningChainCoordinator? _chain;
@@ -32,7 +33,8 @@ public sealed class PlanningSessionManager
WorkerConfig cfg,
ITaskStateService state,
PlanningChainCoordinator chain,
string rootDirectory)
string rootDirectory,
string? projectsRoot = null)
{
_factory = factory;
_git = git;
@@ -40,6 +42,7 @@ public sealed class PlanningSessionManager
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
}
// Test constructor.
@@ -51,7 +54,8 @@ public sealed class PlanningSessionManager
WorkerConfig cfg,
string rootDirectory,
ITaskStateService? state = null,
PlanningChainCoordinator? chain = null)
PlanningChainCoordinator? chain = null,
string? projectsRoot = null)
{
_tasksOverride = tasks;
_listsOverride = lists;
@@ -61,6 +65,7 @@ public sealed class PlanningSessionManager
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
}
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
@@ -235,8 +240,6 @@ public sealed class PlanningSessionManager
if (task.PlanningPhase != PlanningPhase.Active)
throw new InvalidOperationException(
$"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);
if (!Directory.Exists(sessionDir))
@@ -252,12 +255,26 @@ public sealed class PlanningSessionManager
if (!Directory.Exists(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);
return new PlanningSessionResumeContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
ClaudeSessionId: task.PlanningSessionId,
ClaudeSessionId: sessionId,
Token: token,
WorktreePath: worktreePath);
}
@@ -387,4 +404,40 @@ public sealed class PlanningSessionManager
throw new InvalidOperationException($"Token file missing: {path}");
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/&lt;encoded cwd&gt;/&lt;sessionId&gt;.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] : '-';
});
}
}