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:
Mika Kuns
2026-09-02 15:43:48 +02:00
parent 3cf9628f46
commit 453d61ca58
7 changed files with 297 additions and 15 deletions
+15 -3
View File
@@ -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
View File
@@ -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;
+15 -3
View File
@@ -321,7 +321,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.UsageThrottleFiveHourHardPct,
row.UsageThrottleSevenDaySoftPct,
row.UsageThrottleSevenDayHardPct,
row.AutoContinueOnUsageLimit);
row.AutoContinueOnUsageLimit,
row.PowerUseEnabled,
row.ListHandlerMaxHandoffs);
}
public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -357,6 +359,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit,
PowerUseEnabled = dto.PowerUseEnabled,
// The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written
// client would remove that bound entirely, so clamp instead of trusting the wire value.
ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs),
});
}
@@ -553,7 +559,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var id => id,
};
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null)
// Gleiches Tri-State-Muster: null = gespeicherten Wert behalten, false = Opt-in widerrufen.
var powerUseAllowed = dto.PowerUseAllowed ?? existing?.PowerUseAllowed ?? false;
// powerUseAllowed muss mit in die Leer-Prüfung: sonst löscht ein Save, bei dem PowerUse das
// EINZIGE gesetzte Feld ist, die Config-Zeile und damit das Opt-in gleich wieder mit.
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -570,6 +581,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
VerifyCommand = verifyCommand,
PermissionMode = permissionMode,
TicketProjectId = ticketProjectId,
PowerUseAllowed = powerUseAllowed,
});
}
@@ -593,7 +605,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId);
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed);
}
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -296,7 +296,8 @@ public sealed class InteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), ct);
await File.WriteAllTextAsync(systemPromptPath,
await ComposeHandlerPromptAsync(ctx, listId, PromptKind.MergeHelperTriage, ct), ct);
var briefPath = Path.Combine(sessionDir, "brief.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
@@ -391,7 +392,8 @@ public sealed class InteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(phase.PromptKind), ct);
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
@@ -554,6 +556,31 @@ public sealed class InteractiveLaunchSpecService
return handlerTask.Id;
}
// The list-handler system prompt, plus the PowerUse addendum when this run is in PowerUse.
//
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, toggled on the
// usage monitor) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole
// point of the two-key design: the global switch can be left on overnight without turning some
// unrelated list's handler run unattended, because a list that never opted in keeps the
// interactive prompt unchanged.
//
// Appending rather than substituting a second prompt set keeps the shared 95% in one place --
// the addendum is written to read as an override of the base prompt's interactive rules. A run
// that is not in PowerUse gets a byte-identical prompt to what it got before PowerUse existed.
private static async Task<string> ComposeHandlerPromptAsync(
ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct)
{
var basePrompt = PromptFiles.ReadOrDefault(kind);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
if (!settings.PowerUseEnabled) return basePrompt;
var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct);
if (listConfig?.PowerUseAllowed != true) return basePrompt;
return basePrompt + "\n\n" + PromptFiles.ReadOrDefault(PromptKind.PowerUseAddendum);
}
// Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the
// "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait
// + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only