feat(worker): AskUser MCP tool so a running task can ask the user mid-run

A running task can call mcp__claudedo_run__AskUser(question) to block (up to 3
min) on a human answer. PendingQuestionRegistry holds the pending question +
TaskCompletionSource; the tool broadcasts TaskQuestionAsked, awaits the answer
(WorkerHub.AnswerTaskQuestion resolves it), and returns it as the tool result —
or a 'proceed on your judgment' fallback on timeout. The run stays Running
throughout (no status/schema change). ClaudeProcess raises MCP_TOOL_TIMEOUT so
the 60s HTTP-MCP cap doesn't kill the wait; the run MCP is now wired for every
task, not just standalone ones. System prompt updated to reconcile 'unattended'.
This commit is contained in:
Mika Kuns
2026-06-25 22:53:34 +02:00
parent bec26b2232
commit c7f8280106
14 changed files with 308 additions and 26 deletions

View File

@@ -20,7 +20,8 @@ public sealed class ClearMyDayHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore());
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -31,7 +31,7 @@ public sealed class OnlineInboxHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
cfg, inboxCfg, store);
cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return (hub, inboxCfg, store);

View File

@@ -56,7 +56,8 @@ public sealed class PlanningHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!, null!, null!, null!,
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore());
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -20,7 +20,8 @@ public sealed class WorktreeStateHubTests : IDisposable
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore());
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry());
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -0,0 +1,126 @@
using ClaudeDo.Worker.Runner;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
public class PendingQuestionRegistryTests
{
[Fact]
public async Task Register_ThenAnswer_CompletesTheWait()
{
var registry = new PendingQuestionRegistry();
var (questionId, answer) = registry.Register("t1", "which?");
Assert.False(answer.IsCompleted);
Assert.Equal("which?", registry.Get("t1")?.Question);
Assert.True(registry.TryAnswer("t1", questionId, "this one"));
Assert.Equal("this one", await answer);
Assert.Null(registry.Get("t1")); // cleared after answering
}
[Fact]
public void TryAnswer_WrongQuestionId_DoesNothing()
{
var registry = new PendingQuestionRegistry();
var (_, answer) = registry.Register("t1", "q?");
Assert.False(registry.TryAnswer("t1", "stale-id", "x"));
Assert.False(answer.IsCompleted);
Assert.NotNull(registry.Get("t1"));
}
[Fact]
public void TryAnswer_UnknownTask_ReturnsFalse()
{
var registry = new PendingQuestionRegistry();
Assert.False(registry.TryAnswer("ghost", "q", "x"));
}
[Fact]
public void SecondRegister_OverwritesStaleEntry()
{
var registry = new PendingQuestionRegistry();
var (firstId, _) = registry.Register("t1", "first");
var (secondId, _) = registry.Register("t1", "second");
Assert.NotEqual(firstId, secondId);
Assert.Equal("second", registry.Get("t1")?.Question);
Assert.False(registry.TryAnswer("t1", firstId, "x")); // old id no longer valid
Assert.True(registry.TryAnswer("t1", secondId, "ok"));
}
[Fact]
public void Remove_DropsPendingWithoutAnswering()
{
var registry = new PendingQuestionRegistry();
var (questionId, answer) = registry.Register("t1", "q?");
registry.Remove("t1", questionId);
Assert.Null(registry.Get("t1"));
Assert.False(answer.IsCompleted);
}
}
public class AskUserWaitTests
{
[Fact]
public async Task AwaitAnswer_ReturnsUserAnswer_WhenAnsweredInTime()
{
var registry = new PendingQuestionRegistry();
string? askedQuestionId = null;
var asked = new TaskCompletionSource();
var resolved = 0;
var wait = TaskRunMcpService.AwaitAnswerAsync(
registry, "t1", "DPAPI or plaintext?",
onAsked: (qid, _) => { askedQuestionId = qid; asked.TrySetResult(); return Task.CompletedTask; },
onResolved: _ => { resolved++; return Task.CompletedTask; },
timeout: TimeSpan.FromSeconds(5),
ct: CancellationToken.None);
await asked.Task; // registration + onAsked have run
Assert.True(registry.TryAnswer("t1", askedQuestionId!, "DPAPI please"));
Assert.Equal("DPAPI please", await wait);
Assert.Equal(1, resolved);
Assert.Null(registry.Get("t1"));
}
[Fact]
public async Task AwaitAnswer_ReturnsFallback_OnTimeout()
{
var registry = new PendingQuestionRegistry();
var resolved = 0;
var result = await TaskRunMcpService.AwaitAnswerAsync(
registry, "t2", "q?",
onAsked: (_, _) => Task.CompletedTask,
onResolved: _ => { resolved++; return Task.CompletedTask; },
timeout: TimeSpan.FromMilliseconds(40),
ct: CancellationToken.None);
Assert.Equal(TaskRunMcpService.TimeoutFallback, result);
Assert.Equal(1, resolved);
Assert.Null(registry.Get("t2")); // cleaned up after timeout
}
[Fact]
public async Task AwaitAnswer_Rethrows_WhenRunCancelled()
{
var registry = new PendingQuestionRegistry();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
TaskRunMcpService.AwaitAnswerAsync(
registry, "t3", "q?",
onAsked: (_, _) => Task.CompletedTask,
onResolved: _ => Task.CompletedTask,
timeout: TimeSpan.FromMinutes(1),
ct: cts.Token));
Assert.Null(registry.Get("t3")); // cleanup still ran
}
}

View File

@@ -37,7 +37,7 @@ public sealed class SuggestImprovementTests : IDisposable
await SeedCallerAsync("caller", parentId: null);
using var ctx = _db.CreateContext();
var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"),
new HubBroadcaster(new CapturingHubContext()));
new HubBroadcaster(new CapturingHubContext()), new PendingQuestionRegistry());
var dto = await svc.SuggestImprovement("Refactor X", "details", model: null, default);
var child = await new TaskRepository(ctx).GetByIdAsync(dto.ChildTaskId);
Assert.Equal("caller", child!.ParentTaskId);
@@ -53,7 +53,7 @@ public sealed class SuggestImprovementTests : IDisposable
await SeedCallerAsync("caller", parentId: null);
using var ctx = _db.CreateContext();
var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"),
new HubBroadcaster(new CapturingHubContext()));
new HubBroadcaster(new CapturingHubContext()), new PendingQuestionRegistry());
var dto = await svc.SuggestImprovement("Refactor X", "details", model: "HAIKU", default);
var child = await new TaskRepository(ctx).GetByIdAsync(dto.ChildTaskId);
Assert.Equal("haiku", child!.Model);
@@ -65,7 +65,7 @@ public sealed class SuggestImprovementTests : IDisposable
await SeedCallerAsync("caller", parentId: null);
using var ctx = _db.CreateContext();
var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"),
new HubBroadcaster(new CapturingHubContext()));
new HubBroadcaster(new CapturingHubContext()), new PendingQuestionRegistry());
await Assert.ThrowsAsync<ArgumentException>(
() => svc.SuggestImprovement("x", "y", model: "gpt4", default));
}
@@ -77,7 +77,7 @@ public sealed class SuggestImprovementTests : IDisposable
await SeedCallerAsync("child", parentId: "parent");
using var ctx = _db.CreateContext();
var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("child"),
new HubBroadcaster(new CapturingHubContext()));
new HubBroadcaster(new CapturingHubContext()), new PendingQuestionRegistry());
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.SuggestImprovement("nested", "x", model: null, default));
}