namespace ClawdDotNet.Core.Tasks; /// /// Lebenszyklus einer Aufgabe. Als Zeichenkette in der DB und im Frontmatter abgelegt, /// damit beide Seiten dieselbe, menschenlesbare Form teilen. /// public enum TaskItemStatus { /// Angelegt, aber noch nicht zur Ausführung freigegeben. Backlog, /// Bereit; der Scanner darf sie beim nächsten fälligen Termin nehmen. Todo, /// Ein Lauf hat sie beansprucht und arbeitet daran. InProgress, /// Erledigt, wartet aber auf Review (require_approval). InReview, /// Abgeschlossen. Done, /// Verworfen, ohne erledigt zu sein. Canceled, /// Wartet auf einen offenen Blocker (blocked_by) oder auf eine Meldung. Blocked, /// Aus dem Board genommen (Datei gelöscht, aufgeräumt) — bleibt für die Historie. Archived } /// /// Art der Aufgabe. Ein Mensch ist einfach ein Assignee, kein Sonderpfad — approval /// und human_input unterscheiden sich nur darin, dass ihr Ergebnis von einem /// Menschen statt von einem Modell kommt. /// public enum TaskItemType { Work, Approval, HumanInput, /// /// Ein wiederkehrender Poll: Der Scanner tickt beim fälligen Termin einen /// IToolJobProvider (siehe /) /// und weckt den Agenten nur, wenn der Tick etwas meldet. Ersetzt den früheren /// ToolJobScheduler — alles Periodische ist jetzt ein Task. /// ToolJob } /// Wie ein Termin zu verstehen ist. public enum TaskWhenKind { /// Einmaliger Zeitpunkt (ISO 8601). At, /// Wiederkehrendes Intervall (z. B. 30m, 2h). Every, /// 5-Felder-Cron-Ausdruck. Cron } /// /// Ein Termin. Die Zeitzone ist Pflicht — das ist die Antwort auf B7 (Cron lief bisher /// zonen-blind in Lokalzeit). Ohne Zone lässt sich 0 7 * * * nicht eindeutig /// deuten. /// public sealed record TaskWhen { public TaskWhenKind Kind { get; init; } public string Value { get; init; } = ""; /// IANA-Zeitzone, etwa Europe/Berlin. public string TimeZone { get; init; } = ""; } /// /// Wer eine Aufgabe ausführt. Löst das implizite UseChatContext-Flag (T7) durch /// eine explizite Angabe am Auftrag ab. /// public enum TaskAssigneeKind { /// @new — frischer Lauf ohne Historie (RunAsync). New, /// @<agentId> — bestehender Agent mit seinem Kontext (ChatAsync). Agent, /// @human — wartet auf einen Menschen; kein Modell-Lauf. Human } /// /// Deutet den assignee-String, ohne ihn selbst zu speichern. Formen: /// @human, @<agentId> (bestehender Kontext), @new bzw. /// @new:<agentId> (frischer Lauf ohne Historie). Bei bloßem @new ohne /// Agent bleibt die Ziel-Id offen — dann muss die Instanz genau einen Agenten haben. /// 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; } /// /// Die reine Agent-Id. Für @<agent> der Teil hinter dem @, für /// @new:<agent> der Teil hinter dem Doppelpunkt. Leer bei @human /// und bloßem @new. /// 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 ""; } } } /// /// 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. /// public sealed record TaskItem { // ─── Definition (aus dem Frontmatter gespiegelt) ─── /// Stabile Identität. Überlebt das Umbenennen der Datei. public string Id { get; init; } = ""; public string Title { get; init; } = ""; public TaskItemStatus Status { get; init; } = TaskItemStatus.Todo; public TaskItemType Type { get; init; } = TaskItemType.Work; /// 1 (niedrig) .. 5 (hoch). public int Priority { get; init; } = 3; public string Assignee { get; init; } = TaskAssignee.Human; /// Termin; null = einmalige, sofort fällige Aufgabe. public TaskWhen? When { get; init; } /// Gilt erst nach Review als . public bool RequireApproval { get; init; } /// Abnahmekriterien, gegen die das Ergebnis geprüft wird. public string Acceptance { get; init; } = ""; /// Ids der Aufgaben, die erst erledigt sein müssen. public IReadOnlyList BlockedBy { get; init; } = []; /// C1: Termin nur auslösen, wenn der Markt offen ist. public bool OnlyWhenMarketOpen { get; init; } /// Auftragsbeschreibung — geht als Aufgabenstellung an den Agenten. public string Body { get; init; } = ""; /// Dateiname relativ zu tasks/. Dient dem Rückschreiben. public string FileName { get; init; } = ""; // ─── Nur für Typ tool_job ─── /// Das zu tickende Tool (z. B. Telegram) — nur bei tool_job. public string ToolName { get; init; } = ""; /// Die Job-Art des Tools (z. B. telegram_poll) — nur bei tool_job. public string JobTypeId { get; init; } = ""; // ─── Ausführungszustand (nur das Board schreibt) ─── /// /// 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. /// public string? LastOccurrence { get; init; } /// Gesetzt, solange ein Lauf die Aufgabe beansprucht. public string? ClaimToken { get; init; } public DateTime? ClaimedAt { get; init; } public DateTime CreatedAt { get; init; } public DateTime UpdatedAt { get; init; } // ─── Budget misses (#716) ─── /// /// 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. /// public int BudgetMissCount { get; init; } /// When the last budget refusal happened (UTC). Kept after a reset, as history. public DateTime? LastBudgetMissAt { get; init; } /// /// 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. /// public DateTime? DeferredUntil { get; init; } /// /// A recurring task under missed at least /// one occurrence; all of them are caught up by a single run. /// public bool CatchUpPending { get; init; } /// /// #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 . /// public string CreatedBy { get; init; } = ""; /// /// #714: The task this one serves — its purpose ("what for"), where /// is its precondition ("when"). Empty for a task at the top; above the top stands the /// goal of the unit. See . /// public string ParentId { get; init; } = ""; /// /// #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. /// public string OriginRunId { get; init; } = ""; /// #697: The task that run was working on, if any — the task this one arose from. public string OriginTaskId { get; init; } = ""; // ─── Fill work and estimates (#717) ─── /// /// #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. /// public DateTime? FillBy { get; init; } /// /// #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 ). /// public decimal? EstimateUsd { get; init; } /// /// #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. /// public DateTime? FillAlertedAt { get; init; } /// /// #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. /// public DateTime? LargePlannedAt { get; init; } /// #717: One-time work with a deadline instead of an appointment. public bool IsFillWork => FillBy is not null && !IsRecurring; /// A one-time task that was refused for lack of budget and carried over. public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0; /// /// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf todo zurück statt auf /// done — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein /// tool_job ist immer ein Poll und damit wiederkehrend. /// public bool IsRecurring => Type == TaskItemType.ToolJob || When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every }; } /// /// #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. /// public enum BudgetMissPolicy { /// The occurrence lapses and is recorded; the next regular one comes anyway. Expire, /// All missed occurrences are caught up by one run on the next day. CatchUpOnce } /// Suchkriterien für . public sealed record TaskQuery { public TaskItemStatus? Status { get; init; } /// Filtert auf einen Assignee (roher String, z. B. @crawler). public string? Assignee { get; init; } /// Freitext über Titel und Beschreibung. public string? Search { get; init; } /// Archivierte werden standardmäßig ausgeblendet. public bool IncludeArchived { get; init; } public int Limit { get; init; } = 50; /// /// #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. /// 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; } }