feat(worker): PowerUse-Addendum an die Handler-Prompts, Cap aus den Settings
HandoffRoundTracker.Register nimmt den Cap jetzt als Parameter statt aus einer Konstante; HandoffMcpTools liest ihn pro Aufruf aus app_settings. Werte unter 1 werden auf 1 geklemmt (in WorkerHub UND in Register): der Cap ist das Einzige, was eine entlaufene Handoff-Kette begrenzt, "0 = unbegrenzt" darf kein Aufrufer waehlen koennen. InteractiveLaunchSpecService.ComposeHandlerPromptAsync haengt das PowerUse- Addendum an den Phasen-Prompt, aber nur wenn globaler Schalter und Listen-Opt-in beide gesetzt sind. Bewusst ein Addendum statt einer zweiten Prompt-Garnitur: die gemeinsamen 95% bleiben an einer Stelle, und ein Run ohne PowerUse bekommt einen byte-identischen Prompt wie vorher. Das Addendum ist als Override der interaktiven Regeln formuliert (nie fragen, entscheiden und protokollieren; Konflikte loesen oder abbrechen, nie im Tree liegen lassen; Arbeit ernten, wenn eine Runde sonst leer laeuft). Drei Tests pinnen alle vier Ecken des UND, weil die Sicherheitsaussage des Features genau daran haengt.
This commit is contained in:
+15
-3
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
@@ -17,12 +19,18 @@ public sealed class HandoffMcpTools
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly HandoffRoundTracker _rounds;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster, HandoffRoundTracker rounds)
|
||||
public HandoffMcpTools(
|
||||
TaskRepository tasks,
|
||||
HubBroadcaster broadcaster,
|
||||
HandoffRoundTracker rounds,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
_rounds = rounds;
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -33,7 +41,8 @@ public sealed class HandoffMcpTools
|
||||
"after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" +
|
||||
"\"merge_final\" are only for a merge session that started reruns and must eventually stop the " +
|
||||
"chain instead of restarting again. An unrecognized value is rejected. This handoff chain is " +
|
||||
"capped server-side at four handoffs (i.e. two " +
|
||||
"capped server-side at a configurable number of handoffs (the ListHandlerMaxHandoffs setting, " +
|
||||
"4 by default, i.e. two " +
|
||||
"full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " +
|
||||
"silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " +
|
||||
"any handoff at all after a merge_final round for this task throws. The current tile stays " +
|
||||
@@ -59,7 +68,10 @@ public sealed class HandoffMcpTools
|
||||
_ = await _tasks.GetByIdAsync(id, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {id} not found.");
|
||||
|
||||
var round = _rounds.Register(taskId, nextPhase);
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
|
||||
|
||||
var round = _rounds.Register(taskId, nextPhase, maxHandoffs);
|
||||
if (round.Rejected)
|
||||
throw new InvalidOperationException(round.Error);
|
||||
|
||||
|
||||
+10
-4
@@ -13,8 +13,10 @@ namespace ClaudeDo.Worker.External;
|
||||
// resets the count, which is acceptable since the chain restarts fresh anyway.
|
||||
public sealed class HandoffRoundTracker
|
||||
{
|
||||
// Two full wait->merge cycles: wait, merge, wait, merge.
|
||||
public const int MaxHandoffs = 4;
|
||||
// Two full wait->merge cycles: wait, merge, wait, merge. Only the fallback now -- the live cap
|
||||
// comes from AppSettingsEntity.ListHandlerMaxHandoffs, passed per call by HandoffMcpTools, so a
|
||||
// run meant to keep going for hours can raise it without the bound disappearing entirely.
|
||||
public const int DefaultMaxHandoffs = 4;
|
||||
|
||||
private sealed class TaskState
|
||||
{
|
||||
@@ -26,8 +28,12 @@ public sealed class HandoffRoundTracker
|
||||
|
||||
public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error);
|
||||
|
||||
public Result Register(string taskId, string nextPhase)
|
||||
/// <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>
|
||||
public Result Register(string taskId, string nextPhase, int maxHandoffs = DefaultMaxHandoffs)
|
||||
{
|
||||
var cap = Math.Max(1, maxHandoffs);
|
||||
var state = _states.GetOrAdd(taskId, _ => new TaskState());
|
||||
lock (state)
|
||||
{
|
||||
@@ -39,7 +45,7 @@ public sealed class HandoffRoundTracker
|
||||
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;
|
||||
|
||||
var effective = nextPhase;
|
||||
if (state.Count > MaxHandoffs && !isFinal)
|
||||
if (state.Count > cap && !isFinal)
|
||||
{
|
||||
effective = nextPhase == MergeHelperPhase.Wait ? MergeHelperPhase.WaitFinal : MergeHelperPhase.MergeFinal;
|
||||
isFinal = true;
|
||||
|
||||
Reference in New Issue
Block a user