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:
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user