fix(worker): thread handler task id into list-handler kickoff prompts
Neither the initial nor the handoff kickoff ever told a list-handler
session its own handler task id, so handoff_list_handler(taskId, ...)
was unrenderable -- the handoff chain broke exactly where it was needed
(#200/#201 on 2026-08-21).
Add {handlerTaskId} to both MergeHelperInitialDefault and
MergeHelperHandoffDefault, thread a handlerTaskId parameter through
BuildForMergeHelperAsync (interface, WorkerHub.GetMergeHelperLaunchSpec,
IWorkerClient/WorkerClient, and the MissionControlViewModel call site,
which already had the id from CreateMergeHelperTaskAsync but never
passed it on), and render it in BuildForMergeHelperHandoffAsync from the
taskId parameter it already receives.
RenderTemplate leaves unknown/missing tokens untouched, so a
user-edited override without the new token still renders fine -- no
forced migration for override users.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user