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;
}
}