Merge branch 'main' into feat/poweruse-list-handler

Kollision mit #264 (Handoff-Budget im Brief sichtbar machen): beide Seiten haben
HandoffRoundTracker, HandoffMcpTools und InteractiveLaunchSpecService angefasst.

Der Konflikt war inhaltlich, nicht nur textuell. #264 liest den Cap an zwei
Stellen aus der Konstante (GetStatus und das roundInfo-Rendering), waehrend
dieser Branch ihn zum Parameter aus app_settings gemacht hat. Ein reiner
Text-Merge haette kompiliert und trotzdem gelogen: Register haette den
konfigurierten Cap durchgesetzt, waehrend der Brief dem Modell weiter "2 Runden
uebrig" gemeldet haette, obwohl es 39 hat.

Aufgeloest so, dass der Cap eine einzige Quelle hat:
- GetStatus(taskId, maxHandoffs) nimmt den Cap als Parameter, mit demselben
  Clamp wie Register. Die Rechnerei liegt damit an einer Stelle.
- GetHandoffStatus liest das Setting und gibt es weiter.
- Das roundInfo-Rendering benutzt GetStatus statt eigener Arithmetik.

Zwei Tests nageln genau diesen Drift fest, damit er beim naechsten Merge nicht
unbemerkt zurueckkommt: einer auf GetHandoffStatus, einer auf den Brief.

MaxHandoffs -> DefaultMaxHandoffs in #264s vier Test-Referenzen; die laufen alle
mit Default-Settings, die Semantik bleibt.
This commit is contained in:
Mika Kuns
2026-09-02 15:48:43 +02:00
41 changed files with 2323 additions and 225 deletions
+67 -39
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
@@ -55,11 +55,11 @@ public sealed record TaskDto(
string? FailureReason = null,
int? FailureTurnsUsed = null,
int? FailureMaxTurns = null,
// A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
// planning chain's own internal BlockedByTaskId link.
string? DependsOnTaskId = null,
// User/MCP-declared predecessors (set via add_task/update_task), distinct from the planning
// chain's own internal BlockedByTaskId link. Empty unless the task declares dependencies.
IReadOnlyList<string>? DependsOnTaskIds = null,
// True only while Status is Queued and the picker will not claim this task yet -- either a
// planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
// planning-chain predecessor or some dependsOnTaskIds entry hasn't reached Done. See BlockedReason.
bool Blocked = false,
string? BlockedReason = null,
bool IsManual = false,
@@ -82,7 +82,7 @@ public sealed record TaskRefDto(
string? FailureReason = null,
int? FailureTurnsUsed = null,
int? FailureMaxTurns = null,
string? DependsOnTaskId = null,
IReadOnlyList<string>? DependsOnTaskIds = null,
bool Blocked = false,
string? BlockedReason = null,
DirtyBaseWarning? BaseDirty = null,
@@ -205,14 +205,14 @@ public sealed class ExternalMcpService
}
// Batch-resolves, for each task, whether the picker is currently skipping it (Queued with
// either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued
// either a planning-chain BlockedByTaskId or an unmet dependency) and why. Only Queued
// tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot.
private async Task<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
IReadOnlyList<TaskEntity> tasks, CancellationToken ct)
{
var dependencyIds = tasks
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null)
.Select(t => t.DependsOnTaskId!)
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null)
.SelectMany(t => t.DependsOnTaskIds)
.Distinct()
.ToList();
@@ -237,17 +237,10 @@ public sealed class ExternalMcpService
{
result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}.");
}
else if (t.DependsOnTaskId is not null)
{
var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus);
result[t.Id] = known && depStatus == TaskStatus.Done
? (false, null)
: (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " +
(known ? depStatus.ToString() : "not found") + ").");
}
else
{
result[t.Id] = (false, null);
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
}
}
return result;
@@ -298,10 +291,15 @@ public sealed class ExternalMcpService
"list/global default (normally sonnet).")]
string? model = null,
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
"even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " +
"even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " +
"Rejected if it doesn't exist, is this task's own id, or would create a " +
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " +
"task wires together, or verifies, the results of several independent tasks -- one waiting on " +
"A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")]
string[]? dependsOnTaskIds = null,
[Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " +
"that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " +
"flag. false (default): an empty diff is treated as suspicious, not a clean success.")]
@@ -316,7 +314,7 @@ public sealed class ExternalMcpService
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken);
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
@@ -337,12 +335,11 @@ public sealed class ExternalMcpService
};
await _tasks.AddAsync(entity, cancellationToken);
if (dependsOnTaskId is not null)
if (dependsOn is { Count: > 0 })
{
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOn, cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
entity.DependsOnTaskId = dependsOnTaskId;
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
}
if (queueImmediately)
@@ -350,12 +347,39 @@ public sealed class ExternalMcpService
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
if (!enqueue.Ok)
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
entity.Status = TaskStatus.Queued;
}
await _broadcaster.TaskUpdated(entity.Id);
var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
// Reload rather than patching `entity`: SetDependsOnAsync and EnqueueAsync both write
// through their own context, and adding the edges to this still-tracked entity's nav
// would queue a duplicate INSERT on the next SaveChanges.
var reload = (await _tasks.GetByIdAsync(entity.Id, cancellationToken))!;
var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
return new AddTaskResult(ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason), possibleDuplicates);
}
// Folds add_task/update_task's two dependency parameters into one resolved set.
// `dependsOnTaskIds` wins when given; otherwise the legacy scalar form is treated as a
// one-element set, with the empty string still its documented "clear it" sentinel.
// Returns null only when neither parameter was passed = leave the task's set untouched.
private async Task<IReadOnlyList<string>?> ResolveDependsOnAsync(
string? dependsOnTaskId, string[]? dependsOnTaskIds, CancellationToken ct)
{
if (dependsOnTaskIds is not null)
{
var resolved = new List<string>();
foreach (var raw in dependsOnTaskIds)
{
var id = await TaskIdResolver.ResolveOptionalAsync(_tasks, raw, ct);
if (id.NullIfBlank() is { } v) resolved.Add(v);
}
return resolved;
}
if (dependsOnTaskId is null) return null;
// TaskIdResolver passes "" through untouched, so NullIfBlank still yields the clear signal.
var single = (await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, ct)).NullIfBlank();
return single is null ? [] : [single];
}
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
@@ -436,10 +460,15 @@ public sealed class ExternalMcpService
string? title = null,
string? description = null,
string? commitType = null,
[Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " +
"string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
"one-element dependsOnTaskIds; ignored when that is given. Pass an empty " +
"string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " +
"is this task's own id, or would create a dependency cycle.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " +
"task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " +
"a task wires together, or verifies, the results of several independent tasks.")]
string[]? dependsOnTaskIds = null,
[Description("true: mark this task as a manual reminder only the user can complete — the queue picker, " +
"daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " +
"clear the flag. null (default): leave unchanged.")]
@@ -463,15 +492,12 @@ public sealed class ExternalMcpService
if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value;
await _tasks.UpdateAsync(task, cancellationToken);
if (dependsOnTaskId is not null)
var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken);
if (dependsOn is not null)
{
// Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to
// clear the link — TaskIdResolver passes it through untouched, then NullIfBlank
// below still turns it into the clear signal SetDependsOnAsync expects.
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOn, cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
@@ -742,6 +768,7 @@ public sealed class ExternalMcpService
var children = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.Dependencies)
.Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done)
.ToListAsync(ct);
@@ -935,7 +962,8 @@ public sealed class ExternalMcpService
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
var task = await ctx.Tasks.Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (isMyDay && !task.IsMyDay)
@@ -995,7 +1023,7 @@ public sealed class ExternalMcpService
FailureReasonOf(t),
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskId,
t.DependsOnTaskIds,
blocked,
blockedReason,
t.IsManual,
@@ -1014,7 +1042,7 @@ public sealed class ExternalMcpService
FailureReasonOf(t),
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskId,
t.DependsOnTaskIds,
blocked,
blockedReason,
BaseDirty: null,
+27
View File
@@ -13,6 +13,8 @@ namespace ClaudeDo.Worker.External;
// reflects the effective (post-coercion) value in that case.
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false);
public sealed record HandoffStatusResult(string TaskId, int Number, int Round, int Remaining, bool Final);
[McpServerToolType]
public sealed class HandoffMcpTools
{
@@ -78,4 +80,29 @@ public sealed class HandoffMcpTools
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
}
[McpServerTool, Description(
"Read-only lookup of a handler task's handoff-round budget (round already used, rounds " +
"remaining before handoff_list_handler starts coercing to a _final phase, and whether the " +
"chain is already sealed by a completed merge_final round) -- for checking the budget mid-" +
"session or from outside a handoff, instead of only seeing it once at the start of a round. " +
"A task that never called handoff_list_handler reports round 0, its full remaining budget, " +
"and final=false. In-memory only, same as the tracker itself: a worker restart resets it." +
McpToolDocs.TaskNumberHint)]
public async Task<HandoffStatusResult> GetHandoffStatus(
[Description("The handler task's own id.")] string taskId,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
// Same setting HandoffListHandler enforces against — read here too, so a raised cap shows up
// in the reported budget instead of the tool confidently understating it.
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var status = _rounds.GetStatus(taskId, maxHandoffs);
return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final);
}
}
+35 -3
View File
@@ -5,8 +5,8 @@ namespace ClaudeDo.Worker.External;
// Bounds the list-handler wait<->merge handoff chain server-side, so a model that ignores the
// cooperative "this is the FINAL round" line in handoff.md (InteractiveLaunchSpecService's
// finalNote) cannot loop wait->merge->wait->merge forever, each round spawning a fresh ConPTY
// session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh
// roundInfo, read back via GetState) cannot loop wait->merge->wait->merge forever, each round
// spawning a fresh ConPTY session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh
// handler task (InteractiveLaunchSpecService.CreateMergeHelperTaskAsync), so the counter is
// per-run by construction: no reset logic needed, and a later run on the same list starts at 0
// on its own. In-memory only, same tradeoff as TaskRunTokenRegistry -- a worker restart mid-run
@@ -28,6 +28,37 @@ public sealed class HandoffRoundTracker
public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error);
/// <summary>Read-only snapshot for a handler task -- does NOT register a round. Used to report
/// the current round/remaining budget (e.g. in the handoff brief) without mutating state that
/// only Register should advance. Count is 0 for a task that hasn't handed off yet.</summary>
public (int Count, bool Sealed) GetState(string taskId)
{
if (!_states.TryGetValue(taskId, out var state))
return (0, false);
lock (state)
return (state.Count, state.Sealed);
}
// Read-only budget lookup for a handler task that hasn't (yet) called HandoffListHandler --
// Round/Remaining/Final all report their untouched defaults instead of throwing, since "no
// handoff yet" is a normal state, not an error.
public sealed record Status(int Round, int Remaining, bool Final);
/// <param name="maxHandoffs">The live cap (AppSettingsEntity.ListHandlerMaxHandoffs), passed in
/// rather than read off a constant so the REPORTED budget can never drift from the cap Register
/// actually enforces. A brief telling the model "2 rounds left" while the chain really allows 40
/// is worse than no budget line at all. Clamped like Register's.</param>
public Status GetStatus(string taskId, int maxHandoffs = DefaultMaxHandoffs)
{
var cap = Math.Max(1, maxHandoffs);
if (!_states.TryGetValue(taskId, out var state))
return new Status(0, cap, false);
lock (state)
return new Status(state.Count, Math.Max(0, cap - state.Count), state.Sealed);
}
/// <param name="maxHandoffs">Cap for this chain, normally AppSettingsEntity.ListHandlerMaxHandoffs.
/// Values below 1 are treated as 1: the cap exists to bound the loop, so "no cap" is not an
/// option a caller gets to pick by passing 0.</param>
@@ -39,7 +70,8 @@ public sealed class HandoffRoundTracker
{
if (state.Sealed)
return new Result(true, false, nextPhase,
$"Task {taskId} already completed its merge_final round -- no further handoff is allowed.");
$"Task {taskId} already completed its merge_final round -- no further handoff is allowed. " +
"Start a new handler run (\"Let Claude handle it\") on this list instead.");
state.Count++;
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;
+42
View File
@@ -0,0 +1,42 @@
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
/// <summary>
/// Renders the `blockedReason` string shared by <see cref="ExternalMcpService"/> and
/// <see cref="TaskWaitMcpTools"/>. Lives here so the two can't drift on the wording an agent
/// parses.
/// </summary>
internal static class TaskBlocking
{
/// <summary>
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
/// edge as blocking too).
/// </summary>
public static List<(string Id, TaskStatus? Status)> Unmet(
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> statuses)
{
var unmet = new List<(string Id, TaskStatus? Status)>();
foreach (var id in dependencyIds)
{
if (statuses.TryGetValue(id, out var status))
{
if (status != TaskStatus.Done) unmet.Add((id, status));
}
else
{
unmet.Add((id, null));
}
}
return unmet;
}
/// <summary>Names every open predecessor, not just the first — a task can wait on n of them.</summary>
public static string Describe(IReadOnlyList<(string Id, TaskStatus? Status)> unmet)
{
var noun = unmet.Count == 1 ? "task" : "tasks";
var parts = unmet.Select(u => $"{u.Id} (status: {(u.Status?.ToString() ?? "not found")})");
return $"Blocked: depends on {noun} {string.Join(", ", parts)}.";
}
}
+16 -13
View File
@@ -132,17 +132,24 @@ public sealed class TaskWaitMcpTools
var rows = await ctx.Tasks
.AsNoTracking()
.Where(t => taskIds.Contains(t.Id))
.Select(t => new { t.Id, t.Number, t.Status, t.BlockedByTaskId, t.DependsOnTaskId })
.Select(t => new
{
t.Id,
t.Number,
t.Status,
t.BlockedByTaskId,
DependsOnTaskIds = t.Dependencies.Select(d => d.DependsOnTaskId).ToList(),
})
.ToListAsync(ct);
var byId = rows.ToDictionary(r => r.Id, r => r);
// A Queued task with an unmet depends_on link never becomes "changed" by itself -- the
// A Queued task with an unmet depends_on edge never becomes "changed" by itself -- the
// picker will not touch it. Batch-resolve those dependencies' statuses once instead of a
// query per candidate.
var dependencyIds = rows
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null)
.Select(r => r.DependsOnTaskId!)
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null)
.SelectMany(r => r.DependsOnTaskIds)
.Distinct()
.ToList();
var dependencyStatuses = dependencyIds.Count == 0
@@ -168,16 +175,12 @@ public sealed class TaskWaitMcpTools
// every chained child and turn the wait into a one-turn-per-poll busy loop.
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
{
if (row.DependsOnTaskId is not null)
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
if (unmet.Count > 0)
{
var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus);
if (!known || depStatus != TaskStatus.Done)
{
result.Add(new TaskStatusChangeDto(id, "Blocked",
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
(known ? depStatus.ToString() : "not found") + ").", row.Number));
continue;
}
result.Add(new TaskStatusChangeDto(
id, "Blocked", TaskBlocking.Describe(unmet), row.Number));
continue;
}
}