diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 6dc58649..9e8358b6 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -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(); } +} + +/// +/// Recovers the Claude session id of an interactive session from the transcript Claude Code +/// writes to ~/.claude/projects/<encoded cwd>/<sessionId>.jsonl. A planning +/// worktree is used by exactly one session, so the newest transcript in that folder is it. +/// +/// +/// The folder-name encoding is undocumented CLI behaviour (every non-alphanumeric character in +/// the absolute path becomes '-'). If it ever changes, returns null +/// and the caller reports "cannot resume" instead of resuming the wrong session. +/// +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] : '-'; + }); + } } \ No newline at end of file diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index 6fcb7162..1e273113 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -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//.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(() => _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() {