diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 1acb05b..675cf46 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -726,7 +726,15 @@ public sealed class AppHost : IAsyncDisposable engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory); var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory, - maxParallelRuns: instance.MaxParallelTaskRuns); + maxParallelRuns: instance.MaxParallelTaskRuns, + budgetMissPolicy: task => + { + // #716: The agent's rule. Bare '@new' names no agent — then the default applies. + var agentId = TaskAssignee.AgentId(task.Assignee); + return instance.Agents + .FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase)) + ?.Budget.MissPolicy ?? BudgetMissPolicy.Expire; + }); // Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten. _ = Task.Run(async () => diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index c851282..1bbbd92 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -183,6 +183,21 @@ public sealed class AgentBudget [JsonPropertyName("dailyTokens")] public long DailyTokens { get; set; } + /// + /// #716: What happens to a run of a recurring task when the budget is exhausted — + /// expire (default) or catchUpOnce. One-time tasks are always carried + /// over to the next day. Kept as text so an unknown value falls back instead of + /// failing the whole configuration. + /// + [JsonPropertyName("whenExhausted")] + public string WhenExhausted { get; set; } = "expire"; + + [JsonIgnore] + public Tasks.BudgetMissPolicy MissPolicy + => string.Equals(WhenExhausted?.Trim(), "catchUpOnce", StringComparison.OrdinalIgnoreCase) + ? Tasks.BudgetMissPolicy.CatchUpOnce + : Tasks.BudgetMissPolicy.Expire; + [JsonIgnore] public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0; } diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index e54e4cb..712da89 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -339,6 +339,12 @@ public sealed class SqliteStorage // #554: Paart den "angefangen"-Eintrag mit seinem Abschluss. SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''"); + // #716: What happened when the budget refused a run. + SpalteErgaenzen(connection, "Tasks", "BudgetMissCount", "INTEGER NOT NULL DEFAULT 0"); + SpalteErgaenzen(connection, "Tasks", "LastBudgetMissAt", "TEXT NULL"); + SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL"); + SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0"); + // Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen. using var index = connection.CreateCommand(); index.CommandText = """ diff --git a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs index 3164069..90bf206 100644 --- a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs +++ b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs @@ -98,7 +98,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher return result.Status switch { AgentRunStatus.Completed => TaskLauf.Erledigt, - AgentRunStatus.BudgetExceeded or AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen, + AgentRunStatus.BudgetExceeded => TaskLauf.KeinBudget, + AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen, _ => TaskLauf.Gescheitert }; } diff --git a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs index 559fc2b..a8cadc2 100644 --- a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs +++ b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs @@ -65,6 +65,28 @@ public interface ITaskRepository /// Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation). Task SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct); + /// + /// #716: The budget refused a run of a one-time task. Releases the claim, restores the + /// occurrence (the task is still due) and defers it to . + /// + Task CarryOverAsync( + string id, string claimToken, string? previousOccurrence, DateTime deferredUntil, + DateTime now, CancellationToken ct); + + /// + /// #716: The budget refused a run of a recurring task. Releases the claim back to + /// todo; the occurrence stays consumed. With a + /// single catch-up run is scheduled for then — an already pending one is kept. + /// + Task RecordBudgetMissAsync( + string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct); + + /// + /// #716: A run took place — clears the miss counter, a pending catch-up and the + /// deferral. stays as history. + /// + Task ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct); + /// /// Reconciliation beim Start: verwaiste Claims (älter als ) /// lösen und die betroffenen Aufgaben von zurück diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs index d4ee2cd..dba39e2 100644 --- a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs +++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs @@ -193,6 +193,68 @@ public sealed class SqliteTaskRepository : ITaskRepository return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); + public Task CarryOverAsync( + string id, string claimToken, string? previousOccurrence, DateTime deferredUntil, + DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE Tasks + SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, + LastOccurrence = @occ, UpdatedAt = @now, + BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now, + DeferredUntil = @deferred + WHERE Id = @id AND ClaimToken = @token + """; + cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value); + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@deferred", Format(deferredUntil)); + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@token", claimToken); + + return await cmd.ExecuteNonQueryAsync(ct) > 0; + }, ct); + + public Task RecordBudgetMissAsync( + string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + // A pending catch-up keeps its date: several misses still mean one catch-up run. + cmd.CommandText = """ + UPDATE Tasks + SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now, + BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now, + CatchUpPending = CASE WHEN @catchUp = 1 THEN 1 ELSE CatchUpPending END, + DeferredUntil = CASE WHEN @catchUp = 1 AND CatchUpPending = 0 THEN @deferred + ELSE DeferredUntil END + WHERE Id = @id AND ClaimToken = @token + """; + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@catchUp", catchUpUntil is null ? 0 : 1); + cmd.Parameters.AddWithValue("@deferred", catchUpUntil is { } until ? Format(until) : DBNull.Value); + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@token", claimToken); + + return await cmd.ExecuteNonQueryAsync(ct) > 0; + }, ct); + + public Task ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE Tasks + SET BudgetMissCount = 0, CatchUpPending = 0, DeferredUntil = NULL, UpdatedAt = @now + WHERE Id = @id + """; + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@id", id); + + return await cmd.ExecuteNonQueryAsync(ct) > 0; + }, ct); + public Task ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { @@ -252,7 +314,8 @@ public sealed class SqliteTaskRepository : ITaskRepository private const string Columns = "Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " + "RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " + - "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt"; + "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, " + + "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending"; private static void BindDefinition(SqliteCommand cmd, TaskItem task) { @@ -318,7 +381,11 @@ public sealed class SqliteTaskRepository : ITaskRepository ClaimToken = r.IsDBNull(18) ? null : r.GetString(18), ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)), CreatedAt = Parse(r.GetString(20)), - UpdatedAt = Parse(r.GetString(21)) + UpdatedAt = Parse(r.GetString(21)), + BudgetMissCount = r.GetInt32(22), + LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)), + DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)), + CatchUpPending = r.GetInt32(25) != 0 }; } diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs index 31e390b..9f75f22 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskModels.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs @@ -205,6 +205,32 @@ public sealed record TaskItem 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; } + + /// 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 @@ -215,6 +241,19 @@ public sealed record TaskItem || 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 { diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs index 288d29b..c1571c6 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs @@ -12,10 +12,17 @@ public enum TaskLauf Gescheitert, /// - /// Der Lauf fand gar nicht statt — Budget erschöpft, Instanz im Herunterfahren (#643). + /// Der Lauf fand gar nicht statt — Instanz im Herunterfahren (#643). /// Eine einmalige Aufgabe bleibt dann fällig, statt als gelaufen liegenzubleiben. /// - NichtGelaufen + NichtGelaufen, + + /// + /// #716: The run did not take place because the budget is exhausted. Unlike a + /// shutdown, retrying the same day is pointless — the scanner defers to the next day + /// and applies the agent's . + /// + KeinBudget } /// @@ -98,6 +105,11 @@ public sealed class TaskScanner : IAsyncDisposable /// How many due tasks had to wait at the last tick — logged only on change. private int _lastWaiting; + /// #716: What the budget refused, per agent, for the day summary. + private readonly Func _missPolicy; + private readonly Dictionary _missesToday = new(StringComparer.OrdinalIgnoreCase); + private DateOnly? _missDay; + private readonly CancellationTokenSource _cts = new(); private Task? _loop; @@ -109,7 +121,8 @@ public sealed class TaskScanner : IAsyncDisposable TimeProvider? clock = null, TimeSpan? tick = null, TimeSpan? lease = null, - int maxParallelRuns = DefaultMaxParallelRuns) + int maxParallelRuns = DefaultMaxParallelRuns, + Func? budgetMissPolicy = null) { _repo = repo; _dispatcher = dispatcher; @@ -119,6 +132,7 @@ public sealed class TaskScanner : IAsyncDisposable _tick = tick ?? TimeSpan.FromSeconds(60); _lease = lease ?? TimeSpan.FromMinutes(15); _maxParallelRuns = Math.Max(1, maxParallelRuns); + _missPolicy = budgetMissPolicy ?? (_ => BudgetMissPolicy.Expire); } /// How many scheduled runs may be in flight at once (#682). @@ -226,7 +240,9 @@ public sealed class TaskScanner : IAsyncDisposable // #682: ungekappt. Vorher ListAsync mit Limit 500 — darüber fielen Termine lautlos aus. var candidates = await _repo.ListRunnableAsync(ct); - var due = new List<(TaskItem Task, string Occurrence)>(); + SummarizeMissesIfNewDay(now); + + var due = new List<(TaskItem Task, string Occurrence, bool Late)>(); foreach (var task in candidates) { if (task.Status != TaskItemStatus.Todo) @@ -269,26 +285,41 @@ public sealed class TaskScanner : IAsyncDisposable if (_zurueckgestellt.TryGetValue(task.Id, out var ab) && now < ab) continue; - var occ = TaskSchedule.DueOccurrence(task, now); - if (occ is null) + // #716: Refused for lack of budget — not before the next day. For a recurring + // task this only holds back the catch-up; its regular occurrences keep coming. + var deferred = task.DeferredUntil is { } until && now < until; + if (deferred && !task.IsRecurring) continue; if (task.OnlyWhenMarketOpen && !_market.IsOpen(now)) continue; - due.Add((task, occ)); + var occ = TaskSchedule.DueOccurrence(task, now); + if (occ is not null) + { + // A regular occurrence also covers a pending catch-up: one run, not two. + due.Add((task, occ, Late: task.IsCarriedOver)); + continue; + } + + if (task.CatchUpPending && !deferred) + due.Add((task, Iso(now), Late: true)); } + // #716: The new day has right of way — carried-over and catch-up runs come after + // everything regularly due. Within each group: priority, then the oldest occurrence. // Occurrence keys are ISO-8601 UTC ("O"), so ordinal order is time order. due.Sort((a, b) => { + if (a.Late != b.Late) + return a.Late ? 1 : -1; var byPriority = b.Task.Priority.CompareTo(a.Task.Priority); return byPriority != 0 ? byPriority : string.CompareOrdinal(a.Occurrence, b.Occurrence); }); var launched = new List(); var waiting = 0; - foreach (var (task, occ) in due) + foreach (var (task, occ, _) in due) { // OpenCount also counts runs started by hand (RunTaskNowAsync) — they take a // slot, even though they did not wait for one. @@ -413,6 +444,12 @@ public sealed class TaskScanner : IAsyncDisposable var now = _clock.GetUtcNow().UtcDateTime; var ok = lauf == TaskLauf.Erledigt; + if (lauf == TaskLauf.KeinBudget) + { + await HandleBudgetMissAsync(task, token, now, ct); + return; + } + // #643: Fand der Lauf einer einmaligen Aufgabe gar nicht statt, gilt ihr Termin als // nicht verbraucht. Vorher blieb sie mit gesetztem Marker für immer auf todo liegen. if (lauf == TaskLauf.NichtGelaufen && !task.IsRecurring) @@ -443,10 +480,108 @@ public sealed class TaskScanner : IAsyncDisposable await _repo.CompleteClaimAsync(task.Id, token, final, now, ct); + // #716: A run took place — earlier budget misses are settled. + if (lauf != TaskLauf.NichtGelaufen + && (task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null)) + await _repo.ClearBudgetMissAsync(task.Id, now, ct); + if (ok && final == TaskItemStatus.Done) await UnblockDependentsAsync(task.Id, now, ct); } + /// + /// #716: The budget refused the run. A one-time task is carried over to the next day + /// with its occurrence intact; a recurring one follows the agent's policy — the + /// occurrence lapses, and under one + /// catch-up run is scheduled for the next day. + /// + private async Task HandleBudgetMissAsync(TaskItem task, string token, DateTime now, CancellationToken ct) + { + var nextDay = StartOfNextLocalDay(now); + var agent = TaskAssignee.AgentId(task.Assignee); + var carried = false; + var catchUp = false; + + if (!task.IsRecurring) + { + carried = await _repo.CarryOverAsync(task.Id, token, task.LastOccurrence, nextDay, now, ct); + } + else + { + catchUp = _missPolicy(task) == BudgetMissPolicy.CatchUpOnce; + await _repo.RecordBudgetMissAsync(task.Id, token, catchUp ? nextDay : null, now, ct); + } + + lock (_missesToday) + { + var entry = _missesToday.GetValueOrDefault(agent); + _missesToday[agent] = (entry.Misses + 1, + entry.CarriedOver + (carried ? 1 : 0), + entry.CatchUps + (catchUp && !task.CatchUpPending ? 1 : 0)); + } + + _logger.LogInformation( + "Task {TaskId} refused for lack of budget — {Outcome}", + task.Id, + carried ? $"carried over, due again from {nextDay:yyyy-MM-dd HH:mm} UTC" + : catchUp ? "occurrence lapsed, one catch-up run scheduled for the next day" + : "occurrence lapsed (policy: expire)"); + } + + /// + /// #716: On the first tick of a new local day, one line per agent about what the + /// budget refused the day before. The details are on the tasks themselves. + /// + private void SummarizeMissesIfNewDay(DateTime nowUtc) + { + var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone)); + + List> summary; + DateOnly? day; + lock (_missesToday) + { + if (_missDay is null) + { + _missDay = today; + return; + } + if (_missDay == today) + return; + + day = _missDay; + summary = [.. _missesToday]; + _missesToday.Clear(); + _missDay = today; + } + + foreach (var (agent, counts) in summary) + { + _logger.LogWarning( + "Budget summary {Day:yyyy-MM-dd} for agent {AgentId}: {Misses} run(s) refused for lack of " + + "budget, {Carried} task(s) carried over, {CatchUps} catch-up run(s) scheduled", + day, agent.Length == 0 ? "-" : agent, counts.Misses, counts.CarriedOver, counts.CatchUps); + } + } + + /// + /// Local midnight after , in UTC. The budget day follows the + /// machine's local date, like . + /// + private DateTime StartOfNextLocalDay(DateTime nowUtc) + { + var zone = _clock.LocalTimeZone; + var local = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, zone); + var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified); + + // A midnight skipped by a DST change does not exist; take the next valid minute. + while (zone.IsInvalidTime(midnight)) + midnight = midnight.AddMinutes(1); + + return TimeZoneInfo.ConvertTimeToUtc(midnight, zone); + } + + private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O"); + /// /// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben /// freigegeben, sobald alle ihre Blocker erledigt sind. diff --git a/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs index e513bb8..ebbde54 100644 --- a/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs +++ b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs @@ -1,3 +1,4 @@ +using ClawdDotNet.Core.Tasks; using System.Collections.ObjectModel; using Avalonia.Threading; using ClawdDotNet.App; @@ -77,6 +78,13 @@ public sealed partial class AgentItemViewModel : ViewModelBase [ObservableProperty] private long _dailyTokens; + /// #716: What happens to a recurring task's run when the budget is exhausted. + [ObservableProperty] + private string _whenExhausted = "expire"; + + /// The values understands. + public IReadOnlyList WhenExhaustedOptions { get; } = ["expire", "catchUpOnce"]; + public ObservableCollection AvailableTools { get; } = []; /// @@ -101,6 +109,7 @@ public sealed partial class AgentItemViewModel : ViewModelBase _timeoutSeconds = config.LoopGuard.TimeoutSeconds; _dailyCostUsd = config.Budget.DailyCostUsd; _dailyTokens = config.Budget.DailyTokens; + _whenExhausted = config.Budget.MissPolicy == BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire"; foreach (var tool in KnownTools) { @@ -137,6 +146,7 @@ public sealed partial class AgentItemViewModel : ViewModelBase Config.LoopGuard.TimeoutSeconds = TimeoutSeconds; Config.Budget.DailyCostUsd = DailyCostUsd; Config.Budget.DailyTokens = DailyTokens; + Config.Budget.WhenExhausted = WhenExhausted; } } diff --git a/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml index 019da6e..2dd8cca 100644 --- a/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml +++ b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml @@ -208,6 +208,15 @@ + + + + + + diff --git a/src/ClawdDotNet.Desktop/i18n/de.json b/src/ClawdDotNet.Desktop/i18n/de.json index 10e6a7c..ca9f13f 100644 --- a/src/ClawdDotNet.Desktop/i18n/de.json +++ b/src/ClawdDotNet.Desktop/i18n/de.json @@ -64,6 +64,8 @@ "desktop.agents.maxToolResultChars": "Max. Tool-Ergebnis (Zeichen)", "desktop.agents.dailyCost": "Tagesbudget ($ USD)", "desktop.agents.dailyTokens": "Tagesbudget (Tokens)", + "desktop.agents.whenExhausted": "Wenn das Budget erschöpft ist (wiederkehrende Aufgaben)", + "desktop.agents.whenExhaustedHint": "expire: Der verpasste Lauf verfällt und wird an der Aufgabe vermerkt. catchUpOnce: Alle verpassten Läufe werden am nächsten Tag durch einen Lauf nachgeholt, nach der regulären Arbeit des Tages. Einmalige Aufgaben werden immer auf den nächsten Tag mitgenommen.", "desktop.agents.allowedToolsFor": "ZUGELASSENE WERKZEUGE FÜR {0}", "desktop.agents.thTool": "WERKZEUG", "desktop.agents.thDescription": "BESCHREIBUNG", diff --git a/src/ClawdDotNet.Desktop/i18n/en.json b/src/ClawdDotNet.Desktop/i18n/en.json index a24da00..6590e4f 100644 --- a/src/ClawdDotNet.Desktop/i18n/en.json +++ b/src/ClawdDotNet.Desktop/i18n/en.json @@ -64,6 +64,8 @@ "desktop.agents.maxToolResultChars": "Max Tool Result (chars)", "desktop.agents.dailyCost": "Daily Budget ($ USD)", "desktop.agents.dailyTokens": "Daily Budget (Tokens)", + "desktop.agents.whenExhausted": "When the budget is exhausted (recurring tasks)", + "desktop.agents.whenExhaustedHint": "expire: the missed run lapses and is recorded on the task. catchUpOnce: all missed runs are caught up by one run the next day, after the day's regular work. One-time tasks are always carried over to the next day.", "desktop.agents.allowedToolsFor": "ALLOWED TOOLS FOR {0}", "desktop.agents.thTool": "TOOL", "desktop.agents.thDescription": "DESCRIPTION", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 681e8dc..1755eb9 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -192,6 +192,12 @@ "tasks.saved": "Aufgabe gespeichert.", "tasks.deleted": "Aufgabe gelöscht.", "tasks.runTriggered": "Lauf ausgelöst.", + "tasks.nextRun": "Nächster", + "tasks.lastRun": "Letzter", + "tasks.budgetMissed": "Budget erschöpft: {n} Lauf/Läufe abgelehnt", + "tasks.budgetMissedLast": "zuletzt {time}", + "tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}", + "tasks.budgetCatchUp": "ein Nachholauf ab {time}", "services.title": "Dienste", "services.newService": "Neuer Dienst", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index e5044c2..220b99a 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -192,6 +192,12 @@ "tasks.saved": "Task saved.", "tasks.deleted": "Task deleted.", "tasks.runTriggered": "Run triggered.", + "tasks.nextRun": "Next", + "tasks.lastRun": "Last", + "tasks.budgetMissed": "Budget exhausted: {n} run(s) refused", + "tasks.budgetMissedLast": "last at {time}", + "tasks.budgetCarriedOver": "carried over, due again from {time}", + "tasks.budgetCatchUp": "one catch-up run from {time}", "services.title": "Services", "services.newService": "New Service", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js index 17df6df..2d44bb0 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js @@ -163,11 +163,11 @@ function erstelleAufgabenKarte(task) { zeilen.push( erstelleElement("p", { klasse: "gedaempft text-klein", - text: `Next: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}` + text: `${t("tasks.nextRun")}: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}` }) ); - const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, "Last: "); + const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, `${t("tasks.lastRun")}: `); if (task.lastRun) { letzter.append( `${formatiereDatumZeit(task.lastRun.occurredAt)} `, @@ -184,6 +184,19 @@ function erstelleAufgabenKarte(task) { zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` })); } + // #716: What the budget refused, and what happens next. + const ausfall = task.budgetMiss; + if (ausfall) { + const teile = [t("tasks.budgetMissed", { n: ausfall.count })]; + if (ausfall.lastAt) teile.push(t("tasks.budgetMissedLast", { time: formatiereDatumZeit(ausfall.lastAt) })); + if (ausfall.carriedOver && ausfall.deferredUntil) { + teile.push(t("tasks.budgetCarriedOver", { time: formatiereDatumZeit(ausfall.deferredUntil) })); + } else if (ausfall.catchUpPending) { + teile.push(t("tasks.budgetCatchUp", { time: ausfall.deferredUntil ? formatiereDatumZeit(ausfall.deferredUntil) : "—" })); + } + zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${teile.join(" · ")}` })); + } + const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "margin-top: 8px; justify-content: flex-end;" diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 42a42fc..477a21a 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -116,7 +116,14 @@ public sealed record TaskDto( TaskWhenDto? When = null, DateTime? NextRunUtc = null, TaskLastRunDto? LastRun = null, - string? Hinweis = null); + string? Hinweis = null, + TaskBudgetMissDto? BudgetMiss = null); + +/// +/// #716: Runs of this task the budget refused — only present while something is open +/// (misses since the last run that took place, a pending catch-up or a deferral). +/// +public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver); /// Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten. public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef); diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index 5244ba1..5d837ae 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -129,6 +129,10 @@ internal static class Mapping task.UpdatedAt, task.When is { } when ? new TaskWhenDto(when.Kind.ToString().ToLowerInvariant(), when.Value, when.TimeZone) + : null, + BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null + ? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil, + task.CatchUpPending, task.IsCarriedOver) : null); /// diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs new file mode 100644 index 0000000..c3cb850 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs @@ -0,0 +1,257 @@ +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// #716: What the scanner does with a run the budget refused. +/// +/// Before, a one-time task was retried every 30 minutes for the rest of the day — each +/// attempt another refusal and another receipt — and then ran after midnight at the +/// expense of the new day, without anyone having decided that. A recurring task's +/// occurrence lapsed silently. +/// +public sealed class BudgetMissTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + + // 2027: a cron schedule never looks back before the task's creation date. + private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero)); + + public BudgetMissTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleanup is secondary */ } + } + + private TaskScanner Scanner(ITaskDispatcher dispatcher, BudgetMissPolicy policy = BudgetMissPolicy.Expire) + => new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, budgetMissPolicy: _ => policy); + + private Task SeedOneTime(string id, int priority = 3) + => _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", Priority = priority }, default); + + private Task SeedHourly(string id) + => _repo.UpsertAsync(new TaskItem + { + Id = id, Title = id, Assignee = "@worker", + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" } + }, default); + + private async Task Get(string id) => (await _repo.GetAsync(id, default))!; + + /// The next local midnight, in UTC — the budget day follows the local date. + private DateTime NextLocalMidnightUtc() + { + var zone = _clock.LocalTimeZone; + var local = TimeZoneInfo.ConvertTimeFromUtc(_clock.GetUtcNow().UtcDateTime, zone); + return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified), zone); + } + + private void MoveTo(DateTime utc) => _clock.SetUtcNow(new DateTimeOffset(utc, TimeSpan.Zero)); + + // ═══════════════════ one-time tasks: carried over ═══════════════════ + + [Fact] + public async Task A_refused_one_time_task_is_carried_over_to_the_next_day() + { + await SeedOneTime("t-report"); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher); + var midnight = NextLocalMidnightUtc(); + + await scanner.ScanOnceAsync(default); + + var task = await Get("t-report"); + task.Status.ShouldBe(TaskItemStatus.Todo, "it is still work to be done"); + task.BudgetMissCount.ShouldBe(1); + task.LastBudgetMissAt.ShouldNotBeNull(); + task.DeferredUntil.ShouldBe(midnight); + task.IsCarriedOver.ShouldBeTrue(); + } + + [Fact] + public async Task A_carried_over_task_is_not_retried_the_same_day() + { + await SeedOneTime("t-report"); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher); + await scanner.ScanOnceAsync(default); + + // Before #716: again after 30 minutes, and again, until midnight. + MoveTo(NextLocalMidnightUtc().AddMinutes(-1)); + (await scanner.ScanOnceAsync(default)).ShouldBe(0); + + dispatcher.Calls.ShouldBe(1, "one refusal, not one every half hour"); + } + + [Fact] + public async Task On_the_next_day_it_runs_and_the_miss_is_settled() + { + await SeedOneTime("t-report"); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher); + await scanner.ScanOnceAsync(default); + + MoveTo(NextLocalMidnightUtc().AddMinutes(1)); + dispatcher.HasBudget = true; + (await scanner.ScanOnceAsync(default)).ShouldBe(1); + + var task = await Get("t-report"); + task.Status.ShouldBe(TaskItemStatus.Done); + task.BudgetMissCount.ShouldBe(0); + task.DeferredUntil.ShouldBeNull(); + task.LastBudgetMissAt.ShouldNotBeNull("kept as history"); + } + + [Fact] + public async Task The_new_day_has_right_of_way_over_carried_over_work() + { + await SeedOneTime("t-yesterday", priority: 5); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher); + await scanner.ScanOnceAsync(default); + + MoveTo(NextLocalMidnightUtc().AddMinutes(1)); + await SeedOneTime("t-today", priority: 1); + dispatcher.HasBudget = true; + dispatcher.Started.Clear(); + await scanner.ScanOnceAsync(default); + + dispatcher.Started.ShouldBe(["t-today", "t-yesterday"], + "carried-over work comes after the day's own, even with a higher priority"); + } + + // ═══════════════════ recurring tasks: the agent's policy ═══════════════════ + + [Fact] + public async Task Under_expire_a_refused_occurrence_lapses_and_is_recorded() + { + await SeedHourly("t-hourly"); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher, BudgetMissPolicy.Expire); + + await scanner.ScanOnceAsync(default); + + var task = await Get("t-hourly"); + task.Status.ShouldBe(TaskItemStatus.Todo); + task.BudgetMissCount.ShouldBe(1); + task.CatchUpPending.ShouldBeFalse(); + + // Nothing is caught up the next day beyond the regular occurrences. + MoveTo(NextLocalMidnightUtc().AddMinutes(30)); + dispatcher.HasBudget = true; + dispatcher.Started.Clear(); + (await scanner.ScanOnceAsync(default)).ShouldBe(1, "only the regular occurrence of this hour"); + } + + [Fact] + public async Task Under_catch_up_once_several_misses_leave_one_pending_catch_up() + { + await SeedHourly("t-hourly"); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce); + var midnight = NextLocalMidnightUtc(); + + // Three hours in a row refused. + for (var hour = 0; hour < 3; hour++) + { + await scanner.ScanOnceAsync(default); + _clock.Advance(TimeSpan.FromHours(1)); + } + + var task = await Get("t-hourly"); + task.BudgetMissCount.ShouldBe(3); + task.CatchUpPending.ShouldBeTrue(); + task.DeferredUntil.ShouldBe(midnight, "set by the first miss, not pushed on by the later ones"); + } + + [Fact] + public async Task The_catch_up_runs_exactly_once_on_the_next_day() + { + // A daily report at 22:30: its next regular occurrence is 24 hours away, so here + // the catch-up is the only run — the case catchUpOnce exists for. + MoveTo(new DateTime(2027, 3, 10, 22, 30, 0, DateTimeKind.Utc)); + await _repo.UpsertAsync(new TaskItem + { + Id = "t-daily", Title = "t-daily", Assignee = "@worker", + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "30 22 * * *", TimeZone = "UTC" } + }, default); + + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce); + (await scanner.ScanOnceAsync(default)).ShouldBe(1, "the regular 22:30 run, refused"); + + MoveTo(NextLocalMidnightUtc().AddMinutes(1)); + dispatcher.HasBudget = true; + dispatcher.Started.Clear(); + + (await scanner.ScanOnceAsync(default)).ShouldBe(1, "the catch-up"); + _clock.Advance(TimeSpan.FromMinutes(1)); + (await scanner.ScanOnceAsync(default)).ShouldBe(0, "once, not on every tick"); + + dispatcher.Started.ShouldBe(["t-daily"]); + var task = await Get("t-daily"); + task.CatchUpPending.ShouldBeFalse(); + task.BudgetMissCount.ShouldBe(0); + task.Status.ShouldBe(TaskItemStatus.Todo, "still recurring"); + } + + [Fact] + public async Task A_pending_catch_up_does_not_run_on_the_same_day() + { + await SeedHourly("t-hourly"); + var dispatcher = new BudgetDispatcher { HasBudget = false }; + var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce); + await scanner.ScanOnceAsync(default); + + // Budget raised by the human in the evening: the regular occurrence runs, and it + // covers the pending catch-up — one run, not two. + dispatcher.HasBudget = true; + dispatcher.Started.Clear(); + _clock.Advance(TimeSpan.FromHours(1)); + (await scanner.ScanOnceAsync(default)).ShouldBe(1); + (await Get("t-hourly")).CatchUpPending.ShouldBeFalse(); + } + + [Fact] + public async Task A_shutdown_is_not_a_budget_miss() + { + await SeedOneTime("t-report"); + var dispatcher = new BudgetDispatcher { Result = TaskLauf.NichtGelaufen }; + await Scanner(dispatcher).ScanOnceAsync(default); + + var task = await Get("t-report"); + task.BudgetMissCount.ShouldBe(0); + task.DeferredUntil.ShouldBeNull("a shutdown keeps the old 30-minute retry"); + } + + // ═══════════════════ test double ═══════════════════ + + private sealed class BudgetDispatcher : ITaskDispatcher + { + public bool HasBudget { get; set; } = true; + public TaskLauf? Result { get; set; } + public int Calls { get; private set; } + public List Started { get; } = []; + + public Task DispatchAsync(TaskItem task, CancellationToken ct) + { + Calls++; + Started.Add(task.Id); + return Task.FromResult(Result ?? (HasBudget ? TaskLauf.Erledigt : TaskLauf.KeinBudget)); + } + } +}