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
+17 -2
View File
@@ -760,8 +760,14 @@ public sealed class AppHost : IAsyncDisposable
loggerFactory.CreateLogger("ClawdDotNet.Core.Cases"));
engine.CaseDesk = caseDesk;
// #717: A run of a particular task is estimated from its own history first.
var estimator = engine.BudgetGuard is { } estimateGuard
? new TaskCostEstimator(audit, taskRepository, estimateGuard)
: null;
var headroom = engine.BudgetGuard is { } guard
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget,
estimator: estimator, fillSettings: () => instance.FillWork)
: null;
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
@@ -794,7 +800,16 @@ public sealed class AppHost : IAsyncDisposable
tools.Get,
loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Reports")),
// #689: A case nobody solves in time goes to a human.
caseDesk));
caseDesk),
// #717: Fill work runs on budget the day leaves unused, and never overruns its
// deadline silently — a human is asked in time.
fillGate: headroom,
askAboutFill: async (task, ct) =>
{
var estimate = estimator is null ? TaskEstimate.Nothing : await estimator.EstimateAsync(task, ct);
await board.CreateAsync(FillWorkNotice.QuestionFor(task, estimate), ct);
},
fillWarnBefore: () => instance.FillWork.WarnBeforeDeadline);
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
@@ -21,7 +21,10 @@ public sealed record TaskInput(
string? TimeZone = null,
string Acceptance = "",
int Priority = 3,
string ParentId = "");
string ParentId = "",
// #717: fill work — at the latest by (ISO or yyyy-MM-dd), and the author's estimate per run.
string? FillBy = null,
decimal? EstimateUsd = null);
/// <summary>
/// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren,
@@ -88,6 +91,8 @@ public sealed class TaskAdministration(
return AdminResult.Failure(fehler);
if (await CheckParentAsync(null, input.ParentId, ct) is { } parentProblem)
return AdminResult.Failure(parentProblem);
if (CheckFill(input, when, out var fillBy) is { } fillProblem)
return AdminResult.Failure(fillProblem);
var created = await board.CreateAsync(new TaskItem
{
@@ -99,7 +104,9 @@ public sealed class TaskAdministration(
When = when,
Status = TaskItemStatus.Todo,
Type = TaskItemType.Work,
ParentId = input.ParentId.Trim()
ParentId = input.ParentId.Trim(),
FillBy = fillBy,
EstimateUsd = input.EstimateUsd
}, ct);
return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created));
@@ -114,6 +121,8 @@ public sealed class TaskAdministration(
return AdminResult.Failure(fehler);
if (await CheckParentAsync(id, input.ParentId, ct) is { } parentProblem)
return AdminResult.Failure(parentProblem);
if (CheckFill(input, when, out var fillBy) is { } fillProblem)
return AdminResult.Failure(fillProblem);
var updated = await board.UpdateAsync(id, current => current with
{
@@ -123,7 +132,9 @@ public sealed class TaskAdministration(
Priority = Math.Clamp(input.Priority, 1, 5),
Assignee = assignee,
When = when,
ParentId = input.ParentId.Trim()
ParentId = input.ParentId.Trim(),
FillBy = fillBy,
EstimateUsd = input.EstimateUsd
}, ct);
return updated is null
@@ -131,6 +142,27 @@ public sealed class TaskAdministration(
: AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated));
}
/// <summary>
/// #717: Fill work is one-time, and its deadline lies ahead — a recurring schedule and a
/// deadline contradict each other.
/// </summary>
private static string? CheckFill(TaskInput input, TaskWhen? when, out DateTime? fillBy)
{
fillBy = null;
if (input.EstimateUsd is < 0)
return "The estimate cannot be negative.";
if (string.IsNullOrWhiteSpace(input.FillBy))
return null;
if (TaskFrontmatter.ParseUtc(input.FillBy) is not { } parsed)
return $"'{input.FillBy}' is not a point in time. Use a date (yyyy-MM-dd) or an ISO timestamp.";
if (when is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every })
return "Fill work is one-time: a deadline and a recurring schedule do not go together.";
if (parsed <= DateTime.UtcNow)
return "The deadline of fill work has to lie ahead.";
fillBy = parsed;
return null;
}
/// <summary>#714: The parent must exist and must not lie below the task itself.</summary>
private async Task<string?> CheckParentAsync(string? taskId, string parentId, CancellationToken ct)
{