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>
348 lines
13 KiB
C#
348 lines
13 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>@<agentId></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>@<agentId></c> (bestehender Kontext), <c>@new</c> bzw.
|
|
/// <c>@new:<agentId></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>@<agent></c> der Teil hinter dem <c>@</c>, für
|
|
/// <c>@new:<agent></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>
|
|
/// #684: The agent that created the task; empty for tasks from a human, from a file
|
|
/// written by hand, or from before this field. Decides the board of a task nobody in
|
|
/// particular is assigned to — see <see cref="TaskBoard"/>.
|
|
/// </summary>
|
|
public string CreatedBy { get; init; } = "";
|
|
|
|
/// <summary>
|
|
/// #714: The task this one serves — its purpose ("what for"), where <see cref="BlockedBy"/>
|
|
/// is its precondition ("when"). Empty for a task at the top; above the top stands the
|
|
/// goal of the unit. See <see cref="GoalChain"/>.
|
|
/// </summary>
|
|
public string ParentId { get; init; } = "";
|
|
|
|
/// <summary>
|
|
/// #697: The run in which an agent created this task — stamped by the engine, never taken
|
|
/// from the agent. With it, whoever decides can go back to the original without asking:
|
|
/// what the agent saw and did when it handed this out. Empty for tasks from a human.
|
|
/// </summary>
|
|
public string OriginRunId { get; init; } = "";
|
|
|
|
/// <summary>#697: The task that run was working on, if any — the task this one arose from.</summary>
|
|
public string OriginTaskId { get; init; } = "";
|
|
|
|
// ─── Fill work and estimates (#717) ───
|
|
|
|
/// <summary>
|
|
/// #717: Fill work — "at the latest by" instead of an appointment (UTC). The task runs when
|
|
/// the day's budget has room that would otherwise lapse, and before this point at the
|
|
/// latest. Only for one-time tasks; a recurring task has its appointments.
|
|
/// </summary>
|
|
public DateTime? FillBy { get; init; }
|
|
|
|
/// <summary>
|
|
/// #717: What the task's author expects one run to cost, in USD — a guess, and marked as
|
|
/// one. Used only while there is nothing measured: the task's own runs, or those of its
|
|
/// siblings, replace it (see <see cref="Budget.TaskCostEstimator"/>).
|
|
/// </summary>
|
|
public decimal? EstimateUsd { get; init; }
|
|
|
|
/// <summary>
|
|
/// #717: When a human was asked because the fill work found no room before its deadline.
|
|
/// Asked once; execution state, not part of the file.
|
|
/// </summary>
|
|
public DateTime? FillAlertedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// #717: When a large task had its planning run — split or special budget, the agent
|
|
/// proposed. Planned once; afterwards it runs like any other task.
|
|
/// </summary>
|
|
public DateTime? LargePlannedAt { get; init; }
|
|
|
|
/// <summary>#717: One-time work with a deadline instead of an appointment.</summary>
|
|
public bool IsFillWork => FillBy is not null && !IsRecurring;
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// #684: The same filter as the repository's query, for a list that is already in
|
|
/// memory — when a board has to be filtered first and the database cannot do that.
|
|
/// </summary>
|
|
public bool Matches(TaskItem task)
|
|
{
|
|
if (Status is { } status ? task.Status != status : !IncludeArchived && task.Status == TaskItemStatus.Archived)
|
|
return false;
|
|
if (!string.IsNullOrWhiteSpace(Assignee) && !string.Equals(task.Assignee, Assignee.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
if (!string.IsNullOrWhiteSpace(Search))
|
|
{
|
|
var search = Search.Trim();
|
|
if (!task.Title.Contains(search, StringComparison.OrdinalIgnoreCase)
|
|
&& !task.Body.Contains(search, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|