Fuellarbeit: zeitlich flexible Arbeit fuellt ungenutztes Budget (#717, Stufe 1)

Eine einmalige Aufgabe mit fill_by (spaetestens bis) statt Termin laeuft,
wenn der Tag Budget uebrig laesst: nach regulaerer und nachgeholter Arbeit,
nur wenn nichts vom Agenten wartet, bevorzugt ab 18 Uhr, und nur wenn ihre
eigene Schaetzung in den Spielraum aus #716 passt. Kurz vor der Frist wird
einmal ein Mensch gefragt; nach der Frist laeuft sie als regulaere Arbeit.

Ehrliche Schaetzung (TaskCostEstimator): eigene Laeufe, dann Geschwister
unter derselben Elternaufgabe, dann die Angabe des Autors (als Schaetzung
gekennzeichnet), zuletzt das Mittel des Agenten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 15:34:15 +02:00
co-authored by Claude Opus 5.5
parent 25f74d9ad6
commit 183e922278
21 changed files with 1185 additions and 36 deletions
+138 -10
View File
@@ -53,6 +53,29 @@ public interface ILateRunGate
Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>#717: Whether fill work may start now.</summary>
public enum FillVerdict
{
/// <summary>There is room — it starts.</summary>
Run,
/// <summary>Too early in the day; the forecast is still a guess.</summary>
NotYet,
/// <summary>The rest of the day needs the budget.</summary>
NoRoom
}
/// <summary>
/// #717: Decides whether fill work — one-time work with a deadline instead of an appointment —
/// may use the budget the day would otherwise leave unused. Without one, fill work runs like
/// late work: after everything else of the tick.
/// </summary>
public interface IFillGate
{
Task<FillVerdict> CheckAsync(TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>
/// #716: The day ahead — where the budget will not reach the end of today's schedule.
/// </summary>
@@ -139,6 +162,20 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly IBudgetOutlook? _outlook;
private readonly ITaskTreeWatch? _treeWatch;
/// <summary>#717: Whether fill work may use today's unused budget.</summary>
private readonly IFillGate? _fillGate;
/// <summary>#717: Asks a human about fill work that found no room before its deadline.</summary>
private readonly Func<TaskItem, CancellationToken, Task>? _askAboutFill;
private readonly Func<TimeSpan> _fillWarnBefore;
/// <summary>#717: Fill work already reported as past its deadline — once per task.</summary>
private readonly HashSet<string> _warnedOverdueFill = [];
/// <summary>How many fill tasks waited for room at the last tick.</summary>
private int _lastWaitingFill;
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
@@ -165,9 +202,15 @@ public sealed class TaskScanner : IAsyncDisposable
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null,
ITaskTreeWatch? treeWatch = null)
ITaskTreeWatch? treeWatch = null,
IFillGate? fillGate = null,
Func<TaskItem, CancellationToken, Task>? askAboutFill = null,
Func<TimeSpan>? fillWarnBefore = null)
{
_treeWatch = treeWatch;
_fillGate = fillGate;
_askAboutFill = askAboutFill;
_fillWarnBefore = fillWarnBefore ?? (() => TimeSpan.FromHours(24));
_repo = repo;
_dispatcher = dispatcher;
_market = market ?? new AlwaysOpenMarketCalendar();
@@ -293,7 +336,7 @@ public sealed class TaskScanner : IAsyncDisposable
if (_treeWatch is not null)
await _treeWatch.CheckAsync(now, ct);
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
var due = new List<(TaskItem Task, string Occurrence, RunKind Kind)>();
foreach (var task in candidates)
{
if (task.Status != TaskItemStatus.Todo)
@@ -346,24 +389,45 @@ public sealed class TaskScanner : IAsyncDisposable
continue;
var occ = TaskSchedule.DueOccurrence(task, now);
// #717: Fill work waits for room — until its deadline. Past it, the deadline was
// the promise, and it runs like any other due work.
if (task.IsFillWork && occ is not null)
{
await AskAboutFillIfDueAsync(task, now, ct);
if (now < task.FillBy!.Value)
{
due.Add((task, occ, RunKind.Fill));
continue;
}
if (_warnedOverdueFill.Add(task.Id))
_logger.LogWarning(
"Fill work {TaskId} ({Title}) reached its deadline {Deadline:yyyy-MM-dd HH:mm} UTC without "
+ "finding room — it now runs as regular work", task.Id, task.Title, task.FillBy);
}
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));
due.Add((task, occ, task.IsCarriedOver ? RunKind.Late : RunKind.Regular));
continue;
}
if (task.CatchUpPending && !deferred)
due.Add((task, Iso(now), Late: true));
due.Add((task, Iso(now), RunKind.Late));
}
// #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.
// everything regularly due, and fill work (#717) after those: late work was due,
// fill work never was. Within each group: priority, then the oldest occurrence —
// for fill work the nearest deadline first. 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;
if (a.Kind != b.Kind)
return a.Kind.CompareTo(b.Kind);
if (a.Kind == RunKind.Fill && Nullable.Compare(a.Task.FillBy, b.Task.FillBy) is var byDeadline and not 0)
return byDeadline;
var byPriority = b.Task.Priority.CompareTo(a.Task.Priority);
return byPriority != 0 ? byPriority : string.CompareOrdinal(a.Occurrence, b.Occurrence);
});
@@ -371,29 +435,57 @@ public sealed class TaskScanner : IAsyncDisposable
var launched = new List<Task>();
var waiting = 0;
var waitingForHeadroom = 0;
foreach (var (task, occ, late) in due)
var waitingFill = 0;
// #717: Agents with regular work that had to wait this tick — their fill work waits too.
var agentsWithWaitingWork = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (task, occ, kind) 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++;
if (kind != RunKind.Fill)
agentsWithWaitingWork.Add(TaskAssignee.AgentId(task.Assignee));
continue;
}
// #716: Late work only uses what the rest of the day does not need.
if (late && _lateRunGate is not null
if (kind == RunKind.Late && _lateRunGate is not null
&& !await _lateRunGate.HasRoomAsync(task, candidates, now, ct))
{
waitingForHeadroom++;
agentsWithWaitingWork.Add(TaskAssignee.AgentId(task.Assignee));
continue;
}
// #717: Fill work only when nothing of the agent's own waits, and only in room
// the day would otherwise leave unused. That is decided here, not by the model.
if (kind == RunKind.Fill)
{
if (agentsWithWaitingWork.Contains(TaskAssignee.AgentId(task.Assignee))
|| (_fillGate is not null && await _fillGate.CheckAsync(task, candidates, now, ct) != FillVerdict.Run))
{
waitingFill++;
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)));
}
if (waitingFill != _lastWaitingFill)
{
if (waitingFill > 0)
_logger.LogInformation(
"{Waiting} fill task(s) waiting for unused budget — they run when the rest of the day leaves room",
waitingFill);
_lastWaitingFill = waitingFill;
}
// 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)
@@ -420,6 +512,34 @@ public sealed class TaskScanner : IAsyncDisposable
return launched;
}
/// <summary>
/// #717: Fill work that has not found room by <c>deadline - warnBefore</c> is taken to a
/// human once: raise the budget or move the deadline. Still waiting at that point means
/// it has not run — a run that took place would have finished it.
/// </summary>
private async Task AskAboutFillIfDueAsync(TaskItem task, DateTime now, CancellationToken ct)
{
if (_askAboutFill is null || task.FillAlertedAt is not null || task.FillBy is not { } deadline
|| now < deadline - _fillWarnBefore())
return;
// Marked first: asking twice is worse than asking once and failing to log it.
if (!await _repo.MarkFillAlertedAsync(task.Id, now, ct))
return;
try
{
await _askAboutFill(task, ct);
_logger.LogWarning(
"Fill work {TaskId} ({Title}) has found no room and is due by {Deadline:yyyy-MM-dd HH:mm} UTC — "
+ "a human was asked to raise the budget or move the deadline", task.Id, task.Title, deadline);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Could not ask a human about fill work {TaskId}", task.Id);
}
}
/// <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.
@@ -698,6 +818,14 @@ public sealed class TaskScanner : IAsyncDisposable
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
/// <summary>The order in which due work is started: what is due, what is late, what fills.</summary>
private enum RunKind
{
Regular,
Late,
Fill
}
/// <summary>
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.