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.
This commit is contained in:
mika kuns
2026-08-06 15:53:46 +02:00
parent bf19e84e76
commit 4a7b00ed53
2 changed files with 63 additions and 0 deletions
@@ -223,6 +223,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var repoDir = list.WorkingDir; var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
repoDir = TrimTrailingSeparator(repoDir);
var briefLines = new List<string>(); var briefLines = new List<string>();
foreach (var id in taskIds) foreach (var id in taskIds)
@@ -300,6 +301,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var repoDir = list.WorkingDir; var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
repoDir = TrimTrailingSeparator(repoDir);
var briefLines = new List<string>(); var briefLines = new List<string>();
foreach (var id in survivingTaskIds) foreach (var id in survivingTaskIds)
@@ -349,6 +351,19 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(repoDir, resolvedClaude, args, env); 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 // 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 // (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 // the description is placed in a fenced code block indented to the list item's continuation
@@ -618,6 +618,31 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]); 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] [Fact]
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask() public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
{ {
@@ -826,6 +851,29 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains("working directory", ex.Message); 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] [Fact]
public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_NoNewTaskCreated() public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_NoNewTaskCreated()
{ {