Path.GetDirectoryName returned workingDir itself instead of its parent whenever list.WorkingDir ended in a directory separator, placing .claudedo-worktrees inside the target repo's own working tree. Add WorktreeRootResolver to normalize the trailing separator before deriving the sibling root and to guard that the resulting worktree path never lands inside workingDir, used by both WorktreeManager and PlanningSessionManager. Add a startup sweep (LegacyWorktreeFolderRecovery) that warns when a list's working dir already contains a leftover .claudedo-worktrees folder from before the fix.
378 lines
15 KiB
C#
378 lines
15 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Planning;
|
|
|
|
public sealed class PlanningSessionManagerTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly string _rootDir;
|
|
private readonly GitService _git;
|
|
private readonly WorkerConfig _cfg;
|
|
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);
|
|
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}");
|
|
_git = new GitService();
|
|
_cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
|
|
_settingsRepo = new AppSettingsRepository(_ctx);
|
|
_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,
|
|
_projectsRoot);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_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()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
|
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
|
await _lists.AddAsync(new ListEntity
|
|
{
|
|
Id = listId,
|
|
Name = "Test",
|
|
WorkingDir = wd,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
return (listId, wd);
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedManualTaskAsync(string listId)
|
|
{
|
|
var t = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "Brainstorm auth",
|
|
Description = "- review tokens\n- plan rollout",
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "feat",
|
|
};
|
|
await _tasks.AddAsync(t);
|
|
return t;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning()
|
|
{
|
|
var (listId, wd) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
|
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(parent.Id, ctx.ParentTaskId);
|
|
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
|
|
Assert.True(File.Exists(mcpPath));
|
|
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
|
|
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
|
|
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
|
|
|
|
var mcp = await File.ReadAllTextAsync(mcpPath);
|
|
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
|
|
Assert.DoesNotContain(ctx.Token, mcp);
|
|
|
|
var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath);
|
|
Assert.Contains("Brainstorm auth", initial);
|
|
Assert.Contains("review tokens", initial);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
|
Assert.Equal(PlanningPhase.Active, loaded.PlanningPhase);
|
|
Assert.NotNull(loaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_TrailingSeparatorOnWorkingDir_PlacesWorktreeOutsideRepo()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
|
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
|
await _lists.AddAsync(new ListEntity
|
|
{
|
|
Id = listId,
|
|
Name = "Test",
|
|
WorkingDir = wd + Path.DirectorySeparatorChar,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
|
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
|
|
var wdFull = Path.GetFullPath(wd);
|
|
Assert.False(
|
|
ctx.WorktreePath.StartsWith(wdFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase),
|
|
$"worktree path {ctx.WorktreePath} should not be nested inside the repo dir {wdFull}");
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_TaskNotManual_Throws()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var queuedTask = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "x",
|
|
Status = TaskStatus.Queued,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "feat",
|
|
};
|
|
await _tasks.AddAsync(queuedTask);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_sut.StartAsync(queuedTask.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_ChildTask_Throws()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_sut.StartAsync(child.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumeAsync_ReturnsExistingSessionDetails()
|
|
{
|
|
var (listId, wd) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42");
|
|
|
|
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(parent.Id, resumeCtx.ParentTaskId);
|
|
Assert.Equal(resumeCtx.WorktreePath, resumeCtx.WorkingDir);
|
|
Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId);
|
|
Assert.True(Directory.Exists(resumeCtx.WorktreePath));
|
|
Assert.True(File.Exists(Path.Combine(resumeCtx.WorktreePath, ".mcp.json")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumeAsync_NotPlanning_Throws()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
// did not start
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumeAsync_NoSessionIdAndNoTranscript_Throws()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
// 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()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
|
|
|
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
|
|
|
Assert.Equal(2, count);
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase);
|
|
Assert.NotNull(loaded.PlanningFinalizedAt);
|
|
Assert.Null(loaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPendingDraftCountAsync_ReturnsDraftCount()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
|
await _tasks.CreateChildAsync(parent.Id, "c3", null, null);
|
|
|
|
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(3, n);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
|
|
{
|
|
var (listId, _) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
|
|
|
await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None);
|
|
|
|
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
|
Assert.Equal(PlanningPhase.None, loaded.PlanningPhase);
|
|
Assert.Null(loaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardAsync_RemovesWorktreeAndBranch()
|
|
{
|
|
var (listId, wd) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
|
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
|
|
await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None);
|
|
|
|
Assert.False(Directory.Exists(ctx.WorktreePath));
|
|
// branch deleted
|
|
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
|
|
Assert.Empty(paths);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(wd);
|
|
try
|
|
{
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
|
var t = await SeedManualTaskAsync(listId);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(wd, recursive: true); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
|
|
{
|
|
var (listId, wd) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
|
|
// Pre-create a colliding branch.
|
|
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
|
|
var head = await _git.RevParseHeadAsync(wd);
|
|
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
|
|
procInfo.ArgumentList.Add("branch");
|
|
procInfo.ArgumentList.Add(branch);
|
|
procInfo.ArgumentList.Add(head);
|
|
var p = System.Diagnostics.Process.Start(procInfo)!;
|
|
p.WaitForExit();
|
|
Assert.True(p.ExitCode == 0, $"git branch setup failed with exit {p.ExitCode}");
|
|
|
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
|
|
{
|
|
var (listId, wd) = await SeedListAsync();
|
|
var parent = await SeedManualTaskAsync(listId);
|
|
|
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
|
|
|
// Simulate the claude session capturing its session id.
|
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc");
|
|
|
|
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(startCtx.Token, resumeCtx.Token);
|
|
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
|
|
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
|
|
}
|
|
} |