fix(claude-do): merge fix(worker): List-Handler-Session kennt ihre eigene Handler-

ClaudeDo-Task: 997d0ffa-48ae-42d7-bea0-3aed4cf81768
This commit is contained in:
mika kuns
2026-08-21 18:09:02 +02:00
12 changed files with 93 additions and 24 deletions
@@ -360,6 +360,13 @@ public class PromptFilesTests
Assert.Contains("{tasks}", d);
}
[Fact]
public void DefaultFor_merge_helper_initial_has_handler_task_id_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
Assert.Contains("{handlerTaskId}", d);
}
[Fact]
public void RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks()
{
@@ -382,6 +389,13 @@ public class PromptFilesTests
Assert.Contains("{tasks}", d);
}
[Fact]
public void DefaultFor_merge_helper_handoff_has_handler_task_id_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff);
Assert.Contains("{handlerTaskId}", d);
}
[Fact]
public void DefaultFor_merge_helper_handoff_points_at_phase_3()
{
+1 -1
View File
@@ -118,7 +118,7 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -502,7 +502,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed");
}
@@ -628,7 +628,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), CancellationToken.None));
}
[Fact]
@@ -640,7 +640,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
@@ -653,7 +653,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), CancellationToken.None));
}
[Fact]
@@ -669,7 +669,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd);
@@ -726,7 +726,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList();
@@ -749,7 +749,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -761,6 +761,28 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(t2, brief);
}
// Regression guard: no session ever learned its own handler task id, so it could never call
// handoff_list_handler(taskId, ...) on itself -- the handoff chain broke exactly where it was
// needed. The brief must carry it.
[Fact]
public async Task BuildForMergeHelperAsync_BriefIncludesHandlerTaskId()
{
var repo = Path.Combine(_tempDir, "repoHandlerId");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task");
var handlerTaskId = Guid.NewGuid().ToString();
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
Assert.Contains(handlerTaskId, brief);
}
[Fact]
public async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription()
{
@@ -773,7 +795,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
description: "Do the thing carefully and report back.");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -791,7 +813,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -822,7 +844,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -967,6 +989,26 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/'));
}
// Regression guard: the handoff session must see the SAME handler task id as the run that
// called handoff_list_handler -- it's the id already passed in as taskId, just never rendered
// into the handoff kickoff.
[Fact]
public async Task BuildForMergeHelperHandoffAsync_HandoffIncludesSameHandlerTaskId()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview);
var svc = BuildService();
var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var handoff = File.ReadAllText(Path.Combine(sessionDir, "handoff.md"));
Assert.Contains(handlerTaskId, handoff);
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_UnknownPhase_Throws()
{
@@ -1126,7 +1168,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, CancellationToken.None);
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
var triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec);
@@ -1177,7 +1219,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var svc = BuildService();
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, CancellationToken.None);
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
TrackSessionDir(handoffSpec);
@@ -85,7 +85,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)