Grossaufgaben und Sonderbudget (#717, Stufe 2+3)

Eine Aufgabe, deren eigene Schaetzung (gemessen, Geschwister oder Autor)
ueber der Haelfte der Tageskostengrenze liegt, bekommt zuerst einen
Planungslauf: teilen (Teile unter der Elternaufgabe) oder Sonderbudget
beantragen. Danach wartet sie auf ihre Teile bzw. die Freigabe und laeuft
einmal zum Zusammenfuehren.

Sonderbudget ueber Escalation.request_budget, ausnahmslos freigabepflichtig
(auch gegen Werkzeugkonfiguration und Dauerfreigabe). Laeufe im Baum ziehen
den Topf statt der Tagesgrenzen, tragen BudgetPot in Verbrauch und Beleg und
sind in der Kostenanalyse (nach Budget) und auf der Aufgabenkarte gesondert
sichtbar. Aufgebraucht zaehlen sie wieder gegen den Tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 15:47:41 +02:00
co-authored by Claude Opus 5.5
parent 183e922278
commit 67649559f5
30 changed files with 1123 additions and 55 deletions
+133 -2
View File
@@ -33,6 +33,26 @@ public interface ITaskDispatcher
{
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
/// <summary>
/// #717: Runs the task with an instruction from the code ahead of it. Without an
/// implementation the instruction is dropped and the task runs as usual.
/// </summary>
Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
=> DispatchAsync(task, ct);
}
/// <summary>
/// #717: Whether a task is too large to simply start — its estimate is a large share of the
/// agent's daily budget. Then its run plans instead: split it, or ask for a special budget.
/// </summary>
public interface ILargeTaskCheck
{
/// <summary>The instruction for the planning run, or <c>null</c> if the task can simply run.</summary>
Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct);
/// <summary>Whether the task's tree has a special budget — then, after planning, it runs.</summary>
Task<bool> IsFundedAsync(TaskItem task, CancellationToken ct);
}
/// <summary>
@@ -170,6 +190,12 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly Func<TimeSpan> _fillWarnBefore;
/// <summary>#717: Whether a task is too large to simply start.</summary>
private readonly ILargeTaskCheck? _largeCheck;
/// <summary>#717: Leaves a note on a task — why the scanner holds it.</summary>
private readonly Func<string, string, CancellationToken, Task>? _noteOnTask;
/// <summary>#717: Fill work already reported as past its deadline — once per task.</summary>
private readonly HashSet<string> _warnedOverdueFill = [];
@@ -205,8 +231,12 @@ public sealed class TaskScanner : IAsyncDisposable
ITaskTreeWatch? treeWatch = null,
IFillGate? fillGate = null,
Func<TaskItem, CancellationToken, Task>? askAboutFill = null,
Func<TimeSpan>? fillWarnBefore = null)
Func<TimeSpan>? fillWarnBefore = null,
ILargeTaskCheck? largeTaskCheck = null,
Func<string, string, CancellationToken, Task>? noteOnTask = null)
{
_largeCheck = largeTaskCheck;
_noteOnTask = noteOnTask;
_treeWatch = treeWatch;
_fillGate = fillGate;
_askAboutFill = askAboutFill;
@@ -616,10 +646,15 @@ public sealed class TaskScanner : IAsyncDisposable
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
{
// #717: A large task is not simply started — its run plans first.
var planning = await PlanningInstructionAsync(task, ct);
TaskLauf lauf;
try
{
lauf = await _dispatcher.DispatchAsync(task, ct);
lauf = planning is null
? await _dispatcher.DispatchAsync(task, ct)
: await _dispatcher.DispatchAsync(task, planning, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -656,6 +691,12 @@ public sealed class TaskScanner : IAsyncDisposable
_zurueckgestellt.TryRemove(task.Id, out _);
if (planning is not null)
{
await SettlePlanningAsync(task, token, lauf, now, ct);
return;
}
// Endstatus:
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
// wird nicht wiederholt).
@@ -682,9 +723,99 @@ public sealed class TaskScanner : IAsyncDisposable
await _repo.ClearBudgetMissAsync(task.Id, now, ct);
if (ok && final == TaskItemStatus.Done)
{
await UnblockDependentsAsync(task.Id, now, ct);
await ResumeLargeParentAsync(task, now, ct);
}
}
/// <summary>
/// #717: The planning instruction for a large one-time task that has not been planned yet.
/// A failing check costs the check, not the run.
/// </summary>
private async Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct)
{
if (_largeCheck is null || task.IsRecurring || task.LargePlannedAt is not null || task.Type == TaskItemType.ToolJob)
return null;
try
{
return await _largeCheck.PlanningInstructionAsync(task, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Large-task check failed for {TaskId} — it runs as usual", task.Id);
return null;
}
}
/// <summary>
/// #717: After the planning run of a large task. Split into parts → it waits for them.
/// A special budget approved meanwhile → it runs. Otherwise it waits, blocked, for the
/// human's decision on the budget — never silently: the task says why.
///
/// <para>The occurrence is given back first: the planning run did not do the work, and a
/// one-time task whose marker is used up would never run again.</para>
/// </summary>
private async Task SettlePlanningAsync(TaskItem task, string token, TaskLauf lauf, DateTime now, CancellationToken ct)
{
if (!await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
return;
if (lauf != TaskLauf.Erledigt)
return; // the planning did not take place or failed — the next due tick plans again
await _repo.MarkLargePlannedAsync(task.Id, now, ct);
var parts = (await _repo.ListChildrenAsync(task.Id, ct)).Count(IsOpen);
string? note = null;
if (parts > 0)
{
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
note = $"Large task: split into {parts} part(s). It runs again once they are done.";
}
else if (_largeCheck is not null && await _largeCheck.IsFundedAsync(task, ct))
{
_logger.LogInformation("Large task {TaskId} has a special budget — it runs on it", task.Id);
}
else
{
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
note = "Large task: not started. It waits for a special budget to be approved (staging), "
+ "or for a human to split it or set it back to todo.";
}
if (note is not null)
{
_logger.LogInformation("Task {TaskId}: {Note}", task.Id, note);
if (_noteOnTask is not null)
{
try { await _noteOnTask(task.Id, note, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Could not note on task {TaskId}", task.Id);
}
}
}
}
/// <summary>
/// #717: A part of a split large task is done. When it was the last open one, the large
/// task is taken up again — to bring the parts together.
/// </summary>
private async Task ResumeLargeParentAsync(TaskItem part, DateTime now, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(part.ParentId)
|| await _repo.GetAsync(part.ParentId, ct) is not { Status: TaskItemStatus.Blocked, LargePlannedAt: not null } parent)
return;
if ((await _repo.ListChildrenAsync(parent.Id, ct)).Any(IsOpen))
return;
await _repo.SetStatusAsync(parent.Id, TaskItemStatus.Todo, now, ct);
_logger.LogInformation("Large task {TaskId}: all parts done — it runs again to bring them together", parent.Id);
}
private static bool IsOpen(TaskItem task)
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
/// <summary>
/// #716: The budget refused the run. A one-time task is carried over to the next day
/// with its occurrence intact; a recurring one follows the agent's policy — the