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
@@ -50,6 +50,11 @@ public sealed class TaskboardTool : IAgentTool
The time zone 'tz' is required (e.g. 'Europe/Berlin').
If 'when' is missing, the task is one-time and due immediately.
Fill work: large work that is not urgent gets 'fill_by' (at the latest by) instead of
a schedule. It runs when the day's budget has room that would otherwise go unused,
and before the deadline at the latest. Give 'estimate_usd' if you can judge the cost
of one run — it is shown as your estimate until a run has been measured.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -116,6 +121,14 @@ public sealed class TaskboardTool : IAgentTool
"type": "string",
"description": "ID of the task this one serves (its purpose — unlike blocked_by, which is a precondition). Empty string removes it (task_update)."
},
"fill_by": {
"type": "string",
"description": "Fill work: at the latest by (ISO timestamp, or a date for the end of that day). Only for one-time tasks without 'when'. Empty string removes it (task_update)."
},
"estimate_usd": {
"type": "number",
"description": "Your estimate of what one run of the task costs, in USD. Shown as an estimate until runs have been measured."
},
"blocked_by": {
"type": "array",
"items": { "type": "string" },
@@ -312,6 +325,9 @@ public sealed class TaskboardTool : IAgentTool
if (parent.Length > 0 && await CheckParentAsync(null, parent, board, visible, ct) is { } parentProblem)
return ToolResult.Fail(parentProblem);
if (!TryReadFill(input, when, null, out var fillBy, out var estimate, out var fillError))
return ToolResult.Fail(fillError!);
// #674: The same finding a second time is not a second task. It is recorded on the
// existing one — so that later one can see how often and for how long.
if (GetBool(input, "force") != true)
@@ -356,6 +372,8 @@ public sealed class TaskboardTool : IAgentTool
// #684: decides the board when the task is for a human or a fresh run.
CreatedBy = author,
ParentId = parent,
FillBy = fillBy,
EstimateUsd = estimate,
// #697: Where it came from — the engine's knowledge, never an argument of the agent.
OriginRunId = context.RunId ?? "",
OriginTaskId = context.RunTaskId ?? ""
@@ -420,6 +438,9 @@ public sealed class TaskboardTool : IAgentTool
if (parent is { Length: > 0 } && await CheckParentAsync(id.Trim(), parent, board, visible, ct) is { } parentProblem)
return ToolResult.Fail(parentProblem);
if (!TryReadFill(input, hasWhen ? when : existing?.When, existing, out var fillBy, out var estimate, out var fillError))
return ToolResult.Fail(fillError!);
var result = await board.UpdateAsync(id.Trim(), current => current with
{
Title = GetString(input, "title")?.Trim() ?? current.Title,
@@ -432,7 +453,9 @@ public sealed class TaskboardTool : IAgentTool
Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy,
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen,
ParentId = parent ?? current.ParentId
ParentId = parent ?? current.ParentId,
FillBy = fillBy,
EstimateUsd = estimate
}, ct);
if (result is null)
@@ -516,6 +539,11 @@ public sealed class TaskboardTool : IAgentTool
block.AppendLine($" Arose from: {origin}");
if (task.BlockedBy.Count > 0)
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
// #717: Fill work waits for room, not for a time — say so, or it looks forgotten.
if (task.IsFillWork)
block.AppendLine($" Fill work: runs when the budget has room, at the latest by {task.FillBy:yyyy-MM-dd HH:mm} UTC");
if (task.EstimateUsd is { } usd)
block.AppendLine($" Author's estimate: {usd.ToString("0.00##", System.Globalization.CultureInfo.InvariantCulture)} USD per run (not measured)");
if (sb.Length + block.Length > MaxResultChars)
{
@@ -535,6 +563,60 @@ public sealed class TaskboardTool : IAgentTool
// ─── Eingabe lesen ───
/// <summary>
/// #717: 'fill_by' and 'estimate_usd'. Fill work is one-time and lies ahead; a schedule and a
/// deadline contradict each other. Missing fields keep what <paramref name="existing"/> has.
/// </summary>
private static bool TryReadFill(
JsonElement input, TaskWhen? when, TaskItem? existing,
out DateTime? fillBy, out decimal? estimate, out string? error)
{
fillBy = existing?.FillBy;
estimate = existing?.EstimateUsd;
error = null;
if (input.TryGetProperty("fill_by", out var f))
{
var text = f.ValueKind == JsonValueKind.String ? f.GetString()?.Trim() : null;
if (string.IsNullOrEmpty(text))
fillBy = null;
else if (TaskFrontmatter.ParseUtc(text) is { } parsed)
fillBy = parsed;
else
{
error = $"'fill_by' is not a point in time: '{text}'. Use an ISO timestamp or a date (yyyy-MM-dd).";
return false;
}
if (fillBy is { } deadline && deadline <= DateTime.UtcNow)
{
error = $"'fill_by' lies in the past ({deadline:yyyy-MM-dd HH:mm} UTC). Fill work needs a deadline ahead.";
return false;
}
}
if (fillBy is not null && when is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every })
{
error = "Fill work is one-time: a task with 'fill_by' has no recurring schedule ('when').";
return false;
}
if (input.TryGetProperty("estimate_usd", out var e))
{
if (e.ValueKind == JsonValueKind.Null)
estimate = null;
else if (e.ValueKind == JsonValueKind.Number && e.TryGetDecimal(out var usd) && usd >= 0)
estimate = usd;
else
{
error = "'estimate_usd' must be a non-negative number (USD per run).";
return false;
}
}
return true;
}
private static bool TryReadWhen(JsonElement input, out TaskWhen? when, out string? error)
{
when = null;