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
+54 -11
View File
@@ -22,7 +22,7 @@ namespace ClawdDotNet.Core.Budget;
/// then only the margin keeps a reserve. That errs towards letting late work through;
/// the budget check at the start of the run still refuses once the limit is reached.</para>
/// </summary>
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook, IFillGate
{
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
public const decimal SafetyShare = 0.10m;
@@ -31,20 +31,55 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
private readonly Func<InstanceBudget> _instanceBudget;
private readonly TimeProvider _clock;
private readonly TaskCostEstimator? _estimator;
private readonly Func<FillWorkSettings> _fillSettings;
public BudgetHeadroom(
BudgetGuard guard,
Func<IReadOnlyList<AgentConfig>> agents,
Func<InstanceBudget> instanceBudget,
TimeProvider? clock = null)
TimeProvider? clock = null,
TaskCostEstimator? estimator = null,
Func<FillWorkSettings>? fillSettings = null)
{
_guard = guard;
_agents = agents;
_instanceBudget = instanceBudget;
_clock = clock ?? TimeProvider.System;
_estimator = estimator;
_fillSettings = fillSettings ?? (() => new FillWorkSettings());
}
public async Task<bool> HasRoomAsync(
public Task<bool> HasRoomAsync(
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> HasRoomCoreAsync(lateTask, runnable, nowUtc, ct);
/// <summary>
/// #717: Fill work runs in the same headroom as late work — after it, since late work was
/// due and fill work was not. Two things are added: it prefers the evening, when the
/// forecast for the rest of the day is nearly a count, and it is measured with its own
/// estimate, not the agent's average run — fill work is by definition the large kind.
/// </summary>
public async Task<FillVerdict> CheckAsync(
TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
var agent = FindAgent(agents, fillTask);
// Without a limit there is no unused budget to wait for — the work just runs.
if (agent is null || (!agent.Budget.HasAnyLimit && !_instanceBudget().HasAnyLimit))
return FillVerdict.Run;
var settings = _fillSettings();
var nearDeadline = fillTask.FillBy is { } deadline && deadline - nowUtc <= settings.WarnBeforeDeadline;
var localHour = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), _clock.LocalTimeZone).Hour;
if (!nearDeadline && localHour < Math.Clamp(settings.FromHour, 0, 24))
return FillVerdict.NotYet;
return await HasRoomCoreAsync(fillTask, runnable, nowUtc, ct) ? FillVerdict.Run : FillVerdict.NoRoom;
}
private async Task<bool> HasRoomCoreAsync(
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
@@ -66,17 +101,25 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
return estimate;
}
var ownEstimate = await EstimateFor(agent.AgentId);
var agentEstimate = await EstimateFor(agent.AgentId);
// Regular work of the rest of the day: everything scheduled except late work
// itself — that is what has to wait for headroom, not what headroom is kept for.
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t)).ToList();
// #717: The task's own estimate where there is one — measured, from its siblings, or
// stated — instead of the agent's average run.
var ownEstimate = _estimator is null ? agentEstimate : (await _estimator.EstimateAsync(lateTask, ct)) switch
{
{ Source: EstimateSource.None } => agentEstimate,
var estimate => estimate.Run
};
// Regular work of the rest of the day: everything scheduled except late work and
// fill work — that is what has to wait for headroom, not what headroom is kept for.
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t) && !t.IsFillWork).ToList();
if (agent.Budget.HasAnyLimit)
{
var demand = default(RunEstimate);
foreach (var task in regular.Where(t => IsOf(t, agent.AgentId)))
demand += Times(ownEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
demand += Times(agentEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
var reserved = _guard.Reserved(agent.AgentId);
@@ -111,8 +154,8 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
/// <summary>
/// #716: Walks through the rest of today's schedule in time order and finds, per agent
/// with a limit and for the instance, the first run the budget would no longer cover.
/// Late work is left out: it only ever runs in headroom, so it cannot be what makes the
/// day's own runs fail.
/// Late work and fill work (#717) are left out: they only ever run in headroom, so they
/// cannot be what makes the day's own runs fail.
/// </summary>
public async Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(
IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
@@ -124,7 +167,7 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
// Every planned run until midnight, with whose it is.
var planned = new List<(DateTime At, string AgentId)>();
foreach (var task in runnable.Where(t => !IsLate(t)))
foreach (var task in runnable.Where(t => !IsLate(t) && !t.IsFillWork))
{
var agent = FindAgent(agents, task);
if (agent is null)
@@ -0,0 +1,53 @@
using System.Globalization;
using System.Text;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: The question to a human about fill work that has found no room before its deadline.
/// Only a human can answer it — raise the budget or move the deadline — so it goes on the
/// board as a human's task, below the fill work it is about.
/// </summary>
public static class FillWorkNotice
{
public static TaskItem QuestionFor(TaskItem fill, TaskEstimate estimate)
{
var body = new StringBuilder();
body.AppendLine($"Fill work {fill.Id} (\"{fill.Title}\") is due by {fill.FillBy:yyyy-MM-dd HH:mm} UTC " +
"and has not found room in the budget so far.");
body.AppendLine();
body.AppendLine($"Assigned to: {fill.Assignee}");
body.AppendLine($"Expected cost per run: {Describe(estimate)}");
body.AppendLine();
body.AppendLine("Fill work only runs on budget the rest of the day leaves unused. Please decide:");
body.AppendLine("- raise the agent's daily budget, or");
body.AppendLine($"- move the deadline (fill_by in {fill.Id}), or");
body.AppendLine("- leave it: at the deadline it runs as regular work, ahead of other late work.");
return new TaskItem
{
Title = $"Fill work {fill.Id} finds no room before its deadline",
Body = body.ToString().TrimEnd(),
Assignee = TaskAssignee.Human,
Type = TaskItemType.HumanInput,
Priority = Math.Clamp(fill.Priority + 1, 1, 5),
Status = TaskItemStatus.Todo,
ParentId = fill.Id
};
}
/// <summary>"0.80 USD (measured over 3 runs)" — and a guess says it is one.</summary>
public static string Describe(TaskEstimate estimate)
{
var usd = estimate.Run.CostUsd.ToString("0.00##", CultureInfo.InvariantCulture) + " USD";
return estimate.Source switch
{
EstimateSource.Measured => $"{usd} (measured over {estimate.Samples} run(s))",
EstimateSource.Siblings => $"{usd} (measured on {estimate.Samples} run(s) of its sibling tasks)",
EstimateSource.Stated => $"{usd} (estimated by its author, not measured)",
EstimateSource.AgentAverage => $"{usd} (the agent's average run, not measured on this task)",
_ => "unknown - nothing measured and no estimate given"
};
}
}
@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: Fill work and large tasks, instance-wide. Budgets stay at the agent (concept 8a);
/// these only say when unused budget is filled and when a task counts as large.
/// </summary>
public sealed class FillWorkSettings
{
/// <summary>
/// Local hour from which fill work may start. Late in the day the forecast for the rest of
/// it is least uncertain — at 9 in the morning "there is room" is a guess, at 8 in the
/// evening it is nearly a count. 0 lets fill work start any time there is room.
/// </summary>
[JsonPropertyName("fromHour")]
public int FromHour { get; set; } = 18;
/// <summary>
/// How many hours before its deadline fill work that found no room is taken to a human —
/// raise the budget or move the deadline. Fill work never overruns its deadline silently.
/// Within this window it also no longer waits for the evening.
/// </summary>
[JsonPropertyName("warnHoursBeforeDeadline")]
public int WarnHoursBeforeDeadline { get; set; } = 24;
/// <summary>
/// Share of the agent's daily cost limit above which a task counts as large: it is not
/// simply started, the agent proposes to split it or asks for a special budget.
/// </summary>
[JsonPropertyName("largeTaskShare")]
public decimal LargeTaskShare { get; set; } = 0.5m;
[JsonIgnore]
public TimeSpan WarnBeforeDeadline => TimeSpan.FromHours(Math.Max(0, WarnHoursBeforeDeadline));
}
@@ -0,0 +1,116 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>#717: Where an estimate came from — from the most to the least reliable.</summary>
public enum EstimateSource
{
/// <summary>The task's own runs, measured.</summary>
Measured,
/// <summary>Runs of its siblings — the other parts of the same split task.</summary>
Siblings,
/// <summary>What the task's author wrote down. A guess, and shown as one.</summary>
Stated,
/// <summary>The average run of the agent — says nothing about this task in particular.</summary>
AgentAverage,
/// <summary>Nothing to go on.</summary>
None
}
/// <summary>#717: What one run of a task is expected to cost, and how much that is worth.</summary>
public sealed record TaskEstimate(RunEstimate Run, EstimateSource Source, int Samples)
{
/// <summary>Stated or averaged over everything the agent does — not measured on this work.</summary>
public bool IsGuess => Source is EstimateSource.Stated or EstimateSource.AgentAverage or EstimateSource.None;
public static TaskEstimate Nothing { get; } = new(default, EstimateSource.None, 0);
}
/// <summary>
/// #717: Estimates a run of one particular task — honestly.
///
/// <para>Costs cannot be known in advance, and models judge their own effort badly. So the
/// code estimates from history where there is any, and says when it has none:</para>
/// <list type="number">
/// <item>the task's own runs (a fill task that already ran part of the way, or ran before),</item>
/// <item>its siblings — the parts of the same split task, measured as soon as the first one ran,</item>
/// <item>the author's estimate (<see cref="TaskItem.EstimateUsd"/>), marked as a guess,</item>
/// <item>the agent's average run, also a guess.</item>
/// </list>
/// <para>After the first run the measured value replaces the guess — nothing has to be
/// updated for that, the next estimate simply finds the run.</para>
/// </summary>
public sealed class TaskCostEstimator
{
/// <summary>How many runs are averaged — recent enough to follow a change, enough to smooth one outlier.</summary>
public const int Sample = 10;
private readonly IAuditRepository? _audit;
private readonly ITaskRepository _tasks;
private readonly BudgetGuard _guard;
public TaskCostEstimator(IAuditRepository? audit, ITaskRepository tasks, BudgetGuard guard)
{
_audit = audit;
_tasks = tasks;
_guard = guard;
}
public async Task<TaskEstimate> EstimateAsync(TaskItem task, CancellationToken ct)
{
if (_audit is not null)
{
var own = Measured(await _audit.ListReceiptsForTaskAsync(task.Id, ct));
if (own.Count > 0)
return Average(own, EstimateSource.Measured);
if (!string.IsNullOrWhiteSpace(task.ParentId))
{
var siblingRuns = new List<RunReceipt>();
foreach (var sibling in await _tasks.ListChildrenAsync(task.ParentId, ct))
{
if (sibling.Id == task.Id)
continue;
siblingRuns.AddRange(Measured(await _audit.ListReceiptsForTaskAsync(sibling.Id, ct)));
}
if (siblingRuns.Count > 0)
return Average(siblingRuns.OrderByDescending(r => r.OccurredAt).Take(Sample).ToList(), EstimateSource.Siblings);
}
}
var agentId = TaskAssignee.AgentId(task.Assignee);
var agentAverage = agentId.Length == 0 ? default : await _guard.EstimateRunAsync(agentId, ct);
if (task.EstimateUsd is { } stated)
{
// Tokens follow the agent's own price per token, where it has one — a token limit
// needs a token figure, and the author only wrote down money.
var tokens = agentAverage.CostUsd > 0
? (long)Math.Ceiling(stated / agentAverage.CostUsd * agentAverage.Tokens)
: 0;
return new TaskEstimate(new RunEstimate(stated, tokens), EstimateSource.Stated, 0);
}
return agentAverage == default
? TaskEstimate.Nothing
: new TaskEstimate(agentAverage, EstimateSource.AgentAverage, 0);
}
/// <summary>Runs that used something — a refusal or an abort before the first call says nothing about cost.</summary>
private static List<RunReceipt> Measured(IReadOnlyList<RunReceipt> receipts)
=> receipts.Where(r => r.PromptTokens + r.CompletionTokens > 0)
.OrderByDescending(r => r.OccurredAt)
.Take(Sample)
.ToList();
private static TaskEstimate Average(IReadOnlyList<RunReceipt> runs, EstimateSource source)
=> new(new RunEstimate(
runs.Average(r => r.CostUsd),
(long)Math.Ceiling(runs.Average(r => (double)(r.PromptTokens + r.CompletionTokens)))),
source, runs.Count);
}