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:
mika kuns
2026-08-21 18:00:16 +02:00
parent 93be76a144
commit 9c4a63d2c5
12 changed files with 93 additions and 24 deletions
+6
View File
@@ -547,6 +547,9 @@ public static class PromptFiles
Scope: {scope} Scope: {scope}
Repo: {repo} Repo: {repo}
Your own handler task id (for handoff_list_handler / submit_task_for_review do NOT confuse
this with the ids of the tasks you are handling below): {handlerTaskId}
Handle the following tasks. Work Phases 05 as your instructions describe, asking me whenever you are unsure. Handle the following tasks. Work Phases 05 as your instructions describe, asking me whenever you are unsure.
{tasks} {tasks}
@@ -560,6 +563,9 @@ public static class PromptFiles
Scope: {scope} Scope: {scope}
Repo: {repo} Repo: {repo}
Your own handler task id (for handoff_list_handler / submit_task_for_review do NOT confuse
this with the ids of the surviving tasks below): {handlerTaskId}
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3 A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
for the tasks below their descriptions are already sharpened. for the tasks below their descriptions are already sharpened.
@@ -130,8 +130,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// no task, no worktree.</summary> /// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default); Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given /// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.</summary> /// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default); /// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
/// can call handoff_list_handler on its own handler task.</summary>
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default);
/// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual, /// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual,
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary> /// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
Task<string> CreateMergeHelperTaskAsync( Task<string> CreateMergeHelperTaskAsync(
+2 -2
View File
@@ -614,8 +614,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct)); => await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default) public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct)); => await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, ct));
public async Task<string> CreateMergeHelperTaskAsync( public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -279,7 +279,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId)))); () => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId))));
} }
finally finally
{ {
+4 -3
View File
@@ -864,12 +864,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given // Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label // tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
// and cwd to that list. // and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() => // rendered into the brief so the session can call handoff_list_handler on itself.
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId) => HubGuard(() =>
{ {
if (_interactiveLaunchSpec is null) if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured."); throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, Context.ConnectionAborted);
}); });
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens -- // Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
@@ -241,7 +241,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
private const string MergeHelperAllowedTools = private const string MergeHelperAllowedTools =
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task"; "mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task";
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct) public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
{ {
if (taskIds.Count == 0) if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the list handler."); throw new InvalidOperationException("No tasks selected for the list handler.");
@@ -278,6 +278,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
{ {
["scope"] = $"List: {list.Name}", ["scope"] = $"List: {list.Name}",
["repo"] = repoDir, ["repo"] = repoDir,
["handlerTaskId"] = handlerTaskId,
["tasks"] = string.Join("\n", briefLines), ["tasks"] = string.Join("\n", briefLines),
}), ct); }), ct);
@@ -371,6 +372,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
{ {
["scope"] = $"List: {list.Name}", ["scope"] = $"List: {list.Name}",
["repo"] = repoDir, ["repo"] = repoDir,
["handlerTaskId"] = taskId,
["tasks"] = string.Join("\n", briefLines), ["tasks"] = string.Join("\n", briefLines),
["finalNote"] = finalNote, ["finalNote"] = finalNote,
}), ct); }), ct);
@@ -34,9 +34,11 @@ public interface IInteractiveLaunchSpecService
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session /// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/&lt;guid&gt; and exposes /// system prompt + task brief under ~/.todo-app/merge-helper-sessions/&lt;guid&gt; and exposes
/// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory. /// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if /// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
/// taskIds is empty or the list has no existing working directory.</summary> /// taskIds is empty or the list has no existing working directory.</summary>
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct); Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct);
/// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's /// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
/// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's /// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
@@ -360,6 +360,13 @@ public class PromptFilesTests
Assert.Contains("{tasks}", d); 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] [Fact]
public void RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks() public void RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks()
{ {
@@ -382,6 +389,13 @@ public class PromptFilesTests
Assert.Contains("{tasks}", d); 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] [Fact]
public void DefaultFor_merge_helper_handoff_points_at_phase_3() 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>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>())); => 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>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync( public virtual Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) 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 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"); => throw new InvalidOperationException("spec failed");
} }
@@ -628,7 +628,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var listId = await SeedListAsync(workingDir: _tempDir); var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService(); var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None)); () => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), CancellationToken.None));
} }
[Fact] [Fact]
@@ -640,7 +640,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>( 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); Assert.Contains("working directory", ex.Message);
} }
@@ -653,7 +653,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>( 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] [Fact]
@@ -669,7 +669,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService(); 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 sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd); Assert.Equal(repo, spec.Cwd);
@@ -726,7 +726,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService(); 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 sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList(); var args = spec.Args.ToList();
@@ -749,7 +749,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService(); 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 sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -761,6 +761,28 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(t2, brief); 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] [Fact]
public async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription() public async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription()
{ {
@@ -773,7 +795,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
description: "Do the thing carefully and report back."); description: "Do the thing carefully and report back.");
var svc = BuildService(); 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 sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); 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); await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);
var svc = BuildService(); 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 sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); 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"); await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");
var svc = BuildService(); 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 sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); 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('/')); 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] [Fact]
public async Task BuildForMergeHelperHandoffAsync_UnknownPhase_Throws() public async Task BuildForMergeHelperHandoffAsync_UnknownPhase_Throws()
{ {
@@ -1126,7 +1168,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); 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 triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None); var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec); var executeDir = TrackSessionDir(executeSpec);
@@ -1177,7 +1219,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir); ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var svc = BuildService(); 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); TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None); var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
TrackSessionDir(handoffSpec); TrackSessionDir(handoffSpec);
@@ -85,7 +85,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => 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>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<string> CreateMergeHelperTaskAsync( public Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)