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