Merge claudedo/c97dcaafbb3542bda9c670e502ffffb6

This commit is contained in:
mika kuns
2026-08-06 11:43:07 +02:00
10 changed files with 220 additions and 17 deletions
@@ -242,7 +242,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_worktreeDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
Assert.Equal("930000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
@@ -401,7 +401,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Empty(ArgsAfterEffort(spec));
Assert.Equal("930000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Empty(_seeder.Calls);
}
@@ -535,7 +535,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Equal("930000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
@@ -791,7 +791,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(survivor, handoffBrief);
Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
private async Task<int> CountTasksAsync()
@@ -841,4 +841,57 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal(_worktreeDir, spec.Cwd);
}
// Regression guard for the bug where BuildForMergeHelperHandoffAsync set MCP_TOOL_TIMEOUT to
// an older, shorter value (200000) than every other ConPTY spec (930000) after a parallel
// merge landed the two changes independently. Every spec this service builds must carry the
// SAME value, sourced from the one constant, so the value can never drift again.
[Fact]
public async Task AllBuiltSpecs_CarryTheSameMcpToolTimeout()
{
var repo = Path.Combine(_tempDir, "repoTimeoutCheck");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
await SeedWorktreeAsync(taskId, WorktreeState.Active);
await SeedRunAsync(taskId, "sess-timeout-check");
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");
var sessionDir = Path.Combine(_tempDir, "sess-timeout-check");
Directory.CreateDirectory(sessionDir);
var planningStartCtx = new PlanningSessionStartContext(
ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
Files: new PlanningSessionFiles(sessionDir,
Path.Combine(sessionDir, "system-prompt.md"),
Path.Combine(sessionDir, "initial-prompt.txt")));
var planningResumeCtx = new PlanningSessionResumeContext(
ParentTaskId: "p1", WorkingDir: _worktreeDir,
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var svc = BuildService();
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, CancellationToken.None);
TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None);
TrackSessionDir(handoffSpec);
var specs = new List<LaunchSpec>
{
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
mergeHelperSpec,
handoffSpec,
svc.BuildPlanningStart(planningStartCtx),
svc.BuildPlanningResume(planningResumeCtx),
};
Assert.All(specs, spec =>
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]));
}
}
@@ -79,4 +79,46 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable
releaseProcess.TrySetResult();
await runTask;
}
[Fact]
public async Task ContinueAsync_with_alreadyClaimed_broadcasts_TaskUpdated_exactly_once_before_the_run_finishes()
{
string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running,
StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
});
ctx.TaskRuns.Add(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
Prompt = "p", SessionId = "sess-1",
StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1),
ExitCode = 0, ResultMarkdown = "ok",
});
await ctx.SaveChangesAsync();
}
var processStarted = new TaskCompletionSource();
var releaseProcess = new TaskCompletionSource();
var fake = new FakeClaudeProcess(async (_, _, _, _, _) =>
{
processStarted.TrySetResult();
await releaseProcess.Task;
return new RunResult { ExitCode = 0, ResultMarkdown = "ok" };
});
var runner = BuildRunner(fake);
var runTask = runner.ContinueAsync(taskId, "follow up", "queue", CancellationToken.None, alreadyClaimed: true);
await processStarted.Task;
Assert.Single(_hubContext.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
releaseProcess.TrySetResult();
await runTask;
}
}