Files
ClawdDotNet/src/ClawdDotNet.Core/Tasks/TaskModels.cs
T
RichardandClaude Opus 5.5 5f3a85595b Budgetende, Stufe 1: Ausfall vermerken, Regel je Agent, mitnehmen statt anrennen (#716)
Bisher teilte sich eine Budgetablehnung den Ausgang NichtGelaufen mit dem
Herunterfahren. Einmalige Aufgaben wurden den Rest des Tages alle 30 Minuten
erneut abgelehnt (jedes Mal ein Beleg) und liefen nach Mitternacht still zu
Lasten des neuen Tages. Wiederkehrende verfielen still.

- TaskLauf.KeinBudget als eigener Ausgang; der Dispatcher bildet
  BudgetExceeded darauf ab.
- Vier Felder an der Aufgabe: BudgetMissCount, LastBudgetMissAt,
  DeferredUntil, CatchUpPending (Spalten werden fuer bestehende Datenbanken
  nachgeruestet).
- Einmalige Aufgaben werden mitgenommen: Der Termin bleibt erhalten, faellig
  ist die Aufgabe wieder ab der naechsten lokalen Mitternacht, nicht vorher.
- Wiederkehrende Aufgaben folgen der Regel des Agenten
  (budget.whenExhausted): expire vermerkt den Ausfall und laesst den Termin
  verfallen; catchUpOnce fasst alle verpassten Termine zu einem Nachholauf am
  naechsten Tag zusammen. Ein regulaer faelliger Termin deckt das Nachholen
  mit ab, es bleibt bei einem Lauf.
- Der neue Tag hat Vorfahrt: Mitgenommenes und Nachholaeufe kommen nach allem
  regulaer Faelligen, unabhaengig von der Prioritaet.
- Beim ersten Takt eines neuen Tages eine Zusammenfassung je Agent im Log.
- Sichtbar: Die Aufgabenkarte der WebUI zeigt Ausfaelle, Mitnahme und
  Nachholen; die Regel ist im Desktop je Agent einstellbar. Nebenbei sind
  "Next:"/"Last:" auf der Karte ins Sprachpaket gewandert.

Stufen 2 bis 4 (Reservierung, Spielraum statt pauschalem Anteil, Vorbeugen)
folgen.

Tests: BudgetMissTests (9 neu). 1.238 gruen.

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

273 lines
9.9 KiB
C#

namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// Lebenszyklus einer Aufgabe. Als Zeichenkette in der DB und im Frontmatter abgelegt,
/// damit beide Seiten dieselbe, menschenlesbare Form teilen.
/// </summary>
public enum TaskItemStatus
{
/// <summary>Angelegt, aber noch nicht zur Ausführung freigegeben.</summary>
Backlog,
/// <summary>Bereit; der Scanner darf sie beim nächsten fälligen Termin nehmen.</summary>
Todo,
/// <summary>Ein Lauf hat sie beansprucht und arbeitet daran.</summary>
InProgress,
/// <summary>Erledigt, wartet aber auf Review (<c>require_approval</c>).</summary>
InReview,
/// <summary>Abgeschlossen.</summary>
Done,
/// <summary>Verworfen, ohne erledigt zu sein.</summary>
Canceled,
/// <summary>Wartet auf einen offenen Blocker (<c>blocked_by</c>) oder auf eine Meldung.</summary>
Blocked,
/// <summary>Aus dem Board genommen (Datei gelöscht, aufgeräumt) — bleibt für die Historie.</summary>
Archived
}
/// <summary>
/// Art der Aufgabe. Ein Mensch ist einfach ein Assignee, kein Sonderpfad — <c>approval</c>
/// und <c>human_input</c> unterscheiden sich nur darin, dass ihr Ergebnis von einem
/// Menschen statt von einem Modell kommt.
/// </summary>
public enum TaskItemType
{
Work,
Approval,
HumanInput,
/// <summary>
/// Ein wiederkehrender Poll: Der Scanner tickt beim fälligen Termin einen
/// <c>IToolJobProvider</c> (siehe <see cref="TaskItem.ToolName"/>/<see cref="TaskItem.JobTypeId"/>)
/// und weckt den Agenten nur, wenn der Tick etwas meldet. Ersetzt den früheren
/// ToolJobScheduler — alles Periodische ist jetzt ein Task.
/// </summary>
ToolJob
}
/// <summary>Wie ein Termin zu verstehen ist.</summary>
public enum TaskWhenKind
{
/// <summary>Einmaliger Zeitpunkt (ISO 8601).</summary>
At,
/// <summary>Wiederkehrendes Intervall (z. B. <c>30m</c>, <c>2h</c>).</summary>
Every,
/// <summary>5-Felder-Cron-Ausdruck.</summary>
Cron
}
/// <summary>
/// Ein Termin. Die Zeitzone ist Pflicht — das ist die Antwort auf B7 (Cron lief bisher
/// zonen-blind in Lokalzeit). Ohne Zone lässt sich <c>0 7 * * *</c> nicht eindeutig
/// deuten.
/// </summary>
public sealed record TaskWhen
{
public TaskWhenKind Kind { get; init; }
public string Value { get; init; } = "";
/// <summary>IANA-Zeitzone, etwa <c>Europe/Berlin</c>.</summary>
public string TimeZone { get; init; } = "";
}
/// <summary>
/// Wer eine Aufgabe ausführt. Löst das implizite <c>UseChatContext</c>-Flag (T7) durch
/// eine explizite Angabe am Auftrag ab.
/// </summary>
public enum TaskAssigneeKind
{
/// <summary><c>@new</c> — frischer Lauf ohne Historie (<c>RunAsync</c>).</summary>
New,
/// <summary><c>@&lt;agentId&gt;</c> — bestehender Agent mit seinem Kontext (<c>ChatAsync</c>).</summary>
Agent,
/// <summary><c>@human</c> — wartet auf einen Menschen; kein Modell-Lauf.</summary>
Human
}
/// <summary>
/// Deutet den <c>assignee</c>-String, ohne ihn selbst zu speichern. Formen:
/// <c>@human</c>, <c>@&lt;agentId&gt;</c> (bestehender Kontext), <c>@new</c> bzw.
/// <c>@new:&lt;agentId&gt;</c> (frischer Lauf ohne Historie). Bei bloßem <c>@new</c> ohne
/// Agent bleibt die Ziel-Id offen — dann muss die Instanz genau einen Agenten haben.
/// </summary>
public static class TaskAssignee
{
public const string New = "@new";
public const string Human = "@human";
public static TaskAssigneeKind KindOf(string? assignee)
{
var value = assignee?.Trim();
if (string.Equals(value, Human, StringComparison.OrdinalIgnoreCase))
return TaskAssigneeKind.Human;
if (string.Equals(value, New, StringComparison.OrdinalIgnoreCase)
|| value?.StartsWith("@new:", StringComparison.OrdinalIgnoreCase) == true)
return TaskAssigneeKind.New;
return TaskAssigneeKind.Agent;
}
/// <summary>
/// Die reine Agent-Id. Für <c>@&lt;agent&gt;</c> der Teil hinter dem <c>@</c>, für
/// <c>@new:&lt;agent&gt;</c> der Teil hinter dem Doppelpunkt. Leer bei <c>@human</c>
/// und bloßem <c>@new</c>.
/// </summary>
public static string AgentId(string? assignee)
{
var value = assignee?.Trim() ?? "";
switch (KindOf(value))
{
case TaskAssigneeKind.Agent:
return value.StartsWith('@') ? value[1..] : value;
case TaskAssigneeKind.New:
var colon = value.IndexOf(':');
return colon >= 0 ? value[(colon + 1)..].Trim() : "";
default:
return "";
}
}
}
/// <summary>
/// Eine Aufgabe — die Spiegelung der Frontmatter-Definition zusammen mit dem
/// Ausführungszustand. Die Definition ist in der Datei die Wahrheit, der
/// Ausführungszustand in der DB (siehe Taskboard-Konzept). Diese Zeile hält beides,
/// damit der Scanner ohne Dateizugriff entscheiden kann.
/// </summary>
public sealed record TaskItem
{
// ─── Definition (aus dem Frontmatter gespiegelt) ───
/// <summary>Stabile Identität. Überlebt das Umbenennen der Datei.</summary>
public string Id { get; init; } = "";
public string Title { get; init; } = "";
public TaskItemStatus Status { get; init; } = TaskItemStatus.Todo;
public TaskItemType Type { get; init; } = TaskItemType.Work;
/// <summary>1 (niedrig) .. 5 (hoch).</summary>
public int Priority { get; init; } = 3;
public string Assignee { get; init; } = TaskAssignee.Human;
/// <summary>Termin; <c>null</c> = einmalige, sofort fällige Aufgabe.</summary>
public TaskWhen? When { get; init; }
/// <summary>Gilt erst nach Review als <see cref="TaskItemStatus.Done"/>.</summary>
public bool RequireApproval { get; init; }
/// <summary>Abnahmekriterien, gegen die das Ergebnis geprüft wird.</summary>
public string Acceptance { get; init; } = "";
/// <summary>Ids der Aufgaben, die erst erledigt sein müssen.</summary>
public IReadOnlyList<string> BlockedBy { get; init; } = [];
/// <summary>C1: Termin nur auslösen, wenn der Markt offen ist.</summary>
public bool OnlyWhenMarketOpen { get; init; }
/// <summary>Auftragsbeschreibung — geht als Aufgabenstellung an den Agenten.</summary>
public string Body { get; init; } = "";
/// <summary>Dateiname relativ zu <c>tasks/</c>. Dient dem Rückschreiben.</summary>
public string FileName { get; init; } = "";
// ─── Nur für Typ tool_job ───
/// <summary>Das zu tickende Tool (z. B. <c>Telegram</c>) — nur bei <c>tool_job</c>.</summary>
public string ToolName { get; init; } = "";
/// <summary>Die Job-Art des Tools (z. B. <c>telegram_poll</c>) — nur bei <c>tool_job</c>.</summary>
public string JobTypeId { get; init; } = "";
// ─── Ausführungszustand (nur das Board schreibt) ───
/// <summary>
/// Occurrence-Key des zuletzt behandelten Termins (sortierbares ISO-UTC). Ein neuer
/// Termin gilt nur als fällig, wenn er hierüber liegt — so wird ein Termin höchstens
/// einmal ausgelöst.
/// </summary>
public string? LastOccurrence { get; init; }
/// <summary>Gesetzt, solange ein Lauf die Aufgabe beansprucht.</summary>
public string? ClaimToken { get; init; }
public DateTime? ClaimedAt { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
// ─── Budget misses (#716) ───
/// <summary>
/// How many times in a row a run of this task was refused because the budget was
/// exhausted. Reset by the next run that actually takes place.
/// </summary>
public int BudgetMissCount { get; init; }
/// <summary>When the last budget refusal happened (UTC). Kept after a reset, as history.</summary>
public DateTime? LastBudgetMissAt { get; init; }
/// <summary>
/// Not before this point (UTC). Set after a budget refusal to the start of the next
/// local day: retrying within the same day only produces further refusals.
/// </summary>
public DateTime? DeferredUntil { get; init; }
/// <summary>
/// A recurring task under <see cref="BudgetMissPolicy.CatchUpOnce"/> missed at least
/// one occurrence; all of them are caught up by a single run.
/// </summary>
public bool CatchUpPending { get; init; }
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
/// <summary>
/// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf <c>todo</c> zurück statt auf
/// <c>done</c> — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
/// <c>tool_job</c> ist immer ein Poll und damit wiederkehrend.
/// </summary>
public bool IsRecurring
=> Type == TaskItemType.ToolJob
|| When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
}
/// <summary>
/// #716: What happens to a run of a recurring task that the budget refused. One-time
/// tasks are always carried over — they are real work that must not disappear.
/// </summary>
public enum BudgetMissPolicy
{
/// <summary>The occurrence lapses and is recorded; the next regular one comes anyway.</summary>
Expire,
/// <summary>All missed occurrences are caught up by one run on the next day.</summary>
CatchUpOnce
}
/// <summary>Suchkriterien für <see cref="ITaskRepository.ListAsync"/>.</summary>
public sealed record TaskQuery
{
public TaskItemStatus? Status { get; init; }
/// <summary>Filtert auf einen Assignee (roher String, z. B. <c>@crawler</c>).</summary>
public string? Assignee { get; init; }
/// <summary>Freitext über Titel und Beschreibung.</summary>
public string? Search { get; init; }
/// <summary>Archivierte werden standardmäßig ausgeblendet.</summary>
public bool IncludeArchived { get; init; }
public int Limit { get; init; } = 50;
}