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
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Skills;
using Microsoft.EntityFrameworkCore;
@@ -42,6 +43,7 @@ public sealed class InteractiveLaunchSpecService
private readonly WorktreeManager _wtManager;
private readonly GitService _git;
private readonly string _claudePath;
private readonly HandoffRoundTracker _handoffRounds;
public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -49,7 +51,8 @@ public sealed class InteractiveLaunchSpecService
ISessionSkillRegistry skillRegistry,
WorktreeManager wtManager,
GitService git,
WorkerConfig cfg)
WorkerConfig cfg,
HandoffRoundTracker handoffRounds)
{
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
@@ -57,6 +60,7 @@ public sealed class InteractiveLaunchSpecService
_wtManager = wtManager;
_git = git;
_claudePath = cfg.ClaudeBin;
_handoffRounds = handoffRounds;
}
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
@@ -395,13 +399,22 @@ public sealed class InteractiveLaunchSpecService
await File.WriteAllTextAsync(systemPromptPath,
await ComposeHandlerPromptAsync(ctx, handlerTask.ListId, phase.PromptKind, ct), ct);
// A "_final" round differs from its non-final counterpart in exactly one rendered line --
// the same system prompt and model drive both -- marking the final round and forbidding a
// further rerun, so the chain is bounded structurally rather than by prompt appeal alone.
var finalNote = phase.IsFinal
? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
"merge what you can, report the rest, and print the summary."
: "";
// The round count/cap come from HandoffRoundTracker -- the same instance HandoffMcpTools
// registered this handoff against -- never recomputed here, so the brief can't drift from
// the server-side cap that actually enforces the chain. The cap is the CONFIGURED one
// (ListHandlerMaxHandoffs), read through GetStatus so the clamp and the arithmetic live in
// one place; hardcoding the default here would understate a raised budget and tell the model
// it has two rounds left when it really has dozens. A "_final" round adds one more line
// forbidding a further rerun, so the chain is bounded structurally rather than by prompt
// appeal alone.
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(ct)).ListHandlerMaxHandoffs;
var round = _handoffRounds.GetStatus(taskId, maxHandoffs);
var roundInfo = $"Handoff budget: this is round {round.Round} of " +
$"{Math.Max(1, maxHandoffs)} possible handoffs for this list-handler run " +
$"({round.Remaining} more possible after this one).";
if (phase.IsFinal)
roundInfo += "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
"merge what you can, report the rest, and print the summary.";
var briefPath = Path.Combine(sessionDir, "handoff.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
@@ -411,7 +424,7 @@ public sealed class InteractiveLaunchSpecService
["repo"] = repoDir,
["handlerTaskId"] = taskId,
["tasks"] = RenderGroupedBrief(briefEntries),
["finalNote"] = finalNote,
["roundInfo"] = roundInfo,
}), ct);
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)