Budgetende, Stufe 1: Ausfall vermerken, Regel je Agent, mitnehmen statt anrennen (#716)
Bisher teilte sich eine Budgetablehnung den Ausgang NichtGelaufen mit dem Herunterfahren. Einmalige Aufgaben wurden den Rest des Tages alle 30 Minuten erneut abgelehnt (jedes Mal ein Beleg) und liefen nach Mitternacht still zu Lasten des neuen Tages. Wiederkehrende verfielen still. - TaskLauf.KeinBudget als eigener Ausgang; der Dispatcher bildet BudgetExceeded darauf ab. - Vier Felder an der Aufgabe: BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending (Spalten werden fuer bestehende Datenbanken nachgeruestet). - Einmalige Aufgaben werden mitgenommen: Der Termin bleibt erhalten, faellig ist die Aufgabe wieder ab der naechsten lokalen Mitternacht, nicht vorher. - Wiederkehrende Aufgaben folgen der Regel des Agenten (budget.whenExhausted): expire vermerkt den Ausfall und laesst den Termin verfallen; catchUpOnce fasst alle verpassten Termine zu einem Nachholauf am naechsten Tag zusammen. Ein regulaer faelliger Termin deckt das Nachholen mit ab, es bleibt bei einem Lauf. - Der neue Tag hat Vorfahrt: Mitgenommenes und Nachholaeufe kommen nach allem regulaer Faelligen, unabhaengig von der Prioritaet. - Beim ersten Takt eines neuen Tages eine Zusammenfassung je Agent im Log. - Sichtbar: Die Aufgabenkarte der WebUI zeigt Ausfaelle, Mitnahme und Nachholen; die Regel ist im Desktop je Agent einstellbar. Nebenbei sind "Next:"/"Last:" auf der Karte ins Sprachpaket gewandert. Stufen 2 bis 4 (Reservierung, Spielraum statt pauschalem Anteil, Vorbeugen) folgen. Tests: BudgetMissTests (9 neu). 1.238 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b53396a754
commit
5f3a85595b
@@ -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 () =>
|
||||
|
||||
@@ -183,6 +183,21 @@ public sealed class AgentBudget
|
||||
[JsonPropertyName("dailyTokens")]
|
||||
public long DailyTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #716: What happens to a run of a recurring task when the budget is exhausted —
|
||||
/// <c>expire</c> (default) or <c>catchUpOnce</c>. 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.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,28 @@ public interface ITaskRepository
|
||||
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
|
||||
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #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 <paramref name="deferredUntil"/>.
|
||||
/// </summary>
|
||||
Task<bool> CarryOverAsync(
|
||||
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
|
||||
DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: The budget refused a run of a recurring task. Releases the claim back to
|
||||
/// <c>todo</c>; the occurrence stays consumed. With <paramref name="catchUpUntil"/> a
|
||||
/// single catch-up run is scheduled for then — an already pending one is kept.
|
||||
/// </summary>
|
||||
Task<bool> RecordBudgetMissAsync(
|
||||
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: A run took place — clears the miss counter, a pending catch-up and the
|
||||
/// deferral. <see cref="TaskItem.LastBudgetMissAt"/> stays as history.
|
||||
/// </summary>
|
||||
Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
|
||||
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
|
||||
|
||||
@@ -193,6 +193,68 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> 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<bool> 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<bool> 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<int> 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,32 @@ public sealed record TaskItem
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
// ─── Budget misses (#716) ───
|
||||
|
||||
/// <summary>
|
||||
/// How many times in a row a run of this task was refused because the budget was
|
||||
/// exhausted. Reset by the next run that actually takes place.
|
||||
/// </summary>
|
||||
public int BudgetMissCount { get; init; }
|
||||
|
||||
/// <summary>When the last budget refusal happened (UTC). Kept after a reset, as history.</summary>
|
||||
public DateTime? LastBudgetMissAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Not before this point (UTC). Set after a budget refusal to the start of the next
|
||||
/// local day: retrying within the same day only produces further refusals.
|
||||
/// </summary>
|
||||
public DateTime? DeferredUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A recurring task under <see cref="BudgetMissPolicy.CatchUpOnce"/> missed at least
|
||||
/// one occurrence; all of them are caught up by a single run.
|
||||
/// </summary>
|
||||
public bool CatchUpPending { get; init; }
|
||||
|
||||
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
|
||||
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf <c>todo</c> zurück statt auf
|
||||
/// <c>done</c> — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
|
||||
@@ -215,6 +241,19 @@ public sealed record TaskItem
|
||||
|| When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: What happens to a run of a recurring task that the budget refused. One-time
|
||||
/// tasks are always carried over — they are real work that must not disappear.
|
||||
/// </summary>
|
||||
public enum BudgetMissPolicy
|
||||
{
|
||||
/// <summary>The occurrence lapses and is recorded; the next regular one comes anyway.</summary>
|
||||
Expire,
|
||||
|
||||
/// <summary>All missed occurrences are caught up by one run on the next day.</summary>
|
||||
CatchUpOnce
|
||||
}
|
||||
|
||||
/// <summary>Suchkriterien für <see cref="ITaskRepository.ListAsync"/>.</summary>
|
||||
public sealed record TaskQuery
|
||||
{
|
||||
|
||||
@@ -12,10 +12,17 @@ public enum TaskLauf
|
||||
Gescheitert,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
NichtGelaufen
|
||||
NichtGelaufen,
|
||||
|
||||
/// <summary>
|
||||
/// #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 <see cref="BudgetMissPolicy"/>.
|
||||
/// </summary>
|
||||
KeinBudget
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -98,6 +105,11 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
/// <summary>How many due tasks had to wait at the last tick — logged only on change.</summary>
|
||||
private int _lastWaiting;
|
||||
|
||||
/// <summary>#716: What the budget refused, per agent, for the day summary.</summary>
|
||||
private readonly Func<TaskItem, BudgetMissPolicy> _missPolicy;
|
||||
private readonly Dictionary<string, (int Misses, int CarriedOver, int CatchUps)> _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<TaskItem, BudgetMissPolicy>? 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);
|
||||
}
|
||||
|
||||
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
|
||||
@@ -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<Task>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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 <see cref="BudgetMissPolicy.CatchUpOnce"/> one
|
||||
/// catch-up run is scheduled for the next day.
|
||||
/// </summary>
|
||||
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)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
private void SummarizeMissesIfNewDay(DateTime nowUtc)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone));
|
||||
|
||||
List<KeyValuePair<string, (int Misses, int CarriedOver, int CatchUps)>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local midnight after <paramref name="nowUtc"/>, in UTC. The budget day follows the
|
||||
/// machine's local date, like <see cref="Budget.BudgetGuard"/>.
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
/// <summary>
|
||||
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
|
||||
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>#716: What happens to a recurring task's run when the budget is exhausted.</summary>
|
||||
[ObservableProperty]
|
||||
private string _whenExhausted = "expire";
|
||||
|
||||
/// <summary>The values <see cref="AgentBudget.WhenExhausted"/> understands.</summary>
|
||||
public IReadOnlyList<string> WhenExhaustedOptions { get; } = ["expire", "catchUpOnce"];
|
||||
|
||||
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,15 @@
|
||||
<TextBox Text="{Binding DailyTokens}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="{Binding L[desktop.agents.whenExhausted]}" />
|
||||
<ComboBox HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding WhenExhaustedOptions}"
|
||||
SelectedItem="{Binding WhenExhausted}" />
|
||||
<TextBlock Classes="caption" TextWrapping="Wrap"
|
||||
Text="{Binding L[desktop.agents.whenExhaustedHint]}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// #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).
|
||||
/// </summary>
|
||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
||||
|
||||
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
|
||||
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<TaskItem> SeedOneTime(string id, int priority = 3)
|
||||
=> _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", Priority = priority }, default);
|
||||
|
||||
private Task<TaskItem> 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<TaskItem> Get(string id) => (await _repo.GetAsync(id, default))!;
|
||||
|
||||
/// <summary>The next local midnight, in UTC — the budget day follows the local date.</summary>
|
||||
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<string> Started { get; } = [];
|
||||
|
||||
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
Started.Add(task.Id);
|
||||
return Task.FromResult(Result ?? (HasBudget ? TaskLauf.Erledigt : TaskLauf.KeinBudget));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user