Scanner: Laeufe deckeln, stille Kappung bei 500 beseitigen (#682)
Drei Fehler im Taktgeber des Taskboards, zwei gemeldet, einer beim Bau gefunden: 1. Unbegrenzte Parallelitaet. Jede faellige Aufgabe startete sofort. Bei Cron-Terminen auf runden Zeiten hiess das: alle Agenten gleichzeitig, und alle Laeufe passierten die Budgetpruefung gemeinsam, weil sie je Lauf vor dem Start laeuft. Neu: maxParallelTaskRuns je Instanz (Vorgabe 4). Der Rest wartet, sortiert nach Prioritaet und dann nach Faelligkeit. 2. Stille Kappung. Die Kandidaten kamen ueber ListAsync mit Limit 500; darueber fielen Termine lautlos aus dem Takt. Neu: ListRunnableAsync liefert alle Aufgaben auf todo, ungekappt. Muessen Aufgaben warten, steht das im Log, allerdings nur, wenn sich die Zahl aendert. 3. Der Takt wartete auf jeden Lauf, den er startete. Ein zehnminuetiger Lauf hielt damit jeden anderen Termin zehn Minuten an, und mit einem Deckel haetten die freien Plaetze leer gestanden. Neu: Die Schleife startet und wartet nicht; DisposeAsync wartet stattdessen auf die laufenden Laeufe. Beansprucht wird nur, was sofort starten kann. Alles andere bleibt auf todo, sonst liefe beim Warten der Lease ab und die Aufgabe wuerde ein zweites Mal beansprucht. Ein Cron-Termin geht dadurch nicht verloren, weil DueOccurrence bis zum letzten Lauf zuruecksucht. Ein von Hand gestarteter Lauf wartet auf keinen Platz, belegt aber einen. Bleibt offen: Bis zu maxParallelTaskRuns Laeufe koennen die Budgetpruefung weiterhin gemeinsam passieren. Die Luecke ist damit begrenzt, aber nicht geschlossen; dafuer braeuchte es eine Reservierung vor dem Lauf. Tests: ScannerCapacityTests (9 neu). 1.229 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3ba9ebc565
commit
8df5d004e4
@@ -84,6 +84,20 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
/// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary>
|
||||
public static readonly TimeSpan Wiedervorlage = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>#682: Default for <see cref="Config.InstanceConfig.MaxParallelTaskRuns"/>.</summary>
|
||||
public const int DefaultMaxParallelRuns = 4;
|
||||
|
||||
private readonly int _maxParallelRuns;
|
||||
|
||||
/// <summary>
|
||||
/// Runs started by the tick loop that have not finished yet. The loop no longer waits
|
||||
/// for them (#682), so disposal has to.
|
||||
/// </summary>
|
||||
private readonly HashSet<Task> _inFlight = [];
|
||||
|
||||
/// <summary>How many due tasks had to wait at the last tick — logged only on change.</summary>
|
||||
private int _lastWaiting;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
@@ -94,7 +108,8 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
IMarketCalendar? market = null,
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null,
|
||||
TimeSpan? lease = null)
|
||||
TimeSpan? lease = null,
|
||||
int maxParallelRuns = DefaultMaxParallelRuns)
|
||||
{
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
@@ -103,8 +118,12 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner");
|
||||
_tick = tick ?? TimeSpan.FromSeconds(60);
|
||||
_lease = lease ?? TimeSpan.FromMinutes(15);
|
||||
_maxParallelRuns = Math.Max(1, maxParallelRuns);
|
||||
}
|
||||
|
||||
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
|
||||
public int MaxParallelRuns => _maxParallelRuns;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_loop ??= RunLoopAsync(_cts.Token);
|
||||
@@ -158,7 +177,10 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
try
|
||||
{
|
||||
await ScanOnceAsync(ct);
|
||||
// #682: Start, don't wait. Before, the tick awaited every run it started,
|
||||
// so one ten-minute run held back every other schedule for ten minutes.
|
||||
// Now a finished run frees its slot and the next tick fills it.
|
||||
await LaunchDueAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -169,20 +191,42 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der
|
||||
/// tatsächlich angestoßenen Läufe zurück. Öffentlich, damit Tests einen Takt
|
||||
/// deterministisch auslösen können.
|
||||
/// tatsächlich angestoßenen Läufe zurück und kehrt erst zurück, wenn sie fertig sind.
|
||||
/// Öffentlich, damit Tests einen Takt deterministisch auslösen können — die
|
||||
/// Taktschleife selbst wartet nicht auf die Läufe (#682).
|
||||
/// </summary>
|
||||
public async Task<int> ScanOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var launched = await LaunchDueAsync(ct);
|
||||
await Task.WhenAll(launched);
|
||||
return launched.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims due tasks while a slot is free and starts them without waiting.
|
||||
///
|
||||
/// <para><b>Claim only what can start now (#682).</b> A claim sets the task to
|
||||
/// <c>running</c> with a lease. Claiming everything and queueing it in memory would
|
||||
/// let a waiting task outlive its lease and be claimed a second time. So tasks beyond
|
||||
/// the free slots stay <c>todo</c> and are picked up by a later tick — a cron occurrence
|
||||
/// is not lost by that, <see cref="TaskSchedule.DueOccurrence"/> searches back to the
|
||||
/// last run.</para>
|
||||
///
|
||||
/// <para><b>Order:</b> priority first, then the oldest due occurrence — when slots are
|
||||
/// short, what matters and what has waited longest goes first.</para>
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<Task>> LaunchDueAsync(CancellationToken ct)
|
||||
{
|
||||
if (!IsAcceptingWork)
|
||||
return 0;
|
||||
return [];
|
||||
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var leaseCutoff = now - _lease;
|
||||
|
||||
var candidates = await _repo.ListAsync(new TaskQuery { Limit = 500 }, ct);
|
||||
// #682: ungekappt. Vorher ListAsync mit Limit 500 — darüber fielen Termine lautlos aus.
|
||||
var candidates = await _repo.ListRunnableAsync(ct);
|
||||
|
||||
var claimed = new List<(TaskItem Task, string Token)>();
|
||||
var due = new List<(TaskItem Task, string Occurrence)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
if (task.Status != TaskItemStatus.Todo)
|
||||
@@ -232,16 +276,81 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
|
||||
continue;
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
|
||||
claimed.Add((task, token));
|
||||
due.Add((task, occ));
|
||||
}
|
||||
|
||||
// Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das
|
||||
// Agent-Gate der Engine; verschiedene Agenten laufen echt parallel.
|
||||
await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct)));
|
||||
// Occurrence keys are ISO-8601 UTC ("O"), so ordinal order is time order.
|
||||
due.Sort((a, b) =>
|
||||
{
|
||||
var byPriority = b.Task.Priority.CompareTo(a.Task.Priority);
|
||||
return byPriority != 0 ? byPriority : string.CompareOrdinal(a.Occurrence, b.Occurrence);
|
||||
});
|
||||
|
||||
return claimed.Count;
|
||||
var launched = new List<Task>();
|
||||
var waiting = 0;
|
||||
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.
|
||||
if (OpenCount >= _maxParallelRuns)
|
||||
{
|
||||
waiting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
|
||||
launched.Add(Track(RunLoggedAsync(task, token, ct)));
|
||||
}
|
||||
|
||||
// A cap that hides its effect is worse than none (the lesson of #673): say when
|
||||
// due work had to wait, but only when that changes — the tick runs every minute.
|
||||
if (waiting != _lastWaiting)
|
||||
{
|
||||
if (waiting > 0)
|
||||
_logger.LogInformation(
|
||||
"{Waiting} due task(s) waiting for a free slot — {Max} run(s) at a time (maxParallelTaskRuns)",
|
||||
waiting, _maxParallelRuns);
|
||||
else
|
||||
_logger.LogInformation("No due tasks waiting for a slot anymore");
|
||||
_lastWaiting = waiting;
|
||||
}
|
||||
|
||||
return launched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a claimed task and logs what escapes it. The tick loop does not await its runs,
|
||||
/// so an exception here would otherwise go unobserved.
|
||||
/// </summary>
|
||||
private async Task RunLoggedAsync(TaskItem task, string token, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync(task, token, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Shutdown. The claim is released by the lease or the next start's reconciliation.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Run of task {TaskId} failed outside the dispatch", task.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Track(Task run)
|
||||
{
|
||||
lock (_inFlight)
|
||||
_inFlight.Add(run);
|
||||
|
||||
_ = run.ContinueWith(t =>
|
||||
{
|
||||
lock (_inFlight)
|
||||
_inFlight.Remove(t);
|
||||
}, TaskScheduler.Default);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -378,6 +487,14 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
// #682: The loop no longer waits for its runs, so wait for them here — otherwise
|
||||
// a run could still write to the repository after the scanner is gone.
|
||||
Task[] inFlight;
|
||||
lock (_inFlight)
|
||||
inFlight = [.. _inFlight];
|
||||
await Task.WhenAll(inFlight);
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user