From 4a7b00ed539ab5d1031149f3d0c4e346ce701fe9 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 6 Aug 2026 15:53:46 +0200 Subject: [PATCH] fix(worker): strip trailing separator from the list repo in ConPTY launch args The ConPTY host flattens LaunchSpec.Args into one Windows command line and quotes each token, so a list working dir stored as "C:\repo\" produced the token "C:\repo\" -- whose trailing backslash escapes its own closing quote. Everything after it collapsed into --add-dir's variadic list, including --append-system-prompt-file and the positional kickoff, so "Let Claude handle it" opened a session with no prompt at all and the CLI warned that brief.md is not a directory. Only user-supplied working dirs can carry a trailing separator; the session dirs the worker builds never do. --- .../Runner/InteractiveLaunchSpecService.cs | 15 ++++++ .../InteractiveLaunchSpecServiceTests.cs | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 86137de9..776b5361 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -223,6 +223,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService var repoDir = list.WorkingDir; if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); + repoDir = TrimTrailingSeparator(repoDir); var briefLines = new List(); foreach (var id in taskIds) @@ -300,6 +301,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService var repoDir = list.WorkingDir; if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); + repoDir = TrimTrailingSeparator(repoDir); var briefLines = new List(); foreach (var id in survivingTaskIds) @@ -349,6 +351,19 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(repoDir, resolvedClaude, args, env); } + // Strips a trailing directory separator off a path bound for the CLI argument list. The ConPTY + // host flattens Args into ONE Windows command line and quotes each token, so a token ending in + // '\' escapes its own closing quote ("C:\repo\" parses as C:\repo" ...) and every following + // argument is absorbed into the preceding variadic flag -- for a list handler that means + // --add-dir swallows --append-system-prompt-file AND the positional kickoff, and the session + // opens with no prompt at all. Only user-supplied list working dirs can carry one; the session + // dirs we build never do. A bare root ("C:\", "/") is all separator and is left untouched. + private static string TrimTrailingSeparator(string dir) + { + var trimmed = dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return trimmed.Length == 0 || trimmed.EndsWith(':') ? dir : trimmed; + } + // Renders one task as a brief list item. A description can itself be arbitrary Markdown // (headings, lists, fenced code) — those must not merge into the brief's own task list, so // the description is placed in a fenced code block indented to the list item's continuation diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 41af9430..bb1514df 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -618,6 +618,31 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]); } + // A directory argument that keeps its trailing separator escapes its own closing quote once the + // ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal + // quote), so --add-dir's variadic list swallows every following argument -- including the + // positional kickoff, leaving the session with no prompt at all. + [Fact] + public async Task BuildForMergeHelperAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator() + { + var repo = Path.Combine(_tempDir, "repoTrailingSep"); + Directory.CreateDirectory(repo); + + var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing"); + var t1 = Guid.NewGuid().ToString(); + await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); + + var svc = BuildService(); + var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None); + var sessionDir = TrackSessionDir(spec); + + var args = spec.Args.ToList(); + var addIdx = args.IndexOf("--add-dir"); + var appendIdx = args.IndexOf("--append-system-prompt-file"); + Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1)); + Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/')); + } + [Fact] public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask() { @@ -826,6 +851,29 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Contains("working directory", ex.Message); } + [Fact] + public async Task BuildForMergeHelperHandoffAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator() + { + var repo = Path.Combine(_tempDir, "repoHandoffTrailingSep"); + Directory.CreateDirectory(repo); + + var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing"); + var handlerTaskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Trailing"); + var survivor = Guid.NewGuid().ToString(); + await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor"); + + var svc = BuildService(); + var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None); + var sessionDir = TrackSessionDir(spec); + + var args = spec.Args.ToList(); + var addIdx = args.IndexOf("--add-dir"); + var appendIdx = args.IndexOf("--append-system-prompt-file"); + Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1)); + Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/')); + } + [Fact] public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_NoNewTaskCreated() {