Files
ClawdDotNet/src/ClawdDotNet.App/Services/TaskAdministration.cs
T
RichardandClaude Opus 5.5 9cf9a2cfc2 Budgetende, Stufe 3: Nachholen nur im Spielraum des Tages, keine Kappung bei 500 (#716)
- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem
  Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs.
- BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf
  startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus
  Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht
  minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens.
  Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich
  mitgenommene Aufgaben gegenseitig.
- Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es
  warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und
  Engine dieselben Reservierungen sehen.
- Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein
  fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig.

Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt):
Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und
task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger
Prioritaet unsichtbar wurden.

Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500
Aufgaben (1). 1.257 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:10:40 +02:00

277 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Scheduling;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>Eine Aufgabe, wie das Taskboard sie zeigt: Definition, naechster und letzter Lauf.</summary>
public sealed record TaskOverview(TaskItem Task, DateTime? NextRunUtc, RunReceipt? LastRun, string? Hinweis);
/// <summary>Was eine Aufgabe ausmacht - beim Anlegen wie beim Aendern.</summary>
public sealed record TaskInput(
string Title,
string Body = "",
string Assignee = "",
string? WhenKind = null,
string? WhenValue = null,
string? TimeZone = null,
string Acceptance = "",
int Priority = 3);
/// <summary>
/// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren,
/// dazu naechster und letzter Lauf und die Kostenprognose (#595).
///
/// <para>Hier und nicht im Endpunkt, weil es Regeln sind: Ein Termin, der nie feuern kann,
/// wird nicht gespeichert (#597); eine wiederkehrende Aufgabe im Chatverlauf bekommt einen
/// Hinweis (#598); nur bereite Aufgaben lassen sich sofort ausfuehren.</para>
///
/// <para>Ersetzt fuer die Weboberflaeche den Reiter "Geplante Laeufe", der die
/// Alt-Konfiguration scheduler/toolJobs bearbeitete - nach der ersten Uebernahme ins
/// Taskboard wirkte dort keine Aenderung mehr.</para>
/// </summary>
public sealed class TaskAdministration(
TaskboardService? board,
IAuditRepository? audit,
TaskScanner? scanner,
IUsageRepository? usage,
ModelPricingCatalog? pricing,
Func<IReadOnlyList<AgentConfig>> agents,
ILogger logger,
TimeProvider? clock = null)
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
private const string KeinBoard = "Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.";
private DateTime JetztUtc => _clock.GetUtcNow().UtcDateTime;
public async Task<IReadOnlyList<TaskOverview>?> ListAsync(int limit, CancellationToken ct)
{
if (board is null)
return null;
var items = await board.ListAsync(new TaskQuery { Limit = Math.Clamp(limit, 1, 500) }, ct);
var jetzt = JetztUtc;
var result = new List<TaskOverview>(items.Count);
foreach (var task in items)
{
RunReceipt? letzter = null;
if (audit is not null)
letzter = (await audit.ListReceiptsForTaskAsync(task.Id, ct)).LastOrDefault();
var laeuft = task.Status is TaskItemStatus.Todo or TaskItemStatus.InProgress
&& TaskAssignee.KindOf(task.Assignee) != TaskAssigneeKind.Human;
result.Add(new TaskOverview(
task,
laeuft ? TaskSchedule.NextOccurrenceUtc(task, jetzt) : null,
letzter,
TaskHinweise.WachsenderKontext(task)));
}
return result;
}
public async Task<AdminResult> CreateAsync(TaskInput input, CancellationToken ct)
{
if (board is null)
return AdminResult.Failure(KeinBoard);
if (Pruefe(input, out var assignee, out var when) is { } fehler)
return AdminResult.Failure(fehler);
var created = await board.CreateAsync(new TaskItem
{
Title = input.Title.Trim(),
Body = input.Body.Trim(),
Acceptance = input.Acceptance.Trim(),
Priority = Math.Clamp(input.Priority, 1, 5),
Assignee = assignee,
When = when,
Status = TaskItemStatus.Todo,
Type = TaskItemType.Work
}, ct);
return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created));
}
public async Task<AdminResult> UpdateAsync(string id, TaskInput input, CancellationToken ct)
{
if (board is null)
return AdminResult.Failure(KeinBoard);
if (Pruefe(input, out var assignee, out var when) is { } fehler)
return AdminResult.Failure(fehler);
var updated = await board.UpdateAsync(id, current => current with
{
Title = input.Title.Trim(),
Body = input.Body.Trim(),
Acceptance = input.Acceptance.Trim(),
Priority = Math.Clamp(input.Priority, 1, 5),
Assignee = assignee,
When = when
}, ct);
return updated is null
? AdminResult.Failure($"Keine Aufgabe '{id}'.")
: AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated));
}
/// <summary>Pausieren heisst <c>backlog</c>, Aktivieren <c>todo</c> - dieselben Zustaende, die der Scanner kennt.</summary>
public async Task<AdminResult> SetEnabledAsync(string id, bool enabled, CancellationToken ct)
{
if (board is null)
return AdminResult.Failure(KeinBoard);
if (await board.GetAsync(id, ct) is not { } current)
return AdminResult.Failure($"Keine Aufgabe '{id}'.");
if (current.Status == TaskItemStatus.InProgress)
return AdminResult.Failure("Die Aufgabe läuft gerade. Nach dem Lauf erneut versuchen.");
var ziel = enabled ? TaskItemStatus.Todo : TaskItemStatus.Backlog;
if (current.Status == ziel)
return AdminResult.Success(enabled ? "Die Aufgabe ist bereits aktiv." : "Die Aufgabe ist bereits pausiert.");
await board.UpdateAsync(id, c => c with { Status = ziel }, ct);
return AdminResult.Success(enabled ? $"Aufgabe '{current.Title}' aktiviert." : $"Aufgabe '{current.Title}' pausiert.");
}
/// <summary>
/// Stoesst einen Lauf an, ohne auf ihn zu warten - ein Lauf dauert Minuten, eine
/// HTTP-Anfrage soll das nicht. Das Ergebnis erscheint unter "Letzter Lauf".
/// </summary>
public async Task<AdminResult> RunNowAsync(string id, CancellationToken ct)
{
if (board is null)
return AdminResult.Failure(KeinBoard);
if (scanner is null)
return AdminResult.Failure("Kein Scanner: In dieser Instanz laufen keine Aufgaben.");
if (await board.GetAsync(id, ct) is not { } task)
return AdminResult.Failure($"Keine Aufgabe '{id}'.");
if (TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human)
return AdminResult.Failure("Diese Aufgabe gehört einem Menschen – dafür gibt es keinen Agentenlauf.");
if (task.Status != TaskItemStatus.Todo)
return AdminResult.Failure($"Nur bereite Aufgaben lassen sich sofort ausführen (Status: {TaskText.Of(task.Status)}).");
_ = Task.Run(async () =>
{
try
{
if (!await scanner.RunTaskNowAsync(id, CancellationToken.None))
logger.LogInformation("Sofortlauf für Aufgabe {TaskId} fand nicht statt (belegt oder nicht bereit)", id);
}
catch (Exception ex)
{
logger.LogError(ex, "Sofortlauf für Aufgabe {TaskId} ist gescheitert", id);
}
});
return AdminResult.Success($"Lauf für '{task.Title}' angestoßen. Das Ergebnis erscheint unter „Letzter Lauf“.");
}
/// <summary>Die Kostenprognose der Instanz (#595); <c>null</c> ohne Taskboard.</summary>
public async Task<InstanzPrognose?> ForecastAsync(CancellationToken ct)
{
if (board is null)
return null;
// #716: ungekappt - vorher fehlten ab 500 Aufgaben die übrigen in der Prognose.
var aufgaben = await board.ListAllAsync(ct);
var belege = new Dictionary<string, IReadOnlyList<RunReceipt>>(StringComparer.Ordinal);
if (audit is not null)
{
foreach (var aufgabe in aufgaben.Where(a => TaskAssignee.KindOf(a.Assignee) != TaskAssigneeKind.Human))
belege[aufgabe.Id] = await audit.ListReceiptsForTaskAsync(aufgabe.Id, ct);
}
IReadOnlyList<RunUsage> verbrauch = usage is null ? [] : await usage.GetRecentAsync(1000, ct);
return KostenPrognose.Berechne(
aufgaben, belege, verbrauch, agents(),
pricing is null ? null : pricing.Estimate,
JetztUtc);
}
// ─── Regeln ───
private string? Pruefe(TaskInput input, out string assignee, out TaskWhen? when)
{
assignee = input.Assignee?.Trim() ?? "";
when = null;
if (string.IsNullOrWhiteSpace(input.Title))
return "Ohne Titel geht es nicht.";
if (assignee.Length == 0)
return "Wer soll die Aufgabe ausführen? Einen Agenten oder einen Menschen wählen.";
if (PruefeZuordnung(assignee) is { } zuordnung)
return zuordnung;
if (string.IsNullOrWhiteSpace(input.WhenKind))
return null; // einmalig
if (TaskText.WhenKind(input.WhenKind) is not { } kind)
return "Terminart muss 'cron', 'every' oder 'at' sein.";
if (string.IsNullOrWhiteSpace(input.WhenValue))
return "Zum Termin fehlt der Wert (Cron-Ausdruck, Intervall oder Zeitpunkt).";
var candidate = new TaskWhen
{
Kind = kind,
Value = input.WhenValue.Trim(),
TimeZone = string.IsNullOrWhiteSpace(input.TimeZone) ? TimeZones.LocalIanaId : input.TimeZone.Trim()
};
if (TaskSchedule.ValidateWhen(candidate) is { } termin)
return termin;
when = candidate;
return null;
}
private string? PruefeZuordnung(string assignee)
{
var liste = agents();
switch (TaskAssignee.KindOf(assignee))
{
case TaskAssigneeKind.Human:
return null;
case TaskAssigneeKind.New:
{
var id = TaskAssignee.AgentId(assignee);
if (id.Length == 0)
return liste.Count == 1 ? null : "'@new' braucht bei mehreren Agenten einen Namen: '@new:<agent>'.";
return Existiert(liste, id) ? null : $"Kein Agent '{id}'.";
}
default:
{
var id = TaskAssignee.AgentId(assignee);
return Existiert(liste, id) ? null : $"Kein Agent '{id}'.";
}
}
}
private static bool Existiert(IReadOnlyList<AgentConfig> liste, string agentId)
=> liste.Any(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
private static string MitHinweis(string meldung, TaskItem task)
=> TaskHinweise.WachsenderKontext(task) is { } hinweis ? $"{meldung} {hinweis}" : meldung;
}