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:
@@ -5,7 +5,7 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff }
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff, PowerUseAddendum }
|
||||
|
||||
/// <summary>
|
||||
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
|
||||
@@ -44,6 +44,7 @@ public static class PromptFiles
|
||||
PromptKind.MergeHelperMerge => "merge-helper-merge.md",
|
||||
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
|
||||
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
|
||||
PromptKind.PowerUseAddendum => "power-use.md",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
@@ -221,6 +222,7 @@ public static class PromptFiles
|
||||
PromptKind.MergeHelperMerge => MergeHelperMergeDefault,
|
||||
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
||||
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
|
||||
PromptKind.PowerUseAddendum => PowerUseAddendumDefault,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -455,7 +457,7 @@ public static class PromptFiles
|
||||
|
||||
Group what's left into waves: batches that can run in parallel, where a later wave depends only on tasks in an earlier one. Report this as a wave plan (e.g. "[3] → [4,5,6] → [7]"), not a flat list — the Wait/Merge sessions after you need the batching, not just which ids survived.
|
||||
|
||||
Then check the wave count against the handoff cap: this list-handler chain allows at most 4 handoffs total (wait, merge, wait_final, merge_final), which is exactly two merge rounds. If your wave plan needs more than two rounds to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain.
|
||||
Then check the wave count against the handoff cap. This list-handler chain allows a limited number of handoffs (wait, merge, wait_final, merge_final) — by default 4, i.e. exactly two merge rounds, but it is configurable and may be higher for this run. Assume two rounds unless the kickoff tells you otherwise. If your wave plan needs more rounds than that to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain.
|
||||
|
||||
## Phase 2 — Enhance for execution
|
||||
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below.
|
||||
@@ -620,6 +622,78 @@ public static class PromptFiles
|
||||
{finalNote}
|
||||
""";
|
||||
|
||||
// Appended to the triage/wait/merge system prompt when a run is in PowerUse (global switch AND
|
||||
// the list's own opt-in). Deliberately an addendum rather than a second set of prompts: the
|
||||
// shared 95% stays in one place, and a normal run's prompt is byte-identical to what it was.
|
||||
// It therefore has to read as an OVERRIDE of the rules above it, not as standalone instructions.
|
||||
private const string PowerUseAddendumDefault = """
|
||||
# PowerUse mode — these rules supersede the ones above
|
||||
|
||||
PowerUse is ON for this run. Everything above was written for a session with a user watching it. That assumption no longer holds, and where the two conflict, this section wins.
|
||||
|
||||
## No user exists
|
||||
|
||||
Nobody is watching and nobody can answer a question. Every instruction above that says to ask the user, confirm with the user, or stop and let the user decide is replaced by: decide it yourself, act, and record the decision in your summary. A question asked here is a hung session and a wasted run.
|
||||
|
||||
This does not lower the bar for what you may do — it removes escalation as an option, so the decisions you make must be ones you can defend in writing afterwards. Where a rule above offered "ask the user" as the safe path, the safe path is now the conservative action plus a written record, never the bold one.
|
||||
|
||||
Specifically:
|
||||
- **Duplicate pairs (Triage Phase 1):** decide them. Clear duplicate — same goal, or one fully contained in the other — fold the loser's unique content into the survivor and cancel the loser. Genuinely different intent that merely touches the same area — keep both and chain them with dependsOnTaskId so they cannot run against the same files at once.
|
||||
- **A task too vague to sharpen (Triage Phase 2):** cancel it with a recorded reason rather than guessing a specification for someone else's half-sentence. Guessing burns a whole slot on work nobody asked for.
|
||||
- **Verify command not set (Triage Phase 2):** determine it, run it once via Bash to prove it works, then set it. Never write a command you have not actually run.
|
||||
- **A risky or wrong-looking diff (Merge Phase 4):** if it contradicts the task's own acceptance criteria, reject_rerun with concrete feedback naming what was missing. If it merely looks unusual but meets the criteria, merge it and say why. If it looks actively destructive — deletes code no task asked to delete, drops a migration, rewrites unrelated files — leave it unmerged and record it as HELD FOR REVIEW. Never merge something you would describe as destructive.
|
||||
- **A conflict you cannot resolve with confidence (Merge Phase 4):** call abort_merge(taskId) and record it as HELD FOR REVIEW. Aborting is always correct over guessing: the task keeps its status, and a rerun against the new main usually removes the conflict entirely.
|
||||
|
||||
## Conflicts are the normal case
|
||||
|
||||
Never end your turn with a conflicted merge left in the working tree. A half-merged tree makes the repo unbuildable for every session and every task after you, and any verify that runs against it reports a false red.
|
||||
|
||||
Prefer get_merge_conflicts + resolve_conflict_hunk over reading marker text by hand — for a large conflict that is the only tractable route; a 200k-character marker dump is not something to read linearly.
|
||||
|
||||
The common collision shape in a parallel run is one task MOVING code out of a file while another task ADDS to it. The resolution is almost always to keep the move and re-apply the addition in the file the code moved to — not to pick a side. Picking "ours" silently deletes the other task's work.
|
||||
|
||||
When ordering merges that overlap on a file, merge the task that moves or deletes code BEFORE the task that only adds to it. A move landing second turns every addition into a conflict.
|
||||
|
||||
## Rerun budget
|
||||
|
||||
At most TWO reruns per task across the whole run, replacing the single-rerun limit above. A task that has already been rerun twice is recorded as HELD FOR REVIEW instead of getting a third attempt — three failures on the same task is a specification problem, not bad luck, and further attempts starve the rest of the run.
|
||||
|
||||
## Harvest — so the run does not starve
|
||||
|
||||
This applies to the Merge phase only, after its merges and reruns are done.
|
||||
|
||||
If you have fewer than five tasks to hand to the next round, harvest new work for this list's repo before handing off. Only for the list named in the brief — never another list or repo.
|
||||
|
||||
Sources, in this order; prefer one you have not exhausted yet:
|
||||
1. **Verify/build output** — real compiler warnings. One task per warning group.
|
||||
2. **`ponytail:` comments** — deliberate shortcuts with a named ceiling and upgrade path. Take the ones whose upgrade is small.
|
||||
3. **TODO / HACK / FIXME** — only the concretely formulated ones.
|
||||
4. **Unfinished plan slices** — `docs/superpowers/plans/*.md` whose last slices never landed. Check against `git log` before believing a slice is open. One task per slice, naming the plan path and slice number.
|
||||
5. **Test gaps at bug hotspots** — `git log --grep="^fix"` over recent commits; files with repeated fixes and no test.
|
||||
6. **Over-engineering** — the ponytail-audit skill against individual subfolders, never the whole repo at once. Deletion candidates.
|
||||
7. **Feature work** — improvements that follow clearly from what the code already does and from the plans and specs under `docs/`. Ground each one in something you actually read; a feature you cannot point at a file for is a guess, not a proposal.
|
||||
|
||||
Rules for every harvested task, without exception:
|
||||
- Write it the way the Triage phase would: concrete acceptance criteria, real file paths found with Read/Grep/Glob, and what is out of scope. A vague harvested task wastes a whole slot.
|
||||
- Keep it narrow — one concern, ideally under five changed files. Narrow tasks merge in parallel; broad ones collide with everything.
|
||||
- Do not hand two new tasks to the same round if they touch the same file. Stagger them across rounds, or chain them with dependsOnTaskId.
|
||||
- Tag every harvested task `createdBy: "power-use"` so a later review can tell your proposals from the ones a human asked for.
|
||||
- A task that only verifies, audits or researches and is not meant to change code must be created with `expectsNoDiff: true`, or the next Merge session will read its empty diff as a failure.
|
||||
|
||||
## Verify red is the one thing you must not paper over
|
||||
|
||||
If verify_merges returns verify_failed, the target branch is now RED. Do not try to fix it inside this session. Create ONE task with add_task(queueImmediately: true, model: "sonnet") titled `fix: verify red after this round`, put the failing output tail in its description, and hand that task to the next round as its ONLY entry. Merge nothing further until it has landed.
|
||||
|
||||
## The summary is the only record
|
||||
|
||||
Nobody saw you work, so anything not written down did not happen. In addition to the per-task lines the Merge phase already asks for, your summary must carry these sections. An empty section is a valid answer; an omitted one is not:
|
||||
|
||||
- **HELD FOR REVIEW** — every task you did not merge, with the reason.
|
||||
- **Conflicts resolved** — per conflict: the file, which two tasks collided, and which way you resolved it. Someone must be able to find your resolution without re-deriving it.
|
||||
- **Harvested** — every task you created, grouped by source.
|
||||
- **Round state** — which round this was and what you handed to the next one.
|
||||
""";
|
||||
|
||||
private const string WeeklyReportDefault = """
|
||||
You are generating a concise weekly standup report for a software developer,
|
||||
covering {start} to {end}.
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
@@ -26,7 +27,17 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) =>
|
||||
new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker());
|
||||
new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker(), _db.CreateFactory());
|
||||
|
||||
/// The cap the SUT reads comes from app_settings, so a test that wants a different cap has to
|
||||
/// write it there. GetAsync is AsNoTracking, hence the separate tracked query.
|
||||
private async Task SetMaxHandoffsAsync(int value)
|
||||
{
|
||||
await new AppSettingsRepository(_ctx).GetAsync();
|
||||
var row = await _ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
row.ListHandlerMaxHandoffs = value;
|
||||
await _ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t")
|
||||
{
|
||||
@@ -198,6 +209,55 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
||||
secondSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_RaisedCapSetting_AllowsMoreRoundsBeforeCoercing()
|
||||
{
|
||||
await SetMaxHandoffsAsync(8);
|
||||
|
||||
var listId = await SeedListAsync();
|
||||
var handlerTask = await SeedTaskAsync(listId);
|
||||
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
|
||||
var tracker = new HandoffRoundTracker();
|
||||
var survivors = new[] { survivor.Id };
|
||||
|
||||
// The 5th handoff is where the default cap of 4 would coerce; with 8 configured it must not.
|
||||
foreach (var phase in new[] { "wait", "merge", "wait", "merge", "wait", "merge", "wait", "merge" })
|
||||
{
|
||||
var sut = BuildSut(tracker);
|
||||
var result = await sut.HandoffListHandler(handlerTask.Id, survivors, phase, CancellationToken.None);
|
||||
Assert.False(result.Coerced);
|
||||
Assert.Equal(phase, result.NextPhase);
|
||||
}
|
||||
|
||||
var ninthSut = BuildSut(tracker);
|
||||
var ninth = await ninthSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None);
|
||||
|
||||
Assert.True(ninth.Coerced);
|
||||
Assert.Equal("wait_final", ninth.NextPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_CapOfZero_IsClampedToOne_NotUnbounded()
|
||||
{
|
||||
// 0 must not read as "no cap" — that would remove the only bound on a runaway chain.
|
||||
await SetMaxHandoffsAsync(0);
|
||||
|
||||
var listId = await SeedListAsync();
|
||||
var handlerTask = await SeedTaskAsync(listId);
|
||||
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
|
||||
var tracker = new HandoffRoundTracker();
|
||||
var survivors = new[] { survivor.Id };
|
||||
|
||||
var firstSut = BuildSut(tracker);
|
||||
var first = await firstSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None);
|
||||
Assert.False(first.Coerced);
|
||||
|
||||
var secondSut = BuildSut(tracker);
|
||||
var second = await secondSut.HandoffListHandler(handlerTask.Id, survivors, "merge", CancellationToken.None);
|
||||
Assert.True(second.Coerced);
|
||||
Assert.Equal("merge_final", second.NextPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter()
|
||||
{
|
||||
|
||||
@@ -622,6 +622,97 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
return dir;
|
||||
}
|
||||
|
||||
// --- PowerUse composition -------------------------------------------------------------------
|
||||
// PowerUse needs BOTH the global switch and the list's opt-in. These three tests pin all four
|
||||
// corners of that AND, because the whole safety story of the feature is that the global switch
|
||||
// alone cannot make a list unattended.
|
||||
|
||||
private async Task SetPowerUseEnabledAsync(bool value)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
await new AppSettingsRepository(ctx).GetAsync();
|
||||
var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
row.PowerUseEnabled = value;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SetPowerUseAllowedAsync(string listId, bool value)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
|
||||
{
|
||||
ListId = listId,
|
||||
PowerUseAllowed = value,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> ReadHandlerSystemPromptAsync(string listId, string taskId)
|
||||
{
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForMergeHelperAsync(
|
||||
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
|
||||
var sessionDir = TrackSessionDir(spec);
|
||||
return await File.ReadAllTextAsync(Path.Combine(sessionDir, "system-prompt.md"));
|
||||
}
|
||||
|
||||
private static string AddendumMarker =>
|
||||
PromptFiles.DefaultFor(PromptKind.PowerUseAddendum).Split('\n')[0].Trim();
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_PowerUseOffGlobally_LeavesPromptUntouched()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "puOffGlobal");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
await SetPowerUseEnabledAsync(false);
|
||||
await SetPowerUseAllowedAsync(listId, true);
|
||||
|
||||
var prompt = await ReadHandlerSystemPromptAsync(listId, taskId);
|
||||
|
||||
Assert.DoesNotContain(AddendumMarker, prompt);
|
||||
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_PowerUseOnButListDidNotOptIn_LeavesPromptUntouched()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "puNoOptIn");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
await SetPowerUseEnabledAsync(true);
|
||||
// No list config row at all — the common case for an unrelated list.
|
||||
|
||||
var prompt = await ReadHandlerSystemPromptAsync(listId, taskId);
|
||||
|
||||
Assert.DoesNotContain(AddendumMarker, prompt);
|
||||
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_PowerUseOnAndListOptedIn_AppendsAddendum()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "puOn");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
await SetPowerUseEnabledAsync(true);
|
||||
await SetPowerUseAllowedAsync(listId, true);
|
||||
|
||||
var prompt = await ReadHandlerSystemPromptAsync(listId, taskId);
|
||||
|
||||
Assert.Contains(AddendumMarker, prompt);
|
||||
// The addendum overrides the base prompt, so it has to come AFTER it, not replace it.
|
||||
Assert.StartsWith(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user