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
@@ -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()
{